pull/17/head
ahmed.mujtaba 2026-08-17 20:37:30 +05:00
parent f7778538dd
commit 64eae32259
86 changed files with 8418 additions and 1756 deletions

135
backend/assessments/app.py Normal file
View File

@ -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))

View File

@ -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

View File

@ -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,
}

View File

@ -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

View File

@ -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))

View File

@ -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"

View File

@ -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,
)

View File

@ -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),

View File

@ -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}

View File

@ -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))

View File

@ -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"

View File

@ -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)

View File

@ -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 = (

View File

@ -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

View File

@ -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

View File

@ -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,
}

View File

@ -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}

View File

@ -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."""

View File

@ -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)

View File

@ -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)

View File

@ -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;

View File

@ -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))

View File

@ -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

View File

@ -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,

View File

@ -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}

View File

@ -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))

View File

@ -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

View File

@ -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,
}

View File

@ -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]

View File

@ -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))

View File

@ -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

View File

@ -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,
}

View File

@ -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}

30
backend/search/app.py Normal file
View File

@ -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))

View File

@ -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,
}

104
backend/search/views.py Normal file
View File

@ -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()]

View File

@ -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))

View File

@ -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

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
})
}

50
frontend/src/api/costs.js Normal file
View File

@ -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)
}

View File

@ -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,
}
}

View File

@ -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 },
})
}

View File

@ -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,
}
}

View File

@ -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')
}

View File

@ -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 },
})
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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 } })
}

View File

@ -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,
}
}

View File

@ -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">Couldnt 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 &ldquo;{q}&rdquo;</div>}
{empty && <div className="search-empty">No results for &ldquo;{q}&rdquo;</div>}
</>
)}
</>
)}
</div>

View File

@ -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>

View File

@ -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 ---

View File

@ -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,
}
}

View File

@ -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 })

View File

@ -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);

View File

@ -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'],

View File

@ -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
}

View File

@ -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={`Couldnt 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="Couldnt 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>

View File

@ -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="Couldnt 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>
)
}

View File

@ -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="Couldnt 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>
)
}

View File

@ -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 (0100)</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>

View File

@ -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>
)
}

View File

@ -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={`Couldnt 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="Couldnt 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="Couldnt 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="Couldnt 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">

View File

@ -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>
)}
</>
)
}

View File

@ -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="Couldnt 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="Couldnt 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>
)

View File

@ -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="Couldnt 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="Couldnt 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>
)
}

View File

@ -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" />

View File

@ -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="Couldnt 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&apos;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>
)
}

View File

@ -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 }}>

View File

@ -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="Couldnt 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>
)

View File

@ -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="Couldnt 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>
)

View File

@ -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),
}

View File

@ -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 roles <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 roles <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="Couldnt 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>
)

View File

@ -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 MonSun 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="Couldnt 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 &amp; 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="Couldnt load this recruiters 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="Couldnt 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="Couldnt 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 recruiters 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="Couldnt 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>

View File

@ -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="Couldnt 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="Couldnt 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>
)

View File

@ -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 () => {

View File

@ -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': '201500',
'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="Couldnt 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>201500</option><option>51200</option><option>500+</option></select>
<select value={draft['general.company_size'] ?? ''} onChange={(e) => setField('general.company_size', e.target.value)}>
<option>201500</option><option>51200</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="Couldnt 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="Couldnt 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="Couldnt 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="Couldnt 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>
)

View File

@ -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="Couldnt 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>
)
}

View File

@ -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; }

View File

@ -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>
)
}