pull/17/head
parent
f7778538dd
commit
64eae32259
|
|
@ -0,0 +1,135 @@
|
|||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from assessments.views import Assessment
|
||||
from db_setup import get_session
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AssessmentCreate(BaseModel):
|
||||
assessment_type: str
|
||||
inbox_id: int | None = None
|
||||
manual_upload_candidate_id: str | None = None
|
||||
job_post_id: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
due_at: datetime | None = None
|
||||
|
||||
|
||||
class AssessmentUpdate(BaseModel):
|
||||
assessment_status: str | None = None
|
||||
score: int | None = None
|
||||
section_scores: list | None = None
|
||||
due_at: datetime | None = None
|
||||
|
||||
|
||||
@router.get("/assessments/fetch")
|
||||
async def fetch_assessments(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.ASSESSMENTS_VIEW)),
|
||||
assessment_id: str | None = Query(None),
|
||||
inbox_id: int | None = Query(None),
|
||||
manual_upload_candidate_id: str | None = Query(None),
|
||||
job_post_id: str | None = Query(None),
|
||||
assessment_status: str | None = Query(None),
|
||||
top: int | None = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Assessment(session=session)
|
||||
data, total = await service.get_assessments(
|
||||
assessment_id, inbox_id, manual_upload_candidate_id, job_post_id,
|
||||
assessment_status, top, skip,
|
||||
)
|
||||
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/assessments/counts")
|
||||
async def fetch_assessment_counts(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.ASSESSMENTS_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Assessment(session=session)
|
||||
data = await service.get_counts()
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/assessments/create")
|
||||
async def create_assessment(
|
||||
payload: AssessmentCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.ASSESSMENTS_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Assessment(session=session)
|
||||
data = await service.create_assessment(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/assessments/update")
|
||||
async def update_assessment(
|
||||
payload: AssessmentUpdate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.ASSESSMENTS_EDIT)),
|
||||
assessment_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Assessment(session=session)
|
||||
data = await service.update_assessment(
|
||||
assessment_id, payload.model_dump(exclude_unset=True), current_user
|
||||
)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/assessments/delete")
|
||||
async def delete_assessment(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.ASSESSMENTS_DELETE)),
|
||||
assessment_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Assessment(session=session)
|
||||
data = await service.delete_assessment(assessment_id, current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/assessments/remind")
|
||||
async def remind_assessment(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.ASSESSMENTS_EDIT)),
|
||||
assessment_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = Assessment(session=session)
|
||||
data = await service.remind_assessment(assessment_id, current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, JSON, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Assessments(SQLModel, table=True):
|
||||
__tablename__ = "assessments"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
|
||||
manual_upload_candidate_id: uuid.UUID | None = Field(
|
||||
default=None, index=True, foreign_key="manual_upload_candidate.id"
|
||||
)
|
||||
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
|
||||
assessment_type: str
|
||||
assessment_status: str = Field(default="pending")
|
||||
score: int | None = Field(default=None)
|
||||
section_scores: list | None = Field(default=None, sa_type=JSON)
|
||||
duration_minutes: int | None = Field(default=None)
|
||||
assigned_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
due_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
completed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
reminded_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
created_by: uuid.UUID = Field(foreign_key="users.id")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_assessment_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def fetch_assessments(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
assessment_id=None,
|
||||
inbox_id=None,
|
||||
manual_upload_candidate_id=None,
|
||||
job_post_id=None,
|
||||
assessment_status=None,
|
||||
top: int | None = None,
|
||||
skip: int = 0,
|
||||
):
|
||||
if assessment_id:
|
||||
row = await cls.get_assessment_by_id(session, assessment_id)
|
||||
if row is None:
|
||||
return [], 0
|
||||
return [row], 1
|
||||
|
||||
statement = select(cls).where(cls.is_deleted == False) # noqa: E712
|
||||
if inbox_id is not None:
|
||||
statement = statement.where(cls.inbox_id == int(inbox_id))
|
||||
if manual_upload_candidate_id is not None:
|
||||
uid = cls._as_uuid(manual_upload_candidate_id)
|
||||
if uid is None:
|
||||
return [], 0
|
||||
statement = statement.where(cls.manual_upload_candidate_id == uid)
|
||||
if job_post_id is not None:
|
||||
uid = cls._as_uuid(job_post_id)
|
||||
if uid is None:
|
||||
return [], 0
|
||||
statement = statement.where(cls.job_post_id == uid)
|
||||
if assessment_status:
|
||||
statement = statement.where(cls.assessment_status == assessment_status)
|
||||
count_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = (await session.execute(count_statement)).scalar_one()
|
||||
statement = statement.order_by(cls.created_at.desc())
|
||||
if skip:
|
||||
statement = statement.offset(skip)
|
||||
if top is not None:
|
||||
statement = statement.limit(top)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
@classmethod
|
||||
async def count_by_status(cls, session: AsyncSession):
|
||||
statement = (
|
||||
select(cls.assessment_status, func.count())
|
||||
.where(cls.is_deleted == False) # noqa: E712
|
||||
.group_by(cls.assessment_status)
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
counts = {}
|
||||
for status, n in result.all():
|
||||
counts[status] = int(n or 0)
|
||||
return counts
|
||||
|
||||
@classmethod
|
||||
async def insert_assessment(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_assessment_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_assessment(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_assessment_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_assessment(cls, session: AsyncSession, record_id):
|
||||
row = await cls.get_assessment_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
def serialize_assessment(row, *, candidate_name=None, job_title=None) -> dict:
|
||||
"""`candidate_name` / `job_title` come from one batched lookup in views —
|
||||
never a lazy per-row load. The Assessments table's first two columns are an
|
||||
avatar + name and cannot render off foreign keys alone."""
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"inbox_id": row.inbox_id,
|
||||
"manual_upload_candidate_id": (
|
||||
str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None
|
||||
),
|
||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||
"candidate_name": candidate_name,
|
||||
"job_title": job_title,
|
||||
"assessment_type": row.assessment_type,
|
||||
"assessment_status": row.assessment_status,
|
||||
"score": row.score,
|
||||
"section_scores": list(row.section_scores or []),
|
||||
"duration_minutes": row.duration_minutes,
|
||||
"assigned_at": row.assigned_at.isoformat() if row.assigned_at else None,
|
||||
"due_at": row.due_at.isoformat() if row.due_at else None,
|
||||
"completed_at": row.completed_at.isoformat() if row.completed_at else None,
|
||||
"reminded_at": row.reminded_at.isoformat() if row.reminded_at else None,
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
import logging
|
||||
import uuid
|
||||
from datetime import timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from assessments.models import Assessments, _now
|
||||
from assessments.serializers import serialize_assessment
|
||||
from inbox.models import Inbox
|
||||
from inbox.plugins import send_mail
|
||||
from job.candidate.models import Manual_UPLOAD_CANDIDATE
|
||||
from job.job_post.models import JobPosts
|
||||
from notifications.models import Notifications
|
||||
from users.models import Users
|
||||
|
||||
logger = logging.getLogger("assessments")
|
||||
|
||||
VALID_TYPES = (
|
||||
"Coding Challenge",
|
||||
"Take-home Project",
|
||||
"Cognitive Test",
|
||||
"Personality Assessment",
|
||||
"SQL Test",
|
||||
"Case Study",
|
||||
)
|
||||
VALID_STATUS = ("pending", "in_progress", "completed", "expired")
|
||||
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = _as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user id")
|
||||
return uid
|
||||
|
||||
|
||||
def _aware(value):
|
||||
if value is not None and getattr(value, "tzinfo", None) is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
class Assessment:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def _emit(self, current_user, kind, title, body, *, inbox_id=None, job_post_id=None, link_path=None):
|
||||
uid = _as_uuid(current_user.get("id") if current_user else None)
|
||||
if uid is None:
|
||||
return
|
||||
try:
|
||||
await Notifications.insert_notification(self.session, {
|
||||
"user_id": uid,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"link_path": link_path,
|
||||
"inbox_id": inbox_id,
|
||||
"job_post_id": job_post_id,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s", exc)
|
||||
|
||||
async def _context_maps(self, rows):
|
||||
inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None]
|
||||
manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id]
|
||||
job_ids = [r.job_post_id for r in rows if r.job_post_id]
|
||||
|
||||
inbox_by_id = {}
|
||||
if inbox_ids:
|
||||
result = await self.session.execute(
|
||||
select(Inbox)
|
||||
.options(selectinload(Inbox.messages), selectinload(Inbox.user))
|
||||
.where(Inbox.id.in_(inbox_ids))
|
||||
)
|
||||
inbox_by_id = {row.id: row for row in result.scalars().all()}
|
||||
for row in inbox_by_id.values():
|
||||
msg = row.messages
|
||||
if msg is not None and msg.assigned_job_post_id:
|
||||
job_ids.append(msg.assigned_job_post_id)
|
||||
|
||||
manual_by_id = {}
|
||||
if manual_ids:
|
||||
result = await self.session.execute(
|
||||
select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids))
|
||||
)
|
||||
manual_by_id = {row.id: row for row in result.scalars().all()}
|
||||
for row in manual_by_id.values():
|
||||
if row.job_post_id:
|
||||
job_ids.append(row.job_post_id)
|
||||
|
||||
jobs_by_id = {}
|
||||
uids = [j for j in set(job_ids) if j]
|
||||
if uids:
|
||||
result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids)))
|
||||
jobs_by_id = {row.id: row for row in result.scalars().all()}
|
||||
return inbox_by_id, manual_by_id, jobs_by_id
|
||||
|
||||
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
|
||||
candidate_name = None
|
||||
job_title = None
|
||||
if row.job_post_id and row.job_post_id in jobs_by_id:
|
||||
job_title = jobs_by_id[row.job_post_id].title
|
||||
if row.inbox_id is not None:
|
||||
link = inbox_by_id.get(row.inbox_id)
|
||||
if link is not None:
|
||||
if link.user is not None:
|
||||
candidate_name = link.user.name
|
||||
msg = link.messages
|
||||
if job_title is None and msg is not None and msg.assigned_job_post_id:
|
||||
job = jobs_by_id.get(msg.assigned_job_post_id)
|
||||
if job is not None:
|
||||
job_title = job.title
|
||||
if row.manual_upload_candidate_id:
|
||||
manual = manual_by_id.get(row.manual_upload_candidate_id)
|
||||
if manual is not None:
|
||||
candidate_name = candidate_name or manual.candidate_name or None
|
||||
if job_title is None and manual.job_post_id:
|
||||
job = jobs_by_id.get(manual.job_post_id)
|
||||
if job is not None:
|
||||
job_title = job.title
|
||||
return candidate_name, job_title
|
||||
|
||||
async def _serialize_rows(self, rows):
|
||||
inbox_by_id, manual_by_id, jobs_by_id = await self._context_maps(rows)
|
||||
out = []
|
||||
for row in rows:
|
||||
name, title = self._labels(row, inbox_by_id, manual_by_id, jobs_by_id)
|
||||
out.append(serialize_assessment(row, candidate_name=name, job_title=title))
|
||||
return out
|
||||
|
||||
async def _recipient(self, row):
|
||||
if row.inbox_id is not None:
|
||||
link = await Inbox.get_inbox_with_message(self.session, row.inbox_id)
|
||||
if link is None:
|
||||
return None, None, None
|
||||
user = link.user
|
||||
if user is None and link.user_id:
|
||||
user = await Users.get_user_by_id(self.session, str(link.user_id))
|
||||
email = user.email if user else None
|
||||
name = user.name if user else None
|
||||
return email, name, link.id
|
||||
if row.manual_upload_candidate_id:
|
||||
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(
|
||||
self.session, row.manual_upload_candidate_id
|
||||
)
|
||||
if manual is None:
|
||||
return None, None, None
|
||||
return (manual.candidate_email or None), (manual.candidate_name or None), None
|
||||
return None, None, None
|
||||
|
||||
async def get_assessments(
|
||||
self,
|
||||
assessment_id=None,
|
||||
inbox_id=None,
|
||||
manual_upload_candidate_id=None,
|
||||
job_post_id=None,
|
||||
assessment_status=None,
|
||||
top=None,
|
||||
skip=0,
|
||||
):
|
||||
if assessment_status and assessment_status not in VALID_STATUS:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"assessment_status must be one of {', '.join(VALID_STATUS)}"
|
||||
)
|
||||
rows, total = await Assessments.fetch_assessments(
|
||||
self.session,
|
||||
assessment_id=assessment_id,
|
||||
inbox_id=inbox_id,
|
||||
manual_upload_candidate_id=manual_upload_candidate_id,
|
||||
job_post_id=job_post_id,
|
||||
assessment_status=assessment_status,
|
||||
top=top,
|
||||
skip=skip or 0,
|
||||
)
|
||||
return await self._serialize_rows(rows), total
|
||||
|
||||
async def get_counts(self):
|
||||
counts = await Assessments.count_by_status(self.session)
|
||||
return {status: int(counts.get(status, 0)) for status in VALID_STATUS}
|
||||
|
||||
async def create_assessment(self, payload, current_user):
|
||||
assessment_type = (payload.get("assessment_type") or "").strip()
|
||||
if assessment_type not in VALID_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"assessment_type must be one of {', '.join(VALID_TYPES)}"
|
||||
)
|
||||
inbox_id = payload.get("inbox_id")
|
||||
manual_id = _as_uuid(payload.get("manual_upload_candidate_id"))
|
||||
has_inbox = inbox_id is not None
|
||||
has_manual = manual_id is not None
|
||||
if has_inbox == has_manual:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Exactly one of inbox_id or manual_upload_candidate_id is required",
|
||||
)
|
||||
if has_inbox:
|
||||
try:
|
||||
inbox_id = int(inbox_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=422, detail="Invalid inbox_id")
|
||||
link = await Inbox.get_inbox_by_id(self.session, inbox_id)
|
||||
if link is None:
|
||||
raise HTTPException(status_code=404, detail="Inbox record not found")
|
||||
else:
|
||||
inbox_id = None
|
||||
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id)
|
||||
if manual is None:
|
||||
raise HTTPException(status_code=404, detail="Manual upload candidate not found")
|
||||
|
||||
job_post_id = _as_uuid(payload.get("job_post_id"))
|
||||
if payload.get("job_post_id") and job_post_id is None:
|
||||
raise HTTPException(status_code=422, detail="Invalid job_post_id")
|
||||
if job_post_id is not None:
|
||||
post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id))
|
||||
if not post or post.is_deleted:
|
||||
raise HTTPException(status_code=404, detail="Job post not found")
|
||||
|
||||
fields = {
|
||||
"inbox_id": inbox_id,
|
||||
"manual_upload_candidate_id": manual_id,
|
||||
"job_post_id": job_post_id,
|
||||
"assessment_type": assessment_type,
|
||||
"assessment_status": "pending",
|
||||
"duration_minutes": payload.get("duration_minutes"),
|
||||
"assigned_at": _now(),
|
||||
"created_by": _user_id(current_user),
|
||||
}
|
||||
if payload.get("due_at") is not None:
|
||||
fields["due_at"] = _aware(payload["due_at"])
|
||||
row = await Assessments.insert_assessment(self.session, fields)
|
||||
data = (await self._serialize_rows([row]))[0]
|
||||
await self._emit(
|
||||
current_user,
|
||||
"assessment",
|
||||
"Assessment assigned",
|
||||
f"{assessment_type} assigned to {data.get('candidate_name') or 'a candidate'}",
|
||||
inbox_id=inbox_id,
|
||||
job_post_id=job_post_id,
|
||||
link_path="/assessments",
|
||||
)
|
||||
return data
|
||||
|
||||
async def update_assessment(self, assessment_id, payload, current_user):
|
||||
_user_id(current_user)
|
||||
row = await Assessments.get_assessment_by_id(self.session, assessment_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Assessment not found")
|
||||
|
||||
fields = {}
|
||||
if "assessment_status" in payload:
|
||||
status = payload["assessment_status"]
|
||||
if status not in VALID_STATUS:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"assessment_status must be one of {', '.join(VALID_STATUS)}"
|
||||
)
|
||||
fields["assessment_status"] = status
|
||||
if status == "completed" and row.completed_at is None:
|
||||
fields["completed_at"] = _now()
|
||||
if "score" in payload:
|
||||
score = payload["score"]
|
||||
if score is not None and (not isinstance(score, int) or score < 0 or score > 100):
|
||||
raise HTTPException(status_code=422, detail="score must be an integer 0-100")
|
||||
fields["score"] = score
|
||||
if "section_scores" in payload:
|
||||
sections = payload["section_scores"]
|
||||
if sections is not None and not isinstance(sections, list):
|
||||
raise HTTPException(status_code=422, detail="section_scores must be a list")
|
||||
fields["section_scores"] = sections
|
||||
if "due_at" in payload:
|
||||
fields["due_at"] = _aware(payload["due_at"])
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
updated = await Assessments.update_assessment(self.session, assessment_id, fields)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Assessment not found")
|
||||
return (await self._serialize_rows([updated]))[0]
|
||||
|
||||
async def delete_assessment(self, assessment_id, current_user):
|
||||
_user_id(current_user)
|
||||
row = await Assessments.soft_delete_assessment(self.session, assessment_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Assessment not found")
|
||||
return {"id": str(row.id), "deleted": True}
|
||||
|
||||
async def remind_assessment(self, assessment_id, current_user):
|
||||
_user_id(current_user)
|
||||
row = await Assessments.get_assessment_by_id(self.session, assessment_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Assessment not found")
|
||||
email, name, inbox_id = await self._recipient(row)
|
||||
if not email:
|
||||
raise HTTPException(status_code=422, detail="Candidate has no email address")
|
||||
subject = f"Reminder: {row.assessment_type}"
|
||||
body = (
|
||||
f"<p>This is a reminder that your {row.assessment_type} assessment is pending"
|
||||
f"{' for ' + name if name else ''}.</p>"
|
||||
)
|
||||
try:
|
||||
await send_mail(email, subject, body, content_type="html")
|
||||
except (httpx.HTTPError, RuntimeError) as e:
|
||||
raise HTTPException(status_code=502, detail="Failed to send reminder email") from e
|
||||
|
||||
updated = await Assessments.update_assessment(
|
||||
self.session, assessment_id, {"reminded_at": _now()}
|
||||
)
|
||||
data = (await self._serialize_rows([updated]))[0]
|
||||
await self._emit(
|
||||
current_user,
|
||||
"assessment",
|
||||
"Assessment reminder sent",
|
||||
f"Reminder sent for {row.assessment_type}",
|
||||
inbox_id=inbox_id,
|
||||
job_post_id=row.job_post_id,
|
||||
link_path="/assessments",
|
||||
)
|
||||
return data
|
||||
|
|
@ -16,6 +16,27 @@ router = APIRouter()
|
|||
class AssignJobPostBody(BaseModel):
|
||||
job_post_id: str | None = None
|
||||
|
||||
|
||||
class ProcessingStateBody(BaseModel):
|
||||
processing_state: str
|
||||
|
||||
|
||||
class DuplicateBody(BaseModel):
|
||||
is_duplicate: bool
|
||||
|
||||
|
||||
class EmailSendBody(BaseModel):
|
||||
to: str
|
||||
subject: str
|
||||
body: str
|
||||
content_type: str | None = "html"
|
||||
inbox_id: int | None = None
|
||||
|
||||
|
||||
class EmailReplyBody(BaseModel):
|
||||
record_id: str
|
||||
body: str
|
||||
|
||||
@router.get("/email/fetch")
|
||||
async def fetch_email(
|
||||
top:int=Query(100),
|
||||
|
|
@ -176,3 +197,84 @@ async def get_all_applications(
|
|||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/inbox/counts")
|
||||
async def get_inbox_counts(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Email(session=session)
|
||||
data=await service.get_counts()
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/inbox/{record_id}/processing-state")
|
||||
async def set_processing_state(
|
||||
record_id: str,
|
||||
payload: ProcessingStateBody,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Email(session=session)
|
||||
data=await service.set_processing_state(record_id,payload.processing_state)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/inbox/{record_id}/duplicate")
|
||||
async def set_duplicate(
|
||||
record_id: str,
|
||||
payload: DuplicateBody,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Email(session=session)
|
||||
data=await service.set_duplicate(record_id,payload.is_duplicate)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/email/send")
|
||||
async def send_email(
|
||||
payload: EmailSendBody,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Email(session=session)
|
||||
data=await service.send_email(payload.model_dump(exclude_unset=True),current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/email/reply")
|
||||
async def reply_email(
|
||||
payload: EmailReplyBody,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Email(session=session)
|
||||
data=await service.reply_email(payload.model_dump(exclude_unset=True),current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from dotenv import load_dotenv
|
|||
from fastapi import HTTPException
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from role.models import EnumRoles, Roles
|
||||
from sqlalchemy import Column, DateTime, func, or_, update
|
||||
from sqlalchemy import Column, DateTime, case, func, or_, update
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -105,7 +105,10 @@ class Inbox(SQLModel, table=True):
|
|||
.outerjoin(AtsResults,cls.ats_id==AtsResults.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
|
||||
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
|
||||
.order_by(cls.created_at.desc())
|
||||
.order_by(
|
||||
AtsResults.overall_score.desc().nulls_last(),
|
||||
cls.created_at.desc(),
|
||||
)
|
||||
)
|
||||
if job_post_id:
|
||||
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
|
||||
|
|
@ -679,6 +682,52 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def count_processing(cls, session: AsyncSession):
|
||||
statement = select(
|
||||
func.count().label("all_count"),
|
||||
func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712
|
||||
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
|
||||
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
|
||||
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
|
||||
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
|
||||
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"),
|
||||
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"),
|
||||
)
|
||||
row = (await session.execute(statement)).one()
|
||||
return {
|
||||
"all": int(row.all_count or 0),
|
||||
"unread": int(row.unread or 0),
|
||||
"imported": int(row.imported or 0),
|
||||
"processed": int(row.processed or 0),
|
||||
"rejected": int(row.rejected or 0),
|
||||
"duplicates": int(row.duplicates or 0),
|
||||
"assigned": int(row.assigned or 0),
|
||||
"unassigned": int(row.unassigned or 0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def set_processing_state(cls, session: AsyncSession, record_id, processing_state: str):
|
||||
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.processing_state = processing_state
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def set_duplicate(cls, session: AsyncSession, record_id, is_duplicate: bool):
|
||||
row = await cls.get_inbox_message_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_duplicate = bool(is_duplicate)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
class SourceChannels(SQLModel, table=True):
|
||||
__tablename__ = "source_channels"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ load_dotenv()
|
|||
EMAIL_URL=os.getenv("EMAIL_URL")
|
||||
EMAIL_API_TOKEN=os.getenv("EMAIL_API_TOKEN")
|
||||
BACKEND_URL=os.getenv("BACKEND_URL","http://localhost:8000")
|
||||
TEAMS_MAIL_API_URL=os.getenv("TEAMS_MAIL_API_URL")
|
||||
TEAMS_API_TOKEN=os.getenv("TEAMS_API_TOKEN")
|
||||
MAIL_ACCEPTED_STATUS=202
|
||||
|
||||
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments"
|
||||
# Prefer +92 / 03xx style numbers; fall back to a looser intl-ish pattern.
|
||||
|
|
@ -373,3 +376,32 @@ async def get_ats_score_for_manual_user(session:AsyncSession,user_id,job_post_id
|
|||
)
|
||||
|
||||
return _ats_score_payload((await session.execute(qry)).scalars().first())
|
||||
|
||||
|
||||
async def send_mail(to_email: str, subject: str, body: str, content_type: str = "html") -> None:
|
||||
"""POST multipart to TEAMS_MAIL_API_URL. Treats 202 as accepted.
|
||||
|
||||
Same shape as notifications.plugins.send_confirmation_mail — duplicated
|
||||
rather than imported so each domain owns its own mail copy and env reads.
|
||||
"""
|
||||
if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN:
|
||||
raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set")
|
||||
fields=[
|
||||
("subject",(None,subject)),
|
||||
("body",(None,body)),
|
||||
("content_type",(None,content_type or "html")),
|
||||
("save_to_sent_items",(None,"false")),
|
||||
("to",(None,to_email)),
|
||||
]
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response=await client.post(
|
||||
TEAMS_MAIL_API_URL,
|
||||
files=fields,
|
||||
headers={"Authorization":f"Bearer {TEAMS_API_TOKEN}"},
|
||||
)
|
||||
if response.status_code!=MAIL_ACCEPTED_STATUS:
|
||||
raise httpx.HTTPStatusError(
|
||||
response.text,
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
|||
}
|
||||
|
||||
|
||||
_PROCESSING_LABEL = {
|
||||
"unread": "Unread",
|
||||
"imported": "Imported",
|
||||
"processed": "Processed",
|
||||
"rejected": "Rejected",
|
||||
}
|
||||
|
||||
|
||||
def serialize_application(message: Inbox_Messages) -> dict:
|
||||
"""inbox_messages row -> the shape the #inbox All Applications tab renders.
|
||||
|
||||
|
|
@ -78,11 +86,16 @@ def serialize_application(message: Inbox_Messages) -> dict:
|
|||
the board tag (Rozee, Mustakbil, Employee Referral, ...) lands.
|
||||
|
||||
The tab also wants ats_score, phone, experience, recruiter, duplicate and a
|
||||
processing state beyond read/unread. phone comes from candidate_phone_number
|
||||
(filled by the match task); ats_score/recruiter/duplicate stay null until
|
||||
columns exist. `processing` is derived from message_read alone, so it is only
|
||||
ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
|
||||
processing state beyond read/unread. `processing` prefers imported /
|
||||
processed / rejected from the processing_state column so those writes are
|
||||
visible; the default `unread` state still follows message_read so existing
|
||||
rows keep Read/Unread until someone PATCHes a later state.
|
||||
"""
|
||||
state = (message.processing_state or "").strip().lower()
|
||||
if state in ("imported", "processed", "rejected"):
|
||||
processing = _PROCESSING_LABEL[state]
|
||||
else:
|
||||
processing = "Read" if message.message_read else "Unread"
|
||||
return {
|
||||
"id": str(message.id),
|
||||
"name": _sender_name(message),
|
||||
|
|
@ -91,7 +104,7 @@ def serialize_application(message: Inbox_Messages) -> dict:
|
|||
"source": message.message_to,
|
||||
"received": message.message_received_time,
|
||||
"unread": not message.message_read,
|
||||
"processing": "Read" if message.message_read else "Unread",
|
||||
"processing": processing,
|
||||
"application_status": message.application_status,
|
||||
"resume_status": _RESUME_STATUS.get(message.match_status, "Pending"),
|
||||
"attachment": _attachment_name(message),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
import uuid
|
||||
import httpx,os
|
||||
from fastapi import HTTPException
|
||||
from inbox.enums import Candidate_application_Status
|
||||
|
|
@ -10,6 +11,7 @@ from inbox.plugins import (
|
|||
fetch_message_read_status,
|
||||
load_message_files,
|
||||
request_email_confirmation,
|
||||
send_mail,
|
||||
)
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
|
@ -218,3 +220,97 @@ class Email:
|
|||
await Inbox_Messages.apply_read_status(self.session,[status])
|
||||
refreshed=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
return serialize_message(refreshed)
|
||||
|
||||
async def get_counts(self):
|
||||
return await Inbox_Messages.count_processing(self.session)
|
||||
|
||||
async def set_processing_state(self,record_id,processing_state):
|
||||
allowed=("unread","imported","processed","rejected")
|
||||
if processing_state not in allowed:
|
||||
raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}")
|
||||
message=await Inbox_Messages.set_processing_state(self.session,record_id,processing_state)
|
||||
if not message:
|
||||
raise HTTPException(status_code=404,detail="Message not found")
|
||||
return serialize_application(message)
|
||||
|
||||
async def set_duplicate(self,record_id,is_duplicate):
|
||||
if not isinstance(is_duplicate,bool):
|
||||
raise HTTPException(status_code=422,detail="is_duplicate must be a boolean")
|
||||
message=await Inbox_Messages.set_duplicate(self.session,record_id,is_duplicate)
|
||||
if not message:
|
||||
raise HTTPException(status_code=404,detail="Message not found")
|
||||
return serialize_application(message)
|
||||
|
||||
async def send_email(self,payload,current_user):
|
||||
to_email=(payload.get("to") or "").strip()
|
||||
subject=(payload.get("subject") or "").strip()
|
||||
body=payload.get("body") or ""
|
||||
content_type=(payload.get("content_type") or "html").strip() or "html"
|
||||
if not to_email:
|
||||
raise HTTPException(status_code=422,detail="to is required")
|
||||
if not subject:
|
||||
raise HTTPException(status_code=422,detail="subject is required")
|
||||
if not body:
|
||||
raise HTTPException(status_code=422,detail="body is required")
|
||||
try:
|
||||
await send_mail(to_email,subject,body,content_type=content_type)
|
||||
except (httpx.HTTPError,RuntimeError) as e:
|
||||
raise HTTPException(status_code=502,detail="Failed to send email") from e
|
||||
inbox_id=payload.get("inbox_id")
|
||||
job_post_id=None
|
||||
try:
|
||||
from notifications.models import Notifications
|
||||
uid=None
|
||||
raw=current_user.get("id") if current_user else None
|
||||
if raw:
|
||||
uid=uuid.UUID(str(raw))
|
||||
if uid:
|
||||
await Notifications.insert_notification(self.session,{
|
||||
"user_id":uid,
|
||||
"kind":"message",
|
||||
"title":"Email sent",
|
||||
"body":f"Sent “{subject}” to {to_email}",
|
||||
"link_path":"/inbox",
|
||||
"inbox_id":int(inbox_id) if inbox_id is not None else None,
|
||||
"job_post_id":job_post_id,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s",exc)
|
||||
return {"accepted":True,"to":to_email,"subject":subject}
|
||||
|
||||
async def reply_email(self,payload,current_user):
|
||||
record_id=payload.get("record_id")
|
||||
body=payload.get("body") or ""
|
||||
if not record_id:
|
||||
raise HTTPException(status_code=422,detail="record_id is required")
|
||||
if not str(body).strip():
|
||||
raise HTTPException(status_code=422,detail="body is required")
|
||||
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
|
||||
if not message:
|
||||
raise HTTPException(status_code=404,detail="Message not found")
|
||||
to_email=(message.message_from or "").strip()
|
||||
if not to_email:
|
||||
raise HTTPException(status_code=422,detail="Message has no sender address")
|
||||
original=(message.message_subject or "").strip()
|
||||
subject=original if original.lower().startswith("re:") else f"Re: {original}" if original else "Re:"
|
||||
try:
|
||||
await send_mail(to_email,subject,body,content_type="html")
|
||||
except (httpx.HTTPError,RuntimeError) as e:
|
||||
raise HTTPException(status_code=502,detail="Failed to send email") from e
|
||||
try:
|
||||
from notifications.models import Notifications
|
||||
uid=None
|
||||
raw=current_user.get("id") if current_user else None
|
||||
if raw:
|
||||
uid=uuid.UUID(str(raw))
|
||||
if uid:
|
||||
await Notifications.insert_notification(self.session,{
|
||||
"user_id":uid,
|
||||
"kind":"message",
|
||||
"title":"Email sent",
|
||||
"body":f"Replied to {to_email}",
|
||||
"link_path":"/inbox",
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s",exc)
|
||||
return {"accepted":True,"to":to_email,"subject":subject}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from fastapi import APIRouter,Depends,Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import FileResponse,JSONResponse
|
||||
from fastapi import HTTPException
|
||||
from db_setup import get_session
|
||||
from job.candidate.views import CandidateScoring,FileRead,CandidateView
|
||||
|
|
@ -117,6 +117,38 @@ class HiringCostCreate(BaseModel):
|
|||
incurred_at: datetime | None = None
|
||||
|
||||
|
||||
class JobUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
department: str | None = None
|
||||
location: str | None = None
|
||||
employment_type: str | None = None
|
||||
vacancies: int | None = None
|
||||
salary: str | None = None
|
||||
salary_min: float | None = None
|
||||
salary_max: float | None = None
|
||||
experience_min: int | None = None
|
||||
experience_max: int | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class JobStatusUpdate(BaseModel):
|
||||
requisition_status: str
|
||||
|
||||
|
||||
class FeedbackTemplateCreate(BaseModel):
|
||||
name: str
|
||||
department: str | None = None
|
||||
criteria: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FeedbackTemplateUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
department: str | None = None
|
||||
criteria: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@router.get("/jobs/alias")
|
||||
async def get_job_alias(session: AsyncSession = Depends(get_session)):
|
||||
try:
|
||||
|
|
@ -831,3 +863,140 @@ async def create_hiring_cost(
|
|||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/jobs/update")
|
||||
async def update_job(
|
||||
payload:JobUpdate,
|
||||
job_post_id:str=Query(...),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
data=await service.update_job(job_post_id,payload.model_dump(exclude_unset=True),current_user)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/jobs/delete")
|
||||
async def delete_job(
|
||||
job_post_id:str=Query(...),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_DELETE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
data=await service.delete_job(job_post_id,current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/jobs/status")
|
||||
async def set_job_status(
|
||||
payload:JobStatusUpdate,
|
||||
job_post_id:str=Query(...),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=JobPost(session=session)
|
||||
data=await service.set_job_status(job_post_id,payload.model_dump(exclude_unset=True),current_user)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/feedback/templates/fetch")
|
||||
async def fetch_feedback_templates(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=FeedbackView(session=session)
|
||||
data,total=await service.get_templates()
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/feedback/templates/create")
|
||||
async def create_feedback_template(
|
||||
payload:FeedbackTemplateCreate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=FeedbackView(session=session)
|
||||
data=await service.create_template(payload.model_dump(exclude_unset=True),current_user)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/feedback/templates/update")
|
||||
async def update_feedback_template(
|
||||
payload:FeedbackTemplateUpdate,
|
||||
template_id:str=Query(...),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=FeedbackView(session=session)
|
||||
data=await service.update_template(template_id,payload.model_dump(exclude_unset=True))
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/feedback/templates/delete")
|
||||
async def delete_feedback_template(
|
||||
template_id:str=Query(...),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_DELETE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=FeedbackView(session=session)
|
||||
data=await service.delete_template(template_id)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/documents/download")
|
||||
async def download_document(
|
||||
inbox_id:int|None=Query(None),
|
||||
manual_upload_candidate_id:str|None=Query(None),
|
||||
index:int=Query(0,ge=0),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=CandidateView(session=session)
|
||||
path,filename=await service.download_document(inbox_id,manual_upload_candidate_id,index)
|
||||
return FileResponse(
|
||||
path=str(path),
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
|
|
|||
|
|
@ -71,6 +71,29 @@ class JobAssignments(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
||||
@classmethod
|
||||
async def count_open_reqs_by_users(cls, session: AsyncSession, user_ids):
|
||||
"""Open requisitions per user: current assignments joined to open job_posts."""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
uids = [u for u in (user_ids or []) if u]
|
||||
if not uids:
|
||||
return {}
|
||||
statement = (
|
||||
select(cls.user_id, func.count())
|
||||
.select_from(cls)
|
||||
.join(JobPosts, JobPosts.id == cls.job_post_id)
|
||||
.where(
|
||||
cls.user_id.in_(uids),
|
||||
cls.valid_to.is_(None),
|
||||
JobPosts.requisition_status == "open",
|
||||
JobPosts.is_deleted == False, # noqa: E712
|
||||
)
|
||||
.group_by(cls.user_id)
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return {uid: int(n or 0) for uid, n in result.all()}
|
||||
|
||||
|
||||
class ApplicationAssignments(SQLModel, table=True):
|
||||
__tablename__ = "application_assignments"
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
&(AtsResults.job_post_id==cls.job_post_id)
|
||||
&(AtsResults.is_current==True), # noqa: E712
|
||||
)
|
||||
.order_by(cls.created_at.desc())
|
||||
.order_by(
|
||||
AtsResults.overall_score.desc().nulls_last(),
|
||||
cls.created_at.desc(),
|
||||
)
|
||||
)
|
||||
if job_post_id:
|
||||
qry=qry.where(cls.job_post_id==job_post_id)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ AsyncOpenAI client rather than opening a second connection pool.
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.services.llm import OpenAIScorer
|
||||
|
|
@ -194,6 +195,49 @@ def documents_from_message(file_name: str | None, file_path: str | None) -> list
|
|||
return out
|
||||
|
||||
|
||||
_ATTACHMENTS_ROOT = Path(__file__).resolve().parents[2] / "inbox" / "decoded_attachments"
|
||||
|
||||
|
||||
def contained_download_path(stored_path: str | None) -> Path | None:
|
||||
"""Resolve `stored_path` only if it sits inside decoded_attachments.
|
||||
|
||||
Never follows a client-supplied path. Returns None on any failure so the
|
||||
caller can 404 rather than 403 (a 403 would confirm the file exists).
|
||||
|
||||
Stored paths may be host-absolute Windows paths (see resolve_attachment_path).
|
||||
Those fail the containment check against this process's attachments dir; fall
|
||||
back to the basename under decoded_attachments, then contain that too.
|
||||
"""
|
||||
if not stored_path or not str(stored_path).strip():
|
||||
return None
|
||||
root = _ATTACHMENTS_ROOT.resolve()
|
||||
raw = str(stored_path).strip()
|
||||
basename = Path(raw.replace("\\", "/")).name
|
||||
|
||||
def _contained(path: Path) -> Path | None:
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(root)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
return resolved
|
||||
|
||||
try:
|
||||
candidate = Path(raw)
|
||||
if not candidate.is_absolute():
|
||||
candidate = root / basename
|
||||
hit = _contained(candidate)
|
||||
if hit is not None:
|
||||
return hit
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
pass
|
||||
if basename:
|
||||
return _contained(root / basename)
|
||||
return None
|
||||
|
||||
|
||||
# Same system prefixes inbox/models._is_linkable_sender rejects — anything we
|
||||
# accept here must remain linkable when insert_email creates the users row.
|
||||
_SKIP_SENDER_PREFIXES = (
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ from job.candidate.plugins import (
|
|||
build_job_description,
|
||||
candidate_completed_fields,
|
||||
candidate_failed_fields,
|
||||
contained_download_path,
|
||||
documents_from_message,
|
||||
get_scorer,
|
||||
get_scoring_settings,
|
||||
normalize_spaced_text,
|
||||
|
|
@ -814,3 +816,36 @@ class CandidateView:
|
|||
if assigned_job_post.get("title"):
|
||||
base["job_title"]=assigned_job_post.get("title")
|
||||
return base
|
||||
|
||||
async def download_document(self,inbox_id=None,manual_upload_candidate_id=None,index=0):
|
||||
has_inbox=inbox_id is not None and str(inbox_id).strip()!=""
|
||||
has_manual=manual_upload_candidate_id is not None and str(manual_upload_candidate_id).strip()!=""
|
||||
if has_inbox==has_manual:
|
||||
raise HTTPException(status_code=404,detail="Not found")
|
||||
try:
|
||||
index=int(index or 0)
|
||||
except (TypeError,ValueError):
|
||||
raise HTTPException(status_code=404,detail="Not found")
|
||||
if index<0:
|
||||
raise HTTPException(status_code=404,detail="Not found")
|
||||
|
||||
docs=[]
|
||||
if has_inbox:
|
||||
link=await Inbox.get_inbox_with_message(self.session,inbox_id)
|
||||
if not link or not link.messages:
|
||||
raise HTTPException(status_code=404,detail="Not found")
|
||||
docs=documents_from_message(link.messages.file_name,link.messages.file_path)
|
||||
else:
|
||||
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_candidate_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Not found")
|
||||
docs=documents_from_message(row.file_name,row.file_path)
|
||||
|
||||
if index>=len(docs):
|
||||
raise HTTPException(status_code=404,detail="Not found")
|
||||
entry=docs[index]
|
||||
path=contained_download_path(entry.get("path"))
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404,detail="Not found")
|
||||
name=(entry.get("name") or path.name).strip() or path.name
|
||||
return path,name
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, JSON, UniqueConstraint
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FeedbackTemplates(SQLModel, table=True):
|
||||
__tablename__ = "feedback_templates"
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_feedback_templates_name"),)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
name: str
|
||||
department: str | None = Field(default=None)
|
||||
criteria: list[str] = Field(default_factory=list, sa_type=JSON)
|
||||
is_active: bool = Field(default=True)
|
||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_by_id(cls, session: AsyncSession, record_id):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def fetch_templates(cls, session: AsyncSession):
|
||||
statement = (
|
||||
select(cls)
|
||||
.where(cls.is_deleted == False) # noqa: E712
|
||||
.order_by(cls.created_at.asc())
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
rows = list(result.scalars().all())
|
||||
return rows, len(rows)
|
||||
|
||||
@classmethod
|
||||
async def insert_template(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_template(cls, session: AsyncSession, record_id, fields: dict):
|
||||
row = await cls.get_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_template(cls, session: AsyncSession, record_id):
|
||||
row = await cls.get_by_id(session, record_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.is_active = False
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
@ -12,3 +12,16 @@ def serialize_feedback(row) -> dict:
|
|||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_feedback_template(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"name": row.name,
|
||||
"department": row.department,
|
||||
"criteria": list(row.criteria or []),
|
||||
"is_active": row.is_active,
|
||||
"created_by": str(row.created_by) if row.created_by else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ from sqlalchemy.orm import selectinload
|
|||
from sqlmodel import select
|
||||
|
||||
from job.candidate.models import Feedback
|
||||
from job.feedback.serializers import serialize_feedback
|
||||
from job.feedback.models import FeedbackTemplates
|
||||
from job.feedback.serializers import serialize_feedback, serialize_feedback_template
|
||||
|
||||
|
||||
class FeedbackView:
|
||||
|
|
@ -61,3 +62,53 @@ class FeedbackView:
|
|||
raise HTTPException(status_code=404,detail="Feedback not found")
|
||||
row=await self._load(row.id)
|
||||
return serialize_feedback(row)
|
||||
|
||||
async def get_templates(self):
|
||||
rows,total=await FeedbackTemplates.fetch_templates(self.session)
|
||||
return [serialize_feedback_template(r) for r in rows],total
|
||||
|
||||
async def create_template(self,payload,current_user):
|
||||
name=(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422,detail="name is required")
|
||||
criteria=payload.get("criteria") or []
|
||||
if not isinstance(criteria,list) or not all(isinstance(c,str) for c in criteria):
|
||||
raise HTTPException(status_code=422,detail="criteria must be a list of strings")
|
||||
fields={
|
||||
"name":name,
|
||||
"department":payload.get("department"),
|
||||
"criteria":[c.strip() for c in criteria if str(c).strip()],
|
||||
"is_active":payload.get("is_active") if payload.get("is_active") is not None else True,
|
||||
"created_by":current_user.get("id") if isinstance(current_user,dict) else None,
|
||||
}
|
||||
row=await FeedbackTemplates.insert_template(self.session,fields)
|
||||
return serialize_feedback_template(row)
|
||||
|
||||
async def update_template(self,template_id,payload):
|
||||
allowed=("name","department","criteria","is_active")
|
||||
fields={}
|
||||
for key in allowed:
|
||||
if key not in payload:
|
||||
continue
|
||||
value=payload[key]
|
||||
if key=="name":
|
||||
value=(value or "").strip()
|
||||
if not value:
|
||||
raise HTTPException(status_code=422,detail="name cannot be blank")
|
||||
elif key=="criteria":
|
||||
if value is not None and (not isinstance(value,list) or not all(isinstance(c,str) for c in value)):
|
||||
raise HTTPException(status_code=422,detail="criteria must be a list of strings")
|
||||
value=[c.strip() for c in (value or []) if str(c).strip()]
|
||||
fields[key]=value
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400,detail="No fields to update")
|
||||
row=await FeedbackTemplates.update_template(self.session,template_id,fields)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Template not found")
|
||||
return serialize_feedback_template(row)
|
||||
|
||||
async def delete_template(self,template_id):
|
||||
row=await FeedbackTemplates.soft_delete_template(self.session,template_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Template not found")
|
||||
return {"id":str(row.id),"deleted":True}
|
||||
|
|
|
|||
|
|
@ -215,6 +215,48 @@ class JobPosts(SQLModel, table=True):
|
|||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def update_job_post(cls, session: AsyncSession, record_id: str, fields: dict):
|
||||
row = await cls.get_job_post_by_id(session, record_id)
|
||||
if not row or row.is_deleted:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_job_post_by_id(session, record_id)
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_job_post(cls, session: AsyncSession, record_id: str):
|
||||
row = await cls.get_job_post_by_id(session, record_id)
|
||||
if not row or row.is_deleted:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.is_active = False
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def set_requisition_status(cls, session: AsyncSession, record_id: str, status: str):
|
||||
row = await cls.get_job_post_by_id(session, record_id)
|
||||
if not row or row.is_deleted:
|
||||
return None
|
||||
previous = row.requisition_status
|
||||
row.requisition_status = status
|
||||
if status == "closed":
|
||||
if previous != "closed" or row.closed_at is None:
|
||||
row.closed_at = _now()
|
||||
else:
|
||||
row.closed_at = None
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_job_post_by_id(session, record_id)
|
||||
|
||||
|
||||
class SocialPlatform(SQLModel, table=True):
|
||||
"""Buffer publish aliases (fb → facebook). Spelling tolerance + UI list, not an allowlist."""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from datetime import date, time
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
|
@ -20,6 +22,7 @@ from job.job_post.plugins import (
|
|||
from job.job_post.serializers import serialize_job_post, serialize_job_row
|
||||
|
||||
load_dotenv()
|
||||
logger=logging.getLogger("job.job_post")
|
||||
|
||||
|
||||
class JobPostCreate(BaseModel):
|
||||
|
|
@ -163,3 +166,69 @@ class JobPost:
|
|||
serialize_job_row(r,recruiter_name=names.get(str(r.current_recruiter_id)))
|
||||
for r in rows
|
||||
],total
|
||||
|
||||
async def _job_row(self,row):
|
||||
names=await JobPosts.recruiter_names(
|
||||
self.session,[row.current_recruiter_id] if row.current_recruiter_id else [],
|
||||
)
|
||||
return serialize_job_row(row,recruiter_name=names.get(str(row.current_recruiter_id)))
|
||||
|
||||
async def update_job(self,job_post_id,payload,current_user):
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||
allowed=("title","department","location","employment_type","vacancies",
|
||||
"salary","experience_min","experience_max","description")
|
||||
fields={k:payload[k] for k in allowed if k in payload}
|
||||
if "salary" not in fields and ("salary_min" in payload or "salary_max" in payload):
|
||||
low=payload.get("salary_min")
|
||||
high=payload.get("salary_max")
|
||||
if low is not None and high is not None:
|
||||
fields["salary"]=f"{low} - {high}"
|
||||
elif low is not None:
|
||||
fields["salary"]=str(low)
|
||||
elif high is not None:
|
||||
fields["salary"]=str(high)
|
||||
if "department" in fields and fields["department"] is None:
|
||||
fields["department"]=""
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400,detail="No fields to update")
|
||||
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
return await self._job_row(row)
|
||||
|
||||
async def delete_job(self,job_post_id,current_user):
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||
row=await JobPosts.soft_delete_job_post(self.session,job_post_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
return {"id":str(row.id),"deleted":True}
|
||||
|
||||
async def set_job_status(self,job_post_id,payload,current_user):
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||
status=(payload.get("requisition_status") or "").strip()
|
||||
allowed=("open","closed","on_hold")
|
||||
if status not in allowed:
|
||||
raise HTTPException(status_code=422,detail=f"requisition_status must be one of {', '.join(allowed)}")
|
||||
row=await JobPosts.set_requisition_status(self.session,job_post_id,status)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
if status=="closed":
|
||||
try:
|
||||
from notifications.models import Notifications
|
||||
raw=row.current_recruiter_id or (current_user.get("id") if current_user else None)
|
||||
recipient=uuid.UUID(str(raw)) if raw else None
|
||||
if recipient:
|
||||
await Notifications.insert_notification(self.session,{
|
||||
"user_id":recipient,
|
||||
"kind":"approval",
|
||||
"title":"Requisition closed",
|
||||
"body":f"{row.title} was closed",
|
||||
"link_path":"/jobs",
|
||||
"job_post_id":row.id,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s",exc)
|
||||
return await self._job_row(row)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ from notifications.app import router as confirmation_router
|
|||
from analytics.app import router as analytics_router
|
||||
from offer.app import router as offer_router
|
||||
from tasks.app import router as tasks_router
|
||||
from assessments.app import router as assessments_router
|
||||
from org_settings.app import router as org_settings_router
|
||||
from saved_search.app import router as saved_search_router
|
||||
from search.app import router as search_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
|
||||
logger=logging.getLogger("main")
|
||||
|
|
@ -85,3 +89,7 @@ app.include_router(candidate_router)
|
|||
app.include_router(analytics_router)
|
||||
app.include_router(offer_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(assessments_router)
|
||||
app.include_router(org_settings_router)
|
||||
app.include_router(saved_search_router)
|
||||
app.include_router(search_router)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
-- 006_seed_feedback_templates.sql
|
||||
-- Manual one-shot: seed the four interview scorecard templates that the
|
||||
-- evaluation form currently reads from frontend/src/data/seed.js evalTemplates.
|
||||
--
|
||||
-- Order: (1) alembic upgrade / autogenerate so app.feedback_templates exists,
|
||||
-- (2) this file. Applied automatically at startup by alembic_setup.run_manual_sql()
|
||||
-- once the schema is at head; recorded in manual_migrations. Safe to re-run by hand.
|
||||
|
||||
INSERT INTO app.feedback_templates (id, name, department, criteria, is_active, created_by, created_at, updated_at, is_deleted)
|
||||
VALUES
|
||||
(gen_random_uuid(), 'Engineering — Technical', 'Engineering',
|
||||
'["Technical Skills","Problem Solving","System Design","Communication","Culture Fit"]'::json,
|
||||
true, NULL, NOW(), NOW(), false),
|
||||
(gen_random_uuid(), 'Product — PM Loop', 'Product',
|
||||
'["Product Sense","Analytical","Execution","Leadership","Communication"]'::json,
|
||||
true, NULL, NOW(), NOW(), false),
|
||||
(gen_random_uuid(), 'Design — Portfolio', 'Design',
|
||||
'["Craft","Process","Collaboration","Communication","Culture Fit"]'::json,
|
||||
true, NULL, NOW(), NOW(), false),
|
||||
(gen_random_uuid(), 'General — Behavioral', 'All',
|
||||
'["Communication","Technical","Problem Solving","Leadership","Culture Fit"]'::json,
|
||||
true, NULL, NOW(), NOW(), false)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
from fastapi import APIRouter,Depends
|
||||
from fastapi import APIRouter,Depends,Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import HTTPException
|
||||
from db_setup import get_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from notifications.views import Confirmation
|
||||
from notifications.views import Confirmation,Notification
|
||||
from users.permissions import CurrentUser
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
|
@ -41,3 +42,68 @@ async def resend_confirm_email(payload: ConfirmEmailResend,session: AsyncSession
|
|||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/notifications/fetch")
|
||||
async def fetch_notifications(
|
||||
current_user: CurrentUser,
|
||||
unread_only: bool = Query(False),
|
||||
top: int | None = Query(None),
|
||||
skip: int = Query(0,ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Notification(session=session)
|
||||
data,total,unread=await service.get_notifications(current_user,unread_only,top,skip)
|
||||
return JSONResponse(content={"data":data,"total":total,"unread":unread,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/notifications/{record_id}/read")
|
||||
async def mark_notification_read(
|
||||
record_id: str,
|
||||
current_user: CurrentUser,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Notification(session=session)
|
||||
data=await service.mark_read(record_id,current_user)
|
||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/notifications/read-all")
|
||||
async def mark_all_notifications_read(
|
||||
current_user: CurrentUser,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Notification(session=session)
|
||||
data=await service.mark_all_read(current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/notifications/delete")
|
||||
async def delete_notification(
|
||||
current_user: CurrentUser,
|
||||
record_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Notification(session=session)
|
||||
data=await service.delete_notification(record_id,current_user)
|
||||
return JSONResponse(content={"data":data,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
|
|
@ -100,3 +100,113 @@ class EmailConfirmationTokens(SQLModel, table=True):
|
|||
session.add(row)
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
class Notifications(SQLModel, table=True):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
user_id: uuid.UUID = Field(index=True, foreign_key="users.id")
|
||||
kind: str
|
||||
title: str
|
||||
body: str | None = Field(default=None)
|
||||
link_path: str | None = Field(default=None)
|
||||
inbox_id: int | None = Field(default=None)
|
||||
job_post_id: uuid.UUID | None = Field(default=None)
|
||||
is_read: bool = Field(default=False, index=True)
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_by_id(cls, session: AsyncSession, record_id, *, user_id=None):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
statement = select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||
if user_id is not None:
|
||||
statement = statement.where(cls.user_id == user_id)
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def fetch_notifications(
|
||||
cls,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id,
|
||||
unread_only: bool = False,
|
||||
top: int | None = None,
|
||||
skip: int = 0,
|
||||
):
|
||||
statement = select(cls).where(
|
||||
cls.user_id == user_id, cls.is_deleted == False # noqa: E712
|
||||
)
|
||||
if unread_only:
|
||||
statement = statement.where(cls.is_read == False) # noqa: E712
|
||||
count_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = (await session.execute(count_statement)).scalar_one()
|
||||
unread_statement = select(func.count()).select_from(cls).where(
|
||||
cls.user_id == user_id,
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
cls.is_read == False, # noqa: E712
|
||||
)
|
||||
unread = (await session.execute(unread_statement)).scalar_one()
|
||||
statement = statement.order_by(cls.created_at.desc())
|
||||
if skip:
|
||||
statement = statement.offset(skip)
|
||||
if top is not None:
|
||||
statement = statement.limit(top)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all()), total, unread
|
||||
|
||||
@classmethod
|
||||
async def insert_notification(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def mark_read(cls, session: AsyncSession, record_id, *, user_id):
|
||||
row = await cls.get_by_id(session, record_id, user_id=user_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_read = True
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def mark_all_read(cls, session: AsyncSession, user_id):
|
||||
statement = select(cls).where(
|
||||
cls.user_id == user_id,
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
cls.is_read == False, # noqa: E712
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
rows = list(result.scalars().all())
|
||||
for row in rows:
|
||||
row.is_read = True
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_notification(cls, session: AsyncSession, record_id, *, user_id):
|
||||
row = await cls.get_by_id(session, record_id, user_id=user_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
from notifications.plugins import CONFIRM_TOKEN_RESEND_SECONDS,CONFIRM_TOKEN_TTL_SECONDS
|
||||
|
||||
|
||||
def serialize_notification(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"kind": row.kind,
|
||||
"title": row.title,
|
||||
"body": row.body,
|
||||
"link_path": row.link_path,
|
||||
"inbox_id": row.inbox_id,
|
||||
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
|
||||
"is_read": row.is_read,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_confirmation_request(email: str,expires_at) -> dict:
|
||||
return {
|
||||
"email": email,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import httpx
|
||||
|
||||
from notifications.models import EmailConfirmationTokens
|
||||
import uuid
|
||||
|
||||
from notifications.models import EmailConfirmationTokens,Notifications
|
||||
from notifications.plugins import (
|
||||
CONFIRM_TOKEN_RESEND_SECONDS,
|
||||
CONFIRM_TOKEN_TTL_SECONDS,
|
||||
|
|
@ -17,7 +19,11 @@ from notifications.plugins import (
|
|||
split_token,
|
||||
verify_token,
|
||||
)
|
||||
from notifications.serializers import serialize_confirmation_request,serialize_confirmation_result
|
||||
from notifications.serializers import (
|
||||
serialize_confirmation_request,
|
||||
serialize_confirmation_result,
|
||||
serialize_notification,
|
||||
)
|
||||
from users.models import Users
|
||||
|
||||
|
||||
|
|
@ -92,3 +98,56 @@ class Confirmation:
|
|||
raise HTTPException(status_code=429,detail="Please wait before requesting another confirmation email")
|
||||
|
||||
return await self.send_confirmation(user)
|
||||
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None,""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError,ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401,detail="Not authenticated")
|
||||
uid=_as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401,detail="Invalid user id")
|
||||
return uid
|
||||
|
||||
|
||||
class Notification:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def get_notifications(self,current_user,unread_only=False,top=None,skip=0):
|
||||
rows,total,unread=await Notifications.fetch_notifications(
|
||||
self.session,
|
||||
user_id=_user_id(current_user),
|
||||
unread_only=bool(unread_only),
|
||||
top=top,
|
||||
skip=skip or 0,
|
||||
)
|
||||
return [serialize_notification(r) for r in rows],total,unread
|
||||
|
||||
async def mark_read(self,record_id,current_user):
|
||||
row=await Notifications.mark_read(
|
||||
self.session,record_id,user_id=_user_id(current_user)
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Notification not found")
|
||||
return serialize_notification(row)
|
||||
|
||||
async def mark_all_read(self,current_user):
|
||||
count=await Notifications.mark_all_read(self.session,_user_id(current_user))
|
||||
return {"updated": count}
|
||||
|
||||
async def delete_notification(self,record_id,current_user):
|
||||
row=await Notifications.soft_delete_notification(
|
||||
self.session,record_id,user_id=_user_id(current_user)
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404,detail="Notification not found")
|
||||
return {"id": str(row.id),"deleted": True}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db_setup import get_session
|
||||
from org_settings.views import OrgSetting
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class OrgSettingItem(BaseModel):
|
||||
key: str
|
||||
value: Any = None
|
||||
category: str
|
||||
|
||||
|
||||
class OrgSettingsUpdate(BaseModel):
|
||||
settings: list[OrgSettingItem]
|
||||
|
||||
|
||||
@router.get("/org-settings/fetch")
|
||||
async def fetch_org_settings(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_VIEW)),
|
||||
category: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = OrgSetting(session=session)
|
||||
data, total = await service.get_settings(category)
|
||||
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/org-settings/update")
|
||||
async def update_org_settings(
|
||||
payload: OrgSettingsUpdate,
|
||||
current_user: dict = Depends(require_permission(PermissionTag.SETTINGS_CONFIGURE)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = OrgSetting(session=session)
|
||||
data = await service.update_settings(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "total": len(data), "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
from typing import Any
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, JSON
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class OrgSettings(SQLModel, table=True):
|
||||
__tablename__ = "org_settings"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
setting_key: str = Field(unique=True, index=True)
|
||||
setting_value: Any = Field(sa_type=JSON)
|
||||
category: str
|
||||
updated_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def fetch_settings(cls, session: AsyncSession, *, category: str | None = None):
|
||||
statement = select(cls)
|
||||
if category:
|
||||
statement = statement.where(cls.category == category)
|
||||
statement = statement.order_by(cls.setting_key.asc())
|
||||
result = await session.execute(statement)
|
||||
rows = list(result.scalars().all())
|
||||
return rows, len(rows)
|
||||
|
||||
@classmethod
|
||||
async def get_by_key(cls, session: AsyncSession, setting_key: str):
|
||||
result = await session.execute(select(cls).where(cls.setting_key == setting_key))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def upsert_settings(cls, session: AsyncSession, items: list[dict], updated_by):
|
||||
rows = []
|
||||
now = _now()
|
||||
for item in items:
|
||||
key = item["setting_key"]
|
||||
row = await cls.get_by_key(session, key)
|
||||
if row is None:
|
||||
row = cls(
|
||||
setting_key=key,
|
||||
setting_value=item.get("setting_value"),
|
||||
category=item["category"],
|
||||
updated_by=updated_by,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
else:
|
||||
row.setting_value = item.get("setting_value")
|
||||
row.category = item["category"]
|
||||
row.updated_by = updated_by
|
||||
row.updated_at = now
|
||||
session.add(row)
|
||||
rows.append(row)
|
||||
await session.commit()
|
||||
for row in rows:
|
||||
await session.refresh(row)
|
||||
return rows
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
def serialize_org_setting(row) -> dict:
|
||||
return {
|
||||
"key": row.setting_key,
|
||||
"value": row.setting_value,
|
||||
"category": row.category,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from org_settings.models import OrgSettings
|
||||
from org_settings.serializers import serialize_org_setting
|
||||
|
||||
VALID_CATEGORIES = (
|
||||
"general",
|
||||
"notifications",
|
||||
"email_templates",
|
||||
"career_portal",
|
||||
"branding",
|
||||
"security",
|
||||
)
|
||||
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = _as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user id")
|
||||
return uid
|
||||
|
||||
|
||||
class OrgSetting:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_settings(self, category=None):
|
||||
if category and category not in VALID_CATEGORIES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"category must be one of {', '.join(VALID_CATEGORIES)}"
|
||||
)
|
||||
rows, total = await OrgSettings.fetch_settings(self.session, category=category)
|
||||
return [serialize_org_setting(r) for r in rows], total
|
||||
|
||||
async def update_settings(self, payload, current_user):
|
||||
items = payload.get("settings") or []
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="settings is required")
|
||||
cleaned = []
|
||||
for item in items:
|
||||
key = (item.get("key") or "").strip()
|
||||
category = (item.get("category") or "").strip()
|
||||
if not key:
|
||||
raise HTTPException(status_code=422, detail="key is required")
|
||||
if category not in VALID_CATEGORIES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"category must be one of {', '.join(VALID_CATEGORIES)}"
|
||||
)
|
||||
cleaned.append({
|
||||
"setting_key": key,
|
||||
"setting_value": item.get("value"),
|
||||
"category": category,
|
||||
})
|
||||
rows = await OrgSettings.upsert_settings(self.session, cleaned, _user_id(current_user))
|
||||
return [serialize_org_setting(r) for r in rows]
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db_setup import get_session
|
||||
from saved_search.views import SavedSearch
|
||||
from users.permissions import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SavedSearchCreate(BaseModel):
|
||||
name: str
|
||||
entity: str
|
||||
filters: dict | None = None
|
||||
|
||||
|
||||
class SavedSearchUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
entity: str | None = None
|
||||
filters: dict | None = None
|
||||
|
||||
|
||||
@router.get("/saved-searches/fetch")
|
||||
async def fetch_saved_searches(
|
||||
current_user: CurrentUser,
|
||||
entity: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = SavedSearch(session=session)
|
||||
data, total = await service.get_saved_searches(current_user, entity)
|
||||
return JSONResponse(content={"data": data, "total": total, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/saved-searches/create")
|
||||
async def create_saved_search(
|
||||
payload: SavedSearchCreate,
|
||||
current_user: CurrentUser,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = SavedSearch(session=session)
|
||||
data = await service.create_saved_search(payload.model_dump(exclude_unset=True), current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/saved-searches/update")
|
||||
async def update_saved_search(
|
||||
payload: SavedSearchUpdate,
|
||||
current_user: CurrentUser,
|
||||
record_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = SavedSearch(session=session)
|
||||
data = await service.update_saved_search(
|
||||
record_id, payload.model_dump(exclude_unset=True), current_user
|
||||
)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/saved-searches/delete")
|
||||
async def delete_saved_search(
|
||||
current_user: CurrentUser,
|
||||
record_id: str = Query(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service = SavedSearch(session=session)
|
||||
data = await service.delete_saved_search(record_id, current_user)
|
||||
return JSONResponse(content={"data": data, "status_code": 200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, JSON, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class SavedSearches(SQLModel, table=True):
|
||||
__tablename__ = "saved_searches"
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
user_id: uuid.UUID = Field(index=True, foreign_key="users.id")
|
||||
name: str
|
||||
entity: str
|
||||
filters: dict = Field(default_factory=dict, sa_type=JSON)
|
||||
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
is_deleted: bool = Field(default=False)
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_by_id(cls, session: AsyncSession, record_id, *, user_id=None):
|
||||
uid = cls._as_uuid(record_id)
|
||||
if uid is None:
|
||||
return None
|
||||
statement = select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712
|
||||
if user_id is not None:
|
||||
statement = statement.where(cls.user_id == user_id)
|
||||
result = await session.execute(statement)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def fetch_saved_searches(
|
||||
cls, session: AsyncSession, *, user_id, entity: str | None = None
|
||||
):
|
||||
statement = select(cls).where(
|
||||
cls.user_id == user_id, cls.is_deleted == False # noqa: E712
|
||||
)
|
||||
if entity:
|
||||
statement = statement.where(cls.entity == entity)
|
||||
count_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = (await session.execute(count_statement)).scalar_one()
|
||||
statement = statement.order_by(cls.created_at.desc())
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
@classmethod
|
||||
async def insert_saved_search(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return await cls.get_by_id(session, row.id)
|
||||
|
||||
@classmethod
|
||||
async def update_saved_search(cls, session: AsyncSession, record_id, fields: dict, *, user_id):
|
||||
row = await cls.get_by_id(session, record_id, user_id=user_id)
|
||||
if not row:
|
||||
return None
|
||||
for key, value in fields.items():
|
||||
setattr(row, key, value)
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def soft_delete_saved_search(cls, session: AsyncSession, record_id, *, user_id):
|
||||
row = await cls.get_by_id(session, record_id, user_id=user_id)
|
||||
if not row:
|
||||
return None
|
||||
row.is_deleted = True
|
||||
row.updated_at = _now()
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row
|
||||
|
||||
|
||||
import users.models as _users_models # noqa: E402, F401
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
def serialize_saved_search(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"name": row.name,
|
||||
"entity": row.entity,
|
||||
"filters": row.filters or {},
|
||||
"count": None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from saved_search.models import SavedSearches
|
||||
from saved_search.serializers import serialize_saved_search
|
||||
|
||||
VALID_ENTITIES = ("candidates", "jobs", "tasks", "inbox")
|
||||
|
||||
|
||||
def _as_uuid(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _user_id(current_user):
|
||||
if not current_user or not current_user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
uid = _as_uuid(current_user["id"])
|
||||
if uid is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid user id")
|
||||
return uid
|
||||
|
||||
|
||||
class SavedSearch:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def get_saved_searches(self, current_user, entity=None):
|
||||
if entity and entity not in VALID_ENTITIES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"entity must be one of {', '.join(VALID_ENTITIES)}"
|
||||
)
|
||||
rows, total = await SavedSearches.fetch_saved_searches(
|
||||
self.session, user_id=_user_id(current_user), entity=entity
|
||||
)
|
||||
return [serialize_saved_search(r) for r in rows], total
|
||||
|
||||
async def create_saved_search(self, payload, current_user):
|
||||
name = (payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="name is required")
|
||||
entity = (payload.get("entity") or "").strip()
|
||||
if entity not in VALID_ENTITIES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"entity must be one of {', '.join(VALID_ENTITIES)}"
|
||||
)
|
||||
filters = payload.get("filters") if isinstance(payload.get("filters"), dict) else {}
|
||||
row = await SavedSearches.insert_saved_search(self.session, {
|
||||
"user_id": _user_id(current_user),
|
||||
"name": name,
|
||||
"entity": entity,
|
||||
"filters": filters,
|
||||
})
|
||||
return serialize_saved_search(row)
|
||||
|
||||
async def update_saved_search(self, record_id, payload, current_user):
|
||||
uid = _user_id(current_user)
|
||||
fields = {}
|
||||
if "name" in payload:
|
||||
name = (payload.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="name cannot be blank")
|
||||
fields["name"] = name
|
||||
if "entity" in payload:
|
||||
entity = (payload.get("entity") or "").strip()
|
||||
if entity not in VALID_ENTITIES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"entity must be one of {', '.join(VALID_ENTITIES)}"
|
||||
)
|
||||
fields["entity"] = entity
|
||||
if "filters" in payload:
|
||||
if payload["filters"] is not None and not isinstance(payload["filters"], dict):
|
||||
raise HTTPException(status_code=422, detail="filters must be an object")
|
||||
fields["filters"] = payload["filters"] or {}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
row = await SavedSearches.update_saved_search(
|
||||
self.session, record_id, fields, user_id=uid
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Saved search not found")
|
||||
return serialize_saved_search(row)
|
||||
|
||||
async def delete_saved_search(self, record_id, current_user):
|
||||
row = await SavedSearches.soft_delete_saved_search(
|
||||
self.session, record_id, user_id=_user_id(current_user)
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Saved search not found")
|
||||
return {"id": str(row.id), "deleted": True}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
from fastapi import APIRouter,Depends,Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import HTTPException
|
||||
from db_setup import get_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from search.views import Search
|
||||
from users.permissions import PermissionTag,require_permission
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/search/fetch")
|
||||
async def fetch_search(
|
||||
current_user: dict = Depends(
|
||||
require_permission(PermissionTag.JOBS_VIEW,PermissionTag.CANDIDATES_VIEW,require_all=False)
|
||||
),
|
||||
q: str | None = Query(None),
|
||||
limit: int = Query(4,ge=1),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Search(session=session)
|
||||
data,total=await service.fetch(q,limit,current_user)
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
def serialize_search_job(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id) if row.id else None,
|
||||
"title": row.title,
|
||||
"department": row.department or None,
|
||||
"location": row.location,
|
||||
"requisition_status": row.requisition_status,
|
||||
}
|
||||
|
||||
|
||||
def serialize_search_candidate(user_id, name, email, inbox_id=None) -> dict:
|
||||
return {
|
||||
"id": str(user_id) if user_id else None,
|
||||
"name": name,
|
||||
"email": email,
|
||||
"inbox_id": inbox_id,
|
||||
}
|
||||
|
||||
|
||||
def serialize_search_manager(user) -> dict:
|
||||
return {
|
||||
"id": str(user.id) if user.id else None,
|
||||
"name": user.name,
|
||||
"email": user.email,
|
||||
"role_name": user.role.role_name if user.role else None,
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from inbox.models import Inbox
|
||||
from job.job_post.models import JobPosts
|
||||
from role.models import EnumRoles, Roles
|
||||
from search.serializers import (
|
||||
serialize_search_candidate,
|
||||
serialize_search_job,
|
||||
serialize_search_manager,
|
||||
)
|
||||
from users.models import Users
|
||||
from users.permissions import PermissionTag, has_permission
|
||||
|
||||
JOBS_CAP = 4
|
||||
CANDIDATES_CAP = 4
|
||||
MANAGERS_CAP = 3
|
||||
|
||||
|
||||
class Search:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def fetch(self, q, limit, current_user):
|
||||
query = (q or "").strip()
|
||||
granted = current_user.get("permissions") or []
|
||||
jobs = []
|
||||
candidates = []
|
||||
managers = []
|
||||
if query:
|
||||
if has_permission(granted, PermissionTag.JOBS_VIEW):
|
||||
jobs = await self._jobs(query, min(limit, JOBS_CAP))
|
||||
if has_permission(granted, PermissionTag.CANDIDATES_VIEW):
|
||||
candidates = await self._candidates(query, min(limit, CANDIDATES_CAP))
|
||||
managers = await self._managers(query, min(limit, MANAGERS_CAP))
|
||||
data = {"jobs": jobs, "candidates": candidates, "managers": managers}
|
||||
total = len(jobs) + len(candidates) + len(managers)
|
||||
return data, total
|
||||
|
||||
async def _jobs(self, query, cap):
|
||||
like = f"%{query}%"
|
||||
statement = (
|
||||
select(JobPosts)
|
||||
.where(
|
||||
JobPosts.is_deleted == False, # noqa: E712
|
||||
or_(
|
||||
JobPosts.title.ilike(like),
|
||||
JobPosts.location.ilike(like),
|
||||
JobPosts.department.ilike(like),
|
||||
),
|
||||
)
|
||||
.order_by(JobPosts.created_at.desc())
|
||||
.limit(cap)
|
||||
)
|
||||
result = await self.session.execute(statement)
|
||||
return [serialize_search_job(r) for r in result.scalars().all()]
|
||||
|
||||
async def _candidates(self, query, cap):
|
||||
like = f"%{query}%"
|
||||
statement = (
|
||||
select(Users)
|
||||
.join(Roles, Users.role_id == Roles.id)
|
||||
.where(
|
||||
Roles.role_name == EnumRoles.CANDIDATE.value,
|
||||
Users.is_deleted == False, # noqa: E712
|
||||
or_(Users.name.ilike(like), Users.email.ilike(like)),
|
||||
)
|
||||
.order_by(Users.created_at.desc())
|
||||
.limit(cap)
|
||||
)
|
||||
users = list((await self.session.execute(statement)).scalars().all())
|
||||
inbox_by_user = {}
|
||||
if users:
|
||||
inbox_q = (
|
||||
select(Inbox.user_id, Inbox.id)
|
||||
.where(Inbox.user_id.in_([u.id for u in users]))
|
||||
.order_by(Inbox.created_at.desc())
|
||||
)
|
||||
for user_id, inbox_id in (await self.session.execute(inbox_q)).all():
|
||||
inbox_by_user.setdefault(user_id, inbox_id)
|
||||
return [
|
||||
serialize_search_candidate(u.id, u.name, u.email, inbox_by_user.get(u.id))
|
||||
for u in users
|
||||
]
|
||||
|
||||
async def _managers(self, query, cap):
|
||||
like = f"%{query}%"
|
||||
role = await Roles.get_role_by_name(self.session, EnumRoles.HIRING_MANAGER.value)
|
||||
if role is None:
|
||||
return []
|
||||
statement = (
|
||||
select(Users)
|
||||
.options(selectinload(Users.role))
|
||||
.where(
|
||||
Users.role_id == role.id,
|
||||
Users.is_deleted == False, # noqa: E712
|
||||
or_(Users.name.ilike(like), Users.email.ilike(like)),
|
||||
)
|
||||
.order_by(Users.created_at.desc())
|
||||
.limit(cap)
|
||||
)
|
||||
result = await self.session.execute(statement)
|
||||
return [serialize_search_manager(u) for u in result.scalars().all()]
|
||||
|
|
@ -209,3 +209,20 @@ async def delete_user(
|
|||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/managers/fetch")
|
||||
async def fetch_managers(
|
||||
current_user: dict = Depends(
|
||||
require_permission(PermissionTag.JOBS_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False)
|
||||
),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=User(session=session)
|
||||
data,total=await service.get_managers()
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
|
|
|||
|
|
@ -110,6 +110,30 @@ class User:
|
|||
raise HTTPException(status_code=404,detail="User not found")
|
||||
return serialize_user(user)
|
||||
|
||||
async def get_managers(self):
|
||||
"""Hiring-manager directory for Jobs/Candidates callers who do not hold rbac_users.view."""
|
||||
from job.assignment.models import JobAssignments
|
||||
|
||||
role=await Roles.get_role_by_name(self.session,EnumRoles.HIRING_MANAGER.value)
|
||||
if role is None:
|
||||
raise HTTPException(status_code=500,detail="Role hiring_manager is not seeded")
|
||||
rows=await Users.get_users(self.session,top=500,role_id=role.id)
|
||||
counts=await JobAssignments.count_open_reqs_by_users(self.session,[u.id for u in rows])
|
||||
data=[
|
||||
{
|
||||
"id": str(u.id),
|
||||
"name": u.name,
|
||||
"email": u.email,
|
||||
"role_name": role.role_name,
|
||||
"open_reqs": int(counts.get(u.id,0)),
|
||||
"department": None,
|
||||
"title": None,
|
||||
"team_size": None,
|
||||
}
|
||||
for u in rows
|
||||
]
|
||||
return data,len(data)
|
||||
|
||||
async def count_users(self,search=None):
|
||||
return await Users.count_users(self.session,search)
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -23,8 +23,8 @@
|
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-D-apCYSF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
|
||||
<script type="module" crossorigin src="/assets/index-CHbnEeyr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bs8zcrqC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/** Assessments — backend/assessments/app.py. Dual-key: exactly one of inbox_id / manual_upload_candidate_id. */
|
||||
|
||||
export const ASSESSMENT_TYPES = [
|
||||
'Coding Challenge',
|
||||
'Take-home Project',
|
||||
'Cognitive Test',
|
||||
'Personality Assessment',
|
||||
'SQL Test',
|
||||
'Case Study',
|
||||
]
|
||||
|
||||
export const ASSESSMENT_STATUSES = ['pending', 'in_progress', 'completed', 'expired']
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pending: 'Pending',
|
||||
in_progress: 'In Progress',
|
||||
completed: 'Completed',
|
||||
expired: 'Expired',
|
||||
}
|
||||
|
||||
export function list({ assessmentId, inboxId, manualUploadCandidateId, jobPostId, assessmentStatus, top, skip } = {}) {
|
||||
return request('/assessments/fetch', {
|
||||
params: {
|
||||
assessment_id: assessmentId,
|
||||
inbox_id: inboxId,
|
||||
manual_upload_candidate_id: manualUploadCandidateId,
|
||||
job_post_id: jobPostId,
|
||||
assessment_status: assessmentStatus,
|
||||
top,
|
||||
skip,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function counts() {
|
||||
return request('/assessments/counts')
|
||||
}
|
||||
|
||||
export function create(body) {
|
||||
return request('/assessments/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function update(assessmentId, body) {
|
||||
return request('/assessments/update', {
|
||||
method: 'PATCH',
|
||||
params: { assessment_id: assessmentId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function remove(assessmentId) {
|
||||
return request('/assessments/delete', {
|
||||
method: 'DELETE',
|
||||
params: { assessment_id: assessmentId },
|
||||
})
|
||||
}
|
||||
|
||||
export function remind(assessmentId) {
|
||||
return request('/assessments/remind', {
|
||||
method: 'POST',
|
||||
params: { assessment_id: assessmentId },
|
||||
})
|
||||
}
|
||||
|
||||
function durationLabel(minutes) {
|
||||
if (minutes == null) return null
|
||||
if (minutes >= 1440 && minutes % 1440 === 0) {
|
||||
const days = minutes / 1440
|
||||
return days === 1 ? '1 day' : `${days} days`
|
||||
}
|
||||
return `${minutes} min`
|
||||
}
|
||||
|
||||
export function toAssessmentView(row) {
|
||||
const name = row.candidate_name || 'Unknown'
|
||||
return {
|
||||
id: row.id,
|
||||
inboxId: row.inbox_id,
|
||||
manualUploadCandidateId: row.manual_upload_candidate_id,
|
||||
jobPostId: row.job_post_id,
|
||||
candidate: name,
|
||||
jobTitle: row.job_title || '—',
|
||||
type: row.assessment_type,
|
||||
status: STATUS_LABEL[row.assessment_status] ?? row.assessment_status,
|
||||
statusKey: row.assessment_status,
|
||||
score: row.score ?? null,
|
||||
sectionScores: Array.isArray(row.section_scores) ? row.section_scores : [],
|
||||
duration: durationLabel(row.duration_minutes) || '—',
|
||||
durationMinutes: row.duration_minutes,
|
||||
assigned: row.assigned_at ? new Date(row.assigned_at) : null,
|
||||
due: row.due_at ? new Date(row.due_at) : null,
|
||||
completedAt: row.completed_at ? new Date(row.completed_at) : null,
|
||||
remindedAt: row.reminded_at ? new Date(row.reminded_at) : null,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/* ============================================================
|
||||
assignments.js — who owns a requisition, and who owns an application.
|
||||
|
||||
Two parallel tables behind four routes (backend/job/app.py):
|
||||
job_assignments — a recruiter on a JOB POST (jobs.view / jobs.edit)
|
||||
application_assignments — a recruiter on ONE APPLICATION (candidates.view / candidates.edit)
|
||||
|
||||
Rows are valid-time intervals: `valid_to === null` is the assignment in force
|
||||
now, and the fetch routes return only those by default. There is no unassign
|
||||
or reassign route — `insert_assignment` closes the previous open interval and
|
||||
opens a new one, so assigning someone else IS the reassignment.
|
||||
|
||||
The server rejects any user whose role is not `recruiter` with a 422
|
||||
(Assignment._require_recruiter), which is why every picker here is sourced
|
||||
from /tasks/assignees/fetch — the one endpoint that already returns exactly
|
||||
the active recruiter-role users, and needs no rbac_users.view to call.
|
||||
============================================================ */
|
||||
|
||||
/** Current recruiter(s) on one requisition. */
|
||||
export function listJob(jobPostId) {
|
||||
return request('/job/assignments/fetch', { params: { job_post_id: jobPostId } })
|
||||
}
|
||||
|
||||
/** Assign a recruiter to a requisition. Supersedes whoever held it. */
|
||||
export function assignJob({ jobPostId, userId, assignmentRole }) {
|
||||
return request('/job/assignments/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
job_post_id: jobPostId,
|
||||
user_id: userId,
|
||||
assignment_role: assignmentRole || 'primary_recruiter',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Current recruiter(s) on one application. */
|
||||
export function listApplication(inboxId) {
|
||||
return request('/candidate/assignments/fetch', { params: { inbox_id: inboxId } })
|
||||
}
|
||||
|
||||
/** Assign a recruiter to one application. */
|
||||
export function assignApplication({ inboxId, userId, assignmentRole }) {
|
||||
return request('/candidate/assignments/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
inbox_id: inboxId,
|
||||
user_id: userId,
|
||||
assignment_role: assignmentRole || 'primary_recruiter',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The serializers return `user_id` and nothing else about the person, so the
|
||||
* caller resolves names from the assignee list it already holds.
|
||||
*/
|
||||
export function toAssignmentView(row, namesById) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
name: namesById?.get(String(row.user_id)) ?? null,
|
||||
role: row.assignment_role || 'primary_recruiter',
|
||||
jobPostId: row.job_post_id ?? null,
|
||||
inboxId: row.inbox_id ?? null,
|
||||
validFrom: row.valid_from ? new Date(row.valid_from) : null,
|
||||
validTo: row.valid_to ? new Date(row.valid_to) : null,
|
||||
assignedBy: row.assigned_by ?? null,
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
function returns the parsed {data, total, status_code} envelope.
|
||||
============================================================ */
|
||||
|
||||
import { request } from '../lib/apiClient'
|
||||
import { downloadFile, request } from '../lib/apiClient'
|
||||
|
||||
/** Active job posts for pickers. Needs job_board.view OR candidates.view. */
|
||||
export function listJobs() {
|
||||
|
|
@ -257,6 +257,20 @@ export function createNote({ userId, note }) {
|
|||
return request('/notes/create', { method: 'POST', body: { user_id: userId, note } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing note. `created_by` is NOT reassigned server-side, so the
|
||||
* note keeps its original author — editing someone else's note rewrites their
|
||||
* words under their name, which is why the UI only offers this on notes the
|
||||
* signed-in user wrote.
|
||||
*/
|
||||
export function updateNote(noteId, note) {
|
||||
return request('/notes/update', {
|
||||
method: 'PATCH',
|
||||
params: { note_id: noteId },
|
||||
body: { note },
|
||||
})
|
||||
}
|
||||
|
||||
export function createInterview({ inboxId, date, time, type, status }) {
|
||||
return request('/interview/create', {
|
||||
method: 'POST',
|
||||
|
|
@ -278,9 +292,42 @@ export function createFeedback({ inboxId, review, score, note }) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Revise a scorecard. Only the keys passed are written (the service drops
|
||||
* None), and `reviewed_by` is left alone so the revision stays attributed to
|
||||
* whoever originally submitted it.
|
||||
*/
|
||||
export function updateFeedback(feedbackId, { review, score, note } = {}) {
|
||||
const body = {}
|
||||
if (review != null) body.review = review
|
||||
if (score != null) body.score = score
|
||||
if (note != null) body.note = note
|
||||
return request('/feedback/update', {
|
||||
method: 'PATCH',
|
||||
params: { feedback_id: feedbackId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function createActivity({ inboxId, type, status, description }) {
|
||||
return request('/activity/create', {
|
||||
method: 'POST',
|
||||
body: { inbox_id: inboxId, activity_type: type, activity_status: status, description },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated attachment download. Never send a filesystem path — the server
|
||||
* resolves by owning record + index. `inboxId` is the `inbox` table PK (int),
|
||||
* not `inbox_messages.id`.
|
||||
*/
|
||||
export function downloadDocument({ inboxId, manualUploadCandidateId, index = 0, filename } = {}) {
|
||||
return downloadFile('/documents/download', {
|
||||
params: {
|
||||
inbox_id: inboxId,
|
||||
manual_upload_candidate_id: manualUploadCandidateId,
|
||||
index,
|
||||
},
|
||||
filename,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/* ============================================================
|
||||
costs.js — hiring costs, backend/job/app.py `/job/costs/*` (jobs.view / jobs.edit).
|
||||
|
||||
This is the ledger behind cost-per-hire. `/analytics/kpis/fetch` already
|
||||
returns a computed `cost_per_hire` derived from these rows; this endpoint is
|
||||
the breakdown underneath it, which is what the Reports screen needs to show
|
||||
spend by category.
|
||||
============================================================ */
|
||||
|
||||
export function list({ jobPostId, fromDate, toDate, top, skip } = {}) {
|
||||
return request('/job/costs/fetch', {
|
||||
params: {
|
||||
job_post_id: jobPostId,
|
||||
from_date: fromDate,
|
||||
to_date: toDate,
|
||||
top,
|
||||
skip,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function create(body) {
|
||||
return request('/job/costs/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function toCostView(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
jobPostId: row.job_post_id,
|
||||
type: row.cost_type,
|
||||
amount: Number(row.amount ?? 0),
|
||||
currency: row.currency || 'USD',
|
||||
description: row.description || null,
|
||||
incurredAt: row.incurred_at ? new Date(row.incurred_at) : null,
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum by cost_type — the one aggregation Reports needs and the API does not do. */
|
||||
export function totalsByType(rows) {
|
||||
const out = new Map()
|
||||
for (const row of rows) {
|
||||
out.set(row.type, (out.get(row.type) ?? 0) + row.amount)
|
||||
}
|
||||
return [...out.entries()]
|
||||
.map(([type, amount]) => ({ type, amount }))
|
||||
.sort((a, b) => b.amount - a.amount)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/** Interview scorecard templates — backend/job/app.py `/feedback/templates/*`. */
|
||||
|
||||
export function listTemplates() {
|
||||
return request('/feedback/templates/fetch')
|
||||
}
|
||||
|
||||
export function createTemplate(body) {
|
||||
return request('/feedback/templates/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateTemplate(templateId, body) {
|
||||
return request('/feedback/templates/update', {
|
||||
method: 'PATCH',
|
||||
params: { template_id: templateId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTemplate(templateId) {
|
||||
return request('/feedback/templates/delete', {
|
||||
method: 'DELETE',
|
||||
params: { template_id: templateId },
|
||||
})
|
||||
}
|
||||
|
||||
export function toTemplateView(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
department: row.department || 'All',
|
||||
criteria: Array.isArray(row.criteria) ? row.criteria : [],
|
||||
isActive: row.is_active !== false,
|
||||
}
|
||||
}
|
||||
|
|
@ -73,3 +73,61 @@ export function assignJobPost(recordId, jobPostId) {
|
|||
export function rematch(recordId) {
|
||||
return request(`/inbox/${recordId}/match`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Tab badges — `{all, unread, imported, processed, rejected, duplicates, assigned, unassigned}`. */
|
||||
export function counts() {
|
||||
return request('/inbox/counts')
|
||||
}
|
||||
|
||||
/**
|
||||
* The counts object itself, unwrapped.
|
||||
*
|
||||
* Every consumer must go through this. The Inbox tabs and the sidebar badge
|
||||
* share one React Query key (qk.mailbox.counts), and React Query caches on the
|
||||
* key alone — so two queryFns returning different shapes overwrite each other.
|
||||
* That is precisely what happened: the badge's queryFn returned a number, the
|
||||
* tabs' returned the object, and whichever ran last defined the cache. When the
|
||||
* object won, the badge tried to render it as a child and React threw #31.
|
||||
* A consumer that wants one field selects it with `select`, never by narrowing
|
||||
* the fetcher.
|
||||
*/
|
||||
export async function fetchCounts() {
|
||||
const res = await counts()
|
||||
return res?.data ?? {}
|
||||
}
|
||||
|
||||
/** `processing_state` is `unread|imported|processed|rejected`. Requires inbox.edit. */
|
||||
export function setProcessingState(recordId, processingState) {
|
||||
return request(`/inbox/${recordId}/processing-state`, {
|
||||
method: 'PATCH',
|
||||
body: { processing_state: processingState },
|
||||
})
|
||||
}
|
||||
|
||||
export function setDuplicate(recordId, isDuplicate) {
|
||||
return request(`/inbox/${recordId}/duplicate`, {
|
||||
method: 'PATCH',
|
||||
body: { is_duplicate: isDuplicate },
|
||||
})
|
||||
}
|
||||
|
||||
export function sendEmail({ to, subject, body, contentType = 'html', inboxId } = {}) {
|
||||
return request('/email/send', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
content_type: contentType,
|
||||
inbox_id: inboxId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Reply sends a new message with a `Re:` subject — no thread headers. */
|
||||
export function replyEmail({ recordId, body } = {}) {
|
||||
return request('/email/reply', {
|
||||
method: 'POST',
|
||||
body: { record_id: recordId, body },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,41 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/**
|
||||
* Interviews — backend/job/app.py `/interview/*`.
|
||||
* Range mode (from_date / to_date / status / top) is additive; per-inbox
|
||||
* fetch still works when inbox_id is set.
|
||||
*/
|
||||
/* ============================================================
|
||||
interviews.js — backend/job/app.py `/interview/*`.
|
||||
|
||||
export function listRange({ fromDate, toDate, status, top, skip } = {}) {
|
||||
THREE READ MODES on one endpoint, selected by which params are present
|
||||
(backend/job/app.py::fetch_interview):
|
||||
interview_id -> one row, bare object
|
||||
inbox_id -> every interview on one application, list
|
||||
range -> from_date / to_date / status / top
|
||||
|
||||
Range mode only engages when at least ONE of from_date, to_date, status or
|
||||
top is set. With none of them the route raises 400 "interview_id or inbox_id
|
||||
is required" — so `list()` always sends `top`, and the screens never call it
|
||||
bare.
|
||||
|
||||
Permissions are candidates.*, NOT interviews.* — the eight interviews.* tags
|
||||
exist in the catalogue but no route reads them. A user holding only
|
||||
interviews.view gets a 403 here.
|
||||
============================================================ */
|
||||
|
||||
/** Status vocabulary. `interview_status` is a free-text column, so this file is
|
||||
the only place the spelling is decided; writes and filters share it. */
|
||||
export const INTERVIEW_STATUSES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
||||
|
||||
/** Round vocabulary — same story: free text, pinned here. */
|
||||
export const INTERVIEW_TYPES = [
|
||||
'Phone Screen',
|
||||
'Technical',
|
||||
'System Design',
|
||||
'Onsite Loop',
|
||||
'Hiring Manager',
|
||||
'Culture Fit',
|
||||
'Final Round',
|
||||
]
|
||||
|
||||
/** Range read. `top` is always sent so the route takes the range branch. */
|
||||
export function listRange({ fromDate, toDate, status, top = 200, skip } = {}) {
|
||||
return request('/interview/fetch', {
|
||||
params: {
|
||||
from_date: fromDate,
|
||||
|
|
@ -21,3 +50,66 @@ export function listRange({ fromDate, toDate, status, top, skip } = {}) {
|
|||
export function listByInbox(inboxId) {
|
||||
return request('/interview/fetch', { params: { inbox_id: inboxId } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule one interview against an APPLICATION (inbox.id), not a candidate:
|
||||
* `inbox_id` is the only link the table has, so a candidate with no inbox row
|
||||
* (a manual upload) cannot be scheduled through this endpoint at all.
|
||||
*
|
||||
* interview_date and interview_time are both `datetime` columns, so the same
|
||||
* instant goes to each rather than inventing a second one — the same rule the
|
||||
* candidate profile's Interview tab already follows.
|
||||
*/
|
||||
export function create({ inboxId, instant, type, status }) {
|
||||
return request('/interview/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
inbox_id: inboxId,
|
||||
interview_date: instant,
|
||||
interview_time: instant,
|
||||
interview_type: type,
|
||||
interview_status: status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Partial update. Only the keys present are written (exclude_unset server-side). */
|
||||
export function update(interviewId, { instant, type, status } = {}) {
|
||||
const body = {}
|
||||
if (instant != null) {
|
||||
body.interview_date = instant
|
||||
body.interview_time = instant
|
||||
}
|
||||
if (type != null) body.interview_type = type
|
||||
if (status != null) body.interview_status = status
|
||||
return request('/interview/update', {
|
||||
method: 'PATCH',
|
||||
params: { interview_id: interviewId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* API row -> what the Interviews table, the Calendar grid and the Up Next rail
|
||||
* render.
|
||||
*
|
||||
* serialize_interview returns seven fields and the `interviews` table has no
|
||||
* more columns than that, so five things the prototype showed have no source:
|
||||
* meeting mode (video / on-site / phone), duration, interviewer list, the
|
||||
* feedback verdict and a numeric score. They are absent here rather than
|
||||
* defaulted, and the screens drop those columns — the same rule Jobs and
|
||||
* Candidates already follow. `job_title` is likewise absent; the caller
|
||||
* hydrates it from the application row when it has one.
|
||||
*/
|
||||
export function toInterviewView(row) {
|
||||
const whenRaw = row.interview_date || row.interview_time
|
||||
const when = whenRaw ? new Date(whenRaw) : null
|
||||
return {
|
||||
id: row.id,
|
||||
inboxId: row.inbox_id,
|
||||
candidate: row.candidate_name || 'Unknown candidate',
|
||||
type: row.interview_type || 'Interview',
|
||||
status: row.interview_status || 'Scheduled',
|
||||
when: when && !Number.isNaN(when.getTime()) ? when : null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,3 +34,19 @@ export function create(payload) {
|
|||
export function listChannels() {
|
||||
return request('/job/buffer/channels')
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform aliases the backend can resolve — GET /jobs/alias.
|
||||
*
|
||||
* A flat list of strings (SocialPlatform.list_aliases returns `r.alias`), not
|
||||
* objects: there is no connection state and no cost tier on the wire. It is the
|
||||
* vocabulary `platform` is matched against when a post does not name a
|
||||
* channel_id outright, which is why the Job Board shows an alias with no
|
||||
* channel behind it as "Available" rather than "Connected".
|
||||
*
|
||||
* Unauthenticated server-side — the only route in the job module without a
|
||||
* permission dependency.
|
||||
*/
|
||||
export function listAliases() {
|
||||
return request('/jobs/alias')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ export function toJobView(row) {
|
|||
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
closedAt: row.closed_at ? new Date(row.closed_at) : null,
|
||||
requisitionStatus: row.requisition_status,
|
||||
experienceMin: row.experience_min,
|
||||
experienceMax: row.experience_max,
|
||||
experience: experienceLabel(row.experience_min, row.experience_max),
|
||||
salary: row.salary,
|
||||
skills: row.requirements ?? [],
|
||||
|
|
@ -59,3 +62,30 @@ export function toJobView(row) {
|
|||
description: row.description,
|
||||
}
|
||||
}
|
||||
|
||||
const LABEL_TO_STATUS = { Open: 'open', Closed: 'closed', 'On Hold': 'on_hold' }
|
||||
|
||||
export function update(jobPostId, body) {
|
||||
return request('/jobs/update', {
|
||||
method: 'PATCH',
|
||||
params: { job_post_id: jobPostId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function remove(jobPostId) {
|
||||
return request('/jobs/delete', {
|
||||
method: 'DELETE',
|
||||
params: { job_post_id: jobPostId },
|
||||
})
|
||||
}
|
||||
|
||||
/** `status` is the Jobs UI label (Open / Closed / On Hold) or a raw requisition_status. */
|
||||
export function setStatus(jobPostId, status) {
|
||||
const requisition_status = LABEL_TO_STATUS[status] ?? status
|
||||
return request('/jobs/status', {
|
||||
method: 'PATCH',
|
||||
params: { job_post_id: jobPostId },
|
||||
body: { requisition_status },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/** In-app notifications — backend/notifications/app.py. Scoped to the caller; no RBAC tag. */
|
||||
|
||||
const KIND_META = {
|
||||
application: { icon: 'user-plus', color: 'i-green' },
|
||||
interview: { icon: 'calendar', color: 'i-blue' },
|
||||
offer: { icon: 'check', color: 'i-teal' },
|
||||
assessment: { icon: 'star', color: 'i-amber' },
|
||||
approval: { icon: 'file', color: 'i-indigo' },
|
||||
message: { icon: 'message', color: 'i-purple' },
|
||||
system: { icon: 'info', color: 'i-gray' },
|
||||
}
|
||||
|
||||
export function list({ unreadOnly, top, skip } = {}) {
|
||||
return request('/notifications/fetch', {
|
||||
params: { unread_only: unreadOnly, top, skip },
|
||||
})
|
||||
}
|
||||
|
||||
export function markRead(recordId) {
|
||||
return request(`/notifications/${recordId}/read`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function markAllRead() {
|
||||
return request('/notifications/read-all', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function remove(recordId) {
|
||||
return request('/notifications/delete', {
|
||||
method: 'DELETE',
|
||||
params: { record_id: recordId },
|
||||
})
|
||||
}
|
||||
|
||||
function relTime(iso) {
|
||||
if (!iso) return ''
|
||||
const then = new Date(iso)
|
||||
if (Number.isNaN(then.getTime())) return ''
|
||||
const mins = Math.max(0, Math.round((Date.now() - then.getTime()) / 60000))
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
if (mins < 1440) return `${Math.floor(mins / 60)}h ago`
|
||||
return `${Math.floor(mins / 1440)}d ago`
|
||||
}
|
||||
|
||||
export function toNotificationView(row) {
|
||||
const meta = KIND_META[row.kind] || KIND_META.system
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
title: row.title,
|
||||
text: row.body || '',
|
||||
linkPath: row.link_path || null,
|
||||
inboxId: row.inbox_id,
|
||||
jobPostId: row.job_post_id,
|
||||
unread: !row.is_read,
|
||||
time: relTime(row.created_at),
|
||||
createdAt: row.created_at,
|
||||
icon: meta.icon,
|
||||
color: meta.color,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,43 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/**
|
||||
* Offers — backend/offer/app.py.
|
||||
* Permissioned with OFFERS_VIEW / OFFERS_CREATE / OFFERS_EDIT / OFFERS_APPROVE.
|
||||
*/
|
||||
/* ============================================================
|
||||
offers.js — backend/offer/app.py.
|
||||
|
||||
Permissioned OFFERS_VIEW / OFFERS_CREATE / OFFERS_EDIT / OFFERS_APPROVE, so a
|
||||
recruiter who can read offers still cannot issue one.
|
||||
|
||||
serialize_offer returns FOREIGN KEYS ONLY — inbox_id, job_post_id,
|
||||
candidate_user_id. There is no candidate name, job title, department or
|
||||
recruiter on the wire, so the Offers screen hydrates those from two reads it
|
||||
already makes (`/pipeline/candidates/fetch` for the person, `/job/fetch?ids=`
|
||||
for the role) rather than issuing one request per row.
|
||||
============================================================ */
|
||||
|
||||
/** `status` is a free-text column defaulting to "draft"; the vocabulary is decided here. */
|
||||
export const OFFER_STATUSES = ['draft', 'sent', 'negotiating', 'accepted', 'declined', 'expired']
|
||||
|
||||
export const OFFER_STATUS_LABEL = {
|
||||
draft: 'Draft',
|
||||
sent: 'Sent',
|
||||
negotiating: 'Negotiating',
|
||||
accepted: 'Accepted',
|
||||
declined: 'Declined',
|
||||
expired: 'Expired',
|
||||
}
|
||||
|
||||
/** Label -> wire value, for the filter select. */
|
||||
export const OFFER_STATUS_VALUE = Object.fromEntries(
|
||||
Object.entries(OFFER_STATUS_LABEL).map(([value, label]) => [label, value]),
|
||||
)
|
||||
|
||||
const STATUS_CLASS = {
|
||||
accepted: 'b-green',
|
||||
sent: 'b-blue',
|
||||
negotiating: 'b-amber',
|
||||
declined: 'b-red',
|
||||
expired: 'b-gray',
|
||||
draft: 'b-gray',
|
||||
}
|
||||
|
||||
export function list({ offerId, status, inboxId, top, skip } = {}) {
|
||||
return request('/offers/fetch', {
|
||||
|
|
@ -17,6 +51,12 @@ export function list({ offerId, status, inboxId, top, skip } = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a draft. All three links are REQUIRED server-side — `inbox_id` (422 if
|
||||
* blank), `job_post_id` and `candidate_user_id` (422 if not parseable as UUIDs)
|
||||
* — which is why the Create Offer picker is sourced from the pipeline board:
|
||||
* that payload is the only one carrying all three on a single row.
|
||||
*/
|
||||
export function create(body) {
|
||||
return request('/offers/create', { method: 'POST', body })
|
||||
}
|
||||
|
|
@ -29,6 +69,11 @@ export function update(offerId, body) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue — stamps `issued_by` + `sent_at` and moves draft -> sent, writing a row
|
||||
* to offer_status_history. Re-issuing an already-sent offer is allowed and
|
||||
* re-stamps sent_at, which is what "Resend" means here.
|
||||
*/
|
||||
export function issue(offerId, body = {}) {
|
||||
return request('/offers/issue', {
|
||||
method: 'POST',
|
||||
|
|
@ -36,3 +81,54 @@ export function issue(offerId, body = {}) {
|
|||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `{equity_units, equity_instrument}` -> the one string the table shows.
|
||||
* Returns null rather than "0 RSU" when nothing was agreed, so the cell reads
|
||||
* as "not offered" instead of "offered nothing".
|
||||
*/
|
||||
function equityLabel(units, instrument) {
|
||||
if (units == null || units === 0) return null
|
||||
const n = Number(units)
|
||||
const pretty = n >= 1000 && n % 1000 === 0 ? `${n / 1000}k` : String(n)
|
||||
return `${pretty} ${instrument || 'RSU'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* API row -> the shape Offers.jsx renders. `people` and `jobTitles` are the
|
||||
* hydration maps the screen builds once per page; both are optional so this
|
||||
* stays usable from a context that has neither.
|
||||
*/
|
||||
export function toOfferView(row, { people, jobTitles } = {}) {
|
||||
const person = people?.get(String(row.candidate_user_id)) ?? null
|
||||
const jobTitle = jobTitles?.get(String(row.job_post_id)) ?? null
|
||||
const status = row.status || 'draft'
|
||||
const bonusPct = row.annual_bonus_pct
|
||||
return {
|
||||
id: row.id,
|
||||
inboxId: row.inbox_id,
|
||||
jobPostId: row.job_post_id,
|
||||
candidateUserId: row.candidate_user_id,
|
||||
candidate: person?.name || 'Unknown candidate',
|
||||
email: person?.email ?? null,
|
||||
jobTitle: jobTitle || '—',
|
||||
status,
|
||||
statusLabel: OFFER_STATUS_LABEL[status] ?? status,
|
||||
statusClass: STATUS_CLASS[status] ?? 'b-gray',
|
||||
base: row.base_salary ?? null,
|
||||
currency: row.currency || 'USD',
|
||||
salaryPeriod: row.salary_period || 'year',
|
||||
signingBonus: row.signing_bonus ?? null,
|
||||
bonusPct: bonusPct ?? null,
|
||||
bonus: bonusPct != null ? `${bonusPct}%` : null,
|
||||
equity: equityLabel(row.equity_units, row.equity_instrument),
|
||||
equityUnits: row.equity_units ?? null,
|
||||
equityInstrument: row.equity_instrument || null,
|
||||
startDate: row.start_date ? new Date(row.start_date) : null,
|
||||
expiry: row.expiry_date ? new Date(row.expiry_date) : null,
|
||||
sent: row.sent_at ? new Date(row.sent_at) : null,
|
||||
respondedAt: row.responded_at ? new Date(row.responded_at) : null,
|
||||
closedAt: row.closed_at ? new Date(row.closed_at) : null,
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/** Organisation settings — backend/org_settings/app.py. Batch upsert; one Save per tab. */
|
||||
|
||||
export function list({ category } = {}) {
|
||||
return request('/org-settings/fetch', { params: { category } })
|
||||
}
|
||||
|
||||
export function update(settings) {
|
||||
return request('/org-settings/update', { method: 'PUT', body: { settings } })
|
||||
}
|
||||
|
||||
/** Flatten `{data:[{key,value,category}]}` into a key → value map. */
|
||||
export function toMap(res) {
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
const map = {}
|
||||
for (const row of rows) {
|
||||
if (row?.key) map[row.key] = row.value
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/** Saved searches — backend/saved_search/app.py. Scoped to the caller. `count` is always null. */
|
||||
|
||||
export function list({ entity } = {}) {
|
||||
return request('/saved-searches/fetch', { params: { entity } })
|
||||
}
|
||||
|
||||
export function create(body) {
|
||||
return request('/saved-searches/create', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function update(recordId, body) {
|
||||
return request('/saved-searches/update', {
|
||||
method: 'PATCH',
|
||||
params: { record_id: recordId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function remove(recordId) {
|
||||
return request('/saved-searches/delete', {
|
||||
method: 'DELETE',
|
||||
params: { record_id: recordId },
|
||||
})
|
||||
}
|
||||
|
||||
export function toSavedSearchView(row) {
|
||||
const filters = row.filters && typeof row.filters === 'object' ? row.filters : {}
|
||||
const summary = typeof filters.summary === 'string'
|
||||
? filters.summary
|
||||
: Object.keys(filters).length
|
||||
? Object.entries(filters).map(([k, v]) => `${k}: ${v}`).join(' · ')
|
||||
: 'No filters stored'
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
entity: row.entity,
|
||||
filters,
|
||||
summary,
|
||||
count: row.count,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { request } from '../lib/apiClient'
|
||||
|
||||
/** Unified global search — backend/search/app.py. Buckets capped 4/4/3 server-side. */
|
||||
|
||||
export function fetch({ q, limit } = {}) {
|
||||
return request('/search/fetch', { params: { q, limit } })
|
||||
}
|
||||
|
|
@ -32,3 +32,24 @@ export function removeRole(recordId) {
|
|||
export function remove(recordId) {
|
||||
return request('/users/delete', { method: 'DELETE', params: { record_id: recordId } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Hiring-manager directory — GET /managers/fetch.
|
||||
* `department` / `title` / `team_size` are always null until those columns exist.
|
||||
*/
|
||||
export function listManagers() {
|
||||
return request('/managers/fetch')
|
||||
}
|
||||
|
||||
export function toManagerView(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name || row.email || 'Unknown',
|
||||
email: row.email || '',
|
||||
roleName: row.role_name || null,
|
||||
openReqs: row.open_reqs ?? 0,
|
||||
department: row.department ?? null,
|
||||
title: row.title ?? null,
|
||||
teamSize: row.team_size ?? null,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,40 @@
|
|||
/* Global search — App.search from js/app.js:171-191. Same sources and the same
|
||||
4/4/3 caps. The inline onclick="App.searchGo(...)" strings become navigate()
|
||||
calls, and the setTimeout(cb, 120) hack that waited for the old router to
|
||||
swap innerHTML is gone: the target screen reads `state.open` instead. */
|
||||
/* Global search — GET /search/fetch. Deep-links use live UUIDs so the target
|
||||
screen can resolve the row. Debounced so we don't fire on every keystroke. */
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
|
||||
import { Avatar, Icon } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import * as searchApi from '../api/search'
|
||||
|
||||
export default function GlobalSearch({ inputRef }) {
|
||||
const navigate = useNavigate()
|
||||
const [q, setQ] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const boxRef = useRef(null)
|
||||
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
|
||||
const { data: managers = [] } = useQuery(seedQuery('managers'))
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(q.trim()), 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [q])
|
||||
|
||||
const results = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
if (!term) return null
|
||||
return {
|
||||
jobs: jobs.filter((j) => (j.title + j.id + j.department).toLowerCase().includes(term)).slice(0, 4),
|
||||
candidates: candidates.filter((c) => (c.name + c.email + c.jobTitle).toLowerCase().includes(term)).slice(0, 4),
|
||||
managers: managers.filter((m) => m.name.toLowerCase().includes(term)).slice(0, 3),
|
||||
}
|
||||
}, [q, jobs, candidates, managers])
|
||||
const query = useQuery({
|
||||
queryKey: qk.search.query({ q: debounced }),
|
||||
queryFn: async () => {
|
||||
const res = await searchApi.fetch({ q: debounced, limit: 4 })
|
||||
return res?.data ?? { jobs: [], candidates: [], managers: [] }
|
||||
},
|
||||
enabled: debounced.length > 0,
|
||||
})
|
||||
|
||||
const results = debounced ? (query.data ?? { jobs: [], candidates: [], managers: [] }) : null
|
||||
const jobs = results?.jobs ?? []
|
||||
const candidates = results?.candidates ?? []
|
||||
const managers = results?.managers ?? []
|
||||
const empty = results && !query.isPending && !jobs.length && !candidates.length && !managers.length
|
||||
|
||||
function go(path, state) {
|
||||
setQ('')
|
||||
|
|
@ -35,8 +42,6 @@ export default function GlobalSearch({ inputRef }) {
|
|||
navigate(path, { state })
|
||||
}
|
||||
|
||||
const empty = results && !results.jobs.length && !results.candidates.length && !results.managers.length
|
||||
|
||||
return (
|
||||
<div className="topbar-search" onClick={(e) => e.stopPropagation()}>
|
||||
<Icon name="search" />
|
||||
|
|
@ -55,42 +60,48 @@ export default function GlobalSearch({ inputRef }) {
|
|||
<div className={`search-results${open && results ? ' open' : ''}`} ref={boxRef}>
|
||||
{results && (
|
||||
<>
|
||||
{results.jobs.length > 0 && <div className="search-group-label">Jobs</div>}
|
||||
{results.jobs.map((j) => (
|
||||
<div key={j.id} className="search-item" onClick={() => go('/jobs', { openJob: j.id })}>
|
||||
<span className="kpi-icn i-indigo" style={{ width: 32, height: 32, borderRadius: 8 }}>
|
||||
<Icon name="briefcase" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="si-title">{j.title}</div>
|
||||
<div className="si-sub">{j.id} · {j.department}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{query.isPending && <div className="search-empty">Searching…</div>}
|
||||
{query.isError && <div className="search-empty">Couldn’t search. Try again.</div>}
|
||||
{!query.isPending && !query.isError && (
|
||||
<>
|
||||
{jobs.length > 0 && <div className="search-group-label">Jobs</div>}
|
||||
{jobs.map((j) => (
|
||||
<div key={j.id} className="search-item" onClick={() => go('/jobs', { openJob: j.id })}>
|
||||
<span className="kpi-icn i-indigo" style={{ width: 32, height: 32, borderRadius: 8 }}>
|
||||
<Icon name="briefcase" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="si-title">{j.title}</div>
|
||||
<div className="si-sub">{[j.department, j.location].filter(Boolean).join(' · ') || 'Job'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{results.candidates.length > 0 && <div className="search-group-label">Candidates</div>}
|
||||
{results.candidates.map((c) => (
|
||||
<div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
|
||||
<Avatar name={c.name} initials={c.initials} color={c.color} />
|
||||
<div>
|
||||
<div className="si-title">{c.name}</div>
|
||||
<div className="si-sub">{c.jobTitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{candidates.length > 0 && <div className="search-group-label">Candidates</div>}
|
||||
{candidates.map((c) => (
|
||||
<div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
|
||||
<Avatar name={c.name} />
|
||||
<div>
|
||||
<div className="si-title">{c.name}</div>
|
||||
<div className="si-sub">{c.email || 'Candidate'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{results.managers.length > 0 && <div className="search-group-label">Hiring Managers</div>}
|
||||
{results.managers.map((m) => (
|
||||
<div key={m.id} className="search-item" onClick={() => go('/managers', { openManager: m.id })}>
|
||||
<Avatar name={m.name} initials={m.initials} color={m.color} />
|
||||
<div>
|
||||
<div className="si-title">{m.name}</div>
|
||||
<div className="si-sub">{m.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{managers.length > 0 && <div className="search-group-label">Hiring Managers</div>}
|
||||
{managers.map((m) => (
|
||||
<div key={m.id} className="search-item" onClick={() => go('/managers', { openManager: m.id })}>
|
||||
<Avatar name={m.name} />
|
||||
<div>
|
||||
<div className="si-title">{m.name}</div>
|
||||
<div className="si-sub">{m.role_name || m.email || 'Hiring manager'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{empty && <div className="search-empty">No results for “{q}”</div>}
|
||||
{empty && <div className="search-empty">No results for “{q}”</div>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Dropdown, { DropdownGroup } from '../ui/Dropdown'
|
||||
import GlobalSearch from './GlobalSearch'
|
||||
import { Avatar, Icon } from '../ui/primitives'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useTheme } from '../theme/ThemeProvider'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as notificationsApi from '../api/notifications'
|
||||
|
||||
function initialsFromName(name) {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
|
||||
|
|
@ -16,23 +19,48 @@ function initialsFromName(name) {
|
|||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
const res = await notificationsApi.list({ top: 6 })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return {
|
||||
items: rows.map(notificationsApi.toNotificationView),
|
||||
unread: res?.unread ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Topbar({ onOpenNav, searchRef }) {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { user, signOut } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const notifQuery = useQuery({ queryKey: qk.notifications.list({ top: 6 }), queryFn: fetchNotifications })
|
||||
const notifications = notifQuery.data?.items ?? []
|
||||
const unread = notifQuery.data?.unread ?? 0
|
||||
|
||||
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
|
||||
const { data: messages = [] } = useQuery(seedQuery('messages'))
|
||||
const updateNotifications = useSeedMutation('notifications')
|
||||
|
||||
// App.hydrateProfile's DOM sweep is gone — the session is read directly.
|
||||
const markAll = useMutation({
|
||||
mutationFn: () => notificationsApi.markAllRead(),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not mark all as read.'), 'error'),
|
||||
onSuccess: () => toast('All notifications marked as read', 'success'),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.notifications.all() }),
|
||||
})
|
||||
|
||||
const markOne = useMutation({
|
||||
mutationFn: (id) => notificationsApi.markRead(id),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.notifications.all() }),
|
||||
})
|
||||
|
||||
const name = user?.name || 'Guest'
|
||||
const email = user?.email || ''
|
||||
const role = user?.role_name || user?.role || 'Member'
|
||||
|
||||
function markAllRead() {
|
||||
updateNotifications((ns) => ns.map((n) => ({ ...n, unread: false })))
|
||||
toast('All notifications marked as read', 'success')
|
||||
function openNotif(n) {
|
||||
if (n.unread) markOne.mutate(n.id)
|
||||
if (n.linkPath) navigate(n.linkPath)
|
||||
else navigate('/notifications')
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -61,7 +89,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
|
|||
trigger={({ toggle }) => (
|
||||
<button className="icon-btn" onClick={toggle} title="Messages" aria-label="Messages">
|
||||
<Icon name="message" />
|
||||
<span className="dot dot-blue" />
|
||||
{messages.some((m) => m.unread) && <span className="dot dot-blue" />}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
|
|
@ -79,7 +107,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
|
|||
))}
|
||||
</div>
|
||||
<div className="dropdown-foot">
|
||||
<Link to="/notifications">Open inbox</Link>
|
||||
<Link to="/inbox">Open inbox</Link>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
|
|
@ -88,21 +116,37 @@ export default function Topbar({ onOpenNav, searchRef }) {
|
|||
trigger={({ toggle }) => (
|
||||
<button className="icon-btn" onClick={toggle} title="Notifications" aria-label="Notifications">
|
||||
<Icon name="bell" />
|
||||
<span className="dot dot-red" />
|
||||
{unread > 0 && <span className="dot dot-red" />}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="dropdown-head">
|
||||
Notifications
|
||||
<button className="link-btn" onClick={markAllRead}>Mark all read</button>
|
||||
<button className="link-btn" disabled={markAll.isPending} onClick={() => markAll.mutate()}>
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
<div className="dd-scroll">
|
||||
{notifications.slice(0, 6).map((n) => (
|
||||
<div key={n.id ?? n.title} className={`notif-row${n.unread ? ' unread' : ''}`}>
|
||||
{notifQuery.isPending && <div className="notif-row"><div className="notif-text">Loading…</div></div>}
|
||||
{notifQuery.isError && (
|
||||
<div className="notif-row">
|
||||
<div className="notif-text">{friendlyAuthError(notifQuery.error, 'Could not load notifications.')}</div>
|
||||
</div>
|
||||
)}
|
||||
{notifQuery.isSuccess && notifications.length === 0 && (
|
||||
<div className="notif-row"><div className="notif-text">No notifications yet.</div></div>
|
||||
)}
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`notif-row${n.unread ? ' unread' : ''}`}
|
||||
onClick={() => openNotif(n)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
|
||||
<div className="notif-body">
|
||||
<div className="notif-title">{n.title}</div>
|
||||
<div className="notif-text">{n.text}</div>
|
||||
{n.text && <div className="notif-text">{n.text}</div>}
|
||||
<div className="notif-time">{n.time}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const ROUTES = [
|
|||
{ path: 'interviews', title: 'Interviews', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
|
||||
{ path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' },
|
||||
{ path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' },
|
||||
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: null },
|
||||
{ path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' },
|
||||
{ path: 'calendar', title: 'Calendar', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' },
|
||||
|
||||
// --- Insights ---
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as notificationsApi from '../api/notifications'
|
||||
|
||||
const SIDEBAR_KEY = 'tf-sidebar'
|
||||
|
||||
|
|
@ -87,9 +88,6 @@ export function useHotkeys({ onEscape }) {
|
|||
* exists to drive to zero.
|
||||
*/
|
||||
export function useBadges() {
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
|
||||
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
|
||||
const { data: matchingTotal = 0 } = useQuery({
|
||||
queryKey: qk.mailbox.assignments({ assigned: false }),
|
||||
queryFn: async () => {
|
||||
|
|
@ -97,8 +95,6 @@ export function useBadges() {
|
|||
return res?.total ?? 0
|
||||
},
|
||||
})
|
||||
// Live: open tasks across the team (same semantics the seed badge had). A
|
||||
// 403 for users without tasks.view resolves to 0 rather than an error badge.
|
||||
const { data: tasksTotal = 0 } = useQuery({
|
||||
queryKey: qk.tasks.list({ badge: 'open' }),
|
||||
queryFn: async () => {
|
||||
|
|
@ -110,12 +106,41 @@ export function useBadges() {
|
|||
}
|
||||
},
|
||||
})
|
||||
const { data: jobsOpen = 0 } = useQuery({
|
||||
queryKey: qk.jobs.list({ badge: 'open' }),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await jobsApi.list({ requisitionStatus: 'open', top: 1 })
|
||||
return res?.total ?? 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
},
|
||||
})
|
||||
// Shares its cache entry with the Inbox tabs, so it must fetch the same shape
|
||||
// and narrow with `select` — see inboxApi.fetchCounts.
|
||||
const { data: inboxUnread = 0 } = useQuery({
|
||||
queryKey: qk.mailbox.counts(),
|
||||
queryFn: inboxApi.fetchCounts,
|
||||
select: (data) => data?.unread ?? 0,
|
||||
})
|
||||
const { data: notifUnread = 0 } = useQuery({
|
||||
queryKey: qk.notifications.list({ badge: 'unread' }),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const res = await notificationsApi.list({ unreadOnly: true, top: 1 })
|
||||
return res?.unread ?? 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
jobs: jobs.filter((j) => j.status === 'Open').length,
|
||||
notifications: notifications.filter((n) => n.unread).length,
|
||||
jobs: jobsOpen,
|
||||
notifications: notifUnread,
|
||||
tasks: tasksTotal,
|
||||
inbox: inbox.filter((i) => i.unread).length,
|
||||
inbox: inboxUnread,
|
||||
matching: matchingTotal,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,95 @@ export async function request(
|
|||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated file download. Document routes return a binary body, so they
|
||||
* cannot go through `request()` (that always JSON-parses). Same bearer /
|
||||
* refresh behaviour as `request`; 404s stay 404 (the download route never 403s).
|
||||
*/
|
||||
export async function downloadFile(path, { params, auth = true, filename } = {}) {
|
||||
if (auth && isExpiring()) {
|
||||
try {
|
||||
await refreshSession()
|
||||
} catch {
|
||||
/* fall through — the 401 path below makes the final call */
|
||||
}
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const headers = { Accept: '*/*' }
|
||||
const bearer = auth ? getAccessToken() : null
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
||||
return fetch(buildUrl(path, params), { method: 'GET', headers })
|
||||
}
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await send()
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') throw err
|
||||
throw new ApiError('Unable to reach the server. Check your connection.', 0, null)
|
||||
}
|
||||
|
||||
if (res.status === 401 && auth) {
|
||||
try {
|
||||
await refreshSession()
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) onSessionExpired()
|
||||
throw err
|
||||
}
|
||||
res = await send()
|
||||
if (res.status === 401) {
|
||||
onSessionExpired()
|
||||
throw new ApiError('Session expired', 401, null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let data = null
|
||||
const text = await res.text()
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = null
|
||||
}
|
||||
}
|
||||
throw new ApiError(
|
||||
parseDetail(data?.detail) || res.statusText || 'Download failed',
|
||||
res.status,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
const fromHeader = filenameFromDisposition(res.headers.get('Content-Disposition'))
|
||||
const name = filename || fromHeader || 'download'
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function filenameFromDisposition(header) {
|
||||
if (!header) return null
|
||||
const star = /filename\*=UTF-8''([^;]+)/i.exec(header)
|
||||
if (star) {
|
||||
try {
|
||||
return decodeURIComponent(star[1])
|
||||
} catch {
|
||||
return star[1]
|
||||
}
|
||||
}
|
||||
const quoted = /filename="([^"]+)"/i.exec(header)
|
||||
if (quoted) return quoted[1]
|
||||
const plain = /filename=([^;]+)/i.exec(header)
|
||||
return plain ? plain[1].trim() : null
|
||||
}
|
||||
|
||||
export const get = (path, params, opts) => request(path, { ...opts, params })
|
||||
export const post = (path, body, opts) => request(path, { ...opts, method: 'POST', body })
|
||||
export const put = (path, body, opts) => request(path, { ...opts, method: 'PUT', body })
|
||||
|
|
|
|||
|
|
@ -119,6 +119,12 @@ function css(name) { return getComputedStyle(document.documentElement).getProper
|
|||
const color = ds.color || pal[di % pal.length];
|
||||
const pts = ds.data.map((v, i) => ({ x: pad.l + stepX * i, y: h - pad.b - (v * prog / max) * plotH, v }));
|
||||
if (di === 0) pts.forEach((p, i) => points.push({ ...p, label: labels[i] }));
|
||||
// An empty series has no first or last point, and the area path reads
|
||||
// both (pts[0], pts[pts.length-1]) — unguarded that threw a TypeError
|
||||
// and killed the whole render pass, taking every OTHER series on the
|
||||
// chart down with it. Callers legitimately pass empty data: a query
|
||||
// that is still pending, or a window with no rows.
|
||||
if (!pts.length) return;
|
||||
|
||||
if (area) {
|
||||
const grad = ctx.createLinearGradient(0, pad.t, 0, h - pad.b);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,35 @@ export const qk = {
|
|||
applications: (p = {}) => ['mailbox', 'applications', p],
|
||||
message: (id) => ['mailbox', 'message', id],
|
||||
assignments: (p = {}) => ['mailbox', 'assignments', p],
|
||||
counts: () => ['mailbox', 'counts'],
|
||||
},
|
||||
assessments: {
|
||||
all: () => ['assessments'],
|
||||
list: (p = {}) => ['assessments', 'list', p],
|
||||
counts: () => ['assessments', 'counts'],
|
||||
},
|
||||
notifications: {
|
||||
all: () => ['notifications'],
|
||||
list: (p = {}) => ['notifications', 'list', p],
|
||||
},
|
||||
managers: {
|
||||
all: () => ['managers'],
|
||||
list: () => ['managers', 'list'],
|
||||
},
|
||||
orgSettings: {
|
||||
all: () => ['orgSettings'],
|
||||
list: (p = {}) => ['orgSettings', 'list', p],
|
||||
},
|
||||
savedSearches: {
|
||||
all: () => ['savedSearches'],
|
||||
list: (p = {}) => ['savedSearches', 'list', p],
|
||||
},
|
||||
search: {
|
||||
query: (p = {}) => ['search', p],
|
||||
},
|
||||
feedbackTemplates: {
|
||||
all: () => ['feedbackTemplates'],
|
||||
list: () => ['feedbackTemplates', 'list'],
|
||||
},
|
||||
jobPosts: {
|
||||
all: () => ['jobPosts'],
|
||||
|
|
@ -56,7 +85,17 @@ export const qk = {
|
|||
recruiters: (p = {}) => ['analytics', 'recruiters', p],
|
||||
},
|
||||
offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] },
|
||||
interviews: { all: () => ['interviews'], range: (p = {}) => ['interviews', 'range', p] },
|
||||
interviews: {
|
||||
all: () => ['interviews'],
|
||||
range: (p = {}) => ['interviews', 'range', p],
|
||||
byInbox: (inboxId) => ['interviews', 'inbox', inboxId],
|
||||
},
|
||||
costs: { all: () => ['costs'], list: (p = {}) => ['costs', 'list', p] },
|
||||
assignments: {
|
||||
all: () => ['assignments'],
|
||||
job: (jobPostId) => ['assignments', 'job', jobPostId],
|
||||
application: (inboxId) => ['assignments', 'application', inboxId],
|
||||
},
|
||||
activity: { all: () => ['activity'], feed: (p = {}) => ['activity', 'feed', p] },
|
||||
tasks: {
|
||||
all: () => ['tasks'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
/* ============================================================
|
||||
useApplications — the one read three screens need for the same reason.
|
||||
|
||||
Interviews, Calendar and Offers all have to turn an `inbox_id` into a person
|
||||
and a role. GET /pipeline/candidates/fetch is the only payload that carries
|
||||
inbox_id, user_id, name, email and the assigned job title on a single row, so
|
||||
it is the join table for all three.
|
||||
|
||||
INBOX ROWS ONLY. `interviews.inbox_id` and `offers.inbox_id` are the sole
|
||||
links those tables have, so a manual-upload candidate — who has no inbox row
|
||||
— cannot carry an interview or an offer at all. Returning them here would
|
||||
populate pickers with people the write would then reject.
|
||||
|
||||
One query key shared by all three callers, so navigating between them is a
|
||||
cache hit rather than a third identical request.
|
||||
============================================================ */
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { qk } from './queryKeys'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
|
||||
const BOARD_LIMIT = 300
|
||||
|
||||
export function useApplications() {
|
||||
return useQuery({
|
||||
queryKey: qk.pipeline.board({ limit: BOARD_LIMIT, source: 'inbox' }),
|
||||
queryFn: async () => {
|
||||
const res = await pipelineApi.listApplications({ limit: BOARD_LIMIT })
|
||||
const rows = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
|
||||
return rows.map((row) => ({
|
||||
inboxId: row.inbox_id,
|
||||
userId: row.user_id ?? null,
|
||||
name: row.name || row.email || 'Unknown',
|
||||
email: row.email ?? null,
|
||||
jobTitle: row.title ?? null,
|
||||
jobPostId: row.assigned_job_post_id ?? null,
|
||||
stage: pipelineApi.STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
|
||||
}))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** inbox_id -> application, for hydrating rows that only carry the id. */
|
||||
export function byInboxId(rows) {
|
||||
const map = new Map()
|
||||
for (const row of rows ?? []) map.set(row.inboxId, row)
|
||||
return map
|
||||
}
|
||||
|
||||
/** candidate user_id -> application, for rows keyed by the person instead. */
|
||||
export function byUserId(rows) {
|
||||
const map = new Map()
|
||||
for (const row of rows ?? []) {
|
||||
if (row.userId) map.set(String(row.userId), row)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
|
@ -1,79 +1,286 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
/* ============================================================
|
||||
Analytics — live on the five /analytics/* endpoints.
|
||||
|
||||
The Week / Month / Quarter pills are real now: every endpoint takes
|
||||
from_date / to_date / department / recruiter_id, and all four filters are
|
||||
sent on every request. The department and recruiter option lists are
|
||||
themselves live (from /jobs/fetch and /analytics/recruiter-performance), so
|
||||
the filters can only offer values the data actually contains.
|
||||
|
||||
THREE OF THE PROTOTYPE'S EIGHT CHARTS CHANGED SOURCE OR SHAPE:
|
||||
|
||||
- Offer Acceptance now counts real offers (/offers/fetch) instead of a seed
|
||||
ratio. It renders a permission notice rather than a chart when the viewer
|
||||
lacks offers.view, because a doughnut of zeros reads as "nobody accepted".
|
||||
|
||||
- Applications by Department fans one funnel request out per department.
|
||||
There is no group-by-department endpoint, but `department` is a filter on
|
||||
every route, so N small parallel reads is the honest way to get it. The
|
||||
list is capped at DEPT_CAP and the cap is stated on the card.
|
||||
|
||||
- Time to Hire / Time to Fill were monthly line charts over seed arrays.
|
||||
/analytics/kpis/fetch returns those two as SCALARS plus a prior-window
|
||||
comparison — there is no monthly series anywhere in the API — so they are
|
||||
now a current-vs-prior grouped bar, which is what the data supports.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Chart, { ChartLegend } from '../ui/Chart'
|
||||
import Charts from '../lib/charts'
|
||||
import { Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { analytics as a } from '../data/seed'
|
||||
import { EmptyState, Icon } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as analyticsApi from '../api/analytics'
|
||||
import * as offersApi from '../api/offers'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
|
||||
/** Fanning out per department is cheap but not free — a hard ceiling, stated on the card. */
|
||||
const DEPT_CAP = 12
|
||||
const TREND_MONTHS = 7
|
||||
|
||||
const RANGES = [
|
||||
{ key: 'week', label: 'Week', days: 7 },
|
||||
{ key: 'month', label: 'Month', days: 30 },
|
||||
{ key: 'quarter', label: 'Quarter', days: 90 },
|
||||
{ key: 'year', label: 'Year', days: 365 },
|
||||
]
|
||||
|
||||
function rangeWindow(key) {
|
||||
const range = RANGES.find((r) => r.key === key) ?? RANGES[1]
|
||||
const to = new Date()
|
||||
const from = new Date(to.getTime() - range.days * 86400000)
|
||||
return { fromDate: from.toISOString(), toDate: to.toISOString() }
|
||||
}
|
||||
|
||||
/** Every chart that can render is wrapped in this, so one failing read never blanks the page. */
|
||||
function ChartCard({ title, sub, query, height = 260, permission, children, footer }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>{title}</h3>{sub && <span className="ch-sub">{sub}</span>}</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{query.isPending && <EmptyState icon="clock" title="Loading…">Fetching from the server.</EmptyState>}
|
||||
{query.isError && (
|
||||
<EmptyState icon="alert" title={`Couldn’t load ${title.toLowerCase()}`}>
|
||||
{friendlyAuthError(query.error, 'The server did not answer.')}
|
||||
{permission && <> This card needs the <code>{permission}</code> permission.</>}
|
||||
</EmptyState>
|
||||
)}
|
||||
{!query.isPending && !query.isError && children(height)}
|
||||
{!query.isPending && !query.isError && footer}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Analytics() {
|
||||
const { toast } = useToast()
|
||||
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const [rangeKey, setRangeKey] = useState('month')
|
||||
const [department, setDepartment] = useState('')
|
||||
const [recruiterId, setRecruiterId] = useState('')
|
||||
|
||||
const trend = useMemo(
|
||||
() => ({
|
||||
labels: a.hiringTrend.labels,
|
||||
const span = useMemo(() => rangeWindow(rangeKey), [rangeKey])
|
||||
const filters = useMemo(
|
||||
() => ({ ...span, department: department || undefined, recruiterId: recruiterId || undefined }),
|
||||
[span, department, recruiterId],
|
||||
)
|
||||
/* One stable object identity for every query key, so changing a filter
|
||||
invalidates all six reads together instead of six times over. */
|
||||
const keyParams = useMemo(
|
||||
() => ({ range: rangeKey, department: department || null, recruiterId: recruiterId || null }),
|
||||
[rangeKey, department, recruiterId],
|
||||
)
|
||||
|
||||
const kpisQuery = useQuery({
|
||||
queryKey: qk.analytics.kpis(keyParams),
|
||||
queryFn: async () => (await analyticsApi.kpis(filters))?.data ?? null,
|
||||
})
|
||||
const trendQuery = useQuery({
|
||||
queryKey: qk.analytics.trend({ ...keyParams, months: TREND_MONTHS }),
|
||||
queryFn: async () => (await analyticsApi.hiringTrend({ months: TREND_MONTHS, ...filters }))?.data
|
||||
?? { labels: [], applications: [], hires: [] },
|
||||
})
|
||||
const funnelQuery = useQuery({
|
||||
queryKey: qk.analytics.funnel(keyParams),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.funnel(filters)
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: qk.analytics.sources(keyParams),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.sourcePerformance(filters)
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
const recruitersQuery = useQuery({
|
||||
queryKey: qk.analytics.recruiters({ ...keyParams, top: 8 }),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.recruiterPerformance({ top: 8, ...filters })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
|
||||
/* Offer acceptance is not an analytics endpoint — it is counted off the real
|
||||
offers table, which is the only place offer outcomes exist. */
|
||||
const offersQuery = useQuery({
|
||||
queryKey: qk.offers.list({ top: 500, scope: 'analytics' }),
|
||||
queryFn: async () => {
|
||||
const res = await offersApi.list({ top: 500 })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/* Department options come from the requisition list, so the filter can only
|
||||
offer departments that exist. active_only is false: a closed requisition's
|
||||
department is still a legitimate lens on a past window. */
|
||||
const deptsQuery = useQuery({
|
||||
queryKey: qk.jobs.list({ scope: 'departments' }),
|
||||
queryFn: async () => {
|
||||
const res = await jobsApi.list({ top: 500, activeOnly: false })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return [...new Set(rows.map((r) => r.department).filter(Boolean))].sort()
|
||||
},
|
||||
})
|
||||
const departments = deptsQuery.data ?? []
|
||||
|
||||
/* The recruiter filter reuses the unfiltered recruiter list so selecting one
|
||||
never empties its own option list. */
|
||||
const allRecruitersQuery = useQuery({
|
||||
queryKey: qk.analytics.recruiters({ scope: 'options' }),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.recruiterPerformance({ top: 100 })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
|
||||
/* Applications per department: one funnel read each, in parallel. Capped, and
|
||||
skipped entirely while a department filter is already applied — the answer
|
||||
would be a single bar. */
|
||||
const deptTargets = useMemo(
|
||||
() => (department ? [] : departments.slice(0, DEPT_CAP)),
|
||||
[departments, department],
|
||||
)
|
||||
const deptQueries = useQueries({
|
||||
queries: deptTargets.map((dept) => ({
|
||||
queryKey: qk.analytics.funnel({ ...keyParams, department: dept }),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.funnel({ ...filters, department: dept })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return { dept, count: rows.reduce((sum, r) => sum + (r.count || 0), 0) }
|
||||
},
|
||||
})),
|
||||
})
|
||||
const deptPending = deptQueries.some((qr) => qr.isPending)
|
||||
/* useQueries returns a FRESH ARRAY every render, so memoising on it directly
|
||||
is a no-op — and the chart payload derived from it would change identity on
|
||||
every parent render, which re-runs Chart's effect and re-animates the
|
||||
canvas each time. Memoise on a value-based signature instead. */
|
||||
const deptSignature = deptQueries
|
||||
.map((qr) => (qr.data ? `${qr.data.dept}:${qr.data.count}` : '-'))
|
||||
.join('|')
|
||||
const deptRows = useMemo(
|
||||
() => deptQueries
|
||||
.map((qr) => qr.data)
|
||||
.filter(Boolean)
|
||||
.filter((r) => r.count > 0)
|
||||
.sort((a, b) => b.count - a.count),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[deptSignature],
|
||||
)
|
||||
|
||||
/* ---------- chart payloads (memoised: Chart requires stable identity) ---------- */
|
||||
|
||||
const trend = useMemo(() => {
|
||||
const t = trendQuery.data ?? { labels: [], applications: [], hires: [] }
|
||||
return {
|
||||
labels: t.labels ?? [],
|
||||
area: true,
|
||||
datasets: [
|
||||
{ label: 'Applications', data: a.hiringTrend.applications, color: Charts.PALETTE[4] },
|
||||
{ label: 'Hires', data: a.hiringTrend.hires, color: Charts.PALETTE[0] },
|
||||
{ label: 'Applications', data: t.applications ?? [], color: Charts.PALETTE[4] },
|
||||
{ label: 'Hires', data: t.hires ?? [], color: Charts.PALETTE[0] },
|
||||
],
|
||||
}),
|
||||
[],
|
||||
}
|
||||
}, [trendQuery.data])
|
||||
|
||||
const apps = useMemo(() => {
|
||||
const t = trendQuery.data ?? { labels: [], applications: [] }
|
||||
return { labels: t.labels ?? [], data: t.applications ?? [] }
|
||||
}, [trendQuery.data])
|
||||
|
||||
const source = useMemo(() => {
|
||||
const rows = sourcesQuery.data ?? []
|
||||
return {
|
||||
labels: rows.map((s) => s.source),
|
||||
data: rows.map((s) => s.count),
|
||||
centerValue: rows.reduce((sum, s) => sum + (s.count || 0), 0),
|
||||
centerLabel: 'Applications',
|
||||
}
|
||||
}, [sourcesQuery.data])
|
||||
|
||||
const sourceLegend = useMemo(
|
||||
() => (sourcesQuery.data ?? []).map((s, i) => ({
|
||||
label: s.source,
|
||||
color: Charts.PALETTE[i % Charts.PALETTE.length],
|
||||
})),
|
||||
[sourcesQuery.data],
|
||||
)
|
||||
const apps = useMemo(() => ({ labels: a.hiringTrend.labels, data: a.hiringTrend.applications }), [])
|
||||
const source = useMemo(
|
||||
() => ({
|
||||
labels: a.sources.map((s) => s.source),
|
||||
data: a.sources.map((s) => s.count),
|
||||
centerValue: candidates.length,
|
||||
centerLabel: 'Total',
|
||||
}),
|
||||
[candidates.length],
|
||||
)
|
||||
const offer = useMemo(() => {
|
||||
const { accepted, pending, declined } = a.offerAcceptance
|
||||
|
||||
const offerSplit = useMemo(() => {
|
||||
const rows = offersQuery.data ?? []
|
||||
const accepted = rows.filter((o) => o.status === 'accepted').length
|
||||
const declined = rows.filter((o) => o.status === 'declined').length
|
||||
const pending = rows.filter((o) => ['sent', 'negotiating'].includes(o.status)).length
|
||||
const decided = accepted + declined
|
||||
return {
|
||||
labels: ['Accepted', 'Pending', 'Declined'],
|
||||
data: [accepted, pending, declined],
|
||||
colors: [Charts.token('--success'), Charts.token('--warning'), Charts.token('--danger')],
|
||||
centerValue: `${Math.round((accepted / (accepted + declined || 1)) * 100)}%`,
|
||||
centerValue: decided ? `${Math.round((accepted / decided) * 100)}%` : '—',
|
||||
centerLabel: 'Accept rate',
|
||||
empty: rows.length === 0,
|
||||
}
|
||||
}, [])
|
||||
const pipeline = useMemo(
|
||||
() => ({
|
||||
labels: a.pipeline.map((p) => p.stage),
|
||||
data: a.pipeline.map((p) => p.count),
|
||||
}, [offersQuery.data])
|
||||
|
||||
/* The funnel has 11 statuses; REJECTED is dropped because it is an outcome,
|
||||
not a stage, and its volume flattens every other bar. */
|
||||
const pipeline = useMemo(() => {
|
||||
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED')
|
||||
return {
|
||||
labels: rows.map((p) => p.stage),
|
||||
data: rows.map((p) => p.count),
|
||||
colors: Charts.PALETTE,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
}
|
||||
}, [funnelQuery.data])
|
||||
|
||||
const dept = useMemo(
|
||||
() => ({ labels: a.departments.map((d) => d.dept), data: a.departments.map((d) => d.apps) }),
|
||||
[],
|
||||
() => ({ labels: deptRows.map((d) => d.dept), data: deptRows.map((d) => d.count) }),
|
||||
[deptRows],
|
||||
)
|
||||
|
||||
const rec = useMemo(() => {
|
||||
const top = [...recruiters].sort((x, y) => y.hires - x.hires).slice(0, 8)
|
||||
return { labels: top.map((r) => r.name), data: top.map((r) => r.hires) }
|
||||
}, [recruiters])
|
||||
const tth = useMemo(
|
||||
() => ({
|
||||
labels: a.hiringTrend.labels, area: true, yFmt: (v) => `${v}d`,
|
||||
datasets: [{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] }],
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const ttf = useMemo(
|
||||
() => ({
|
||||
labels: a.hiringTrend.labels, area: true, yFmt: (v) => `${v}d`,
|
||||
datasets: [{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] }],
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const rows = [...(recruitersQuery.data ?? [])].sort((a, b) => (b.hires ?? 0) - (a.hires ?? 0))
|
||||
return { labels: rows.map((r) => r.name || 'Recruiter'), data: rows.map((r) => r.hires ?? 0) }
|
||||
}, [recruitersQuery.data])
|
||||
|
||||
/* Current vs prior window. The KPI payload has no monthly series for these,
|
||||
only the two scalars and their prior-window counterparts. */
|
||||
const cycle = useMemo(() => {
|
||||
const k = kpisQuery.data ?? {}
|
||||
const round = (v) => (v == null ? 0 : Math.round(Number(v)))
|
||||
return {
|
||||
labels: ['Time to Hire', 'Time to Fill'],
|
||||
datasets: [
|
||||
{ label: 'Current', data: [round(k.time_to_hire), round(k.time_to_fill)], color: Charts.PALETTE[0] },
|
||||
{ label: 'Prior', data: [round(k.time_to_hire_prior), round(k.time_to_fill_prior)], color: Charts.PALETTE[2] },
|
||||
],
|
||||
yFmt: (v) => `${v}d`,
|
||||
}
|
||||
}, [kpisQuery.data])
|
||||
|
||||
const trendLegend = useMemo(
|
||||
() => [
|
||||
|
|
@ -82,11 +289,16 @@ export default function Analytics() {
|
|||
],
|
||||
[],
|
||||
)
|
||||
const sourceLegend = useMemo(
|
||||
() => a.sources.map((s, i) => ({ label: s.source, color: Charts.PALETTE[i % Charts.PALETTE.length] })),
|
||||
const cycleLegend = useMemo(
|
||||
() => [
|
||||
{ label: 'Current window', color: Charts.PALETTE[0] },
|
||||
{ label: 'Prior window', color: Charts.PALETTE[2] },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const k = kpisQuery.data
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
|
|
@ -96,74 +308,205 @@ export default function Analytics() {
|
|||
</div>
|
||||
<div className="page-head-actions">
|
||||
<div className="pill-tabs">
|
||||
<span className="pill-tab">Week</span>
|
||||
<span className="pill-tab active">Month</span>
|
||||
<span className="pill-tab">Quarter</span>
|
||||
{RANGES.map((r) => (
|
||||
<span
|
||||
key={r.key}
|
||||
className={`pill-tab${rangeKey === r.key ? ' active' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setRangeKey(r.key)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRangeKey(r.key) } }}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => toast('Analytics exported', 'success')}>
|
||||
<Icon name="download" /> Export
|
||||
</button>
|
||||
<select className="select" value={department} onChange={(e) => setDepartment(e.target.value)}>
|
||||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
<select className="select" value={recruiterId} onChange={(e) => setRecruiterId(e.target.value)}>
|
||||
<option value="">All Recruiters</option>
|
||||
{(allRecruitersQuery.data ?? []).map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-2 mb-18">
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Hiring Trend</h3><span className="ch-sub">Hires vs applications</span></div></div>
|
||||
{kpisQuery.isError && (
|
||||
<div className="card mb-18">
|
||||
<div className="card-body">
|
||||
<div className="chart-wrap"><Chart type="line" data={trend} height={260} /></div>
|
||||
<ChartLegend items={trendLegend} />
|
||||
<EmptyState icon="alert" title="Couldn’t load analytics">
|
||||
{friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
|
||||
{' '}This screen needs the <code>analytics.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Applications Received</h3><span className="ch-sub">Monthly volume</span></div></div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={apps} height={260} /></div></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid g-2 mb-18">
|
||||
<ChartCard
|
||||
title="Hiring Trend"
|
||||
sub={`Hires vs applications, last ${TREND_MONTHS} months`}
|
||||
query={trendQuery}
|
||||
permission="analytics.view"
|
||||
footer={<ChartLegend items={trendLegend} />}
|
||||
>
|
||||
{(h) => <div className="chart-wrap"><Chart type="line" data={trend} height={h} /></div>}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard title="Applications Received" sub="Monthly volume" query={trendQuery} permission="analytics.view">
|
||||
{(h) => <div className="chart-wrap"><Chart type="bar" data={apps} height={h} /></div>}
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="grid g-3 mb-18">
|
||||
<ChartCard
|
||||
title="Source Breakdown"
|
||||
sub="Where applications arrive from"
|
||||
query={sourcesQuery}
|
||||
height={220}
|
||||
permission="analytics.view"
|
||||
footer={<ChartLegend items={sourceLegend} />}
|
||||
>
|
||||
{(h) => (
|
||||
(sourcesQuery.data ?? []).length === 0
|
||||
? <EmptyState icon="inbox" title="No source data">Applications are tagged once a source channel is matched.</EmptyState>
|
||||
: <div className="chart-wrap"><Chart type="doughnut" data={source} height={h} /></div>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Source Breakdown</h3></div></div>
|
||||
<div className="card-head"><div><h3>Offer Acceptance</h3><span className="ch-sub">From the offers table</span></div></div>
|
||||
<div className="card-body">
|
||||
<div className="chart-wrap"><Chart type="doughnut" data={source} height={220} /></div>
|
||||
<ChartLegend items={sourceLegend} />
|
||||
{offersQuery.isPending && <EmptyState icon="clock" title="Loading…">Counting offers.</EmptyState>}
|
||||
{offersQuery.isError && (
|
||||
<EmptyState icon="lock" title="Offers not visible">
|
||||
{friendlyAuthError(offersQuery.error, 'The offers table did not answer.')}
|
||||
{' '}This card needs the <code>offers.view</code> permission.
|
||||
</EmptyState>
|
||||
)}
|
||||
{!offersQuery.isPending && !offersQuery.isError && (
|
||||
offerSplit.empty ? (
|
||||
<EmptyState icon="file" title="No offers yet">This fills in once the first offer is issued.</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="chart-wrap"><Chart type="doughnut" data={offerSplit} height={220} /></div>
|
||||
<div className="chart-legend">
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--success)' }} />Accepted</span>
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--warning)' }} />Pending</span>
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--danger)' }} />Declined</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Offer Acceptance</h3></div></div>
|
||||
<div className="card-body">
|
||||
<div className="chart-wrap"><Chart type="doughnut" data={offer} height={220} /></div>
|
||||
<div className="chart-legend">
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--success)' }} />Accepted</span>
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--warning)' }} />Pending</span>
|
||||
<span className="legend-item"><span className="legend-dot" style={{ background: 'var(--danger)' }} />Declined</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Pipeline Distribution</h3></div></div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={pipeline} height={260} /></div></div>
|
||||
</div>
|
||||
|
||||
<ChartCard
|
||||
title="Pipeline Distribution"
|
||||
sub="Active by stage, rejections excluded"
|
||||
query={funnelQuery}
|
||||
permission="analytics.view"
|
||||
>
|
||||
{(h) => (
|
||||
pipeline.data.every((n) => !n)
|
||||
? <EmptyState icon="inbox" title="No pipeline data">Stage counts appear once applications land.</EmptyState>
|
||||
: <div className="chart-wrap"><Chart type="horizontalBar" data={pipeline} height={h} /></div>
|
||||
)}
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="grid g-2 mb-18">
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Applications by Department</h3><span className="ch-sub">Volume per team</span></div></div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={dept} height={300} /></div></div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Recruiter Performance</h3><span className="ch-sub">Hires by recruiter (top 8)</span></div></div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={rec} height={300} /></div></div>
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Applications by Department</h3>
|
||||
<span className="ch-sub">
|
||||
{department
|
||||
? 'Filtered to one department'
|
||||
: `Top ${Math.min(departments.length, DEPT_CAP)} of ${departments.length}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{department ? (
|
||||
<EmptyState icon="filter" title={`Filtered to ${department}`}>
|
||||
Clear the department filter to compare teams.
|
||||
</EmptyState>
|
||||
) : deptPending ? (
|
||||
<EmptyState icon="clock" title="Loading…">One read per department.</EmptyState>
|
||||
) : deptRows.length === 0 ? (
|
||||
<EmptyState icon="inbox" title="No applications in this window">
|
||||
Departments appear once their requisitions receive applications.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="chart-wrap"><Chart type="bar" data={dept} height={300} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChartCard
|
||||
title="Recruiter Performance"
|
||||
sub="Hires by recruiter (top 8)"
|
||||
query={recruitersQuery}
|
||||
height={300}
|
||||
permission="analytics.view"
|
||||
>
|
||||
{(h) => (
|
||||
rec.labels.length === 0
|
||||
? <EmptyState icon="users" title="No recruiter stats">Assign recruiters to requisitions to populate this.</EmptyState>
|
||||
: <div className="chart-wrap"><Chart type="horizontalBar" data={rec} height={h} /></div>
|
||||
)}
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="grid g-2">
|
||||
<ChartCard
|
||||
title="Cycle Time"
|
||||
sub="Days, current window vs prior"
|
||||
query={kpisQuery}
|
||||
height={240}
|
||||
permission="analytics.view"
|
||||
footer={<ChartLegend items={cycleLegend} />}
|
||||
>
|
||||
{(h) => (
|
||||
k?.time_to_hire == null && k?.time_to_fill == null
|
||||
? <EmptyState icon="clock" title="No completed cycles">Time to hire needs at least one hire in the window.</EmptyState>
|
||||
: <div className="chart-wrap"><Chart type="groupedBar" data={cycle} height={h} /></div>
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Time to Hire</h3><span className="ch-sub">Days, monthly average</span></div></div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={tth} height={240} /></div></div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Time to Fill</h3><span className="ch-sub">Days, monthly average</span></div></div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={ttf} height={240} /></div></div>
|
||||
<div className="card-head"><div><h3>Window Summary</h3><span className="ch-sub">Totals behind the charts</span></div></div>
|
||||
<div className="card-body">
|
||||
{kpisQuery.isPending ? (
|
||||
<EmptyState icon="clock" title="Loading…">Fetching totals.</EmptyState>
|
||||
) : (
|
||||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">Open Jobs</div><div className="iv">{k?.open_jobs ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Candidates</div><div className="iv">{k?.total_candidates ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Hires</div><div className="iv">{k?.hires ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Offers Sent</div><div className="iv">{k?.offers_sent ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Offers Accepted</div><div className="iv">{k?.offers_accepted ?? '—'}</div></div>
|
||||
<div className="info-item">
|
||||
<div className="il">Cost per Hire</div>
|
||||
<div className="iv">
|
||||
{k?.cost_per_hire != null ? `$${Math.round(k.cost_per_hire).toLocaleString()}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item"><div className="il">Closed Jobs</div><div className="iv">{k?.closed_jobs ?? '—'}</div></div>
|
||||
<div className="info-item">
|
||||
<div className="il">Interviews Today</div>
|
||||
<div className="iv">{k?.interviews_today ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted" style={{ marginTop: 14, fontSize: 13 }}>
|
||||
<Icon name="info" /> Every figure here respects the range, department and recruiter filters above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,73 @@
|
|||
/* ============================================================
|
||||
Assessments — coding tests and take-homes, on live backend data.
|
||||
|
||||
Rows come from GET /assessments/fetch via toAssessmentView. KPI cards use
|
||||
GET /assessments/counts so they cover the whole table, not the current page.
|
||||
Assign / remind / delete hit create, remind and the soft-delete route.
|
||||
Dual-key: exactly one of inbox_id / manual_upload_candidate_id.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import { Avatar, Badge, Icon, KpiCard, ProgressBar, ScoreChip } from '../ui/primitives'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, ProgressBar, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { candidates as allCandidates, fmtDate, fmtShort, int } from '../data/seed'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as assessmentsApi from '../api/assessments'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import { ASSESSMENT_TYPES } from '../api/assessments'
|
||||
import { fmtDate, fmtShort } from '../data/seed'
|
||||
|
||||
const SECTIONS = ['Problem Solving', 'Code Quality', 'Communication', 'Time Management']
|
||||
const STATUS_FILTER = [
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'In Progress', value: 'in_progress' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Expired', value: 'expired' },
|
||||
]
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ label: '45 min', minutes: 45 },
|
||||
{ label: '60 min', minutes: 60 },
|
||||
{ label: '90 min', minutes: 90 },
|
||||
{ label: '3 days', minutes: 4320 },
|
||||
]
|
||||
|
||||
async function fetchAssessments() {
|
||||
const res = await assessmentsApi.list({ top: 200 })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(assessmentsApi.toAssessmentView)
|
||||
}
|
||||
|
||||
async function fetchCounts() {
|
||||
const res = await assessmentsApi.counts()
|
||||
return res?.data ?? {}
|
||||
}
|
||||
|
||||
async function fetchCandidateOptions() {
|
||||
const res = await candidatesApi.list({ limit: 200 })
|
||||
return candidatesApi.toRows(res).filter((r) => r.inbox_id != null)
|
||||
}
|
||||
|
||||
export default function Assessments() {
|
||||
const { toast } = useToast()
|
||||
const { can } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { data: assessments = [] } = useQuery(seedQuery('assessments'))
|
||||
const qc = useQueryClient()
|
||||
|
||||
const canCreate = can('assessments.create')
|
||||
const canEdit = can('assessments.edit')
|
||||
const canDelete = can('assessments.delete')
|
||||
|
||||
const listQuery = useQuery({ queryKey: qk.assessments.list(), queryFn: fetchAssessments })
|
||||
const countsQuery = useQuery({ queryKey: qk.assessments.counts(), queryFn: fetchCounts })
|
||||
const assessments = listQuery.data ?? []
|
||||
const counts = countsQuery.data ?? {}
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
|
|
@ -22,22 +75,21 @@ export default function Assessments() {
|
|||
const [viewing, setViewing] = useState(null)
|
||||
const [assigning, setAssigning] = useState(false)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const scored = assessments.filter((a) => a.score)
|
||||
return {
|
||||
total: assessments.length,
|
||||
completed: assessments.filter((a) => a.status === 'Completed').length,
|
||||
pending: assessments.filter((a) => ['Pending', 'In Progress'].includes(a.status)).length,
|
||||
avg: Math.round(scored.reduce((s, a) => s + a.score, 0) / (scored.length || 1)),
|
||||
}
|
||||
}, [assessments])
|
||||
const completedCount = counts.completed ?? 0
|
||||
const pendingCount = (counts.pending ?? 0) + (counts.in_progress ?? 0)
|
||||
const totalCount = (counts.pending ?? 0) + (counts.in_progress ?? 0) + (counts.completed ?? 0) + (counts.expired ?? 0)
|
||||
const scored = assessments.filter((a) => a.score != null)
|
||||
const avg = scored.length ? Math.round(scored.reduce((s, a) => s + a.score, 0) / scored.length) : 0
|
||||
|
||||
const types = useMemo(() => [...new Set(assessments.map((a) => a.type))], [assessments])
|
||||
const types = useMemo(
|
||||
() => [...new Set([...ASSESSMENT_TYPES, ...assessments.map((a) => a.type).filter(Boolean)])],
|
||||
[assessments],
|
||||
)
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
assessments.filter((a) => {
|
||||
if (status && a.status !== status) return false
|
||||
if (status && a.statusKey !== status) return false
|
||||
if (type && a.type !== type) return false
|
||||
if (q && !(a.candidate + a.jobTitle + a.type).toLowerCase().includes(q.toLowerCase())) return false
|
||||
return true
|
||||
|
|
@ -45,19 +97,42 @@ export default function Assessments() {
|
|||
[assessments, q, status, type],
|
||||
)
|
||||
|
||||
// Section scores were generated inline at render in the prototype, so they
|
||||
// reshuffled on every repaint. Derived per assessment id and memoised here.
|
||||
const sectionScores = useMemo(
|
||||
() => (viewing ? SECTIONS.map((s) => ({ label: s, score: int(60, 98) })) : []),
|
||||
[viewing],
|
||||
)
|
||||
const remind = useMutation({
|
||||
mutationFn: (id) => assessmentsApi.remind(id),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not send the reminder.'), 'error'),
|
||||
onSuccess: (_res, id) => {
|
||||
const row = assessments.find((a) => a.id === id)
|
||||
toast(`Reminder sent${row ? ` to ${row.candidate}` : ''}`, 'success')
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.assessments.all() }),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id) => assessmentsApi.remove(id),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the assessment.'), 'error'),
|
||||
onSuccess: () => {
|
||||
setViewing(null)
|
||||
toast('Assessment deleted', 'success')
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.assessments.all() }),
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body) => assessmentsApi.create(body),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the assessment.'), 'error'),
|
||||
onSuccess: () => {
|
||||
setAssigning(false)
|
||||
toast('Assessment assigned & invite sent', 'success')
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.assessments.all() }),
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'candidate', label: 'Candidate', sortable: true,
|
||||
render: (a) => (
|
||||
<div className="user-cell">
|
||||
<Avatar name={a.candidate} initials={a.initials} color={a.color} />
|
||||
<Avatar name={a.candidate} />
|
||||
<div>
|
||||
<div className="cell-primary">{a.candidate}</div>
|
||||
<div className="cell-sub">{a.jobTitle}</div>
|
||||
|
|
@ -74,16 +149,35 @@ export default function Assessments() {
|
|||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'assigned', label: 'Assigned', sortable: true, sortValue: (a) => a.assigned.getTime(), render: (a) => <span className="text-muted">{fmtShort(a.assigned)}</span> },
|
||||
{ key: 'due', label: 'Due', sortable: true, sortValue: (a) => a.due.getTime(), render: (a) => <span className="text-muted">{fmtShort(a.due)}</span> },
|
||||
{ key: 'score', label: 'Score', sortable: true, align: 'center', render: (a) => (a.score !== null ? <ScoreChip score={a.score} /> : <span className="text-muted">—</span>) },
|
||||
{
|
||||
key: 'assigned', label: 'Assigned', sortable: true,
|
||||
sortValue: (a) => (a.assigned ? a.assigned.getTime() : 0),
|
||||
render: (a) => <span className="text-muted">{a.assigned ? fmtShort(a.assigned) : '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'due', label: 'Due', sortable: true,
|
||||
sortValue: (a) => (a.due ? a.due.getTime() : 0),
|
||||
render: (a) => <span className="text-muted">{a.due ? fmtShort(a.due) : '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'score', label: 'Score', sortable: true, align: 'center',
|
||||
render: (a) => (a.score !== null ? <ScoreChip score={a.score} /> : <span className="text-muted">—</span>),
|
||||
},
|
||||
{ key: 'status', label: 'Status', sortable: true, render: (a) => <Badge>{a.status}</Badge> },
|
||||
{
|
||||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (a) => (
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="View" onClick={() => setViewing(a)}><Icon name="eye" /></button>
|
||||
<button className="act-btn" data-tip="Remind" onClick={() => toast(`Reminder sent to ${a.candidate}`, 'info')}><Icon name="mail" /></button>
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Remind"
|
||||
disabled={!canEdit || remind.isPending}
|
||||
title={!canEdit ? 'Requires assessments.edit' : undefined}
|
||||
onClick={() => remind.mutate(a.id)}
|
||||
>
|
||||
<Icon name="mail" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -97,37 +191,58 @@ export default function Assessments() {
|
|||
<p className="page-sub">Coding tests, take-homes, and evaluations</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button className="btn btn-primary" onClick={() => setAssigning(true)}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!canCreate}
|
||||
title={!canCreate ? 'Requires assessments.create' : undefined}
|
||||
onClick={() => setAssigning(true)}
|
||||
>
|
||||
<Icon name="plus" /> Assign Assessment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Total Assigned" value={stats.total} icon="file" tone="i-indigo" />
|
||||
<KpiCard label="Completed" value={stats.completed} icon="check-circle" tone="i-green" />
|
||||
<KpiCard label="In Progress / Pending" value={stats.pending} icon="clock" tone="i-amber" />
|
||||
<KpiCard label="Average Score" value={`${stats.avg}%`} icon="target" tone="i-teal" />
|
||||
<KpiCard label="Total Assigned" value={totalCount} icon="file" tone="i-indigo" />
|
||||
<KpiCard label="Completed" value={completedCount} icon="check-circle" tone="i-green" />
|
||||
<KpiCard label="In Progress / Pending" value={pendingCount} icon="clock" tone="i-amber" />
|
||||
<KpiCard label="Average Score" value={`${Number.isFinite(avg) ? avg : 0}%`} icon="target" tone="i-teal" />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or assessment…" />
|
||||
</div>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
{['Completed', 'In Progress', 'Pending', 'Expired'].map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All Types</option>
|
||||
{types.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
{listQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="check-square" title="Loading…">Fetching assessments from the server.</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={rows} pageSize={8} />
|
||||
)}
|
||||
{listQuery.isError && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="check-square" title="Couldn’t load assessments">
|
||||
{friendlyAuthError(listQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{!listQuery.isPending && !listQuery.isError && (
|
||||
<>
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or assessment…" />
|
||||
</div>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
{STATUS_FILTER.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All Types</option>
|
||||
{types.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={rows} pageSize={8} empty="No assessments match these filters." />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewing && (
|
||||
|
|
@ -137,22 +252,31 @@ export default function Assessments() {
|
|||
onClose={() => setViewing(null)}
|
||||
footer={
|
||||
<>
|
||||
{canDelete && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginRight: 'auto', color: 'var(--danger)' }}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(viewing.id)}
|
||||
>
|
||||
<Icon name="trash" /> {remove.isPending ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={() => setViewing(null)}>Close</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
const id = viewing.candidateId
|
||||
setViewing(null)
|
||||
navigate('/candidates', { state: { openCandidate: id } })
|
||||
navigate('/candidates')
|
||||
}}
|
||||
>
|
||||
View Candidate
|
||||
View Candidates
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-12 mb-18">
|
||||
<Avatar name={viewing.candidate} initials={viewing.initials} color={viewing.color} className="avatar-lg" />
|
||||
<Avatar name={viewing.candidate} className="avatar-lg" />
|
||||
<div>
|
||||
<div className="ph-name" style={{ fontSize: 17 }}>{viewing.candidate}</div>
|
||||
<div className="ph-role">{viewing.type} · {viewing.jobTitle}</div>
|
||||
|
|
@ -163,8 +287,8 @@ export default function Assessments() {
|
|||
<div className="info-grid mb-18">
|
||||
<div className="info-item"><div className="il">Type</div><div className="iv">{viewing.type}</div></div>
|
||||
<div className="info-item"><div className="il">Duration</div><div className="iv">{viewing.duration}</div></div>
|
||||
<div className="info-item"><div className="il">Assigned</div><div className="iv">{fmtDate(viewing.assigned)}</div></div>
|
||||
<div className="info-item"><div className="il">Due</div><div className="iv">{fmtDate(viewing.due)}</div></div>
|
||||
<div className="info-item"><div className="il">Assigned</div><div className="iv">{viewing.assigned ? fmtDate(viewing.assigned) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Due</div><div className="iv">{viewing.due ? fmtDate(viewing.due) : '—'}</div></div>
|
||||
</div>
|
||||
|
||||
{viewing.score !== null ? (
|
||||
|
|
@ -182,14 +306,18 @@ export default function Assessments() {
|
|||
<div className="text-muted">Overall Score</div>
|
||||
</div>
|
||||
<div className="mb-18"><ProgressBar pct={viewing.score} /></div>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Section Breakdown</div>
|
||||
{sectionScores.map((s) => (
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 10 }} key={s.label}>
|
||||
<span style={{ width: 130, fontSize: 13 }}>{s.label}</span>
|
||||
<div style={{ flex: 1 }}><ProgressBar pct={s.score} /></div>
|
||||
<b style={{ width: 40, textAlign: 'right' }}>{s.score}%</b>
|
||||
</div>
|
||||
))}
|
||||
{viewing.sectionScores.length > 0 && (
|
||||
<>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Section Breakdown</div>
|
||||
{viewing.sectionScores.map((s) => (
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 10 }} key={s.label}>
|
||||
<span style={{ width: 130, fontSize: 13 }}>{s.label}</span>
|
||||
<div style={{ flex: 1 }}><ProgressBar pct={s.score} /></div>
|
||||
<b style={{ width: 40, textAlign: 'right' }}>{s.score}%</b>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
|
|
@ -202,47 +330,120 @@ export default function Assessments() {
|
|||
)}
|
||||
|
||||
{assigning && (
|
||||
<Modal
|
||||
title="Assign Assessment"
|
||||
subtitle="Send an evaluation to a candidate"
|
||||
<AssignForm
|
||||
pending={create.isPending}
|
||||
onClose={() => setAssigning(false)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setAssigning(false)}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
setAssigning(false)
|
||||
toast('Assessment assigned & invite sent', 'success')
|
||||
}}
|
||||
>
|
||||
<Icon name="send" /> Assign
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Candidate</label>
|
||||
<select>{allCandidates.slice(0, 40).map((c) => <option key={c.id}>{c.name}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Assessment Type</label>
|
||||
<select>{types.map((t) => <option key={t}>{t}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Time Limit</label>
|
||||
<select><option>45 min</option><option>60 min</option><option>90 min</option><option>3 days</option></select>
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Due Date</label>
|
||||
<input type="date" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
onSave={(body) => create.mutate(body)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssignForm({ pending, onClose, onSave }) {
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: qk.candidates.list({ for: 'assessment-assign' }),
|
||||
queryFn: fetchCandidateOptions,
|
||||
})
|
||||
const options = candidatesQuery.data ?? []
|
||||
const form = useFormState({
|
||||
inbox_id: '',
|
||||
assessment_type: ASSESSMENT_TYPES[0],
|
||||
duration_minutes: '60',
|
||||
due_at: '',
|
||||
})
|
||||
|
||||
function submit() {
|
||||
if (pending) return
|
||||
const v = form.values
|
||||
const errors = {}
|
||||
if (!v.inbox_id) errors.inbox_id = 'Pick a candidate'
|
||||
if (!v.assessment_type) errors.assessment_type = 'Type is required'
|
||||
form.setErrors(errors)
|
||||
if (Object.keys(errors).length) return
|
||||
|
||||
const picked = options.find((c) => String(c.inbox_id) === String(v.inbox_id))
|
||||
const body = {
|
||||
assessment_type: v.assessment_type,
|
||||
inbox_id: Number(v.inbox_id),
|
||||
duration_minutes: Number(v.duration_minutes) || null,
|
||||
due_at: v.due_at ? new Date(`${v.due_at}T23:59:00`).toISOString() : null,
|
||||
}
|
||||
if (picked?.assigned_job_post_id) body.job_post_id = picked.assigned_job_post_id
|
||||
onSave(body)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Assign Assessment"
|
||||
subtitle="Send an evaluation to a candidate"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={pending}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={pending || candidatesQuery.isPending}>
|
||||
<Icon name="send" /> {pending ? 'Assigning…' : 'Assign'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Candidate <span className="req">*</span></label>
|
||||
<select
|
||||
className={form.errors.inbox_id ? 'err' : ''}
|
||||
value={form.values.inbox_id}
|
||||
onChange={(e) => form.setField('inbox_id', e.target.value)}
|
||||
disabled={candidatesQuery.isPending || candidatesQuery.isError}
|
||||
>
|
||||
{candidatesQuery.isPending && <option value="">Loading candidates…</option>}
|
||||
{candidatesQuery.isError && <option value="">Could not load candidates</option>}
|
||||
{!candidatesQuery.isPending && !candidatesQuery.isError && options.length === 0 && (
|
||||
<option value="">No inbox-linked candidates</option>
|
||||
)}
|
||||
{!candidatesQuery.isPending && options.length > 0 && (
|
||||
<option value="">Select a candidate…</option>
|
||||
)}
|
||||
{options.map((c) => (
|
||||
<option key={c.inbox_id} value={c.inbox_id}>
|
||||
{c.name || c.email || `Inbox #${c.inbox_id}`}
|
||||
{c.job_title ? ` · ${c.job_title}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldError>{form.errors.inbox_id}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Assessment Type</label>
|
||||
<select
|
||||
value={form.values.assessment_type}
|
||||
onChange={(e) => form.setField('assessment_type', e.target.value)}
|
||||
>
|
||||
{ASSESSMENT_TYPES.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Time Limit</label>
|
||||
<select
|
||||
value={form.values.duration_minutes}
|
||||
onChange={(e) => form.setField('duration_minutes', e.target.value)}
|
||||
>
|
||||
{DURATION_OPTIONS.map((d) => (
|
||||
<option key={d.minutes} value={d.minutes}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.values.due_at}
|
||||
onChange={(e) => form.setField('due_at', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,30 @@
|
|||
/* ============================================================
|
||||
Calendar — live on GET /interview/fetch, scoped to the visible month.
|
||||
|
||||
The month grid is the prototype's, unchanged. What moved is the data source
|
||||
and the window: `from_date`/`to_date` are sent for the month on screen, so
|
||||
paging back a year is one small request rather than a filter over everything
|
||||
ever scheduled. Each month is its own query key, so revisiting a month you
|
||||
already looked at repaints from cache.
|
||||
|
||||
"Today" is real time now. The prototype pinned TODAY to 2026-07-09 so its
|
||||
generated dates stayed stable; with live rows that pin would highlight the
|
||||
wrong cell and show an empty agenda every day of the year.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { Avatar, Icon } from '../ui/primitives'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { TODAY } from '../data/seed'
|
||||
import { Avatar, EmptyState, Icon } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as interviewsApi from '../api/interviews'
|
||||
import { byInboxId, useApplications } from '../lib/useApplications'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
/* Round -> event colour. Unknown rounds fall through to blue rather than
|
||||
vanishing; `interview_type` is free text, so an unrecognised value is normal. */
|
||||
const EVENT_COLORS = {
|
||||
'Phone Screen': 'b-blue', Technical: 'b-indigo', 'System Design': 'b-purple',
|
||||
'Onsite Loop': 'b-teal', 'Hiring Manager': 'b-amber', 'Culture Fit': 'b-green',
|
||||
|
|
@ -29,13 +48,60 @@ function buildCells(year, month) {
|
|||
|
||||
export default function Calendar() {
|
||||
const navigate = useNavigate()
|
||||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
const [{ year, month }, setView] = useState({ year: TODAY.getFullYear(), month: TODAY.getMonth() })
|
||||
const today = useMemo(() => new Date(), [])
|
||||
const [{ year, month }, setView] = useState({ year: today.getFullYear(), month: today.getMonth() })
|
||||
|
||||
/* Half-open [from, to): the route filters `interview_date >= from` and
|
||||
`< to`, so passing the 1st of the next month includes the whole month
|
||||
without an off-by-one on the last day. */
|
||||
const from = useMemo(() => new Date(year, month, 1), [year, month])
|
||||
const to = useMemo(() => new Date(year, month + 1, 1), [year, month])
|
||||
|
||||
const monthQuery = useQuery({
|
||||
queryKey: qk.interviews.range({ month: `${year}-${String(month + 1).padStart(2, '0')}` }),
|
||||
queryFn: async () => {
|
||||
const res = await interviewsApi.listRange({
|
||||
fromDate: from.toISOString(),
|
||||
toDate: to.toISOString(),
|
||||
top: 500,
|
||||
})
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(interviewsApi.toInterviewView)
|
||||
},
|
||||
})
|
||||
|
||||
/* Job title and the candidate's user id are not on the interview row; the
|
||||
application supplies both. One extra request for the whole screen. */
|
||||
const appsQuery = useApplications()
|
||||
const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data])
|
||||
|
||||
const events = useMemo(
|
||||
() => (monthQuery.data ?? [])
|
||||
.filter((iv) => iv.when)
|
||||
.map((iv) => {
|
||||
const app = appByInbox.get(iv.inboxId)
|
||||
return { ...iv, jobTitle: app?.jobTitle ?? null, userId: app?.userId ?? null }
|
||||
}),
|
||||
[monthQuery.data, appByInbox],
|
||||
)
|
||||
|
||||
/* One pass into a day bucket, so the 42 cells below are lookups rather than
|
||||
42 filters over the month. */
|
||||
const byDay = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const e of events) {
|
||||
const key = e.when.toDateString()
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key).push(e)
|
||||
}
|
||||
for (const list of map.values()) list.sort((a, b) => a.when - b.when)
|
||||
return map
|
||||
}, [events])
|
||||
|
||||
const cells = useMemo(() => buildCells(year, month), [year, month])
|
||||
const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
const todayKey = TODAY.toDateString()
|
||||
const todayIvs = interviews.filter((iv) => iv.when.toDateString() === todayKey)
|
||||
const todayKey = today.toDateString()
|
||||
const todayIvs = byDay.get(todayKey) ?? []
|
||||
|
||||
const step = (delta) =>
|
||||
setView(({ year: y, month: m }) => {
|
||||
|
|
@ -45,14 +111,20 @@ export default function Calendar() {
|
|||
return { year: y, month: next }
|
||||
})
|
||||
|
||||
const openCandidate = (id) => navigate('/candidates', { state: { openCandidate: id } })
|
||||
const openCandidate = (userId) => {
|
||||
if (!userId) return
|
||||
navigate('/candidates', { state: { openCandidate: userId } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Calendar</h1>
|
||||
<p className="page-sub">Interview schedule at a glance</p>
|
||||
<p className="page-sub">
|
||||
Interview schedule at a glance
|
||||
{monthQuery.isSuccess ? ` · ${events.length} this month` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<div className="flex items-center gap-8">
|
||||
|
|
@ -64,6 +136,12 @@ export default function Calendar() {
|
|||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setView({ year: today.getFullYear(), month: today.getMonth() })}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => navigate('/interviews', { state: { openSchedule: true } })}
|
||||
|
|
@ -73,77 +151,92 @@ export default function Calendar() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-2-1">
|
||||
{monthQuery.isError ? (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="cal-grid">
|
||||
{DOW.map((d) => <div className="cal-dow" key={d}>{d}</div>)}
|
||||
{cells.map((c, i) => {
|
||||
const dayEvents = !c.other && c.date
|
||||
? interviews.filter((iv) => iv.when.toDateString() === c.date.toDateString())
|
||||
: []
|
||||
const isToday = !c.other && c.date && c.date.toDateString() === todayKey
|
||||
return (
|
||||
<div className={`cal-cell ${c.other ? 'other' : ''} ${isToday ? 'today' : ''}`} key={i}>
|
||||
<div className="cal-date">{c.day}</div>
|
||||
{dayEvents.slice(0, 3).map((iv) => (
|
||||
<div
|
||||
key={iv.id}
|
||||
className={`cal-event ${EVENT_COLORS[iv.type] || 'b-blue'}`}
|
||||
title={`${iv.candidate} · ${iv.type}`}
|
||||
onClick={() => openCandidate(iv.candidateId)}
|
||||
>
|
||||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="cal-event b-gray">+{dayEvents.length - 3} more</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<EmptyState icon="alert" title="Couldn’t load the calendar">
|
||||
{friendlyAuthError(monthQuery.error, 'The server did not return interviews.')}
|
||||
{' '}This screen needs the <code>candidates.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid g-2-1">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="cal-grid">
|
||||
{DOW.map((d) => <div className="cal-dow" key={d}>{d}</div>)}
|
||||
{cells.map((c, i) => {
|
||||
const dayEvents = !c.other && c.date ? (byDay.get(c.date.toDateString()) ?? []) : []
|
||||
const isToday = !c.other && c.date && c.date.toDateString() === todayKey
|
||||
return (
|
||||
<div className={`cal-cell ${c.other ? 'other' : ''} ${isToday ? 'today' : ''}`} key={i}>
|
||||
<div className="cal-date">{c.day}</div>
|
||||
{dayEvents.slice(0, 3).map((iv) => (
|
||||
<div
|
||||
key={iv.id}
|
||||
className={`cal-event ${EVENT_COLORS[iv.type] || 'b-blue'}`}
|
||||
title={`${iv.candidate} · ${iv.type}${iv.jobTitle ? ` · ${iv.jobTitle}` : ''}`}
|
||||
onClick={() => openCandidate(iv.userId)}
|
||||
>
|
||||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="cal-event b-gray">+{dayEvents.length - 3} more</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ alignSelf: 'start' }}>
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Today</h3>
|
||||
<span className="ch-sub">
|
||||
{TODAY.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<div className="card" style={{ alignSelf: 'start' }}>
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Today</h3>
|
||||
<span className="ch-sub">
|
||||
{today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
{todayIvs.length === 0 ? (
|
||||
<p className="text-muted">No interviews today</p>
|
||||
) : (
|
||||
todayIvs.map((iv) => (
|
||||
<div
|
||||
key={iv.id}
|
||||
className="list-row"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => openCandidate(iv.candidateId)}
|
||||
>
|
||||
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{iv.candidate}</div>
|
||||
<div className="lr-sub">{iv.type}</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
<div className="fw-600 text-sm">
|
||||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
{monthQuery.isPending ? (
|
||||
<p className="text-muted">Loading…</p>
|
||||
) : todayIvs.length === 0 ? (
|
||||
<p className="text-muted">No interviews today</p>
|
||||
) : (
|
||||
todayIvs.map((iv) => (
|
||||
<div
|
||||
key={iv.id}
|
||||
className="list-row"
|
||||
style={{ cursor: iv.userId ? 'pointer' : 'default' }}
|
||||
onClick={() => openCandidate(iv.userId)}
|
||||
>
|
||||
<Avatar
|
||||
name={iv.candidate}
|
||||
initials={initialsOf(iv.candidate)}
|
||||
color={avatarColor(iv.candidate)}
|
||||
/>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{iv.candidate}</div>
|
||||
<div className="lr-sub">{iv.type}</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
<div className="fw-600 text-sm">
|
||||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import Modal from '../ui/Modal'
|
|||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
|
|
@ -202,9 +203,6 @@ export default function CandidateProfile({
|
|||
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
|
||||
<Icon name="target" /> ATS Match
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => toast('Email drafted', 'info')}>
|
||||
<Icon name="mail" /> Message
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
|
||||
<Icon name="check" /> Advance Stage
|
||||
</button>
|
||||
|
|
@ -453,7 +451,7 @@ export default function CandidateProfile({
|
|||
)))}
|
||||
|
||||
{tab === 'Documents' && (guard || (live ? (
|
||||
<DocumentsTab rows={live.documents ?? []} />
|
||||
<DocumentsTab rows={live.documents ?? []} inboxId={inboxId} />
|
||||
) : (
|
||||
<div className="list-tight">
|
||||
{[
|
||||
|
|
@ -721,16 +719,7 @@ function NotesTab({ userId, rows }) {
|
|||
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((n) => (
|
||||
<div className="list-row" key={n.id}>
|
||||
<Avatar name={n.created_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{n.created_by_name || 'Unknown author'}</div>
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{n.note}</div>
|
||||
<div className="lr-sub">{fmtWhen(n.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rows.map((n) => <NoteRow key={n.id} note={n} userId={userId} />)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="edit" title="No notes yet">The first note on this candidate goes above.</EmptyState>
|
||||
|
|
@ -739,6 +728,79 @@ function NotesTab({ userId, rows }) {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One note, editable in place via PATCH /notes/update.
|
||||
*
|
||||
* Editing is offered only on the signed-in user's OWN notes. The route does not
|
||||
* check authorship and does not reassign `created_by`, so anyone with
|
||||
* candidates.edit could silently rewrite a colleague's words under that
|
||||
* colleague's name. Gating it here is the honest read of what the endpoint does.
|
||||
*/
|
||||
function NoteRow({ note: n, userId }) {
|
||||
const { user } = useAuth()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [text, setText] = useState(n.note ?? '')
|
||||
|
||||
const mine = Boolean(user?.id && n.created_by && String(user.id) === String(n.created_by))
|
||||
|
||||
const save = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.updateNote(n.id, text.trim()),
|
||||
success: 'Note updated',
|
||||
onDone: () => setEditing(false),
|
||||
})
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="list-row" style={{ alignItems: 'flex-start' }}>
|
||||
<Avatar name={n.created_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="form-field" style={{ marginBottom: 8 }}>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={3} />
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!text.trim() || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={save.isPending}
|
||||
onClick={() => { setText(n.note ?? ''); setEditing(false) }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-row">
|
||||
<Avatar name={n.created_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{n.created_by_name || 'Unknown author'}</div>
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{n.note}</div>
|
||||
<div className="lr-sub">
|
||||
{fmtWhen(n.created_at)}
|
||||
{n.updated_at && n.updated_at !== n.created_at ? ' · edited' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{mine && (
|
||||
<div className="lr-right">
|
||||
<button className="act-btn" data-tip="Edit note" onClick={() => setEditing(true)}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityTab({ userId, inboxId, rows }) {
|
||||
const { toast } = useToast()
|
||||
const [form, setForm] = useState({ type: ACTIVITY_TYPES[0], description: '' })
|
||||
|
|
@ -818,31 +880,154 @@ function ActivityTab({ userId, inboxId, rows }) {
|
|||
)
|
||||
}
|
||||
|
||||
function DocumentsTab({ rows }) {
|
||||
function DocumentsTab({ rows, inboxId }) {
|
||||
const { toast } = useToast()
|
||||
const download = useMutation({
|
||||
mutationFn: ({ index, filename }) => candidatesApi.downloadDocument({
|
||||
inboxId,
|
||||
index,
|
||||
filename,
|
||||
}),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Download failed.'), 'error'),
|
||||
})
|
||||
|
||||
if (!rows.length) {
|
||||
return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="list-tight">
|
||||
{rows.map((d, i) => (
|
||||
<div className="list-row" key={`${d.name}-${i}`}>
|
||||
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="file" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{d.name}</div>
|
||||
<div className="lr-sub">{d.path || 'Stored with the application'}</div>
|
||||
<div className="list-tight">
|
||||
{rows.map((d, i) => (
|
||||
<div className="list-row" key={`${d.name}-${i}`}>
|
||||
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="file" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{d.name}</div>
|
||||
<div className="lr-sub">Stored with the application</div>
|
||||
</div>
|
||||
<button
|
||||
className="act-btn"
|
||||
disabled={!inboxId || download.isPending}
|
||||
title={!inboxId ? 'No application id for download' : 'Download'}
|
||||
onClick={() => download.mutate({ index: i, filename: d.name })}
|
||||
>
|
||||
<Icon name="download" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One scorecard, revisable in place via PATCH /feedback/update.
|
||||
*
|
||||
* Same authorship rule as NoteRow: the route neither checks nor reassigns
|
||||
* `reviewed_by`, so only the original reviewer is offered the control. A
|
||||
* revision keeps their name on it, which is the point.
|
||||
*/
|
||||
function FeedbackRow({ row: f, userId }) {
|
||||
const { user } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
review: f.review || REVIEWS[0],
|
||||
score: f.score == null ? '' : String(f.score),
|
||||
note: f.note || '',
|
||||
})
|
||||
const set = (k, v) => setForm((s) => ({ ...s, [k]: v }))
|
||||
|
||||
const mine = Boolean(user?.id && f.reviewed_by && String(user.id) === String(f.reviewed_by))
|
||||
|
||||
const save = useProfileWrite({
|
||||
userId,
|
||||
mutationFn: () => candidatesApi.updateFeedback(f.id, {
|
||||
review: form.review,
|
||||
score: form.score === '' ? 0 : Number(form.score),
|
||||
note: form.note.trim(),
|
||||
}),
|
||||
success: 'Scorecard updated',
|
||||
onDone: () => setEditing(false),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const score = form.score === '' ? 0 : Number(form.score)
|
||||
if (!Number.isFinite(score) || score < 0 || score > 100) {
|
||||
toast('Score must be between 0 and 100', 'warning')
|
||||
return
|
||||
}
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="list-row" style={{ alignItems: 'flex-start' }}>
|
||||
<Avatar name={f.reviewed_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="form-grid">
|
||||
<div className="form-field">
|
||||
<label>Recommendation</label>
|
||||
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
|
||||
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Score (0–100)</label>
|
||||
<input
|
||||
type="number" min="0" max="100"
|
||||
value={form.score}
|
||||
onChange={(e) => set('score', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="form-field">
|
||||
<label>Notes</label>
|
||||
<textarea value={form.note} onChange={(e) => set('note', e.target.value)} rows={3} />
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<button className="btn btn-primary btn-sm" disabled={save.isPending} onClick={submit}>
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={save.isPending}
|
||||
onClick={() => {
|
||||
setForm({
|
||||
review: f.review || REVIEWS[0],
|
||||
score: f.score == null ? '' : String(f.score),
|
||||
note: f.note || '',
|
||||
})
|
||||
setEditing(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* No download button: attachments live on the worker's filesystem and no
|
||||
route serves them yet, so a button here could only lie. */}
|
||||
<p className="text-muted text-sm" style={{ marginTop: 12 }}>
|
||||
<Icon name="info" /> Attachments are stored server-side; download is not exposed yet.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-row">
|
||||
<Avatar name={f.reviewed_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
|
||||
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
|
||||
<div className="lr-sub">
|
||||
{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}
|
||||
{f.updated_at && f.updated_at !== f.created_at ? ' · revised' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{f.review ? <Badge>{f.review}</Badge> : null}
|
||||
{mine && (
|
||||
<button className="act-btn" data-tip="Revise scorecard" onClick={() => setEditing(true)}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -876,17 +1061,7 @@ function FeedbackTab({ userId, inboxId, rows }) {
|
|||
<>
|
||||
{rows.length ? (
|
||||
<div className="list-tight">
|
||||
{rows.map((f) => (
|
||||
<div className="list-row" key={f.id}>
|
||||
<Avatar name={f.reviewed_by_name || 'Unknown'} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
|
||||
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
|
||||
<div className="lr-sub">{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}</div>
|
||||
</div>
|
||||
<div className="lr-right">{f.review ? <Badge>{f.review}</Badge> : null}</div>
|
||||
</div>
|
||||
))}
|
||||
{rows.map((f) => <FeedbackRow key={f.id} row={f} userId={userId} />)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon="award" title="No scorecards yet">Be the first to review this candidate.</EmptyState>
|
||||
|
|
|
|||
|
|
@ -17,14 +17,15 @@ import Modal from '../ui/Modal'
|
|||
import { Pagination, useDataTable } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import CandidateProfile from './ScoredCandidateProfile'
|
||||
import CandidateProfile, { useJobTitles } from './ScoredCandidateProfile'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
import { persist } from '../data/seedQueries'
|
||||
import { avatarColor, initials as initialsOf, sources, stages } from '../data/seed'
|
||||
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
|
||||
|
||||
const EMPTY_FILTERS = { account: '' }
|
||||
|
||||
|
|
@ -194,9 +195,14 @@ export default function Candidates() {
|
|||
|
||||
const setFilter = (k, v) => setFilters((f) => ({ ...f, [k]: v }))
|
||||
|
||||
/* The gate is the ACCOUNT, not `scoringStatus`. Rows here are `users` rows and
|
||||
toCandidateUserView leaves scoringStatus null by construction, so checking it
|
||||
rejected every candidate on the screen and the ATS Match button only ever
|
||||
toasted. Whether a score exists is the modal's own question — it resolves
|
||||
that from ats_results, which the row cannot know about. */
|
||||
function openAts(c) {
|
||||
if (c.scoringStatus !== 'completed') {
|
||||
toast('This CV could not be scored — no match analysis available', 'info')
|
||||
if (!c.userId) {
|
||||
toast('This candidate has no account to look a score up against', 'info')
|
||||
return
|
||||
}
|
||||
setAtsFor(c)
|
||||
|
|
@ -422,17 +428,82 @@ function Facet({ label, value, onChange, any, options, labels }) {
|
|||
)
|
||||
}
|
||||
|
||||
/** Exported so TalentPool's profile modal can open the same ATS breakdown. */
|
||||
const asList = (value) => (Array.isArray(value) ? value : [])
|
||||
|
||||
/** ISO stamp -> display date; ats_results.computed_at is a string, fmtDate takes a Date. */
|
||||
function fmtStamp(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : fmtDate(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole ATS result for one candidate, assembled from the two places it lives.
|
||||
*
|
||||
* It CANNOT render from the `candidate` prop on this screen: a row here is a
|
||||
* `users` account and toCandidateUserView leaves score, keywords and critique
|
||||
* null by construction, so the modal used to paint an empty shell. It reads the
|
||||
* same two sources ScoredCandidateProfile does, under the same query keys, so
|
||||
* opening it from that profile is a cache hit rather than two more requests:
|
||||
*
|
||||
* - ats_results (GET /pipeline/candidate/score/fetch) — overall_score, band,
|
||||
* the job post the score was computed against, and when.
|
||||
* - the detail payload (GET /candidate/fetch?user_id=) — matched/missing
|
||||
* keywords and the critique, which the route resolves off the scored
|
||||
* `candidates` row: by candidate_id, or by email+job when the CV's address
|
||||
* matched a user account and candidate_id is therefore NULL
|
||||
* (backend/job/candidate/views.py:782-792).
|
||||
*
|
||||
* The prop is the last fallback, for callers whose rows already carry a score
|
||||
* (Talent Pool cards, the scored leaderboard).
|
||||
*
|
||||
* Exported so TalentPool's profile modal can open the same ATS breakdown.
|
||||
*/
|
||||
export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
|
||||
const recommendation = recommendationOf(c)
|
||||
const userId = c.userId ?? null
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: qk.candidates.detail(userId),
|
||||
queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,
|
||||
enabled: Boolean(userId),
|
||||
})
|
||||
const ats = useQuery({
|
||||
queryKey: qk.pipeline.candidateScore({ userId }),
|
||||
queryFn: () => pipelineApi.fetchCandidateScore({ userId }),
|
||||
select: pipelineApi.toAtsScore,
|
||||
enabled: Boolean(userId),
|
||||
})
|
||||
const { data: jobTitles } = useJobTitles()
|
||||
|
||||
const live = detail.data ?? null
|
||||
const row = ats.data ?? null
|
||||
|
||||
// ats_results wins over the detail payload's denormalised copy, because it is
|
||||
// the row the copy is made from; the prop is the fallback for rows that came
|
||||
// from the scored leaderboard already carrying one.
|
||||
const score = row?.overall_score ?? live?.ai_score ?? c.aiScore ?? null
|
||||
const matched = asList(live?.matched_keywords).length
|
||||
? asList(live.matched_keywords) : asList(c.matchedSkills)
|
||||
const missing = asList(live?.missing_keywords).length
|
||||
? asList(live.missing_keywords) : asList(c.missingSkills)
|
||||
const critique = live?.summary_critique ?? c.critique ?? null
|
||||
const scoredJobId = row?.job_post_id ?? live?.scored_job_post_id ?? null
|
||||
const against = (scoredJobId && jobTitles?.get(String(scoredJobId)))
|
||||
|| live?.job_title
|
||||
|| (jobTitle && jobTitle !== '—' ? jobTitle : null)
|
||||
const scoredOn = fmtStamp(row?.computed_at ?? live?.scored_at)
|
||||
|
||||
const recommendation = row?.band || live?.recommendation || recommendationOf({ aiScore: score })
|
||||
const recCls = recommendation === 'Strong Match' ? 'recc-strong'
|
||||
: recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
|
||||
const ringColor = c.aiScore >= 82 ? 'var(--success)' : c.aiScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
||||
const ringColor = score >= 82 ? 'var(--success)' : score >= 65 ? 'var(--warning)' : 'var(--danger)'
|
||||
|
||||
const pending = Boolean(userId) && (ats.isPending || detail.isPending)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="ATS Match Analysis"
|
||||
subtitle={jobTitle}
|
||||
subtitle={against ?? jobTitle}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
|
|
@ -442,59 +513,77 @@ export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
|
|||
</>
|
||||
}
|
||||
>
|
||||
<div className={`recc-banner ${recCls}`}>
|
||||
<span className="recc-icn">
|
||||
<Icon name={recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="fw-600" style={{ fontSize: 15 }}>{recommendation}</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name}{jobTitle ? ` for ${jobTitle}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-2" style={{ alignItems: 'center', marginBottom: 20 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="ats-ring" style={{ '--pct': c.aiScore, '--c': ringColor }}>
|
||||
<div className="ats-val">
|
||||
<div className="ats-num">{c.aiScore}</div>
|
||||
<div className="ats-lbl">ATS MATCH</div>
|
||||
</div>
|
||||
{pending ? (
|
||||
<EmptyState icon="refresh" title="Loading match analysis…">
|
||||
Fetching the ATS result.
|
||||
</EmptyState>
|
||||
) : score == null ? (
|
||||
<EmptyState icon="target" title="Not scored yet">
|
||||
{ats.isError
|
||||
? friendlyAuthError(ats.error, 'The ATS result could not be loaded.')
|
||||
: 'This candidate has not been scored against a job post.'}
|
||||
</EmptyState>
|
||||
) : (<>
|
||||
<div className={`recc-banner ${recCls}`}>
|
||||
<span className="recc-icn">
|
||||
<Icon name={recommendation === 'Weak Match' ? 'x-circle' : 'check-circle'} />
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="fw-600" style={{ fontSize: 15 }}>{recommendation}</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name}{against ? ` for ${against}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Assessment</div>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>{c.critique ?? '—'}</p>
|
||||
|
||||
<div className="grid g-2" style={{ alignItems: 'center', marginBottom: 20 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{/* overall_score is a float column; the ring and the number want an int. */}
|
||||
<div className="ats-ring" style={{ '--pct': Math.round(score), '--c': ringColor }}>
|
||||
<div className="ats-val">
|
||||
<div className="ats-num">{Math.round(score)}</div>
|
||||
<div className="ats-lbl">ATS MATCH</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Assessment</div>
|
||||
<p className="text-muted" style={{ fontSize: 13 }}>{critique ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||||
Matched Skills ({c.matchedSkills.length})
|
||||
</div>
|
||||
<div className="k-tags" style={{ marginBottom: 16 }}>
|
||||
{c.matchedSkills.length
|
||||
? c.matchedSkills.map((s) => (
|
||||
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
|
||||
))
|
||||
: <span className="text-muted">—</span>}
|
||||
</div>
|
||||
<div className="info-grid" style={{ marginBottom: 20 }}>
|
||||
<div className="info-item"><div className="il">Scored Against</div><div className="iv">{against ?? '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Scored On</div><div className="iv">{scoredOn ?? '—'}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||||
Missing Skills ({c.missingSkills.length})
|
||||
</div>
|
||||
<div className="k-tags">
|
||||
{c.missingSkills.length
|
||||
? c.missingSkills.map((s) => (
|
||||
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
|
||||
))
|
||||
: <span className="text-muted">None — full match</span>}
|
||||
</div>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||||
Matched Skills ({matched.length})
|
||||
</div>
|
||||
<div className="k-tags" style={{ marginBottom: 16 }}>
|
||||
{matched.length
|
||||
? matched.map((s) => (
|
||||
<span className="skill-pill skill-matched" key={s}><Icon name="check" /> {s}</span>
|
||||
))
|
||||
: <span className="text-muted">—</span>}
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<p className="text-muted text-sm">
|
||||
<Icon name="sparkles" /> Scored by the ATS engine against the job post's requirements.
|
||||
Matched skills are verified to appear in the resume text; the one-line assessment is
|
||||
model-generated and evidence-based.
|
||||
</p>
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>
|
||||
Missing Skills ({missing.length})
|
||||
</div>
|
||||
<div className="k-tags">
|
||||
{missing.length
|
||||
? missing.map((s) => (
|
||||
<span className="skill-pill skill-missing" key={s}><Icon name="x" /> {s}</span>
|
||||
))
|
||||
: <span className="text-muted">None — full match</span>}
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<p className="text-muted text-sm">
|
||||
<Icon name="sparkles" /> Scored by the ATS engine against the job post's requirements.
|
||||
Matched skills are verified to appear in the resume text; the one-line assessment is
|
||||
model-generated and evidence-based.
|
||||
</p>
|
||||
</>)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import Charts from '../lib/charts'
|
|||
import { Avatar, EmptyState, Icon, KpiCard, ScoreChip } from '../ui/primitives'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import { ApiError, friendlyAuthError } from '../lib/errors'
|
||||
import { fmtShort, money, relTime, initials as initialsOf, avatarColor } from '../data/seed'
|
||||
import * as analyticsApi from '../api/analytics'
|
||||
import * as interviewsApi from '../api/interviews'
|
||||
|
|
@ -121,6 +121,29 @@ function mapActivityRow(a) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What actually went wrong, rather than one guess applied to everything.
|
||||
*
|
||||
* Every widget used to append "This widget needs the <permission> permission"
|
||||
* to EVERY error, so a 500, an expired session and a dead API server all read
|
||||
* on screen as a permissions problem — the misleading state called out in
|
||||
* backend/README.md's known issues. The permission line is now shown only for
|
||||
* the status that actually means it (403), and the status is always named so a
|
||||
* server fault is diagnosable from the page.
|
||||
*/
|
||||
function widgetError(err, permission, fallback) {
|
||||
const status = err instanceof ApiError ? err.status : null
|
||||
const message = friendlyAuthError(err, fallback)
|
||||
if (status === 403) {
|
||||
return <>{message} This widget needs the <code>{permission}</code> permission.</>
|
||||
}
|
||||
if (status === 401) return <>{message} Sign in again to reload this widget.</>
|
||||
// status 0 is the client-side "could not reach the server" ApiError, whose
|
||||
// message already says so; naming a fake HTTP status there would be a lie.
|
||||
if (!status) return <>{message}</>
|
||||
return <>HTTP {status} — {message}</>
|
||||
}
|
||||
|
||||
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
|
||||
if (query.isPending) {
|
||||
return (
|
||||
|
|
@ -132,8 +155,7 @@ function ListGate({ query, title, permission, children, emptyTitle, emptyHint })
|
|||
if (query.isError) {
|
||||
return (
|
||||
<EmptyState icon="alert" title={`Couldn’t load ${title}`}>
|
||||
{friendlyAuthError(query.error, `The server did not return ${title}.`)}
|
||||
{' '}This widget needs the <code>{permission}</code> permission.
|
||||
{widgetError(query.error, permission, `The server did not return ${title}.`)}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
|
|
@ -373,8 +395,7 @@ export default function Dashboard() {
|
|||
<div className="card-body">
|
||||
{trendQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load hiring trend">
|
||||
{friendlyAuthError(trendQuery.error, 'The server did not return the trend.')}
|
||||
{' '}This widget needs the <code>analytics.view</code> permission.
|
||||
{widgetError(trendQuery.error, 'analytics.view', 'The server did not return the trend.')}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -396,8 +417,7 @@ export default function Dashboard() {
|
|||
<div className="card-body">
|
||||
{funnelQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load pipeline">
|
||||
{friendlyAuthError(funnelQuery.error, 'The server did not return the funnel.')}
|
||||
{' '}This widget needs the <code>analytics.view</code> permission.
|
||||
{widgetError(funnelQuery.error, 'analytics.view', 'The server did not return the funnel.')}
|
||||
</EmptyState>
|
||||
) : asList(funnelQuery.data).length === 0 && funnelQuery.isSuccess ? (
|
||||
<EmptyState icon="inbox" title="No pipeline data yet">
|
||||
|
|
@ -468,8 +488,7 @@ export default function Dashboard() {
|
|||
<div className="card-body">
|
||||
{sourcesQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load sources">
|
||||
{friendlyAuthError(sourcesQuery.error, 'The server did not return source analytics.')}
|
||||
{' '}This widget needs the <code>analytics.view</code> permission.
|
||||
{widgetError(sourcesQuery.error, 'analytics.view', 'The server did not return source analytics.')}
|
||||
</EmptyState>
|
||||
) : asList(sourcesQuery.data).length === 0 && sourcesQuery.isSuccess ? (
|
||||
<EmptyState icon="inbox" title="No source data yet">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
|
|
@ -22,35 +23,24 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import {
|
||||
atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob,
|
||||
initials as initialsOf, inboxSources, int, locations, pick, relTime, sourceMeta,
|
||||
TODAY,
|
||||
atsRecommendationClass, avatarColor, fmtDate, fmtShort, initials as initialsOf,
|
||||
inboxSources, relTime, sourceMeta,
|
||||
} from '../data/seed'
|
||||
|
||||
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
|
||||
|
||||
/**
|
||||
* Server-side filters for the tabs that /inbox/all-applications can narrow.
|
||||
* Unfiltered tabs (and countsQuery) pass `{}` so the backend defaults apply —
|
||||
* isread=true and application_status=CLOSED both mean "no filter".
|
||||
* Server-side filters for tabs /inbox/all-applications can narrow.
|
||||
* Imported / Processed / Rejected / Duplicates filter client-side on
|
||||
* processing_state + is_duplicate (see GET /inbox/counts).
|
||||
*/
|
||||
const TAB_FILTERS = {
|
||||
Unread: { isread: false },
|
||||
Processed: { applicationStatus: 'PROCESS' },
|
||||
Rejected: { applicationStatus: 'REJECTED' },
|
||||
}
|
||||
|
||||
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
|
||||
const NOW = new Date('2026-07-09T20:00')
|
||||
|
||||
/**
|
||||
* The seed candidate record importEmail() writes needs a number. The agent
|
||||
* returns a verdict, not a score, so there is nothing on the wire to use —
|
||||
* named here so the fabricated value is visible at its point of use instead of
|
||||
* arriving disguised as a server field on every message.
|
||||
*/
|
||||
const SEED_ATS_SCORE = 70
|
||||
|
||||
/**
|
||||
* message_received_time / message_sent_time are plain string columns
|
||||
* (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields
|
||||
|
|
@ -147,6 +137,8 @@ async function fetchMessageDetail(recordId) {
|
|||
attachment: row.attachment_name,
|
||||
hasAttachment: Boolean(row.attachment),
|
||||
body: htmlToText(row.body),
|
||||
// Raw markup for the HTML viewer; `body` stays the plain-text fallback.
|
||||
bodyHtml: row.body || '',
|
||||
cc: row.message_cc || '',
|
||||
bcc: row.message_bcc || '',
|
||||
sentAt: parseDate(row.message_sent_time),
|
||||
|
|
@ -162,12 +154,8 @@ async function fetchMessageDetail(recordId) {
|
|||
/**
|
||||
* GET /inbox/all-applications -> the shape the application tabs render.
|
||||
*
|
||||
* READ-ONLY: inbox_messages has no columns for duplicates, recruiter, phone,
|
||||
* experience or an ATS score, so those arrive null and every mutating action
|
||||
* on these tabs is disabled until the endpoints exist. `processing` is derived
|
||||
* from message_read alone (Read/Unread). Processed / Rejected tabs filter on
|
||||
* `application_status` (PROCESS / REJECTED); Imported / Duplicates stay empty
|
||||
* with no backing columns.
|
||||
* `processing` prefers processing_state (imported/processed/rejected); otherwise
|
||||
* Read/Unread from message_read. Duplicate comes from is_duplicate.
|
||||
*/
|
||||
async function fetchApplications(params) {
|
||||
const res = await inboxApi.listApplications(params)
|
||||
|
|
@ -185,6 +173,7 @@ async function fetchApplications(params) {
|
|||
received: parseDate(row.received),
|
||||
unread: Boolean(row.unread),
|
||||
processing: row.processing || 'Unread',
|
||||
processingState: row.processing_state || null,
|
||||
applicationStatus: row.application_status || null,
|
||||
resumeStatus: row.resume_status || 'Pending',
|
||||
attachment: row.attachment,
|
||||
|
|
@ -199,6 +188,9 @@ async function fetchApplications(params) {
|
|||
})
|
||||
}
|
||||
|
||||
// Shared with the sidebar badge through qk.mailbox.counts — see inboxApi.fetchCounts.
|
||||
const fetchInboxCounts = inboxApi.fetchCounts
|
||||
|
||||
/**
|
||||
* POST /inbox/{record_id}/read — flips message_read false -> true for one row.
|
||||
*
|
||||
|
|
@ -239,10 +231,8 @@ export default function Inbox() {
|
|||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const updateInbox = useSeedMutation('inbox')
|
||||
const updateCandidates = useSeedMutation('candidates')
|
||||
|
||||
const [tab, setTab] = useState('All Applications')
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
|
|
@ -261,21 +251,14 @@ export default function Inbox() {
|
|||
enabled: tab !== 'Email',
|
||||
})
|
||||
|
||||
/**
|
||||
* The tab badges need whole-table counts, which a server-filtered response
|
||||
* cannot give — and there is no counts endpoint. So the unfiltered set stays
|
||||
* loaded for them. On every tab without a TAB_FILTERS entry this resolves to
|
||||
* the SAME query key as the list above, so React Query serves both from one
|
||||
* request.
|
||||
*/
|
||||
const countsQuery = useQuery({
|
||||
queryKey: qk.mailbox.applications({}),
|
||||
queryFn: () => fetchApplications({}),
|
||||
queryKey: qk.mailbox.counts(),
|
||||
queryFn: fetchInboxCounts,
|
||||
enabled: tab !== 'Email',
|
||||
})
|
||||
|
||||
const inbox = applicationsQuery.data ?? []
|
||||
const allApplications = countsQuery.data ?? []
|
||||
const serverCounts = countsQuery.data ?? {}
|
||||
|
||||
const emailsQuery = useQuery({
|
||||
queryKey: qk.mailbox.messages(),
|
||||
|
|
@ -307,30 +290,24 @@ export default function Inbox() {
|
|||
})
|
||||
|
||||
const counts = useMemo(
|
||||
// Counted off the UNFILTERED set — `inbox` is server-filtered on Unread /
|
||||
// Processed / Rejected, so counting it there would report that tab's total
|
||||
// for every badge.
|
||||
() => ({
|
||||
'All Applications': allApplications.length,
|
||||
Unread: allApplications.filter((i) => i.processing === 'Unread').length,
|
||||
Imported: allApplications.filter((i) => i.processing === 'Imported').length,
|
||||
Processed: allApplications.filter((i) => i.applicationStatus === 'PROCESS').length,
|
||||
Rejected: allApplications.filter((i) => i.applicationStatus === 'REJECTED').length,
|
||||
Duplicates: allApplications.filter((i) => i.duplicate).length,
|
||||
'All Applications': serverCounts.all ?? 0,
|
||||
Unread: serverCounts.unread ?? 0,
|
||||
Imported: serverCounts.imported ?? 0,
|
||||
Processed: serverCounts.processed ?? 0,
|
||||
Rejected: serverCounts.rejected ?? 0,
|
||||
Duplicates: serverCounts.duplicates ?? 0,
|
||||
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
|
||||
}),
|
||||
[allApplications, emailsQuery.data],
|
||||
[serverCounts, emailsQuery.data],
|
||||
)
|
||||
|
||||
const list = useMemo(() => {
|
||||
let l = inbox
|
||||
// Unread / Processed / Rejected are already filtered server-side; re-applying
|
||||
// client-side keeps the optimistic mark-read drop-off for Unread, and keeps
|
||||
// Processed/Rejected coherent if a stale cache briefly holds mixed rows.
|
||||
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
|
||||
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
|
||||
else if (tab === 'Processed') l = l.filter((i) => i.applicationStatus === 'PROCESS')
|
||||
else if (tab === 'Rejected') l = l.filter((i) => i.applicationStatus === 'REJECTED')
|
||||
else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed')
|
||||
else if (tab === 'Rejected') l = l.filter((i) => i.processing === 'Rejected')
|
||||
else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate)
|
||||
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
|
||||
return l
|
||||
|
|
@ -350,63 +327,56 @@ export default function Inbox() {
|
|||
? { ...selectedRow, ...(detailQuery.data ?? {}) }
|
||||
: null
|
||||
|
||||
// The one mutation these tabs CAN persist — everything else on them is
|
||||
// disabled until the endpoints exist.
|
||||
const markRead = useMarkRead(toast)
|
||||
|
||||
const setState = useMutation({
|
||||
mutationFn: ({ id, state }) => inboxApi.setProcessingState(id, state),
|
||||
onSuccess: (_data, vars) => {
|
||||
const labels = { imported: 'Imported', processed: 'Processed', rejected: 'Rejected', unread: 'Unread' }
|
||||
toast(`${vars.name || 'Application'} marked ${labels[vars.state] || vars.state}`, vars.state === 'rejected' ? 'warning' : 'success')
|
||||
if (vars.state === 'processed') setTimeout(() => navigate('/pipeline'), 700)
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update processing state.'), 'error'),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
|
||||
},
|
||||
})
|
||||
|
||||
const markDuplicate = useMutation({
|
||||
mutationFn: ({ id, isDuplicate }) => inboxApi.setDuplicate(id, isDuplicate),
|
||||
onSuccess: (_d, vars) => toast(vars.isDuplicate ? 'Marked as duplicate' : 'Duplicate cleared', 'success'),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update duplicate flag.'), 'error'),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
|
||||
},
|
||||
})
|
||||
|
||||
function select(id) {
|
||||
setSelectedId(id)
|
||||
const item = inbox.find((i) => i.id === id)
|
||||
if (item?.unread) markRead.mutate(id)
|
||||
}
|
||||
|
||||
function makeCandidate(item, job, cs) {
|
||||
return {
|
||||
id: `CAN-${5001 + cs.length}`,
|
||||
name: item.name, initials: item.initials, color: item.color,
|
||||
email: item.email, phone: item.phone,
|
||||
jobId: job.id, jobTitle: job.title, department: job.department,
|
||||
experience: item.experience, currentCompany: pick(companies), currentTitle: job.title,
|
||||
location: pick(locations), stage: 'Applied', status: 'Applied',
|
||||
aiScore: item.atsScore, source: item.source, recruiter: item.recruiter, recruiterId: '',
|
||||
applied: new Date(TODAY), education: "Bachelor's Degree",
|
||||
skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000,
|
||||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
||||
recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
|
||||
subScores: { skills: item.atsScore, experience: item.atsScore, education: 80, keywords: item.atsScore, location: 100, salary: 90 },
|
||||
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
|
||||
favorite: false, interviewStatus: 'Not Scheduled',
|
||||
}
|
||||
}
|
||||
|
||||
function importItem(item) {
|
||||
const job = getJob(item.jobId) || jobs[0]
|
||||
updateCandidates((cs) => [makeCandidate(item, job, cs), ...cs])
|
||||
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Imported', unread: false } : i)))
|
||||
toast(`${item.name} imported → Applied stage of ${job.title}`, 'success')
|
||||
setState.mutate({ id: item.id, state: 'imported', name: item.name })
|
||||
}
|
||||
|
||||
function parseResume(item) {
|
||||
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsing' } : i)))
|
||||
toast('Parsing resume with AI…', 'info')
|
||||
setTimeout(() => {
|
||||
updateInbox((items) =>
|
||||
items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsed', atsScore: int(60, 96) } : i)),
|
||||
)
|
||||
toast('Resume parsed — profile fields extracted', 'success')
|
||||
}, 1100)
|
||||
function parseResume() {
|
||||
toast('Resume parsing runs via the matching agent — use Assign Job / Rematch.', 'info')
|
||||
}
|
||||
|
||||
function moveToPipeline(item) {
|
||||
if (item.processing !== 'Imported' && item.processing !== 'Processed') importItem(item)
|
||||
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Processed' } : i)))
|
||||
toast(`${item.name} moved to pipeline`, 'success')
|
||||
setTimeout(() => navigate('/pipeline'), 700)
|
||||
setState.mutate({ id: item.id, state: 'processed', name: item.name })
|
||||
}
|
||||
|
||||
function reject(item) {
|
||||
updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Rejected', unread: false } : i)))
|
||||
toast(`${item.name} rejected`, 'warning')
|
||||
setState.mutate({ id: item.id, state: 'rejected', name: item.name })
|
||||
}
|
||||
|
||||
function toggleDuplicate(item) {
|
||||
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate })
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -443,7 +413,7 @@ export default function Inbox() {
|
|||
</div>
|
||||
|
||||
{tab === 'Email' ? (
|
||||
<EmailTab query={emailsQuery} jobs={jobs} updateCandidates={updateCandidates} toast={toast} />
|
||||
<EmailTab query={emailsQuery} toast={toast} />
|
||||
) : (
|
||||
<div className="split">
|
||||
<div className="split-list">
|
||||
|
|
@ -521,12 +491,14 @@ export default function Inbox() {
|
|||
<ApplicationDetail
|
||||
item={selected}
|
||||
loading={detailQuery.isPending}
|
||||
busy={setState.isPending || markDuplicate.isPending}
|
||||
onPreview={() => setPreviewing(selected)}
|
||||
onImport={() => importItem(selected)}
|
||||
onParse={() => parseResume(selected)}
|
||||
onParse={() => parseResume()}
|
||||
onMove={() => moveToPipeline(selected)}
|
||||
onNote={() => setNoting(selected)}
|
||||
onReject={() => reject(selected)}
|
||||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -546,8 +518,6 @@ export default function Inbox() {
|
|||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
|
||||
disabled
|
||||
title="Needs a backend endpoint — not implemented yet"
|
||||
>
|
||||
<Icon name="user-plus" /> Import Candidate
|
||||
</button>
|
||||
|
|
@ -602,13 +572,12 @@ function orDash(value, suffix = '') {
|
|||
return value == null || value === '' ? '—' : `${value}${suffix}`
|
||||
}
|
||||
|
||||
function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onMove, onNote, onReject }) {
|
||||
function ApplicationDetail({
|
||||
item: i, loading, busy, onPreview, onImport, onParse, onMove, onNote, onReject, onToggleDuplicate,
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
||||
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
|
||||
// Every action below writes to a table column or an endpoint that does not
|
||||
// exist yet, so they are disabled rather than silently dropping the click.
|
||||
const noBackend = 'Needs a backend endpoint — not implemented yet'
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
|
|
@ -619,6 +588,7 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
|
|||
<div className="ph-role">{i.position}</div>
|
||||
<div className="ph-tags" style={{ marginTop: 8 }}>
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
|
||||
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
|
||||
<><Badge>{i.applicationStatus}</Badge>{' '}</>
|
||||
)}
|
||||
|
|
@ -647,8 +617,6 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
|
|||
<div className="il">Received</div>
|
||||
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
|
||||
</div>
|
||||
{/* Only present once GET /inbox/fetch?record_id= has resolved — the list
|
||||
endpoint carries none of these. */}
|
||||
{i.sentAt && (
|
||||
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
|
||||
)}
|
||||
|
|
@ -662,13 +630,17 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Body arrives only from GET /inbox/fetch?record_id= — the list endpoint
|
||||
does not carry it. Already run through htmlToText, and still rendered as
|
||||
TEXT: inbound mail is attacker-supplied. A body that is only an empty
|
||||
HTML skeleton flattens to '' and the block is skipped entirely. */}
|
||||
{/* Same Subject-strip + framed-body template as /matching. */}
|
||||
{!loading && (
|
||||
<div className="email-preview" style={{ marginBottom: 20 }}>
|
||||
{i.body || <span className="text-muted">This email has no message body.</span>}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="email-head">Subject: {i.position || '(no subject)'}</div>
|
||||
{looksLikeHtml(i.bodyHtml) ? (
|
||||
<EmailBody html={i.bodyHtml} />
|
||||
) : (
|
||||
<pre className="resume-thumb is-full email-plain">
|
||||
{i.body || 'This email has no message body.'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -684,8 +656,6 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
|
|||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
||||
</div>
|
||||
{/* The real extracted PDF text (inbox_messages.resume_text), written
|
||||
by the matching task. Empty until that task has run. */}
|
||||
<pre className="resume-thumb">
|
||||
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
|
||||
</pre>
|
||||
|
|
@ -694,10 +664,10 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
|
|||
)}
|
||||
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
|
||||
<Icon name="user-plus" /> Import Candidate
|
||||
<button className="btn btn-primary" onClick={onImport} disabled={busy || i.processing === 'Imported'}>
|
||||
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
|
||||
<button className="btn btn-secondary" onClick={onParse}>
|
||||
<Icon name="sparkles" /> Parse Resume
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -706,20 +676,22 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
|
|||
>
|
||||
<Icon name="target" /> Assign Job
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
|
||||
<button className="btn btn-secondary" onClick={onMove} disabled={busy || i.processing === 'Processed'}>
|
||||
<Icon name="layers" /> Move to Pipeline
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onNote} disabled title={noBackend}>
|
||||
<button className="btn btn-secondary" onClick={onNote} disabled title="Notes attach to a candidate profile — open the candidate first">
|
||||
<Icon name="edit" /> Add Note
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onToggleDuplicate} disabled={busy}>
|
||||
<Icon name="alert" /> {i.duplicate ? 'Clear Duplicate' : 'Mark Duplicate'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={onReject}
|
||||
disabled
|
||||
title={noBackend}
|
||||
disabled={busy || i.processing === 'Rejected'}
|
||||
>
|
||||
<Icon name="x" /> Reject
|
||||
<Icon name="trash" /> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -755,10 +727,12 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) {
|
|||
}
|
||||
|
||||
/** The live tab: real fetch, real loading state, real error state. */
|
||||
function EmailTab({ query, jobs, updateCandidates, toast }) {
|
||||
function EmailTab({ query, toast }) {
|
||||
const qc = useQueryClient()
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [imported, setImported] = useState(() => new Set())
|
||||
const [replying, setReplying] = useState(null)
|
||||
const [replyBody, setReplyBody] = useState('')
|
||||
const [importedIds, setImportedIds] = useState(() => new Set())
|
||||
|
||||
const emails = query.data ?? []
|
||||
const selected = emails.find((e) => e.id === selectedId)
|
||||
|
|
@ -766,10 +740,6 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
|
||||
const markRead = useMarkRead(toast)
|
||||
|
||||
// Refetching the list alone only re-reads rows already in our DB. GET
|
||||
// /email/fetch is the Graph proxy pull that inserts new mail and enqueues the
|
||||
// matching agent, so it has to run FIRST — then the list is invalidated to
|
||||
// pick up whatever it wrote.
|
||||
const sync = useMutation({
|
||||
mutationFn: () => inboxApi.syncMailbox(),
|
||||
onSuccess: async () => {
|
||||
|
|
@ -779,39 +749,36 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
|
||||
})
|
||||
|
||||
function importEmail(e) {
|
||||
const job = jobs[0]
|
||||
if (!job) return
|
||||
updateCandidates((cs) => [
|
||||
{
|
||||
id: `CAN-${5001 + cs.length}`,
|
||||
name: e.from, initials: initialsOf(e.from), color: avatarColor(e.from),
|
||||
email: e.fromEmail, phone: '+1 (555) 000-0000',
|
||||
jobId: job.id, jobTitle: job.title, department: job.department,
|
||||
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
|
||||
location: pick(locations), stage: 'Applied', status: 'Applied',
|
||||
aiScore: SEED_ATS_SCORE, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '',
|
||||
applied: new Date(TODAY), education: "Bachelor's Degree",
|
||||
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
|
||||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
|
||||
recommendation: 'Potential Match',
|
||||
subScores: { skills: SEED_ATS_SCORE, experience: 80, education: 80, keywords: SEED_ATS_SCORE, location: 100, salary: 90 },
|
||||
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
|
||||
favorite: false, interviewStatus: 'Not Scheduled',
|
||||
},
|
||||
...cs,
|
||||
])
|
||||
setImported((s) => new Set(s).add(e.id))
|
||||
toast(`${e.from} imported from Outlook → ${job.title}`, 'success')
|
||||
}
|
||||
const importMsg = useMutation({
|
||||
mutationFn: (id) => inboxApi.setProcessingState(id, 'imported'),
|
||||
onSuccess: (_d, id) => {
|
||||
setImportedIds((s) => new Set(s).add(id))
|
||||
toast('Marked as imported', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not import.'), 'error'),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
|
||||
},
|
||||
})
|
||||
|
||||
const isImported = (e) => imported.has(e.id)
|
||||
const reply = useMutation({
|
||||
mutationFn: ({ recordId, body }) => inboxApi.replyEmail({ recordId, body }),
|
||||
onSuccess: () => {
|
||||
toast('Reply sent', 'success')
|
||||
setReplying(null)
|
||||
setReplyBody('')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not send reply.'), 'error'),
|
||||
})
|
||||
|
||||
function selectEmail(e) {
|
||||
setSelectedId(e.id)
|
||||
if (e.unread) markRead.mutate(e.id)
|
||||
}
|
||||
|
||||
const isImported = (e) => importedIds.has(e.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
|
|
@ -871,10 +838,6 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 6 }}>
|
||||
<h2 style={{ fontSize: 18, flex: 1 }}>{selected.subject}</h2>
|
||||
{isImported(selected) ? <Badge className="b-green">Imported</Badge> : <Badge className="b-blue">New</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 20 }}>
|
||||
<Avatar name={selected.from} />
|
||||
<div>
|
||||
|
|
@ -885,10 +848,22 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rendered as TEXT. This is js/inbox.js:292, the widest XSS sink
|
||||
in the prototype, and inbound mail is attacker-supplied. */}
|
||||
<div className="email-preview" style={{ marginBottom: 18, whiteSpace: 'pre-wrap' }}>
|
||||
{selected.body}
|
||||
{/* Same Subject-strip + framed-body template as /matching. It replaces
|
||||
the old <h2> subject rather than sitting under it — two subject
|
||||
lines on one panel is worse than none. The Imported/New badge
|
||||
moves into the strip, which is where a mail client puts status. */}
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div className="email-head flex items-center gap-12" style={{ justifyContent: 'space-between' }}>
|
||||
<span>Subject: {selected.subject || '(no subject)'}</span>
|
||||
{isImported(selected) ? <Badge className="b-green">Imported</Badge> : <Badge className="b-blue">New</Badge>}
|
||||
</div>
|
||||
{looksLikeHtml(selected.body) ? (
|
||||
<EmailBody html={selected.body} />
|
||||
) : (
|
||||
<pre className="resume-thumb is-full email-plain">
|
||||
{htmlToText(selected.body) || 'This email has no message body.'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="attach-card" style={{ marginBottom: 18 }}>
|
||||
|
|
@ -897,32 +872,62 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
|
|||
<div className="fw-600">{selected.attachment}</div>
|
||||
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
|
||||
<Icon name="eye" /> Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
{isImported(selected) ? (
|
||||
<button className="btn btn-secondary" disabled><Icon name="check" /> Already Imported</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => importEmail(selected)}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={importMsg.isPending}
|
||||
onClick={() => importMsg.mutate(selected.id)}
|
||||
>
|
||||
<Icon name="user-plus" /> Import Candidate
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={() => toast('Reply drafted', 'info')}>
|
||||
<button className="btn btn-secondary" onClick={() => { setReplying(selected); setReplyBody('') }}>
|
||||
<Icon name="mail" /> Reply
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={() => toast('Email archived', 'info')}>
|
||||
<Icon name="trash" /> Archive
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{replying && (
|
||||
<Modal
|
||||
title="Reply"
|
||||
subtitle={`Re: ${replying.subject || '(no subject)'}`}
|
||||
onClose={() => setReplying(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setReplying(null)} disabled={reply.isPending}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={reply.isPending || !replyBody.trim()}
|
||||
onClick={() => reply.mutate({ recordId: replying.id, body: replyBody.trim() })}
|
||||
>
|
||||
<Icon name="send" /> {reply.isPending ? 'Sending…' : 'Send Reply'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="form-field">
|
||||
<label>To</label>
|
||||
<input value={replying.fromEmail || ''} disabled />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Message</label>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={replyBody}
|
||||
onChange={(e) => setReplyBody(e.target.value)}
|
||||
placeholder="Write your reply…"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,71 @@
|
|||
/* ============================================================
|
||||
Interviews — live on GET /interview/fetch (range mode).
|
||||
|
||||
Scheduling writes POST /interview/create, the row actions write
|
||||
PATCH /interview/update, and the scorecard writes POST /feedback/create
|
||||
against the same application. Templates come from /feedback/templates/fetch.
|
||||
|
||||
FOUR COLUMNS THE PROTOTYPE HAD ARE GONE. `serialize_interview` returns seven
|
||||
fields and the `interviews` table has no more columns than that, so meeting
|
||||
mode, duration, the interviewer list and the feedback verdict have no source.
|
||||
They are dropped rather than rendered as permanent em-dashes — the rule Jobs,
|
||||
Candidates and Inbox already follow. Job title is not on the interview row
|
||||
either; it is hydrated from the pipeline application the interview hangs off.
|
||||
|
||||
An interview is scoped to an APPLICATION (inbox.id), which is why the
|
||||
candidate picker reads the pipeline board rather than the candidate list:
|
||||
that payload is the only one carrying inbox_id, the person and the role
|
||||
together. Manual-upload candidates have no inbox row and therefore cannot be
|
||||
scheduled here at all — the picker says so instead of silently omitting them.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, AvatarStack, Badge, Icon, KpiCard } from '../ui/primitives'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import {
|
||||
candidates as allCandidates, evalTemplates, fmtShort, interviewTypes, meetingTypes,
|
||||
} from '../data/seed'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as interviewsApi from '../api/interviews'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as feedbackApi from '../api/feedback'
|
||||
import { INTERVIEW_STATUSES, INTERVIEW_TYPES } from '../api/interviews'
|
||||
import { byInboxId, useApplications } from '../lib/useApplications'
|
||||
import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const FETCH_TOP = 200
|
||||
|
||||
const STATUS_CLASS = {
|
||||
Scheduled: 'b-blue',
|
||||
Completed: 'b-green',
|
||||
Cancelled: 'b-red',
|
||||
'No Show': 'b-amber',
|
||||
}
|
||||
|
||||
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
|
||||
function toInstant(date, time) {
|
||||
if (!date) return null
|
||||
const d = new Date(`${date}T${time || '09:00'}`)
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
||||
}
|
||||
|
||||
function clock(d) {
|
||||
return d ? d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : '—'
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
return Boolean(a && b) && a.toDateString() === b.toDateString()
|
||||
}
|
||||
|
||||
export default function Interviews() {
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const { data: managers = [] } = useQuery(seedQuery('managers'))
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
|
|
@ -32,77 +77,150 @@ export default function Interviews() {
|
|||
if (location.state?.openSchedule) setScheduling(true)
|
||||
}, [location.state])
|
||||
|
||||
const stats = useMemo(
|
||||
() => ({
|
||||
scheduled: interviews.filter((i) => i.status === 'Scheduled').length,
|
||||
completed: interviews.filter((i) => i.status === 'Completed').length,
|
||||
today: 5,
|
||||
cancelled: interviews.filter((i) => ['Cancelled', 'No Show'].includes(i.status)).length,
|
||||
}),
|
||||
[interviews],
|
||||
/* Status is filtered SERVER-side (the range branch takes it), round is not —
|
||||
there is no type param on the route, so that select stays client-side. */
|
||||
const listQuery = useQuery({
|
||||
queryKey: qk.interviews.range({ top: FETCH_TOP, status: status || null }),
|
||||
queryFn: async () => {
|
||||
const res = await interviewsApi.listRange({ top: FETCH_TOP, status: status || undefined })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(interviewsApi.toInterviewView)
|
||||
},
|
||||
})
|
||||
|
||||
/* The KPI strip must count the WHOLE table, not the filtered page, so it
|
||||
reads the unfiltered set. With no status filter this resolves to the same
|
||||
query key as the list above and React Query serves both from one request. */
|
||||
const allQuery = useQuery({
|
||||
queryKey: qk.interviews.range({ top: FETCH_TOP, status: null }),
|
||||
queryFn: async () => {
|
||||
const res = await interviewsApi.listRange({ top: FETCH_TOP })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(interviewsApi.toInterviewView)
|
||||
},
|
||||
})
|
||||
|
||||
const appsQuery = useApplications()
|
||||
const appByInbox = useMemo(() => byInboxId(appsQuery.data), [appsQuery.data])
|
||||
|
||||
const hydrate = useMemo(
|
||||
() => (iv) => {
|
||||
const app = appByInbox.get(iv.inboxId)
|
||||
return { ...iv, jobTitle: app?.jobTitle ?? null, userId: app?.userId ?? null }
|
||||
},
|
||||
[appByInbox],
|
||||
)
|
||||
|
||||
const interviews = useMemo(
|
||||
() => (listQuery.data ?? []).map(hydrate),
|
||||
[listQuery.data, hydrate],
|
||||
)
|
||||
const all = useMemo(() => allQuery.data ?? [], [allQuery.data])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const today = new Date()
|
||||
return {
|
||||
scheduled: all.filter((i) => i.status === 'Scheduled').length,
|
||||
completed: all.filter((i) => i.status === 'Completed').length,
|
||||
today: all.filter((i) => sameDay(i.when, today)).length,
|
||||
cancelled: all.filter((i) => ['Cancelled', 'No Show'].includes(i.status)).length,
|
||||
}
|
||||
}, [all])
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
interviews.filter((iv) => {
|
||||
if (status && iv.status !== status) return false
|
||||
if (type && iv.type !== type) return false
|
||||
if (q && !(iv.candidate + iv.jobTitle + iv.interviewers.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
|
||||
if (q) {
|
||||
const hay = `${iv.candidate} ${iv.jobTitle ?? ''} ${iv.type}`.toLowerCase()
|
||||
if (!hay.includes(q.toLowerCase())) return false
|
||||
}
|
||||
return true
|
||||
}),
|
||||
[interviews, q, status, type],
|
||||
[interviews, q, type],
|
||||
)
|
||||
|
||||
const upcoming = interviews.filter((iv) => iv.status === 'Scheduled').slice(0, 4)
|
||||
const upcoming = useMemo(() => {
|
||||
const now = Date.now()
|
||||
return all
|
||||
.map(hydrate)
|
||||
.filter((iv) => iv.status === 'Scheduled' && iv.when && iv.when.getTime() >= now)
|
||||
.sort((a, b) => a.when - b.when)
|
||||
.slice(0, 4)
|
||||
}, [all, hydrate])
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.interviews.all() })
|
||||
}
|
||||
|
||||
const setStatusMutation = useMutation({
|
||||
mutationFn: ({ id, next }) => interviewsApi.update(id, { status: next }),
|
||||
onSuccess: (_res, { next }) => {
|
||||
invalidate()
|
||||
toast(`Interview marked ${next.toLowerCase()}`, 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update the interview.'), 'error'),
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body) => interviewsApi.create(body),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setScheduling(false)
|
||||
toast('Interview scheduled', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not schedule the interview.'), 'error'),
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'candidate', label: 'Candidate', sortable: true,
|
||||
render: (iv) => (
|
||||
<div className="user-cell">
|
||||
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
|
||||
<Avatar name={iv.candidate} initials={initialsOf(iv.candidate)} color={avatarColor(iv.candidate)} />
|
||||
<div>
|
||||
<div className="cell-primary">{iv.candidate}</div>
|
||||
<div className="cell-sub">{iv.jobTitle}</div>
|
||||
<div className="cell-sub">{iv.jobTitle || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'type', label: 'Round', sortable: true, render: (iv) => <Badge className="b-indigo">{iv.type}</Badge> },
|
||||
{
|
||||
key: 'when', label: 'Date & Time', sortable: true, sortValue: (iv) => iv.when.getTime(),
|
||||
key: 'when', label: 'Date & Time', sortable: true,
|
||||
sortValue: (iv) => (iv.when ? iv.when.getTime() : 0),
|
||||
render: (iv) => (
|
||||
<>
|
||||
<div className="text-sm fw-600">{fmtShort(iv.when)}</div>
|
||||
<div className="cell-sub">
|
||||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · {iv.duration}m
|
||||
</div>
|
||||
<div className="text-sm fw-600">{iv.when ? fmtShort(iv.when) : '—'}</div>
|
||||
<div className="cell-sub">{clock(iv.when)}</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'meeting', label: 'Type',
|
||||
render: (iv) => (
|
||||
<span className="flex items-center gap-8">
|
||||
<Icon name={iv.meeting === 'Video Call' ? 'video' : iv.meeting === 'Phone' ? 'phone' : 'map'} />
|
||||
{iv.meeting}
|
||||
</span>
|
||||
),
|
||||
key: 'status', label: 'Status', sortable: true,
|
||||
render: (iv) => <Badge className={STATUS_CLASS[iv.status] ?? 'b-gray'}>{iv.status}</Badge>,
|
||||
},
|
||||
{ key: 'interviewers', label: 'Interviewers', render: (iv) => <AvatarStack names={iv.interviewers} /> },
|
||||
{ key: 'status', label: 'Status', sortable: true, render: (iv) => <Badge>{iv.status}</Badge> },
|
||||
{ key: 'feedback', label: 'Feedback', render: (iv) => (iv.feedback ? <Badge>{iv.feedback}</Badge> : <span className="text-muted">—</span>) },
|
||||
{
|
||||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (iv) => (
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="act-btn" data-tip="View candidate"
|
||||
onClick={() => navigate('/candidates', { state: { openCandidate: iv.candidateId } })}
|
||||
disabled={!iv.userId}
|
||||
onClick={() => navigate('/candidates', { state: { openCandidate: iv.userId } })}
|
||||
>
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
<button className="act-btn" data-tip="Feedback" onClick={() => setFeedbackFor(iv)}>
|
||||
{iv.status === 'Scheduled' && (
|
||||
<button
|
||||
className="act-btn" data-tip="Mark completed"
|
||||
disabled={setStatusMutation.isPending}
|
||||
onClick={() => setStatusMutation.mutate({ id: iv.id, next: 'Completed' })}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</button>
|
||||
)}
|
||||
<button className="act-btn" data-tip="Scorecard" onClick={() => setFeedbackFor(iv)}>
|
||||
<Icon name="star" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -110,6 +228,8 @@ export default function Interviews() {
|
|||
},
|
||||
]
|
||||
|
||||
const listError = listQuery.isError
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
|
|
@ -126,10 +246,10 @@ export default function Interviews() {
|
|||
</div>
|
||||
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Scheduled" value={stats.scheduled} icon="calendar" tone="i-blue" />
|
||||
<KpiCard label="Completed" value={stats.completed} icon="check-circle" tone="i-green" />
|
||||
<KpiCard label="Today" value={stats.today} icon="clock" tone="i-purple" />
|
||||
<KpiCard label="Cancelled / No-show" value={stats.cancelled} icon="x-circle" tone="i-red" />
|
||||
<KpiCard label="Scheduled" value={allQuery.isPending ? '—' : stats.scheduled} icon="calendar" tone="i-blue" />
|
||||
<KpiCard label="Completed" value={allQuery.isPending ? '—' : stats.completed} icon="check-circle" tone="i-green" />
|
||||
<KpiCard label="Today" value={allQuery.isPending ? '—' : stats.today} icon="clock" tone="i-purple" />
|
||||
<KpiCard label="Cancelled / No-show" value={allQuery.isPending ? '—' : stats.cancelled} icon="x-circle" tone="i-red" />
|
||||
</div>
|
||||
|
||||
<div className="grid g-2-1">
|
||||
|
|
@ -139,40 +259,65 @@ export default function Interviews() {
|
|||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or interviewer…" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or role…" />
|
||||
</div>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
{['Scheduled', 'Completed', 'Cancelled', 'No Show'].map((s) => <option key={s}>{s}</option>)}
|
||||
{INTERVIEW_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="select" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="">All Rounds</option>
|
||||
{interviewTypes.map((t) => <option key={t}>{t}</option>)}
|
||||
{INTERVIEW_TYPES.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={rows} pageSize={8} />
|
||||
|
||||
{listQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="calendar" title="Loading…">Fetching interviews from the server.</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{listError && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load interviews">
|
||||
{friendlyAuthError(listQuery.error, 'The server did not return interviews.')}
|
||||
{' '}This screen needs the <code>candidates.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{!listQuery.isPending && !listError && (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
pageSize={8}
|
||||
empty="No interviews match these filters."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ alignSelf: 'start' }}>
|
||||
<div className="card-head"><div><h3>Up Next</h3><span className="ch-sub">Scheduled sessions</span></div></div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
{upcoming.map((iv) => (
|
||||
<div className="list-row" key={iv.id}>
|
||||
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{iv.candidate}</div>
|
||||
<div className="lr-sub">{iv.type} · {iv.meeting}</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
<div className="fw-600 text-sm">{fmtShort(iv.when)}</div>
|
||||
<div className="lr-sub">
|
||||
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-muted">
|
||||
{allQuery.isPending ? 'Loading…' : 'Nothing scheduled ahead.'}
|
||||
</p>
|
||||
) : (
|
||||
upcoming.map((iv) => (
|
||||
<div className="list-row" key={iv.id}>
|
||||
<Avatar name={iv.candidate} initials={initialsOf(iv.candidate)} color={avatarColor(iv.candidate)} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{iv.candidate}</div>
|
||||
<div className="lr-sub">{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
<div className="fw-600 text-sm">{iv.when ? fmtShort(iv.when) : '—'}</div>
|
||||
<div className="lr-sub">{clock(iv.when)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -181,18 +326,20 @@ export default function Interviews() {
|
|||
{feedbackFor && (
|
||||
<Scorecard
|
||||
interview={feedbackFor}
|
||||
jobs={jobs}
|
||||
onClose={() => setFeedbackFor(null)}
|
||||
onSubmit={() => { setFeedbackFor(null); toast('Scorecard submitted', 'success') }}
|
||||
onSaved={() => { setFeedbackFor(null); invalidate() }}
|
||||
toast={toast}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scheduling && (
|
||||
<ScheduleForm
|
||||
people={[...recruiters, ...managers]}
|
||||
applications={appsQuery.data ?? []}
|
||||
loading={appsQuery.isPending}
|
||||
busy={create.isPending}
|
||||
onClose={() => setScheduling(false)}
|
||||
onSubmit={() => { setScheduling(false); toast('Interview scheduled & invite sent', 'success') }}
|
||||
onSubmit={(body) => create.mutate(body)}
|
||||
toast={toast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -229,39 +376,100 @@ function CriteriaList({ criteria, ratings, setRating }) {
|
|||
))
|
||||
}
|
||||
|
||||
function Scorecard({ interview: iv, jobs, onClose, onSubmit, toast }) {
|
||||
const job = jobs.find((j) => j.title === iv.jobTitle)
|
||||
const dept = job ? job.department : 'All'
|
||||
const initial = evalTemplates.find((t) => t.dept === dept) || evalTemplates.find((t) => t.dept === 'All')
|
||||
/**
|
||||
* The scorecard now PERSISTS. It writes one `feedback` row against the
|
||||
* interview's application: `review` carries the overall recommendation,
|
||||
* `score` the mean of the criteria stars, and `note` the comments plus the
|
||||
* per-criterion breakdown — the feedback table has no structured criteria
|
||||
* column, so folding them into the note is the only way they survive the write
|
||||
* at all. `reviewed_by` is omitted on purpose: the server stamps the caller.
|
||||
*
|
||||
* The Upload Sheet tab is now an explicit "not stored" state. There is no
|
||||
* attachment endpoint for feedback, and a dropzone that accepts a file and
|
||||
* discards it is worse than saying so.
|
||||
*/
|
||||
function Scorecard({ interview: iv, onClose, onSaved, toast }) {
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: qk.feedbackTemplates.list(),
|
||||
queryFn: async () => {
|
||||
const res = await feedbackApi.listTemplates()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(feedbackApi.toTemplateView)
|
||||
},
|
||||
})
|
||||
const templates = templatesQuery.data ?? []
|
||||
|
||||
const [tab, setTab] = useState('form')
|
||||
const [templateName, setTemplateName] = useState(initial.name)
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const [ratings, setRatings] = useState({})
|
||||
const [comments, setComments] = useState('')
|
||||
const [recommendation, setRecommendation] = useState('Hire')
|
||||
|
||||
const template = evalTemplates.find((t) => t.name === templateName) ?? initial
|
||||
const initial = templates[0] ?? null
|
||||
useEffect(() => {
|
||||
if (initial?.name && !templateName) setTemplateName(initial.name)
|
||||
}, [initial, templateName])
|
||||
|
||||
const template = templates.find((t) => t.name === templateName) ?? initial
|
||||
const setRating = (crit, val) => setRatings((r) => ({ ...r, [crit]: val }))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body) => candidatesApi.createFeedback(body),
|
||||
onSuccess: () => {
|
||||
toast('Scorecard submitted', 'success')
|
||||
onSaved()
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not submit the scorecard.'), 'error'),
|
||||
})
|
||||
|
||||
function submit() {
|
||||
if (iv.inboxId == null) {
|
||||
toast('This interview is not linked to an application, so feedback cannot be stored', 'warning')
|
||||
return
|
||||
}
|
||||
const scored = Object.entries(ratings).filter(([, v]) => v > 0)
|
||||
if (!scored.length) {
|
||||
toast('Rate at least one criterion', 'warning')
|
||||
return
|
||||
}
|
||||
const mean = scored.reduce((s, [, v]) => s + v, 0) / scored.length
|
||||
const breakdown = scored.map(([k, v]) => `${k}: ${v}/5`).join(' · ')
|
||||
const note = [comments.trim(), breakdown].filter(Boolean).join('\n\n')
|
||||
save.mutate({
|
||||
inboxId: iv.inboxId,
|
||||
review: recommendation,
|
||||
score: Number(mean.toFixed(2)),
|
||||
note,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Interview Evaluation"
|
||||
subtitle={`${iv.id} · ${iv.type}`}
|
||||
subtitle={`${iv.candidate} · ${iv.type}`}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={onSubmit}><Icon name="check" /> Submit Scorecard</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={!template || save.isPending}>
|
||||
<Icon name="check" /> {save.isPending ? 'Submitting…' : 'Submit Scorecard'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-12 mb-18">
|
||||
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} className="avatar-lg" />
|
||||
<Avatar
|
||||
name={iv.candidate}
|
||||
initials={initialsOf(iv.candidate)}
|
||||
color={avatarColor(iv.candidate)}
|
||||
className="avatar-lg"
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="ph-name" style={{ fontSize: 17 }}>{iv.candidate}</div>
|
||||
<div className="ph-role">{iv.type} · {iv.jobTitle}</div>
|
||||
<div className="ph-role">{iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}</div>
|
||||
</div>
|
||||
<Badge>{iv.status}</Badge>
|
||||
<Badge className={STATUS_CLASS[iv.status] ?? 'b-gray'}>{iv.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
|
|
@ -271,75 +479,107 @@ function Scorecard({ interview: iv, jobs, onClose, onSubmit, toast }) {
|
|||
tabs={[
|
||||
{ key: 'form', label: 'Dynamic Form' },
|
||||
{ key: 'upload', label: 'Upload Sheet' },
|
||||
{ key: 'both', label: 'Both' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === 'form' && (
|
||||
<div className="tab-pane active">
|
||||
<div className="form-field" style={{ marginBottom: 8 }}>
|
||||
<label>Evaluation Template</label>
|
||||
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)}>
|
||||
{evalTemplates.map((t) => <option key={t.name}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} />
|
||||
<div className="form-field" style={{ marginTop: 8 }}>
|
||||
<label>Comments</label>
|
||||
<textarea placeholder="Strengths, concerns, and areas explored…" />
|
||||
</div>
|
||||
<div className="form-field" style={{ marginTop: 14 }}>
|
||||
<label>Overall Recommendation</label>
|
||||
<div className="seg" style={{ marginTop: 4 }}>
|
||||
{['Hire', 'Hold', 'Reject'].map((r) => (
|
||||
<button
|
||||
type="button" key={r}
|
||||
className={r === recommendation ? 'active' : ''}
|
||||
onClick={() => setRecommendation(r)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{templatesQuery.isPending && (
|
||||
<EmptyState icon="file" title="Loading templates…">Fetching scorecard templates.</EmptyState>
|
||||
)}
|
||||
{templatesQuery.isError && (
|
||||
<EmptyState icon="alert" title="Couldn’t load templates">
|
||||
{friendlyAuthError(templatesQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{templatesQuery.isSuccess && templates.length === 0 && (
|
||||
<EmptyState icon="file" title="No evaluation templates">
|
||||
Create one from Settings before scoring an interview.
|
||||
</EmptyState>
|
||||
)}
|
||||
{template && (
|
||||
<>
|
||||
<div className="form-field" style={{ marginBottom: 8 }}>
|
||||
<label>Evaluation Template</label>
|
||||
<select value={templateName} onChange={(e) => setTemplateName(e.target.value)}>
|
||||
{templates.map((t) => <option key={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<CriteriaList criteria={template.criteria} ratings={ratings} setRating={setRating} />
|
||||
<div className="form-field" style={{ marginTop: 8 }}>
|
||||
<label>Comments</label>
|
||||
<textarea
|
||||
placeholder="Strengths, concerns, and areas explored…"
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field" style={{ marginTop: 14 }}>
|
||||
<label>Overall Recommendation</label>
|
||||
<div className="seg" style={{ marginTop: 4 }}>
|
||||
{['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'].map((r) => (
|
||||
<button
|
||||
type="button" key={r}
|
||||
className={r === recommendation ? 'active' : ''}
|
||||
onClick={() => setRecommendation(r)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'upload' && (
|
||||
<div className="tab-pane active">
|
||||
<div className="dropzone" style={{ padding: 32 }} onClick={() => toast('File picker (demo)', 'info')}>
|
||||
<div className="dz-icn"><Icon name="upload" /></div>
|
||||
<h3 style={{ fontSize: 15 }}>Upload evaluation sheet</h3>
|
||||
<p className="text-muted">PDF, DOC, or DOCX · scanned scorecards supported</p>
|
||||
<div className="flex items-center gap-8" style={{ justifyContent: 'center', marginTop: 12 }}>
|
||||
{['PDF', 'DOC', 'DOCX'].map((t) => <span className="badge b-gray badge-plain" key={t}>{t}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'both' && (
|
||||
<div className="tab-pane active">
|
||||
<p className="text-muted" style={{ marginBottom: 14 }}>
|
||||
Capture structured ratings <b>and</b> attach a signed sheet — both are stored on the scorecard.
|
||||
</p>
|
||||
<CriteriaList criteria={template.criteria.slice(0, 3)} ratings={ratings} setRating={setRating} />
|
||||
<div className="upload-row" style={{ marginTop: 12 }}>
|
||||
<span className="attach-icn" style={{ width: 38, height: 38 }}><Icon name="file" /></span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="fw-600 text-sm">Interviewer_Scorecard.pdf</div>
|
||||
<div className="cell-sub">Attached · 214 KB</div>
|
||||
</div>
|
||||
<Badge className="b-green">Uploaded</Badge>
|
||||
</div>
|
||||
<EmptyState icon="upload" title="Attachments are not stored yet">
|
||||
The feedback record has no document column and there is no upload route, so a signed
|
||||
sheet would be accepted and then lost. Capture the ratings on the form tab instead.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduleForm({ people, onClose, onSubmit }) {
|
||||
function ScheduleForm({ applications, loading, busy, onClose, onSubmit, toast }) {
|
||||
const [form, setForm] = useState({
|
||||
inboxId: '',
|
||||
type: INTERVIEW_TYPES[0],
|
||||
date: '',
|
||||
time: '14:00',
|
||||
status: 'Scheduled',
|
||||
})
|
||||
const [errors, setErrors] = useState({})
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const inboxId = form.inboxId || (applications[0] ? String(applications[0].inboxId) : '')
|
||||
|
||||
function submit() {
|
||||
if (busy) return
|
||||
const next = {}
|
||||
if (!inboxId) next.inboxId = 'Pick a candidate'
|
||||
if (!form.date) next.date = 'Date is required'
|
||||
setErrors(next)
|
||||
if (Object.keys(next).length) return
|
||||
|
||||
const instant = toInstant(form.date, form.time)
|
||||
if (!instant) {
|
||||
toast('That date and time could not be read', 'warning')
|
||||
return
|
||||
}
|
||||
onSubmit({
|
||||
inboxId: Number(inboxId),
|
||||
instant,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Schedule Interview"
|
||||
|
|
@ -347,36 +587,66 @@ function ScheduleForm({ people, onClose, onSubmit }) {
|
|||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={onSubmit}><Icon name="calendar" /> Schedule</button>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={busy || !applications.length}>
|
||||
<Icon name="calendar" /> {busy ? 'Scheduling…' : 'Schedule'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Candidate <span className="req">*</span></label>
|
||||
<select>{allCandidates.slice(0, 40).map((c) => <option key={c.id}>{c.name}</option>)}</select>
|
||||
<select
|
||||
value={inboxId}
|
||||
className={errors.inboxId ? 'err' : ''}
|
||||
onChange={(e) => set('inboxId', e.target.value)}
|
||||
disabled={loading || !applications.length}
|
||||
>
|
||||
{loading && <option value="">Loading applications…</option>}
|
||||
{!loading && !applications.length && <option value="">No assigned applications</option>}
|
||||
{applications.map((a) => (
|
||||
<option key={a.inboxId} value={a.inboxId}>
|
||||
{a.name}{a.jobTitle ? ` — ${a.jobTitle}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldError>{errors.inboxId}</FieldError>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Interview Round</label>
|
||||
<select>{interviewTypes.map((t) => <option key={t}>{t}</option>)}</select>
|
||||
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
|
||||
{INTERVIEW_TYPES.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Meeting Type</label>
|
||||
<select>{meetingTypes.map((t) => <option key={t}>{t}</option>)}</select>
|
||||
</div>
|
||||
<div className="form-field"><label>Date</label><input type="date" /></div>
|
||||
<div className="form-field"><label>Time</label><input type="time" defaultValue="14:00" /></div>
|
||||
<div className="form-field">
|
||||
<label>Duration</label>
|
||||
<select defaultValue="60 min"><option>30 min</option><option>45 min</option><option>60 min</option><option>90 min</option></select>
|
||||
<label>Status</label>
|
||||
<select value={form.status} onChange={(e) => set('status', e.target.value)}>
|
||||
{INTERVIEW_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Interviewer</label>
|
||||
<select>{people.map((p) => <option key={p.id}>{p.name}</option>)}</select>
|
||||
<label>Date <span className="req">*</span></label>
|
||||
<input
|
||||
type="date"
|
||||
className={errors.date ? 'err' : ''}
|
||||
value={form.date}
|
||||
onChange={(e) => set('date', e.target.value)}
|
||||
/>
|
||||
<FieldError>{errors.date}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Time</label>
|
||||
<input type="time" value={form.time} onChange={(e) => set('time', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
|
||||
Interviews attach to an application, so only candidates with an assigned job post appear here.
|
||||
Duration, meeting mode and interviewers are not stored by the interview record.
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,101 +1,228 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
/* ============================================================
|
||||
Job Board — live on the publishing side of job posts.
|
||||
|
||||
Reads: GET /job/fetch (every post with its Buffer state), GET
|
||||
/job/buffer/channels (the connected destinations) and GET /jobs/alias (the
|
||||
platform vocabulary the backend will resolve). Publishing a new post is
|
||||
POST /job/post-job, which is Create Job on the Jobs screen — this page links
|
||||
there rather than duplicating a 15-field form.
|
||||
|
||||
THIS IS NOW A PUBLISHING BOARD, NOT AN ANALYTICS BOARD. The prototype's
|
||||
views / clicks / applications / conversion columns had no source anywhere:
|
||||
Buffer posts go out, and nothing reads engagement back. Inventing four
|
||||
metrics per row is exactly the failure mode the Jobs and Candidates screens
|
||||
already refused, so those columns are gone. What replaced them is the state
|
||||
the backend genuinely tracks — publish status, the channel, when Buffer
|
||||
accepted it, the external permalink and the error text on failure — which is
|
||||
what a recruiter actually needs when a post did not appear.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Chart from '../ui/Chart'
|
||||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import { Badge, Icon, KpiCard } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { int, publishPlatforms, TODAY } from '../data/seed'
|
||||
import { Badge, EmptyState, Icon, KpiCard } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import { fmtShort } from '../data/seed'
|
||||
|
||||
const STEPS = ['Select Job', 'Approval', 'Platforms', 'Publish']
|
||||
const POST_LIMIT = 300
|
||||
|
||||
/* Buffer publishing lifecycle (job_posts.status) -> badge. NOT the hiring
|
||||
lifecycle: requisition_status is open/closed/on_hold and lives on the Jobs
|
||||
screen. Conflating the two is the single easiest mistake to make here. */
|
||||
const PUBLISH_BADGE = {
|
||||
published: { label: 'Published', cls: 'b-green' },
|
||||
sent: { label: 'Published', cls: 'b-green' },
|
||||
scheduled: { label: 'Scheduled', cls: 'b-blue' },
|
||||
queued: { label: 'Queued', cls: 'b-indigo' },
|
||||
draft: { label: 'Draft', cls: 'b-gray' },
|
||||
failed: { label: 'Failed', cls: 'b-red' },
|
||||
}
|
||||
|
||||
function badgeFor(status) {
|
||||
return PUBLISH_BADGE[String(status || '').toLowerCase()] ?? { label: status || 'Unknown', cls: 'b-gray' }
|
||||
}
|
||||
|
||||
export default function JobBoard() {
|
||||
const { toast } = useToast()
|
||||
const location = useLocation()
|
||||
const { data: publishings = [] } = useQuery(seedQuery('publishings'))
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const updatePublishings = useSeedMutation('publishings')
|
||||
const navigate = useNavigate()
|
||||
const [q, setQ] = useState('')
|
||||
const [platform, setPlatform] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
|
||||
const [publishing, setPublishing] = useState(null) // { jobId } | null
|
||||
/* active_only false: a post that failed or was taken down still belongs on a
|
||||
publishing board — that is precisely the row someone came here to find. */
|
||||
const postsQuery = useQuery({
|
||||
queryKey: qk.jobPosts.list({ top: POST_LIMIT, scope: 'board' }),
|
||||
queryFn: async () => {
|
||||
const res = await jobPostsApi.list({ top: POST_LIMIT, activeOnly: false })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
platform: row.platform || 'unknown',
|
||||
channelId: row.channel_id || null,
|
||||
status: row.status || 'draft',
|
||||
link: row.buffer_external_link || null,
|
||||
postId: row.buffer_post_id || null,
|
||||
sentAt: row.buffer_sent_at ? new Date(row.buffer_sent_at) : null,
|
||||
error: row.buffer_error || null,
|
||||
isActive: row.is_active,
|
||||
created: row.created_at ? new Date(row.created_at) : null,
|
||||
createdBy: row.created_by_name || null,
|
||||
location: row.location || null,
|
||||
employmentType: row.employment_type || null,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.publishJob) setPublishing({ jobId: location.state.publishJob })
|
||||
}, [location.state])
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: qk.jobPosts.all(),
|
||||
queryFn: async () => {
|
||||
const res = await jobPostsApi.listChannels()
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
publishings.reduce(
|
||||
(a, p) => ({ views: a.views + p.views, clicks: a.clicks + p.clicks, apps: a.apps + p.apps }),
|
||||
{ views: 0, clicks: 0, apps: 0 },
|
||||
),
|
||||
[publishings],
|
||||
)
|
||||
const conv = totals.views ? ((totals.apps / totals.views) * 100).toFixed(1) : '0'
|
||||
/* The alias list is the vocabulary the backend can resolve a platform name
|
||||
against. A destination the org has connected is a channel; an alias with no
|
||||
channel is a platform the backend understands but nobody has hooked up. */
|
||||
const aliasQuery = useQuery({
|
||||
queryKey: ['jobPosts', 'aliases'],
|
||||
queryFn: async () => {
|
||||
const res = await jobPostsApi.listAliases()
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const platRows = useMemo(() => {
|
||||
const agg = {}
|
||||
for (const p of publishings) {
|
||||
if (!agg[p.platform]) agg[p.platform] = { views: 0, clicks: 0, apps: 0, jobs: 0 }
|
||||
agg[p.platform].views += p.views
|
||||
agg[p.platform].clicks += p.clicks
|
||||
agg[p.platform].apps += p.apps
|
||||
agg[p.platform].jobs += 1
|
||||
const posts = postsQuery.data ?? []
|
||||
const channels = channelsQuery.data ?? []
|
||||
const aliases = aliasQuery.data ?? []
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const by = (s) => posts.filter((p) => badgeFor(p.status).label === s).length
|
||||
return {
|
||||
total: posts.length,
|
||||
published: by('Published'),
|
||||
pending: by('Scheduled') + by('Queued'),
|
||||
failed: by('Failed'),
|
||||
}
|
||||
return Object.entries(agg).sort((a, b) => b[1].apps - a[1].apps)
|
||||
}, [publishings])
|
||||
}, [posts])
|
||||
|
||||
const chartData = useMemo(
|
||||
() => ({ labels: platRows.map((p) => p[0]), data: platRows.map((p) => p[1].apps) }),
|
||||
[platRows],
|
||||
const platformOptions = useMemo(
|
||||
() => [...new Set(posts.map((p) => p.platform).filter(Boolean))].sort(),
|
||||
[posts],
|
||||
)
|
||||
const statusOptions = useMemo(
|
||||
() => [...new Set(posts.map((p) => badgeFor(p.status).label))].sort(),
|
||||
[posts],
|
||||
)
|
||||
|
||||
const rows = useMemo(
|
||||
() => posts.filter((p) => {
|
||||
if (platform && p.platform !== platform) return false
|
||||
if (status && badgeFor(p.status).label !== status) return false
|
||||
if (q) {
|
||||
const hay = `${p.title} ${p.platform} ${p.location ?? ''}`.toLowerCase()
|
||||
if (!hay.includes(q.toLowerCase())) return false
|
||||
}
|
||||
return true
|
||||
}),
|
||||
[posts, q, platform, status],
|
||||
)
|
||||
|
||||
/* Connected destinations first, then aliases the backend knows about that
|
||||
have no channel behind them. `service` is Buffer's own name for the
|
||||
network, which is what the alias list is keyed on. */
|
||||
const destinations = useMemo(() => {
|
||||
const connected = channels.map((ch) => ({
|
||||
key: String(ch.id),
|
||||
name: ch.displayName || ch.name || String(ch.id),
|
||||
service: ch.service ? String(ch.service) : null,
|
||||
connected: true,
|
||||
posts: posts.filter((p) => String(p.channelId) === String(ch.id)).length,
|
||||
}))
|
||||
const known = new Set(connected.map((c) => (c.service || c.name || '').toLowerCase()))
|
||||
const unconnected = aliases
|
||||
.filter((a) => !known.has(String(a).toLowerCase()))
|
||||
.map((a) => ({
|
||||
key: `alias:${a}`,
|
||||
name: String(a),
|
||||
service: null,
|
||||
connected: false,
|
||||
posts: posts.filter((p) => String(p.platform).toLowerCase() === String(a).toLowerCase()).length,
|
||||
}))
|
||||
return [...connected, ...unconnected]
|
||||
}, [channels, aliases, posts])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'jobTitle', label: 'Job', sortable: true,
|
||||
render: (p) => (<><div className="cell-primary">{p.jobTitle}</div><div className="cell-sub">{p.jobId}</div></>),
|
||||
key: 'title', label: 'Job', sortable: true,
|
||||
render: (p) => (
|
||||
<>
|
||||
<div className="cell-primary">{p.title}</div>
|
||||
<div className="cell-sub">
|
||||
{[p.location, p.employmentType].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'platform', label: 'Platform', sortable: true,
|
||||
render: (p) => <Badge className="b-gray">{p.platform}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'status', label: 'Publish Status', sortable: true,
|
||||
sortValue: (p) => badgeFor(p.status).label,
|
||||
render: (p) => {
|
||||
const pl = publishPlatforms.find((x) => x.name === p.platform) || {}
|
||||
const b = badgeFor(p.status)
|
||||
return (
|
||||
<span className="flex items-center gap-8">
|
||||
<span className="platform-logo" style={{ width: 26, height: 26, background: pl.color || '#888' }}>
|
||||
<Icon name={pl.icon || 'briefcase'} />
|
||||
</span>
|
||||
{p.platform}
|
||||
</span>
|
||||
<>
|
||||
<Badge className={b.cls}>{b.label}</Badge>
|
||||
{p.error && <div className="cell-sub" style={{ color: 'var(--danger)' }}>{p.error}</div>}
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status', label: 'Status', sortable: true,
|
||||
render: (p) => (
|
||||
<Badge className={p.status === 'Live' ? 'b-green' : p.status === 'Paused' ? 'b-amber' : 'b-blue'}>
|
||||
{p.status}
|
||||
</Badge>
|
||||
),
|
||||
key: 'sentAt', label: 'Sent', sortable: true,
|
||||
sortValue: (p) => (p.sentAt ? p.sentAt.getTime() : 0),
|
||||
render: (p) => <span className="text-muted">{p.sentAt ? fmtShort(p.sentAt) : '—'}</span>,
|
||||
},
|
||||
{ key: 'views', label: 'Views', sortable: true, align: 'right', render: (p) => p.views.toLocaleString() },
|
||||
{ key: 'clicks', label: 'Clicks', sortable: true, align: 'right', render: (p) => p.clicks.toLocaleString() },
|
||||
{ key: 'apps', label: 'Applications', sortable: true, align: 'right', render: (p) => <b>{p.apps}</b> },
|
||||
{
|
||||
key: '_conv', label: 'Conversion', sortable: true, sortValue: (p) => (p.views ? p.apps / p.views : 0),
|
||||
key: 'created', label: 'Created', sortable: true,
|
||||
sortValue: (p) => (p.created ? p.created.getTime() : 0),
|
||||
render: (p) => (
|
||||
<span className="badge b-indigo badge-plain">
|
||||
{p.views ? ((p.apps / p.views) * 100).toFixed(1) : '0.0'}%
|
||||
</span>
|
||||
<>
|
||||
<div className="text-muted">{p.created ? fmtShort(p.created) : '—'}</div>
|
||||
{p.createdBy && <div className="cell-sub">{p.createdBy}</div>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_a', label: '', align: 'right',
|
||||
render: (p) => (
|
||||
<button className="act-btn" data-tip="Manage" onClick={() => toast(`Managing ${p.platform} posting`, 'info')}>
|
||||
<Icon name="external" />
|
||||
</button>
|
||||
<div className="row-actions">
|
||||
{p.link ? (
|
||||
<a
|
||||
className="act-btn"
|
||||
href={p.link}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
data-tip="Open live post"
|
||||
>
|
||||
<Icon name="external" />
|
||||
</a>
|
||||
) : (
|
||||
<button className="act-btn" data-tip="Not published yet" disabled>
|
||||
<Icon name="external" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
|
@ -105,240 +232,124 @@ export default function JobBoard() {
|
|||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Job Board</h1>
|
||||
<p className="page-sub">Publish requisitions across channels and track performance</p>
|
||||
<p className="page-sub">Where each requisition was published, and whether it landed</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<Link className="btn btn-secondary" to="/analytics"><Icon name="trending-up" /> Analytics</Link>
|
||||
<button className="btn btn-primary" onClick={() => setPublishing({})}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => navigate('/jobs', { state: { openCreate: true } })}
|
||||
>
|
||||
<Icon name="send" /> Publish a Job
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Total Views" value={totals.views.toLocaleString()} icon="eye" tone="i-blue" foot="across all platforms" />
|
||||
<KpiCard
|
||||
label="Total Clicks" value={totals.clicks.toLocaleString()} icon="target" tone="i-purple"
|
||||
foot={`${totals.views ? ((totals.clicks / totals.views) * 100).toFixed(1) : '0.0'}% CTR`}
|
||||
/>
|
||||
<KpiCard label="Applications" value={totals.apps.toLocaleString()} icon="users" tone="i-green" foot="from job boards" />
|
||||
<KpiCard label="Conversion Rate" value={`${conv}%`} icon="trending-up" tone="i-teal" foot="view → application" />
|
||||
<KpiCard label="Total Posts" value={postsQuery.isPending ? '—' : stats.total} icon="layers" tone="i-indigo" />
|
||||
<KpiCard label="Published" value={postsQuery.isPending ? '—' : stats.published} icon="check-circle" tone="i-green" foot="live on a channel" />
|
||||
<KpiCard label="Queued / Scheduled" value={postsQuery.isPending ? '—' : stats.pending} icon="clock" tone="i-amber" foot="awaiting Buffer" />
|
||||
<KpiCard label="Failed" value={postsQuery.isPending ? '—' : stats.failed} icon="alert" tone="i-red" foot="needs a retry" />
|
||||
</div>
|
||||
|
||||
<div className="grid g-2-1 mb-18">
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Platform Performance</h3><span className="ch-sub">Applications by channel</span></div></div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={chartData} height={300} /></div></div>
|
||||
<div className="card mb-18">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Destinations</h3>
|
||||
<span className="ch-sub">Connected Buffer channels and known platforms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Connected Platforms</h3></div></div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
{publishPlatforms.map((p) => (
|
||||
<div className="list-row" key={p.name}>
|
||||
<span className="platform-logo" style={{ background: p.color }}><Icon name={p.icon} /></span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{p.name}</div>
|
||||
<div className="lr-sub">{p.cost === 'Free' ? 'Free posting' : `Paid · ${p.cost}`}</div>
|
||||
<div className="card-body">
|
||||
{channelsQuery.isPending && aliasQuery.isPending && (
|
||||
<EmptyState icon="clock" title="Loading…">Fetching connected channels.</EmptyState>
|
||||
)}
|
||||
{channelsQuery.isError && (
|
||||
<EmptyState icon="alert" title="Couldn’t reach Buffer">
|
||||
{friendlyAuthError(channelsQuery.error, 'The channel list did not load.')}
|
||||
{' '}A 502 here means the Buffer credentials are missing or rejected, not that
|
||||
publishing is disabled.
|
||||
</EmptyState>
|
||||
)}
|
||||
{!channelsQuery.isPending && !channelsQuery.isError && destinations.length === 0 && (
|
||||
<EmptyState icon="layers" title="No channels connected">
|
||||
Connect a channel in Buffer, then set <code>BUFFER_CHANNEL_ID</code> so posts have a default destination.
|
||||
</EmptyState>
|
||||
)}
|
||||
{destinations.length > 0 && (
|
||||
<div className="grid g-3">
|
||||
{destinations.map((d) => (
|
||||
<div
|
||||
key={d.key}
|
||||
className="card"
|
||||
style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-8" style={{ marginBottom: 10 }}>
|
||||
<span className={`kpi-icn ${d.connected ? 'i-green' : 'i-indigo'}`} style={{ width: 34, height: 34, borderRadius: 9 }}>
|
||||
<Icon name={d.connected ? 'check-circle' : 'layers'} />
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="lr-title" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.name}</div>
|
||||
<div className="lr-sub">{d.service || (d.connected ? 'channel' : 'not connected')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub">{d.posts} post{d.posts === 1 ? '' : 's'}</span>
|
||||
<Badge className={d.connected ? 'b-green' : 'b-gray'}>
|
||||
{d.connected ? 'Connected' : 'Available'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{p.connected ? (
|
||||
<Badge className="b-green">Connected</Badge>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => toast(`Connecting ${p.name}…`, 'info')}>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Active Postings</h3>
|
||||
<span className="ch-sub">{publishings.length} live postings across {platRows.length} platforms</span>
|
||||
<h3>Published Posts</h3>
|
||||
<span className="ch-sub">
|
||||
{postsQuery.isSuccess ? `${rows.length} of ${posts.length}` : 'Every job post and its Buffer state'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search job or platform…" />
|
||||
</div>
|
||||
<select className="select" value={platform} onChange={(e) => setPlatform(e.target.value)}>
|
||||
<option value="">All Platforms</option>
|
||||
{platformOptions.map((p) => <option key={p}>{p}</option>)}
|
||||
</select>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All Statuses</option>
|
||||
{statusOptions.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => toast('Performance report exported', 'success')}>
|
||||
<Icon name="download" /> Export
|
||||
</button>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={publishings} pageSize={8} />
|
||||
</div>
|
||||
|
||||
{publishing && (
|
||||
<PublishFlow
|
||||
jobs={jobs}
|
||||
initialJobId={publishing.jobId}
|
||||
onClose={() => setPublishing(null)}
|
||||
onPublish={(job, platforms) => {
|
||||
updatePublishings((ps) => [
|
||||
...platforms.map((p) => ({
|
||||
jobId: job.id, jobTitle: job.title, platform: p, status: 'Live',
|
||||
views: int(0, 30), clicks: 0, apps: 0, published: new Date(TODAY),
|
||||
})),
|
||||
...ps,
|
||||
])
|
||||
}}
|
||||
toast={toast}
|
||||
/>
|
||||
)}
|
||||
{postsQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="layers" title="Loading…">Fetching job posts.</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{postsQuery.isError && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load job posts">
|
||||
{friendlyAuthError(postsQuery.error, 'The server did not return job posts.')}
|
||||
{' '}This screen needs the <code>job_board.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{!postsQuery.isPending && !postsQuery.isError && (
|
||||
<DataTable columns={columns} rows={rows} pageSize={10} empty="No posts match these filters." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The app's only multi-step form. State lives here rather than on a global. */
|
||||
function PublishFlow({ jobs, initialJobId, onClose, onPublish, toast }) {
|
||||
const publishable = jobs.filter((j) => j.status !== 'Draft')
|
||||
const openJobs = jobs.filter((j) => j.status === 'Open')
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [jobId, setJobId] = useState(initialJobId || openJobs[0]?.id || publishable[0]?.id)
|
||||
const [platforms, setPlatforms] = useState(['Career Portal'])
|
||||
|
||||
const job = jobs.find((j) => j.id === jobId)
|
||||
|
||||
function next() {
|
||||
if (step === 3) {
|
||||
if (!platforms.length) {
|
||||
toast('Select at least one platform', 'warning')
|
||||
return
|
||||
}
|
||||
onPublish(job, platforms)
|
||||
}
|
||||
setStep((s) => s + 1)
|
||||
}
|
||||
|
||||
function togglePlatform(name) {
|
||||
setPlatforms((ps) => (ps.includes(name) ? ps.filter((p) => p !== name) : [...ps, name]))
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Publish Job"
|
||||
subtitle="Distribute this requisition to job boards"
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
step === 4 ? (
|
||||
<button className="btn btn-primary" onClick={onClose}><Icon name="check" /> Done</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => (step === 1 ? onClose() : setStep((s) => s - 1))}>
|
||||
{step === 1 ? 'Cancel' : 'Back'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={next}>
|
||||
{step === 3 ? <><Icon name="send" /> Publish</> : 'Continue'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="stepper">
|
||||
{STEPS.map((s, i) => {
|
||||
const n = i + 1
|
||||
const cls = n < step ? 'done' : n === step ? 'active' : ''
|
||||
return (
|
||||
<div style={{ display: 'contents' }} key={s}>
|
||||
<div className={`step ${cls}`}>
|
||||
<div className="step-num">{n < step ? '✓' : n}</div>
|
||||
<div className="step-label">{s}</div>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className={`step-line ${n < step ? 'done' : ''}`} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<label>Select requisition to publish</label>
|
||||
<select value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||
{publishable.map((j) => <option key={j.id} value={j.id}>{j.title} · {j.id}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{job && (
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginTop: 16 }}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12">
|
||||
<span className="kpi-icn i-indigo" style={{ width: 44, height: 44, borderRadius: 12 }}>
|
||||
<Icon name="briefcase" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="fw-600">{job.title}</div>
|
||||
<div className="cell-sub">{job.department} · {job.location} · {job.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
|
||||
<span className="kpi-icn i-green" style={{ width: 44, height: 44, borderRadius: 12 }}>
|
||||
<Icon name="check-circle" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="fw-600">Approval granted</div>
|
||||
<div className="cell-sub">Approved by Department Head · Budget confirmed</div>
|
||||
</div>
|
||||
</div>
|
||||
{['Hiring Manager sign-off', 'Finance budget approval', 'Compliance review'].map((label, i) => (
|
||||
<div className="setting-row" style={{ padding: '10px 0', ...(i === 2 ? { border: 'none' } : {}) }} key={label}>
|
||||
<div className="setting-info"><h4>{label}</h4></div>
|
||||
<Badge className="b-green">Approved</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<>
|
||||
<p className="text-muted" style={{ marginBottom: 14 }}>
|
||||
Select the platforms to publish this role to
|
||||
</p>
|
||||
<div className="grid g-2">
|
||||
{publishPlatforms.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className={`platform-card${platforms.includes(p.name) ? ' selected' : ''}${!p.connected ? ' disabled' : ''}`}
|
||||
style={!p.connected ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
|
||||
onClick={() => togglePlatform(p.name)}
|
||||
>
|
||||
<span className="platform-logo" style={{ background: p.color }}><Icon name={p.icon} /></span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="fw-600">{p.name}</div>
|
||||
<div className="cell-sub">{p.cost === 'Free' ? 'Free' : `Paid · ${p.cost}`}</div>
|
||||
</div>
|
||||
<span className="platform-check"><Icon name="check" /></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div className="kpi-icn i-green" style={{ width: 64, height: 64, borderRadius: 18, margin: '0 auto 16px' }}>
|
||||
<Icon name="check-circle" />
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, marginBottom: 6 }}>Published Successfully</h2>
|
||||
<p className="text-muted" style={{ marginBottom: 20 }}>
|
||||
{job?.title} is now live on {platforms.length} platform{platforms.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="flex gap-8" style={{ justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{platforms.map((p) => <Badge className="b-green" key={p}>{p}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
|
||||
Facets, columns and actions that had no backing column are gone rather than
|
||||
rendered as placeholders — the Candidates / Inbox screens set that precedent.
|
||||
Create is wired to POST /job/post-job (create + Buffer publish). Edit / delete
|
||||
/ reassign stay off until real write endpoints exist; publishing still routes
|
||||
to /jobboard.
|
||||
Create → POST /job/post-job. Update / delete / status → PATCH /jobs/update,
|
||||
DELETE /jobs/delete, PATCH /jobs/status.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
|
@ -22,6 +21,8 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as assignmentsApi from '../api/assignments'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import { JOB_STATUSES } from '../api/jobs'
|
||||
import { empTypes, fmtShort } from '../data/seed'
|
||||
|
||||
|
|
@ -56,8 +57,12 @@ export default function Jobs() {
|
|||
const [type, setType] = useState('')
|
||||
|
||||
const [viewing, setViewing] = useState(null)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const canEdit = can('jobs.edit')
|
||||
const canDelete = can('jobs.delete')
|
||||
|
||||
// Deep-link intents from global search, the dashboard and the manager portal.
|
||||
useEffect(() => {
|
||||
const st = location.state
|
||||
|
|
@ -66,6 +71,13 @@ export default function Jobs() {
|
|||
if (st.openJob) setViewing(jobs.find((j) => j.id === st.openJob) ?? null)
|
||||
}, [location.state, jobs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewing) return
|
||||
const fresh = jobs.find((j) => j.id === viewing.id)
|
||||
if (fresh) setViewing(fresh)
|
||||
else if (jobsQuery.isSuccess) setViewing(null)
|
||||
}, [jobs]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: qk.jobPosts.all(),
|
||||
queryFn: async () => (await jobPostsApi.listChannels())?.data ?? [],
|
||||
|
|
@ -92,6 +104,35 @@ export default function Jobs() {
|
|||
},
|
||||
})
|
||||
|
||||
const updateJob = useMutation({
|
||||
mutationFn: ({ id, body }) => jobsApi.update(id, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
setEditing(null)
|
||||
toast('Job updated', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update the job'), 'error'),
|
||||
})
|
||||
|
||||
const setJobStatus = useMutation({
|
||||
mutationFn: ({ id, status: next }) => jobsApi.setStatus(id, next),
|
||||
onSuccess: (_d, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
toast(`Status set to ${vars.status}`, 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update status'), 'error'),
|
||||
})
|
||||
|
||||
const deleteJob = useMutation({
|
||||
mutationFn: (id) => jobsApi.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
setViewing(null)
|
||||
toast('Job deleted', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the job'), 'error'),
|
||||
})
|
||||
|
||||
const departmentOptions = useMemo(
|
||||
() => [...new Set(jobs.map((j) => j.department).filter(Boolean))].sort(),
|
||||
[jobs],
|
||||
|
|
@ -148,6 +189,9 @@ export default function Jobs() {
|
|||
render: (j) => (
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="View" onClick={() => setViewing(j)}><Icon name="eye" /></button>
|
||||
{canEdit && (
|
||||
<button className="act-btn" data-tip="Edit" onClick={() => setEditing(j)}><Icon name="edit" /></button>
|
||||
)}
|
||||
<button className="act-btn" data-tip="Publish" onClick={() => navigate('/jobboard', { state: { publishJob: j.id } })}><Icon name="send" /></button>
|
||||
</div>
|
||||
),
|
||||
|
|
@ -221,8 +265,27 @@ export default function Jobs() {
|
|||
{viewing && (
|
||||
<JobDetail
|
||||
job={viewing}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
statusBusy={setJobStatus.isPending}
|
||||
deleteBusy={deleteJob.isPending}
|
||||
onClose={() => setViewing(null)}
|
||||
onPublish={() => { const id = viewing.id; setViewing(null); navigate('/jobboard', { state: { publishJob: id } }) }}
|
||||
onEdit={() => { setEditing(viewing); setViewing(null) }}
|
||||
onStatus={(s) => setJobStatus.mutate({ id: viewing.id, status: s })}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`Delete “${viewing.title}”?`)) deleteJob.mutate(viewing.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditJobForm
|
||||
job={editing}
|
||||
departmentOptions={departmentOptions}
|
||||
busy={updateJob.isPending}
|
||||
onClose={() => setEditing(null)}
|
||||
onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -477,7 +540,223 @@ function JobForm({
|
|||
)
|
||||
}
|
||||
|
||||
function JobDetail({ job: j, onClose, onPublish }) {
|
||||
function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
||||
const form = useFormState({
|
||||
title: j.title || '',
|
||||
department: j.department || '',
|
||||
location: j.location || '',
|
||||
employment_type: j.type || '',
|
||||
vacancies: j.vacancies != null ? String(j.vacancies) : '1',
|
||||
salary: j.salary || '',
|
||||
experience_min: j.experienceMin != null ? String(j.experienceMin) : '',
|
||||
experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
|
||||
description: j.description || '',
|
||||
})
|
||||
|
||||
function submit() {
|
||||
if (busy) return
|
||||
const title = form.values.title.trim()
|
||||
if (!title) {
|
||||
form.setErrors({ title: 'Job title is required' })
|
||||
return
|
||||
}
|
||||
onSubmit({
|
||||
title,
|
||||
department: form.values.department.trim() || null,
|
||||
location: form.values.location.trim() || null,
|
||||
employment_type: form.values.employment_type || null,
|
||||
vacancies: Number(form.values.vacancies) || 1,
|
||||
salary: form.values.salary.trim() || null,
|
||||
experience_min: form.values.experience_min === '' ? null : Number(form.values.experience_min),
|
||||
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
|
||||
description: form.values.description.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Edit Job"
|
||||
subtitle={j.department || undefined}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={busy}>
|
||||
<Icon name="check" /> {busy ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Title</label>
|
||||
<input className={form.errors.title ? 'err' : ''} value={form.values.title} onChange={(e) => form.setField('title', e.target.value)} disabled={busy} />
|
||||
<FieldError>{form.errors.title}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Department</label>
|
||||
<input list="edit-job-depts" value={form.values.department} onChange={(e) => form.setField('department', e.target.value)} disabled={busy} />
|
||||
<datalist id="edit-job-depts">{departmentOptions.map((d) => <option key={d} value={d} />)}</datalist>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Location</label>
|
||||
<input value={form.values.location} onChange={(e) => form.setField('location', e.target.value)} disabled={busy} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Employment Type</label>
|
||||
<select value={form.values.employment_type} onChange={(e) => form.setField('employment_type', e.target.value)} disabled={busy}>
|
||||
<option value="">—</option>
|
||||
{empTypes.map((t) => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Vacancies</label>
|
||||
<input type="number" min="1" value={form.values.vacancies} onChange={(e) => form.setField('vacancies', e.target.value)} disabled={busy} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Salary</label>
|
||||
<input value={form.values.salary} onChange={(e) => form.setField('salary', e.target.value)} disabled={busy} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Experience min</label>
|
||||
<input type="number" min="0" value={form.values.experience_min} onChange={(e) => form.setField('experience_min', e.target.value)} disabled={busy} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Experience max</label>
|
||||
<input type="number" min="0" value={form.values.experience_max} onChange={(e) => form.setField('experience_max', e.target.value)} disabled={busy} />
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Description</label>
|
||||
<textarea rows={4} value={form.values.description} onChange={(e) => form.setField('description', e.target.value)} disabled={busy} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recruiter ownership of one requisition — GET/POST /job/assignments/*.
|
||||
*
|
||||
* Rows are valid-time intervals and the fetch returns only the OPEN one, so
|
||||
* "the assigned recruiter" is simply the first row back. There is no unassign
|
||||
* route: posting a new assignment closes the previous interval, which is why
|
||||
* the control is a picker with a Save rather than an assign/remove pair.
|
||||
*
|
||||
* The picker is /tasks/assignees/fetch because the server rejects any
|
||||
* non-recruiter with a 422, and that endpoint returns exactly the active
|
||||
* recruiter-role users without needing rbac_users.view.
|
||||
*/
|
||||
function RecruiterAssignment({ jobPostId, fallbackName, canEdit }) {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const [picked, setPicked] = useState('')
|
||||
|
||||
const assigneesQuery = useQuery({
|
||||
queryKey: qk.tasks.assignees(),
|
||||
queryFn: async () => {
|
||||
const res = await tasksApi.listAssignees()
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const namesById = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const u of assigneesQuery.data ?? []) map.set(String(u.id), u.name)
|
||||
return map
|
||||
}, [assigneesQuery.data])
|
||||
|
||||
const currentQuery = useQuery({
|
||||
queryKey: qk.assignments.job(jobPostId),
|
||||
queryFn: async () => {
|
||||
const res = await assignmentsApi.listJob(jobPostId)
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map((r) => assignmentsApi.toAssignmentView(r, namesById))
|
||||
},
|
||||
enabled: Boolean(jobPostId),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const current = currentQuery.data?.[0] ?? null
|
||||
|
||||
const assign = useMutation({
|
||||
mutationFn: (userId) => assignmentsApi.assignJob({ jobPostId, userId }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.assignments.job(jobPostId) })
|
||||
qc.invalidateQueries({ queryKey: qk.jobs.all() })
|
||||
setPicked('')
|
||||
toast('Recruiter assigned', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the recruiter.'), 'error'),
|
||||
})
|
||||
|
||||
/* current.name resolves only once the assignee list has loaded; the
|
||||
requisition's own recruiter_name is the fallback until then. */
|
||||
const currentName = current?.name
|
||||
|| (current ? namesById.get(String(current.userId)) : null)
|
||||
|| fallbackName
|
||||
|| null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divider" />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={SECTION_LABEL}>Recruiter ownership</div>
|
||||
{currentQuery.isError ? (
|
||||
<p className="text-muted text-sm">
|
||||
{friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
|
||||
{' '}Needs the <code>jobs.view</code> permission.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted text-sm" style={{ marginBottom: canEdit ? 10 : 0 }}>
|
||||
{currentQuery.isPending
|
||||
? 'Loading…'
|
||||
: currentName
|
||||
? <>Owned by <b>{currentName}</b>{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}</>
|
||||
: 'No recruiter assigned yet.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canEdit && !currentQuery.isError && (
|
||||
<div className="flex items-center gap-8">
|
||||
<select
|
||||
className="select"
|
||||
value={picked}
|
||||
disabled={assigneesQuery.isPending || assign.isPending}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{assigneesQuery.isPending ? 'Loading recruiters…' : 'Assign a recruiter…'}
|
||||
</option>
|
||||
{(assigneesQuery.data ?? []).map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={!picked || assign.isPending}
|
||||
onClick={() => assign.mutate(picked)}
|
||||
>
|
||||
{assign.isPending ? 'Assigning…' : 'Assign'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{canEdit && assigneesQuery.isError && (
|
||||
<p className="text-muted text-sm">
|
||||
The recruiter list needs the <code>tasks.view</code> permission.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function JobDetail({
|
||||
job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
title="Job Details"
|
||||
|
|
@ -486,7 +765,15 @@ function JobDetail({ job: j, onClose, onPublish }) {
|
|||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
{canDelete && (
|
||||
<button className="btn btn-ghost" style={{ color: 'var(--danger)', marginRight: 'auto' }} onClick={onDelete} disabled={deleteBusy}>
|
||||
<Icon name="trash" /> {deleteBusy ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||
{canEdit && (
|
||||
<button className="btn btn-secondary" onClick={onEdit}><Icon name="edit" /> Edit</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={onPublish}><Icon name="send" /> Publish</button>
|
||||
</>
|
||||
}
|
||||
|
|
@ -499,7 +786,20 @@ function JobDetail({ job: j, onClose, onPublish }) {
|
|||
<div style={{ fontSize: 19, fontWeight: 700 }}>{j.title}</div>
|
||||
<div className="text-muted">{[j.department, j.location].filter(Boolean).join(' · ') || '—'}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }}><Badge>{j.status}</Badge></div>
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
{canEdit ? (
|
||||
<select
|
||||
className="select"
|
||||
value={j.status}
|
||||
disabled={statusBusy}
|
||||
onChange={(e) => onStatus(e.target.value)}
|
||||
>
|
||||
{JOB_STATUSES.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Badge>{j.status}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-grid mb-18">
|
||||
|
|
@ -516,6 +816,8 @@ function JobDetail({ job: j, onClose, onPublish }) {
|
|||
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
|
||||
</div>
|
||||
|
||||
<RecruiterAssignment jobPostId={j.id} fallbackName={j.recruiter} canEdit={canEdit} />
|
||||
|
||||
{j.description && (
|
||||
<>
|
||||
<div className="divider" />
|
||||
|
|
|
|||
|
|
@ -1,28 +1,46 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Avatar, Badge, Icon } from '../ui/primitives'
|
||||
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
|
||||
async function fetchManagers() {
|
||||
const res = await usersApi.listManagers()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(usersApi.toManagerView)
|
||||
}
|
||||
|
||||
async function fetchJobs() {
|
||||
const res = await jobsApi.list({ top: 200 })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(jobsApi.toJobView)
|
||||
}
|
||||
|
||||
export default function Managers() {
|
||||
const { toast } = useToast()
|
||||
const { can } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { data: managers = [] } = useQuery(seedQuery('managers'))
|
||||
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
|
||||
const managersQuery = useQuery({ queryKey: qk.managers.list(), queryFn: fetchManagers })
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||
const managers = managersQuery.data ?? []
|
||||
const jobs = jobsQuery.data ?? []
|
||||
const [detail, setDetail] = useState(null)
|
||||
|
||||
// Global search navigates here with the manager to open — replaces the old
|
||||
// App.searchGo(route, cb) + setTimeout(cb, 120) hack.
|
||||
useEffect(() => {
|
||||
const id = location.state?.openManager
|
||||
if (id) setDetail(managers.find((m) => m.id === id) ?? null)
|
||||
}, [location.state, managers])
|
||||
|
||||
const totalReqs = managers.reduce((s, m) => s + m.openReqs, 0)
|
||||
const totalReqs = managers.reduce((s, m) => s + (m.openReqs || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
|
@ -31,42 +49,53 @@ export default function Managers() {
|
|||
<h1 className="page-title">Hiring Managers</h1>
|
||||
<p className="page-sub">{managers.length} managers · {totalReqs} active requisitions</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button className="btn btn-primary" onClick={() => toast('Invite manager', 'info')}>
|
||||
<Icon name="plus" /> Add Manager
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-3">
|
||||
{managers.map((m) => (
|
||||
<div className="card" key={m.id}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
|
||||
<Avatar name={m.name} initials={m.initials} color={m.color} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="lr-title">{m.name}</div>
|
||||
<div className="lr-sub">{m.title}</div>
|
||||
{managersQuery.isPending && (
|
||||
<EmptyState icon="managers" title="Loading…">Fetching hiring managers.</EmptyState>
|
||||
)}
|
||||
{managersQuery.isError && (
|
||||
<EmptyState icon="managers" title="Couldn’t load hiring managers">
|
||||
{friendlyAuthError(managersQuery.error, 'This directory needs jobs.view or candidates.view.')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{managersQuery.isSuccess && managers.length === 0 && (
|
||||
<EmptyState icon="managers" title="No hiring managers">
|
||||
No accounts currently hold the hiring-manager role.
|
||||
</EmptyState>
|
||||
)}
|
||||
{managersQuery.isSuccess && managers.length > 0 && (
|
||||
<div className="grid g-3">
|
||||
{managers.map((m) => (
|
||||
<div className="card" key={m.id}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 14 }}>
|
||||
<Avatar name={m.name} className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="lr-title">{m.name}</div>
|
||||
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub"><Icon name="mail" /> {m.email ? m.email.split('@')[0] : '—'}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize}</span><span className="stat-mini-lbl">Team Size</span></div>
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub"><Icon name="mail" /> {m.email.split('@')[0]}</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<ManagerDetail
|
||||
manager={detail}
|
||||
jobs={jobs.filter((j) => j.manager === detail.name)}
|
||||
jobs={jobs}
|
||||
canSend={can('inbox.edit')}
|
||||
onClose={() => setDetail(null)}
|
||||
navigate={navigate}
|
||||
toast={toast}
|
||||
|
|
@ -76,7 +105,17 @@ export default function Managers() {
|
|||
)
|
||||
}
|
||||
|
||||
function ManagerDetail({ manager: m, jobs, onClose, navigate, toast }) {
|
||||
function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) {
|
||||
const [messaging, setMessaging] = useState(false)
|
||||
const send = useMutation({
|
||||
mutationFn: (body) => inboxApi.sendEmail({ to: m.email, subject: body.subject, body: body.body, contentType: 'text' }),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not send the message.'), 'error'),
|
||||
onSuccess: () => {
|
||||
setMessaging(false)
|
||||
toast('Message sent', 'success')
|
||||
},
|
||||
})
|
||||
|
||||
const go = (path, state) => {
|
||||
onClose()
|
||||
navigate(path, { state })
|
||||
|
|
@ -85,79 +124,127 @@ function ManagerDetail({ manager: m, jobs, onClose, navigate, toast }) {
|
|||
return (
|
||||
<Modal
|
||||
title="Hiring Manager"
|
||||
subtitle={m.id}
|
||||
subtitle={m.email || undefined}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||
<button className="btn btn-primary" onClick={() => toast('Message sent', 'success')}>
|
||||
<Icon name="mail" /> Message
|
||||
</button>
|
||||
</>
|
||||
messaging ? (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setMessaging(false)} disabled={send.isPending}>Cancel</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={send.isPending || !m.email}
|
||||
onClick={() => {
|
||||
const subject = document.getElementById('mgr-msg-subject')?.value?.trim()
|
||||
const body = document.getElementById('mgr-msg-body')?.value?.trim()
|
||||
if (!subject || !body) {
|
||||
toast('Subject and body are required', 'warning')
|
||||
return
|
||||
}
|
||||
send.mutate({ subject, body })
|
||||
}}
|
||||
>
|
||||
<Icon name="send" /> {send.isPending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!canSend || !m.email}
|
||||
title={!canSend ? 'Requires inbox.edit' : !m.email ? 'No email on file' : undefined}
|
||||
onClick={() => setMessaging(true)}
|
||||
>
|
||||
<Icon name="mail" /> Message
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="profile-hero" style={{ marginBottom: 18 }}>
|
||||
<Avatar name={m.name} initials={m.initials} color={m.color} className="avatar-lg" />
|
||||
<Avatar name={m.name} className="avatar-lg" />
|
||||
<div>
|
||||
<div className="ph-name">{m.name}</div>
|
||||
<div className="ph-role">{m.title}</div>
|
||||
<div className="ph-role">{m.title || m.roleName || 'Hiring manager'}</div>
|
||||
<div className="ph-tags">
|
||||
<Badge className="b-indigo">{m.department}</Badge>
|
||||
<span className="badge b-gray badge-plain">{m.teamSize} reports</span>
|
||||
{m.department && <Badge className="b-indigo">{m.department}</Badge>}
|
||||
{m.teamSize != null && <span className="badge b-gray badge-plain">{m.teamSize} reports</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-3" style={{ marginBottom: 18 }}>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{jobs.length}</span><span className="stat-mini-lbl">Total Jobs</span></div>
|
||||
<div className="stat-mini">
|
||||
<span className="stat-mini-val">{jobs.reduce((s, j) => s + j.applications, 0)}</span>
|
||||
<span className="stat-mini-lbl">Applications</span>
|
||||
{messaging ? (
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>To</label>
|
||||
<input value={m.email} readOnly />
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Subject</label>
|
||||
<input id="mgr-msg-subject" defaultValue={`Hiring update`} />
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Message</label>
|
||||
<textarea id="mgr-msg-body" rows={5} placeholder="Write a message…" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Hiring Manager Portal</div>
|
||||
<div className="grid g-2" style={{ gap: 10, marginBottom: 16 }}>
|
||||
<button className="btn btn-secondary" onClick={() => go('/jobs', { openCreate: true })}>
|
||||
<Icon name="plus" /> Raise Requisition
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go('/candidates')}>
|
||||
<Icon name="users" /> Review Candidates
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go('/interviews', { openSchedule: true })}>
|
||||
<Icon name="calendar" /> Schedule Interview
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go('/offers')}>
|
||||
<Icon name="check-circle" /> Approve Offers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Requisitions</div>
|
||||
<div className="list-tight">
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-muted">No requisitions</p>
|
||||
) : (
|
||||
jobs.map((j) => (
|
||||
<div
|
||||
key={j.id}
|
||||
className="list-row"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => go('/jobs', { openJob: j.id })}
|
||||
>
|
||||
<span className="kpi-icn i-indigo" style={{ width: 36, height: 36, borderRadius: 9 }}>
|
||||
<Icon name="briefcase" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{j.title}</div>
|
||||
<div className="lr-sub">{j.applications} applications</div>
|
||||
</div>
|
||||
<Badge>{j.status}</Badge>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid g-3" style={{ marginBottom: 18 }}>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{m.openReqs}</span><span className="stat-mini-lbl">Open Reqs</span></div>
|
||||
<div className="stat-mini"><span className="stat-mini-val">{jobs.length}</span><span className="stat-mini-lbl">Open Jobs</span></div>
|
||||
<div className="stat-mini">
|
||||
<span className="stat-mini-val">{m.email ? 'Yes' : '—'}</span>
|
||||
<span className="stat-mini-lbl">Email on file</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Hiring Manager Portal</div>
|
||||
<div className="grid g-2" style={{ gap: 10, marginBottom: 16 }}>
|
||||
<button className="btn btn-secondary" onClick={() => go('/jobs', { openCreate: true })}>
|
||||
<Icon name="plus" /> Raise Requisition
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go('/candidates')}>
|
||||
<Icon name="users" /> Review Candidates
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go('/interviews', { openSchedule: true })}>
|
||||
<Icon name="calendar" /> Schedule Interview
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => go('/offers')}>
|
||||
<Icon name="check-circle" /> Approve Offers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">Open requisitions</div>
|
||||
<p className="text-muted text-sm" style={{ marginBottom: 10 }}>
|
||||
Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager's own.
|
||||
</p>
|
||||
<div className="list-tight">
|
||||
{jobs.filter((j) => j.status === 'Open').length === 0 ? (
|
||||
<p className="text-muted">No open requisitions</p>
|
||||
) : (
|
||||
jobs.filter((j) => j.status === 'Open').slice(0, 8).map((j) => (
|
||||
<div
|
||||
key={j.id}
|
||||
className="list-row"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => go('/jobs', { openJob: j.id })}
|
||||
>
|
||||
<span className="kpi-icn i-indigo" style={{ width: 36, height: 36, borderRadius: 9 }}>
|
||||
<Icon name="briefcase" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{j.title}</div>
|
||||
<div className="lr-sub">{[j.department, j.location].filter(Boolean).join(' · ') || '—'}</div>
|
||||
</div>
|
||||
<Badge>{j.status}</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useSearchParams } from 'react-router-dom'
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
|
|
@ -131,8 +132,14 @@ async function fetchDetail(recordId) {
|
|||
color: avatarColor(name),
|
||||
email: row.fromEmail || '',
|
||||
position: row.subject || '(no subject)',
|
||||
// Same value as `position`, kept under its own name: the email panel renders
|
||||
// it as a mail header, not as the candidate's role.
|
||||
subject: row.subject || '',
|
||||
...sourceFrom(row.message_to),
|
||||
body: htmlToText(row.body),
|
||||
// Kept raw for the HTML viewer; `body` stays as the plain-text fallback for
|
||||
// mail that never had markup. EmailBody sanitises before rendering.
|
||||
bodyHtml: row.body || '',
|
||||
resumeText: row.resume_text || '',
|
||||
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
|
||||
processing: row.unread ? 'Unread' : 'Read',
|
||||
|
|
@ -769,19 +776,26 @@ function MatchingWorkspace({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Email first: it is the application itself, and the resume is its
|
||||
attachment. Reading order follows that. */}
|
||||
{(detail?.subject || detail?.body) && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="fw-600" style={{ marginBottom: 6 }}>Email</div>
|
||||
<div className="email-head">Subject: {detail.subject || '(no subject)'}</div>
|
||||
{looksLikeHtml(detail.bodyHtml) ? (
|
||||
<EmailBody html={detail.bodyHtml} />
|
||||
) : (
|
||||
<pre className="resume-thumb is-full email-plain">
|
||||
{detail.body || 'No email body.'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fw-600" style={{ marginBottom: 6 }}>Resume text</div>
|
||||
<pre className="resume-thumb" style={{ maxHeight: 220, marginBottom: 16 }}>
|
||||
<pre className="resume-thumb is-full">
|
||||
{resumeText || 'Resume text not extracted yet.'}
|
||||
</pre>
|
||||
|
||||
{(detail?.body) && (
|
||||
<>
|
||||
<div className="fw-600" style={{ marginBottom: 6 }}>Email body</div>
|
||||
<pre className="resume-thumb" style={{ maxHeight: 160 }}>
|
||||
{detail.body}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,51 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Icon } from '../ui/primitives'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { EmptyState, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as notificationsApi from '../api/notifications'
|
||||
|
||||
async function fetchNotifications() {
|
||||
const res = await notificationsApi.list({ top: 100 })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return {
|
||||
items: rows.map(notificationsApi.toNotificationView),
|
||||
unread: res?.unread ?? 0,
|
||||
total: res?.total ?? rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Notifications() {
|
||||
const { toast } = useToast()
|
||||
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
|
||||
const update = useSeedMutation('notifications')
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const query = useQuery({ queryKey: qk.notifications.list(), queryFn: fetchNotifications })
|
||||
const items = query.data?.items ?? []
|
||||
|
||||
// Marking one read used to be `this.classList.remove('unread')` — a DOM edit
|
||||
// the badge count never saw. Writing to the cache keeps the sidebar in sync.
|
||||
const markOne = (i) => update((ns) => ns.map((n, j) => (j === i ? { ...n, unread: false } : n)))
|
||||
const markAll = () => {
|
||||
update((ns) => ns.map((n) => ({ ...n, unread: false })))
|
||||
toast('All notifications marked as read', 'success')
|
||||
const markOne = useMutation({
|
||||
mutationFn: (id) => notificationsApi.markRead(id),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not mark as read.'), 'error'),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.notifications.all() }),
|
||||
})
|
||||
|
||||
const markAll = useMutation({
|
||||
mutationFn: () => notificationsApi.markAllRead(),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not mark all as read.'), 'error'),
|
||||
onSuccess: () => toast('All notifications marked as read', 'success'),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.notifications.all() }),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id) => notificationsApi.remove(id),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the notification.'), 'error'),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.notifications.all() }),
|
||||
})
|
||||
|
||||
function open(n) {
|
||||
if (n.unread) markOne.mutate(n.id)
|
||||
if (n.linkPath) navigate(n.linkPath)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -24,33 +56,68 @@ export default function Notifications() {
|
|||
<p className="page-sub">Stay on top of hiring activity</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button className="btn btn-secondary" onClick={markAll}><Icon name="check" /> Mark all read</button>
|
||||
<button className="btn btn-ghost" onClick={() => toast('Notification settings', 'info')}>
|
||||
<Icon name="more" />
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
disabled={markAll.isPending}
|
||||
onClick={() => markAll.mutate()}
|
||||
>
|
||||
<Icon name="check" /> Mark all read
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="list-tight" style={{ padding: 0 }}>
|
||||
{notifications.map((n, i) => (
|
||||
<div
|
||||
key={n.id ?? `${n.title}-${i}`}
|
||||
className={`notif-row${n.unread ? ' unread' : ''}`}
|
||||
onClick={() => markOne(i)}
|
||||
>
|
||||
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
|
||||
<div className="notif-body">
|
||||
<div className="notif-title">{n.title}</div>
|
||||
<div className="notif-text">{n.text}</div>
|
||||
<div className="notif-time">{n.time}</div>
|
||||
{query.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="bell" title="Loading…">Fetching notifications.</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{query.isError && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="bell" title="Couldn’t load notifications">
|
||||
{friendlyAuthError(query.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{query.isSuccess && items.length === 0 && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="bell" title="No notifications">
|
||||
New activity — assessments, requisitions, mail — will land here.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{query.isSuccess && items.length > 0 && (
|
||||
<div className="list-tight" style={{ padding: 0 }}>
|
||||
{items.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`notif-row${n.unread ? ' unread' : ''}`}
|
||||
onClick={() => open(n)}
|
||||
style={{ cursor: n.linkPath || n.unread ? 'pointer' : 'default' }}
|
||||
>
|
||||
<span className={`notif-icn ${n.color}`}><Icon name={n.icon} /></span>
|
||||
<div className="notif-body">
|
||||
<div className="notif-title">{n.title}</div>
|
||||
{n.text && <div className="notif-text">{n.text}</div>}
|
||||
<div className="notif-time">{n.time}</div>
|
||||
</div>
|
||||
{n.unread && (
|
||||
<span className="dot dot-blue" style={{ position: 'static', border: 'none', alignSelf: 'center' }} />
|
||||
)}
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
remove.mutate(n.id)
|
||||
}}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
{n.unread && (
|
||||
<span className="dot dot-blue" style={{ position: 'static', border: 'none', alignSelf: 'center' }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,51 +1,196 @@
|
|||
/* ============================================================
|
||||
Offers — live on backend/offer/app.py.
|
||||
|
||||
Read is GET /offers/fetch; Create Offer writes POST /offers/create; Send and
|
||||
Resend write POST /offers/issue (which stamps issued_by + sent_at and moves
|
||||
draft -> sent, logging the change to offer_status_history); the response
|
||||
actions write PATCH /offers/update.
|
||||
|
||||
HYDRATION, NOT N+1. serialize_offer returns foreign keys only — no candidate
|
||||
name, job title, department or recruiter. Two reads the screen needs anyway
|
||||
fill those in: the pipeline board (one row per application, carrying the
|
||||
person, their user id and their inbox id together) and /job/fetch?ids= for
|
||||
the titles. A per-row lookup would be one request per offer.
|
||||
|
||||
Two columns from the prototype are gone. `department` is not on a job post at
|
||||
all in the offers path, and `recruiter` is not on the offer record — neither
|
||||
has a source, so neither is rendered. Equity is `equity_units` +
|
||||
`equity_instrument` server-side, so the free-text "20k RSU" box became a
|
||||
number and a picker; nothing else would round-trip.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import DataTable from '../ui/DataTable'
|
||||
import Modal from '../ui/Modal'
|
||||
import { Avatar, Badge, FieldError, Icon, KpiCard } from '../ui/primitives'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
import { fmtDate, fmtShort, money, TODAY } from '../data/seed'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as offersApi from '../api/offers'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import { OFFER_STATUS_LABEL, OFFER_STATUS_VALUE } from '../api/offers'
|
||||
import { byUserId, useApplications } from '../lib/useApplications'
|
||||
import { avatarColor, fmtDate, fmtShort, initials as initialsOf, money } from '../data/seed'
|
||||
|
||||
const FETCH_TOP = 200
|
||||
|
||||
/* Stages a candidate must be at before an offer makes sense. The API does not
|
||||
enforce this — it is a data-entry guard, so the picker does not invite an
|
||||
offer to someone still in screening. */
|
||||
const OFFER_READY_STAGES = ['Interview', 'Offer', 'Hired']
|
||||
|
||||
/** Statuses reachable from the row menu, keyed by where the offer is now. */
|
||||
const NEXT_STATUSES = {
|
||||
sent: ['negotiating', 'accepted', 'declined'],
|
||||
negotiating: ['accepted', 'declined'],
|
||||
draft: [],
|
||||
accepted: [],
|
||||
declined: [],
|
||||
expired: [],
|
||||
}
|
||||
|
||||
function useOffers(status) {
|
||||
return useQuery({
|
||||
queryKey: qk.offers.list({ top: FETCH_TOP, status: status || null }),
|
||||
queryFn: async () => {
|
||||
const res = await offersApi.list({ top: FETCH_TOP, status: status || undefined })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default function Offers() {
|
||||
const { toast } = useToast()
|
||||
const { data: offers = [] } = useQuery(seedQuery('offers'))
|
||||
const { data: candidates = [] } = useQuery(seedQuery('candidates'))
|
||||
const updateOffers = useSeedMutation('offers')
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [statusLabel, setStatusLabel] = useState('')
|
||||
const [viewing, setViewing] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const decided = offers.filter((o) => ['Accepted', 'Declined'].includes(o.status)).length
|
||||
return {
|
||||
sent: offers.filter((o) => o.status !== 'Draft').length,
|
||||
accepted: offers.filter((o) => o.status === 'Accepted').length,
|
||||
pending: offers.filter((o) => ['Sent', 'Negotiating'].includes(o.status)).length,
|
||||
rate: Math.round((offers.filter((o) => o.status === 'Accepted').length / (decided || 1)) * 100),
|
||||
const status = statusLabel ? OFFER_STATUS_VALUE[statusLabel] : ''
|
||||
|
||||
const offersQuery = useOffers(status)
|
||||
/* KPIs count the whole table, not the filtered page. With no filter this is
|
||||
the same query key as above, so React Query serves both from one request. */
|
||||
const allQuery = useOffers('')
|
||||
|
||||
const appsQuery = useApplications()
|
||||
|
||||
/* candidate_user_id -> the person. Built from the pipeline board, which is
|
||||
the only payload carrying user id, name and email on one row. */
|
||||
const peopleByUserId = useMemo(() => byUserId(appsQuery.data), [appsQuery.data])
|
||||
|
||||
/* Titles for every job referenced by an offer, in one call. Offers can point
|
||||
at a closed requisition, so active_only is false. */
|
||||
const jobIds = useMemo(() => {
|
||||
const ids = new Set()
|
||||
for (const row of allQuery.data ?? []) {
|
||||
if (row.job_post_id) ids.add(String(row.job_post_id))
|
||||
}
|
||||
}, [offers])
|
||||
return [...ids]
|
||||
}, [allQuery.data])
|
||||
|
||||
const titlesQuery = useQuery({
|
||||
queryKey: qk.jobPosts.list({ ids: jobIds }),
|
||||
queryFn: async () => {
|
||||
if (!jobIds.length) return []
|
||||
const res = await jobPostsApi.list({ ids: jobIds, activeOnly: false })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
enabled: jobIds.length > 0,
|
||||
})
|
||||
|
||||
const jobTitles = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const p of titlesQuery.data ?? []) map.set(String(p.id), p.title)
|
||||
return map
|
||||
}, [titlesQuery.data])
|
||||
|
||||
const hydration = useMemo(
|
||||
() => ({ people: peopleByUserId, jobTitles }),
|
||||
[peopleByUserId, jobTitles],
|
||||
)
|
||||
|
||||
const offers = useMemo(
|
||||
() => (offersQuery.data ?? []).map((row) => offersApi.toOfferView(row, hydration)),
|
||||
[offersQuery.data, hydration],
|
||||
)
|
||||
const all = useMemo(
|
||||
() => (allQuery.data ?? []).map((row) => offersApi.toOfferView(row, hydration)),
|
||||
[allQuery.data, hydration],
|
||||
)
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const decided = all.filter((o) => ['accepted', 'declined'].includes(o.status)).length
|
||||
const accepted = all.filter((o) => o.status === 'accepted').length
|
||||
return {
|
||||
sent: all.filter((o) => o.status !== 'draft').length,
|
||||
accepted,
|
||||
pending: all.filter((o) => ['sent', 'negotiating'].includes(o.status)).length,
|
||||
rate: Math.round((accepted / (decided || 1)) * 100),
|
||||
}
|
||||
}, [all])
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
offers.filter((o) => {
|
||||
if (status && o.status !== status) return false
|
||||
if (q && !(o.candidate + o.jobTitle + o.recruiter).toLowerCase().includes(q.toLowerCase())) return false
|
||||
return true
|
||||
if (!q) return true
|
||||
const hay = `${o.candidate} ${o.jobTitle} ${o.email ?? ''}`.toLowerCase()
|
||||
return hay.includes(q.toLowerCase())
|
||||
}),
|
||||
[offers, q, status],
|
||||
[offers, q],
|
||||
)
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.offers.all() })
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: (offerId) => offersApi.issue(offerId),
|
||||
onSuccess: (_res, _id) => {
|
||||
invalidate()
|
||||
toast('Offer issued and marked sent', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not issue the offer.'), 'error'),
|
||||
})
|
||||
|
||||
const setStatus = useMutation({
|
||||
mutationFn: ({ offerId, next }) => {
|
||||
const body = { status: next }
|
||||
/* responded_at is what separates "we sent it" from "they answered". The
|
||||
server does not stamp it, so the client does, on the two statuses that
|
||||
actually represent a candidate response. */
|
||||
if (next === 'accepted' || next === 'declined') {
|
||||
body.responded_at = new Date().toISOString()
|
||||
}
|
||||
return offersApi.update(offerId, body)
|
||||
},
|
||||
onSuccess: (_res, { next }) => {
|
||||
invalidate()
|
||||
toast(`Offer marked ${OFFER_STATUS_LABEL[next].toLowerCase()}`, 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update the offer.'), 'error'),
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body) => offersApi.create(body),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setCreating(false)
|
||||
toast('Offer created as a draft — issue it when ready', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not create the offer.'), 'error'),
|
||||
})
|
||||
|
||||
const busy = issue.isPending || setStatus.isPending
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'candidate', label: 'Candidate', sortable: true,
|
||||
render: (o) => (
|
||||
<div className="user-cell">
|
||||
<Avatar name={o.candidate} initials={o.initials} color={o.color} />
|
||||
<Avatar name={o.candidate} initials={initialsOf(o.candidate)} color={avatarColor(o.candidate)} />
|
||||
<div>
|
||||
<div className="cell-primary">{o.candidate}</div>
|
||||
<div className="cell-sub">{o.jobTitle}</div>
|
||||
|
|
@ -53,18 +198,43 @@ export default function Offers() {
|
|||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'department', label: 'Department', sortable: true },
|
||||
{ key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: (o) => <b>{money(o.base)}</b> },
|
||||
{ key: 'equity', label: 'Equity', render: (o) => <span className="text-muted">{o.equity}</span> },
|
||||
{ key: 'bonus', label: 'Bonus', align: 'center', render: (o) => <span className="text-muted">{o.bonus}</span> },
|
||||
{ key: 'sent', label: 'Sent', sortable: true, sortValue: (o) => o.sent.getTime(), render: (o) => <span className="text-muted">{fmtShort(o.sent)}</span> },
|
||||
{ key: 'status', label: 'Status', sortable: true, render: (o) => <Badge>{o.status}</Badge> },
|
||||
{
|
||||
key: 'base', label: 'Base Salary', sortable: true, align: 'right',
|
||||
sortValue: (o) => o.base ?? 0,
|
||||
render: (o) => (o.base != null ? <b>{money(o.base)}</b> : <span className="text-muted">—</span>),
|
||||
},
|
||||
{
|
||||
key: 'equity', label: 'Equity',
|
||||
render: (o) => <span className="text-muted">{o.equity ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'bonus', label: 'Bonus', align: 'center',
|
||||
render: (o) => <span className="text-muted">{o.bonus ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'sent', label: 'Sent', sortable: true,
|
||||
sortValue: (o) => (o.sent ? o.sent.getTime() : 0),
|
||||
render: (o) => <span className="text-muted">{o.sent ? fmtShort(o.sent) : '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status', label: 'Status', sortable: true,
|
||||
render: (o) => <Badge className={o.statusClass}>{o.statusLabel}</Badge>,
|
||||
},
|
||||
{
|
||||
key: '_a', label: 'Actions', align: 'right',
|
||||
render: (o) => (
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="View" onClick={() => setViewing(o)}><Icon name="eye" /></button>
|
||||
<button className="act-btn" data-tip="Resend" onClick={() => toast(`Offer resent to ${o.candidate}`, 'info')}><Icon name="send" /></button>
|
||||
<button className="act-btn" data-tip="View" onClick={() => setViewing(o)}>
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip={o.status === 'draft' ? 'Send offer' : 'Resend'}
|
||||
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
||||
onClick={() => issue.mutate(o.id)}
|
||||
>
|
||||
<Icon name="send" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
|
@ -85,10 +255,16 @@ export default function Offers() {
|
|||
</div>
|
||||
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Offers Sent" value={stats.sent} icon="send" tone="i-indigo" />
|
||||
<KpiCard label="Accepted" value={stats.accepted} icon="check-circle" tone="i-green" />
|
||||
<KpiCard label="Awaiting Response" value={stats.pending} icon="clock" tone="i-amber" />
|
||||
<KpiCard label="Acceptance Rate" value={`${stats.rate}%`} icon="trending-up" tone="i-teal" />
|
||||
<KpiCard label="Offers Sent" value={allQuery.isPending ? '—' : stats.sent} icon="send" tone="i-indigo" />
|
||||
<KpiCard label="Accepted" value={allQuery.isPending ? '—' : stats.accepted} icon="check-circle" tone="i-green" />
|
||||
<KpiCard label="Awaiting Response" value={allQuery.isPending ? '—' : stats.pending} icon="clock" tone="i-amber" />
|
||||
<KpiCard
|
||||
label="Acceptance Rate"
|
||||
value={allQuery.isPending ? '—' : `${stats.rate}%`}
|
||||
icon="trending-up"
|
||||
tone="i-teal"
|
||||
foot="of offers that got an answer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
|
@ -98,119 +274,208 @@ export default function Offers() {
|
|||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search candidate or role…" />
|
||||
</div>
|
||||
<select className="select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<select className="select" value={statusLabel} onChange={(e) => setStatusLabel(e.target.value)}>
|
||||
<option value="">All Status</option>
|
||||
{['Sent', 'Accepted', 'Negotiating', 'Declined', 'Draft', 'Expired'].map((s) => <option key={s}>{s}</option>)}
|
||||
{Object.values(OFFER_STATUS_LABEL).map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={rows} pageSize={8} />
|
||||
|
||||
{offersQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="file" title="Loading…">Fetching offers from the server.</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{offersQuery.isError && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load offers">
|
||||
{friendlyAuthError(offersQuery.error, 'The server did not return offers.')}
|
||||
{' '}This screen needs the <code>offers.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)}
|
||||
{!offersQuery.isPending && !offersQuery.isError && (
|
||||
<DataTable columns={columns} rows={rows} pageSize={8} empty="No offers match these filters." />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewing && <OfferDetail offer={viewing} onClose={() => setViewing(null)} toast={toast} />}
|
||||
{viewing && (
|
||||
<OfferDetail
|
||||
offer={viewing}
|
||||
busy={busy}
|
||||
onClose={() => setViewing(null)}
|
||||
onIssue={() => issue.mutate(viewing.id)}
|
||||
onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }}
|
||||
/>
|
||||
)}
|
||||
{creating && (
|
||||
<CreateOffer
|
||||
candidates={candidates}
|
||||
applications={(appsQuery.data ?? []).filter((a) => OFFER_READY_STAGES.includes(a.stage))}
|
||||
loading={appsQuery.isPending}
|
||||
busy={create.isPending}
|
||||
onClose={() => setCreating(false)}
|
||||
onSave={(offer) => {
|
||||
updateOffers((os) => [offer, ...os])
|
||||
setCreating(false)
|
||||
toast('Offer sent successfully', 'success')
|
||||
}}
|
||||
toast={toast}
|
||||
onSubmit={(body) => create.mutate(body)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OfferDetail({ offer: o, onClose, toast }) {
|
||||
const total = o.base + Math.round((o.base * parseInt(o.bonus, 10)) / 100)
|
||||
function OfferDetail({ offer: o, busy, onClose, onIssue, onStatus }) {
|
||||
/* Est. total cash = base + the bonus percentage applied to it. Signing bonus
|
||||
is a one-off and is shown separately rather than folded in, because adding
|
||||
it would overstate year two. */
|
||||
const total = o.base != null
|
||||
? o.base + Math.round((o.base * (o.bonusPct ?? 0)) / 100)
|
||||
: null
|
||||
const next = NEXT_STATUSES[o.status] ?? []
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Offer Details"
|
||||
subtitle={o.id}
|
||||
subtitle={o.jobTitle}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Close</button>
|
||||
<button className="btn btn-secondary" onClick={() => toast('Offer PDF downloaded', 'info')}>
|
||||
<Icon name="download" /> Download
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => { onClose(); toast('Offer resent', 'success') }}>
|
||||
<Icon name="send" /> Resend Offer
|
||||
{next.map((s) => (
|
||||
<button key={s} className="btn btn-secondary" disabled={busy} onClick={() => onStatus(s)}>
|
||||
Mark {OFFER_STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={busy || ['accepted', 'declined'].includes(o.status)}
|
||||
onClick={() => { onIssue(); onClose() }}
|
||||
>
|
||||
<Icon name="send" /> {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-12 mb-18">
|
||||
<Avatar name={o.candidate} initials={o.initials} color={o.color} className="avatar-lg" />
|
||||
<Avatar
|
||||
name={o.candidate}
|
||||
initials={initialsOf(o.candidate)}
|
||||
color={avatarColor(o.candidate)}
|
||||
className="avatar-lg"
|
||||
/>
|
||||
<div>
|
||||
<div className="ph-name" style={{ fontSize: 17 }}>{o.candidate}</div>
|
||||
<div className="ph-role">{o.jobTitle} · {o.department}</div>
|
||||
<div className="ph-role">{o.jobTitle}{o.email ? ` · ${o.email}` : ''}</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }}><Badge>{o.status}</Badge></div>
|
||||
<div style={{ marginLeft: 'auto' }}><Badge className={o.statusClass}>{o.statusLabel}</Badge></div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
|
||||
<div className="card-body">
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Compensation Package</div>
|
||||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">Base Salary</div><div className="iv" style={{ fontSize: 18 }}>{money(o.base)}</div></div>
|
||||
<div className="info-item"><div className="il">Annual Bonus</div><div className="iv" style={{ fontSize: 18 }}>{o.bonus}</div></div>
|
||||
<div className="info-item"><div className="il">Equity</div><div className="iv" style={{ fontSize: 18 }}>{o.equity}</div></div>
|
||||
<div className="info-item"><div className="il">Est. Total Cash</div><div className="iv" style={{ fontSize: 18, color: 'var(--success)' }}>{money(total)}</div></div>
|
||||
<div className="info-item">
|
||||
<div className="il">Base Salary</div>
|
||||
<div className="iv" style={{ fontSize: 18 }}>
|
||||
{o.base != null ? `${money(o.base)} / ${o.salaryPeriod}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Annual Bonus</div>
|
||||
<div className="iv" style={{ fontSize: 18 }}>{o.bonus ?? '—'}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Equity</div>
|
||||
<div className="iv" style={{ fontSize: 18 }}>{o.equity ?? '—'}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Est. Total Cash</div>
|
||||
<div className="iv" style={{ fontSize: 18, color: 'var(--success)' }}>
|
||||
{total != null ? money(total) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-grid">
|
||||
<div className="info-item"><div className="il">Sent On</div><div className="iv">{fmtDate(o.sent)}</div></div>
|
||||
<div className="info-item"><div className="il">Expires</div><div className="iv">{fmtDate(o.expires)}</div></div>
|
||||
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{o.recruiter}</div></div>
|
||||
<div className="info-item"><div className="il">Signing Bonus</div><div className="iv">{o.signingBonus != null ? money(o.signingBonus) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Currency</div><div className="iv">{o.currency}</div></div>
|
||||
<div className="info-item"><div className="il">Start Date</div><div className="iv">{o.startDate ? fmtDate(o.startDate) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Expires</div><div className="iv">{o.expiry ? fmtDate(o.expiry) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Sent On</div><div className="iv">{o.sent ? fmtDate(o.sent) : 'Not sent yet'}</div></div>
|
||||
<div className="info-item"><div className="il">Responded</div><div className="iv">{o.respondedAt ? fmtDate(o.respondedAt) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Created</div><div className="iv">{o.created ? fmtDate(o.created) : '—'}</div></div>
|
||||
<div className="info-item"><div className="il">Offer ID</div><div className="iv mono">{o.id}</div></div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateOffer({ candidates, onClose, onSave, toast }) {
|
||||
const eligible = candidates.filter((c) => ['Interview', 'Offer'].includes(c.stage))
|
||||
const form = useFormState({
|
||||
candidate: eligible[0]?.name ?? '',
|
||||
base: '', bonus: '10', equity: '', expires: '', notes: '',
|
||||
function CreateOffer({ applications, loading, busy, onClose, onSubmit }) {
|
||||
const [form, setForm] = useState({
|
||||
inboxId: '',
|
||||
base: '',
|
||||
currency: 'USD',
|
||||
salaryPeriod: 'year',
|
||||
bonusPct: '10',
|
||||
signingBonus: '',
|
||||
equityUnits: '',
|
||||
equityInstrument: 'RSU',
|
||||
startDate: '',
|
||||
expiryDate: '',
|
||||
})
|
||||
const [errors, setErrors] = useState({})
|
||||
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const inboxId = form.inboxId || (applications[0] ? String(applications[0].inboxId) : '')
|
||||
const selected = applications.find((a) => String(a.inboxId) === String(inboxId)) ?? null
|
||||
|
||||
function submit() {
|
||||
if (!form.values.base || Number(form.values.base) <= 0) {
|
||||
form.setErrors({ base: 'Required' })
|
||||
toast('Enter a base salary', 'error')
|
||||
return
|
||||
if (busy) return
|
||||
const next = {}
|
||||
if (!selected) next.inboxId = 'Pick a candidate'
|
||||
/* All three links are required server-side (422 otherwise). A pipeline row
|
||||
always carries them, so a miss here means the picker is stale. */
|
||||
if (selected && (!selected.userId || !selected.jobPostId)) {
|
||||
next.inboxId = 'This application has no candidate account or assigned role'
|
||||
}
|
||||
const cand = candidates.find((c) => c.name === form.values.candidate) || candidates[0]
|
||||
onSave({
|
||||
id: `OFR-${9001 + candidates.length}`,
|
||||
candidate: cand.name, candidateId: cand.id, initials: cand.initials, color: cand.color,
|
||||
jobTitle: cand.jobTitle, department: cand.department, status: 'Sent',
|
||||
base: Number(form.values.base),
|
||||
equity: form.values.equity || '10k RSU',
|
||||
bonus: `${form.values.bonus || 10}%`,
|
||||
sent: new Date(TODAY),
|
||||
expires: form.values.expires ? new Date(form.values.expires) : new Date('2026-07-23'),
|
||||
recruiter: cand.recruiter,
|
||||
const base = form.base === '' ? null : Number(form.base)
|
||||
if (base == null || !Number.isFinite(base) || base <= 0) next.base = 'Enter a base salary'
|
||||
const bonus = form.bonusPct === '' ? null : Number(form.bonusPct)
|
||||
if (bonus != null && (!Number.isFinite(bonus) || bonus < 0)) next.bonusPct = 'Enter a valid percentage'
|
||||
const units = form.equityUnits === '' ? null : Number(form.equityUnits)
|
||||
if (units != null && (!Number.isInteger(units) || units < 0)) next.equityUnits = 'Whole units only'
|
||||
setErrors(next)
|
||||
if (Object.keys(next).length) return
|
||||
|
||||
const signing = form.signingBonus === '' ? null : Number(form.signingBonus)
|
||||
onSubmit({
|
||||
inbox_id: Number(selected.inboxId),
|
||||
job_post_id: selected.jobPostId,
|
||||
candidate_user_id: selected.userId,
|
||||
status: 'draft',
|
||||
base_salary: base,
|
||||
currency: form.currency,
|
||||
salary_period: form.salaryPeriod,
|
||||
annual_bonus_pct: bonus,
|
||||
signing_bonus: Number.isFinite(signing) ? signing : null,
|
||||
equity_units: units,
|
||||
equity_instrument: units != null ? form.equityInstrument : null,
|
||||
start_date: form.startDate ? new Date(form.startDate).toISOString() : null,
|
||||
expiry_date: form.expiryDate ? new Date(form.expiryDate).toISOString() : null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create Offer"
|
||||
subtitle="Generate and send an offer letter"
|
||||
subtitle="Saved as a draft — nothing is sent until you issue it"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit}><Icon name="send" /> Send Offer</button>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={busy || !applications.length}>
|
||||
<Icon name="check" /> {busy ? 'Saving…' : 'Save Draft'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
@ -218,41 +483,99 @@ function CreateOffer({ candidates, onClose, onSave, toast }) {
|
|||
<div className="form-grid">
|
||||
<div className="form-field col-span-2">
|
||||
<label>Candidate <span className="req">*</span></label>
|
||||
<select value={form.values.candidate} onChange={(e) => form.setField('candidate', e.target.value)}>
|
||||
{eligible.map((c) => <option key={c.id}>{c.name}</option>)}
|
||||
<select
|
||||
value={inboxId}
|
||||
className={errors.inboxId ? 'err' : ''}
|
||||
onChange={(e) => set('inboxId', e.target.value)}
|
||||
disabled={loading || !applications.length}
|
||||
>
|
||||
{loading && <option value="">Loading applications…</option>}
|
||||
{!loading && !applications.length && (
|
||||
<option value="">No candidates at interview stage or later</option>
|
||||
)}
|
||||
{applications.map((a) => (
|
||||
<option key={a.inboxId} value={a.inboxId}>
|
||||
{a.name}{a.jobTitle ? ` — ${a.jobTitle}` : ''} · {a.stage}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldError>{errors.inboxId}</FieldError>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Base Salary <span className="req">*</span></label>
|
||||
<input
|
||||
type="number" min="0" placeholder="140000"
|
||||
className={errors.base ? 'err' : ''}
|
||||
value={form.base}
|
||||
onChange={(e) => set('base', e.target.value)}
|
||||
/>
|
||||
<FieldError>{errors.base}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Currency</label>
|
||||
<select value={form.currency} onChange={(e) => set('currency', e.target.value)}>
|
||||
{['USD', 'EUR', 'GBP', 'PKR', 'AED'].map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Base Salary ($) <span className="req">*</span></label>
|
||||
<input
|
||||
type="number" placeholder="140000"
|
||||
className={form.errors.base ? 'err' : ''}
|
||||
value={form.values.base}
|
||||
onChange={(e) => form.setField('base', e.target.value)}
|
||||
/>
|
||||
<FieldError>{form.errors.base}</FieldError>
|
||||
<label>Period</label>
|
||||
<select value={form.salaryPeriod} onChange={(e) => set('salaryPeriod', e.target.value)}>
|
||||
<option value="year">Per year</option>
|
||||
<option value="month">Per month</option>
|
||||
<option value="hour">Per hour</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Annual Bonus (%)</label>
|
||||
<input type="number" value={form.values.bonus} onChange={(e) => form.setField('bonus', e.target.value)} />
|
||||
<input
|
||||
type="number" min="0"
|
||||
className={errors.bonusPct ? 'err' : ''}
|
||||
value={form.bonusPct}
|
||||
onChange={(e) => set('bonusPct', e.target.value)}
|
||||
/>
|
||||
<FieldError>{errors.bonusPct}</FieldError>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Signing Bonus</label>
|
||||
<input
|
||||
type="number" min="0" placeholder="10000"
|
||||
value={form.signingBonus}
|
||||
onChange={(e) => set('signingBonus', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Equity (RSU)</label>
|
||||
<input placeholder="20k RSU" value={form.values.equity} onChange={(e) => form.setField('equity', e.target.value)} />
|
||||
<label>Equity Units</label>
|
||||
<input
|
||||
type="number" min="0" placeholder="20000"
|
||||
className={errors.equityUnits ? 'err' : ''}
|
||||
value={form.equityUnits}
|
||||
onChange={(e) => set('equityUnits', e.target.value)}
|
||||
/>
|
||||
<FieldError>{errors.equityUnits}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Instrument</label>
|
||||
<select value={form.equityInstrument} onChange={(e) => set('equityInstrument', e.target.value)}>
|
||||
{['RSU', 'ISO', 'NSO', 'Options'].map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Start Date</label>
|
||||
<input type="date" value={form.startDate} onChange={(e) => set('startDate', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Expiration Date</label>
|
||||
<input type="date" value={form.values.expires} onChange={(e) => form.setField('expires', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Notes</label>
|
||||
<textarea
|
||||
placeholder="Additional details for the offer…"
|
||||
value={form.values.notes}
|
||||
onChange={(e) => form.setField('notes', e.target.value)}
|
||||
/>
|
||||
<input type="date" value={form.expiryDate} onChange={(e) => set('expiryDate', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted" style={{ marginTop: 16, fontSize: 13 }}>
|
||||
Equity is stored as a unit count plus an instrument, so “20k RSU” is entered as 20000 and RSU.
|
||||
Issuing the offer is a separate, permissioned step (<code>offers.approve</code>).
|
||||
</p>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,21 @@ export const KANBAN_STAGES = [
|
|||
const BOARD_LIMIT = 200
|
||||
const JOB_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Highest AI score first, unscored candidates last, newest first within a tie.
|
||||
*
|
||||
* The API sorts each list this way already, but it returns `inbox` and
|
||||
* `manual_upload` as two arrays from two queries — concatenating them would
|
||||
* rank each source separately and show two descending runs per column. One
|
||||
* ranking across both sources can only happen after the merge.
|
||||
*/
|
||||
function byScoreDesc(a, b) {
|
||||
if (a.aiScore == null && b.aiScore == null) return (b.applied ?? 0) - (a.applied ?? 0)
|
||||
if (a.aiScore == null) return 1
|
||||
if (b.aiScore == null) return -1
|
||||
return b.aiScore - a.aiScore || (b.applied ?? 0) - (a.applied ?? 0)
|
||||
}
|
||||
|
||||
async function fetchBoard(jobId) {
|
||||
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT })
|
||||
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
|
||||
|
|
@ -48,7 +63,7 @@ async function fetchBoard(jobId) {
|
|||
cards: [
|
||||
...inbox.map((row) => pipelineApi.toBoardCard(row)),
|
||||
...manuals.map((row) => pipelineApi.toManualBoardCard(row)),
|
||||
],
|
||||
].sort(byScoreDesc),
|
||||
total: res?.total ?? 0,
|
||||
stageCounts: pipelineApi.toStageCounts(res?.counts?.by_status),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
/* ============================================================
|
||||
Access Control — one of the three screens with a real backend.
|
||||
Access Control — fully live against backend/role/app.py.
|
||||
|
||||
The prototype's 13 modules x 8 permission types map EXACTLY onto the
|
||||
backend's 104-tag vocabulary (same modules, same actions, same order), so the
|
||||
matrix can render real server truth instead of an invented boolean grid.
|
||||
THE MATRIX AXES ARE NOW SERVER TRUTH. They used to be two hardcoded seed
|
||||
arrays (13 module labels, 8 action labels) positionally zipped against
|
||||
backend slugs — correct only for as long as nobody added a module. Both axes
|
||||
now come from GET /permission-tags/fetch, so a 105th tag appears here without
|
||||
a frontend change, and a renamed module cannot silently shift every column.
|
||||
|
||||
HONESTY NOTE: the prototype's "Save Changes" fired a success toast and saved
|
||||
nothing, and its matrix gated nothing (01-repository-assessment.md §2.4). The
|
||||
backend grants permissions through *bundles* (`roles.permissions` is a list of
|
||||
bundle ids), not per-tag, so an arbitrary tag set is not expressible through
|
||||
`PUT /roles/update`. Rather than reproduce a lying save button, the matrix
|
||||
shows resolved `effective_permissions` read-only and says where they come
|
||||
from. Creating a role is a real POST.
|
||||
THE SAVE BUTTON IS STILL NOT A PER-CELL TOGGLE, AND THAT IS DELIBERATE. The
|
||||
backend grants access through BUNDLES — `roles.permissions` is a list of
|
||||
permission-bundle ids, and `effective_permissions` is the resolved union. An
|
||||
arbitrary per-tag set is not expressible through PUT /roles/update, so the
|
||||
matrix stays read-only and the editable thing is the bundle set, which is
|
||||
what actually determines access. Editing bundles writes real permissions;
|
||||
a per-cell grid would have to lie about what it saved.
|
||||
|
||||
Delete is soft server-side and refuses system roles (Role.delete_role), so
|
||||
the button is hidden on those rather than offered and rejected.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
|
|
@ -24,43 +29,103 @@ import { useFormState } from '../components/AuthLayout'
|
|||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as rolesApi from '../api/roles'
|
||||
import { permTypes, rbacModules } from '../data/seed'
|
||||
|
||||
// Prototype label -> backend module slug. Order matches, so this is positional.
|
||||
const MODULE_SLUGS = [
|
||||
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users',
|
||||
]
|
||||
const ACTION_SLUGS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
|
||||
|
||||
const ROLE_COLORS = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)']
|
||||
|
||||
/** "rbac_users" -> "Rbac Users". Slugs are the source of truth; this is display only. */
|
||||
function humanise(slug) {
|
||||
return String(slug || '')
|
||||
.split(/[_-]/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export default function Rbac() {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: qk.roles.list(),
|
||||
queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
/* The 104-tag catalogue. Both matrix axes are derived from it, in first-seen
|
||||
order, which is the order the seeder inserted them — so the grid reads the
|
||||
same way the permission catalogue does. */
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: qk.roles.tags(),
|
||||
queryFn: () => rolesApi.listPermissionTags().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
/* Bundles are what a role is actually granted, so the editor needs them. */
|
||||
const bundlesQuery = useQuery({
|
||||
queryKey: qk.roles.permissions(),
|
||||
queryFn: () => rolesApi.listPermissions().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
const roles = rolesQuery.data ?? []
|
||||
const tags = tagsQuery.data ?? []
|
||||
const bundles = bundlesQuery.data ?? []
|
||||
|
||||
const { modules, actions } = useMemo(() => {
|
||||
const mods = []
|
||||
const acts = []
|
||||
for (const t of tags) {
|
||||
if (t.module && !mods.includes(t.module)) mods.push(t.module)
|
||||
if (t.action && !acts.includes(t.action)) acts.push(t.action)
|
||||
}
|
||||
return { modules: mods, actions: acts }
|
||||
}, [tags])
|
||||
|
||||
/* Only render a cell where the tag exists. A module that has no `export`
|
||||
action should show a gap, not an unchecked box implying it was denied. */
|
||||
const tagSet = useMemo(
|
||||
() => new Set(tags.map((t) => `${t.module}.${t.action}`)),
|
||||
[tags],
|
||||
)
|
||||
|
||||
const role = roles.find((r) => r.id === selectedId) ?? roles[0]
|
||||
const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role])
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: qk.roles.all() })
|
||||
|
||||
const createRole = useMutation({
|
||||
mutationFn: (body) => rolesApi.createRole(body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.roles.all() })
|
||||
invalidate()
|
||||
setCreating(false)
|
||||
toast('Role created', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not create the role.'), 'error'),
|
||||
})
|
||||
|
||||
const roles = rolesQuery.data ?? []
|
||||
const role = roles.find((r) => r.id === selectedId) ?? roles[0]
|
||||
const updateRole = useMutation({
|
||||
mutationFn: ({ id, body }) => rolesApi.updateRole(id, body),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
setEditing(null)
|
||||
toast('Role updated', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not update the role.'), 'error'),
|
||||
})
|
||||
|
||||
// effective_permissions is a flat list of "module.action" tags.
|
||||
const granted = useMemo(() => new Set(role?.effective_permissions ?? []), [role])
|
||||
const deleteRole = useMutation({
|
||||
mutationFn: (id) => rolesApi.deleteRole(id),
|
||||
onSuccess: (_res, id) => {
|
||||
invalidate()
|
||||
setConfirmDelete(null)
|
||||
if (selectedId === id) setSelectedId(null)
|
||||
toast('Role deleted', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the role.'), 'error'),
|
||||
})
|
||||
|
||||
const totalTags = tags.length
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
|
@ -68,7 +133,7 @@ export default function Rbac() {
|
|||
<div>
|
||||
<h1 className="page-title">Access Control</h1>
|
||||
<p className="page-sub">
|
||||
Enterprise RBAC — roles, permission bundles and the 104-tag vocabulary, live from the server
|
||||
Roles, permission bundles and the {totalTags || '104'}-tag vocabulary, live from the server
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
|
|
@ -79,7 +144,9 @@ export default function Rbac() {
|
|||
</div>
|
||||
|
||||
{rolesQuery.isPending && (
|
||||
<div className="card"><div className="card-body"><EmptyState icon="clock" title="Loading roles">Fetching from the server…</EmptyState></div></div>
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="clock" title="Loading roles">Fetching from the server…</EmptyState>
|
||||
</div></div>
|
||||
)}
|
||||
|
||||
{rolesQuery.isError && (
|
||||
|
|
@ -104,6 +171,11 @@ export default function Rbac() {
|
|||
key={r.id}
|
||||
className={`role-item${r.id === role?.id ? ' active' : ''}`}
|
||||
onClick={() => setSelectedId(r.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(r.id) }
|
||||
}}
|
||||
>
|
||||
<span className="role-badge" style={{ background: ROLE_COLORS[i % ROLE_COLORS.length] }}>
|
||||
<Icon name="shield" />
|
||||
|
|
@ -137,49 +209,78 @@ export default function Rbac() {
|
|||
<div className="flex items-center gap-8">
|
||||
{role.is_system && <Badge className="b-gray">System role</Badge>}
|
||||
<span className="badge b-gray badge-plain">
|
||||
{role.effective_permissions?.length ?? 0} / 104
|
||||
{role.effective_permissions?.length ?? 0}{totalTags ? ` / ${totalTags}` : ''}
|
||||
</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(role)}>
|
||||
<Icon name="edit" /> Edit
|
||||
</button>
|
||||
{/* delete_role refuses system roles server-side, so the
|
||||
control is hidden rather than offered and rejected. */}
|
||||
{!role.is_system && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setConfirmDelete(role)}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
<p className="text-muted text-sm" style={{ marginBottom: 12 }}>
|
||||
<Icon name="lock" /> These are the role’s <b>resolved</b> permissions. The server grants
|
||||
them through permission bundles
|
||||
{role.bundles?.length ? ` (${role.bundles.map((b) => b.name ?? b).join(', ')})` : ''},
|
||||
so individual cells are not directly editable here.
|
||||
<Icon name="lock" /> These are the role’s <b>resolved</b> permissions. Access is granted
|
||||
through bundles
|
||||
{role.bundles?.length
|
||||
? ` — ${role.bundles.map((b) => b.name ?? b).join(', ')}`
|
||||
: ' — none assigned yet'}
|
||||
. Edit the bundle set to change what this role can do.
|
||||
</p>
|
||||
<div className="table-wrap">
|
||||
<table className="rbac-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
{permTypes.map((p) => <th key={p}>{p}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rbacModules.map((label, mi) => (
|
||||
<tr key={label}>
|
||||
<td>{label}</td>
|
||||
{ACTION_SLUGS.map((action, ai) => {
|
||||
const on = granted.has(`${MODULE_SLUGS[mi]}.${action}`)
|
||||
return (
|
||||
<td key={action}>
|
||||
<span
|
||||
className={`perm-check${on ? ' on' : ''}`}
|
||||
title={`${MODULE_SLUGS[mi]}.${action}`}
|
||||
aria-label={`${label} ${permTypes[ai]}: ${on ? 'granted' : 'not granted'}`}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
||||
{tagsQuery.isPending && (
|
||||
<EmptyState icon="clock" title="Loading the permission catalogue">
|
||||
Fetching the tag vocabulary…
|
||||
</EmptyState>
|
||||
)}
|
||||
{tagsQuery.isError && (
|
||||
<EmptyState icon="alert" title="Couldn’t load permission tags">
|
||||
{friendlyAuthError(tagsQuery.error, 'The tag catalogue did not load.')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{tagsQuery.isSuccess && modules.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="rbac-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
{actions.map((a) => <th key={a}>{humanise(a)}</th>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modules.map((mod) => (
|
||||
<tr key={mod}>
|
||||
<td>{humanise(mod)}</td>
|
||||
{actions.map((action) => {
|
||||
const tag = `${mod}.${action}`
|
||||
if (!tagSet.has(tag)) {
|
||||
return <td key={action}><span className="text-muted">·</span></td>
|
||||
}
|
||||
const on = granted.has(tag)
|
||||
return (
|
||||
<td key={action}>
|
||||
<span
|
||||
className={`perm-check${on ? ' on' : ''}`}
|
||||
title={tag}
|
||||
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -188,42 +289,119 @@ export default function Rbac() {
|
|||
)}
|
||||
|
||||
{creating && (
|
||||
<CreateRole
|
||||
<RoleForm
|
||||
title="Create Role"
|
||||
subtitle="Define a new access role"
|
||||
bundles={bundles}
|
||||
bundlesLoading={bundlesQuery.isPending}
|
||||
busy={createRole.isPending}
|
||||
onClose={() => setCreating(false)}
|
||||
onSave={(body) => createRole.mutate(body)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<RoleForm
|
||||
title="Edit Role"
|
||||
subtitle={editing.role_name}
|
||||
role={editing}
|
||||
bundles={bundles}
|
||||
bundlesLoading={bundlesQuery.isPending}
|
||||
busy={updateRole.isPending}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={(body) => updateRole.mutate({ id: editing.id, body })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<Modal
|
||||
title="Delete role"
|
||||
subtitle={confirmDelete.role_name}
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={() => setConfirmDelete(null)} disabled={deleteRole.isPending}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={deleteRole.isPending}
|
||||
onClick={() => deleteRole.mutate(confirmDelete.id)}
|
||||
>
|
||||
<Icon name="trash" /> {deleteRole.isPending ? 'Deleting…' : 'Delete role'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
<b>{confirmDelete.role_name}</b> will be soft-deleted. Anyone currently holding it keeps the
|
||||
account but loses every permission the role granted, so reassign them first.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateRole({ busy, onClose, onSave }) {
|
||||
const form = useFormState({ role_name: '', description: '' })
|
||||
/**
|
||||
* One form for create and edit. `permissions` is a list of BUNDLE IDS — the
|
||||
* only permission grant the API accepts — so the editor is a bundle checklist,
|
||||
* not a tag grid.
|
||||
*/
|
||||
function RoleForm({ title, subtitle, role, bundles, bundlesLoading, busy, onClose, onSave }) {
|
||||
const form = useFormState({
|
||||
role_name: role?.role_name ?? '',
|
||||
description: role?.description ?? '',
|
||||
})
|
||||
const [picked, setPicked] = useState(() => new Set((role?.permissions ?? []).map(Number)))
|
||||
const [isActive, setIsActive] = useState(role?.is_active !== false)
|
||||
|
||||
/* A role opened from the list may arrive before the bundle list does; sync
|
||||
once the role identity changes rather than on every render. */
|
||||
useEffect(() => {
|
||||
setPicked(new Set((role?.permissions ?? []).map(Number)))
|
||||
}, [role?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const toggle = (id) => setPicked((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const grantedTags = useMemo(() => {
|
||||
const out = new Set()
|
||||
for (const b of bundles) {
|
||||
if (picked.has(b.id)) for (const t of b.tag_names ?? []) out.add(t)
|
||||
}
|
||||
return out
|
||||
}, [bundles, picked])
|
||||
|
||||
function submit() {
|
||||
if (!form.values.role_name.trim()) {
|
||||
const name = form.values.role_name.trim()
|
||||
if (!name) {
|
||||
form.setErrors({ role_name: 'Required' })
|
||||
return
|
||||
}
|
||||
onSave({
|
||||
role_name: form.values.role_name.trim(),
|
||||
description: form.values.description.trim() || 'Custom role',
|
||||
permissions: [],
|
||||
is_active: true,
|
||||
role_name: name,
|
||||
description: form.values.description.trim() || null,
|
||||
permissions: [...picked],
|
||||
is_active: isActive,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create Role"
|
||||
subtitle="Define a new access role"
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={busy}>
|
||||
<Icon name="check" /> {busy ? 'Creating…' : 'Create Role'}
|
||||
<Icon name="check" /> {busy ? 'Saving…' : 'Save Role'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
|
|
@ -249,9 +427,54 @@ function CreateRole({ busy, onClose, onSave }) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
|
||||
The role starts with no permission bundles. Assign bundles server-side to grant it access.
|
||||
</p>
|
||||
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<h4>Active</h4>
|
||||
<p>Inactive roles stay on the books but grant nothing.</p>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} />
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-section-title">
|
||||
Permission bundles
|
||||
<span className="text-muted text-sm" style={{ marginLeft: 8, fontWeight: 400 }}>
|
||||
{picked.size} selected · {grantedTags.size} tags resolved
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{bundlesLoading && <p className="text-muted">Loading bundles…</p>}
|
||||
{!bundlesLoading && bundles.length === 0 && (
|
||||
<EmptyState icon="lock" title="No bundles available">
|
||||
Permission bundles are seeded server-side; without them a role can only be created empty.
|
||||
</EmptyState>
|
||||
)}
|
||||
<div className="list-tight">
|
||||
{bundles.map((b) => (
|
||||
<label
|
||||
className="setting-row"
|
||||
key={b.id}
|
||||
style={{ cursor: 'pointer', padding: '10px 0' }}
|
||||
>
|
||||
<div className="setting-info">
|
||||
<h4>{b.name}</h4>
|
||||
<p>
|
||||
{b.description || 'No description'}
|
||||
{b.tag_names?.length ? ` · ${b.tag_names.length} tags` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.has(b.id)}
|
||||
onChange={() => toggle(b.id)}
|
||||
aria-label={`Grant ${b.name}`}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,179 +1,384 @@
|
|||
/* ============================================================
|
||||
Recruiter Hub — live, by pointing every analytics endpoint at one recruiter.
|
||||
|
||||
The trick that makes this screen real: /analytics/kpis, /hiring-trend and
|
||||
/funnel all take a `recruiter_id`, so selecting a recruiter re-scopes the
|
||||
whole page server-side rather than filtering a client-side array. The
|
||||
recruiter list itself is /analytics/recruiter-performance, which is also the
|
||||
leaderboard.
|
||||
|
||||
TEN OF THE PROTOTYPE'S EIGHTEEN TILES ARE GONE. workload %, efficiency %, SLA
|
||||
state, interview completion %, avg response time, TAT %, star rating, jobs
|
||||
awaiting approval and jobs overdue have no column, no table and in most cases
|
||||
no concept behind them — there is no approval workflow and no requisition
|
||||
deadline in the schema. They were random numbers re-rolled on every render.
|
||||
What replaced them is derived from real counts and labelled as such:
|
||||
conversion rate is hires ÷ candidates, offer acceptance is accepted ÷ sent.
|
||||
|
||||
The workload heatmap survived because interviews are real: it buckets
|
||||
/interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
|
||||
per recruiter — the interviews table has no recruiter column — and the card
|
||||
says so rather than implying the selected person owns all of it.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Chart from '../ui/Chart'
|
||||
import Charts from '../lib/charts'
|
||||
import { Avatar, Badge, Icon, KpiCard } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { seedQuery } from '../data/seedQueries'
|
||||
import { analytics, int } from '../data/seed'
|
||||
import { Avatar, EmptyState, Icon, KpiCard } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as analyticsApi from '../api/analytics'
|
||||
import * as interviewsApi from '../api/interviews'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const WEEKS = ['W1', 'W2', 'W3', 'W4', 'W5']
|
||||
const STAGES = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
||||
const MAX_HEAT = 5
|
||||
const WEEKS = 5
|
||||
const TREND_MONTHS = 7
|
||||
const HEAT_MAX = 5
|
||||
|
||||
const heatColor = (v) => (v === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + (v / MAX_HEAT) * 0.8})`)
|
||||
const heatColor = (v) => (v === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + (Math.min(v, HEAT_MAX) / HEAT_MAX) * 0.8})`)
|
||||
|
||||
const pct = (num, den) => (den ? `${Math.round((num / den) * 100)}%` : '—')
|
||||
const days = (v) => (v == null ? '—' : `${Math.round(Number(v))}d`)
|
||||
|
||||
/** Monday-indexed weekday, so the grid reads Mon–Sun like the rest of the app. */
|
||||
function weekdayIndex(date) {
|
||||
return (date.getDay() + 6) % 7
|
||||
}
|
||||
|
||||
export default function RecruiterHub() {
|
||||
const { toast } = useToast()
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const [recId, setRecId] = useState(null)
|
||||
const [recruiterId, setRecruiterId] = useState('')
|
||||
|
||||
const r = recruiters.find((x) => x.id === recId) ?? recruiters[0]
|
||||
const boardQuery = useQuery({
|
||||
queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.recruiterPerformance({ top: 50 })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
|
||||
const trendData = useMemo(
|
||||
() =>
|
||||
r
|
||||
? {
|
||||
labels: analytics.hiringTrend.labels,
|
||||
area: true,
|
||||
datasets: [{ label: 'Hires', data: r.monthlyTrend, color: Charts.PALETTE[0] }],
|
||||
}
|
||||
: null,
|
||||
[r],
|
||||
const recruiters = boardQuery.data ?? []
|
||||
const selected = recruiters.find((r) => r.id === recruiterId) ?? recruiters[0] ?? null
|
||||
const activeId = selected?.id ?? null
|
||||
|
||||
const kpisQuery = useQuery({
|
||||
queryKey: qk.analytics.kpis({ recruiterId: activeId, scope: 'hub' }),
|
||||
queryFn: async () => (await analyticsApi.kpis({ recruiterId: activeId }))?.data ?? null,
|
||||
enabled: Boolean(activeId),
|
||||
})
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: qk.analytics.trend({ recruiterId: activeId, months: TREND_MONTHS, scope: 'hub' }),
|
||||
queryFn: async () => (await analyticsApi.hiringTrend({ months: TREND_MONTHS, recruiterId: activeId }))?.data
|
||||
?? { labels: [], hires: [] },
|
||||
enabled: Boolean(activeId),
|
||||
})
|
||||
|
||||
const funnelQuery = useQuery({
|
||||
queryKey: qk.analytics.funnel({ recruiterId: activeId, scope: 'hub' }),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.funnel({ recruiterId: activeId })
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
enabled: Boolean(activeId),
|
||||
})
|
||||
|
||||
/* The heatmap window: the last five whole weeks ending today. Sent as a real
|
||||
range so the request stays small however long the table gets. */
|
||||
const heatFrom = useMemo(() => {
|
||||
const d = new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
d.setDate(d.getDate() - (WEEKS * 7 - 1))
|
||||
return d
|
||||
}, [])
|
||||
|
||||
const heatQuery = useQuery({
|
||||
queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS }),
|
||||
queryFn: async () => {
|
||||
const res = await interviewsApi.listRange({
|
||||
fromDate: heatFrom.toISOString(),
|
||||
toDate: new Date().toISOString(),
|
||||
top: 500,
|
||||
})
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(interviewsApi.toInterviewView)
|
||||
},
|
||||
})
|
||||
|
||||
const heatmap = useMemo(() => {
|
||||
const grid = Array.from({ length: 7 }, () => Array.from({ length: WEEKS }, () => 0))
|
||||
for (const iv of heatQuery.data ?? []) {
|
||||
if (!iv.when) continue
|
||||
const dayOffset = Math.floor((iv.when - heatFrom) / 86400000)
|
||||
if (dayOffset < 0 || dayOffset >= WEEKS * 7) continue
|
||||
const week = Math.floor(dayOffset / 7)
|
||||
grid[weekdayIndex(iv.when)][week] += 1
|
||||
}
|
||||
return grid
|
||||
}, [heatQuery.data, heatFrom])
|
||||
|
||||
const weekLabels = useMemo(
|
||||
() => Array.from({ length: WEEKS }, (_, i) => (i === WEEKS - 1 ? 'This' : `W${i + 1}`)),
|
||||
[],
|
||||
)
|
||||
|
||||
// The prototype re-rolled these counts on every render via DB.int(). Keyed to
|
||||
// the recruiter so they're stable while you look at one.
|
||||
const pipelineData = useMemo(
|
||||
() => ({ labels: STAGES, data: STAGES.map(() => int(2, 14)), colors: Charts.PALETTE }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[r?.id],
|
||||
const trendData = useMemo(() => {
|
||||
const t = trendQuery.data ?? { labels: [], hires: [] }
|
||||
return {
|
||||
labels: t.labels ?? [],
|
||||
area: true,
|
||||
datasets: [{ label: 'Hires', data: t.hires ?? [], color: Charts.PALETTE[0] }],
|
||||
}
|
||||
}, [trendQuery.data])
|
||||
|
||||
const pipelineData = useMemo(() => {
|
||||
const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED')
|
||||
return {
|
||||
labels: rows.map((p) => p.stage),
|
||||
data: rows.map((p) => p.count),
|
||||
colors: Charts.PALETTE,
|
||||
}
|
||||
}, [funnelQuery.data])
|
||||
|
||||
const board = useMemo(
|
||||
() => [...recruiters].sort((a, b) => (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8),
|
||||
[recruiters],
|
||||
)
|
||||
|
||||
const board = useMemo(() => [...recruiters].sort((a, b) => b.hires - a.hires).slice(0, 8), [recruiters])
|
||||
if (boardQuery.isPending) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="clock" title="Loading recruiters…">Fetching the recruiter roster.</EmptyState>
|
||||
</div></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!r) return null
|
||||
if (boardQuery.isError) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load recruiter performance">
|
||||
{friendlyAuthError(boardQuery.error, 'The server did not answer.')}
|
||||
{' '}This screen needs the <code>analytics.view</code> permission.
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const slaCls = r.sla === 'On Track' ? 'b-green' : r.sla === 'At Risk' ? 'b-amber' : 'b-red'
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="users" title="No recruiters yet">
|
||||
Users with the <code>recruiter</code> role appear here once they exist.
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const k = kpisQuery.data
|
||||
const name = selected.name || 'Recruiter'
|
||||
const loading = kpisQuery.isPending
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Recruiter Hub</h1>
|
||||
<p className="page-sub">Personalized performance dashboard & workload</p>
|
||||
<p className="page-sub">Per-recruiter performance, scoped server-side</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<select className="select" value={r.id} onChange={(e) => setRecId(e.target.value)}>
|
||||
<select className="select" value={selected.id} onChange={(e) => setRecruiterId(e.target.value)}>
|
||||
{recruiters.map((x) => <option key={x.id} value={x.id}>{x.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-secondary" onClick={() => toast('Report exported', 'success')}>
|
||||
<Icon name="download" /> Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card brand-hero mb-18">
|
||||
<div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
|
||||
<Avatar name={r.name} initials={r.initials} color="rgba(255,255,255,.18)" className="avatar-lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700 }}>{r.name}</div>
|
||||
<div style={{ opacity: 0.85 }}>{r.department} Recruiter · ⭐ {r.rating} rating</div>
|
||||
<div className="card-body" style={{ display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}>
|
||||
<Avatar name={name} initials={initialsOf(name)} color="rgba(255,255,255,.18)" className="avatar-lg" />
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700 }}>{name}</div>
|
||||
<div style={{ opacity: 0.85 }}>
|
||||
{selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'}
|
||||
{' · '}
|
||||
{selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 800 }}>{r.workload}%</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 12 }}>Workload</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 800 }}>
|
||||
{loading ? '—' : pct(k?.hires ?? 0, k?.total_candidates ?? 0)}
|
||||
</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 12 }}>Applicant → hire</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 800 }}>{r.efficiency}%</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 12 }}>Efficiency</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 800 }}>
|
||||
{loading ? '—' : pct(k?.offers_accepted ?? 0, k?.offers_sent ?? 0)}
|
||||
</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 12 }}>Offer acceptance</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}><Badge className={slaCls}>{r.sla}</Badge></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kpisQuery.isError && (
|
||||
<div className="card mb-18"><div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load this recruiter’s metrics">
|
||||
{friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)}
|
||||
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Open Positions" value={r.openPositions} icon="briefcase" tone="i-indigo" foot="active reqs" />
|
||||
<KpiCard label="Closed Positions" value={r.closedPositions} icon="check-circle" tone="i-green" foot="this year" />
|
||||
<KpiCard label="Avg Time to Hire" value={`${r.avgTimeToHire}d`} icon="clock" tone="i-teal" foot="target 30d" />
|
||||
<KpiCard label="Avg Time to Fill" value={`${r.avgTimeToFill}d`} icon="target" tone="i-amber" foot="req → offer" />
|
||||
<KpiCard label="Open Positions" value={loading ? '—' : (k?.open_jobs ?? selected.open_reqs ?? 0)} icon="briefcase" tone="i-indigo" foot="active reqs" />
|
||||
<KpiCard label="Closed Positions" value={loading ? '—' : (k?.closed_jobs ?? 0)} icon="check-circle" tone="i-green" foot="in window" />
|
||||
<KpiCard label="Avg Time to Hire" value={loading ? '—' : days(k?.time_to_hire ?? selected.avg_time_to_hire)} icon="clock" tone="i-teal" foot="offer accepted → hire" />
|
||||
<KpiCard label="Avg Time to Fill" value={loading ? '—' : days(k?.time_to_fill)} icon="target" tone="i-amber" foot="req opened → closed" />
|
||||
</div>
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Interviews Today" value={r.interviewsToday} icon="calendar" tone="i-purple" />
|
||||
<KpiCard label="Offers Pending" value={r.offersPending} icon="file" tone="i-blue" />
|
||||
<KpiCard label="Awaiting Approval" value={r.jobsAwaitingApproval} icon="clock" tone="i-amber" />
|
||||
<KpiCard label="Jobs Overdue" value={r.jobsOverdue} icon="alert" tone="i-red" />
|
||||
</div>
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard label="Conversion Rate" value={`${r.conversionRate}%`} icon="trending-up" tone="i-green" foot="applicant → hire" />
|
||||
<KpiCard label="Interview Completion" value={`${r.interviewCompletion}%`} icon="check-square" tone="i-teal" />
|
||||
<KpiCard label="Avg Response Time" value={`${r.avgResponseTime}h`} icon="zap" tone="i-purple" foot="to candidates" />
|
||||
<KpiCard label="TAT Performance" value={`${r.tat}%`} icon="award" tone="i-indigo" foot="turnaround" />
|
||||
<KpiCard label="Interviews Today" value={loading ? '—' : (k?.interviews_today ?? 0)} icon="calendar" tone="i-purple" foot={k?.interviews_upcoming != null ? `${k.interviews_upcoming} upcoming` : undefined} />
|
||||
<KpiCard label="Offers Sent" value={loading ? '—' : (k?.offers_sent ?? 0)} icon="send" tone="i-blue" />
|
||||
<KpiCard label="Offers Accepted" value={loading ? '—' : (k?.offers_accepted ?? 0)} icon="file" tone="i-green" />
|
||||
<KpiCard label="Candidates" value={loading ? '—' : (k?.total_candidates ?? 0)} icon="users" tone="i-indigo" foot="in their pipeline" />
|
||||
</div>
|
||||
|
||||
<div className="grid g-2-1 mb-18">
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Monthly Hiring Trend</h3><span className="ch-sub">Hires per month</span></div></div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="line" data={trendData} height={260} /></div></div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Workload Heatmap</h3><span className="ch-sub">Interview load</span></div></div>
|
||||
<div className="card-head">
|
||||
<div><h3>Monthly Hiring Trend</h3><span className="ch-sub">Hires per month, this recruiter</span></div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="heatmap">
|
||||
<div className="hm-label" />
|
||||
{WEEKS.map((w) => <div className="hm-label" style={{ justifyContent: 'center' }} key={w}>{w}</div>)}
|
||||
{DAYS.map((d, di) => (
|
||||
<div style={{ display: 'contents' }} key={d}>
|
||||
<div className="hm-label">{d}</div>
|
||||
{r.heatmap[di].map((v, wi) => (
|
||||
<div
|
||||
className="hm-cell"
|
||||
key={`${d}-${wi}`}
|
||||
style={{ background: heatColor(v) }}
|
||||
data-tip={`${v} interviews`}
|
||||
/>
|
||||
{trendQuery.isPending && <EmptyState icon="clock" title="Loading…">Fetching the trend.</EmptyState>}
|
||||
{trendQuery.isError && (
|
||||
<EmptyState icon="alert" title="Couldn’t load the trend">
|
||||
{friendlyAuthError(trendQuery.error, 'The server did not answer.')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{trendQuery.isSuccess && (
|
||||
<div className="chart-wrap"><Chart type="line" data={trendData} height={260} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Interview Load</h3>
|
||||
<span className="ch-sub">Team-wide, last {WEEKS} weeks</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{heatQuery.isPending ? (
|
||||
<EmptyState icon="clock" title="Loading…">Bucketing interviews.</EmptyState>
|
||||
) : heatQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load interviews">
|
||||
{friendlyAuthError(heatQuery.error, 'The server did not answer.')}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="heatmap">
|
||||
<div className="hm-label" />
|
||||
{weekLabels.map((w) => (
|
||||
<div className="hm-label" style={{ justifyContent: 'center' }} key={w}>{w}</div>
|
||||
))}
|
||||
{DAYS.map((d, di) => (
|
||||
<div style={{ display: 'contents' }} key={d}>
|
||||
<div className="hm-label">{d}</div>
|
||||
{heatmap[di].map((v, wi) => (
|
||||
<div
|
||||
className="hm-cell"
|
||||
key={`${d}-${wi}`}
|
||||
style={{ background: heatColor(v) }}
|
||||
data-tip={`${v} interview${v === 1 ? '' : 's'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hm-legend">
|
||||
Less
|
||||
{[0, 1, 2, 3, 5].map((v) => (
|
||||
<span className="hm-box" key={v} style={{ background: heatColor(v) }} />
|
||||
))}
|
||||
More
|
||||
</div>
|
||||
<div className="hm-legend">
|
||||
Less
|
||||
{[0, 1, 2, 3, 5].map((v) => (
|
||||
<span className="hm-box" key={v} style={{ background: heatColor(v) }} />
|
||||
))}
|
||||
More
|
||||
</div>
|
||||
<p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}>
|
||||
<Icon name="info" /> Interviews carry no recruiter, so this counts the whole team.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-2">
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Top performers by hires</span></div></div>
|
||||
<div className="card-head">
|
||||
<div><h3>Recruiter Leaderboard</h3><span className="ch-sub">Top performers by hires</span></div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{board.map((rec, i) => (
|
||||
<div
|
||||
className="leader-row"
|
||||
key={rec.id}
|
||||
style={
|
||||
rec.id === r.id
|
||||
? { background: 'var(--primary-soft)', borderRadius: 10, paddingLeft: 8, paddingRight: 8 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className={`leader-rank ${i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<Avatar name={rec.name} initials={rec.initials} color={rec.color} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{rec.name}</div>
|
||||
<div className="lr-sub">{rec.efficiency}% efficiency · {rec.avgTimeToHire}d avg</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
<div className="fw-600">{rec.hires}</div>
|
||||
<div className="lr-sub">hires</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{board.length === 0 ? (
|
||||
<EmptyState icon="users" title="No hires recorded yet">
|
||||
The board fills in as applications reach the hired stage.
|
||||
</EmptyState>
|
||||
) : (
|
||||
board.map((rec, i) => {
|
||||
const rn = rec.name || 'Recruiter'
|
||||
return (
|
||||
<div
|
||||
className="leader-row"
|
||||
key={rec.id}
|
||||
style={
|
||||
rec.id === selected.id
|
||||
? { background: 'var(--primary-soft)', borderRadius: 10, paddingLeft: 8, paddingRight: 8 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className={`leader-rank ${i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<Avatar name={rn} initials={initialsOf(rn)} color={avatarColor(rn)} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{rn}</div>
|
||||
<div className="lr-sub">
|
||||
{rec.open_reqs ?? 0} open · {days(rec.avg_time_to_hire)} avg
|
||||
</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
<div className="fw-600">{rec.hires ?? 0}</div>
|
||||
<div className="lr-sub">hires</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>Candidate Pipeline</h3><span className="ch-sub">This recruiter’s active candidates</span></div>
|
||||
</div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="horizontalBar" data={pipelineData} height={260} /></div></div>
|
||||
<div className="card-body">
|
||||
{funnelQuery.isPending ? (
|
||||
<EmptyState icon="clock" title="Loading…">Fetching stage counts.</EmptyState>
|
||||
) : funnelQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load the pipeline">
|
||||
{friendlyAuthError(funnelQuery.error, 'The server did not answer.')}
|
||||
</EmptyState>
|
||||
) : pipelineData.data.every((n) => !n) ? (
|
||||
<EmptyState icon="inbox" title="No active candidates">
|
||||
Applications assigned to this recruiter appear here.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="chart-wrap"><Chart type="horizontalBar" data={pipelineData} height={260} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,94 +1,283 @@
|
|||
import { useMemo } from 'react'
|
||||
/* ============================================================
|
||||
Reports — live on /analytics/* plus the hiring-cost ledger (/job/costs/fetch).
|
||||
|
||||
THE FUNNEL IS AN APPROXIMATION AND THE CARD SAYS SO. /analytics/funnel/fetch
|
||||
returns a POINT-IN-TIME count per stage — where everyone stands right now —
|
||||
not how many ever passed through a stage. "Reached this stage" is therefore
|
||||
derived as the sum of every stage at or beyond it. Rejected applications are
|
||||
excluded because a point-in-time count does not record how far they got; the
|
||||
true history lives in application_stage_transitions, which has no global read
|
||||
(/pipeline/transitions/fetch needs one application id).
|
||||
|
||||
DEPARTMENT PERFORMANCE is one /analytics/kpis read per department, in
|
||||
parallel. There is no group-by endpoint, but `department` is a filter on
|
||||
every analytics route, and one KPI payload carries all four columns at once.
|
||||
|
||||
THE REPORT LIBRARY IS GONE. Six cards that fired a toast and generated
|
||||
nothing is worse than an honest note: there is no report-generation or export
|
||||
endpoint on the backend, so the grid was removed rather than left to imply
|
||||
otherwise. In its place is the real cost ledger those reports would draw on.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Chart, { ChartLegend } from '../ui/Chart'
|
||||
import Charts from '../lib/charts'
|
||||
import DataTable from '../ui/DataTable'
|
||||
import { Icon, KpiCard, ProgressBar } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { analytics as a, int } from '../data/seed'
|
||||
import { EmptyState, Icon, KpiCard, ProgressBar } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as analyticsApi from '../api/analytics'
|
||||
import * as costsApi from '../api/costs'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import { money } from '../data/seed'
|
||||
|
||||
const FUNNEL = [
|
||||
{ stage: 'Applied', v: 100 }, { stage: 'Screened', v: 62 }, { stage: 'Assessed', v: 41 },
|
||||
{ stage: 'Interviewed', v: 28 }, { stage: 'Offered', v: 14 }, { stage: 'Hired', v: 9 },
|
||||
const DEPT_CAP = 12
|
||||
const TREND_MONTHS = 7
|
||||
|
||||
const RANGES = [
|
||||
{ key: 'quarter', label: 'This quarter', days: 90 },
|
||||
{ key: 'half', label: 'Last 6 months', days: 182 },
|
||||
{ key: 'year', label: 'This year', days: 365 },
|
||||
]
|
||||
|
||||
const REPORT_TYPES = [
|
||||
{ name: 'Hiring Funnel Report', desc: 'Conversion rates across each pipeline stage', icn: 'filter', cls: 'i-indigo' },
|
||||
{ name: 'Source Effectiveness', desc: 'ROI and quality by sourcing channel', icn: 'target', cls: 'i-teal' },
|
||||
{ name: 'Diversity & Inclusion', desc: 'Demographic breakdown of the pipeline', icn: 'users', cls: 'i-purple' },
|
||||
{ name: 'Recruiter Scorecard', desc: 'Individual performance metrics', icn: 'award', cls: 'i-amber' },
|
||||
{ name: 'Offer Analysis', desc: 'Acceptance rates and compensation trends', icn: 'file', cls: 'i-green' },
|
||||
{ name: 'Interview Analytics', desc: 'Interviewer load and feedback quality', icn: 'calendar', cls: 'i-blue' },
|
||||
/* Order matters: "reached" is a running sum from the end of this list back to
|
||||
the start. REJECTED is deliberately absent — see the header note. */
|
||||
const FUNNEL_ORDER = [
|
||||
{ key: 'PENDING', label: 'Applied' },
|
||||
{ key: 'CLOSED', label: 'Applied' },
|
||||
{ key: 'SCREENING', label: 'Screened' },
|
||||
{ key: 'PROCESS', label: 'Screened' },
|
||||
{ key: 'ONHOLD', label: 'Screened' },
|
||||
{ key: 'ASSESSMENT', label: 'Assessed' },
|
||||
{ key: 'INTERVIEW', label: 'Interviewed' },
|
||||
{ key: 'OFFER', label: 'Offered' },
|
||||
{ key: 'APPROVED', label: 'Hired' },
|
||||
{ key: 'HIRED', label: 'Hired' },
|
||||
]
|
||||
|
||||
function rangeWindow(key) {
|
||||
const range = RANGES.find((r) => r.key === key) ?? RANGES[0]
|
||||
const to = new Date()
|
||||
const from = new Date(to.getTime() - range.days * 86400000)
|
||||
return { fromDate: from.toISOString(), toDate: to.toISOString() }
|
||||
}
|
||||
|
||||
export default function Reports() {
|
||||
const { toast } = useToast()
|
||||
const [rangeKey, setRangeKey] = useState('quarter')
|
||||
|
||||
// The prototype generated hires/ttf inline at render time via DB.int(), so
|
||||
// they changed on every re-render. Computed once here instead.
|
||||
const deptRows = useMemo(
|
||||
() =>
|
||||
a.departments.map((d) => {
|
||||
const rate = Math.round((d.open ? d.apps / (d.open * 40) : 0.5) * 100)
|
||||
const span = useMemo(() => rangeWindow(rangeKey), [rangeKey])
|
||||
const keyParams = useMemo(() => ({ range: rangeKey, scope: 'reports' }), [rangeKey])
|
||||
|
||||
const kpisQuery = useQuery({
|
||||
queryKey: qk.analytics.kpis(keyParams),
|
||||
queryFn: async () => (await analyticsApi.kpis(span))?.data ?? null,
|
||||
})
|
||||
const trendQuery = useQuery({
|
||||
queryKey: qk.analytics.trend({ ...keyParams, months: TREND_MONTHS }),
|
||||
queryFn: async () => (await analyticsApi.hiringTrend({ months: TREND_MONTHS, ...span }))?.data
|
||||
?? { labels: [], applications: [], hires: [] },
|
||||
})
|
||||
const funnelQuery = useQuery({
|
||||
queryKey: qk.analytics.funnel(keyParams),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.funnel(span)
|
||||
return Array.isArray(res?.data) ? res.data : []
|
||||
},
|
||||
})
|
||||
|
||||
const costsQuery = useQuery({
|
||||
queryKey: qk.costs.list(keyParams),
|
||||
queryFn: async () => {
|
||||
const res = await costsApi.list({ ...span, top: 500 })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(costsApi.toCostView)
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const deptsQuery = useQuery({
|
||||
queryKey: qk.jobs.list({ scope: 'departments' }),
|
||||
queryFn: async () => {
|
||||
const res = await jobsApi.list({ top: 500, activeOnly: false })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return [...new Set(rows.map((r) => r.department).filter(Boolean))].sort()
|
||||
},
|
||||
})
|
||||
const departments = useMemo(() => (deptsQuery.data ?? []).slice(0, DEPT_CAP), [deptsQuery.data])
|
||||
|
||||
/* One KPI read per department: open_jobs, total_candidates, hires and
|
||||
time_to_fill all arrive together, which is the whole table in one payload. */
|
||||
const deptQueries = useQueries({
|
||||
queries: departments.map((dept) => ({
|
||||
queryKey: qk.analytics.kpis({ ...keyParams, department: dept }),
|
||||
queryFn: async () => {
|
||||
const data = (await analyticsApi.kpis({ ...span, department: dept }))?.data ?? {}
|
||||
return {
|
||||
id: d.dept, dept: d.dept, open: d.open, apps: d.apps,
|
||||
hires: int(1, 8), ttf: int(28, 52), rate: Math.min(rate, 98),
|
||||
id: dept,
|
||||
dept,
|
||||
open: data.open_jobs ?? 0,
|
||||
apps: data.total_candidates ?? 0,
|
||||
hires: data.hires ?? 0,
|
||||
ttf: data.time_to_fill != null ? Math.round(Number(data.time_to_fill)) : null,
|
||||
}
|
||||
}),
|
||||
[],
|
||||
},
|
||||
})),
|
||||
})
|
||||
const deptPending = deptQueries.some((qr) => qr.isPending)
|
||||
/* useQueries hands back a new array every render, so memoise on a value
|
||||
signature — otherwise the table re-sorts and the row identity churns on
|
||||
every unrelated re-render. */
|
||||
const deptSignature = deptQueries
|
||||
.map((qr) => (qr.data ? `${qr.data.dept}:${qr.data.open}:${qr.data.apps}:${qr.data.hires}:${qr.data.ttf}` : '-'))
|
||||
.join('|')
|
||||
const deptRows = useMemo(
|
||||
() => deptQueries.map((qr) => qr.data).filter(Boolean).filter((r) => r.open || r.apps || r.hires),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[deptSignature],
|
||||
)
|
||||
|
||||
const funnelData = useMemo(
|
||||
() => ({
|
||||
labels: FUNNEL.map((f) => f.stage),
|
||||
data: FUNNEL.map((f) => f.v),
|
||||
/* ---------- derived payloads ---------- */
|
||||
|
||||
/* Fold the 11 raw statuses onto the six funnel labels, then run a suffix sum
|
||||
so each label carries "reached at least here". */
|
||||
const funnel = useMemo(() => {
|
||||
const raw = new Map((funnelQuery.data ?? []).map((r) => [r.stage, r.count || 0]))
|
||||
const labels = []
|
||||
const perLabel = []
|
||||
for (const { key, label } of FUNNEL_ORDER) {
|
||||
const idx = labels.indexOf(label)
|
||||
if (idx === -1) {
|
||||
labels.push(label)
|
||||
perLabel.push(raw.get(key) ?? 0)
|
||||
} else {
|
||||
perLabel[idx] += raw.get(key) ?? 0
|
||||
}
|
||||
}
|
||||
const reached = perLabel.map((_, i) => perLabel.slice(i).reduce((s, n) => s + n, 0))
|
||||
const base = reached[0] || 0
|
||||
return {
|
||||
labels,
|
||||
counts: reached,
|
||||
data: reached.map((n) => (base ? Math.round((n / base) * 100) : 0)),
|
||||
colors: Charts.PALETTE,
|
||||
yFmt: (v) => `${v}%`,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const timeData = useMemo(
|
||||
() => ({
|
||||
labels: a.hiringTrend.labels,
|
||||
base,
|
||||
}
|
||||
}, [funnelQuery.data])
|
||||
|
||||
const cycle = useMemo(() => {
|
||||
const k = kpisQuery.data ?? {}
|
||||
const round = (v) => (v == null ? 0 : Math.round(Number(v)))
|
||||
return {
|
||||
labels: ['Time to Hire', 'Time to Fill'],
|
||||
datasets: [
|
||||
{ label: 'Time to Hire', data: a.timeToHire, color: Charts.PALETTE[0] },
|
||||
{ label: 'Time to Fill', data: a.timeToFill, color: Charts.PALETTE[2] },
|
||||
{ label: 'Current', data: [round(k.time_to_hire), round(k.time_to_fill)], color: Charts.PALETTE[0] },
|
||||
{ label: 'Prior', data: [round(k.time_to_hire_prior), round(k.time_to_fill_prior)], color: Charts.PALETTE[2] },
|
||||
],
|
||||
yFmt: (v) => `${v}d`,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const timeLegend = useMemo(
|
||||
}
|
||||
}, [kpisQuery.data])
|
||||
|
||||
const cycleLegend = useMemo(
|
||||
() => [
|
||||
{ label: 'Time to Hire', color: Charts.PALETTE[0] },
|
||||
{ label: 'Time to Fill', color: Charts.PALETTE[2] },
|
||||
{ label: 'Current window', color: Charts.PALETTE[0] },
|
||||
{ label: 'Prior window', color: Charts.PALETTE[2] },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const costTotals = useMemo(
|
||||
() => costsApi.totalsByType(costsQuery.data ?? []),
|
||||
[costsQuery.data],
|
||||
)
|
||||
const costSum = useMemo(
|
||||
() => costTotals.reduce((s, r) => s + r.amount, 0),
|
||||
[costTotals],
|
||||
)
|
||||
|
||||
const k = kpisQuery.data
|
||||
const totalApplications = useMemo(() => {
|
||||
const t = trendQuery.data
|
||||
if (!t?.applications?.length) return null
|
||||
return t.applications.reduce((s, v) => s + (v || 0), 0)
|
||||
}, [trendQuery.data])
|
||||
|
||||
const cards = [
|
||||
{ label: 'Total Hires (YTD)', value: a.hiringTrend.hires.reduce((s, v) => s + v, 0), icon: 'award', tone: 'i-green', foot: '+18% vs last year' },
|
||||
{ label: 'Total Applications', value: a.hiringTrend.applications.reduce((s, v) => s + v, 0).toLocaleString(), icon: 'users', tone: 'i-blue', foot: 'across all channels' },
|
||||
{ label: 'Avg. Time to Hire', value: '27 days', icon: 'clock', tone: 'i-teal', foot: '3 days faster' },
|
||||
{ label: 'Avg. Cost per Hire', value: '$4,280', icon: 'dollar', tone: 'i-amber', foot: 'within budget' },
|
||||
{
|
||||
label: 'Total Hires',
|
||||
value: kpisQuery.isPending ? '—' : (k?.hires ?? 0),
|
||||
icon: 'award',
|
||||
tone: 'i-green',
|
||||
foot: 'in the selected window',
|
||||
},
|
||||
{
|
||||
label: 'Total Applications',
|
||||
value: trendQuery.isPending ? '—' : (totalApplications?.toLocaleString() ?? '—'),
|
||||
icon: 'users',
|
||||
tone: 'i-blue',
|
||||
foot: `last ${TREND_MONTHS} months`,
|
||||
},
|
||||
{
|
||||
label: 'Avg. Time to Hire',
|
||||
value: kpisQuery.isPending ? '—' : (k?.time_to_hire != null ? `${Math.round(k.time_to_hire)} days` : '—'),
|
||||
icon: 'clock',
|
||||
tone: 'i-teal',
|
||||
foot: k?.time_to_hire == null ? 'no hires in window' : 'offer → start',
|
||||
},
|
||||
{
|
||||
label: 'Avg. Cost per Hire',
|
||||
value: kpisQuery.isPending ? '—' : (k?.cost_per_hire != null ? money(Math.round(k.cost_per_hire)) : '—'),
|
||||
icon: 'dollar',
|
||||
tone: 'i-amber',
|
||||
foot: k?.cost_per_hire == null ? 'no cost data recorded' : 'from the cost ledger',
|
||||
},
|
||||
]
|
||||
|
||||
const columns = [
|
||||
const deptColumns = [
|
||||
{ key: 'dept', label: 'Department', sortable: true, render: (r) => <span className="cell-primary">{r.dept}</span> },
|
||||
{ key: 'open', label: 'Open Roles', sortable: true, align: 'center' },
|
||||
{ key: 'apps', label: 'Applications', sortable: true, align: 'center', render: (r) => <b>{r.apps}</b> },
|
||||
{ key: 'hires', label: 'Hires', sortable: true, align: 'center' },
|
||||
{ key: 'ttf', label: 'Time to Fill', sortable: true, align: 'center', render: (r) => `${r.ttf} days` },
|
||||
{
|
||||
key: 'rate',
|
||||
label: 'Fill Rate',
|
||||
sortable: true,
|
||||
render: (r) => (
|
||||
<div className="flex items-center gap-8">
|
||||
<div style={{ flex: 1 }}><ProgressBar pct={r.rate} /></div>
|
||||
<b style={{ width: 38, textAlign: 'right' }}>{r.rate}%</b>
|
||||
</div>
|
||||
),
|
||||
key: 'ttf', label: 'Time to Fill', sortable: true, align: 'center',
|
||||
sortValue: (r) => r.ttf ?? Number.MAX_SAFE_INTEGER,
|
||||
render: (r) => (r.ttf != null ? `${r.ttf} days` : <span className="text-muted">—</span>),
|
||||
},
|
||||
{
|
||||
key: '_conv', label: 'Applicant → hire', sortable: true,
|
||||
sortValue: (r) => (r.apps ? r.hires / r.apps : 0),
|
||||
render: (r) => {
|
||||
const rate = r.apps ? Math.round((r.hires / r.apps) * 100) : 0
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
<div style={{ flex: 1 }}><ProgressBar pct={rate} /></div>
|
||||
<b style={{ width: 38, textAlign: 'right' }}>{rate}%</b>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const costColumns = [
|
||||
{ key: 'type', label: 'Cost Type', sortable: true, render: (r) => <span className="cell-primary">{r.type}</span> },
|
||||
{
|
||||
key: 'amount', label: 'Total', sortable: true, align: 'right',
|
||||
render: (r) => <b>{money(Math.round(r.amount))}</b>,
|
||||
},
|
||||
{
|
||||
key: '_share', label: 'Share', sortable: true,
|
||||
sortValue: (r) => r.amount,
|
||||
render: (r) => {
|
||||
const share = costSum ? Math.round((r.amount / costSum) * 100) : 0
|
||||
return (
|
||||
<div className="flex items-center gap-8">
|
||||
<div style={{ flex: 1 }}><ProgressBar pct={share} /></div>
|
||||
<b style={{ width: 38, textAlign: 'right' }}>{share}%</b>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -97,20 +286,24 @@ export default function Reports() {
|
|||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Reports</h1>
|
||||
<p className="page-sub">Recruitment metrics and downloadable insights</p>
|
||||
<p className="page-sub">Recruitment metrics across the selected window</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<select className="select" defaultValue="Last 7 months">
|
||||
<option>Last 7 months</option>
|
||||
<option>This quarter</option>
|
||||
<option>This year</option>
|
||||
<select className="select" value={rangeKey} onChange={(e) => setRangeKey(e.target.value)}>
|
||||
{RANGES.map((r) => <option key={r.key} value={r.key}>{r.label}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary" onClick={() => toast('Full report exported to PDF', 'success')}>
|
||||
<Icon name="download" /> Export Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kpisQuery.isError && (
|
||||
<div className="card mb-18"><div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load reporting metrics">
|
||||
{friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
|
||||
{' '}This screen needs the <code>analytics.view</code> permission.
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)}
|
||||
|
||||
<div className="grid g-kpi mb-18">
|
||||
{cards.map((c) => <KpiCard key={c.label} {...c} />)}
|
||||
</div>
|
||||
|
|
@ -118,55 +311,112 @@ export default function Reports() {
|
|||
<div className="grid g-2 mb-18">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>Hiring Funnel</h3><span className="ch-sub">Stage-by-stage conversion</span></div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => toast('Chart exported', 'info')}>
|
||||
<Icon name="download" />
|
||||
</button>
|
||||
<div>
|
||||
<h3>Hiring Funnel</h3>
|
||||
<span className="ch-sub">
|
||||
Share reaching each stage{funnel.base ? ` · base ${funnel.base}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body"><div className="chart-wrap"><Chart type="bar" data={funnelData} height={280} /></div></div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Time to Hire vs Fill</h3><span className="ch-sub">Monthly trend (days)</span></div></div>
|
||||
<div className="card-body">
|
||||
<div className="chart-wrap"><Chart type="groupedBar" data={timeData} height={280} /></div>
|
||||
<ChartLegend items={timeLegend} />
|
||||
{funnelQuery.isPending ? (
|
||||
<EmptyState icon="clock" title="Loading…">Fetching stage counts.</EmptyState>
|
||||
) : funnelQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load the funnel">
|
||||
{friendlyAuthError(funnelQuery.error, 'The server did not answer.')}
|
||||
</EmptyState>
|
||||
) : !funnel.base ? (
|
||||
<EmptyState icon="inbox" title="No applications in this window">
|
||||
The funnel fills in once applications arrive.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="chart-wrap"><Chart type="bar" data={funnel} height={280} /></div>
|
||||
<p className="text-muted" style={{ marginTop: 10, fontSize: 12 }}>
|
||||
<Icon name="info" /> Derived from current stage counts, so rejected applications are
|
||||
not counted at the stage they reached.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>Cycle Time</h3><span className="ch-sub">Days, current window vs prior</span></div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{kpisQuery.isPending ? (
|
||||
<EmptyState icon="clock" title="Loading…">Fetching cycle times.</EmptyState>
|
||||
) : k?.time_to_hire == null && k?.time_to_fill == null ? (
|
||||
<EmptyState icon="clock" title="No completed cycles">
|
||||
Needs at least one hire and one closed requisition in the window.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="chart-wrap"><Chart type="groupedBar" data={cycle} height={280} /></div>
|
||||
<ChartLegend items={cycleLegend} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-18">
|
||||
<div className="card-head">
|
||||
<div><h3>Department Performance</h3><span className="ch-sub">Hiring breakdown by team</span></div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => toast('Table exported to CSV', 'success')}>
|
||||
<Icon name="download" /> CSV
|
||||
</button>
|
||||
<div>
|
||||
<h3>Department Performance</h3>
|
||||
<span className="ch-sub">
|
||||
{deptsQuery.data && deptsQuery.data.length > DEPT_CAP
|
||||
? `Top ${DEPT_CAP} of ${deptsQuery.data.length} departments`
|
||||
: 'Hiring breakdown by team'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable columns={columns} rows={deptRows} pageSize={10} />
|
||||
{deptPending ? (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="clock" title="Loading…">One read per department.</EmptyState>
|
||||
</div>
|
||||
) : deptRows.length === 0 ? (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="inbox" title="No department activity">
|
||||
Set a department on a requisition for it to appear here.
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable columns={deptColumns} rows={deptRows} pageSize={10} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head"><div><h3>Report Library</h3><span className="ch-sub">Generate a detailed report</span></div></div>
|
||||
<div className="card-body">
|
||||
<div className="grid g-3">
|
||||
{REPORT_TYPES.map((r) => (
|
||||
<div
|
||||
key={r.name}
|
||||
className="card"
|
||||
style={{ boxShadow: 'none', background: 'var(--bg-sunken)', cursor: 'pointer' }}
|
||||
onClick={() => toast(`Generating: ${r.name}`, 'info')}
|
||||
>
|
||||
<div className="card-body">
|
||||
<span className={`kpi-icn ${r.cls}`} style={{ marginBottom: 12 }}><Icon name={r.icn} /></span>
|
||||
<div className="lr-title">{r.name}</div>
|
||||
<div className="lr-sub" style={{ marginTop: 4 }}>{r.desc}</div>
|
||||
<div style={{ marginTop: 12, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }}>
|
||||
Generate <Icon name="chevron-right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Hiring Spend</h3>
|
||||
<span className="ch-sub">
|
||||
{costSum ? `${money(Math.round(costSum))} recorded in this window` : 'From the hiring-cost ledger'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{costsQuery.isPending ? (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="clock" title="Loading…">Fetching the cost ledger.</EmptyState>
|
||||
</div>
|
||||
) : costsQuery.isError ? (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="lock" title="Costs not visible">
|
||||
{friendlyAuthError(costsQuery.error, 'The cost ledger did not answer.')}
|
||||
{' '}This card needs the <code>jobs.view</code> permission.
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : costTotals.length === 0 ? (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="dollar" title="No costs recorded">
|
||||
Cost-per-hire stays blank until spend is logged against a requisition.
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable columns={costColumns} rows={costTotals.map((r) => ({ id: r.type, ...r }))} pageSize={10} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ function useAtsResult(userId) {
|
|||
* Engineer" rather than a uuid. Same query key and row shape as the Candidates
|
||||
* screen's own jobs query, so this is a cache hit rather than a second request.
|
||||
*/
|
||||
function useJobTitles() {
|
||||
export function useJobTitles() {
|
||||
return useQuery({
|
||||
queryKey: qk.jobPosts.list(),
|
||||
queryFn: async () => {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,16 @@
|
|||
/* ============================================================
|
||||
Settings — 10 tabs. Two are real, eight are inert chrome exactly as in the
|
||||
prototype.
|
||||
|
||||
The Users tab is wired to GET /users/fetch and its row pencil assigns roles
|
||||
through PUT /users/assign-role / PUT /users/remove-role; Appearance drives the
|
||||
real ThemeProvider. Everything else (General, Roles, Permissions,
|
||||
Notifications, Email Templates, Career Portal, Branding, Security) is markup
|
||||
with no persistence — same as the prototype.
|
||||
|
||||
The Security tab in particular renders 2FA and audit logging as ENABLED while
|
||||
enforcing nothing; 01-repository-assessment.md §2.4 calls that out as
|
||||
"actively dangerous as a demo artefact". A standing notice is rendered above
|
||||
it rather than silently reproducing the claim.
|
||||
Settings — org settings tabs persist via GET/PUT /org-settings/*.
|
||||
Users + Appearance stay as before. Email Templates stay decorative
|
||||
(explicitly out of Section C wiring scope). Roles/Permissions remain
|
||||
chrome; Access Control is the authoritative RBAC surface.
|
||||
============================================================ */
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import Modal from '../ui/Modal'
|
||||
import { Tabs } from '../ui/Tabs'
|
||||
import { Avatar, Badge, FieldError, Icon } from '../ui/primitives'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { useTheme } from '../theme/ThemeProvider'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
|
|
@ -28,6 +19,7 @@ import { qk } from '../lib/queryKeys'
|
|||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as rolesApi from '../api/roles'
|
||||
import * as usersApi from '../api/users'
|
||||
import * as orgSettingsApi from '../api/orgSettings'
|
||||
import { roles as seedRoles } from '../data/seed'
|
||||
|
||||
const TABS = [
|
||||
|
|
@ -35,16 +27,26 @@ const TABS = [
|
|||
'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance',
|
||||
]
|
||||
|
||||
function ToggleRow({ title, desc, defaultChecked }) {
|
||||
const { toast } = useToast()
|
||||
const ORG_TABS = new Set(['General', 'Notifications', 'Career Portal', 'Branding', 'Security'])
|
||||
|
||||
const CATEGORY_BY_TAB = {
|
||||
General: 'general',
|
||||
Notifications: 'notifications',
|
||||
'Career Portal': 'career_portal',
|
||||
Branding: 'branding',
|
||||
Security: 'security',
|
||||
}
|
||||
|
||||
function ToggleRow({ title, desc, checked, onChange, disabled }) {
|
||||
return (
|
||||
<div className="setting-row">
|
||||
<div className="setting-info"><h4>{title}</h4><p>{desc}</p></div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={defaultChecked}
|
||||
onChange={() => toast('Preference updated', 'success')}
|
||||
checked={Boolean(checked)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange?.(e.target.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
|
|
@ -52,9 +54,56 @@ function ToggleRow({ title, desc, defaultChecked }) {
|
|||
)
|
||||
}
|
||||
|
||||
function useOrgDraft(category, defaults) {
|
||||
const query = useQuery({
|
||||
queryKey: qk.orgSettings.list({ category }),
|
||||
queryFn: async () => orgSettingsApi.toMap(await orgSettingsApi.list({ category })),
|
||||
})
|
||||
const [draft, setDraft] = useState(defaults)
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data) return
|
||||
setDraft((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const key of Object.keys(defaults)) {
|
||||
if (query.data[key] !== undefined) next[key] = query.data[key]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [query.data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function setField(key, value) {
|
||||
setDraft((d) => ({ ...d, [key]: value }))
|
||||
}
|
||||
|
||||
function toPayload() {
|
||||
return Object.entries(draft).map(([key, value]) => ({ key, value, category }))
|
||||
}
|
||||
|
||||
return { query, draft, setField, toPayload }
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { toast } = useToast()
|
||||
const { can } = usePermission()
|
||||
const qc = useQueryClient()
|
||||
const [tab, setTab] = useState('General')
|
||||
const saveRef = useRef(null)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!saveRef.current) return null
|
||||
return saveRef.current()
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.orgSettings.all() })
|
||||
toast('Settings saved', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save settings.'), 'error'),
|
||||
})
|
||||
|
||||
const canConfigure = can('settings.configure')
|
||||
const showOrgSave = ORG_TABS.has(tab)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
|
@ -64,48 +113,93 @@ export default function Settings() {
|
|||
<p className="page-sub">Configure your workspace and team preferences</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button className="btn btn-primary" onClick={() => toast('Settings saved', 'success')}>
|
||||
<Icon name="check" /> Save Changes
|
||||
</button>
|
||||
{showOrgSave && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!canConfigure || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
<Icon name="check" /> {save.isPending ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
|
||||
|
||||
<div className="tab-pane active">
|
||||
{tab === 'General' && <General />}
|
||||
{tab === 'General' && <General registerSave={(fn) => { saveRef.current = fn }} />}
|
||||
{tab === 'Users' && <Users />}
|
||||
{tab === 'Roles' && <Roles />}
|
||||
{tab === 'Permissions' && <Permissions />}
|
||||
{tab === 'Notifications' && <Notifications />}
|
||||
{tab === 'Notifications' && <Notifications registerSave={(fn) => { saveRef.current = fn }} />}
|
||||
{tab === 'Email Templates' && <EmailTemplates />}
|
||||
{tab === 'Career Portal' && <CareerPortal />}
|
||||
{tab === 'Branding' && <Branding />}
|
||||
{tab === 'Security' && <Security />}
|
||||
{tab === 'Career Portal' && <CareerPortal registerSave={(fn) => { saveRef.current = fn }} />}
|
||||
{tab === 'Branding' && <Branding registerSave={(fn) => { saveRef.current = fn }} />}
|
||||
{tab === 'Security' && <Security registerSave={(fn) => { saveRef.current = fn }} />}
|
||||
{tab === 'Appearance' && <Appearance />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function General() {
|
||||
function General({ registerSave }) {
|
||||
const defaults = {
|
||||
'general.company_name': 'Utopia Brands Inc.',
|
||||
'general.website': 'https://utopiabrands.com',
|
||||
'general.industry': 'Consumer Goods',
|
||||
'general.company_size': '201–500',
|
||||
'general.timezone': '(GMT-08:00) Pacific Time',
|
||||
'general.currency': 'USD ($)',
|
||||
'general.auto_archive_stale_jobs': true,
|
||||
'general.duplicate_detection': true,
|
||||
}
|
||||
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.General, defaults)
|
||||
|
||||
useEffect(() => {
|
||||
registerSave?.(() => orgSettingsApi.update(toPayload()))
|
||||
})
|
||||
|
||||
if (query.isPending) {
|
||||
return <div className="card"><div className="card-body"><EmptyState icon="settings" title="Loading…">Fetching organisation settings.</EmptyState></div></div>
|
||||
}
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="settings" title="Couldn’t load settings">
|
||||
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="form-grid">
|
||||
<div className="form-field"><label>Organization Name</label><input defaultValue="Utopia Brands Inc." /></div>
|
||||
<div className="form-field"><label>Company Website</label><input defaultValue="https://utopiabrands.com" /></div>
|
||||
<div className="form-field">
|
||||
<label>Organization Name</label>
|
||||
<input value={draft['general.company_name'] ?? ''} onChange={(e) => setField('general.company_name', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Company Website</label>
|
||||
<input value={draft['general.website'] ?? ''} onChange={(e) => setField('general.website', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Industry</label>
|
||||
<select><option>Consumer Goods</option><option>Technology</option><option>Retail</option></select>
|
||||
<select value={draft['general.industry'] ?? ''} onChange={(e) => setField('general.industry', e.target.value)}>
|
||||
<option>Consumer Goods</option><option>Technology</option><option>Retail</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Company Size</label>
|
||||
<select><option>201–500</option><option>51–200</option><option>500+</option></select>
|
||||
<select value={draft['general.company_size'] ?? ''} onChange={(e) => setField('general.company_size', e.target.value)}>
|
||||
<option>201–500</option><option>51–200</option><option>500+</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Default Time Zone</label>
|
||||
<select>
|
||||
<select value={draft['general.timezone'] ?? ''} onChange={(e) => setField('general.timezone', e.target.value)}>
|
||||
<option>(GMT-08:00) Pacific Time</option>
|
||||
<option>(GMT-05:00) Eastern Time</option>
|
||||
<option>(GMT+00:00) UTC</option>
|
||||
|
|
@ -113,12 +207,24 @@ function General() {
|
|||
</div>
|
||||
<div className="form-field">
|
||||
<label>Default Currency</label>
|
||||
<select><option>USD ($)</option><option>EUR (€)</option><option>GBP (£)</option></select>
|
||||
<select value={draft['general.currency'] ?? ''} onChange={(e) => setField('general.currency', e.target.value)}>
|
||||
<option>USD ($)</option><option>EUR (€)</option><option>GBP (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<ToggleRow title="Auto-archive stale jobs" desc="Automatically close requisitions inactive for 90 days" defaultChecked />
|
||||
<ToggleRow title="Duplicate detection" desc="Flag candidates that already exist in the system" defaultChecked />
|
||||
<ToggleRow
|
||||
title="Auto-archive stale jobs"
|
||||
desc="Automatically close requisitions inactive for 90 days"
|
||||
checked={draft['general.auto_archive_stale_jobs']}
|
||||
onChange={(v) => setField('general.auto_archive_stale_jobs', v)}
|
||||
/>
|
||||
<ToggleRow
|
||||
title="Duplicate detection"
|
||||
desc="Flag candidates that already exist in the system"
|
||||
checked={draft['general.duplicate_detection']}
|
||||
onChange={(v) => setField('general.duplicate_detection', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -209,19 +315,6 @@ function Users() {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Users-tab row pencil. Resolves the name from user_id and the current role
|
||||
* from role_id, and persists a change through PUT /users/assign-role — or
|
||||
* /users/remove-role when the role is cleared.
|
||||
*
|
||||
* SINGLE select, deliberately: `users.role_id` is one nullable FK and
|
||||
* Users.update_user does `setattr(user, 'role_id', v)`, so N roles written in a
|
||||
* loop would leave only the last one. Multi-role needs a user_roles join table.
|
||||
*
|
||||
* Saving needs rbac_users.manage on top of the route's rbac_users.edit, and the
|
||||
* server refuses to hand out permissions the caller does not already hold. Both
|
||||
* come back as 403 detail strings, which friendlyAuthError surfaces verbatim.
|
||||
*/
|
||||
function AssignRoleModal({ user, users, onClose }) {
|
||||
const { toast } = useToast()
|
||||
const { can } = usePermission()
|
||||
|
|
@ -232,14 +325,11 @@ function AssignRoleModal({ user, users, onClose }) {
|
|||
role_id: user.role_id == null ? '' : String(user.role_id),
|
||||
})
|
||||
|
||||
// Same key as Access Control, so this is served from cache after visiting it.
|
||||
const rolesQuery = useQuery({
|
||||
queryKey: qk.roles.list(),
|
||||
queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
// Inactive roles are an option the server can only 400 on — see the is_active
|
||||
// check in users/views.py _check_role_assignment.
|
||||
const roles = (rolesQuery.data ?? []).filter((r) => r.is_active)
|
||||
|
||||
const target = users.find((u) => String(u.id) === form.values.user_id) ?? user
|
||||
|
|
@ -247,8 +337,6 @@ function AssignRoleModal({ user, users, onClose }) {
|
|||
const clearing = form.values.role_id === ''
|
||||
const dirty = form.values.role_id !== currentRoleId
|
||||
|
||||
// Re-point the role select at THAT user's role, so the two fields can never
|
||||
// end up describing different people.
|
||||
function pickUser(id) {
|
||||
const next = users.find((u) => String(u.id) === id)
|
||||
form.setValues({
|
||||
|
|
@ -364,8 +452,8 @@ function Roles() {
|
|||
return (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>Roles</h3><span className="ch-sub">Define access levels</span></div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => toast('New role dialog', 'info')}>
|
||||
<div><h3>Roles</h3><span className="ch-sub">Define access levels — use Access Control for live RBAC</span></div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => toast('Use Access Control to manage roles', 'info')}>
|
||||
<Icon name="plus" /> Add Role
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -418,7 +506,7 @@ function Permissions() {
|
|||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={m !== 'Settings'}
|
||||
onChange={() => toast('Permission updated', 'success')}
|
||||
onChange={() => toast('Use Access Control to change permissions', 'info')}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
|
|
@ -433,143 +521,250 @@ function Permissions() {
|
|||
)
|
||||
}
|
||||
|
||||
function Notifications() {
|
||||
function Notifications({ registerSave }) {
|
||||
const defaults = {
|
||||
'notifications.email_new_applications': true,
|
||||
'notifications.email_interview_reminders': true,
|
||||
'notifications.email_offer_responses': true,
|
||||
'notifications.email_weekly_digest': false,
|
||||
'notifications.inapp_mentions': true,
|
||||
'notifications.inapp_stage_changes': false,
|
||||
'notifications.inapp_task_assignments': true,
|
||||
}
|
||||
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Notifications, defaults)
|
||||
|
||||
useEffect(() => {
|
||||
registerSave?.(() => orgSettingsApi.update(toPayload()))
|
||||
})
|
||||
|
||||
if (query.isPending) {
|
||||
return <div className="card"><div className="card-body"><EmptyState icon="bell" title="Loading…">Fetching notification preferences.</EmptyState></div></div>
|
||||
}
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="bell" title="Couldn’t load preferences">
|
||||
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="form-section-title" style={{ marginTop: 0 }}>Email Notifications</div>
|
||||
<ToggleRow title="New applications" desc="Get notified when a candidate applies" defaultChecked />
|
||||
<ToggleRow title="Interview reminders" desc="Reminders 30 minutes before interviews" defaultChecked />
|
||||
<ToggleRow title="Offer responses" desc="When candidates accept or decline offers" defaultChecked />
|
||||
<ToggleRow title="Weekly digest" desc="A summary of hiring activity every Monday" />
|
||||
<ToggleRow title="New applications" desc="Get notified when a candidate applies" checked={draft['notifications.email_new_applications']} onChange={(v) => setField('notifications.email_new_applications', v)} />
|
||||
<ToggleRow title="Interview reminders" desc="Reminders 30 minutes before interviews" checked={draft['notifications.email_interview_reminders']} onChange={(v) => setField('notifications.email_interview_reminders', v)} />
|
||||
<ToggleRow title="Offer responses" desc="When candidates accept or decline offers" checked={draft['notifications.email_offer_responses']} onChange={(v) => setField('notifications.email_offer_responses', v)} />
|
||||
<ToggleRow title="Weekly digest" desc="A summary of hiring activity every Monday" checked={draft['notifications.email_weekly_digest']} onChange={(v) => setField('notifications.email_weekly_digest', v)} />
|
||||
<div className="form-section-title">In-App Notifications</div>
|
||||
<ToggleRow title="Mentions" desc="When a teammate @mentions you" defaultChecked />
|
||||
<ToggleRow title="Stage changes" desc="When a candidate moves stages" />
|
||||
<ToggleRow title="Task assignments" desc="When you are assigned a task" defaultChecked />
|
||||
<ToggleRow title="Mentions" desc="When a teammate @mentions you" checked={draft['notifications.inapp_mentions']} onChange={(v) => setField('notifications.inapp_mentions', v)} />
|
||||
<ToggleRow title="Stage changes" desc="When a candidate moves stages" checked={draft['notifications.inapp_stage_changes']} onChange={(v) => setField('notifications.inapp_stage_changes', v)} />
|
||||
<ToggleRow title="Task assignments" desc="When you are assigned a task" checked={draft['notifications.inapp_task_assignments']} onChange={(v) => setField('notifications.inapp_task_assignments', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailTemplates() {
|
||||
const { toast } = useToast()
|
||||
const templates = [
|
||||
'Application Received', 'Interview Invitation', 'Assessment Assignment',
|
||||
'Offer Letter', 'Rejection — Post Interview', 'Reference Request',
|
||||
]
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>Email Templates</h3></div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => toast('New template', 'info')}>
|
||||
<Icon name="plus" /> New Template
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
{templates.map((t) => (
|
||||
<div className="list-row" key={t}>
|
||||
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
<Icon name="mail" />
|
||||
</span>
|
||||
<div className="lr-main"><div className="lr-title">{t}</div><div className="lr-sub">Last edited 3 days ago</div></div>
|
||||
<Badge className="b-green">Active</Badge>
|
||||
<button className="act-btn" onClick={() => toast('Editing template', 'info')}><Icon name="edit" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<EmptyState icon="mail" title="Email templates not wired">
|
||||
Template CRUD exists on the backend but is out of scope for this wiring pass.
|
||||
Use Access Control / org settings for other configuration.
|
||||
</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CareerPortal() {
|
||||
function CareerPortal({ registerSave }) {
|
||||
const defaults = {
|
||||
'career_portal.url': 'https://careers.utopiabrands.com',
|
||||
'career_portal.headline': 'Build the future with us',
|
||||
'career_portal.cta': 'View Open Roles',
|
||||
'career_portal.public_job_board': true,
|
||||
'career_portal.one_click_apply': true,
|
||||
'career_portal.show_salary': false,
|
||||
'career_portal.enable_referrals': true,
|
||||
}
|
||||
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB['Career Portal'], defaults)
|
||||
|
||||
useEffect(() => {
|
||||
registerSave?.(() => orgSettingsApi.update(toPayload()))
|
||||
})
|
||||
|
||||
if (query.isPending) {
|
||||
return <div className="card"><div className="card-body"><EmptyState icon="settings" title="Loading…">Fetching career portal settings.</EmptyState></div></div>
|
||||
}
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="settings" title="Couldn’t load settings">
|
||||
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="form-grid">
|
||||
<div className="form-field col-span-2"><label>Careers Page URL</label><input defaultValue="https://careers.utopiabrands.com" /></div>
|
||||
<div className="form-field"><label>Page Headline</label><input defaultValue="Build the future with us" /></div>
|
||||
<div className="form-field"><label>Primary CTA Text</label><input defaultValue="View Open Roles" /></div>
|
||||
<div className="form-field col-span-2">
|
||||
<label>Careers Page URL</label>
|
||||
<input value={draft['career_portal.url'] ?? ''} onChange={(e) => setField('career_portal.url', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Page Headline</label>
|
||||
<input value={draft['career_portal.headline'] ?? ''} onChange={(e) => setField('career_portal.headline', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Primary CTA Text</label>
|
||||
<input value={draft['career_portal.cta'] ?? ''} onChange={(e) => setField('career_portal.cta', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<ToggleRow title="Public job board" desc="Make open roles visible to the public" defaultChecked />
|
||||
<ToggleRow title="Allow one-click apply" desc="Let candidates apply with LinkedIn" defaultChecked />
|
||||
<ToggleRow title="Show salary ranges" desc="Display compensation on job listings" />
|
||||
<ToggleRow title="Enable referrals" desc="Employees can refer candidates" defaultChecked />
|
||||
<ToggleRow title="Public job board" desc="Make open roles visible to the public" checked={draft['career_portal.public_job_board']} onChange={(v) => setField('career_portal.public_job_board', v)} />
|
||||
<ToggleRow title="Allow one-click apply" desc="Let candidates apply with LinkedIn" checked={draft['career_portal.one_click_apply']} onChange={(v) => setField('career_portal.one_click_apply', v)} />
|
||||
<ToggleRow title="Show salary ranges" desc="Display compensation on job listings" checked={draft['career_portal.show_salary']} onChange={(v) => setField('career_portal.show_salary', v)} />
|
||||
<ToggleRow title="Enable referrals" desc="Employees can refer candidates" checked={draft['career_portal.enable_referrals']} onChange={(v) => setField('career_portal.enable_referrals', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Branding() {
|
||||
const { toast } = useToast()
|
||||
function Branding({ registerSave }) {
|
||||
const colors = ['#004d43', '#ceff71', '#25e9a5', '#8e92ff', '#1a3134', '#eafff4']
|
||||
const defaults = {
|
||||
'branding.primary_color': '#004d43',
|
||||
'branding.email_footer': 'Utopia Brands · San Francisco, CA',
|
||||
'branding.support_email': 'talent@utopiabrands.com',
|
||||
}
|
||||
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Branding, defaults)
|
||||
|
||||
useEffect(() => {
|
||||
registerSave?.(() => orgSettingsApi.update(toPayload()))
|
||||
})
|
||||
|
||||
if (query.isPending) {
|
||||
return <div className="card"><div className="card-body"><EmptyState icon="settings" title="Loading…">Fetching branding settings.</EmptyState></div></div>
|
||||
}
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="settings" title="Couldn’t load settings">
|
||||
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="setting-row">
|
||||
<div className="setting-info"><h4>Company Logo</h4><p>Displayed on career pages and emails</p></div>
|
||||
<div className="flex items-center gap-12">
|
||||
<span className="brand-logo" style={{ width: 48, height: 48 }}>UB</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => toast('Upload dialog', 'info')}>Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info"><h4>Brand Color</h4><p>Primary accent across the portal</p></div>
|
||||
<div className="setting-info"><h4>Brand Color</h4><p>Primary accent across the portal (persisted; not yet applied globally)</p></div>
|
||||
<div className="flex items-center gap-8">
|
||||
{colors.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
style={{ width: 28, height: 28, borderRadius: 8, background: c, cursor: 'pointer', border: '2px solid var(--border)' }}
|
||||
onClick={() => toast('Brand color updated', 'success')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: 8, background: c, cursor: 'pointer',
|
||||
border: draft['branding.primary_color'] === c ? '2px solid var(--primary)' : '2px solid var(--border)',
|
||||
}}
|
||||
onClick={() => setField('branding.primary_color', c)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setField('branding.primary_color', c) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid" style={{ marginTop: 16 }}>
|
||||
<div className="form-field"><label>Email Footer</label><input defaultValue="Utopia Brands · San Francisco, CA" /></div>
|
||||
<div className="form-field"><label>Support Email</label><input defaultValue="talent@utopiabrands.com" /></div>
|
||||
<div className="form-field">
|
||||
<label>Email Footer</label>
|
||||
<input value={draft['branding.email_footer'] ?? ''} onChange={(e) => setField('branding.email_footer', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Support Email</label>
|
||||
<input value={draft['branding.support_email'] ?? ''} onChange={(e) => setField('branding.support_email', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Security() {
|
||||
function Security({ registerSave }) {
|
||||
const defaults = {
|
||||
'security.two_factor_enabled': true,
|
||||
'security.sso_enabled': false,
|
||||
'security.ip_allowlist': false,
|
||||
'security.audit_logging': true,
|
||||
'security.session_timeout': '30 minutes',
|
||||
'security.password_policy': 'Strong (12+ chars)',
|
||||
'security.data_retention_months': '24 months',
|
||||
}
|
||||
const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.Security, defaults)
|
||||
|
||||
useEffect(() => {
|
||||
registerSave?.(() => orgSettingsApi.update(toPayload()))
|
||||
})
|
||||
|
||||
if (query.isPending) {
|
||||
return <div className="card"><div className="card-body"><EmptyState icon="shield" title="Loading…">Fetching security settings.</EmptyState></div></div>
|
||||
}
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div className="card"><div className="card-body">
|
||||
<EmptyState icon="shield" title="Couldn’t load settings">
|
||||
{friendlyAuthError(query.error, 'This tab needs settings.view.')}
|
||||
</EmptyState>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
|
||||
<b>Not yet enforced.</b> These controls are interface only — none of them is wired to the
|
||||
backend, which today has no 2FA, no SSO, no IP allowlist and no audit log. Do not read the
|
||||
toggles below as a statement of what is switched on.
|
||||
<b>Not yet enforced.</b> These controls persist flags only — none of them is wired to
|
||||
enforcement. The backend today has no 2FA, no SSO, no IP allowlist and no audit log.
|
||||
Do not read the toggles below as a statement of what is switched on.
|
||||
</div>
|
||||
<ToggleRow title="Two-factor authentication" desc="Require 2FA for all team members" defaultChecked />
|
||||
<ToggleRow title="Single Sign-On (SSO)" desc="Enable SAML-based SSO login" />
|
||||
<ToggleRow title="IP allowlist" desc="Restrict access to approved IP ranges" />
|
||||
<ToggleRow title="Audit logging" desc="Track all data access and changes" defaultChecked />
|
||||
<ToggleRow title="Two-factor authentication" desc="Require 2FA for all team members" checked={draft['security.two_factor_enabled']} onChange={(v) => setField('security.two_factor_enabled', v)} />
|
||||
<ToggleRow title="Single Sign-On (SSO)" desc="Enable SAML-based SSO login" checked={draft['security.sso_enabled']} onChange={(v) => setField('security.sso_enabled', v)} />
|
||||
<ToggleRow title="IP allowlist" desc="Restrict access to approved IP ranges" checked={draft['security.ip_allowlist']} onChange={(v) => setField('security.ip_allowlist', v)} />
|
||||
<ToggleRow title="Audit logging" desc="Track all data access and changes" checked={draft['security.audit_logging']} onChange={(v) => setField('security.audit_logging', v)} />
|
||||
<div className="form-grid" style={{ marginTop: 16 }}>
|
||||
<div className="form-field">
|
||||
<label>Session Timeout</label>
|
||||
<select><option>30 minutes</option><option>1 hour</option><option>8 hours</option></select>
|
||||
<select value={draft['security.session_timeout'] ?? ''} onChange={(e) => setField('security.session_timeout', e.target.value)}>
|
||||
<option>30 minutes</option><option>1 hour</option><option>8 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Password Policy</label>
|
||||
<select><option>Strong (12+ chars)</option><option>Medium (8+ chars)</option></select>
|
||||
<select value={draft['security.password_policy'] ?? ''} onChange={(e) => setField('security.password_policy', e.target.value)}>
|
||||
<option>Strong (12+ chars)</option><option>Medium (8+ chars)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<div className="setting-row">
|
||||
<div className="setting-info"><h4>Data Retention</h4><p>Auto-delete candidate data after set period</p></div>
|
||||
<select className="select"><option>24 months</option><option>12 months</option><option>36 months</option></select>
|
||||
<select className="select" value={draft['security.data_retention_months'] ?? ''} onChange={(e) => setField('security.data_retention_months', e.target.value)}>
|
||||
<option>24 months</option><option>12 months</option><option>36 months</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The one tab in the prototype that actually did something. */
|
||||
function Appearance() {
|
||||
const { toast } = useToast()
|
||||
const { setTheme, useSystemTheme } = useTheme()
|
||||
|
|
@ -618,9 +813,6 @@ function Appearance() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<ToggleRow title="Compact mode" desc="Reduce spacing for denser layouts" />
|
||||
<ToggleRow title="Show animations" desc="Enable transitions and motion" defaultChecked />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
enforced server-side against the roles table, mirrored here so the button
|
||||
doesn't invite a 403. Fields the backend does not carry (task type, notes,
|
||||
candidate link) are gone rather than rendered as placeholders — the Inbox
|
||||
screen precedent. Saved Searches stays decorative seed chrome.
|
||||
screen precedent. Saved searches come from GET /saved-searches/fetch.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
|
@ -24,7 +24,8 @@ import { useFormState } from '../components/AuthLayout'
|
|||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import { fmtDate, fmtShort, savedSearches } from '../data/seed'
|
||||
import * as savedSearchesApi from '../api/savedSearches'
|
||||
import { fmtDate, fmtShort } from '../data/seed'
|
||||
|
||||
const FILTERS = ['All', 'Open', 'Completed', 'Overdue', 'High', 'Medium', 'Low']
|
||||
const PRIORITY_CLASS = { High: 'b-red', Medium: 'b-amber', Low: 'b-gray' }
|
||||
|
|
@ -43,6 +44,12 @@ async function fetchAssignees() {
|
|||
return Array.isArray(res?.data) ? res.data : []
|
||||
}
|
||||
|
||||
async function fetchSavedSearches() {
|
||||
const res = await savedSearchesApi.list({ entity: 'candidates' })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(savedSearchesApi.toSavedSearchView)
|
||||
}
|
||||
|
||||
export default function Tasks() {
|
||||
const { toast } = useToast()
|
||||
const { can, user } = useAuth()
|
||||
|
|
@ -54,11 +61,14 @@ export default function Tasks() {
|
|||
|
||||
const tasksQuery = useQuery({ queryKey: qk.tasks.list(), queryFn: fetchTasks })
|
||||
const assigneesQuery = useQuery({ queryKey: qk.tasks.assignees(), queryFn: fetchAssignees })
|
||||
const savedQuery = useQuery({ queryKey: qk.savedSearches.list({ entity: 'candidates' }), queryFn: fetchSavedSearches })
|
||||
const tasks = tasksQuery.data ?? []
|
||||
const savedSearches = savedQuery.data ?? []
|
||||
|
||||
const [filter, setFilter] = useState('All')
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addingSearch, setAddingSearch] = useState(false)
|
||||
|
||||
const now = new Date()
|
||||
const isOverdue = (t) => !t.done && t.due && t.due < now
|
||||
|
|
@ -119,6 +129,23 @@ export default function Tasks() {
|
|||
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
|
||||
})
|
||||
|
||||
const createSearch = useMutation({
|
||||
mutationFn: (body) => savedSearchesApi.create(body),
|
||||
onSuccess: () => {
|
||||
setAddingSearch(false)
|
||||
toast('Saved search created', 'success')
|
||||
},
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not save the search.'), 'error'),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.savedSearches.all() }),
|
||||
})
|
||||
|
||||
const deleteSearch = useMutation({
|
||||
mutationFn: (id) => savedSearchesApi.remove(id),
|
||||
onSuccess: () => toast('Saved search deleted', 'success'),
|
||||
onError: (err) => toast(friendlyAuthError(err, 'Could not delete the search.'), 'error'),
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.savedSearches.all() }),
|
||||
})
|
||||
|
||||
function toggle(task) {
|
||||
if (!canEdit) {
|
||||
toast('Requires tasks.edit', 'info')
|
||||
|
|
@ -214,27 +241,65 @@ export default function Tasks() {
|
|||
<div className="card" style={{ alignSelf: 'start' }}>
|
||||
<div className="card-head">
|
||||
<div><h3>Saved Searches</h3><span className="ch-sub">Quick candidate filters</span></div>
|
||||
<button className="act-btn" onClick={() => toast('New saved search', 'info')}><Icon name="plus" /></button>
|
||||
<button className="act-btn" onClick={() => setAddingSearch(true)}><Icon name="plus" /></button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
{savedSearches.map((s) => (
|
||||
<div key={s.name} className="list-row" style={{ cursor: 'pointer' }} onClick={() => navigate('/candidates')}>
|
||||
<span className="kpi-icn i-indigo" style={{ width: 36, height: 36, borderRadius: 9 }}>
|
||||
<Icon name="bookmark" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{s.name}</div>
|
||||
<div className="lr-sub">{s.filters}</div>
|
||||
{savedQuery.isPending && <EmptyState icon="bookmark" title="Loading…">Fetching saved searches.</EmptyState>}
|
||||
{savedQuery.isError && (
|
||||
<EmptyState icon="bookmark" title="Couldn’t load">
|
||||
{friendlyAuthError(savedQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{savedQuery.isSuccess && savedSearches.length === 0 && (
|
||||
<EmptyState icon="bookmark" title="No saved searches">
|
||||
Save a candidate filter to reopen it later.
|
||||
</EmptyState>
|
||||
)}
|
||||
{savedQuery.isSuccess && savedSearches.length > 0 && (
|
||||
<div className="list-tight">
|
||||
{savedSearches.map((s) => (
|
||||
<div key={s.id} className="list-row" style={{ cursor: 'pointer' }}>
|
||||
<span
|
||||
className="kpi-icn i-indigo"
|
||||
style={{ width: 36, height: 36, borderRadius: 9 }}
|
||||
onClick={() => navigate('/candidates', { state: { savedSearch: s } })}
|
||||
>
|
||||
<Icon name="bookmark" />
|
||||
</span>
|
||||
<div
|
||||
className="lr-main"
|
||||
onClick={() => navigate('/candidates', { state: { savedSearch: s } })}
|
||||
>
|
||||
<div className="lr-title">{s.name}</div>
|
||||
<div className="lr-sub">{s.summary}</div>
|
||||
</div>
|
||||
{s.count != null && <span className="badge b-gray badge-plain">{s.count}</span>}
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteSearch.mutate(s.id)
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="badge b-gray badge-plain">{s.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addingSearch && (
|
||||
<SavedSearchForm
|
||||
busy={createSearch.isPending}
|
||||
onClose={() => setAddingSearch(false)}
|
||||
onSubmit={(body) => createSearch.mutate(body)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<TaskDetail
|
||||
task={detail}
|
||||
|
|
@ -403,3 +468,61 @@ function AddTask({ assignees, me, pending, onClose, onSave }) {
|
|||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function SavedSearchForm({ busy, onClose, onSubmit }) {
|
||||
const form = useFormState({ name: '', summary: '' })
|
||||
|
||||
function submit() {
|
||||
if (busy) return
|
||||
if (!form.values.name.trim()) {
|
||||
form.setErrors({ name: 'Required' })
|
||||
return
|
||||
}
|
||||
onSubmit({
|
||||
name: form.values.name.trim(),
|
||||
entity: 'candidates',
|
||||
filters: form.values.summary.trim()
|
||||
? { summary: form.values.summary.trim() }
|
||||
: {},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Save Search"
|
||||
subtitle="Quick filter for the Candidates screen"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-secondary" onClick={onClose} disabled={busy}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={busy}>
|
||||
<Icon name="check" /> {busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
|
||||
<div className="form-field">
|
||||
<label>Name</label>
|
||||
<input
|
||||
className={form.errors.name ? 'err' : ''}
|
||||
value={form.values.name}
|
||||
onChange={(e) => form.setField('name', e.target.value)}
|
||||
placeholder="e.g. Senior Engineers · SF"
|
||||
disabled={busy}
|
||||
/>
|
||||
<FieldError>{form.errors.name}</FieldError>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Filter summary</label>
|
||||
<input
|
||||
value={form.values.summary}
|
||||
onChange={(e) => form.setField('summary', e.target.value)}
|
||||
placeholder="Engineering · L5+ · San Francisco"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -860,6 +860,19 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.attach-icn { width: 42px; height: 42px; border-radius: 10px; background: var(--danger-soft); color: var(--danger); display: grid; place-items: center; }
|
||||
.resume-thumb { border: 1px solid var(--border); border-radius: 10px; background: var(--bg-sunken); padding: 20px; font-family: var(--mono); font-size: 11px; color: var(--text-2); line-height: 1.8; max-height: 300px; overflow: hidden; position: relative; }
|
||||
.resume-thumb::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 60px; background: linear-gradient(transparent, var(--bg-sunken)); }
|
||||
/* Untruncated variant. The base is a thumbnail: 300px tall, clipped, with a fade
|
||||
over the last 60px. Raising max-height alone still leaves that fade washing out
|
||||
the closing lines, which is the opposite of showing the whole text. */
|
||||
.resume-thumb.is-full { max-height: none; overflow: visible; white-space: pre-wrap; word-break: break-word; }
|
||||
.resume-thumb.is-full::after { content: none; }
|
||||
.resume-thumb .rt-subject { display: block; color: var(--text); font-weight: 600; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
||||
|
||||
/* Email viewer: a header strip joined to the body below it, Outlook-style. The
|
||||
body is either an iframe (HTML mail, see ui/EmailBody.jsx) or a <pre> for
|
||||
plain text — both square off their top corners to meet the header. */
|
||||
.email-head { border: 1px solid var(--border); border-bottom: none; border-radius: 10px 10px 0 0; background: var(--bg-elev); padding: 10px 14px; font-weight: 600; color: var(--text); font-size: 13px; overflow-wrap: break-word; }
|
||||
.email-frame { display: block; width: 100%; border: 1px solid var(--border); border-radius: 0 0 10px 10px; background: var(--bg-sunken); }
|
||||
.email-plain { border-radius: 0 0 10px 10px; }
|
||||
|
||||
/* Upload dropzone */
|
||||
.dropzone { border: 2px dashed var(--border-strong); border-radius: var(--radius-lg); padding: 48px 24px; text-align: center; transition: .18s; background: var(--bg-sunken); cursor: pointer; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
/* ============================================================
|
||||
EmailBody.jsx — render a candidate email as HTML, the way a mail client does.
|
||||
|
||||
Two layers of defence, because one is not enough:
|
||||
|
||||
1. A sanitiser pass strips scripts, embedded frames, form controls and every
|
||||
event handler / javascript: URL before the markup is handed over.
|
||||
2. The result renders inside an iframe whose sandbox never includes
|
||||
allow-scripts, so even a miss in layer 1 cannot execute. A Content-Security-
|
||||
Policy meta inside the document blocks every outbound request by default.
|
||||
|
||||
allow-scripts is the one token that must never appear here. Paired with
|
||||
allow-same-origin it lets the frame reach into its own sandbox attribute and
|
||||
remove it, which hands the attacker the parent origin. allow-same-origin on
|
||||
its own is safe and is what lets the parent measure scrollHeight to size the
|
||||
frame — no scripts run either way.
|
||||
|
||||
The iframe also isolates CSS. Emails ship <style> blocks written for Outlook;
|
||||
inlined into the page they would restyle the whole app.
|
||||
|
||||
Remote images stay blocked until the user asks for them. A tracking pixel in
|
||||
an applicant email would otherwise tell the sender exactly when a recruiter
|
||||
opened it.
|
||||
============================================================ */
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/** Elements that have no business in a rendered email. */
|
||||
const STRIP_TAGS = [
|
||||
'script', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet',
|
||||
'form', 'input', 'button', 'select', 'textarea', 'base', 'meta', 'link',
|
||||
]
|
||||
|
||||
/** Anything else is a scheme we do not want behind a click. */
|
||||
const SAFE_URL = /^(https?:|mailto:|tel:|cid:|data:image\/)/i
|
||||
|
||||
function sanitize(html) {
|
||||
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html')
|
||||
|
||||
doc.querySelectorAll(STRIP_TAGS.join(',')).forEach((node) => node.remove())
|
||||
|
||||
doc.querySelectorAll('*').forEach((node) => {
|
||||
for (const attr of [...node.attributes]) {
|
||||
const name = attr.name.toLowerCase()
|
||||
// onclick, onerror, onload — the classic sanitiser bypass.
|
||||
if (name.startsWith('on')) {
|
||||
node.removeAttribute(attr.name)
|
||||
continue
|
||||
}
|
||||
if ((name === 'href' || name === 'src' || name === 'action') && !SAFE_URL.test(attr.value.trim())) {
|
||||
node.removeAttribute(attr.name)
|
||||
}
|
||||
if (name === 'srcdoc' || name === 'srcset') node.removeAttribute(attr.name)
|
||||
}
|
||||
})
|
||||
|
||||
// Sandbox blocks in-frame navigation, so a link has to open a new tab to work
|
||||
// at all. noopener keeps the opened tab from reaching back via window.opener.
|
||||
doc.querySelectorAll('a[href]').forEach((a) => {
|
||||
a.setAttribute('target', '_blank')
|
||||
a.setAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
return doc.body?.innerHTML || ''
|
||||
}
|
||||
|
||||
/** Inherit the host theme's colours so the email does not glare in dark mode. */
|
||||
function frameStyles() {
|
||||
const css = getComputedStyle(document.documentElement)
|
||||
const pick = (name, fallback) => (css.getPropertyValue(name) || fallback).trim()
|
||||
return `
|
||||
:root { color-scheme: ${pick('--bg', '#fff').startsWith('#f') ? 'light' : 'dark'}; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: ${pick('--bg-sunken', '#f7f7f8')};
|
||||
color: ${pick('--text', '#111')};
|
||||
font-family: ${pick('--sans', 'system-ui, sans-serif')};
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
img, table { max-width: 100%; }
|
||||
img { height: auto; }
|
||||
table { border-collapse: collapse; }
|
||||
a { color: ${pick('--primary', '#2563eb')}; }
|
||||
blockquote {
|
||||
margin: 8px 0; padding-left: 12px;
|
||||
border-left: 3px solid ${pick('--border', '#ddd')};
|
||||
color: ${pick('--text-2', '#555')};
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function buildSrcDoc(html, allowRemoteImages) {
|
||||
const img = allowRemoteImages ? "img-src data: cid: https: http:" : "img-src data: cid:"
|
||||
// default-src 'none' is the backstop: no fetches, no frames, no scripts, even
|
||||
// if something slipped past sanitize().
|
||||
const csp = `default-src 'none'; style-src 'unsafe-inline'; ${img}`
|
||||
return `<!doctype html><html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<style>${frameStyles()}</style>
|
||||
</head><body>${html}</body></html>`
|
||||
}
|
||||
|
||||
/** True when the string carries real markup rather than incidental angle brackets. */
|
||||
export function looksLikeHtml(value) {
|
||||
return /<[a-z!/][\s\S]*>/i.test(String(value || ''))
|
||||
}
|
||||
|
||||
export default function EmailBody({ html, maxHeight }) {
|
||||
const ref = useRef(null)
|
||||
const [allowRemoteImages, setAllowRemoteImages] = useState(false)
|
||||
const [height, setHeight] = useState(320)
|
||||
const [blockedImages, setBlockedImages] = useState(0)
|
||||
|
||||
const clean = sanitize(html)
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const frame = ref.current
|
||||
// contentDocument is readable only because the sandbox keeps allow-same-origin.
|
||||
const body = frame?.contentDocument?.body
|
||||
if (!body) return
|
||||
setHeight(body.scrollHeight + 8)
|
||||
}, [])
|
||||
|
||||
const onLoad = useCallback(() => {
|
||||
measure()
|
||||
const doc = ref.current?.contentDocument
|
||||
if (!doc) return
|
||||
const remote = [...doc.querySelectorAll('img[src]')].filter((i) =>
|
||||
/^https?:/i.test(i.getAttribute('src') || ''),
|
||||
)
|
||||
setBlockedImages(allowRemoteImages ? 0 : remote.length)
|
||||
// An image that arrives after load changes the height under us.
|
||||
doc.querySelectorAll('img').forEach((i) => i.addEventListener('load', measure))
|
||||
}, [measure, allowRemoteImages])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', measure)
|
||||
return () => window.removeEventListener('resize', measure)
|
||||
}, [measure])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{blockedImages > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-8"
|
||||
style={{
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
marginBottom: 8,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--bg-elev)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<span className="text-muted">
|
||||
{blockedImages} remote image{blockedImages === 1 ? '' : 's'} blocked to stop read tracking.
|
||||
</span>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setAllowRemoteImages(true)}>
|
||||
Show images
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={ref}
|
||||
className="email-frame"
|
||||
title="Email body"
|
||||
onLoad={onLoad}
|
||||
// No allow-scripts. Ever. See the header comment.
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
srcDoc={buildSrcDoc(clean, allowRemoteImages)}
|
||||
style={{ height: maxHeight ? Math.min(height, maxHeight) : height }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue