diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fa8125f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Build context for backend/Dockerfile and app/Dockerfile is the repo root, so this +# file decides what is even eligible to be copied into those images. + +.git/ +.gitignore +.gitignore.local +.claude/ +.cursor/ +.vscode/ +.idea/ + +# Secrets are passed at runtime via compose `env_file` — never baked into a layer. +**/.env +**/.env.* +!**/.env.example + +**/__pycache__/ +**/*.py[cod] +**/*.egg-info/ +.venv/ +venv/ +env/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +# Frontend has its own context (./frontend) and its own .dockerignore; nothing of it +# belongs in a Python image, and node_modules would dominate the context transfer. +frontend/ + +# Candidate CVs live on the bind mount, not inside an image. +backend/inbox/decoded_attachments/ + +docs/ +tests/ +scripts/ +tools/ +*.md +*.log +tmp/ +temp/ diff --git a/README.md b/README.md index 76b5d94..67d821b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,83 @@ Optional — background inbox sync workers (need Redis): docker compose up redis taskiq-worker taskiq-scheduler ``` +## Docker + +Every service has its own image and its own container, all in one +[docker-compose.yml](docker-compose.yml). **Postgres is in that file but does not run** +— it sits behind a compose profile, and the stack talks to the PostgreSQL server +already running on the host. + +```bash +docker compose build +docker compose up -d +docker compose ps +``` + +| Service | Image | Host port | Built from | +|---|---|---|---| +| `backend-api` | `hrms-backend:local` | 8000 | [backend/Dockerfile](backend/Dockerfile) | +| `taskiq-worker` · `taskiq-scheduler` · `taskiq-cv-worker` · `taskiq-cv-scheduler` | `hrms-backend:local` (same image, different `command`) | — | same | +| `ats-engine` | `hrms-ats-engine:local` | 8100 | [app/Dockerfile](app/Dockerfile) | +| `frontend` | `hrms-frontend:local` | 5173 | [frontend/Dockerfile](frontend/Dockerfile) | +| `redis` | `redis:7-alpine` | 6379 | — | +| `postgres` *(profile `postgres` — never starts by default)* | `hrms-postgres:local` | 5433 | [docker/postgres/Dockerfile](docker/postgres/Dockerfile) | + +The backend image builds from the **repo root**, not `./backend`: `job/candidate` +imports the scoring engine from `app/`, and `inbox.plugins` pulls that in transitively, +so a `./backend` context produces workers that die on `No module named 'app'`. + +### The shared file mount + +`backend/inbox/decoded_attachments/` on the host is bind-mounted into every container +that touches a CV — `backend-api`, `taskiq-worker`, `taskiq-cv-worker` — at the +identical path `/app/inbox/decoded_attachments`. A PDF written by the API is the same +file the worker opens, and absolute paths stored in the database resolve in either +direction (`inbox.plugins.resolve_attachment_path` also falls back to +basename-under-that-folder for rows written by a host process). Point it elsewhere with +`ATTACHMENTS_DIR=/some/host/path`. + +### Talking to the host + +`backend/.env` is written for host processes, so compose overrides the three values a +container needs: `DB_HOST=host.docker.internal` (the local Postgres), +`EMAIL_URL=http://host.docker.internal:5000` (the email service on the host), and +`REDIS_URL=redis://redis:6379/0`. The host Postgres must accept connections from the +Docker bridge — `listen_addresses = '*'` plus a `pg_hba.conf` entry for `172.16.0.0/12`. + +> **Stop the host `uvicorn` and `npm run dev` first.** Windows lets a host process bind +> `127.0.0.1:8000` while Docker binds `0.0.0.0:8000`, and `localhost` resolves to `::1` +> first — so both listen and requests silently reach whichever won. Same for 5173. Use +> `BACKEND_PORT` / `FRONTEND_PORT` / `ATS_PORT` if both must run. + +`VITE_API_BASE` is inlined into the bundle at **build** time (default +`http://localhost:8000`), so changing the API origin means rebuilding the frontend +image, not restarting the container. + +### The Postgres profile + +The image is defined alongside everything else, but the `postgres` profile keeps it out +of `docker compose build` and `docker compose up` — bringing it up is always explicit: + +```bash +docker compose --profile postgres build postgres +docker compose --profile postgres up -d postgres # host port 5433; 5432 is the host server's +``` + +Pointing the app at it is a second, deliberate step: set `DB_HOST=postgres` (the only +value that changes — services reach it on 5432 over the compose network) and recreate +the services. Its volume starts empty, so Alembic rebuilds the schema on first boot; it +does not share the host server's data. + +### Live-code overlay + +[docker-compose.dev.yml](docker-compose.dev.yml) is not a second stack — it defines no +services or images, it only adds source bind mounts and `--reload` to the ones above: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up +``` + ### 4. First run 1. Sign up / log in (`/auth/login`) — the user needs a role carrying diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..99613fb --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +# +# Bulk ATS scoring engine — the standalone FastAPI service (CLAUDE.md is its spec). +# Serves POST /api/v1/score, GET /api/v1/health and the card-grid test UI at /. +# +# The backend imports this same package as a library; this image is the separate +# service form of it, so it can be scaled, restarted or pointed at a different model +# independently of the portal API. +# +# THE BUILD CONTEXT IS THE REPO ROOT (pyproject.toml lives there): +# +# docker build -f app/Dockerfile -t hrms-ats-engine:local . + +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/srv + +WORKDIR /srv + +COPY pyproject.toml ./ +COPY app/ ./app/ + +# Installs the pinned dependencies from pyproject.toml along with the package. The +# copy at /srv/app stays on sys.path ahead of the installed one, so the dev overlay's +# source bind mount is what actually executes. +RUN pip install --no-cache-dir . + +EXPOSE 8100 + +# No module-level `app` object exists on purpose (app/main.py), so the factory form +# is mandatory here. +CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8100"] diff --git a/backend/.env.example b/backend/.env.example index e47fb09..20041c7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -54,6 +54,21 @@ MAX_PDF_SIZE_MB=10 MAX_JD_CHARS=30000 MAX_RESUME_CHARS=60000 +# Inbox intake gate (inbox_classifier/): only mail judged to be a job application +# gets an inbox_messages row; every verdict is logged to inbox_message_triage. +# Model / token / effort / cache knobs are the OPENAI_* ones above. +# false restores the pre-gate behaviour exactly — the rollback lever. +INBOX_TRIAGE_ENABLED=true +# true: a provider outage or missing key ingests the mail and marks the verdict +# unclassified. false: skip it and leave it for a later /email/fetch. +INBOX_TRIAGE_FAIL_OPEN=true +INBOX_TRIAGE_CONCURRENCY=5 +INBOX_TRIAGE_MAX_SUBJECT_CHARS=300 +INBOX_TRIAGE_MAX_BODY_CHARS=4000 +# 0 disables the uncertainty branch; >0 routes low-confidence verdicts to the +# INBOX_TRIAGE_FAIL_OPEN policy. +INBOX_TRIAGE_MIN_CONFIDENCE=0 + REDIS_URL=redis://localhost:6379/0 TASKIQ_QUEUE_NAME=inbox TASKIQ_CV_QUEUE_NAME=cv_upload diff --git a/backend/Dockerfile b/backend/Dockerfile index 3a584e1..1da523b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,43 @@ +# syntax=docker/dockerfile:1 +# +# Backend image. The FastAPI API and all four Taskiq processes (inbox worker and +# scheduler, CV worker and scheduler) run from this one image; docker-compose picks +# the process with `command:`. +# +# THE BUILD CONTEXT IS THE REPO ROOT, not ./backend: +# +# docker build -f backend/Dockerfile -t hrms-backend:local . +# +# backend/job/candidate imports the bulk-ats scoring engine (`app.core.errors`, +# `app.services.pdf`, `app.services.scoring`), which lives in app/ at the repo root +# and is pulled in transitively by inbox.plugins -> inbox.tasks. A ./backend context +# cannot see it, so the workers would die on import. + FROM python:3.12-slim -WORKDIR /app -ENV PYTHONPATH=/app +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app -COPY requirements.txt . +WORKDIR /app + +COPY backend/requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt -COPY . . +# Backend tree at /app; the scoring engine at /app/app so `import app.services.pdf` +# resolves under PYTHONPATH=/app. requirements.txt says to `pip install -e ..` for +# this in a host environment — copying it in is the container equivalent, and its +# dependencies (openai, pypdf, pydantic-settings, python-multipart) are already pinned +# above. +COPY backend/ /app/ +COPY app/ /app/app/ -# Runs the Taskiq worker against taskiq_management.broker_setup. -# docker-compose overrides this command if needed. -CMD ["taskiq", "worker", "taskiq_management.broker_setup:broker", "inbox.tasks", "inbox.sync_tasks", "taskiq_management.tasks"] +# Decoded CV attachments are read and written here. docker-compose bind-mounts the +# host folder over this path so every container shares one set of files; creating it +# in the image keeps an un-mounted container from failing on first write. +RUN mkdir -p /app/inbox/decoded_attachments + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/assessments/app.py b/backend/assessments/app.py new file mode 100644 index 0000000..9554fdd --- /dev/null +++ b/backend/assessments/app.py @@ -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)) diff --git a/backend/assessments/models.py b/backend/assessments/models.py new file mode 100644 index 0000000..44def1b --- /dev/null +++ b/backend/assessments/models.py @@ -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 diff --git a/backend/assessments/serializers.py b/backend/assessments/serializers.py new file mode 100644 index 0000000..6064143 --- /dev/null +++ b/backend/assessments/serializers.py @@ -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, + } diff --git a/backend/assessments/views.py b/backend/assessments/views.py new file mode 100644 index 0000000..750c3ce --- /dev/null +++ b/backend/assessments/views.py @@ -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"
This is a reminder that your {row.assessment_type} assessment is pending" + f"{' for ' + name if name else ''}.
" + ) + 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 diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 34ff814..e4953a0 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -16,6 +16,31 @@ router = APIRouter() class AssignJobPostBody(BaseModel): job_post_id: str | None = None + +class ProcessingStateBody(BaseModel): + processing_state: str + + +class DuplicateBody(BaseModel): + is_duplicate: bool + + +class TriageOverrideBody(BaseModel): + is_application: 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), @@ -31,21 +56,28 @@ async def fetch_email( data=await service.service_email(top,skip) value=data.get("value") items_lst=[] + # Classify the whole page first, bounded-parallel, then replay it in upstream + # order: the inserts stay serial on the one request session and pending_match_ids + # keeps the sequence it has today. + decisions=await service.triage_round([item.get("id") for item in value]) for item in value: message_id=item.get("id") - service_per_email=await service.get_email_by_id(message_id,test_on) + service_per_email=await service.get_email_by_id(message_id,test_on,decision=decisions.get(str(message_id))) items_lst.append({"message_id":message_id,"email_contents":service_per_email}) if service.pending_match_ids: await service.enqueue_matching(list(service.pending_match_ids),force=False) + skipped=len(service.skipped_message_ids) + triage={"ingested":len(items_lst)-skipped,"skipped":skipped,"errors":len(service.triage_errors)} + account_setup=[] if test_on: - return JSONResponse(content={"data":items_lst,"status_code":200}) + return JSONResponse(content={"data":items_lst,"triage":triage,"status_code":200}) if service.pending_confirmation_emails: account_setup=await service.send_account_setup(list(service.pending_confirmation_emails)) - return JSONResponse(content={"data":items_lst,"account_setup":account_setup,"status_code":200}) + return JSONResponse(content={"data":items_lst,"account_setup":account_setup,"triage":triage,"status_code":200}) except HTTPException: raise @@ -176,3 +208,122 @@ 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.get("/inbox/triage") +async def fetch_triage( + search: str | None = Query(None), + is_application: bool | None = Query(None), + status: str | None = Query(None), + top: int = Query(100), + skip: int = Query(0, ge=0), + current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + data=await service.get_triage_messages(top,skip,search,is_application,status) + total=await service.count_triage(search,is_application,status) + 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.patch("/inbox/triage/{record_id}/override") +async def override_triage( + record_id: str, + payload: TriageOverrideBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + data=await service.override_triage(record_id,payload.is_application,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("/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)) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index f355f43..44d2832 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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) @@ -668,6 +671,32 @@ class Inbox_Messages(SQLModel, table=True): await session.commit() return result.rowcount or 0 + @classmethod + async def get_by_upstream_id(cls, session: AsyncSession, message_id): + """Lookup by the UPSTREAM Graph id, not the local PK. + + get_inbox_message_by_id above takes the uuid primary key; the triage ledger is + keyed on the upstream id, so overturning a verdict needs this direction. + """ + result=await session.execute(select(cls).where(cls.message_id == str(message_id))) + return result.scalars().first() + + @classmethod + async def existing_message_ids(cls, session: AsyncSession, message_ids) -> set: + """The subset of upstream ids already persisted — the free half of the gate. + + A message already in this table was judged an application once, so the intake + classifier must never be paid for a second time; insert_email's upsert still + refreshes the row. One query per fetch round, columns only. + """ + ids=[str(m) for m in message_ids or [] if m] + if not ids: + return set() + result=await session.execute( + select(cls.message_id).where(cls.message_id.in_(ids)) + ) + return {row for (row,) in result.all() if row} + @classmethod async def mark_message_read(cls, session: AsyncSession, record_id): row=await cls.get_inbox_message_by_id(session,record_id) @@ -679,6 +708,213 @@ 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 Inbox_Message_Triage(SQLModel, table=True): + """One intake verdict per upstream message id — the gate before inbox_messages. + + Rows land here for BOTH outcomes. Rejections are the point: inbox_messages stays + application-only, and a repeated /email/fetch never re-pays for the same + classification. Acceptances are recorded too, so a round that classified and then + failed to insert does not pay twice either. + + Deliberately no message_body column: the body is what this feature keeps out of the + database, and the override route re-reads the mail from upstream by message_id. + message_subject is kept (capped in inbox_classifier.decorators.triage_fields) + because a review screen without it is unusable — it is stored, never logged. + + server_default is load-bearing on every NOT NULL column: alembic_setup runs with + compare_server_default=True, so a model default without a matching server default + autogenerates a drift revision on every boot. + """ + + __tablename__ = "inbox_message_triage" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + # Upstream Graph id — the same key insert_email upserts on. + message_id: str = Field(index=True, unique=True) + is_application: bool = Field(default=False, sa_column_kwargs={"server_default": "false"}) + reason_code: str = Field(default="", sa_column_kwargs={"server_default": ""}) + confidence: float | None = Field(default=None) + evidence: str = Field(default="", sa_column_kwargs={"server_default": ""}) + # Triage_Status: classified | low_confidence | error. Plain text, not a PG enum — + # alembic autogenerate cannot see new enum labels, and the enum in + # inbox_classifier/enums.py already gates what code writes here. + status: str = Field(default="classified", sa_column_kwargs={"server_default": "classified"}) + error: str | None = Field(default=None) + model_name: str = Field(default="", sa_column_kwargs={"server_default": ""}) + message_subject: str = Field(default="", sa_column_kwargs={"server_default": ""}) + message_from: str = Field(default="", sa_column_kwargs={"server_default": ""}) + message_received_time: str = Field(default="", sa_column_kwargs={"server_default": ""}) + file_name: str = Field(default="", sa_column_kwargs={"server_default": ""}) + attachment: bool = Field(default=False, sa_column_kwargs={"server_default": "false"}) + ingested: bool = Field(default=False, sa_column_kwargs={"server_default": "false"}) + overridden_by_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + overridden_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + # _now(), never datetime.now(): a naive local value bound to a timestamptz column + # is read back as UTC and silently backdates the row (see AtsResults below). + classified_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + created_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 get_by_message_id(cls, session: AsyncSession, message_id): + result=await session.execute(select(cls).where(cls.message_id == str(message_id))) + return result.scalars().first() + + @classmethod + async def get_triage_by_id(cls, session: AsyncSession, record_id): + rid=cls._as_uuid(record_id) + if rid is None: + return None + result=await session.execute(select(cls).where(cls.id == rid)) + return result.scalars().first() + + @classmethod + async def verdicts_for_message_ids(cls, session: AsyncSession, message_ids) -> dict: + """{upstream message_id: is_application} for a whole fetch round, one query.""" + ids=[str(m) for m in message_ids or [] if m] + if not ids: + return {} + result=await session.execute( + select(cls.message_id, cls.is_application).where(cls.message_id.in_(ids)) + ) + return {message_id: bool(is_application) for message_id, is_application in result.all()} + + @classmethod + async def record_verdict(cls, session: AsyncSession, fields: dict): + """Upsert one verdict on message_id. + + Two fetch rounds can race the unique index, so IntegrityError rolls back and + re-reads rather than failing the round — same shape as _link_sender above. + """ + message_id=str(fields.get("message_id") or "") + if not message_id: + return None + existing=await cls.get_by_message_id(session, message_id) + if existing: + for key, value in fields.items(): + setattr(existing, key, value) + existing.classified_at=_now() + session.add(existing) + await session.commit() + await session.refresh(existing) + return existing + row=cls(**fields) + session.add(row) + try: + await session.commit() + except IntegrityError: + await session.rollback() + return await cls.get_by_message_id(session, message_id) + await session.refresh(row) + return row + + @classmethod + def _triage_filter(cls, statement, is_application, status, search): + if is_application is not None: + statement=statement.where(cls.is_application == bool(is_application)) + if status: + statement=statement.where(cls.status == str(status)) + if search: + pattern=f"%{search}%" + statement=statement.where( + or_(cls.message_subject.ilike(pattern), cls.message_from.ilike(pattern)) + ) + return statement + + @classmethod + async def list_triage(cls, session: AsyncSession, top, skip, is_application=None, + status=None, search=None): + statement=cls._triage_filter(select(cls), is_application, status, search) + statement=statement.order_by(cls.classified_at.desc()).offset(skip).limit(top) + result=await session.execute(statement) + return list(result.scalars().all()) + + @classmethod + async def count_triage(cls, session: AsyncSession, is_application=None, status=None, + search=None) -> int: + statement=cls._triage_filter(select(func.count(cls.id)), is_application, status, search) + result=await session.execute(statement) + return int(result.scalar() or 0) + + @classmethod + async def set_override(cls, session: AsyncSession, record_id, is_application, user_id=None): + row=await cls.get_triage_by_id(session, record_id) + if not row: + return None + row.is_application=bool(is_application) + row.overridden_by_id=cls._as_uuid(user_id) + row.overridden_at=_now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_ingested(cls, session: AsyncSession, message_id, ingested: bool = True): + row=await cls.get_by_message_id(session, message_id) + if not row: + return None + row.ingested=bool(ingested) + session.add(row) + await session.commit() + await session.refresh(row) + return row + class SourceChannels(SQLModel, table=True): __tablename__ = "source_channels" diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 3a10e6d..ac64172 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -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, + ) diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 5b819dd..6d368df 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -1,6 +1,6 @@ from pathlib import Path -from inbox.models import Inbox_Messages +from inbox.models import Inbox_Message_Triage, Inbox_Messages # match_status (inbox/tasks.py) -> the resume badge the inbox tabs render. _RESUME_STATUS = { @@ -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), @@ -115,3 +128,32 @@ def serialize_application(message: Inbox_Messages) -> dict: "processing_state": message.processing_state, "source_channel_id": message.source_channel_id, } + + +def serialize_triage(row: Inbox_Message_Triage) -> dict: + """inbox_message_triage row -> the intake gate's review shape. + + No body field exists to expose: the gate stores the verdict, never the mail. A + reviewer opens the original from the mailbox, or overturns the verdict and lets the + normal ingestion path re-fetch it. + """ + return { + "id": str(row.id), + "message_id": row.message_id, + "is_application": row.is_application, + "reason_code": row.reason_code, + "confidence": row.confidence, + "evidence": row.evidence, + "status": row.status, + "error": row.error, + "model_name": row.model_name or None, + "subject": row.message_subject, + "fromEmail": row.message_from, + "when": row.message_received_time, + "attachment": row.file_name or None, + "has_attachment": row.attachment, + "ingested": row.ingested, + "overridden_by": str(row.overridden_by_id) if row.overridden_by_id else None, + "overridden_at": row.overridden_at.isoformat() if row.overridden_at else None, + "classified_at": row.classified_at.isoformat() if row.classified_at else None, + } diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 873d88d..822ba6d 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -1,15 +1,28 @@ +import asyncio import logging +import uuid import httpx,os from fastapi import HTTPException from inbox.enums import Candidate_application_Status -from inbox.models import Inbox_Messages +from inbox.models import Inbox_Messages,Inbox_Message_Triage from inbox.file_decoder import decode_attachment -from inbox.serializers import serialize_application, serialize_message +from inbox.serializers import serialize_application, serialize_message, serialize_triage from inbox.plugins import ( EMAIL_API_TOKEN, fetch_message_read_status, load_message_files, request_email_confirmation, + send_mail, +) +from inbox_classifier.decorators import is_manual_upload,triage_fields +from inbox_classifier.execute_agent import classify_email +from inbox_classifier.plugins import ( + TRIAGE_CONCURRENCY, + TRIAGE_ENABLED, + TRIAGE_STATUSES, + sender_domain, + should_ingest, + triage_model_name, ) from dotenv import load_dotenv load_dotenv() @@ -17,6 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from datetime import datetime,timezone logger=logging.getLogger("inbox.match") +triage_logger=logging.getLogger("inbox.triage") class Email: @@ -26,6 +40,10 @@ class Email: self.token=token or EMAIL_API_TOKEN self.pending_match_ids:list[str]=[] self.pending_confirmation_emails:list[str]=[] + # Upstream ids the intake gate judged not to be job applications. They get a + # verdict row and no inbox_messages row. + self.skipped_message_ids:list[str]=[] + self.triage_errors:list[str]=[] # async def get_all_applications(self,app_id=None): # try: @@ -51,27 +69,163 @@ class Email: except Exception as e: raise HTTPException(status_code=500,detail=str(e)) - async def get_email_by_id(self,message_id,test_on=True): + async def fetch_message(self,message_id): + """GET /emails/{id} on the upstream Email API -> the Graph payload.""" async with httpx.AsyncClient() as client: - try: - response=await client.get(f"{self.get_url}/emails/{message_id}", - headers={"Authorization":f"Bearer {self.token}"} - ) - if response.status_code==200: - data=response.json() - re_create_file=await decode_attachment(data.get("attachments")) - row,new_user_email=await Inbox_Messages.insert_email(session=self.session,email_data=data,file_path=re_create_file) - if row.attachment and row.file_path and row.match_status is None: - self.pending_match_ids.append(str(row.id)) - if test_on: - return data - if new_user_email: - self.pending_confirmation_emails.append(new_user_email) - return data - else: - raise HTTPException(status_code=response.status_code,detail=response.text) - except Exception as e: - raise HTTPException(status_code=500,detail=str(e)) + response=await client.get(f"{self.get_url}/emails/{message_id}", + headers={"Authorization":f"Bearer {self.token}"} + ) + if response.status_code!=200: + raise HTTPException(status_code=response.status_code,detail=response.text) + return response.json() + + async def triage_round(self,message_ids): + """Fetch and classify a whole /email/fetch page, bounded by a semaphore. + + Returns {message_id: decision}. The caller replays the page in upstream order, + so pending_match_ids and pending_confirmation_emails keep the exact sequence + they have today. + + Only the upstream GET and the OpenAI call run concurrently, and nothing inside + the gather touches self.session — Depends(get_session) yields ONE AsyncSession, + which cannot be shared across tasks. All DB work stays in the serial replay. + + Two pre-filters run first and cost no tokens: a message already in + inbox_messages was judged an application once, and a message already in + inbox_message_triage has a stored verdict to replay. That is what makes a + repeated fetch free. + """ + ids=[str(m) for m in message_ids or [] if m] + decisions={} + if not ids: + return decisions + known=await Inbox_Messages.existing_message_ids(self.session,ids) + recorded=await Inbox_Message_Triage.verdicts_for_message_ids(self.session,ids) + pending=[] + for message_id in ids: + if message_id in known: + decisions[message_id]={"ingest":True,"status":"known","fresh":False} + elif message_id in recorded: + decisions[message_id]={"ingest":recorded[message_id],"status":"recorded","fresh":False} + else: + pending.append(message_id) + if not pending: + triage_logger.info("triage round: page=%s known=%s classified=0",len(ids),len(decisions)) + return decisions + + semaphore=asyncio.Semaphore(TRIAGE_CONCURRENCY) + + async def run(message_id): + async with semaphore: + data=await self.fetch_message(message_id) + if not TRIAGE_ENABLED or is_manual_upload(data): + return message_id,{"data":data,"ingest":True,"status":"disabled","fresh":False} + verdict,error=await classify_email(data) + ingest,status,reason=should_ingest(verdict,error) + return message_id,{"data":data,"verdict":verdict,"error":error,"ingest":ingest, + "status":status,"reason":reason,"fresh":True} + + results=await asyncio.gather(*(run(m) for m in pending),return_exceptions=True) + accepted=rejected=errors=0 + for result in results: + if isinstance(result,BaseException): + # First failure wins, preserving today's all-or-nothing behaviour for a + # failing upstream message. Never catch BaseException itself: a + # CancelledError must keep propagating. + raise result + message_id,decision=result + decisions[message_id]=decision + if decision.get("status")=="error": + errors+=1 + if decision.get("ingest"): + accepted+=1 + else: + rejected+=1 + triage_logger.info( + "triage round: page=%s known=%s classified=%s accepted=%s rejected=%s errors=%s", + len(ids),len(known)+len(recorded),len(pending),accepted,rejected,errors, + ) + return decisions + + async def record_triage(self,data,decision,ingested): + """Persist one verdict. Never raises into the ingestion path. + + A failed audit write must not cost us a candidate: the worst case is that the + next fetch re-classifies this message. + """ + try: + fields=triage_fields( + data, + decision.get("verdict"), + decision.get("status") or "classified", + decision.get("reason") or "", + error=decision.get("error") or "", + model_name=triage_model_name(), + ingested=ingested, + ) + await Inbox_Message_Triage.record_verdict(self.session,fields) + # Allowlisted keys only: sender DOMAIN not address, attachment COUNT not + # names, no subject or body text, no evidence text. + verdict=decision.get("verdict") + triage_logger.info( + "triage %s: application=%s reason=%s confidence=%s domain=%s attachments=%s", + fields["message_id"], + fields["is_application"], + fields["reason_code"], + getattr(verdict,"confidence",None), + sender_domain(fields["message_from"]), + len(data.get("attachments") or []), + ) + except Exception as e: + triage_logger.warning("triage record failed: %s",type(e).__name__) + + async def get_email_by_id(self,message_id,test_on=True,decision=None): + """Persist one upstream message, gated by the application classifier. + + `decision` is the pre-computed verdict from triage_round; without one this + classifies inline, so a single-message call still works. + + Ordering is deliberate. The verdict comes BEFORE decode_attachment: a rejected + mail must not write a file into decoded_attachments (nothing on this path ever + deletes one, and _write uses the basename only, so a vendor "resume.pdf" would + clobber a candidate's stored CV), and must not reach _link_sender, which would + create a candidate Users row and queue a confirmation mail for a stranger. + + The gate lives here, not in Inbox_Messages.insert_email, so + FileRead.ingest_upload bypasses it for free — that path fabricates an EMPTY body + and would be a guaranteed false negative under a subject+body classifier. + """ + try: + if decision is None: + decision=(await self.triage_round([message_id])).get(str(message_id)) or {} + data=decision.get("data") or await self.fetch_message(message_id) + + if not decision.get("ingest"): + if decision.get("fresh"): + await self.record_triage(data,decision,ingested=False) + self.skipped_message_ids.append(str(message_id)) + if decision.get("status")=="error": + self.triage_errors.append(str(message_id)) + return {"message_id":str(message_id),"skipped":"not_application", + "reason":decision.get("reason") or "","status":decision.get("status") or ""} + + re_create_file=await decode_attachment(data.get("attachments")) + row,new_user_email=await Inbox_Messages.insert_email(session=self.session,email_data=data,file_path=re_create_file) + if decision.get("fresh"): + await self.record_triage(data,decision,ingested=True) + if row.attachment and row.file_path and row.match_status is None: + self.pending_match_ids.append(str(row.id)) + if test_on: + return data + if new_user_email: + self.pending_confirmation_emails.append(new_user_email) + return data + except HTTPException: + # Was missing: the bare `except Exception` below caught the upstream-status + # HTTPException and re-raised every one of them as a 500. + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) async def get_inbox_messages(self,top,skip,search=None): messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search) @@ -218,3 +372,146 @@ 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 get_triage_messages(self,top,skip,search=None,is_application=None,status=None): + """The intake gate's verdict log — mostly the mail that never became a row. + + A hard gate's only real risk is the silent false negative, so the rejections + have to be reviewable. + """ + if status is not None and status not in TRIAGE_STATUSES: + raise HTTPException(status_code=422,detail=f"status must be one of {', '.join(TRIAGE_STATUSES)}") + rows=await Inbox_Message_Triage.list_triage(self.session,top,skip,is_application,status,search) + return [serialize_triage(row) for row in rows] + + async def count_triage(self,search=None,is_application=None,status=None): + return await Inbox_Message_Triage.count_triage(self.session,is_application,status,search) + + async def override_triage(self,record_id,is_application,current_user=None): + """Overturn a verdict a recruiter disagrees with. + + false -> true re-fetches the mail from upstream and runs the normal ingestion + path, which is why the body was never stored. + + true -> false does NOT delete the inbox_messages row: inbox, ats_results, + assessments, notifications and application_stage_transitions all reference it, + so a purge would take candidate accounts and scores with it. It moves the row to + processing_state 'rejected' instead, an already-allowlisted value. + """ + if not isinstance(is_application,bool): + raise HTTPException(status_code=422,detail="is_application must be a boolean") + row=await Inbox_Message_Triage.get_triage_by_id(self.session,record_id) + if not row: + raise HTTPException(status_code=404,detail="Triage record not found") + + user_id=(current_user or {}).get("id") + if is_application and not row.ingested: + data=await self.fetch_message(row.message_id) + re_create_file=await decode_attachment(data.get("attachments")) + message,new_user_email=await Inbox_Messages.insert_email(session=self.session,email_data=data,file_path=re_create_file) + await Inbox_Message_Triage.mark_ingested(self.session,row.message_id,True) + if message.attachment and message.file_path and message.match_status is None: + await self.enqueue_matching([str(message.id)],force=False) + if new_user_email: + await self.send_account_setup([new_user_email]) + elif not is_application and row.ingested: + message=await Inbox_Messages.get_by_upstream_id(self.session,row.message_id) + if message: + await Inbox_Messages.set_processing_state(self.session,message.id,"rejected") + + updated=await Inbox_Message_Triage.set_override(self.session,record_id,is_application,user_id) + return serialize_triage(updated) + + 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} diff --git a/backend/inbox_classifier/agent_setup.py b/backend/inbox_classifier/agent_setup.py new file mode 100644 index 0000000..a19f2d1 --- /dev/null +++ b/backend/inbox_classifier/agent_setup.py @@ -0,0 +1,162 @@ +"""Intake-gate adapter and its process-wide instance. + +Pure module: no FastAPI imports and no HTTPException. + +Owns construction and lifecycle only — get_classifier() / close_classifier() — +mirroring agent/agent_setup.py. The prompt lives in prompt.py, the verdict shape in +models.py, the run entrypoint in execute_agent.py. + +responses.parse rather than a hand-built JSON schema, for the reason +app/services/llm.py:1-10 gives: parse derives a conforming schema and validates the +reply back into the Pydantic model, so extra="forbid" still gates every verdict. Not +llm_setup.llm_call(json_mode=True), which is schema-free — a gate that decides whether a +row exists at all needs a validated bool, and needs the delivery-status branch below to +tell "the model said no" from "the model could not answer". +""" + +from __future__ import annotations + +import logging + +from app.core.config import supports_reasoning +from app.core.errors import ModelRefusedError, ModelResponseInvalidError, ModelUnavailableError +from openai import AsyncOpenAI + +from inbox_classifier.models import EmailTriageVerdict +from inbox_classifier.plugins import PROMPT_CACHE_KEY, get_triage_settings +from inbox_classifier.prompt import SYSTEM_PROMPT, build_input + +logger=logging.getLogger("inbox.triage") + +# Reasons the provider can return on an incomplete response. +_TRUNCATED="max_output_tokens" +_FILTERED="content_filter" + + +def _first_refusal(response): + """Return the refusal text if the model declined, else None. + + A refusal arrives as a content part inside an output message, not as an error, so it + has to be walked for explicitly before the parsed output is trusted. + + Duplicated from app/services/llm.py:42-53 rather than imported: it is private there, + and each domain owning its own copy is the same call inbox/plugins.py:384-386 already + makes. + """ + for item in getattr(response,"output",None) or []: + for part in getattr(item,"content",None) or []: + if getattr(part,"type",None)=="refusal": + refusal=getattr(part,"refusal",None) + return str(refusal) if refusal else "refused" + return None + + +class EmailClassifier: + def __init__(self, client:AsyncOpenAI, model, max_output_tokens, effort, enable_cache=True): + self._client=client + self._model=model + self._max_output_tokens=max_output_tokens + self._effort=effort + self._enable_cache=enable_cache + self._supports_reasoning=supports_reasoning(model) + + @property + def model(self) -> str: + return self._model + + async def classify(self, subject, body) -> EmailTriageVerdict: + kwargs={ + "model":self._model, + "instructions":SYSTEM_PROMPT, + "input":build_input(subject,body), + "text_format":EmailTriageVerdict, + "max_output_tokens":self._max_output_tokens, + } + # No temperature and no top_p: reasoning models reject them, and sampling was + # never the right lever for a classification task. + if self._supports_reasoning: + kwargs["reasoning"]={"effort":self._effort} + if self._enable_cache: + kwargs["prompt_cache_key"]=PROMPT_CACHE_KEY + + response=await self._client.responses.parse(**kwargs) + + status=getattr(response,"status",None) + self._log_usage(response,status) + + # Branch on delivery status before trusting any output. + if status=="failed": + raise ModelUnavailableError("provider reported a failed response") + + if status=="incomplete": + reason=getattr(getattr(response,"incomplete_details",None),"reason",None) + if reason==_FILTERED: + raise ModelRefusedError("content filter blocked the response") + if reason==_TRUNCATED: + raise ModelResponseInvalidError("response truncated at max_output_tokens") + raise ModelResponseInvalidError(f"incomplete response: {reason}") + + if _first_refusal(response) is not None: + raise ModelRefusedError("model declined to classify this email") + + parsed=getattr(response,"output_parsed",None) + if not isinstance(parsed,EmailTriageVerdict): + raise ModelResponseInvalidError("response did not parse into EmailTriageVerdict") + return parsed + + def _log_usage(self, response, status): + """Token and cache visibility. + + %-args, not extra={}: main.py:21 configures + format="%(levelname)-8s %(name)s: %(message)s", which renders no extra keys — the + ATS adapter's structured fields are invisible in this process today. + """ + usage=getattr(response,"usage",None) + input_details=getattr(usage,"input_tokens_details",None) + output_details=getattr(usage,"output_tokens_details",None) + logger.info( + "triage upstream: model=%s status=%s request_id=%s in=%s out=%s cached=%s reasoning=%s", + self._model, + status, + getattr(response,"id",None), + getattr(usage,"input_tokens",None), + getattr(usage,"output_tokens",None), + getattr(input_details,"cached_tokens",None), + getattr(output_details,"reasoning_tokens",None), + ) + + +_classifier=None + + +def get_classifier() -> EmailClassifier: + """Process-wide classifier over llm_setup's shared AsyncOpenAI client. + + Lazy so a missing OPENAI configuration surfaces on the first /email/fetch, not at + import; llm_setup.init_llm() in the app lifespan has normally created and verified + the client already. Mirrors job/candidate/plugins.get_scorer(). + """ + global _classifier + if _classifier is None: + from llm_setup import get_client + + settings=get_triage_settings() + _classifier=EmailClassifier( + get_client(), + model=settings.openai_model, + max_output_tokens=settings.openai_max_output_tokens, + effort=settings.openai_effort, + enable_cache=settings.openai_enable_prompt_cache, + ) + return _classifier + + +def close_classifier(): + """Drop the cached instance. + + Hooked into main.py's lifespan beside close_llm(), which disposes the shared client — + a retained reference would otherwise point at a closed pool on an in-process restart. + """ + global _classifier + _classifier=None + logger.info("classifier closed") diff --git a/backend/inbox_classifier/decorators.py b/backend/inbox_classifier/decorators.py new file mode 100644 index 0000000..8256b2b --- /dev/null +++ b/backend/inbox_classifier/decorators.py @@ -0,0 +1,200 @@ +"""HTML reduction, signal extraction, and triage column builders. + +Pure module: no FastAPI imports and no HTTPException. Plain functions despite the file +name, following agent/decorators.py. + +Stdlib only (html.parser + re). requirements.txt is deliberately untouched: a +dependency on an HTML library for one classifier prompt is not worth the pin. +""" + +from __future__ import annotations + +import re +from html.parser import HTMLParser + +from inbox_classifier.enums import Block_Tags, Drop_Tags + +_TAG=re.compile(r"<[^>]+>") +# \xa0 is listed explicitly: unescapes to a NO-BREAK SPACE, which a plain \s +# collapse does not match, so an HTML mail would otherwise reach the prompt full of +# stray non-breaking spaces. Written as an escape, not the literal character, so it +# stays visible in a diff. +_SPACES=re.compile(r"[ \t\xa0\r\f\v]+") +_BLANK_LINES=re.compile(r"\n{3,}") + +# Quoted-history markers, in the order Outlook and Gmail actually emit them. +_QUOTE_MARKERS=( + re.compile(r"^-{2,}\s*original message\s*-{2,}", re.IGNORECASE | re.MULTILINE), + re.compile(r"^-{2,}\s*forwarded message\s*-{2,}", re.IGNORECASE | re.MULTILINE), + re.compile(r"^\s*on .{0,200}? wrote:\s*$", re.IGNORECASE | re.MULTILINE), + re.compile(r"^\s*from:\s.+$", re.IGNORECASE | re.MULTILINE), + re.compile(r"^\s*>", re.MULTILINE), +) + +# Below this many characters of new text, a "quoted" reply is really a bare forward +# with nothing above the line. Load-bearing: the prompt says to judge the quoted text +# in exactly that case, so it must not be trimmed away. +_MIN_NEW_TEXT=40 + +MANUAL_UPLOAD_PREFIX="manual-cv:" + + +class _TextExtractor(HTMLParser): + """Visible text only, block tags collapsed to newlines. + + convert_charrefs (default True) means handle_data already receives unescaped text, + so & / / ' never reach the prompt as entities. handle_startendtag + dispatches to start+end by default, so so
+ the review route can find every one of them.
+ """
+ if verdict is None:
+ reason=f"{UNCLASSIFIED_PREFIX}{error_code or 'unknown'}"[:60]
+ return TRIAGE_FAIL_OPEN,Triage_Status.ERROR.value,reason
+ if verdict.confidence\n"
+ "{subject} \n"
+ "\n{body}\n\n"
+ ""
+)
+
+
+def build_email_block(subject, body) -> dict:
+ """The one content block. Delimiters are prompt text, not parsed markup.
+
+ Nothing is escaped: there is no XML parser downstream, and the system prompt is what
+ defends against instruction-shaped content. Escaping here would only corrupt ordinary
+ resume punctuation.
+ """
+ return {
+ "type":"input_text",
+ "text":_EMAIL_TEMPLATE.format(subject=subject,body=body),
+ }
+
+
+def build_user_content(subject, body) -> list:
+ return [build_email_block(subject,body)]
+
+
+def build_input(subject, body) -> list:
+ """The full ``input`` argument for ``responses.parse``."""
+ return [
+ {
+ "role":"user",
+ "content":build_user_content(subject,body),
+ }
+ ]
diff --git a/backend/job/app.py b/backend/job/app.py
index 8ae988c..3474f77 100644
--- a/backend/job/app.py
+++ b/backend/job/app.py
@@ -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))
diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py
index 21fa7b3..fe906b9 100644
--- a/backend/job/assignment/models.py
+++ b/backend/job/assignment/models.py
@@ -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"
diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py
index 4119c54..e23acaa 100644
--- a/backend/job/candidate/models.py
+++ b/backend/job/candidate/models.py
@@ -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)
diff --git a/backend/job/candidate/plugins.py b/backend/job/candidate/plugins.py
index cb433a6..736151b 100644
--- a/backend/job/candidate/plugins.py
+++ b/backend/job/candidate/plugins.py
@@ -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 = (
diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py
index 9957c5e..491f4e0 100644
--- a/backend/job/candidate/views.py
+++ b/backend/job/candidate/views.py
@@ -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
diff --git a/backend/job/feedback/models.py b/backend/job/feedback/models.py
new file mode 100644
index 0000000..b5337bc
--- /dev/null
+++ b/backend/job/feedback/models.py
@@ -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
diff --git a/backend/job/feedback/serializers.py b/backend/job/feedback/serializers.py
index 6248a85..65ac0a8 100644
--- a/backend/job/feedback/serializers.py
+++ b/backend/job/feedback/serializers.py
@@ -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,
+ }
diff --git a/backend/job/feedback/views.py b/backend/job/feedback/views.py
index b8b1b61..16e2e57 100644
--- a/backend/job/feedback/views.py
+++ b/backend/job/feedback/views.py
@@ -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}
diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py
index 54be9a6..a91ca55 100644
--- a/backend/job/job_post/models.py
+++ b/backend/job/job_post/models.py
@@ -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."""
diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py
index a880367..7022951 100644
--- a/backend/job/job_post/views.py
+++ b/backend/job/job_post/views.py
@@ -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)
diff --git a/backend/main.py b/backend/main.py
index b511947..2c8bf64 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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")
@@ -59,6 +63,13 @@ async def lifespan(app):
finally:
if agent_ready and close_agent is not None:
await close_agent()
+ # Before close_llm(): the classifier holds a reference to the shared client,
+ # which close_llm() disposes. No init counterpart — get_classifier() is lazy.
+ try:
+ from inbox_classifier.agent_setup import close_classifier
+ close_classifier()
+ except Exception as exc:
+ logger.warning("classifier close skipped: %s",exc)
if llm_ready and close_llm is not None:
await close_llm()
if cv_broker_ready and cv_broker is not None:
@@ -85,3 +96,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)
diff --git a/backend/migrations/manual/006_seed_feedback_templates.sql b/backend/migrations/manual/006_seed_feedback_templates.sql
new file mode 100644
index 0000000..bde0f64
--- /dev/null
+++ b/backend/migrations/manual/006_seed_feedback_templates.sql
@@ -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;
diff --git a/backend/notifications/app.py b/backend/notifications/app.py
index f26159c..c4be2d8 100644
--- a/backend/notifications/app.py
+++ b/backend/notifications/app.py
@@ -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))
diff --git a/backend/notifications/models.py b/backend/notifications/models.py
index ceca0c3..9b66469 100644
--- a/backend/notifications/models.py
+++ b/backend/notifications/models.py
@@ -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
diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py
index 62f4ee0..9eaa5c4 100644
--- a/backend/notifications/serializers.py
+++ b/backend/notifications/serializers.py
@@ -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,
diff --git a/backend/notifications/views.py b/backend/notifications/views.py
index 8e6521a..eef3bf6 100644
--- a/backend/notifications/views.py
+++ b/backend/notifications/views.py
@@ -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}
diff --git a/backend/org_settings/app.py b/backend/org_settings/app.py
new file mode 100644
index 0000000..18060f3
--- /dev/null
+++ b/backend/org_settings/app.py
@@ -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))
diff --git a/backend/org_settings/models.py b/backend/org_settings/models.py
new file mode 100644
index 0000000..7a729ed
--- /dev/null
+++ b/backend/org_settings/models.py
@@ -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
diff --git a/backend/org_settings/serializers.py b/backend/org_settings/serializers.py
new file mode 100644
index 0000000..c6fdb95
--- /dev/null
+++ b/backend/org_settings/serializers.py
@@ -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,
+ }
diff --git a/backend/org_settings/views.py b/backend/org_settings/views.py
new file mode 100644
index 0000000..b287aaa
--- /dev/null
+++ b/backend/org_settings/views.py
@@ -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]
diff --git a/backend/role/app.py b/backend/role/app.py
index bd4875d..e057734 100644
--- a/backend/role/app.py
+++ b/backend/role/app.py
@@ -4,7 +4,7 @@ from fastapi import HTTPException
from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
-from role.views import Role
+from role.views import Role,PermissionBundle
from users.permissions import PermissionTag, require_permission
from dotenv import load_dotenv
load_dotenv()
@@ -39,6 +39,14 @@ class PermissionUpdate(BaseModel):
permission_tags: list[int] | None = None
is_active: bool | None = None
+class RolePermissionTagsUpdate(BaseModel):
+ """`id` is a permissions.id from /permissions/fetch; permission_tags is the exact set."""
+ id: int
+ name: str | None = None
+ description: str | None = None
+ permission_tags: list[int]
+ is_active: bool | None = None
+
@router.get("/roles/fetch")
async def fetch_roles(
@@ -134,7 +142,6 @@ async def fetch_permissions(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
-
@router.post("/permissions/create")
async def create_permission(
payload: PermissionCreate,
@@ -167,6 +174,20 @@ async def update_permission(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
+@router.put("/roles/permission-tags/update")
+async def update_role_permission_tags(
+ payload: RolePermissionTagsUpdate,
+ current_user: dict = Depends(require_permission(PermissionTag.RBAC_USERS_MANAGE)),
+ session: AsyncSession = Depends(get_session),
+):
+ try:
+ service=PermissionBundle(session=session)
+ data=await service.set_role_tags(payload.model_dump())
+ 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("/permission-tags/fetch")
async def fetch_permission_tags(
diff --git a/backend/role/views.py b/backend/role/views.py
index a53a395..9109768 100644
--- a/backend/role/views.py
+++ b/backend/role/views.py
@@ -159,3 +159,43 @@ class Role:
async def count_permission_tags(self, search=None):
return await PermissionTags.count_permission_tags(self.session, search)
+
+
+class PermissionBundle:
+ """Sets the exact permission-tag set on one bundle, for the permission matrix.
+
+ `id` is a permissions.id from /permissions/fetch. Every role holding that bundle
+ picks the change up on its next request, because Roles.resolve_tags runs live.
+ """
+
+ def __init__(self,session:AsyncSession):
+ self.session=session
+
+ async def set_role_tags(self,payload):
+ bundle=await Permissions.get_permission_by_id(self.session,int(payload.get("id")))
+ if not bundle or bundle.is_deleted:
+ raise HTTPException(status_code=404,detail="Permission bundle not found")
+ tag_ids=sorted(set(payload.get("permission_tags") or []))
+ # an unknown or inactive id resolves to nothing, so the box would silently
+ # refuse to stay ticked. reject instead of granting less than was asked.
+ found=await PermissionTags.get_permission_tags_by_ids(self.session,tag_ids)
+ unknown=sorted(set(tag_ids)-{t.id for t in found})
+ if unknown:
+ raise HTTPException(status_code=422,detail=f"Unknown or inactive permission tag ids: {unknown}")
+ # build fields explicitly: update_permission setattr's whatever it is given, so
+ # passing the payload through would write name=None and hit the NOT NULL.
+ fields={"permission_tags":tag_ids}
+ if payload.get("name") is not None:
+ name=payload["name"].strip()
+ if bundle.is_system and name!=bundle.name:
+ raise HTTPException(status_code=409,detail="System permission bundles cannot be renamed")
+ clash=await Permissions.get_permission_by_name(self.session,name)
+ if clash and clash.id!=bundle.id:
+ raise HTTPException(status_code=409,detail="Permission bundle name already exists")
+ fields["name"]=name
+ if payload.get("description") is not None:
+ fields["description"]=payload["description"]
+ if payload.get("is_active") is not None:
+ fields["is_active"]=payload["is_active"]
+ await Permissions.update_permission(self.session,int(bundle.id),fields)
+ return await Role(session=self.session).get_permission_by_id(int(bundle.id))
diff --git a/backend/saved_search/app.py b/backend/saved_search/app.py
new file mode 100644
index 0000000..26945da
--- /dev/null
+++ b/backend/saved_search/app.py
@@ -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))
diff --git a/backend/saved_search/models.py b/backend/saved_search/models.py
new file mode 100644
index 0000000..645075e
--- /dev/null
+++ b/backend/saved_search/models.py
@@ -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
diff --git a/backend/saved_search/serializers.py b/backend/saved_search/serializers.py
new file mode 100644
index 0000000..87f80da
--- /dev/null
+++ b/backend/saved_search/serializers.py
@@ -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,
+ }
diff --git a/backend/saved_search/views.py b/backend/saved_search/views.py
new file mode 100644
index 0000000..a9a46ff
--- /dev/null
+++ b/backend/saved_search/views.py
@@ -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}
diff --git a/backend/search/app.py b/backend/search/app.py
new file mode 100644
index 0000000..be04117
--- /dev/null
+++ b/backend/search/app.py
@@ -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))
diff --git a/backend/search/serializers.py b/backend/search/serializers.py
new file mode 100644
index 0000000..a9b68b2
--- /dev/null
+++ b/backend/search/serializers.py
@@ -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,
+ }
diff --git a/backend/search/views.py b/backend/search/views.py
new file mode 100644
index 0000000..008a7a6
--- /dev/null
+++ b/backend/search/views.py
@@ -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()]
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..d7c8d2e
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,40 @@
+"""Fixtures for the backend suite.
+
+The backend runs *from* `backend/` and has no __init__.py anywhere, so its modules are
+top-level imports (`import inbox_classifier.prompt`). pytest is invoked from the repo
+root, so `backend/` has to go on sys.path here — the root suite (tests/) imports the
+installed `app` package instead and needs no such help.
+
+No live API calls anywhere: the adapter is exercised against a fake `responses`
+resource, exactly as tests/unit/test_llm.py does.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+
+_BACKEND = Path(__file__).resolve().parent.parent
+if str(_BACKEND) not in sys.path:
+ # APPEND, never insert(0): backend/ contains a `tests` directory of its own, so
+ # putting it first would shadow the root `tests` package and break the root
+ # suite's `from tests.conftest import ...` imports.
+ sys.path.append(str(_BACKEND))
+
+
+@pytest.fixture(autouse=True)
+def _hermetic_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
+ """Keep the suite hermetic.
+
+ A real key must never leak in from the environment, and a developer's local
+ OPENAI_MODEL or INBOX_TRIAGE_* values must not change what the tests assert.
+ """
+ for name in list(os.environ):
+ upper = name.upper()
+ if upper.startswith(("OPENAI_", "ANTHROPIC_", "INBOX_TRIAGE_", "SCORING_", "MAX_")):
+ monkeypatch.delenv(name, raising=False)
+ yield
diff --git a/backend/users/app.py b/backend/users/app.py
index 843c4de..11dd187 100644
--- a/backend/users/app.py
+++ b/backend/users/app.py
@@ -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))
diff --git a/backend/users/views.py b/backend/users/views.py
index 4ce0f0c..0a469ea 100644
--- a/backend/users/views.py
+++ b/backend/users/views.py
@@ -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)
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..9286ca9
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,60 @@
+# Live-code overlay. Mounts the source folders into the running containers, so what
+# executes is what is on disk on the host — edit, save, uvicorn reloads.
+#
+# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
+#
+# The CV attachments mount from the base file still applies: compose merges volumes
+# by target path, and /app/inbox/decoded_attachments is nested under the /app mount,
+# so the daemon mounts the parent first and the attachments folder on top.
+#
+# Two mounts per Python service, not one: /app is the backend tree and /app/app is the
+# bulk-ats engine that backend/job/candidate imports. Mounting only ./backend over
+# /app would hide the engine baked into the image and every worker would fail on
+# import.
+#
+# The frontend stays as the built nginx image — a Vite dev server wants node_modules
+# on the mount, which is slow and fragile across a Windows bind mount. Run
+# `npm run dev` on the host for the frontend loop.
+
+services:
+ backend-api:
+ command:
+ ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+ volumes:
+ - ./backend:/app
+ - ./app:/app/app
+
+ ats-engine:
+ command:
+ [
+ "uvicorn",
+ "app.main:create_app",
+ "--factory",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "8100",
+ "--reload",
+ ]
+ volumes:
+ - ./app:/srv/app
+
+ taskiq-worker:
+ volumes:
+ - ./backend:/app
+ - ./app:/app/app
+
+ taskiq-scheduler:
+ volumes:
+ - ./backend:/app
+ - ./app:/app/app
+
+ taskiq-cv-worker:
+ volumes:
+ - ./backend:/app
+ - ./app:/app/app
+
+ taskiq-cv-scheduler:
+ volumes:
+ - ./backend:/app
+ - ./app:/app/app
diff --git a/docker-compose.yml b/docker-compose.yml
index e29ec44..bcb6da6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,82 @@
+# HR-ATS-Portal — every service and every image, in one file.
+#
+# docker compose build # hrms-backend / hrms-ats-engine / hrms-frontend
+# docker compose up -d
+# docker compose ps
+# docker compose logs -f backend-api
+#
+# ── Postgres ──────────────────────────────────────────────────────────────────────
+# The `postgres` service at the bottom is behind a compose PROFILE, so it is defined
+# and buildable here but never starts with a plain `docker compose up`. The stack
+# talks to the PostgreSQL server already running on the host, via
+# DB_HOST=host.docker.internal (backend/.env says localhost — correct for a host
+# process, wrong inside a container).
+#
+# Host Postgres must accept connections from the Docker bridge: listen_addresses = '*'
+# in postgresql.conf and a pg_hba.conf line for 172.16.0.0/12 (or the specific subnet).
+#
+# To build/run the containerised database instead, see the comments on that service.
+#
+# ── Ports ─────────────────────────────────────────────────────────────────────────
+# Stop a host `uvicorn` (8000) and `npm run dev` (5173) before starting these, or
+# override with BACKEND_PORT / FRONTEND_PORT / ATS_PORT. Windows lets a host process
+# bind 127.0.0.1:8000 while Docker binds 0.0.0.0:8000, and `localhost` resolves to ::1
+# first — both listen, and requests reach whichever won.
+#
+# ── Overlay ───────────────────────────────────────────────────────────────────────
+# docker-compose.dev.yml adds live source mounts and --reload on top of this file. It
+# defines no services or images of its own; it only overrides the ones here:
+# docker compose -f docker-compose.yml -f docker-compose.dev.yml up
+
+x-backend-build: &backend-build
+ # Root context, not ./backend: backend/job/candidate imports the bulk-ats engine
+ # from app/, which sits outside the backend folder. See backend/Dockerfile.
+ context: .
+ dockerfile: backend/Dockerfile
+
+x-backend-env: &backend-env
+ PYTHONPATH: /app
+ # backend/.env is written for host processes; these are the values a container needs.
+ #
+ # DB_HOST defaults to the LOCAL Postgres server on the host. Set DB_HOST=postgres in
+ # the shell (or a root .env) to point the whole stack at the container below instead
+ # — that is the only value that has to change, since services reach it over the
+ # compose network on 5432, not the published host port.
+ DB_HOST: ${DB_HOST:-host.docker.internal}
+ REDIS_URL: redis://redis:6379/0
+ EMAIL_URL: http://host.docker.internal:5000
+ BACKEND_URL: http://backend-api:8000
+
+# The one shared folder. Every process that decodes, scores or serves a CV reads and
+# writes the same host directory, so a file written by the API is the same file the
+# worker opens. Absolute paths stored in the DB match across services because the
+# mount target is identical everywhere; inbox.plugins.resolve_attachment_path also
+# falls back to basename-under-this-directory for rows written by a host process.
+x-attachments: &attachments
+ - ${ATTACHMENTS_DIR:-./backend/inbox/decoded_attachments}:/app/inbox/decoded_attachments
+
+x-backend-service: &backend-service
+ build: *backend-build
+ image: hrms-backend:local
+ working_dir: /app
+ env_file:
+ - ./backend/.env
+ environment: *backend-env
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ depends_on:
+ redis:
+ condition: service_healthy
+ # required:false — the default stack uses the host's Postgres and never starts
+ # this one, and that must not be an error. When the profile IS active, startup
+ # waits for it to pass pg_isready.
+ postgres:
+ condition: service_healthy
+ required: false
+ restart: unless-stopped
+
services:
+ # --- Redis (broker + result backend for taskiq) ----------------------------------
redis:
image: redis:7-alpine
container_name: hrms-redis
@@ -14,11 +92,80 @@ services:
retries: 5
restart: unless-stopped
- taskiq-worker:
+ # --- portal API ------------------------------------------------------------------
+ backend-api:
+ <<: *backend-service
+ container_name: hrms-backend-api
+ command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+ ports:
+ - "${BACKEND_PORT:-8000}:8000"
+ volumes: *attachments
+ healthcheck:
+ # python:slim has no curl and the API exposes no /health route, so this is a
+ # plain TCP check against the uvicorn socket.
+ test:
+ [
+ "CMD",
+ "python",
+ "-c",
+ "import socket;socket.create_connection(('127.0.0.1',8000),3).close()",
+ ]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 40s
+
+ # --- bulk ATS scoring engine (standalone service form of app/) --------------------
+ ats-engine:
build:
- context: ./backend
+ context: .
+ dockerfile: app/Dockerfile
+ image: hrms-ats-engine:local
+ container_name: hrms-ats-engine
+ env_file:
+ # OPENAI_API_KEY currently lives in backend/.env; a root .env (see .env.example)
+ # overrides it when present, and the stack still comes up when it is not.
+ - ./backend/.env
+ - path: ./.env
+ required: false
+ ports:
+ - "${ATS_PORT:-8100}:8100"
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "python",
+ "-c",
+ "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8100/api/v1/health',timeout=3)",
+ ]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 20s
+ restart: unless-stopped
+
+ # --- React portal -----------------------------------------------------------------
+ frontend:
+ build:
+ context: ./frontend
+ args:
+ # Baked into the bundle at build time — change it and rebuild, not restart.
+ VITE_API_BASE: ${VITE_API_BASE:-http://localhost:8000}
+ image: hrms-frontend:local
+ container_name: hrms-frontend
+ ports:
+ - "${FRONTEND_PORT:-5173}:80"
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ # --- background processing (same image as backend-api, different command) ---------
+ taskiq-worker:
+ <<: *backend-service
container_name: hrms-taskiq-worker
- working_dir: /app
command:
[
"taskiq",
@@ -30,53 +177,29 @@ services:
"--workers",
"1",
]
- env_file:
- - ./backend/.env
environment:
- PYTHONPATH: /app
- REDIS_URL: redis://redis:6379/0
+ <<: *backend-env
TASKIQ_QUEUE_NAME: inbox
TASKIQ_WORKER_NAME: worker-01
- # .env uses localhost for the host-side API; containers must reach the host.
- DB_HOST: host.docker.internal
- EMAIL_URL: http://host.docker.internal:5000
- BACKEND_URL: http://host.docker.internal:8000
- extra_hosts:
- - "host.docker.internal:host-gateway"
- volumes:
- - ./backend/inbox/decoded_attachments:/app/inbox/decoded_attachments
- depends_on:
- redis:
- condition: service_healthy
- restart: unless-stopped
+ volumes: *attachments
taskiq-scheduler:
- build:
- context: ./backend
+ <<: *backend-service
container_name: hrms-taskiq-scheduler
- working_dir: /app
- command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler", "inbox.sync_tasks"]
- env_file:
- - ./backend/.env
+ command:
+ [
+ "taskiq",
+ "scheduler",
+ "taskiq_management.broker_setup:scheduler",
+ "inbox.sync_tasks",
+ ]
environment:
- PYTHONPATH: /app
- REDIS_URL: redis://redis:6379/0
+ <<: *backend-env
TASKIQ_QUEUE_NAME: inbox
- DB_HOST: host.docker.internal
- EMAIL_URL: http://host.docker.internal:5000
- BACKEND_URL: http://host.docker.internal:8000
- extra_hosts:
- - "host.docker.internal:host-gateway"
- depends_on:
- redis:
- condition: service_healthy
- restart: unless-stopped
taskiq-cv-worker:
- build:
- context: ./backend
+ <<: *backend-service
container_name: hrms-taskiq-cv-worker
- working_dir: /app
command:
[
"taskiq",
@@ -86,30 +209,15 @@ services:
"--workers",
"1",
]
- env_file:
- - ./backend/.env
environment:
- PYTHONPATH: /app
- REDIS_URL: redis://redis:6379/0
+ <<: *backend-env
TASKIQ_CV_QUEUE_NAME: cv_upload
TASKIQ_WORKER_NAME: cv-worker-01
- DB_HOST: host.docker.internal
- EMAIL_URL: http://host.docker.internal:5000
- BACKEND_URL: http://host.docker.internal:8000
- extra_hosts:
- - "host.docker.internal:host-gateway"
- volumes:
- - ./backend/inbox/decoded_attachments:/app/inbox/decoded_attachments
- depends_on:
- redis:
- condition: service_healthy
- restart: unless-stopped
+ volumes: *attachments
taskiq-cv-scheduler:
- build:
- context: ./backend
+ <<: *backend-service
container_name: hrms-taskiq-cv-scheduler
- working_dir: /app
command:
[
"taskiq",
@@ -117,21 +225,49 @@ services:
"taskiq_management.cv_broker_setup:cv_scheduler",
"inbox.cv_tasks",
]
- env_file:
- - ./backend/.env
environment:
- PYTHONPATH: /app
- REDIS_URL: redis://redis:6379/0
+ <<: *backend-env
TASKIQ_CV_QUEUE_NAME: cv_upload
- DB_HOST: host.docker.internal
- EMAIL_URL: http://host.docker.internal:5000
- BACKEND_URL: http://host.docker.internal:8000
- extra_hosts:
- - "host.docker.internal:host-gateway"
- depends_on:
- redis:
- condition: service_healthy
+
+ # --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT -------------------------------
+ # The profile is what keeps it out of `docker compose build` and `docker compose up`.
+ # Nothing about the default stack changes by its presence in this file.
+ #
+ # docker compose --profile postgres build postgres # build the image
+ # docker compose --profile postgres up -d postgres # run it, host port 5433
+ #
+ # Pointing the app at it is a separate, deliberate step — set DB_HOST=postgres (see
+ # x-backend-env) and recreate the services. The volume starts empty, so Alembic
+ # rebuilds the schema on first boot; it does not share the host server's data.
+ postgres:
+ profiles: ["postgres"]
+ build:
+ context: ./docker/postgres
+ image: hrms-postgres:local
+ container_name: hrms-postgres
+ environment:
+ # Interpolated from the shell or a root .env, NOT from backend/.env — compose
+ # variable substitution and container environment are different things.
+ # Defaults match backend/.env.example.
+ POSTGRES_USER: ${DB_USERNAME:-postgres}
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
+ POSTGRES_DB: ${DB_NAME:-hrms}
+ POSTGRES_INITDB_ARGS: "--encoding=UTF8"
+ ports:
+ # 5433: the host's own Postgres server owns 5432. Only for host-side tools —
+ # containers reach this one on 5432 over the compose network.
+ - "${POSTGRES_PORT:-5433}:5432"
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test:
+ ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-hrms}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 20s
restart: unless-stopped
volumes:
redis-data:
+ postgres-data:
diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile
new file mode 100644
index 0000000..ac681d2
--- /dev/null
+++ b/docker/postgres/Dockerfile
@@ -0,0 +1,24 @@
+# syntax=docker/dockerfile:1
+#
+# Postgres image — BUILT, BUT NOT USED BY THE DEFAULT STACK.
+#
+# Every service in docker-compose.yml points at the Postgres already running on the
+# host (DB_HOST=host.docker.internal). This image exists so the database can be
+# containerised on demand — a clean machine, a throwaway test run, a second dev — and
+# it is kept in its own file (docker-compose.postgres.yml) so bringing it up is always
+# a deliberate act:
+#
+# docker compose -f docker-compose.yml -f docker-compose.postgres.yml build postgres
+# docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d postgres
+#
+# Context is ./docker/postgres.
+
+FROM postgres:16-alpine
+
+# The inbox tables store tz-aware timestamps and the app reads them as UTC
+# (backend/migrations/versions/20260812_1035-b3f1c2d4e5a6_inbox_timestamps_tz_aware.py).
+ENV TZ=UTC \
+ PGTZ=UTC
+
+# Runs once, against an empty data volume only.
+COPY initdb/ /docker-entrypoint-initdb.d/
diff --git a/docker/postgres/initdb/01-init.sql b/docker/postgres/initdb/01-init.sql
new file mode 100644
index 0000000..4b98944
--- /dev/null
+++ b/docker/postgres/initdb/01-init.sql
@@ -0,0 +1,17 @@
+-- Runs once, on first initialisation of an empty data volume.
+
+-- Timestamps are stored tz-aware and read back as UTC by the backend.
+ALTER SYSTEM SET timezone TO 'UTC';
+ALTER SYSTEM SET log_timezone TO 'UTC';
+
+-- Known migration gap in the inbox module: Alembic autogeneration creates every
+-- table on first boot, but not this enum type, and a brand-new database fails
+-- without it (README "Fresh database — one manual step").
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_application_status') THEN
+ CREATE TYPE candidate_application_status AS ENUM
+ ('PROCESS','PENDING','APPROVED','REJECTED','ONHOLD','CLOSED');
+ END IF;
+END
+$$;
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..c7d81d3
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,9 @@
+node_modules/
+dist/
+.vite/
+tmp/
+.env
+.env.*
+!.env.development
+!.env.production
+*.log
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..f2a2e3a
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,30 @@
+# syntax=docker/dockerfile:1
+#
+# React portal: Vite build in node, served by nginx with the SPA history fallback.
+# Context is ./frontend.
+#
+# docker build -t hrms-frontend:local ./frontend
+
+FROM node:22-alpine AS build
+
+WORKDIR /src
+
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY . .
+
+# Vite inlines VITE_* at BUILD time, so the API origin is fixed when the image is
+# built, not when the container starts — rebuild the image to point it elsewhere.
+# `.env.production.local` outranks every other env file, so this wins over the empty
+# VITE_API_BASE in .env.production (which means "same origin, behind a proxy").
+ARG VITE_API_BASE=http://172.16.204.191:8000
+RUN printf 'VITE_API_BASE=%s\n' "$VITE_API_BASE" > .env.production.local \
+ && npm run build
+
+FROM nginx:1.27-alpine
+
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /src/dist /usr/share/nginx/html
+
+EXPOSE 80
diff --git a/frontend/dist/assets/index-CSn67wit.css b/frontend/dist/assets/index-CSn67wit.css
deleted file mode 100644
index b5574e7..0000000
--- a/frontend/dist/assets/index-CSn67wit.css
+++ /dev/null
@@ -1 +0,0 @@
-:root{--brand-green: #004d43;--brand-ink: #1a3134;--brand-lime: #ceff71;--brand-mint: #25e9a5;--brand-peri: #8e92ff;--brand-tint: #eafff4}:root{color-scheme:light;--bg: #f1f7f4;--bg-elev: #ffffff;--bg-sunken: #e8f2ec;--sidebar-bg: #1a3134;--sidebar-fg: #9fb8b4;--sidebar-fg-active: #ffffff;--sidebar-active-bg: rgba(206,255,113,.14);--sidebar-rail: #ceff71;--border: #dbe8e2;--border-strong: #c2d6ce;--text: #10231f;--text-2: #4a625c;--text-3: #54726c;--primary: #004d43;--primary-600: #00382f;--primary-fg: #ffffff;--primary-soft: #eafff4;--primary-border: #b4e3d2;--accent: #ceff71;--accent-fg: #1a3134;--accent-ink: #4f6619;--accent-soft: #f4ffdf;--success: #00734f;--success-fg: #ffffff;--success-soft: #d9f7ec;--warning: #8a5a00;--warning-fg: #ffffff;--warning-soft: #fff2d9;--danger: #b3243a;--danger-fg: #ffffff;--danger-soft: #ffe6ea;--info: #0d6580;--info-fg: #ffffff;--info-soft: #e4f4f9;--purple: #4b4fd6;--purple-fg: #ffffff;--purple-soft: #ecedff;--teal: #00734f;--teal-soft: #d9f7ec;--shadow-sm: 0 1px 2px rgba(10,35,31,.05);--shadow: 0 1px 3px rgba(10,35,31,.07), 0 1px 2px rgba(10,35,31,.04);--shadow-md: 0 4px 12px rgba(10,35,31,.08), 0 2px 4px rgba(10,35,31,.05);--shadow-lg: 0 12px 32px rgba(10,35,31,.12), 0 4px 8px rgba(10,35,31,.06);--radius-sm: 7px;--radius: 11px;--radius-lg: 16px;--radius-xl: 22px;--sidebar-w: 262px;--sidebar-w-collapsed: 74px;--topbar-h: 64px;--font: "Neue Montreal", "PP Neue Montreal", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--font-display: "Belleza", "Neue Montreal", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--mono: "SF Mono", ui-monospace, "Cascadia Code", Menlo, monospace;--c1: #004d43;--c2: #0f9d76;--c3: #5b60e8;--c4: #6f8f14;--c5: #0e7490;--c6: #8a5a00;--c7: #a8327d;--c8: #3f6d64;--avatar-fg: #ffffff;--av-1: #004d43;--av-2: #1a3134;--av-3: #0f5f4a;--av-4: #2f4858;--av-5: #4b4fd6;--av-6: #155e63;--av-7: #5b3f8f;--av-8: #8a2f4a;--stage-1: #0e7490;--stage-2: #5b60e8;--stage-3: #8a5a00;--stage-4: #004d43;--stage-5: #0f9d76;--stage-6: #6f8f14;--stage-7: #b3243a;--ring: 0 0 0 3px rgba(0,77,67,.2);--chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2354726c' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")}[data-theme=dark]{color-scheme:dark;--bg: #0e1d1f;--bg-elev: #16292c;--bg-sunken: #1a3134;--sidebar-bg: #0a1618;--sidebar-fg: #8fa9a4;--sidebar-fg-active: #ffffff;--sidebar-active-bg: rgba(206,255,113,.12);--sidebar-rail: #ceff71;--border: #24403f;--border-strong: #33534f;--text: #e6f2ec;--text-2: #a3bdb6;--text-3: #8fada6;--primary: #ceff71;--primary-600: #dcff96;--primary-fg: #0d2523;--primary-soft: rgba(206,255,113,.08);--primary-border: rgba(206,255,113,.32);--accent: #ceff71;--accent-fg: #12292b;--accent-ink: #ceff71;--accent-soft: rgba(206,255,113,.1);--success: #25e9a5;--success-fg: #06251b;--success-soft: rgba(37,233,165,.12);--warning: #f5c451;--warning-fg: #2a1e04;--warning-soft: rgba(245,196,81,.12);--danger: #ff7a8a;--danger-fg: #2d0a10;--danger-soft: rgba(255,122,138,.12);--info: #5fd3e8;--info-fg: #05242b;--info-soft: rgba(95,211,232,.12);--purple: #8e92ff;--purple-fg: #12133a;--purple-soft: rgba(142,146,255,.12);--teal: #25e9a5;--teal-soft: rgba(37,233,165,.12);--shadow-sm: 0 1px 2px rgba(0,0,0,.45);--shadow: 0 1px 3px rgba(0,0,0,.55);--shadow-md: 0 4px 14px rgba(0,0,0,.6);--shadow-lg: 0 14px 40px rgba(0,0,0,.65);--c1: #ceff71;--c2: #25e9a5;--c3: #8e92ff;--c4: #a8e063;--c5: #5fd3e8;--c6: #f5c451;--c7: #ff9ec4;--c8: #7fb3aa;--avatar-fg: #0d2523;--av-1: #ceff71;--av-2: #25e9a5;--av-3: #8e92ff;--av-4: #a8e063;--av-5: #5fd3e8;--av-6: #f5c451;--av-7: #ff9ec4;--av-8: #7fb3aa;--stage-1: #5fd3e8;--stage-2: #8e92ff;--stage-3: #f5c451;--stage-4: #ceff71;--stage-5: #25e9a5;--stage-6: #a8e063;--stage-7: #ff7a8a;--ring: 0 0 0 3px rgba(206,255,113,.28);--chev-url: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238fada6' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")}*{box-sizing:border-box;margin:0;padding:0}html{height:100%;-webkit-text-size-adjust:100%;text-size-adjust:100%}body{min-height:100%;font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;-webkit-tap-highlight-color:transparent;overscroll-behavior-y:none}svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit;touch-action:manipulation}input,select,textarea{font-family:inherit;font-size:14px;color:var(--text)}a{color:inherit;text-decoration:none;-webkit-tap-highlight-color:transparent}img,svg,video,canvas{max-width:100%}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:20px;border:2px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:hover{background:var(--text-3);background-clip:padding-box}::selection{background:var(--brand-lime);color:var(--brand-ink)}@supports (scrollbar-color: auto){*{scrollbar-color:var(--border-strong) transparent;scrollbar-width:thin}}@media(hover:none){.btn:active,.icon-btn:active,.nav-item:active,.act-btn:active,.page-btn:active,.dropdown-link:active,.prompt-chip:active,.search-item:active,.k-card:active,.platform-card:active,.role-item:active,.inbox-item:active,.tab:active,.pill-tab:active{opacity:.68;transition:opacity .05s}}@media(pointer:coarse){input,select,textarea,.form-field input,.form-field select,.form-field textarea,.toolbar-search input,.topbar-search input,.chat-input-bar textarea,.select{font-size:16px}}.page-title,.modal-head h2,.brand-name,.ph-name,.ai-hero h2,.empty-state h3{font-family:var(--font-display);font-weight:400;letter-spacing:0}.page-title{font-size:30px;line-height:1.15}.modal-head h2{font-size:22px}.ph-name{font-size:25px}.brand-name{font-size:18px;font-weight:400;letter-spacing:.2px}.kpi-value,.stat-mini-val,.ats-ring .ats-num,.cell-mono,.mono,table.data td,.k-count,.nav-badge{font-variant-numeric:tabular-nums}:where(a,button,input,select,textarea,[tabindex]):focus-visible{outline:2px solid var(--primary);outline-offset:2px;border-radius:var(--radius-sm)}:focus:not(:focus-visible){outline:none}.sidebar :where(a,button):focus-visible{outline-color:var(--brand-lime)}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}#app{display:flex;min-height:100vh;min-height:100dvh}.sidebar{width:var(--sidebar-w);background:var(--sidebar-bg);color:var(--sidebar-fg);display:flex;flex-direction:column;position:sticky;top:0;height:100vh;height:100dvh;flex-shrink:0;transition:width .22s cubic-bezier(.4,0,.2,1);z-index:60}.sidebar-brand{display:flex;align-items:center;gap:12px;padding:18px 20px;height:var(--topbar-h);border-bottom:1px solid rgba(255,255,255,.06);position:relative}.brand-logo{width:38px;height:38px;border-radius:10px;flex-shrink:0;background:var(--brand-green);display:grid;place-items:center;color:var(--brand-lime);box-shadow:0 4px 12px #00000047}.brand-mark{width:22px;height:auto;fill:currentColor;stroke:none}.brand-text{display:flex;flex-direction:column;line-height:1.1;overflow:hidden}.brand-name{color:#fff}.brand-sub{color:var(--sidebar-fg);font-size:11px;letter-spacing:.3px}.sidebar-collapse-btn{margin-left:auto;color:var(--sidebar-fg);width:26px;height:26px;border-radius:6px;display:grid;place-items:center;transition:.15s}.sidebar-collapse-btn:hover{background:#ffffff14;color:#fff}.sidebar-collapse-btn svg{width:18px;height:18px}.sidebar-nav{flex:1;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;padding:14px 12px}.sidebar-nav::-webkit-scrollbar{width:6px}.sidebar-nav::-webkit-scrollbar-thumb{background:#ffffff1a}.nav-section-label{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.9px;color:var(--text-3);padding:14px 12px 6px}.nav-item{display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:9px;margin-bottom:2px;color:var(--sidebar-fg);font-weight:500;font-size:13.5px;transition:background .14s,color .14s;position:relative;white-space:nowrap}.nav-item svg{width:19px;height:19px;flex-shrink:0;stroke-width:1.9}.nav-item:hover{background:#ffffff0d;color:#fff}.nav-item.active{background:var(--sidebar-active-bg);color:#fff}.nav-item.active:before{content:"";position:absolute;left:-12px;top:50%;transform:translateY(-50%);width:3px;height:20px;background:var(--sidebar-rail);border-radius:0 3px 3px 0}.nav-badge{margin-left:auto;background:#ffffff1f;color:#fff;font-size:11px;font-weight:600;padding:1px 8px;border-radius:20px;min-width:22px;text-align:center}.nav-badge-alert{background:var(--danger);color:var(--danger-fg)}.nav-badge-ai{background:var(--brand-lime);color:var(--brand-ink);font-weight:700;letter-spacing:.3px}.sidebar .nav-section-label,.sidebar .btn-ghost{color:var(--sidebar-fg)}.sidebar .btn-ghost:hover{background:#ffffff1a;color:#fff}.sidebar .btn-secondary{background:#ffffff0f;color:#fff;border-color:#ffffff24}.sidebar-footer{padding:14px;border-top:1px solid rgba(255,255,255,.06)}.usage-card{background:#ffffff0a;border:1px solid rgba(255,255,255,.07);border-radius:12px;padding:14px}.usage-top{display:flex;justify-content:space-between;font-size:12px;color:var(--sidebar-fg);margin-bottom:8px}.usage-top span:last-child{color:#fff;font-weight:600}.usage-bar{height:6px;background:#ffffff1a;border-radius:20px;overflow:hidden;margin-bottom:12px}.usage-fill{height:100%;background:linear-gradient(90deg,var(--brand-mint),var(--brand-lime));border-radius:20px}.sidebar.collapsed{width:var(--sidebar-w-collapsed)}.sidebar.collapsed .brand-text,.sidebar.collapsed .nav-item span:not(.nav-badge),.sidebar.collapsed .nav-section-label,.sidebar.collapsed .sidebar-footer,.sidebar.collapsed .nav-badge{display:none}.sidebar.collapsed .sidebar-collapse-btn{transform:rotate(180deg);position:absolute;right:8px}.sidebar.collapsed .nav-item{justify-content:center;padding:10px}.sidebar.collapsed .sidebar-brand{justify-content:center;padding:18px 0}.main-wrap{flex:1;display:flex;flex-direction:column;min-width:0}.topbar{height:var(--topbar-h);background:var(--bg-elev);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:16px;padding:0 22px;position:sticky;top:0;z-index:50}.menu-toggle{display:none}.topbar-search{position:relative;flex:1;max-width:480px;display:flex;align-items:center}.topbar-search>svg{position:absolute;left:14px;width:18px;height:18px;color:var(--text-3);pointer-events:none}.topbar-search input{width:100%;padding:9px 14px 9px 42px;border-radius:10px;background:var(--bg-sunken);border:1px solid transparent;outline:none;transition:.15s}.topbar-search input:focus{background:var(--bg-elev);border-color:var(--primary);box-shadow:var(--ring)}.search-kbd{position:absolute;right:12px;font-family:var(--mono);font-size:11px;color:var(--text-3);background:var(--bg-elev);border:1px solid var(--border);padding:2px 6px;border-radius:5px;pointer-events:none}.search-results{position:absolute;top:calc(100% + 8px);left:0;right:0;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow-lg);max-height:420px;overflow-y:auto;display:none;z-index:80;padding:6px}.search-results.open{display:block}.search-group-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text-3);padding:8px 10px 4px}.search-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer}.search-item:hover{background:var(--bg-sunken)}.search-item .si-title{font-weight:600;font-size:13px}.search-item .si-sub{font-size:12px;color:var(--text-3)}.search-empty{padding:24px;text-align:center;color:var(--text-3);font-size:13px}.topbar-actions{display:flex;align-items:center;gap:6px;margin-left:auto}.topbar-divider{width:1px;height:30px;background:var(--border);margin:0 6px}.icon-btn{position:relative;width:40px;height:40px;border-radius:10px;display:grid;place-items:center;color:var(--text-2);transition:.15s}.icon-btn:hover{background:var(--bg-sunken);color:var(--text)}.icon-btn svg{width:20px;height:20px}.icon-sun{display:none}[data-theme=dark] .icon-sun{display:block}[data-theme=dark] .icon-moon{display:none}.dot{position:absolute;top:9px;right:10px;width:8px;height:8px;border-radius:50%;border:2px solid var(--bg-elev)}.dot-red{background:var(--danger)}.dot-blue{background:var(--info)}.avatar{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;font-weight:600;font-size:13px;color:var(--avatar-fg);flex-shrink:0}.avatar-grad{background:linear-gradient(135deg,var(--brand-green),var(--brand-mint));color:#fff}.avatar-lg{width:44px;height:44px;font-size:15px}.profile-btn{display:flex;align-items:center;gap:10px;padding:5px 8px 5px 5px;border-radius:30px;transition:.15s}.profile-btn:hover{background:var(--bg-sunken)}.profile-meta{display:flex;flex-direction:column;line-height:1.2;text-align:left}.profile-name{font-weight:600;font-size:13px}.profile-role{font-size:11.5px;color:var(--text-3)}.chev{width:16px;height:16px;color:var(--text-3)}.dropdown{position:relative}.dropdown-menu{position:absolute;top:calc(100% + 10px);right:0;min-width:230px;background:var(--bg-elev);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow-lg);padding:8px;opacity:0;visibility:hidden;transform:translateY(-6px);transition:.16s;z-index:90}.dropdown.open .dropdown-menu{opacity:1;visibility:visible;transform:translateY(0)}.dropdown-menu-wide{min-width:340px;padding:0}.dropdown-head{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;font-weight:700;font-size:14px;border-bottom:1px solid var(--border)}.dropdown-foot{padding:10px 16px;border-top:1px solid var(--border);text-align:center}.dropdown-foot a{color:var(--primary);font-weight:600;font-size:13px}.dropdown-profile{display:flex;gap:12px;align-items:center;padding:14px}.dp-name{font-weight:600}.dp-email{font-size:12px;color:var(--text-3)}.dropdown-divider{height:1px;background:var(--border);margin:6px 0}.dropdown-link{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;font-size:13.5px;font-weight:500;width:100%;text-align:left;color:var(--text)}.dropdown-link svg{width:17px;height:17px;color:var(--text-3)}.dropdown-link:hover{background:var(--bg-sunken)}.dropdown-link.danger{color:var(--danger)}.dropdown-link.danger svg{color:var(--danger)}.link-btn{color:var(--primary);font-size:12px;font-weight:600}.notif-row{display:flex;gap:12px;padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:.12s}.notif-row:hover{background:var(--bg-sunken)}.notif-row.unread,[data-theme=dark] .notif-row.unread{background:var(--primary-soft)}.notif-icn{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;flex-shrink:0}.notif-icn svg{width:16px;height:16px}.notif-body{flex:1;min-width:0}.notif-title{font-size:13px;font-weight:600}.notif-text{font-size:12.5px;color:var(--text-2)}.notif-time{font-size:11px;color:var(--text-3);margin-top:3px}.dd-scroll{max-height:360px;overflow-y:auto;overscroll-behavior:contain}.content{flex:1;overflow-y:auto;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;padding:26px 30px 60px}.page{animation:fadeUp .3s ease}@keyframes fadeUp{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:24px;flex-wrap:wrap}.page-sub{color:var(--text-2);font-size:14px;margin-top:3px}.page-head-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.breadcrumb{display:flex;gap:8px;align-items:center;font-size:12.5px;color:var(--text-3);margin-bottom:10px}.breadcrumb svg{width:14px;height:14px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:9px 16px;border-radius:10px;font-weight:600;font-size:13.5px;transition:.15s;white-space:nowrap;border:1px solid transparent}.btn svg{width:17px;height:17px}.btn-primary{background:var(--primary);color:var(--primary-fg);box-shadow:var(--shadow-sm)}.btn-primary:hover{background:var(--primary-600)}.btn-secondary{background:var(--bg-elev);color:var(--text);border-color:var(--border-strong);box-shadow:var(--shadow-sm)}.btn-secondary:hover{background:var(--bg-sunken)}.btn-ghost{background:transparent;color:var(--text-2)}.btn-ghost:hover{background:var(--bg-sunken);color:var(--text)}.btn-danger{background:var(--danger);color:var(--danger-fg)}.btn-danger:hover{filter:brightness(.94)}.btn-block{width:100%;margin-top:12px}.btn-sm{padding:6px 12px;font-size:12.5px}.btn-icon{padding:8px;width:34px;height:34px}.btn:disabled{opacity:.5;cursor:not-allowed}.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm)}.card-pad{padding:20px}.card-head{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid var(--border);gap:12px}.card-head h3{font-size:15px;font-weight:700;letter-spacing:-.2px}.card-head .ch-sub{font-size:12.5px;color:var(--text-3);font-weight:400}.card-body{padding:20px}.grid{display:grid;gap:18px}.g-kpi{grid-template-columns:repeat(4,1fr)}.g-3{grid-template-columns:repeat(3,1fr)}.g-2{grid-template-columns:repeat(2,1fr)}.g-2-1{grid-template-columns:2fr 1fr}.g-1-2{grid-template-columns:1fr 2fr}.mt-18{margin-top:18px}.mb-18{margin-bottom:18px}.kpi{background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius-lg);padding:18px 20px;box-shadow:var(--shadow-sm);position:relative;overflow:hidden;transition:.18s}.kpi:hover{box-shadow:var(--shadow-md);transform:translateY(-2px)}.kpi-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.kpi-label{font-size:12.5px;color:var(--text-2);font-weight:500}.kpi-icn{width:40px;height:40px;border-radius:11px;display:grid;place-items:center}.kpi-icn svg{width:20px;height:20px}.kpi-value{font-size:28px;font-weight:700;letter-spacing:-1px;line-height:1}.kpi-foot{display:flex;align-items:center;gap:6px;margin-top:10px;font-size:12.5px}.trend{display:inline-flex;align-items:center;gap:3px;font-weight:600;padding:2px 7px;border-radius:6px;font-size:12px}.trend svg{width:13px;height:13px}.trend-up{color:var(--success);background:var(--success-soft)}.trend-down{color:var(--danger);background:var(--danger-soft)}.trend-flat{color:var(--text-2);background:var(--bg-sunken)}.kpi-foot-text{color:var(--text-3)}.i-indigo{background:var(--primary-soft);color:var(--primary)}.i-green{background:var(--success-soft);color:var(--success)}.i-amber{background:var(--warning-soft);color:var(--warning)}.i-red{background:var(--danger-soft);color:var(--danger)}.i-blue{background:var(--info-soft);color:var(--info)}.i-purple{background:var(--purple-soft);color:var(--purple)}.i-teal{background:var(--teal-soft);color:var(--teal)}.badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:600;white-space:nowrap}.badge:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.badge-plain:before{display:none}.b-green{color:var(--success);background:var(--success-soft)}.b-amber{color:var(--warning);background:var(--warning-soft)}.b-red{color:var(--danger);background:var(--danger-soft)}.b-blue{color:var(--info);background:var(--info-soft)}.b-purple{color:var(--purple);background:var(--purple-soft)}.b-teal{color:var(--teal);background:var(--teal-soft)}.b-gray{color:var(--text-2);background:var(--bg-sunken)}.b-indigo{color:var(--primary);background:var(--primary-soft)}.table-wrap{overflow-x:auto;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch}table.data{width:100%;border-collapse:collapse;font-size:13.5px}table.data thead th{text-align:left;padding:12px 16px;font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-3);border-bottom:1px solid var(--border);white-space:nowrap;background:var(--bg-elev);position:sticky;top:0}table.data thead th.sortable{cursor:pointer;-webkit-user-select:none;user-select:none}table.data thead th.sortable:hover{color:var(--text)}.sort-ind{display:inline-block;margin-left:4px;opacity:.4;font-size:10px}th.sorted-asc .sort-ind,th.sorted-desc .sort-ind{opacity:1;color:var(--primary)}table.data tbody td{padding:13px 16px;border-bottom:1px solid var(--border);vertical-align:middle}table.data tbody tr{transition:background .12s}table.data tbody tr:hover{background:var(--bg-sunken)}table.data tbody tr:last-child td{border-bottom:none}.cell-primary{font-weight:600;color:var(--text)}.cell-sub{font-size:12px;color:var(--text-3)}.cell-mono{font-family:var(--mono);font-size:12.5px;color:var(--text-2)}.user-cell{display:flex;align-items:center;gap:11px}.user-cell .avatar{width:34px;height:34px;font-size:12px}.row-actions{display:flex;gap:4px;justify-content:flex-end}.act-btn{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--text-3);transition:.12s}.act-btn:hover{background:var(--bg-sunken);color:var(--primary)}.act-btn.danger:hover{color:var(--danger);background:var(--danger-soft)}.act-btn svg{width:16px;height:16px}.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:16px}.toolbar-search{position:relative;flex:1;min-width:200px;max-width:340px}.toolbar-search svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);width:16px;height:16px;color:var(--text-3)}.toolbar-search input{width:100%;padding:8px 12px 8px 36px;border-radius:9px;background:var(--bg-elev);border:1px solid var(--border-strong);outline:none}.toolbar-search input:focus{border-color:var(--primary);box-shadow:var(--ring)}.toolbar .spacer{flex:1}.select{padding:8px 32px 8px 12px;border-radius:9px;background:var(--bg-elev) var(--chev-url) no-repeat right 10px center;background-size:15px;border:1px solid var(--border-strong);outline:none;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;font-weight:500}.select:focus{border-color:var(--primary);box-shadow:var(--ring)}.pagination{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-top:1px solid var(--border);flex-wrap:wrap;gap:12px}.page-info{font-size:13px;color:var(--text-2)}.page-controls{display:flex;gap:4px;align-items:center}.page-btn{min-width:34px;height:34px;padding:0 8px;border-radius:8px;display:grid;place-items:center;font-size:13px;font-weight:600;color:var(--text-2);border:1px solid transparent}.page-btn:hover:not(:disabled){background:var(--bg-sunken)}.page-btn.active{background:var(--primary);color:var(--primary-fg)}.page-btn:disabled{opacity:.4;cursor:not-allowed}.page-btn svg{width:16px;height:16px}.pbar{height:7px;background:var(--bg-sunken);border-radius:20px;overflow:hidden}.pbar-fill{height:100%;border-radius:20px;background:var(--primary);transition:width .5s ease}.pbar-fill.green{background:var(--success)}.pbar-fill.amber{background:var(--warning)}.pbar-fill.red{background:var(--danger)}.score{display:inline-flex;align-items:center;gap:6px;font-weight:700;font-size:13px}.score-ring{--pct: 0;position:relative;width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:conic-gradient(var(--sc-color) calc(var(--pct)*1%),var(--bg-sunken) 0)}.score-ring:after{content:"";position:absolute;top:4px;right:4px;bottom:4px;left:4px;border-radius:50%;background:var(--bg-elev)}.score-ring span{position:relative;z-index:1;font-size:10px;font-weight:700}.chart-wrap{position:relative;width:100%}canvas{width:100%;max-width:100%;display:block}.chart-legend{display:flex;flex-wrap:wrap;gap:14px;margin-top:14px;justify-content:center}.legend-item{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--text-2)}.legend-dot{width:10px;height:10px;border-radius:3px;flex-shrink:0}.chart-tooltip{position:fixed;background:var(--text);color:var(--bg-elev);padding:7px 11px;border-radius:8px;font-size:12px;font-weight:600;pointer-events:none;opacity:0;transition:opacity .12s;z-index:200;box-shadow:var(--shadow-lg);white-space:nowrap}[data-theme=dark] .chart-tooltip{background:var(--brand-lime);color:var(--brand-ink)}.modal-root{position:fixed;top:0;right:0;bottom:0;left:0;z-index:300;display:none}.modal-root.open{display:block}.modal-backdrop{position:absolute;top:0;right:0;bottom:0;left:0;background:#0a16188c;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);animation:fadeIn .2s}[data-theme=dark] .modal-backdrop{background:#000000a6}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.modal{position:relative;margin:5vh auto;max-width:640px;width:calc(100% - 40px);background:var(--bg-elev);border-radius:var(--radius-xl);box-shadow:var(--shadow-lg);animation:modalIn .25s cubic-bezier(.34,1.3,.64,1);max-height:90vh;display:flex;flex-direction:column}.modal-lg{max-width:860px}.modal-xl{max-width:1040px}@keyframes modalIn{0%{opacity:0;transform:translateY(24px) scale(.97)}to{opacity:1;transform:none}}.modal-head{display:flex;align-items:flex-start;justify-content:space-between;padding:22px 24px;border-bottom:1px solid var(--border)}.modal-head p{font-size:13px;color:var(--text-3);margin-top:3px}.modal-close{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;color:var(--text-3)}.modal-close:hover{background:var(--bg-sunken);color:var(--text)}.modal-body{padding:24px;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}.modal-foot{display:flex;justify-content:flex-end;gap:10px;padding:18px 24px;border-top:1px solid var(--border);background:var(--bg-sunken);border-radius:0 0 var(--radius-xl) var(--radius-xl)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.form-field{display:flex;flex-direction:column;gap:6px}.form-field.col-span-2{grid-column:1 / -1}.form-field label{font-size:12.5px;font-weight:600;color:var(--text-2)}.form-field label .req{color:var(--danger)}.form-field input,.form-field select,.form-field textarea{padding:9px 12px;border-radius:9px;background:var(--bg-elev);border:1px solid var(--border-strong);outline:none;transition:.15s;width:100%}.form-field select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background:var(--bg-elev) var(--chev-url) no-repeat right 12px center;background-size:15px;cursor:pointer}.form-field textarea{resize:vertical;min-height:84px}.form-field input:focus,.form-field select:focus,.form-field textarea:focus{border-color:var(--primary);box-shadow:var(--ring)}.form-field input.err,.form-field select.err,.form-field textarea.err{border-color:var(--danger)}.field-error{font-size:11.5px;color:var(--danger);display:none}.field-error.show{display:block}.form-section-title{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text-3);margin:22px 0 4px;grid-column:1/-1}.switch{position:relative;display:inline-flex;align-items:center}.switch input{position:absolute;opacity:0;width:0;height:0}.switch-track{width:42px;height:24px;border-radius:20px;background:var(--border-strong);position:relative;transition:.2s;cursor:pointer}.switch-track:after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;transition:.2s;box-shadow:var(--shadow-sm)}.switch input:checked+.switch-track{background:var(--primary)}.switch input:checked+.switch-track:after{background:var(--primary-fg)}.switch input:checked+.switch-track:after{transform:translate(18px)}.setting-row{display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-bottom:1px solid var(--border);gap:20px}.setting-row:last-child{border-bottom:none}.setting-info h4{font-size:14px;font-weight:600}.setting-info p{font-size:13px;color:var(--text-3);margin-top:2px}.tabs{display:flex;gap:4px;border-bottom:1px solid var(--border);margin-bottom:22px;overflow-x:auto}.tab{padding:11px 16px;font-weight:600;font-size:13.5px;color:var(--text-2);border-bottom:2px solid transparent;white-space:nowrap;transition:.15s;margin-bottom:-1px}.tab:hover{color:var(--text)}.tab.active{color:var(--primary);border-bottom-color:var(--primary)}.tab-pane{display:none;animation:fadeUp .25s}.tab-pane.active{display:block}.pill-tabs{display:inline-flex;gap:4px;background:var(--bg-sunken);padding:4px;border-radius:11px}.pill-tab{padding:7px 14px;border-radius:8px;font-weight:600;font-size:13px;color:var(--text-2);transition:.15s}.pill-tab.active{background:var(--bg-elev);color:var(--text);box-shadow:var(--shadow-sm)}.kanban{display:flex;gap:16px;overflow-x:auto;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;padding-bottom:12px;align-items:flex-start;scroll-snap-type:x proximity}.kanban-col{flex:0 0 288px;background:var(--bg-sunken);border-radius:var(--radius-lg);display:flex;flex-direction:column;max-height:calc(100vh - 220px);max-height:calc(100dvh - 220px)}.kanban-col-head{display:flex;align-items:center;gap:8px;padding:14px 16px;position:sticky;top:0}.kanban-col-head .k-dot{width:9px;height:9px;border-radius:50%}.kanban-col-head h4{font-size:13.5px;font-weight:700}.k-count{margin-left:auto;background:var(--bg-elev);color:var(--text-2);font-size:12px;font-weight:700;padding:1px 9px;border-radius:20px}.kanban-cards{padding:0 12px 12px;display:flex;flex-direction:column;gap:10px;overflow-y:auto;min-height:60px}.kanban-cards.drag-over{background:var(--primary-soft);border-radius:10px;outline:2px dashed var(--primary);outline-offset:-4px}.k-card{background:var(--bg-elev);border:1px solid var(--border);border-radius:11px;padding:13px;cursor:grab;box-shadow:var(--shadow-sm);transition:.15s}.k-card:hover{box-shadow:var(--shadow-md);border-color:var(--border-strong)}.k-card.dragging{opacity:.5;transform:rotate(2deg);cursor:grabbing}.k-card-top{display:flex;align-items:center;gap:10px;margin-bottom:10px}.k-card-top .avatar{width:32px;height:32px;font-size:11px}.kc-name{font-weight:600;font-size:13.5px}.kc-role{font-size:12px;color:var(--text-3)}.k-card-meta{display:flex;align-items:center;justify-content:space-between;margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.k-tags{display:flex;gap:5px;flex-wrap:wrap;margin-top:8px}.tag{font-size:11px;font-weight:600;padding:2px 8px;border-radius:6px;background:var(--bg-sunken);color:var(--text-2)}.list-tight>*+*{border-top:1px solid var(--border)}.list-row{display:flex;align-items:center;gap:12px;padding:13px 0}.list-row:first-child{padding-top:0}.list-row .avatar{width:38px;height:38px;font-size:13px}.lr-main{flex:1;min-width:0}.lr-title{font-weight:600;font-size:13.5px}.lr-sub{font-size:12.5px;color:var(--text-3)}.lr-right{text-align:right;flex-shrink:0}.timeline{position:relative;padding-left:28px}.timeline:before{content:"";position:absolute;left:9px;top:4px;bottom:4px;width:2px;background:var(--border)}.tl-item{position:relative;padding-bottom:22px}.tl-item:last-child{padding-bottom:0}.tl-dot{position:absolute;left:-28px;top:2px;width:20px;height:20px;border-radius:50%;background:var(--bg-elev);border:2px solid var(--primary);display:grid;place-items:center}.tl-dot svg{width:11px;height:11px;color:var(--primary)}.tl-title{font-weight:600;font-size:13.5px}.tl-meta{font-size:12px;color:var(--text-3);margin-top:2px}.tl-desc{font-size:13px;color:var(--text-2);margin-top:5px}.empty-state{text-align:center;padding:60px 20px;color:var(--text-3)}.empty-state svg{width:48px;height:48px;margin-bottom:14px;opacity:.5}.empty-state h3{font-size:18px;color:var(--text-2);margin-bottom:6px}.avatar-stack{display:flex}.avatar-stack .avatar{width:30px;height:30px;font-size:11px;border:2px solid var(--bg-elev);margin-left:-8px}.avatar-stack .avatar:first-child{margin-left:0}.more-count{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:var(--bg-sunken);color:var(--text-2);font-size:11px;font-weight:700;border:2px solid var(--bg-elev);margin-left:-8px}.stat-mini{display:flex;flex-direction:column;gap:4px}.stat-mini-val{font-size:22px;font-weight:700;letter-spacing:-.5px}.stat-mini-lbl{font-size:12.5px;color:var(--text-3)}.divider{height:1px;background:var(--border);margin:16px 0}.flex{display:flex}.items-center{align-items:center}.gap-8{gap:8px}.gap-12{gap:12px}.gap-16{gap:16px}.text-muted{color:var(--text-3)}.fw-600{font-weight:600}.text-sm{font-size:12.5px}.mono{font-family:var(--mono)}.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:12px;overflow:hidden}.cal-dow{background:var(--bg-elev);padding:10px;text-align:center;font-size:11.5px;font-weight:700;text-transform:uppercase;color:var(--text-3);letter-spacing:.5px}.cal-cell{background:var(--bg-elev);min-height:108px;padding:8px;position:relative;transition:.12s}.cal-cell:hover,.cal-cell.other{background:var(--bg-sunken)}.cal-date{font-size:12.5px;font-weight:600;color:var(--text-2)}.cal-cell.today .cal-date{background:var(--primary);color:var(--primary-fg);width:24px;height:24px;border-radius:50%;display:grid;place-items:center}.cal-cell.other .cal-date{color:var(--text-3)}.cal-event{font-size:11px;font-weight:600;padding:3px 6px;border-radius:5px;margin-top:4px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toast-root{position:fixed;bottom:24px;right:24px;z-index:500;display:flex;flex-direction:column;gap:10px}.toast{display:flex;align-items:center;gap:12px;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;padding:13px 16px;box-shadow:var(--shadow-lg);min-width:300px;max-width:400px;animation:toastIn .3s cubic-bezier(.34,1.3,.64,1)}@keyframes toastIn{0%{opacity:0;transform:translate(40px)}to{opacity:1;transform:none}}.toast.out{animation:toastOut .3s forwards}@keyframes toastOut{to{opacity:0;transform:translate(40px)}}.toast-icn{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;flex-shrink:0}.toast-icn svg{width:18px;height:18px}.toast-body{flex:1}.toast-title{font-weight:600;font-size:13.5px}.toast-msg{font-size:12.5px;color:var(--text-3)}.toast-close{color:var(--text-3);width:24px;height:24px;display:grid;place-items:center;border-radius:6px}.toast-close:hover{background:var(--bg-sunken)}.scrim{position:fixed;top:0;right:0;bottom:0;left:0;background:#0a16188c;z-index:55;display:none}.scrim.open{display:block}[data-tip]{position:relative}[data-tip]:after{content:attr(data-tip);position:absolute;bottom:calc(100% + 8px);left:50%;transform:translate(-50%);background:var(--text);color:var(--bg-elev);padding:5px 9px;border-radius:7px;font-size:11.5px;font-weight:600;white-space:nowrap;opacity:0;pointer-events:none;transition:.15s;z-index:100}[data-tip]:hover:after{opacity:1}@media(hover:none){[data-tip]:after{content:none}}[data-theme=dark] [data-tip]:after{background:var(--brand-lime);color:var(--brand-ink)}.mini-bars{display:flex;align-items:flex-end;gap:3px;height:40px}.mini-bar{flex:1;background:var(--primary-soft);border-radius:3px 3px 0 0;min-height:4px;transition:.3s}.mini-bar.hl{background:var(--primary)}.profile-hero{display:flex;gap:18px;align-items:center;margin-bottom:4px}.profile-hero .avatar{width:68px;height:68px;font-size:24px}.ph-role{color:var(--text-2);font-size:14px}.ph-tags{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}.info-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px 24px}.info-item .il{font-size:12px;color:var(--text-3);font-weight:600;text-transform:uppercase;letter-spacing:.4px}.info-item .iv{font-size:14px;font-weight:500;margin-top:3px}.split{display:grid;grid-template-columns:380px 1fr;gap:0;min-height:560px}.split-list{border-right:1px solid var(--border);overflow-y:auto;overscroll-behavior:contain;max-height:calc(100vh - 260px);max-height:calc(100dvh - 260px)}.split-detail{overflow-y:auto;overscroll-behavior:contain;max-height:calc(100vh - 260px);max-height:calc(100dvh - 260px)}.inbox-item{display:flex;gap:12px;padding:14px 18px;border-bottom:1px solid var(--border);cursor:pointer;transition:.12s;position:relative}.inbox-item:hover{background:var(--bg-sunken)}.inbox-item.active,[data-theme=dark] .inbox-item.active{background:var(--primary-soft)}.inbox-item.unread:before{content:"";position:absolute;left:6px;top:50%;transform:translateY(-50%);width:6px;height:6px;border-radius:50%;background:var(--primary)}.inbox-item.unread .ii-name{font-weight:700}.ii-main{flex:1;min-width:0}.ii-name{font-weight:600;font-size:13.5px;display:flex;align-items:center;gap:6px}.ii-pos{font-size:12.5px;color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ii-meta{display:flex;align-items:center;gap:8px;margin-top:5px}.ii-time{font-size:11px;color:var(--text-3);white-space:nowrap}.source-chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;--chip: var(--text-3);color:var(--text-2);background:var(--bg-sunken);background:color-mix(in srgb,var(--chip) 14%,transparent)}.source-chip svg{width:12px;height:12px;color:var(--chip)}.source-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--chip)}.integration-status{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:20px;font-size:12.5px;font-weight:600;background:var(--success-soft);color:var(--success)}.integration-status.pending{background:var(--warning-soft);color:var(--warning)}.integration-status .pulse{width:8px;height:8px;border-radius:50%;background:currentColor;position:relative}.integration-status .pulse:after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;border-radius:50%;background:currentColor;animation:pulse 1.8s infinite}@keyframes pulse{0%{transform:scale(1);opacity:.7}to{transform:scale(3);opacity:0}}.email-preview{background:var(--bg-sunken);border:1px solid var(--border);border-radius:12px;padding:18px;white-space:pre-wrap;font-size:13.5px;line-height:1.7;color:var(--text-2)}.attach-card{display:flex;align-items:center;gap:12px;padding:14px;border:1px solid var(--border);border-radius:12px;background:var(--bg-elev)}.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))}.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}.dropzone.drag{border-color:var(--primary);background:var(--primary-soft);transform:scale(1.005)}.dropzone .dz-icn{width:64px;height:64px;border-radius:18px;background:var(--primary-soft);color:var(--primary);display:grid;place-items:center;margin:0 auto 16px}.dropzone .dz-icn svg{width:30px;height:30px}.dropzone h3{font-size:17px;margin-bottom:6px}.upload-row{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:11px;margin-top:10px;background:var(--bg-elev)}.upload-progress{height:5px;background:var(--bg-sunken);border-radius:20px;overflow:hidden;flex:1}.upload-progress-fill{height:100%;background:var(--primary);border-radius:20px;transition:width .2s}.ats-ring{--pct: 0;--c: var(--primary);position:relative;width:120px;height:120px;border-radius:50%;display:grid;place-items:center;margin:0 auto;background:conic-gradient(var(--c) calc(var(--pct)*1%),var(--bg-sunken) 0)}.ats-ring:after{content:"";position:absolute;top:12px;right:12px;bottom:12px;left:12px;border-radius:50%;background:var(--bg-elev)}.ats-ring .ats-val{position:relative;z-index:1;text-align:center}.ats-ring .ats-num{font-size:30px;font-weight:800;letter-spacing:-1px;line-height:1}.ats-ring .ats-lbl{font-size:11px;color:var(--text-3);font-weight:600}.skill-pill{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:4px 10px;border-radius:8px}.skill-pill svg{width:12px;height:12px}.skill-matched{background:var(--success-soft);color:var(--success)}.skill-missing{background:var(--danger-soft);color:var(--danger)}.platform-card{display:flex;align-items:center;gap:14px;padding:16px;border:1px solid var(--border);border-radius:14px;transition:.15s;cursor:pointer;background:var(--bg-elev)}.platform-card:hover{border-color:var(--border-strong);box-shadow:var(--shadow-sm)}.platform-card.selected{border-color:var(--primary);box-shadow:var(--ring);background:var(--primary-soft)}[data-theme=dark] .platform-card.selected{background:var(--primary-soft)}.platform-logo{width:44px;height:44px;border-radius:11px;display:grid;place-items:center;color:#fff;flex-shrink:0}.platform-check{width:22px;height:22px;border-radius:6px;border:2px solid var(--border-strong);display:grid;place-items:center;margin-left:auto;flex-shrink:0;transition:.15s}.platform-card.selected .platform-check{background:var(--primary);border-color:var(--primary);color:var(--primary-fg)}.platform-check svg{width:14px;height:14px;opacity:0}.platform-card.selected .platform-check svg{opacity:1}.stepper{display:flex;align-items:center;margin-bottom:24px}.step{display:flex;align-items:center;gap:10px}.step-num{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-weight:700;font-size:13px;background:var(--bg-sunken);color:var(--text-3);border:2px solid var(--border)}.step.active .step-num{background:var(--primary);color:var(--primary-fg);border-color:var(--primary)}.step.done .step-num{background:var(--success);color:var(--success-fg);border-color:var(--success)}.step-label{font-size:13px;font-weight:600;color:var(--text-3)}.step.active .step-label,.step.done .step-label{color:var(--text)}.step-line{flex:1;height:2px;background:var(--border);margin:0 14px;min-width:20px}.step.done+.step-line,.step-line.done{background:var(--success)}.heatmap{display:grid;grid-template-columns:40px repeat(5,1fr);gap:5px}.hm-label{font-size:11px;color:var(--text-3);display:flex;align-items:center}.hm-cell{aspect-ratio:1.4;border-radius:5px;background:var(--bg-sunken);transition:.15s;cursor:pointer}.hm-cell:hover{outline:2px solid var(--primary)}.hm-legend{display:flex;align-items:center;gap:4px;justify-content:flex-end;margin-top:10px;font-size:11px;color:var(--text-3)}.hm-legend .hm-box{width:13px;height:13px;border-radius:3px}.leader-row{display:flex;align-items:center;gap:14px;padding:12px 0;border-bottom:1px solid var(--border)}.leader-row:last-child{border-bottom:none}.leader-rank{width:28px;height:28px;border-radius:8px;display:grid;place-items:center;font-weight:800;font-size:13px;background:var(--bg-sunken);color:var(--text-2);flex-shrink:0}.leader-rank.gold{background:var(--accent);color:var(--accent-fg)}.leader-rank.silver{background:var(--success-soft);color:var(--success)}.leader-rank.bronze{background:var(--warning-soft);color:var(--warning)}.rbac-layout{display:grid;grid-template-columns:280px 1fr;gap:18px}.role-list{display:flex;flex-direction:column;gap:6px}.role-item{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:11px;cursor:pointer;border:1px solid transparent;transition:.12s}.role-item:hover{background:var(--bg-sunken)}.role-item.active{background:var(--primary-soft);border-color:var(--primary)}[data-theme=dark] .role-item.active{background:var(--primary-soft)}.role-badge{width:38px;height:38px;border-radius:10px;display:grid;place-items:center;color:var(--avatar-fg);flex-shrink:0}.rbac-matrix{width:100%;border-collapse:collapse;font-size:13px}.rbac-matrix th{padding:12px 8px;font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--text-3);border-bottom:1px solid var(--border);text-align:center;font-weight:700}.rbac-matrix th:first-child{text-align:left;padding-left:16px}.rbac-matrix td{padding:10px 8px;border-bottom:1px solid var(--border);text-align:center}.rbac-matrix td:first-child{text-align:left;padding-left:16px;font-weight:600}.perm-check{width:22px;height:22px;border-radius:6px;border:2px solid var(--border-strong);display:inline-grid;place-items:center;cursor:pointer;transition:.12s}.perm-check.on{background:var(--primary);border-color:var(--primary);color:var(--primary-fg)}.perm-check.on svg{width:13px;height:13px}.perm-check:not(.on) svg{display:none}.chat-wrap{display:flex;flex-direction:column;height:calc(100vh - 190px);height:calc(100dvh - 190px)}.chat-scroll{flex:1;overflow-y:auto;padding:8px 4px 20px}.chat-msg{display:flex;gap:12px;margin-bottom:22px;max-width:820px}.chat-msg .chat-av{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;flex-shrink:0;color:#fff}.chat-av.ai{background:linear-gradient(135deg,var(--brand-green),var(--brand-mint))}.chat-av.user{background:var(--bg-sunken);color:var(--text-2)}.chat-bubble{padding-top:3px}.chat-role{font-weight:700;font-size:13px;margin-bottom:4px}.chat-text{font-size:14px;line-height:1.65;color:var(--text)}.chat-text p{margin-bottom:10px}.chat-text ul{padding-left:20px;margin-bottom:10px}.chat-text li{margin-bottom:4px}.chat-typing span{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--text-3);margin-right:3px;animation:typing 1.2s infinite}.chat-typing span:nth-child(2){animation-delay:.2s}.chat-typing span:nth-child(3){animation-delay:.4s}@keyframes typing{0%,60%,to{opacity:.3;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}.chat-input-bar{border:1px solid var(--border-strong);border-radius:16px;padding:10px 12px;display:flex;gap:10px;align-items:flex-end;background:var(--bg-elev);box-shadow:var(--shadow-sm)}.chat-input-bar:focus-within{border-color:var(--primary);box-shadow:var(--ring)}.chat-input-bar textarea{flex:1;border:none;outline:none;resize:none;background:transparent;font-size:14px;max-height:140px;line-height:1.5;padding:6px 4px}.chat-send{width:38px;height:38px;border-radius:10px;background:var(--primary);color:var(--primary-fg);display:grid;place-items:center;flex-shrink:0;transition:.15s}.chat-send:hover{background:var(--primary-600)}.chat-send:disabled{opacity:.4}.prompt-chip{display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border:1px solid var(--border-strong);border-radius:11px;font-size:13px;font-weight:500;cursor:pointer;transition:.15s;background:var(--bg-elev);text-align:left}.prompt-chip:hover{border-color:var(--primary);background:var(--primary-soft);color:var(--primary)}.prompt-chip svg{width:15px;height:15px;color:var(--primary)}.ai-hero{text-align:center;padding:30px 0 24px}.ai-hero .ai-logo{width:64px;height:64px;border-radius:20px;background:linear-gradient(135deg,var(--brand-green),var(--brand-mint));display:grid;place-items:center;margin:0 auto 16px;box-shadow:0 10px 30px #004d4359}.ai-hero .ai-logo svg{width:32px;height:32px;color:var(--brand-lime)}.brand-hero{background:linear-gradient(120deg,var(--brand-green),#0a6a58);border:none;color:#fff;position:relative;overflow:hidden}.brand-hero:after{content:"";position:absolute;right:-40px;top:-60px;width:260px;height:260px;border-radius:50%;background:radial-gradient(circle at 30% 30%,rgba(206,255,113,.2),transparent 68%);pointer-events:none}.brand-hero .card-body{position:relative;z-index:1}.brand-hero h2,.brand-hero h3{color:#fff}.brand-hero p{color:#ffffffdb}.brand-hero .ai-logo{background:#ceff7129;box-shadow:none}.brand-hero .avatar{color:#fff}.brand-hero .ai-logo svg{color:var(--brand-lime)}.brand-hero .topbar-search>svg{color:var(--text-3)}.brand-hero .topbar-search input{background:#fff;color:#10231f;border-color:transparent}.brand-hero .topbar-search input::placeholder{color:#54726c}.btn-on-brand{background:var(--brand-lime);color:var(--brand-ink);font-weight:600}.btn-on-brand:hover{background:#dcff96}.ai-fab{position:fixed;bottom:26px;right:26px;width:56px;height:56px;border-radius:50%;background:var(--brand-green);color:var(--brand-lime);display:grid;place-items:center;box-shadow:0 10px 30px #004d4373;z-index:200;transition:.2s}.ai-fab:hover{transform:scale(1.08) rotate(8deg)}[data-view=aiassistant] .ai-fab{display:none}.ai-fab svg{width:26px;height:26px}.ai-dock{position:fixed;top:0;right:0;bottom:0;width:440px;max-width:92vw;background:var(--bg-elev);border-left:1px solid var(--border);box-shadow:var(--shadow-lg);z-index:310;transform:translate(100%);transition:transform .28s cubic-bezier(.4,0,.2,1)}.ai-dock.open{transform:translate(0)}.ai-dock-inner{height:100%;display:flex;flex-direction:column}.bulk-bar{display:flex;align-items:center;gap:12px;padding:12px 18px;background:var(--primary);color:var(--primary-fg);border-radius:12px;margin-bottom:14px;animation:fadeUp .2s}.bulk-bar .btn{background:#ffffff26;background:color-mix(in srgb,var(--primary-fg) 14%,transparent);color:var(--primary-fg);border:none}.bulk-bar .btn:hover{background:#ffffff40;background:color-mix(in srgb,var(--primary-fg) 24%,transparent)}.checkbox{width:18px;height:18px;border-radius:5px;border:2px solid var(--border-strong);display:inline-grid;place-items:center;cursor:pointer;flex-shrink:0;transition:.12s;background:var(--bg-elev)}.checkbox.on{background:var(--primary);border-color:var(--primary);color:var(--primary-fg)}.checkbox svg{width:12px;height:12px;opacity:0}.checkbox.on svg{opacity:1}.filter-panel{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.filter-panel .form-field label{font-size:11.5px}.star-btn{color:var(--text-3);transition:.12s}.star-btn.on{color:var(--warning)}.star-btn.on svg{fill:currentColor}.seg{display:inline-flex;background:var(--bg-sunken);padding:3px;border-radius:10px}.seg button{padding:6px 14px;border-radius:8px;font-size:13px;font-weight:600;color:var(--text-2)}.seg button.active{background:var(--bg-elev);color:var(--text);box-shadow:var(--shadow-sm)}.rating-stars{display:inline-flex;gap:3px}.rating-stars .rs{color:var(--border-strong);cursor:pointer;transition:.1s}.rating-stars .rs svg{width:22px;height:22px}.rating-stars .rs.on{color:var(--warning)}.rating-stars .rs.on svg{fill:currentColor}.recc-banner{display:flex;align-items:center;gap:14px;padding:16px 18px;border-radius:14px;margin-bottom:18px}.recc-strong{background:var(--success-soft);color:var(--success)}.recc-potential{background:var(--warning-soft);color:var(--warning)}.recc-weak{background:var(--danger-soft);color:var(--danger)}.recc-banner .recc-icn{width:44px;height:44px;border-radius:12px;background:#ffffff80;display:grid;place-items:center}[data-theme=dark] .recc-banner .recc-icn{background:#0003}.sidebar{padding-left:env(safe-area-inset-left)}.topbar{padding-left:max(22px,env(safe-area-inset-left));padding-right:max(22px,env(safe-area-inset-right))}.content{padding-left:max(30px,env(safe-area-inset-left));padding-right:max(30px,env(safe-area-inset-right));padding-bottom:max(60px,env(safe-area-inset-bottom))}.ai-fab{right:max(26px,env(safe-area-inset-right));bottom:max(26px,env(safe-area-inset-bottom))}.toast-root{right:max(24px,env(safe-area-inset-right));bottom:max(24px,env(safe-area-inset-bottom))}.ai-dock{padding-bottom:env(safe-area-inset-bottom)}.modal-foot{padding-bottom:max(18px,env(safe-area-inset-bottom))}@media(pointer:coarse){.act-btn,.page-btn,.icon-btn,.modal-close,.toast-close,.sidebar-collapse-btn,.checkbox,.perm-check,.rating-stars .rs,.star-btn,.link-btn{position:relative}.act-btn:before,.page-btn:before,.icon-btn:before,.modal-close:before,.toast-close:before,.sidebar-collapse-btn:before,.checkbox:before,.perm-check:before,.rating-stars .rs:before,.star-btn:before{content:"";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:44px;height:44px;pointer-events:auto}.btn{min-height:44px;padding-top:11px;padding-bottom:11px}.btn-sm{min-height:40px}.nav-item,.dropdown-link{padding-top:12px;padding-bottom:12px}.tab{padding-top:14px;padding-bottom:14px}.list-row,.search-item,.notif-row{min-height:48px}.form-field input,.form-field select,.form-field textarea,.toolbar-search input,.select{min-height:44px}.switch-track{width:50px;height:30px}.switch-track:after{width:24px;height:24px}.switch input:checked+.switch-track:after{transform:translate(20px)}}@media(min-width:1600px){.content>.page{max-width:1560px;margin-inline:auto}}@media(max-width:1200px){.g-kpi{grid-template-columns:repeat(2,1fr)}.g-3,.g-2-1,.g-1-2,.g-2{grid-template-columns:1fr}.filter-panel{grid-template-columns:repeat(3,1fr)}}@media(max-width:980px){.split{grid-template-columns:1fr}.split-list{border-right:none;border-bottom:1px solid var(--border);max-height:380px}.rbac-layout{grid-template-columns:1fr}.filter-panel{grid-template-columns:repeat(2,1fr)}.rbac-matrix{min-width:620px}}@media(max-width:900px){.sidebar{position:fixed;left:0;z-index:100;transform:translate(-100%);transition:transform .25s;box-shadow:none}.sidebar.mobile-open{transform:translate(0);box-shadow:var(--shadow-lg)}.nav-open .ai-fab{display:none}.menu-toggle{display:grid}.search-kbd{display:none}.content{padding:20px max(16px,env(safe-area-inset-left)) 50px max(16px,env(safe-area-inset-right))}.profile-meta{display:none}.topbar{padding-left:max(16px,env(safe-area-inset-left));padding-right:max(16px,env(safe-area-inset-right))}.chat-wrap{height:calc(100vh - 170px);height:calc(100dvh - 170px)}}@media(max-width:640px){.g-kpi{grid-template-columns:1fr}.topbar{padding-left:max(12px,env(safe-area-inset-left));padding-right:max(12px,env(safe-area-inset-right));gap:6px}.form-grid,.info-grid,.filter-panel{grid-template-columns:1fr}.page-title{font-size:25px}.page-head{gap:12px;margin-bottom:18px}.page-head-actions{width:100%}.page-head-actions .btn{flex:1 1 auto;justify-content:center}.topbar-search{max-width:none;min-width:0}#messagesDropdown,.topbar-divider,.profile-btn .chev{display:none}.topbar-actions{gap:2px}.icon-btn{width:40px;height:40px}.profile-btn{padding:4px}.dropdown-menu,.dropdown-menu-wide{position:fixed;top:calc(var(--topbar-h) + 6px);left:8px;right:8px;width:auto;min-width:0;max-width:none;max-height:calc(100vh - var(--topbar-h) - 24px);max-height:calc(100dvh - var(--topbar-h) - 24px);overflow-y:auto;overscroll-behavior:contain}.dd-scroll{max-height:none}.toast-root{left:max(12px,env(safe-area-inset-left));right:max(12px,env(safe-area-inset-right))}.toast{min-width:0;max-width:none;width:100%}.modal{margin:0;width:100%;max-width:none;border-radius:var(--radius-xl) var(--radius-xl) 0 0;position:fixed;left:0;right:0;bottom:0;max-height:94vh;max-height:94dvh}.modal-head{padding:18px 18px 14px}.modal-body{padding:18px}.modal-foot{padding:14px 18px max(14px,env(safe-area-inset-bottom));flex-direction:column-reverse}.modal-foot .btn{width:100%}.kanban{gap:12px;scroll-padding-left:16px}.kanban-col{flex:0 0 min(78vw,300px);scroll-snap-align:start;max-height:none}.kanban-cards{max-height:60vh}.ai-dock{width:100%;max-width:100%}.split-list{max-height:320px}.card-head{padding:14px 16px}.card-body,.card-pad,.kpi{padding:16px}.tabs{gap:0}.cal-cell{min-height:76px}.chat-msg{gap:10px}.brand-hero .card-body{padding:22px 18px}.stepper{overflow-x:auto;padding-bottom:6px}.step-label{display:none}.step-line{margin:0 8px}table.data{min-width:720px}table.data thead th{padding:10px 12px}table.data tbody td{padding:11px 12px}.user-cell .cell-primary{white-space:nowrap}.user-cell .cell-sub{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:190px}table.data thead th:nth-child(2),table.data tbody td:nth-child(2){position:sticky;left:0;z-index:2;background:var(--bg-elev);box-shadow:1px 0 0 var(--border)}table.data tbody tr:hover td:nth-child(2){background:var(--bg-sunken)}}@media(max-width:400px){.content{padding-left:max(12px,env(safe-area-inset-left));padding-right:max(12px,env(safe-area-inset-right))}.page-title{font-size:22px}.kpi-value{font-size:25px}.sidebar{width:min(88vw,var(--sidebar-w))}.toolbar-search{max-width:none}.pagination{justify-content:center}.page-info{width:100%;text-align:center}}@media(max-height:500px)and (orientation:landscape){:root{--topbar-h: 52px}.content{padding-top:14px;padding-bottom:28px}.page-head{margin-bottom:14px}.chat-wrap{height:calc(100vh - 120px);height:calc(100dvh - 120px)}.kanban-col,.split-list,.split-detail{max-height:calc(100vh - 150px);max-height:calc(100dvh - 150px)}.modal{max-height:96vh;max-height:96dvh}.sidebar-footer{display:none}}@media(max-height:420px){.ai-fab{width:46px;height:46px}.ai-fab svg{width:22px;height:22px}}.auth-shell{min-height:100dvh;display:grid;grid-template-columns:minmax(280px,42%) 1fr;background:var(--bg)}.auth-aside{position:relative;display:flex;flex-direction:column;justify-content:space-between;gap:32px;padding:40px 44px;background:var(--brand-green);color:#fff;overflow:hidden}.auth-aside:before{content:"";position:absolute;inset:auto -20% -30% 20%;height:70%;background:radial-gradient(ellipse at center,rgba(206,255,113,.28),transparent 65%);pointer-events:none}.auth-aside .auth-brand{position:relative;z-index:1}.auth-aside .brand-name{color:#fff;font-size:22px}.auth-aside-copy{position:relative;z-index:1;max-width:28ch}.auth-aside-copy h1{font-family:Belleza,Georgia,serif;font-size:clamp(32px,4vw,44px);line-height:1.15;font-weight:400;letter-spacing:-.02em;margin:0 0 14px;color:#fff}.auth-aside-copy p{margin:0;font-size:15px;line-height:1.55;color:#ffffffc7}.auth-aside-foot{position:relative;z-index:1;margin:0;font-size:12px;letter-spacing:.4px;color:var(--brand-lime)}.auth-panel{display:flex;flex-direction:column;justify-content:center;padding:32px clamp(20px,5vw,64px);position:relative}.auth-panel-top{position:absolute;top:20px;right:20px;left:20px;display:flex;align-items:center;justify-content:space-between}.auth-panel-brand{display:none}.auth-brand{display:flex;align-items:center;gap:12px}.auth-brand .brand-logo{width:42px;height:42px}.auth-brand .brand-logo-lg{width:48px;height:48px;border-radius:12px}.auth-brand .brand-name{font-family:Belleza,Georgia,serif;font-weight:400;letter-spacing:.2px}.auth-card{width:100%;max-width:420px;margin:0 auto;padding:32px 28px 28px}.auth-card .page-title{margin:0 0 8px;font-size:28px}.auth-sub{margin:0 0 22px;color:var(--text-2);font-size:14px;line-height:1.45}.auth-card .form-field{margin-bottom:14px}.auth-row-end{display:flex;justify-content:flex-end;margin:-4px 0 4px}.auth-foot{max-width:420px;margin:18px auto 0;text-align:center;font-size:13.5px;color:var(--text-2)}.auth-resend{margin-top:14px;text-align:center}.auth-theme-toggle{width:40px;height:40px;border-radius:10px;display:grid;place-items:center;color:var(--text-2);background:var(--bg-elev);border:1px solid var(--border);transition:.15s}.auth-theme-toggle:hover{background:var(--bg-sunken);color:var(--text)}.auth-theme-toggle svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.alert{padding:11px 14px;border-radius:10px;font-size:13px;line-height:1.45;margin-bottom:16px;border:1px solid transparent}.alert-danger{background:var(--danger-soft);color:var(--danger);border-color:color-mix(in srgb,var(--danger) 25%,transparent)}.alert-success{background:var(--success-soft);color:var(--success);border-color:color-mix(in srgb,var(--success) 25%,transparent)}.pw-wrap{position:relative;display:flex;align-items:center}.pw-wrap input{width:100%;padding-right:44px}.pw-toggle{position:absolute;right:8px;width:34px;height:34px;border-radius:8px;display:grid;place-items:center;color:var(--text-3);transition:.15s}.pw-toggle:hover{color:var(--text);background:var(--bg-sunken)}.pw-toggle svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.otp-row{display:flex;gap:8px;justify-content:space-between;margin-bottom:8px}.otp-input{width:100%;max-width:52px;aspect-ratio:1;text-align:center;font-size:20px;font-weight:700;letter-spacing:0;border:1px solid var(--border-strong);border-radius:10px;background:var(--bg-elev);color:var(--text);outline:none;transition:.15s}.otp-input:focus{border-color:var(--primary);box-shadow:var(--ring)}.otp-input.err{border-color:var(--danger)}.countdown{margin:8px 0 4px;font-size:13px;color:var(--text-2);text-align:center}.countdown strong{color:var(--text);font-variant-numeric:tabular-nums}.spinner{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px}.spinner-dot{width:14px;height:14px;border-radius:50%;border:2px solid color-mix(in srgb,var(--primary-fg) 35%,transparent);border-top-color:var(--primary-fg);animation:auth-spin .7s linear infinite}@keyframes auth-spin{to{transform:rotate(360deg)}}.btn-primary .spinner-dot{border-color:color-mix(in srgb,var(--primary-fg) 35%,transparent);border-top-color:var(--primary-fg)}@media(max-width:860px){.auth-shell{grid-template-columns:1fr}.auth-aside{display:none}.auth-panel-brand{display:block}.auth-panel-brand .brand-name{color:var(--text)}.auth-panel{padding-top:88px;min-height:100dvh}.auth-card{box-shadow:none;border:none;background:transparent;padding:8px 0 24px}}@media(max-width:420px){.otp-row{gap:6px}.otp-input{max-width:46px;font-size:18px}}
diff --git a/frontend/dist/index.html b/frontend/dist/index.html
index af70aa1..f973dc0 100644
--- a/frontend/dist/index.html
+++ b/frontend/dist/index.html
@@ -23,8 +23,8 @@
-
-
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..147d1dd
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,30 @@
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # One SPA at `/`. `vite dev` and `vite preview` serve index.html for every
+ # unmatched path; vite.config.js notes that a static deploy needs the equivalent
+ # rewrite rule. This is it — without it /auth/confirm-email (a router path, not a
+ # file) 404s when a confirmation email link is opened cold.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Hashed filenames, so they can be cached hard.
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # index.html must never be cached, or a redeploy keeps serving the old asset hashes.
+ location = /index.html {
+ add_header Cache-Control "no-store";
+ }
+
+ gzip on;
+ gzip_min_length 1024;
+ gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
+}
diff --git a/frontend/node_modules/.vite/deps/_metadata.json b/frontend/node_modules/.vite/deps/_metadata.json
index f6fcee4..426a454 100644
--- a/frontend/node_modules/.vite/deps/_metadata.json
+++ b/frontend/node_modules/.vite/deps/_metadata.json
@@ -1,8 +1,8 @@
{
- "hash": "e445d3fc",
- "configHash": "4ee64dba",
+ "hash": "789e2a06",
+ "configHash": "c5b65d5f",
"lockfileHash": "fac4afd8",
- "browserHash": "340fe321",
+ "browserHash": "8203abd8",
"optimized": {
"react": {
"src": "../../react/index.js",
diff --git a/frontend/src/api/assessments.js b/frontend/src/api/assessments.js
new file mode 100644
index 0000000..15ffede
--- /dev/null
+++ b/frontend/src/api/assessments.js
@@ -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,
+ }
+}
diff --git a/frontend/src/api/assignments.js b/frontend/src/api/assignments.js
new file mode 100644
index 0000000..ab0a530
--- /dev/null
+++ b/frontend/src/api/assignments.js
@@ -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,
+ }
+}
diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js
index eae3e04..862eb84 100644
--- a/frontend/src/api/candidates.js
+++ b/frontend/src/api/candidates.js
@@ -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,
+ })
+}
diff --git a/frontend/src/api/costs.js b/frontend/src/api/costs.js
new file mode 100644
index 0000000..4247fab
--- /dev/null
+++ b/frontend/src/api/costs.js
@@ -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)
+}
diff --git a/frontend/src/api/feedback.js b/frontend/src/api/feedback.js
new file mode 100644
index 0000000..36250b5
--- /dev/null
+++ b/frontend/src/api/feedback.js
@@ -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,
+ }
+}
diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js
index 4c3bd23..1e0e414 100644
--- a/frontend/src/api/inbox.js
+++ b/frontend/src/api/inbox.js
@@ -73,3 +73,107 @@ 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 },
+ })
+}
+
+/* ============================================================
+ Intake gate (backend/inbox_classifier/).
+
+ /email/fetch now classifies every message on subject + body and only persists
+ the job applications, so the mail that never became an inbox row is only
+ visible through these two calls. Both require the same tags as the rest of the
+ inbox: INBOX_VIEW to read, INBOX_EDIT to override.
+ ============================================================ */
+
+/**
+ * The verdict ledger — one row per upstream message id.
+ *
+ * `isApplication` is tri-valued: omit for no filter, false for the mail the gate
+ * dropped, true for the mail it let through. buildUrl drops undefined but keeps
+ * false, so `isApplication: undefined` sends no param at all — the same
+ * convention `isread` uses above.
+ *
+ * `status` is `classified|low_confidence|error`; anything else 422s server-side.
+ */
+export function listTriage({ search, top, skip, isApplication, status } = {}) {
+ return request('/inbox/triage', {
+ params: {
+ search,
+ top,
+ skip,
+ is_application: isApplication,
+ status,
+ },
+ })
+}
+
+/**
+ * Overturn one verdict. `recordId` is the triage row's own uuid, NOT the inbox
+ * message id — a dropped message has no inbox row to point at.
+ *
+ * true re-fetches the mail from upstream and runs the normal ingestion path
+ * (which is why the body was never stored). false moves an already-ingested row
+ * to processing_state 'rejected'; it never deletes it.
+ */
+export function overrideTriage(recordId, isApplication) {
+ return request(`/inbox/triage/${recordId}/override`, {
+ method: 'PATCH',
+ body: { is_application: isApplication },
+ })
+}
diff --git a/frontend/src/api/interviews.js b/frontend/src/api/interviews.js
index 98eef70..ca97f30 100644
--- a/frontend/src/api/interviews.js
+++ b/frontend/src/api/interviews.js
@@ -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,
+ }
+}
diff --git a/frontend/src/api/jobPosts.js b/frontend/src/api/jobPosts.js
index 63c29e1..c487a25 100644
--- a/frontend/src/api/jobPosts.js
+++ b/frontend/src/api/jobPosts.js
@@ -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')
+}
diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js
index e089abe..fae4530 100644
--- a/frontend/src/api/jobs.js
+++ b/frontend/src/api/jobs.js
@@ -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 },
+ })
+}
diff --git a/frontend/src/api/notifications.js b/frontend/src/api/notifications.js
new file mode 100644
index 0000000..d92bff6
--- /dev/null
+++ b/frontend/src/api/notifications.js
@@ -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,
+ }
+}
diff --git a/frontend/src/api/offers.js b/frontend/src/api/offers.js
index c5c499b..310941a 100644
--- a/frontend/src/api/offers.js
+++ b/frontend/src/api/offers.js
@@ -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,
+ }
+}
diff --git a/frontend/src/api/orgSettings.js b/frontend/src/api/orgSettings.js
new file mode 100644
index 0000000..858a2ba
--- /dev/null
+++ b/frontend/src/api/orgSettings.js
@@ -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
+}
diff --git a/frontend/src/api/roles.js b/frontend/src/api/roles.js
index e3d17db..b95cb10 100644
--- a/frontend/src/api/roles.js
+++ b/frontend/src/api/roles.js
@@ -29,3 +29,8 @@ export function updatePermission(recordId, body) {
export function listPermissionTags() {
return request('/permission-tags/fetch')
}
+
+/** Sets the exact tag set on one bundle. `id` comes from listPermissions(). */
+export function updatePermissionTags(body) {
+ return request('/roles/permission-tags/update', { method: 'PUT', body })
+}
diff --git a/frontend/src/api/savedSearches.js b/frontend/src/api/savedSearches.js
new file mode 100644
index 0000000..b75ccea
--- /dev/null
+++ b/frontend/src/api/savedSearches.js
@@ -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,
+ }
+}
diff --git a/frontend/src/api/search.js b/frontend/src/api/search.js
new file mode 100644
index 0000000..ecffc08
--- /dev/null
+++ b/frontend/src/api/search.js
@@ -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 } })
+}
diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js
index 54f9589..2ac9504 100644
--- a/frontend/src/api/users.js
+++ b/frontend/src/api/users.js
@@ -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,
+ }
+}
diff --git a/frontend/src/app/GlobalSearch.jsx b/frontend/src/app/GlobalSearch.jsx
index 686a071..3555140 100644
--- a/frontend/src/app/GlobalSearch.jsx
+++ b/frontend/src/app/GlobalSearch.jsx
@@ -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 (
e.stopPropagation()}>
@@ -55,42 +60,48 @@ export default function GlobalSearch({ inputRef }) {
{results && (
<>
- {results.jobs.length > 0 && Jobs}
- {results.jobs.map((j) => (
- go('/jobs', { openJob: j.id })}>
-
-
-
-
- {j.title}
- {j.id} · {j.department}
-
-
- ))}
+ {query.isPending && Searching…}
+ {query.isError && Couldn’t search. Try again.}
+ {!query.isPending && !query.isError && (
+ <>
+ {jobs.length > 0 && Jobs}
+ {jobs.map((j) => (
+ go('/jobs', { openJob: j.id })}>
+
+
+
+
+ {j.title}
+ {[j.department, j.location].filter(Boolean).join(' · ') || 'Job'}
+
+
+ ))}
- {results.candidates.length > 0 && Candidates}
- {results.candidates.map((c) => (
- go('/candidates', { openCandidate: c.id })}>
-
-
- {c.name}
- {c.jobTitle}
-
-
- ))}
+ {candidates.length > 0 && Candidates}
+ {candidates.map((c) => (
+ go('/candidates', { openCandidate: c.id })}>
+
+
+ {c.name}
+ {c.email || 'Candidate'}
+
+
+ ))}
- {results.managers.length > 0 && Hiring Managers}
- {results.managers.map((m) => (
- go('/managers', { openManager: m.id })}>
-
-
- {m.name}
- {m.title}
-
-
- ))}
+ {managers.length > 0 && Hiring Managers}
+ {managers.map((m) => (
+ go('/managers', { openManager: m.id })}>
+
+
+ {m.name}
+ {m.role_name || m.email || 'Hiring manager'}
+
+
+ ))}
- {empty && No results for “{q}”}
+ {empty && No results for “{q}”}
+ >
+ )}
>
)}
diff --git a/frontend/src/app/Topbar.jsx b/frontend/src/app/Topbar.jsx
index e87f9df..3b7a8e1 100644
--- a/frontend/src/app/Topbar.jsx
+++ b/frontend/src/app/Topbar.jsx
@@ -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 }) => (
)}
>
@@ -79,7 +107,7 @@ export default function Topbar({ onOpenNav, searchRef }) {
))}
- Open inbox
+ Open inbox
@@ -88,21 +116,37 @@ export default function Topbar({ onOpenNav, searchRef }) {
trigger={({ toggle }) => (
)}
>
Notifications
-
+
- {notifications.slice(0, 6).map((n) => (
-
+ {notifQuery.isPending && Loading…}
+ {notifQuery.isError && (
+
+ {friendlyAuthError(notifQuery.error, 'Could not load notifications.')}
+
+ )}
+ {notifQuery.isSuccess && notifications.length === 0 && (
+ No notifications yet.
+ )}
+ {notifications.map((n) => (
+ openNotif(n)}
+ style={{ cursor: 'pointer' }}
+ >
{n.title}
- {n.text}
+ {n.text && {n.text}}
{n.time}
diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js
index 58ea31e..79f541d 100644
--- a/frontend/src/app/routes.js
+++ b/frontend/src/app/routes.js
@@ -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 ---
diff --git a/frontend/src/app/useShell.js b/frontend/src/app/useShell.js
index 6540e0d..2bccdd2 100644
--- a/frontend/src/app/useShell.js
+++ b/frontend/src/app/useShell.js
@@ -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,
}
}
diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js
index 63dfc8a..6d452da 100644
--- a/frontend/src/lib/apiClient.js
+++ b/frontend/src/lib/apiClient.js
@@ -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 })
diff --git a/frontend/src/lib/charts.js b/frontend/src/lib/charts.js
index 287f916..c7550ba 100644
--- a/frontend/src/lib/charts.js
+++ b/frontend/src/lib/charts.js
@@ -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);
diff --git a/frontend/src/lib/platforms.js b/frontend/src/lib/platforms.js
new file mode 100644
index 0000000..5e73798
--- /dev/null
+++ b/frontend/src/lib/platforms.js
@@ -0,0 +1,77 @@
+/* ============================================================
+ Publishing platform vocabulary — alias → label, frontend only.
+
+ `GET /jobs/alias` returns the raw `alias` column and nothing else, so the
+ Job Board was rendering Buffer's spelling-tolerance keys as if they were
+ networks: "fb", "ig", "insta", "li", "x", "tweet", "gbp", "google",
+ "googlebusinessprofile" — Instagram twice, Google Business three times.
+ Those rows exist so a caller can *type* "insta"; they were never a display
+ list.
+
+ This table mirrors backend/migrations/manual/003_seed_social_platforms.sql.
+ The alias stays the wire value (it is what `platform` is matched against in
+ normalize_platform / resolve_channel), and only the rendering changes — the
+ backend contract is untouched.
+
+ Anything not in the table falls back to the raw string, so a network the org
+ connects in Buffer later still appears, just without a curated label.
+ ============================================================ */
+
+/** alias → { service, label }. Keyed exactly as the seed rows are. */
+export const PLATFORM_ALIASES = {
+ fb: { service: 'facebook', label: 'Facebook' },
+ ig: { service: 'instagram', label: 'Instagram' },
+ insta: { service: 'instagram', label: 'Instagram' },
+ li: { service: 'linkedin', label: 'LinkedIn' },
+ x: { service: 'twitter', label: 'Twitter/X' },
+ tweet: { service: 'twitter', label: 'Twitter/X' },
+ yt: { service: 'youtube', label: 'YouTube' },
+ gbp: { service: 'googlebusiness', label: 'Google Business' },
+ google: { service: 'googlebusiness', label: 'Google Business' },
+ googlebusinessprofile: { service: 'googlebusiness', label: 'Google Business' },
+}
+
+/* Stored posts carry the Buffer *service* ("facebook"), not the alias — views.py
+ writes `service or normalize_platform(...)`. So the lookup has to resolve both
+ directions or every table badge falls through to the raw value. */
+const SERVICE_LABELS = Object.values(PLATFORM_ALIASES).reduce((acc, { service, label }) => {
+ acc[service] = label
+ return acc
+}, {})
+
+/** Same key normalization as backend normalize_platform: strip non-alphanumerics. */
+function key(value) {
+ return String(value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
+}
+
+/** Canonical Buffer service for an alias or service string. Falls back to the key. */
+export function platformService(value) {
+ const k = key(value)
+ if (!k) return ''
+ return PLATFORM_ALIASES[k]?.service ?? k
+}
+
+/** Display label for an alias or service string. Unknown values render as-is. */
+export function platformLabel(value) {
+ const k = key(value)
+ if (!k) return ''
+ return PLATFORM_ALIASES[k]?.label ?? SERVICE_LABELS[k] ?? String(value)
+}
+
+/**
+ * Collapse raw platform strings into one option per network.
+ *
+ * Returns `{ value, label }` where `value` is the untouched wire string — the
+ * filter and any request keep sending the alias, only the option text changes.
+ * "ig" and "insta" collapse into a single Instagram entry.
+ */
+export function platformOptions(values) {
+ const seen = new Map()
+ for (const raw of values) {
+ if (!raw) continue
+ const svc = platformService(raw) || key(raw)
+ if (seen.has(svc)) continue
+ seen.set(svc, { value: raw, label: platformLabel(raw) })
+ }
+ return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label))
+}
diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js
index 24b768a..299cea5 100644
--- a/frontend/src/lib/queryKeys.js
+++ b/frontend/src/lib/queryKeys.js
@@ -22,6 +22,39 @@ export const qk = {
applications: (p = {}) => ['mailbox', 'applications', p],
message: (id) => ['mailbox', 'message', id],
assignments: (p = {}) => ['mailbox', 'assignments', p],
+ counts: () => ['mailbox', 'counts'],
+ // Under `mailbox` on purpose: every existing invalidateQueries on
+ // qk.mailbox.all() already refreshes the intake gate's ledger, so an
+ // override or a sync needs no extra invalidation.
+ triage: (p = {}) => ['mailbox', 'triage', p],
+ },
+ 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 +89,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'],
diff --git a/frontend/src/lib/useApplications.js b/frontend/src/lib/useApplications.js
new file mode 100644
index 0000000..2e084f3
--- /dev/null
+++ b/frontend/src/lib/useApplications.js
@@ -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
+}
diff --git a/frontend/src/screens/Analytics.jsx b/frontend/src/screens/Analytics.jsx
index b0a17dd..a04df92 100644
--- a/frontend/src/screens/Analytics.jsx
+++ b/frontend/src/screens/Analytics.jsx
@@ -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 (
+
+
+ {title}
{sub && {sub}}
+
+
+ {query.isPending && Fetching from the server. }
+ {query.isError && (
+
+ {friendlyAuthError(query.error, 'The server did not answer.')}
+ {permission && <> This card needs the {permission} permission.>}
+
+ )}
+ {!query.isPending && !query.isError && children(height)}
+ {!query.isPending && !query.isError && footer}
+
+
+ )
+}
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 (
@@ -96,74 +308,205 @@ export default function Analytics() {
- Week
- Month
- Quarter
+ {RANGES.map((r) => (
+ setRangeKey(r.key)}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRangeKey(r.key) } }}
+ >
+ {r.label}
+
+ ))}
-
+
+
-
-
- Hiring Trend
Hires vs applications
+ {kpisQuery.isError && (
+
-
-
+
+ {friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
+ {' '}This screen needs the analytics.view permission.
+
-
- Applications Received
Monthly volume
-
-
+ )}
+
+
+ }
+ >
+ {(h) => }
+
+
+
+ {(h) => }
+
+ }
+ >
+ {(h) => (
+ (sourcesQuery.data ?? []).length === 0
+ ? Applications are tagged once a source channel is matched.
+ :
+ )}
+
+
- Source Breakdown
+ Offer Acceptance
From the offers table
-
-
+ {offersQuery.isPending && Counting offers. }
+ {offersQuery.isError && (
+
+ {friendlyAuthError(offersQuery.error, 'The offers table did not answer.')}
+ {' '}This card needs the offers.view permission.
+
+ )}
+ {!offersQuery.isPending && !offersQuery.isError && (
+ offerSplit.empty ? (
+ This fills in once the first offer is issued.
+ ) : (
+ <>
+
+
+ Accepted
+ Pending
+ Declined
+
+ >
+ )
+ )}
-
- Offer Acceptance
-
-
-
- Accepted
- Pending
- Declined
-
-
-
-
- Pipeline Distribution
-
-
+
+
+ {(h) => (
+ pipeline.data.every((n) => !n)
+ ? Stage counts appear once applications land.
+ :
+ )}
+
- Applications by Department
Volume per team
-
-
-
- Recruiter Performance
Hires by recruiter (top 8)
-
+
+
+ Applications by Department
+
+ {department
+ ? 'Filtered to one department'
+ : `Top ${Math.min(departments.length, DEPT_CAP)} of ${departments.length}`}
+
+
+
+
+ {department ? (
+
+ Clear the department filter to compare teams.
+
+ ) : deptPending ? (
+ One read per department.
+ ) : deptRows.length === 0 ? (
+
+ Departments appear once their requisitions receive applications.
+
+ ) : (
+
+ )}
+
+
+
+ {(h) => (
+ rec.labels.length === 0
+ ? Assign recruiters to requisitions to populate this.
+ :
+ )}
+
+ }
+ >
+ {(h) => (
+ k?.time_to_hire == null && k?.time_to_fill == null
+ ? Time to hire needs at least one hire in the window.
+ :
+ )}
+
+
- Time to Hire
Days, monthly average
-
-
-
- Time to Fill
Days, monthly average
-
+ Window Summary
Totals behind the charts
+
+ {kpisQuery.isPending ? (
+ Fetching totals.
+ ) : (
+
+ Open Jobs{k?.open_jobs ?? '—'}
+ Candidates{k?.total_candidates ?? '—'}
+ Hires{k?.hires ?? '—'}
+ Offers Sent{k?.offers_sent ?? '—'}
+ Offers Accepted{k?.offers_accepted ?? '—'}
+
+ Cost per Hire
+
+ {k?.cost_per_hire != null ? `$${Math.round(k.cost_per_hire).toLocaleString()}` : '—'}
+
+
+ Closed Jobs{k?.closed_jobs ?? '—'}
+
+ Interviews Today
+ {k?.interviews_today ?? '—'}
+
+
+ )}
+
+ Every figure here respects the range, department and recruiter filters above.
+
+
diff --git a/frontend/src/screens/Assessments.jsx b/frontend/src/screens/Assessments.jsx
index e1e19af..3e57097 100644
--- a/frontend/src/screens/Assessments.jsx
+++ b/frontend/src/screens/Assessments.jsx
@@ -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) => (
-
+
{a.candidate}
{a.jobTitle}
@@ -74,16 +149,35 @@ export default function Assessments() {
>
),
},
- { key: 'assigned', label: 'Assigned', sortable: true, sortValue: (a) => a.assigned.getTime(), render: (a) => {fmtShort(a.assigned)} },
- { key: 'due', label: 'Due', sortable: true, sortValue: (a) => a.due.getTime(), render: (a) => {fmtShort(a.due)} },
- { key: 'score', label: 'Score', sortable: true, align: 'center', render: (a) => (a.score !== null ? : —) },
+ {
+ key: 'assigned', label: 'Assigned', sortable: true,
+ sortValue: (a) => (a.assigned ? a.assigned.getTime() : 0),
+ render: (a) => {a.assigned ? fmtShort(a.assigned) : '—'},
+ },
+ {
+ key: 'due', label: 'Due', sortable: true,
+ sortValue: (a) => (a.due ? a.due.getTime() : 0),
+ render: (a) => {a.due ? fmtShort(a.due) : '—'},
+ },
+ {
+ key: 'score', label: 'Score', sortable: true, align: 'center',
+ render: (a) => (a.score !== null ? : —),
+ },
{ key: 'status', label: 'Status', sortable: true, render: (a) => {a.status} },
{
key: '_a', label: 'Actions', align: 'right',
render: (a) => (
-
+
),
},
@@ -97,37 +191,58 @@ export default function Assessments() {
Coding tests, take-homes, and evaluations
-
-
-
-
-
+
+
+
+
-
-
-
-
- setQ(e.target.value)} placeholder="Search candidate or assessment…" />
-
-
-
+ {listQuery.isPending && (
+
+ Fetching assessments from the server.
-
-
+ )}
+ {listQuery.isError && (
+
+
+ {friendlyAuthError(listQuery.error, 'Request failed')}
+
+
+ )}
+ {!listQuery.isPending && !listQuery.isError && (
+ <>
+
+
+
+
+ setQ(e.target.value)} placeholder="Search candidate or assessment…" />
+
+
+
+
+
+
+ >
+ )}
{viewing && (
@@ -137,22 +252,31 @@ export default function Assessments() {
onClose={() => setViewing(null)}
footer={
<>
+ {canDelete && (
+ remove.mutate(viewing.id)}
+ >
+ {remove.isPending ? 'Deleting…' : 'Delete'}
+
+ )}
setViewing(null)}>Close
{
- const id = viewing.candidateId
setViewing(null)
- navigate('/candidates', { state: { openCandidate: id } })
+ navigate('/candidates')
}}
>
- View Candidate
+ View Candidates
>
}
>
-
+
{viewing.candidate}
{viewing.type} · {viewing.jobTitle}
@@ -163,8 +287,8 @@ export default function Assessments() {
Type{viewing.type}
Duration{viewing.duration}
- Assigned{fmtDate(viewing.assigned)}
- Due{fmtDate(viewing.due)}
+ Assigned{viewing.assigned ? fmtDate(viewing.assigned) : '—'}
+ Due{viewing.due ? fmtDate(viewing.due) : '—'}
{viewing.score !== null ? (
@@ -182,14 +306,18 @@ export default function Assessments() {
Overall Score
- Section Breakdown
- {sectionScores.map((s) => (
-
- {s.label}
-
- {s.score}%
-
- ))}
+ {viewing.sectionScores.length > 0 && (
+ <>
+ Section Breakdown
+ {viewing.sectionScores.map((s) => (
+
+ {s.label}
+
+ {s.score}%
+
+ ))}
+ >
+ )}
>
) : (
@@ -202,47 +330,120 @@ export default function Assessments() {
)}
{assigning && (
- setAssigning(false)}
- footer={
- <>
- setAssigning(false)}>Cancel
- {
- setAssigning(false)
- toast('Assessment assigned & invite sent', 'success')
- }}
- >
- Assign
-
- >
- }
- >
-
-
+ onSave={(body) => create.mutate(body)}
+ />
)}
)
}
+
+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 (
+
+ Cancel
+
+ {pending ? 'Assigning…' : 'Assign'}
+
+ >
+ }
+ >
+
+
+ )
+}
diff --git a/frontend/src/screens/Calendar.jsx b/frontend/src/screens/Calendar.jsx
index 42bdb66..9c5660e 100644
--- a/frontend/src/screens/Calendar.jsx
+++ b/frontend/src/screens/Calendar.jsx
@@ -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 (
Calendar
- Interview schedule at a glance
+
+ Interview schedule at a glance
+ {monthQuery.isSuccess ? ` · ${events.length} this month` : ''}
+
@@ -64,6 +136,12 @@ export default function Calendar() {
+ setView({ year: today.getFullYear(), month: today.getMonth() })}
+ >
+ Today
+
navigate('/interviews', { state: { openSchedule: true } })}
@@ -73,77 +151,92 @@ export default function Calendar() {
-
+ {monthQuery.isError ? (
-
- {DOW.map((d) => {d})}
- {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 (
-
- {c.day}
- {dayEvents.slice(0, 3).map((iv) => (
- openCandidate(iv.candidateId)}
- >
- {iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
-
- ))}
- {dayEvents.length > 3 && (
- +{dayEvents.length - 3} more
- )}
-
- )
- })}
-
+
+ {friendlyAuthError(monthQuery.error, 'The server did not return interviews.')}
+ {' '}This screen needs the candidates.view permission.
+
+ ) : (
+
+
+
+
+ {DOW.map((d) => {d})}
+ {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 (
+
+ {c.day}
+ {dayEvents.slice(0, 3).map((iv) => (
+ openCandidate(iv.userId)}
+ >
+ {iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]}
+
+ ))}
+ {dayEvents.length > 3 && (
+ +{dayEvents.length - 3} more
+ )}
+
+ )
+ })}
+
+
+
-
-
-
- Today
-
- {TODAY.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
-
+
+
+
+ Today
+
+ {today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
+
+
-
-
-
- {todayIvs.length === 0 ? (
- No interviews today
- ) : (
- todayIvs.map((iv) => (
- openCandidate(iv.candidateId)}
- >
-
-
- {iv.candidate}
- {iv.type}
-
-
-
- {iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+
+
+ {monthQuery.isPending ? (
+ Loading…
+ ) : todayIvs.length === 0 ? (
+ No interviews today
+ ) : (
+ todayIvs.map((iv) => (
+ openCandidate(iv.userId)}
+ >
+
+
+ {iv.candidate}
+ {iv.type}
+
+
+
+ {iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+
-
- ))
- )}
+ ))
+ )}
+
-
+ )}
)
}
diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx
index 67e7dbe..d68fc32 100644
--- a/frontend/src/screens/CandidateProfile.jsx
+++ b/frontend/src/screens/CandidateProfile.jsx
@@ -25,8 +25,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
-import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
+import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } 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'
@@ -139,6 +140,15 @@ export default function CandidateProfile({
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
})
+ // Same PATCH as favorite: the server writes rating onto every inbox row the
+ // candidate owns and returns the refreshed detail payload.
+ const rating = Number(live?.rating ?? 0)
+ const setRating = useProfileWrite({
+ userId: c.userId,
+ mutationFn: (next) => candidatesApi.update(c.userId, { rating: next }),
+ success: (next) => `Rating saved — ${next}/5`,
+ })
+
// Score the candidate's inbox CV against the assigned job with the ATS engine.
// Needs both an assigned job (what to score against) and a message (whose
// attachment to score); the refetch lands the new ai_score in this modal.
@@ -202,9 +212,6 @@ export default function CandidateProfile({
onAtsMatch(c)}>
ATS Match
- toast('Email drafted', 'info')}>
- Message
-
{ onAdvance(c); onClose() }}>
Advance Stage
@@ -256,7 +263,17 @@ export default function CandidateProfile({
-
+
+ Rating
+
+ setRating.mutate(n)}
+ />
+ {rating.toFixed(1)} / 5.0
+
+
@@ -453,7 +470,7 @@ export default function CandidateProfile({
)))}
{tab === 'Documents' && (guard || (live ? (
-
+
) : (
{[
@@ -721,16 +738,7 @@ function NotesTab({ userId, rows }) {
{rows.length ? (
- {rows.map((n) => (
-
-
-
- {n.created_by_name || 'Unknown author'}
- {n.note}
- {fmtWhen(n.created_at)}
-
-
- ))}
+ {rows.map((n) => )}
) : (
The first note on this candidate goes above.
@@ -739,6 +747,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 (
+
+
+
+
+
+
+ save.mutate()}
+ >
+ {save.isPending ? 'Saving…' : 'Save'}
+
+ { setText(n.note ?? ''); setEditing(false) }}
+ >
+ Cancel
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {n.created_by_name || 'Unknown author'}
+ {n.note}
+
+ {fmtWhen(n.created_at)}
+ {n.updated_at && n.updated_at !== n.created_at ? ' · edited' : ''}
+
+
+ {mine && (
+
+ setEditing(true)}>
+
+
+
+ )}
+
+ )
+}
+
function ActivityTab({ userId, inboxId, rows }) {
const { toast } = useToast()
const [form, setForm] = useState({ type: ACTIVITY_TYPES[0], description: '' })
@@ -818,31 +899,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 This application arrived without attachments.
}
return (
- <>
-
- {rows.map((d, i) => (
-
-
-
-
-
- {d.name}
- {d.path || 'Stored with the application'}
+
+ {rows.map((d, i) => (
+
+
+
+
+
+ {d.name}
+ Stored with the application
+
+ download.mutate({ index: i, filename: d.name })}
+ >
+
+
+
+ ))}
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+
+
+
+
+ set('score', e.target.value)}
+ />
- ))}
+
+
+
+
+
+ {save.isPending ? 'Saving…' : 'Save'}
+
+ {
+ setForm({
+ review: f.review || REVIEWS[0],
+ score: f.score == null ? '' : String(f.score),
+ note: f.note || '',
+ })
+ setEditing(false)
+ }}
+ >
+ Cancel
+
+
+
- {/* No download button: attachments live on the worker's filesystem and no
- route serves them yet, so a button here could only lie. */}
-
- Attachments are stored server-side; download is not exposed yet.
-
- >
+ )
+ }
+
+ return (
+
+
+
+ {f.reviewed_by_name || 'Unknown reviewer'}
+ {f.note && {f.note}}
+
+ {fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}
+ {f.updated_at && f.updated_at !== f.created_at ? ' · revised' : ''}
+
+
+
+ {f.review ? {f.review} : null}
+ {mine && (
+ setEditing(true)}>
+
+
+ )}
+
+
)
}
@@ -876,17 +1080,7 @@ function FeedbackTab({ userId, inboxId, rows }) {
<>
{rows.length ? (
- {rows.map((f) => (
-
-
-
- {f.reviewed_by_name || 'Unknown reviewer'}
- {f.note && {f.note}}
- {fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}
-
- {f.review ? {f.review} : null}
-
- ))}
+ {rows.map((f) => )}
) : (
Be the first to review this candidate.
diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx
index 3f2ffcb..7e65e1f 100644
--- a/frontend/src/screens/Candidates.jsx
+++ b/frontend/src/screens/Candidates.jsx
@@ -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 (
}
>
-
-
-
-
-
- {recommendation}
- {c.name}{jobTitle ? ` for ${jobTitle}` : ''}
-
-
-
-
-
-
-
- {c.aiScore}
- ATS MATCH
-
+ {pending ? (
+
+ Fetching the ATS result.
+
+ ) : score == null ? (
+
+ {ats.isError
+ ? friendlyAuthError(ats.error, 'The ATS result could not be loaded.')
+ : 'This candidate has not been scored against a job post.'}
+
+ ) : (<>
+
+
+
+
+
+ {recommendation}
+ {c.name}{against ? ` for ${against}` : ''}
-
- Assessment
- {c.critique ?? '—'}
+
+
+
+ {/* overall_score is a float column; the ring and the number want an int. */}
+
+
+ {Math.round(score)}
+ ATS MATCH
+
+
+
+
+ Assessment
+ {critique ?? '—'}
+
-
-
- Matched Skills ({c.matchedSkills.length})
-
-
- {c.matchedSkills.length
- ? c.matchedSkills.map((s) => (
- {s}
- ))
- : —}
-
+
+ Scored Against{against ?? '—'}
+ Scored On{scoredOn ?? '—'}
+
-
- Missing Skills ({c.missingSkills.length})
-
-
- {c.missingSkills.length
- ? c.missingSkills.map((s) => (
- {s}
- ))
- : None — full match}
-
+
+ Matched Skills ({matched.length})
+
+
+ {matched.length
+ ? matched.map((s) => (
+ {s}
+ ))
+ : —}
+
-
-
- 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.
-
+
+ Missing Skills ({missing.length})
+
+
+ {missing.length
+ ? missing.map((s) => (
+ {s}
+ ))
+ : None — full match}
+
+
+
+
+ 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.
+
+ >)}
)
}
diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx
index 63a4b23..590939e 100644
--- a/frontend/src/screens/CvImport.jsx
+++ b/frontend/src/screens/CvImport.jsx
@@ -14,6 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
+import JobCandidates from './JobCandidates'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
@@ -262,6 +263,11 @@ export default function CvImport() {
+
+ {/* Everything ever scored against the selected job — this batch, earlier
+ uploads and synced inbox CVs alike. The scoring mutation invalidates
+ qk.candidates.all(), so the grid refreshes as each batch lands. */}
+
)
}
diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx
index 3da77de..57fa3f6 100644
--- a/frontend/src/screens/Dashboard.jsx
+++ b/frontend/src/screens/Dashboard.jsx
@@ -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"
+ * 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 {permission} 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 (
- {friendlyAuthError(query.error, `The server did not return ${title}.`)}
- {' '}This widget needs the {permission} permission.
+ {widgetError(query.error, permission, `The server did not return ${title}.`)}
)
}
@@ -373,8 +395,7 @@ export default function Dashboard() {
{trendQuery.isError ? (
- {friendlyAuthError(trendQuery.error, 'The server did not return the trend.')}
- {' '}This widget needs the analytics.view permission.
+ {widgetError(trendQuery.error, 'analytics.view', 'The server did not return the trend.')}
) : (
<>
@@ -396,8 +417,7 @@ export default function Dashboard() {
{funnelQuery.isError ? (
- {friendlyAuthError(funnelQuery.error, 'The server did not return the funnel.')}
- {' '}This widget needs the analytics.view permission.
+ {widgetError(funnelQuery.error, 'analytics.view', 'The server did not return the funnel.')}
) : asList(funnelQuery.data).length === 0 && funnelQuery.isSuccess ? (
@@ -468,8 +488,7 @@ export default function Dashboard() {
{sourcesQuery.isError ? (
- {friendlyAuthError(sourcesQuery.error, 'The server did not return source analytics.')}
- {' '}This widget needs the analytics.view permission.
+ {widgetError(sourcesQuery.error, 'analytics.view', 'The server did not return source analytics.')}
) : asList(sourcesQuery.data).length === 0 && sourcesQuery.isSuccess ? (
diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx
index ee1ddfd..573ee47 100644
--- a/frontend/src/screens/Inbox.jsx
+++ b/frontend/src/screens/Inbox.jsx
@@ -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,67 @@ 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']
+const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Filtered Out', '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".
+ * Tabs that do not read /inbox/all-applications at all. Email reads
+ * /inbox/fetch; Filtered Out reads /inbox/triage, whose rows are the mail that
+ * never became an inbox row and so cannot appear in the applications list.
+ */
+const SPECIAL_TABS = new Set(['Email', 'Filtered Out'])
+
+/**
+ * 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' },
+}
+
+/**
+ * reason_code -> the chip the Filtered Out tab paints. Mirrors
+ * Triage_Reason_Code in backend/inbox_classifier/enums.py; an unknown code
+ * falls through to the raw string rather than rendering blank, so adding a
+ * label server-side degrades visibly instead of silently.
+ */
+const TRIAGE_REASONS = {
+ job_application: { label: 'Job application', cls: 'b-green' },
+ recruiter_or_vendor: { label: 'Recruiter / vendor', cls: 'b-amber' },
+ newsletter_or_marketing: { label: 'Newsletter', cls: 'b-gray' },
+ internal_or_scheduling: { label: 'Internal', cls: 'b-blue' },
+ automated_notification: { label: 'Automated', cls: 'b-gray' },
+ other: { label: 'Other', cls: 'b-gray' },
+}
+
+/**
+ * `unclassified:` is what should_ingest stamps when the model could not be
+ * consulted at all — no API key, a timeout, a refusal. Those rows were ingested
+ * anyway (INBOX_TRIAGE_FAIL_OPEN defaults true), so they are a provider-health
+ * signal, not a filtering decision, and get their own chip.
+ */
+function triageReason(code) {
+ const raw = code || ''
+ if (raw.startsWith('unclassified:')) {
+ return { label: `Unclassified · ${raw.slice('unclassified:'.length)}`, cls: 'b-red' }
+ }
+ return TRIAGE_REASONS[raw] || { label: raw || 'Unknown', cls: 'b-gray' }
+}
+
+/** The three views over the ledger. `undefined` sends no is_application param. */
+const TRIAGE_VIEWS = {
+ 'Filtered out': false,
+ Kept: true,
+ All: undefined,
}
/** 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 +180,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 +197,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 +216,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 +231,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 +274,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)
@@ -258,24 +291,17 @@ export default function Inbox() {
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(tabFilter),
queryFn: () => fetchApplications(tabFilter),
- enabled: tab !== 'Email',
+ enabled: !SPECIAL_TABS.has(tab),
})
- /**
- * 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(),
@@ -306,31 +332,59 @@ export default function Inbox() {
enabled: tab === 'Email',
})
+ // Hoisted like emailsQuery so `total` can badge the tab. Default view is the
+ // rejections: the mail the gate kept is already visible in every other tab.
+ const [triageView, setTriageView] = useState('Filtered out')
+ const triageFilter = { isApplication: TRIAGE_VIEWS[triageView] }
+
+ const triageQuery = useQuery({
+ queryKey: qk.mailbox.triage(triageFilter),
+ queryFn: async () => {
+ const res = await inboxApi.listTriage(triageFilter)
+ const rows = Array.isArray(res?.data) ? res.data : []
+ return {
+ total: res?.total ?? rows.length,
+ rows: rows.map((row) => ({
+ id: String(row.id),
+ messageId: row.message_id || '',
+ isApplication: Boolean(row.is_application),
+ reason: triageReason(row.reason_code),
+ confidence: typeof row.confidence === 'number' ? row.confidence : null,
+ evidence: row.evidence || '',
+ status: row.status || '',
+ from: row.fromEmail || 'Unknown',
+ subject: row.subject || '(no subject)',
+ when: parseDate(row.when),
+ attachment: row.attachment || '',
+ hasAttachment: Boolean(row.has_attachment),
+ ingested: Boolean(row.ingested),
+ overriddenAt: parseDate(row.overridden_at),
+ })),
+ }
+ },
+ enabled: tab === 'Filtered Out',
+ })
+
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,
+ 'Filtered Out': triageQuery.data?.total ?? 0,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}),
- [allApplications, emailsQuery.data],
+ [serverCounts, emailsQuery.data, triageQuery.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
@@ -342,7 +396,7 @@ export default function Inbox() {
const detailQuery = useQuery({
queryKey: qk.mailbox.message(selectedId),
queryFn: () => fetchMessageDetail(selectedId),
- enabled: tab !== 'Email' && Boolean(selectedId),
+ enabled: !SPECIAL_TABS.has(tab) && Boolean(selectedId),
})
const selectedRow = inbox.find((i) => i.id === selectedId)
@@ -350,63 +404,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 +490,14 @@ export default function Inbox() {
{tab === 'Email' ? (
-
+
+ ) : tab === 'Filtered Out' ? (
+
) : (
@@ -521,12 +575,14 @@ export default function Inbox() {
setPreviewing(selected)}
onImport={() => importItem(selected)}
- onParse={() => parseResume(selected)}
+ onParse={() => parseResume()}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
+ onToggleDuplicate={() => toggleDuplicate(selected)}
/>
)}
@@ -546,8 +602,6 @@ export default function Inbox() {
{ const it = previewing; setPreviewing(null); importItem(it) }}
- disabled
- title="Needs a backend endpoint — not implemented yet"
>
Import Candidate
@@ -602,13 +656,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 (
@@ -619,6 +672,7 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
{i.position}
{i.processing} {' '}
+ {i.duplicate && <>Duplicate {' '}>}
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<>{i.applicationStatus} {' '}>
)}
@@ -647,8 +701,6 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
Received
{i.received ? fmtDate(i.received) : '—'}
- {/* Only present once GET /inbox/fetch?record_id= has resolved — the list
- endpoint carries none of these. */}
{i.sentAt && (
Sent{fmtDate(i.sentAt)}
)}
@@ -662,13 +714,17 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
)}
- {/* 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 && (
-
- {i.body || This email has no message body.}
+
+ Subject: {i.position || '(no subject)'}
+ {looksLikeHtml(i.bodyHtml) ? (
+
+ ) : (
+
+ {i.body || 'This email has no message body.'}
+
+ )}
)}
@@ -684,8 +740,6 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
Preview
- {/* The real extracted PDF text (inbox_messages.resume_text), written
- by the matching task. Empty until that task has run. */}
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
@@ -694,10 +748,10 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onM
)}
-
- Import Candidate
+
+ {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
-
+
Parse Resume
Assign Job
-
+
Move to Pipeline
-
+
Add Note
+
+ {i.duplicate ? 'Clear Duplicate' : 'Mark Duplicate'}
+
- Reject
+ Reject
@@ -754,11 +810,135 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) {
)
}
+/* ============================================================
+ Filtered Out — the intake gate's ledger (GET /inbox/triage).
+
+ Every inbound mail is classified on subject + body and only the job
+ applications get an inbox_messages row, so this tab is the ONLY place the
+ dropped mail is visible. That is the point: a hard gate's real risk is the
+ silent false negative, and Restore is what makes one recoverable.
+
+ There is no body to preview here — the gate stores the verdict, never the
+ mail. Restore re-fetches the original from upstream and runs it through the
+ normal ingestion path.
+ ============================================================ */
+function TriageTab({ query, view, onView, toast }) {
+ const qc = useQueryClient()
+ const rows = query.data?.rows ?? []
+ const total = query.data?.total ?? 0
+
+ const override = useMutation({
+ mutationFn: ({ id, isApplication }) => inboxApi.overrideTriage(id, isApplication),
+ onSuccess: (_d, vars) => {
+ toast(
+ vars.isApplication
+ ? 'Restored — the message is being imported and matched.'
+ : 'Marked as not an application.',
+ 'success',
+ )
+ },
+ onError: (err) => toast(friendlyAuthError(err, 'Could not update the verdict.'), 'error'),
+ // qk.mailbox.all() covers the ledger, the application lists and the counts:
+ // restoring writes a real inbox row, so all three are stale at once.
+ onSettled: () => {
+ qc.invalidateQueries({ queryKey: qk.mailbox.all() })
+ qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
+ },
+ })
+
+ const pendingId = override.isPending ? override.variables?.id : null
+
+ return (
+ <>
+
+ Intake gate · OpenAI
+
+ {query.isPending ? 'Loading…' : query.isError ? 'Could not load' : `${total} message${total === 1 ? '' : 's'}`}
+
+
+ {Object.keys(TRIAGE_VIEWS).map((key) => (
+ onView(key)}
+ >
+ {key}
+
+ ))}
+
+
+
+
+ {query.isPending && (
+ Fetching the intake ledger.
+ )}
+ {query.isError && (
+
+ {friendlyAuthError(query.error, 'Request failed')}
+
+ )}
+ {query.isSuccess && rows.length === 0 && (
+
+ Every message the gate has seen was judged a job application.
+
+ )}
+ {query.isSuccess && rows.map((r) => (
+
+
+
+ {r.subject}
+ {r.from}
+
+ {r.reason.label}
+ {r.confidence != null && (
+ {Math.round(r.confidence * 100)}% confident
+ )}
+ {r.hasAttachment && (
+
+ {r.attachment || 'attachment'}
+
+ )}
+ {r.ingested && Kept }
+ {r.overriddenAt && Overridden }
+
+ {r.evidence && (
+ {r.evidence}
+ )}
+
+
+ {r.when ? fmtShort(r.when) : '—'}
+ {r.isApplication ? (
+ override.mutate({ id: r.id, isApplication: false })}
+ >
+ Not an application
+
+ ) : (
+ override.mutate({ id: r.id, isApplication: true })}
+ >
+ {pendingId === r.id ? 'Restoring…' : 'Restore'}
+
+ )}
+
+
+ ))}
+
+ >
+ )
+}
+
/** 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,52 +946,54 @@ 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 () => {
+ onSuccess: async (res) => {
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
- toast('Mailbox synced', 'success')
+ // /email/fetch now reports what the intake gate did with the page. The
+ // key is additive and absent when the gate is disabled, so fall back to
+ // the old message rather than rendering "undefined filtered out".
+ const t = res?.triage
+ toast(
+ t
+ ? `Mailbox synced — ${t.ingested} imported, ${t.skipped} filtered out`
+ : 'Mailbox synced',
+ 'success',
+ )
},
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 (
<>
@@ -871,10 +1053,6 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
) : (
-
- {selected.subject}
- {isImported(selected) ? Imported : New }
-
@@ -885,10 +1063,22 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
- {/* Rendered as TEXT. This is js/inbox.js:292, the widest XSS sink
- in the prototype, and inbound mail is attacker-supplied. */}
-
- {selected.body}
+ {/* Same Subject-strip + framed-body template as /matching. It replaces
+ the old 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. */}
+
+
+ Subject: {selected.subject || '(no subject)'}
+ {isImported(selected) ? Imported : New }
+
+ {looksLikeHtml(selected.body) ? (
+
+ ) : (
+
+ {htmlToText(selected.body) || 'This email has no message body.'}
+
+ )}
@@ -897,32 +1087,62 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
{selected.attachment}
{selected.attachmentSize} · PDF
-
- toast('Opening attachment preview', 'info')}>
- Preview
-
-
{isImported(selected) ? (
Already Imported
) : (
- importEmail(selected)}>
+ importMsg.mutate(selected.id)}
+ >
Import Candidate
)}
- toast('Reply drafted', 'info')}>
+ { setReplying(selected); setReplyBody('') }}>
Reply
- toast('Email archived', 'info')}>
- Archive
-
)}
+
+ {replying && (
+ setReplying(null)}
+ footer={
+ <>
+ setReplying(null)} disabled={reply.isPending}>Cancel
+ reply.mutate({ recordId: replying.id, body: replyBody.trim() })}
+ >
+ {reply.isPending ? 'Sending…' : 'Send Reply'}
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+ )}
>
)
}
diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx
index 7199313..5f1d1d1 100644
--- a/frontend/src/screens/Interviews.jsx
+++ b/frontend/src/screens/Interviews.jsx
@@ -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',
+}
+
+/** + -> 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) => (
-
+
{iv.candidate}
- {iv.jobTitle}
+ {iv.jobTitle || '—'}
),
},
{ key: 'type', label: 'Round', sortable: true, render: (iv) => {iv.type} },
{
- 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) => (
<>
- {fmtShort(iv.when)}
-
- {iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} · {iv.duration}m
-
+ {iv.when ? fmtShort(iv.when) : '—'}
+ {clock(iv.when)}
>
),
},
{
- key: 'meeting', label: 'Type',
- render: (iv) => (
-
-
- {iv.meeting}
-
- ),
+ key: 'status', label: 'Status', sortable: true,
+ render: (iv) => {iv.status} ,
},
- { key: 'interviewers', label: 'Interviewers', render: (iv) => },
- { key: 'status', label: 'Status', sortable: true, render: (iv) => {iv.status} },
- { key: 'feedback', label: 'Feedback', render: (iv) => (iv.feedback ? {iv.feedback} : —) },
{
key: '_a', label: 'Actions', align: 'right',
render: (iv) => (
navigate('/candidates', { state: { openCandidate: iv.candidateId } })}
+ disabled={!iv.userId}
+ onClick={() => navigate('/candidates', { state: { openCandidate: iv.userId } })}
>
- setFeedbackFor(iv)}>
+ {iv.status === 'Scheduled' && (
+ setStatusMutation.mutate({ id: iv.id, next: 'Completed' })}
+ >
+
+
+ )}
+ setFeedbackFor(iv)}>
@@ -110,6 +228,8 @@ export default function Interviews() {
},
]
+ const listError = listQuery.isError
+
return (
@@ -126,10 +246,10 @@ export default function Interviews() {
-
-
-
-
+
+
+
+
@@ -139,40 +259,65 @@ export default function Interviews() {
- setQ(e.target.value)} placeholder="Search candidate or interviewer…" />
+ setQ(e.target.value)} placeholder="Search candidate or role…" />
-
+
+ {listQuery.isPending && (
+
+ Fetching interviews from the server.
+
+ )}
+ {listError && (
+
+
+ {friendlyAuthError(listQuery.error, 'The server did not return interviews.')}
+ {' '}This screen needs the candidates.view permission.
+
+
+ )}
+ {!listQuery.isPending && !listError && (
+
+ )}
Up Next
Scheduled sessions
- {upcoming.map((iv) => (
-
-
-
- {iv.candidate}
- {iv.type} · {iv.meeting}
-
-
- {fmtShort(iv.when)}
-
- {iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
+ {upcoming.length === 0 ? (
+
+ {allQuery.isPending ? 'Loading…' : 'Nothing scheduled ahead.'}
+
+ ) : (
+ upcoming.map((iv) => (
+
+
+
+ {iv.candidate}
+ {iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}
+
+
+ {iv.when ? fmtShort(iv.when) : '—'}
+ {clock(iv.when)}
-
- ))}
+ ))
+ )}
@@ -181,45 +326,26 @@ export default function Interviews() {
{feedbackFor && (
setFeedbackFor(null)}
- onSubmit={() => { setFeedbackFor(null); toast('Scorecard submitted', 'success') }}
+ onSaved={() => { setFeedbackFor(null); invalidate() }}
toast={toast}
/>
)}
{scheduling && (
setScheduling(false)}
- onSubmit={() => { setScheduling(false); toast('Interview scheduled & invite sent', 'success') }}
+ onSubmit={(body) => create.mutate(body)}
+ toast={toast}
/>
)}
)
}
-/** Star rating — replaces the imperative Interviews._bindStars() DOM toggling. */
-function Stars({ value, onChange }) {
- return (
-
- {[1, 2, 3, 4, 5].map((n) => (
- onChange(n)}
- role="radio"
- aria-checked={n === value}
- tabIndex={0}
- onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onChange(n) } }}
- >
-
-
- ))}
-
- )
-}
-
function CriteriaList({ criteria, ratings, setRating }) {
return criteria.map((c) => (
@@ -229,39 +355,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 (
Cancel
- Submit Scorecard
+
+ {save.isPending ? 'Submitting…' : 'Submit Scorecard'}
+
>
}
>
-
+
{iv.candidate}
- {iv.type} · {iv.jobTitle}
+ {iv.type}{iv.jobTitle ? ` · ${iv.jobTitle}` : ''}
- {iv.status}
+ {iv.status}
@@ -271,75 +458,107 @@ function Scorecard({ interview: iv, jobs, onClose, onSubmit, toast }) {
tabs={[
{ key: 'form', label: 'Dynamic Form' },
{ key: 'upload', label: 'Upload Sheet' },
- { key: 'both', label: 'Both' },
]}
/>
{tab === 'form' && (
-
-
-
-
-
-
-
-
-
-
-
-
- {['Hire', 'Hold', 'Reject'].map((r) => (
- setRecommendation(r)}
- >
- {r}
-
- ))}
-
-
+ {templatesQuery.isPending && (
+ Fetching scorecard templates.
+ )}
+ {templatesQuery.isError && (
+
+ {friendlyAuthError(templatesQuery.error, 'Request failed')}
+
+ )}
+ {templatesQuery.isSuccess && templates.length === 0 && (
+
+ Create one from Settings before scoring an interview.
+
+ )}
+ {template && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {['Strong Hire', 'Hire', 'Lean Hire', 'No Hire'].map((r) => (
+ setRecommendation(r)}
+ >
+ {r}
+
+ ))}
+
+
+ >
+ )}
)}
{tab === 'upload' && (
- toast('File picker (demo)', 'info')}>
-
- Upload evaluation sheet
- PDF, DOC, or DOCX · scanned scorecards supported
-
- {['PDF', 'DOC', 'DOCX'].map((t) => {t})}
-
-
-
- )}
-
- {tab === 'both' && (
-
-
- Capture structured ratings and attach a signed sheet — both are stored on the scorecard.
-
-
-
-
-
- Interviewer_Scorecard.pdf
- Attached · 214 KB
-
- Uploaded
-
+
+ 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.
+
)}
)
}
-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 (
- Cancel
- Schedule
+ Cancel
+
+ {busy ? 'Scheduling…' : 'Schedule'}
+
>
}
>
-
)
diff --git a/frontend/src/screens/JobBoard.jsx b/frontend/src/screens/JobBoard.jsx
index 620638e..b468588 100644
--- a/frontend/src/screens/JobBoard.jsx
+++ b/frontend/src/screens/JobBoard.jsx
@@ -1,101 +1,249 @@
-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.
+
+ Platform naming is rendered through lib/platforms: /jobs/alias hands back the
+ raw spelling-tolerance keys ("fb", "insta", "gbp"), which are input vocabulary,
+ not networks. The alias stays the value sent on the wire; only the text the
+ recruiter reads is the label.
+ ============================================================ */
+
+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 { platformLabel, platformOptions, platformService } from '../lib/platforms'
+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],
+ /* One option per network, not per alias: "ig" and "insta" are the same
+ destination. `value` stays the raw platform string so the filter (and any
+ request built from it) still carries the alias the backend resolves. */
+ const platformChoices = useMemo(
+ () => platformOptions(posts.map((p) => p.platform)),
+ [posts],
)
+ const statusOptions = useMemo(
+ () => [...new Set(posts.map((p) => badgeFor(p.status).label))].sort(),
+ [posts],
+ )
+
+ const rows = useMemo(
+ () => posts.filter((p) => {
+ // Compare on the resolved service so picking Instagram matches posts
+ // stored as "ig", "insta" and "instagram" alike.
+ if (platform && platformService(p.platform) !== platformService(platform)) return false
+ if (status && badgeFor(p.status).label !== status) return false
+ if (q) {
+ // Search both spellings: someone types "Facebook", the row stores "fb".
+ const hay = `${p.title} ${p.platform} ${platformLabel(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. `serviceKey` is Buffer's own name for the
+ network — what the alias list is keyed on, and what the dedupe compares;
+ `service` is the label rendered under the channel name. */
+ const destinations = useMemo(() => {
+ const connected = channels.map((ch) => ({
+ key: String(ch.id),
+ name: ch.displayName || ch.name || String(ch.id),
+ serviceKey: ch.service ? String(ch.service) : null,
+ service: ch.service ? platformLabel(ch.service) : null,
+ connected: true,
+ posts: posts.filter((p) => String(p.channelId) === String(ch.id)).length,
+ }))
+ const known = new Set(connected.map((c) => platformService(c.serviceKey || c.name || '')).filter(Boolean))
+ const unconnected = []
+ for (const a of aliases) {
+ const service = platformService(a)
+ // Aliases are spelling tolerance, not distinct destinations — collapse
+ // ig/insta and gbp/google/googlebusinessprofile into one card each.
+ if (!service || known.has(service)) continue
+ known.add(service)
+ unconnected.push({
+ key: `alias:${service}`,
+ name: platformLabel(a),
+ service: null,
+ connected: false,
+ posts: posts.filter((p) => platformService(p.platform) === service).length,
+ })
+ }
+ return [...connected, ...unconnected]
+ }, [channels, aliases, posts])
const columns = [
{
- key: 'jobTitle', label: 'Job', sortable: true,
- render: (p) => (<>{p.jobTitle}{p.jobId}>),
+ key: 'title', label: 'Job', sortable: true,
+ render: (p) => (
+ <>
+ {p.title}
+
+ {[p.location, p.employmentType].filter(Boolean).join(' · ') || '—'}
+
+ >
+ ),
},
{
key: 'platform', label: 'Platform', sortable: true,
+ sortValue: (p) => platformLabel(p.platform),
+ render: (p) => {platformLabel(p.platform)} ,
+ },
+ {
+ 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 (
-
-
-
-
- {p.platform}
-
+ <>
+ {b.label}
+ {p.error && {p.error}}
+ >
)
},
},
{
- key: 'status', label: 'Status', sortable: true,
- render: (p) => (
-
- {p.status}
-
- ),
+ key: 'sentAt', label: 'Sent', sortable: true,
+ sortValue: (p) => (p.sentAt ? p.sentAt.getTime() : 0),
+ render: (p) => {p.sentAt ? fmtShort(p.sentAt) : '—'},
},
- { 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) => {p.apps} },
{
- 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) => (
-
- {p.views ? ((p.apps / p.views) * 100).toFixed(1) : '0.0'}%
-
+ <>
+ {p.created ? fmtShort(p.created) : '—'}
+ {p.createdBy && {p.createdBy}}
+ >
),
},
{
key: '_a', label: '', align: 'right',
render: (p) => (
- toast(`Managing ${p.platform} posting`, 'info')}>
-
-
+
+ {p.link ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
),
},
]
@@ -105,240 +253,126 @@ export default function JobBoard() {
Job Board
- Publish requisitions across channels and track performance
+ Where each requisition was published, and whether it landed
Analytics
- setPublishing({})}>
+ navigate('/jobs', { state: { openCreate: true } })}
+ >
Publish a Job
-
-
-
-
+
+
+
+
-
-
- Platform Performance
Applications by channel
-
+
+
+
+ Destinations
+ Connected Buffer channels and known platforms
+
-
- Connected Platforms
-
-
- {publishPlatforms.map((p) => (
-
-
-
- {p.name}
- {p.cost === 'Free' ? 'Free posting' : `Paid · ${p.cost}`}
+
+ {channelsQuery.isPending && aliasQuery.isPending && (
+ Fetching connected channels.
+ )}
+ {channelsQuery.isError && (
+
+ {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.
+
+ )}
+ {!channelsQuery.isPending && !channelsQuery.isError && destinations.length === 0 && (
+
+ Connect a channel in Buffer, then set BUFFER_CHANNEL_ID so posts have a default destination.
+
+ )}
+ {destinations.length > 0 && (
+
+ {destinations.map((d) => (
+
+
+
+
+
+
+
+ {d.name}
+ {d.service || (d.connected ? 'channel' : 'not connected')}
+
+
+
+ {d.posts} post{d.posts === 1 ? '' : 's'}
+
+ {d.connected ? 'Connected' : 'Available'}
+
+
- {p.connected ? (
- Connected
- ) : (
- toast(`Connecting ${p.name}…`, 'info')}>
- Connect
-
- )}
))}
-
+ )}
- Active Postings
- {publishings.length} live postings across {platRows.length} platforms
+ Published Posts
+
+ {postsQuery.isSuccess ? `${rows.length} of ${posts.length}` : 'Every job post and its Buffer state'}
+
+
+
+
+
+
+
+ setQ(e.target.value)} placeholder="Search job or platform…" />
+
+
+
- toast('Performance report exported', 'success')}>
- Export
-
-
-
- {publishing && (
- 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 && (
+
+ Fetching job posts.
+
+ )}
+ {postsQuery.isError && (
+
+
+ {friendlyAuthError(postsQuery.error, 'The server did not return job posts.')}
+ {' '}This screen needs the job_board.view permission.
+
+
+ )}
+ {!postsQuery.isPending && !postsQuery.isError && (
+
+ )}
+
)
}
-
-/** 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 (
- Done
- ) : (
- <>
- (step === 1 ? onClose() : setStep((s) => s - 1))}>
- {step === 1 ? 'Cancel' : 'Back'}
-
-
- {step === 3 ? <> Publish> : 'Continue'}
-
- >
- )
- }
- >
-
- {STEPS.map((s, i) => {
- const n = i + 1
- const cls = n < step ? 'done' : n === step ? 'active' : ''
- return (
-
-
- {n < step ? '✓' : n}
- {s}
-
- {i < STEPS.length - 1 && }
-
- )
- })}
-
-
- {step === 1 && (
- <>
-
-
-
-
- {job && (
-
-
-
-
-
-
-
- {job.title}
- {job.department} · {job.location} · {job.type}
-
-
-
-
- )}
- >
- )}
-
- {step === 2 && (
-
-
-
-
-
-
-
- Approval granted
- Approved by Department Head · Budget confirmed
-
-
- {['Hiring Manager sign-off', 'Finance budget approval', 'Compliance review'].map((label, i) => (
-
- {label}
- Approved
-
- ))}
-
-
- )}
-
- {step === 3 && (
- <>
-
- Select the platforms to publish this role to
-
-
- {publishPlatforms.map((p) => (
- togglePlatform(p.name)}
- >
-
-
- {p.name}
- {p.cost === 'Free' ? 'Free' : `Paid · ${p.cost}`}
-
-
-
- ))}
-
- >
- )}
-
- {step === 4 && (
-
-
-
-
- Published Successfully
-
- {job?.title} is now live on {platforms.length} platform{platforms.length > 1 ? 's' : ''}
-
-
- {platforms.map((p) => {p} )}
-
-
- )}
-
- )
-}
diff --git a/frontend/src/screens/JobCandidates.jsx b/frontend/src/screens/JobCandidates.jsx
new file mode 100644
index 0000000..74c83a7
--- /dev/null
+++ b/frontend/src/screens/JobCandidates.jsx
@@ -0,0 +1,341 @@
+/* ============================================================
+ JobCandidates — the per-job scored-candidates card grid + detail modal
+ (the engine test UI's card layout, rebuilt on the portal's primitives).
+
+ Fed by GET /candidate/scored/fetch?job_id= via toCandidateView. Upload and
+ inbox rows live in the same table, so CVs scored from CV Import and CVs
+ synced from the mailbox both render here; `source` tells them apart.
+ Completed rows arrive score-desc from the server; failed rows follow in
+ upload order and render as failed cards rather than disappearing.
+ ============================================================ */
+
+import { useMemo, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+
+import Modal from '../ui/Modal'
+import { Tabs } from '../ui/Tabs'
+import { Avatar, Badge, EmptyState, Icon, ProgressBar } from '../ui/primitives'
+import { qk } from '../lib/queryKeys'
+import { friendlyAuthError } from '../lib/errors'
+import * as candidatesApi from '../api/candidates'
+import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
+
+const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' }
+
+/** Extraction keeps the resume's own casing; SHOUTED headers read badly on cards. */
+function displayName(name) {
+ if (!name || /[a-z]/.test(name)) return name
+ return name.toLowerCase().replace(/\p{L}+/gu, (w) => w[0].toUpperCase() + w.slice(1))
+}
+
+function bandOf(score) {
+ if (score == null) return null
+ return score >= 82 ? 'Strong Match' : score >= 65 ? 'Potential Match' : 'Weak Match'
+}
+
+function ringColor(score) {
+ return score >= 82 ? 'var(--success)' : score >= 65 ? 'var(--warning)' : 'var(--danger)'
+}
+
+/** The 120px .ats-ring shrunk to card size — same conic trick, no new CSS. */
+function MiniRing({ score, size = 46 }) {
+ return (
+
+ = 56 ? 17 : 13.5, letterSpacing: '-.3px',
+ color: ringColor(score),
+ }}
+ >
+ {score}
+
+
+ )
+}
+
+function CandidateCard({ c, onView }) {
+ const matched = c.matchedSkills.slice(0, 4)
+ const missing = c.missingSkills.slice(0, 2)
+ const more = (c.matchedSkills.length - matched.length) + (c.missingSkills.length - missing.length)
+
+ return (
+ onView(c)}>
+
+
+
+
+ {displayName(c.name)}
+ {c.currentTitle ?? '—'}
+
+
+
+
+
+ {matched.map((s) => {s})}
+ {missing.map((s) => (
+ {s}
+ ))}
+ {more > 0 && +{more} more}
+
+
+ {c.critique}
+
+
+ {c.experience != null ? `${c.experience} yrs` : '—'}
+ {c.currentCompany ?? ''}
+ {SOURCE_LABEL[c.source] ?? c.source}
+ { e.stopPropagation(); onView(c) }}>
+
+
+
+
+
+ )
+}
+
+function FailedCard({ c, onView }) {
+ return (
+ onView(c)}>
+
+
+
+
+ {c.filename}
+ Could not be scored
+
+
+
+ {c.errorMessage ?? 'No usable text could be extracted from this file.'}
+
+
+ {c.errorCode ?? 'FAILED'}
+
+ {SOURCE_LABEL[c.source] ?? c.source}
+ { e.stopPropagation(); onView(c) }}>
+
+
+
+
+
+ )
+}
+
+/** Screenshot-style detail: hero header + Overview / Job Match tabs. */
+export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
+ const completed = c.scoringStatus === 'completed'
+ const TABS = completed ? ['Overview', 'Job Match'] : ['Overview']
+ const [tab, setTab] = useState('Overview')
+ const band = bandOf(c.aiScore)
+ const bandCls = band === 'Strong Match' ? 'b-green' : band === 'Potential Match' ? 'b-amber' : 'b-red'
+ const roleLine = [c.currentTitle, c.currentCompany].filter(Boolean).join(' at ') || c.filename
+
+ return (
+ Close}
+ >
+
+
+
+ {displayName(c.name)}
+ {roleLine}
+
+ {SOURCE_LABEL[c.source] ?? c.source ?? '—'}
+ {c.applied && Received {fmtDate(c.applied)} }
+ {c.experience != null && (
+ {c.experience} yrs exp
+ )}
+
+
+ {completed && (
+
+
+ AI Match
+
+ )}
+
+
+
+ ({ key: t, label: t }))} />
+
+
+
+ {tab === 'Overview' && (
+ <>
+
+ Candidate{displayName(c.name)}
+ Current Title{c.currentTitle ?? '—'}
+ Current Company{c.currentCompany ?? '—'}
+ Experience{c.experience != null ? `${c.experience} years` : '—'}
+ Source{SOURCE_LABEL[c.source] ?? c.source ?? '—'}
+ File{c.filename ?? '—'}
+ Scored For{jobTitle ?? '—'}
+ Added On{c.applied ? fmtDate(c.applied) : '—'}
+
+ {completed ? (
+ <>
+ AI Assessment
+ {c.critique ?? '—'}
+ >
+ ) : (
+
+ {c.errorMessage ?? 'This CV could not be scored.'}
+
+ )}
+ >
+ )}
+
+ {tab === 'Job Match' && completed && (
+ <>
+ Job-Match Score
+
+ Match this candidate against the job the CV was scored for.
+
+
+
+
+
+ {jobTitle ?? 'Selected job'}
+
+
+ {band && {band} }
+
+
+
+
+ Matched must-have skills ({c.matchedSkills.length})
+
+
+ {c.matchedSkills.length
+ ? c.matchedSkills.map((s) => (
+ {s}
+ ))
+ : —}
+
+
+
+ Missing must-have skills ({c.missingSkills.length})
+
+
+ {c.missingSkills.length
+ ? c.missingSkills.map((s) => (
+ {s}
+ ))
+ : None — full match}
+
+
+
+
+ Matched skills are verified to appear in the resume text;
+ missing skills use the job description's wording.
+
+ >
+ )}
+
+
+ )
+}
+
+export default function JobCandidates({ jobId, jobTitle }) {
+ const [q, setQ] = useState('')
+ const [filter, setFilter] = useState('all')
+ const [viewing, setViewing] = useState(null)
+
+ const query = useQuery({
+ queryKey: qk.candidates.list({ jobId }),
+ queryFn: async () => {
+ const res = await candidatesApi.listCandidates({ jobId })
+ const rows = Array.isArray(res?.data) ? res.data : []
+ return rows.map(candidatesApi.toCandidateView)
+ },
+ enabled: Boolean(jobId),
+ })
+
+ const rows = useMemo(() => query.data ?? [], [query.data])
+ const scored = rows.filter((r) => r.scoringStatus === 'completed').length
+ const failed = rows.length - scored
+
+ const list = useMemo(
+ () =>
+ rows.filter((c) => {
+ if (filter === 'completed' && c.scoringStatus !== 'completed') return false
+ if (filter === 'failed' && c.scoringStatus !== 'failed') return false
+ if (q) {
+ const hay = [
+ c.name, c.filename ?? '', c.currentTitle ?? '',
+ c.currentCompany ?? '', c.matchedSkills.join(' '),
+ ].join(' ').toLowerCase()
+ if (!hay.includes(q.toLowerCase())) return false
+ }
+ return true
+ }),
+ [rows, q, filter],
+ )
+
+ if (!jobId) return null
+
+ return (
+
+
+ Candidates
+
+ {rows.length} candidate{rows.length === 1 ? '' : 's'} · {scored} scored · {failed} failed
+ {jobTitle ? ` · vs ${jobTitle}` : ''}
+
+
+
+
+
+
+
+
+ setQ(e.target.value)} placeholder="Search by name, skill, company…" />
+
+
+
+
+
+
+
+ {list.length === 0 ? (
+
+ {query.isError ? (
+
+ {friendlyAuthError(query.error, 'Please try again.')}
+
+ ) : query.isPending ? (
+ Fetching scored CVs for this job.
+ ) : rows.length === 0 ? (
+
+ Upload CVs above or score synced inbox CVs against this job.
+
+ ) : (
+ Try a different search or filter.
+ )}
+
+ ) : (
+ list.map((c) =>
+ c.scoringStatus === 'completed'
+ ?
+ : ,
+ )
+ )}
+
+
+ {viewing && (
+ setViewing(null)} />
+ )}
+
+ )
+}
diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx
index c9cc71e..3fa71b8 100644
--- a/frontend/src/screens/Jobs.jsx
+++ b/frontend/src/screens/Jobs.jsx
@@ -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'
@@ -20,8 +19,11 @@ import { useAuth } from '../auth/AuthContext'
import { useFormState } from '../components/AuthLayout'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
+import { platformLabel } from '../lib/platforms'
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 +58,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 +72,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 +105,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],
@@ -135,7 +177,7 @@ export default function Jobs() {
{ key: 'department', label: 'Department', sortable: true, render: (j) => j.department || '—' },
{ key: 'location', label: 'Location', sortable: true, render: (j) => {j.location || '—'} },
{ key: 'type', label: 'Type', render: (j) => j.type ? {j.type} : '—' },
- { key: 'platform', label: 'Platform', sortable: true, render: (j) => j.platform ? {j.platform} : '—' },
+ { key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ? {platformLabel(j.platform)} : '—' },
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => {j.vacancies ?? '—'} },
{ key: 'status', label: 'Status', sortable: true, render: (j) => {j.status} },
{
@@ -148,6 +190,9 @@ export default function Jobs() {
render: (j) => (
setViewing(j)}>
+ {canEdit && (
+ setEditing(j)}>
+ )}
navigate('/jobboard', { state: { publishJob: j.id } })}>
),
@@ -221,8 +266,27 @@ export default function Jobs() {
{viewing && (
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 && (
+ setEditing(null)}
+ onSubmit={(body) => updateJob.mutate({ id: editing.id, body })}
/>
)}
@@ -477,7 +541,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 (
+
+ Cancel
+
+ {busy ? 'Saving…' : 'Save Changes'}
+
+ >
+ }
+ >
+
+
+ )
+}
+
+/**
+ * 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 (
+ <>
+
+
+ Recruiter ownership
+ {currentQuery.isError ? (
+
+ {friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
+ {' '}Needs the jobs.view permission.
+
+ ) : (
+
+ {currentQuery.isPending
+ ? 'Loading…'
+ : currentName
+ ? <>Owned by {currentName}{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}>
+ : 'No recruiter assigned yet.'}
+
+ )}
+
+ {canEdit && !currentQuery.isError && (
+
+
+ assign.mutate(picked)}
+ >
+ {assign.isPending ? 'Assigning…' : 'Assign'}
+
+
+ )}
+ {canEdit && assigneesQuery.isError && (
+
+ The recruiter list needs the tasks.view permission.
+
+ )}
+
+ >
+ )
+}
+
+function JobDetail({
+ job: j, canEdit, canDelete, statusBusy, deleteBusy, onClose, onPublish, onEdit, onStatus, onDelete,
+}) {
return (
+ {canDelete && (
+
+ {deleteBusy ? 'Deleting…' : 'Delete'}
+
+ )}
Close
+ {canEdit && (
+ Edit
+ )}
Publish
>
}
@@ -499,14 +787,27 @@ function JobDetail({ job: j, onClose, onPublish }) {
{j.title}
{[j.department, j.location].filter(Boolean).join(' · ') || '—'}
- {j.status}
+
+ {canEdit ? (
+
+ ) : (
+ {j.status}
+ )}
+
Department{j.department || '—'}
Location{j.location || '—'}
Employment Type{j.type || '—'}
- Platform{j.platform || '—'}
+ Platform{platformLabel(j.platform) || '—'}
Vacancies{j.vacancies ?? '—'}
Salary{j.salary || '—'}
Experience{j.experience || '—'}
@@ -516,6 +817,8 @@ function JobDetail({ job: j, onClose, onPublish }) {
Closed at{j.closedAt ? fmtShort(j.closedAt) : '—'}
+
+
{j.description && (
<>
diff --git a/frontend/src/screens/Managers.jsx b/frontend/src/screens/Managers.jsx
index 11a4d3e..c62da51 100644
--- a/frontend/src/screens/Managers.jsx
+++ b/frontend/src/screens/Managers.jsx
@@ -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 (
@@ -31,42 +49,53 @@ export default function Managers() {
Hiring Managers
{managers.length} managers · {totalReqs} active requisitions
-
- toast('Invite manager', 'info')}>
- Add Manager
-
-
-
- {managers.map((m) => (
-
-
-
-
-
- {m.name}
- {m.title}
+ {managersQuery.isPending && (
+ Fetching hiring managers.
+ )}
+ {managersQuery.isError && (
+
+ {friendlyAuthError(managersQuery.error, 'This directory needs jobs.view or candidates.view.')}
+
+ )}
+ {managersQuery.isSuccess && managers.length === 0 && (
+
+ No accounts currently hold the hiring-manager role.
+
+ )}
+ {managersQuery.isSuccess && managers.length > 0 && (
+
+ {managers.map((m) => (
+
+
+
+
+
+ {m.name}
+ {m.title || m.roleName || 'Hiring manager'}
+
+
+
+ {m.openReqs}Open Reqs
+ {m.teamSize ?? '—'}Team Size
+
+
+
+ {m.email ? m.email.split('@')[0] : '—'}
+ setDetail(m)}>View
-
- {m.openReqs}Open Reqs
- {m.teamSize}Team Size
-
-
-
- {m.email.split('@')[0]}
- setDetail(m)}>View
-
-
- ))}
-
+ ))}
+
+ )}
{detail && (
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 (
- Close
- toast('Message sent', 'success')}>
- Message
-
- >
+ messaging ? (
+ <>
+ setMessaging(false)} disabled={send.isPending}>Cancel
+ {
+ 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 })
+ }}
+ >
+ {send.isPending ? 'Sending…' : 'Send'}
+
+ >
+ ) : (
+ <>
+ Close
+ setMessaging(true)}
+ >
+ Message
+
+ >
+ )
}
>
-
+
{m.name}
- {m.title}
+ {m.title || m.roleName || 'Hiring manager'}
- {m.department}
- {m.teamSize} reports
+ {m.department && {m.department} }
+ {m.teamSize != null && {m.teamSize} reports}
-
- {m.openReqs}Open Reqs
- {jobs.length}Total Jobs
-
- {jobs.reduce((s, j) => s + j.applications, 0)}
- Applications
+ {messaging ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- Hiring Manager Portal
-
- go('/jobs', { openCreate: true })}>
- Raise Requisition
-
- go('/candidates')}>
- Review Candidates
-
- go('/interviews', { openSchedule: true })}>
- Schedule Interview
-
- go('/offers')}>
- Approve Offers
-
-
-
- Requisitions
-
- {jobs.length === 0 ? (
- No requisitions
- ) : (
- jobs.map((j) => (
- go('/jobs', { openJob: j.id })}
- >
-
-
-
-
- {j.title}
- {j.applications} applications
-
- {j.status}
+ ) : (
+ <>
+
+ {m.openReqs}Open Reqs
+ {jobs.length}Open Jobs
+
+ {m.email ? 'Yes' : '—'}
+ Email on file
- ))
- )}
-
+
+
+ Hiring Manager Portal
+
+ go('/jobs', { openCreate: true })}>
+ Raise Requisition
+
+ go('/candidates')}>
+ Review Candidates
+
+ go('/interviews', { openSchedule: true })}>
+ Schedule Interview
+
+ go('/offers')}>
+ Approve Offers
+
+
+
+ Open requisitions
+
+ Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager's own.
+
+
+ {jobs.filter((j) => j.status === 'Open').length === 0 ? (
+ No open requisitions
+ ) : (
+ jobs.filter((j) => j.status === 'Open').slice(0, 8).map((j) => (
+ go('/jobs', { openJob: j.id })}
+ >
+
+
+
+
+ {j.title}
+ {[j.department, j.location].filter(Boolean).join(' · ') || '—'}
+
+ {j.status}
+
+ ))
+ )}
+
+ >
+ )}
)
}
diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx
index 26045b0..596e300 100644
--- a/frontend/src/screens/Matching.jsx
+++ b/frontend/src/screens/Matching.jsx
@@ -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({
)}
+ {/* Email first: it is the application itself, and the resume is its
+ attachment. Reading order follows that. */}
+ {(detail?.subject || detail?.body) && (
+
+ Email
+ Subject: {detail.subject || '(no subject)'}
+ {looksLikeHtml(detail.bodyHtml) ? (
+
+ ) : (
+
+ {detail.body || 'No email body.'}
+
+ )}
+
+ )}
+
Resume text
-
+
{resumeText || 'Resume text not extracted yet.'}
-
- {(detail?.body) && (
- <>
- Email body
-
- {detail.body}
-
- >
- )}
diff --git a/frontend/src/screens/Notifications.jsx b/frontend/src/screens/Notifications.jsx
index 3d61fd6..b23b45e 100644
--- a/frontend/src/screens/Notifications.jsx
+++ b/frontend/src/screens/Notifications.jsx
@@ -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() {
Stay on top of hiring activity
- Mark all read
- toast('Notification settings', 'info')}>
-
+ markAll.mutate()}
+ >
+ Mark all read
-
- {notifications.map((n, i) => (
- markOne(i)}
- >
-
-
- {n.title}
- {n.text}
- {n.time}
+ {query.isPending && (
+
+ Fetching notifications.
+
+ )}
+ {query.isError && (
+
+
+ {friendlyAuthError(query.error, 'Request failed')}
+
+
+ )}
+ {query.isSuccess && items.length === 0 && (
+
+
+ New activity — assessments, requisitions, mail — will land here.
+
+
+ )}
+ {query.isSuccess && items.length > 0 && (
+
+ {items.map((n) => (
+ open(n)}
+ style={{ cursor: n.linkPath || n.unread ? 'pointer' : 'default' }}
+ >
+
+
+ {n.title}
+ {n.text && {n.text}}
+ {n.time}
+
+ {n.unread && (
+
+ )}
+ {
+ e.stopPropagation()
+ remove.mutate(n.id)
+ }}
+ >
+
+
- {n.unread && (
-
- )}
-
- ))}
-
+ ))}
+
+ )}
)
diff --git a/frontend/src/screens/Offers.jsx b/frontend/src/screens/Offers.jsx
index 4cc4d0a..7261cbe 100644
--- a/frontend/src/screens/Offers.jsx
+++ b/frontend/src/screens/Offers.jsx
@@ -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) => (
-
+
{o.candidate}
{o.jobTitle}
@@ -53,18 +198,43 @@ export default function Offers() {
),
},
- { key: 'department', label: 'Department', sortable: true },
- { key: 'base', label: 'Base Salary', sortable: true, align: 'right', render: (o) => {money(o.base)} },
- { key: 'equity', label: 'Equity', render: (o) => {o.equity} },
- { key: 'bonus', label: 'Bonus', align: 'center', render: (o) => {o.bonus} },
- { key: 'sent', label: 'Sent', sortable: true, sortValue: (o) => o.sent.getTime(), render: (o) => {fmtShort(o.sent)} },
- { key: 'status', label: 'Status', sortable: true, render: (o) => {o.status} },
+ {
+ key: 'base', label: 'Base Salary', sortable: true, align: 'right',
+ sortValue: (o) => o.base ?? 0,
+ render: (o) => (o.base != null ? {money(o.base)} : —),
+ },
+ {
+ key: 'equity', label: 'Equity',
+ render: (o) => {o.equity ?? '—'},
+ },
+ {
+ key: 'bonus', label: 'Bonus', align: 'center',
+ render: (o) => {o.bonus ?? '—'},
+ },
+ {
+ key: 'sent', label: 'Sent', sortable: true,
+ sortValue: (o) => (o.sent ? o.sent.getTime() : 0),
+ render: (o) => {o.sent ? fmtShort(o.sent) : '—'},
+ },
+ {
+ key: 'status', label: 'Status', sortable: true,
+ render: (o) => {o.statusLabel} ,
+ },
{
key: '_a', label: 'Actions', align: 'right',
render: (o) => (
- setViewing(o)}>
- toast(`Offer resent to ${o.candidate}`, 'info')}>
+ setViewing(o)}>
+
+
+ issue.mutate(o.id)}
+ >
+
+
),
},
@@ -85,10 +255,16 @@ export default function Offers() {
-
-
-
-
+
+
+
+
@@ -98,119 +274,208 @@ export default function Offers() {
setQ(e.target.value)} placeholder="Search candidate or role…" />
-
-
+
+ {offersQuery.isPending && (
+
+ Fetching offers from the server.
+
+ )}
+ {offersQuery.isError && (
+
+
+ {friendlyAuthError(offersQuery.error, 'The server did not return offers.')}
+ {' '}This screen needs the offers.view permission.
+
+
+ )}
+ {!offersQuery.isPending && !offersQuery.isError && (
+
+ )}
- {viewing && setViewing(null)} toast={toast} />}
+ {viewing && (
+ setViewing(null)}
+ onIssue={() => issue.mutate(viewing.id)}
+ onStatus={(next) => { setStatus.mutate({ offerId: viewing.id, next }); setViewing(null) }}
+ />
+ )}
{creating && (
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)}
/>
)}
)
}
-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 (
Close
- toast('Offer PDF downloaded', 'info')}>
- Download
-
- { onClose(); toast('Offer resent', 'success') }}>
- Resend Offer
+ {next.map((s) => (
+ onStatus(s)}>
+ Mark {OFFER_STATUS_LABEL[s]}
+
+ ))}
+ { onIssue(); onClose() }}
+ >
+ {o.status === 'draft' ? 'Send Offer' : 'Resend Offer'}
>
}
>
-
+
{o.candidate}
- {o.jobTitle} · {o.department}
+ {o.jobTitle}{o.email ? ` · ${o.email}` : ''}
- {o.status}
+ {o.statusLabel}
Compensation Package
- Base Salary{money(o.base)}
- Annual Bonus{o.bonus}
- Equity{o.equity}
- Est. Total Cash{money(total)}
+
+ Base Salary
+
+ {o.base != null ? `${money(o.base)} / ${o.salaryPeriod}` : '—'}
+
+
+
+ Annual Bonus
+ {o.bonus ?? '—'}
+
+
+ Equity
+ {o.equity ?? '—'}
+
+
+ Est. Total Cash
+
+ {total != null ? money(total) : '—'}
+
+
- Sent On{fmtDate(o.sent)}
- Expires{fmtDate(o.expires)}
- Recruiter{o.recruiter}
+ Signing Bonus{o.signingBonus != null ? money(o.signingBonus) : '—'}
+ Currency{o.currency}
+ Start Date{o.startDate ? fmtDate(o.startDate) : '—'}
+ Expires{o.expiry ? fmtDate(o.expiry) : '—'}
+ Sent On{o.sent ? fmtDate(o.sent) : 'Not sent yet'}
+ Responded{o.respondedAt ? fmtDate(o.respondedAt) : '—'}
+ Created{o.created ? fmtDate(o.created) : '—'}
Offer ID{o.id}
)
}
-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 (
- Cancel
- Send Offer
+ Cancel
+
+ {busy ? 'Saving…' : 'Save Draft'}
+
>
}
>
@@ -218,41 +483,99 @@ function CreateOffer({ candidates, onClose, onSave, toast }) {
- form.setField('candidate', e.target.value)}>
- {eligible.map((c) => )}
+ set('inboxId', e.target.value)}
+ disabled={loading || !applications.length}
+ >
+ {loading && }
+ {!loading && !applications.length && (
+
+ )}
+ {applications.map((a) => (
+
+ ))}
+
+ {errors.inboxId}
+
+
+
+
+ set('base', e.target.value)}
+ />
+ {errors.base}
+
+
+
+ set('currency', e.target.value)}>
+ {['USD', 'EUR', 'GBP', 'PKR', 'AED'].map((c) => )}
-
- form.setField('base', e.target.value)}
- />
- {form.errors.base}
+
+ set('salaryPeriod', e.target.value)}>
+
+
+
+
- form.setField('bonus', e.target.value)} />
+ set('bonusPct', e.target.value)}
+ />
+ {errors.bonusPct}
+
+
+
+
+ set('signingBonus', e.target.value)}
+ />
-
- form.setField('equity', e.target.value)} />
+
+ set('equityUnits', e.target.value)}
+ />
+ {errors.equityUnits}
+
+
+
+ set('equityInstrument', e.target.value)}>
+ {['RSU', 'ISO', 'NSO', 'Options'].map((c) => )}
+
+
+
+
+
+ set('startDate', e.target.value)} />
- form.setField('expires', e.target.value)} />
-
-
-
-
+
+
+ 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 (offers.approve).
+
)
diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx
index 0f7ae5e..87954ad 100644
--- a/frontend/src/screens/Pipeline.jsx
+++ b/frontend/src/screens/Pipeline.jsx
@@ -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),
}
diff --git a/frontend/src/screens/Rbac.jsx b/frontend/src/screens/Rbac.jsx
index 61add3f..26e483b 100644
--- a/frontend/src/screens/Rbac.jsx
+++ b/frontend/src/screens/Rbac.jsx
@@ -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 (
@@ -68,7 +133,7 @@ export default function Rbac() {
Access Control
- 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
@@ -79,7 +144,9 @@ export default function Rbac() {
{rolesQuery.isPending && (
- Fetching from the server…
+
+ Fetching from the server…
+
)}
{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) }
+ }}
>
@@ -137,49 +209,78 @@ export default function Rbac() {
{role.is_system && System role }
- {role.effective_permissions?.length ?? 0} / 104
+ {role.effective_permissions?.length ?? 0}{totalTags ? ` / ${totalTags}` : ''}
+ setEditing(role)}>
+ Edit
+
+ {/* delete_role refuses system roles server-side, so the
+ control is hidden rather than offered and rejected. */}
+ {!role.is_system && (
+ setConfirmDelete(role)}>
+
+
+ )}
- These are the role’s resolved 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.
+ These are the role’s resolved 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.
-
-
-
-
- Module
- {permTypes.map((p) => {p} )}
-
-
-
- {rbacModules.map((label, mi) => (
-
- {label}
- {ACTION_SLUGS.map((action, ai) => {
- const on = granted.has(`${MODULE_SLUGS[mi]}.${action}`)
- return (
-
-
-
-
-
- )
- })}
+
+ {tagsQuery.isPending && (
+
+ Fetching the tag vocabulary…
+
+ )}
+ {tagsQuery.isError && (
+
+ {friendlyAuthError(tagsQuery.error, 'The tag catalogue did not load.')}
+
+ )}
+ {tagsQuery.isSuccess && modules.length > 0 && (
+
+
+
+
+ Module
+ {actions.map((a) => {humanise(a)} )}
- ))}
-
-
-
+
+
+ {modules.map((mod) => (
+
+ {humanise(mod)}
+ {actions.map((action) => {
+ const tag = `${mod}.${action}`
+ if (!tagSet.has(tag)) {
+ return ·
+ }
+ const on = granted.has(tag)
+ return (
+
+
+
+
+
+ )
+ })}
+
+ ))}
+
+
+
+ )}
>
)}
@@ -188,42 +289,119 @@ export default function Rbac() {
)}
{creating && (
- setCreating(false)}
onSave={(body) => createRole.mutate(body)}
/>
)}
+
+ {editing && (
+ setEditing(null)}
+ onSave={(body) => updateRole.mutate({ id: editing.id, body })}
+ />
+ )}
+
+ {confirmDelete && (
+ setConfirmDelete(null)}
+ footer={
+ <>
+ setConfirmDelete(null)} disabled={deleteRole.isPending}>
+ Cancel
+
+ deleteRole.mutate(confirmDelete.id)}
+ >
+ {deleteRole.isPending ? 'Deleting…' : 'Delete role'}
+
+ >
+ }
+ >
+
+ {confirmDelete.role_name} will be soft-deleted. Anyone currently holding it keeps the
+ account but loses every permission the role granted, so reassign them first.
+
+
+ )}
)
}
-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 (
Cancel
- {busy ? 'Creating…' : 'Create Role'}
+ {busy ? 'Saving…' : 'Save Role'}
>
}
@@ -249,9 +427,54 @@ function CreateRole({ busy, onClose, onSave }) {
/>
-
- The role starts with no permission bundles. Assign bundles server-side to grant it access.
-
+
+
+
+ Active
+ Inactive roles stay on the books but grant nothing.
+
+
+
+
+
+ Permission bundles
+
+ {picked.size} selected · {grantedTags.size} tags resolved
+
+
+
+ {bundlesLoading && Loading bundles…
}
+ {!bundlesLoading && bundles.length === 0 && (
+
+ Permission bundles are seeded server-side; without them a role can only be created empty.
+
+ )}
+
+ {bundles.map((b) => (
+
+ ))}
+
)
diff --git a/frontend/src/screens/RecruiterHub.jsx b/frontend/src/screens/RecruiterHub.jsx
index 0e464a0..8fdb848 100644
--- a/frontend/src/screens/RecruiterHub.jsx
+++ b/frontend/src/screens/RecruiterHub.jsx
@@ -1,179 +1,384 @@
+/* ============================================================
+ Recruiter Hub — live, by pointing every analytics endpoint at one recruiter.
+
+ The trick that makes this screen real: /analytics/kpis, /hiring-trend and
+ /funnel all take a `recruiter_id`, so selecting a recruiter re-scopes the
+ whole page server-side rather than filtering a client-side array. The
+ recruiter list itself is /analytics/recruiter-performance, which is also the
+ leaderboard.
+
+ TEN OF THE PROTOTYPE'S EIGHTEEN TILES ARE GONE. workload %, efficiency %, SLA
+ state, interview completion %, avg response time, TAT %, star rating, jobs
+ awaiting approval and jobs overdue have no column, no table and in most cases
+ no concept behind them — there is no approval workflow and no requisition
+ deadline in the schema. They were random numbers re-rolled on every render.
+ What replaced them is derived from real counts and labelled as such:
+ conversion rate is hires ÷ candidates, offer acceptance is accepted ÷ sent.
+
+ The workload heatmap survived because interviews are real: it buckets
+ /interview/fetch over the last five weeks by weekday. It is TEAM-WIDE, not
+ per recruiter — the interviews table has no recruiter column — and the card
+ says so rather than implying the selected person owns all of it.
+ ============================================================ */
+
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Chart from '../ui/Chart'
import Charts from '../lib/charts'
-import { Avatar, Badge, Icon, KpiCard } from '../ui/primitives'
-import { useToast } from '../ui/Toast'
-import { seedQuery } from '../data/seedQueries'
-import { analytics, int } from '../data/seed'
+import { Avatar, EmptyState, Icon, KpiCard } from '../ui/primitives'
+import { qk } from '../lib/queryKeys'
+import { friendlyAuthError } from '../lib/errors'
+import * as analyticsApi from '../api/analytics'
+import * as interviewsApi from '../api/interviews'
+import { avatarColor, initials as initialsOf } from '../data/seed'
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
-const WEEKS = ['W1', 'W2', 'W3', 'W4', 'W5']
-const STAGES = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
-const MAX_HEAT = 5
+const WEEKS = 5
+const TREND_MONTHS = 7
+const HEAT_MAX = 5
-const heatColor = (v) => (v === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + (v / MAX_HEAT) * 0.8})`)
+const heatColor = (v) => (v === 0 ? 'var(--bg-sunken)' : `rgba(79,70,229,${0.2 + (Math.min(v, HEAT_MAX) / HEAT_MAX) * 0.8})`)
+
+const pct = (num, den) => (den ? `${Math.round((num / den) * 100)}%` : '—')
+const days = (v) => (v == null ? '—' : `${Math.round(Number(v))}d`)
+
+/** Monday-indexed weekday, so the grid reads Mon–Sun like the rest of the app. */
+function weekdayIndex(date) {
+ return (date.getDay() + 6) % 7
+}
export default function RecruiterHub() {
- const { toast } = useToast()
- const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
- const [recId, setRecId] = useState(null)
+ const [recruiterId, setRecruiterId] = useState('')
- const r = recruiters.find((x) => x.id === recId) ?? recruiters[0]
+ const boardQuery = useQuery({
+ queryKey: qk.analytics.recruiters({ top: 50, scope: 'hub' }),
+ queryFn: async () => {
+ const res = await analyticsApi.recruiterPerformance({ top: 50 })
+ return Array.isArray(res?.data) ? res.data : []
+ },
+ })
- const trendData = useMemo(
- () =>
- r
- ? {
- labels: analytics.hiringTrend.labels,
- area: true,
- datasets: [{ label: 'Hires', data: r.monthlyTrend, color: Charts.PALETTE[0] }],
- }
- : null,
- [r],
+ const recruiters = boardQuery.data ?? []
+ const selected = recruiters.find((r) => r.id === recruiterId) ?? recruiters[0] ?? null
+ const activeId = selected?.id ?? null
+
+ const kpisQuery = useQuery({
+ queryKey: qk.analytics.kpis({ recruiterId: activeId, scope: 'hub' }),
+ queryFn: async () => (await analyticsApi.kpis({ recruiterId: activeId }))?.data ?? null,
+ enabled: Boolean(activeId),
+ })
+
+ const trendQuery = useQuery({
+ queryKey: qk.analytics.trend({ recruiterId: activeId, months: TREND_MONTHS, scope: 'hub' }),
+ queryFn: async () => (await analyticsApi.hiringTrend({ months: TREND_MONTHS, recruiterId: activeId }))?.data
+ ?? { labels: [], hires: [] },
+ enabled: Boolean(activeId),
+ })
+
+ const funnelQuery = useQuery({
+ queryKey: qk.analytics.funnel({ recruiterId: activeId, scope: 'hub' }),
+ queryFn: async () => {
+ const res = await analyticsApi.funnel({ recruiterId: activeId })
+ return Array.isArray(res?.data) ? res.data : []
+ },
+ enabled: Boolean(activeId),
+ })
+
+ /* The heatmap window: the last five whole weeks ending today. Sent as a real
+ range so the request stays small however long the table gets. */
+ const heatFrom = useMemo(() => {
+ const d = new Date()
+ d.setHours(0, 0, 0, 0)
+ d.setDate(d.getDate() - (WEEKS * 7 - 1))
+ return d
+ }, [])
+
+ const heatQuery = useQuery({
+ queryKey: qk.interviews.range({ scope: 'heatmap', weeks: WEEKS }),
+ queryFn: async () => {
+ const res = await interviewsApi.listRange({
+ fromDate: heatFrom.toISOString(),
+ toDate: new Date().toISOString(),
+ top: 500,
+ })
+ const rows = Array.isArray(res?.data) ? res.data : []
+ return rows.map(interviewsApi.toInterviewView)
+ },
+ })
+
+ const heatmap = useMemo(() => {
+ const grid = Array.from({ length: 7 }, () => Array.from({ length: WEEKS }, () => 0))
+ for (const iv of heatQuery.data ?? []) {
+ if (!iv.when) continue
+ const dayOffset = Math.floor((iv.when - heatFrom) / 86400000)
+ if (dayOffset < 0 || dayOffset >= WEEKS * 7) continue
+ const week = Math.floor(dayOffset / 7)
+ grid[weekdayIndex(iv.when)][week] += 1
+ }
+ return grid
+ }, [heatQuery.data, heatFrom])
+
+ const weekLabels = useMemo(
+ () => Array.from({ length: WEEKS }, (_, i) => (i === WEEKS - 1 ? 'This' : `W${i + 1}`)),
+ [],
)
- // The prototype re-rolled these counts on every render via DB.int(). Keyed to
- // the recruiter so they're stable while you look at one.
- const pipelineData = useMemo(
- () => ({ labels: STAGES, data: STAGES.map(() => int(2, 14)), colors: Charts.PALETTE }),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [r?.id],
+ const trendData = useMemo(() => {
+ const t = trendQuery.data ?? { labels: [], hires: [] }
+ return {
+ labels: t.labels ?? [],
+ area: true,
+ datasets: [{ label: 'Hires', data: t.hires ?? [], color: Charts.PALETTE[0] }],
+ }
+ }, [trendQuery.data])
+
+ const pipelineData = useMemo(() => {
+ const rows = (funnelQuery.data ?? []).filter((p) => p.stage !== 'REJECTED')
+ return {
+ labels: rows.map((p) => p.stage),
+ data: rows.map((p) => p.count),
+ colors: Charts.PALETTE,
+ }
+ }, [funnelQuery.data])
+
+ const board = useMemo(
+ () => [...recruiters].sort((a, b) => (b.hires ?? 0) - (a.hires ?? 0)).slice(0, 8),
+ [recruiters],
)
- const board = useMemo(() => [...recruiters].sort((a, b) => b.hires - a.hires).slice(0, 8), [recruiters])
+ if (boardQuery.isPending) {
+ return (
+
+
+ Fetching the recruiter roster.
+
+
+ )
+ }
- if (!r) return null
+ if (boardQuery.isError) {
+ return (
+
+
+
+ {friendlyAuthError(boardQuery.error, 'The server did not answer.')}
+ {' '}This screen needs the analytics.view permission.
+
+
+
+ )
+ }
- const slaCls = r.sla === 'On Track' ? 'b-green' : r.sla === 'At Risk' ? 'b-amber' : 'b-red'
+ if (!selected) {
+ return (
+
+
+
+ Users with the recruiter role appear here once they exist.
+
+
+
+ )
+ }
+
+ const k = kpisQuery.data
+ const name = selected.name || 'Recruiter'
+ const loading = kpisQuery.isPending
return (
Recruiter Hub
- Personalized performance dashboard & workload
+ Per-recruiter performance, scoped server-side
- setRecId(e.target.value)}>
+ setRecruiterId(e.target.value)}>
{recruiters.map((x) => )}
- toast('Report exported', 'success')}>
- Export
-
-
-
-
- {r.name}
- {r.department} Recruiter · ⭐ {r.rating} rating
+
+
+
+ {name}
+
+ {selected.open_reqs ?? 0} open req{(selected.open_reqs ?? 0) === 1 ? '' : 's'}
+ {' · '}
+ {selected.hires ?? 0} hire{(selected.hires ?? 0) === 1 ? '' : 's'}
+
- {r.workload}%
- Workload
+
+ {loading ? '—' : pct(k?.hires ?? 0, k?.total_candidates ?? 0)}
+
+ Applicant → hire
- {r.efficiency}%
- Efficiency
+
+ {loading ? '—' : pct(k?.offers_accepted ?? 0, k?.offers_sent ?? 0)}
+
+ Offer acceptance
- {r.sla}
+ {kpisQuery.isError && (
+
+
+ {friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
+
+
+ )}
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
- Monthly Hiring Trend
Hires per month
-
-
-
- Workload Heatmap
Interview load
+
+ Monthly Hiring Trend
Hires per month, this recruiter
+
-
-
- {WEEKS.map((w) => {w})}
- {DAYS.map((d, di) => (
-
- {d}
- {r.heatmap[di].map((v, wi) => (
-
+ {trendQuery.isPending && Fetching the trend. }
+ {trendQuery.isError && (
+
+ {friendlyAuthError(trendQuery.error, 'The server did not answer.')}
+
+ )}
+ {trendQuery.isSuccess && (
+
+ )}
+
+
+
+
+
+
+ Interview Load
+ Team-wide, last {WEEKS} weeks
+
+
+
+ {heatQuery.isPending ? (
+ Bucketing interviews.
+ ) : heatQuery.isError ? (
+
+ {friendlyAuthError(heatQuery.error, 'The server did not answer.')}
+
+ ) : (
+ <>
+
+
+ {weekLabels.map((w) => (
+ {w}
+ ))}
+ {DAYS.map((d, di) => (
+
+ {d}
+ {heatmap[di].map((v, wi) => (
+
+ ))}
+
))}
- ))}
-
-
- Less
- {[0, 1, 2, 3, 5].map((v) => (
-
- ))}
- More
-
+
+ Less
+ {[0, 1, 2, 3, 5].map((v) => (
+
+ ))}
+ More
+
+
+ Interviews carry no recruiter, so this counts the whole team.
+
+ >
+ )}
- Recruiter Leaderboard
Top performers by hires
+
+ Recruiter Leaderboard
Top performers by hires
+
- {board.map((rec, i) => (
-
-
- {i + 1}
-
-
-
- {rec.name}
- {rec.efficiency}% efficiency · {rec.avgTimeToHire}d avg
-
-
- {rec.hires}
- hires
-
-
- ))}
+ {board.length === 0 ? (
+
+ The board fills in as applications reach the hired stage.
+
+ ) : (
+ board.map((rec, i) => {
+ const rn = rec.name || 'Recruiter'
+ return (
+
+
+ {i + 1}
+
+
+
+ {rn}
+
+ {rec.open_reqs ?? 0} open · {days(rec.avg_time_to_hire)} avg
+
+
+
+ {rec.hires ?? 0}
+ hires
+
+
+ )
+ })
+ )}
+
Candidate Pipeline
This recruiter’s active candidates
-
+
+ {funnelQuery.isPending ? (
+ Fetching stage counts.
+ ) : funnelQuery.isError ? (
+
+ {friendlyAuthError(funnelQuery.error, 'The server did not answer.')}
+
+ ) : pipelineData.data.every((n) => !n) ? (
+
+ Applications assigned to this recruiter appear here.
+
+ ) : (
+
+ )}
+
diff --git a/frontend/src/screens/Reports.jsx b/frontend/src/screens/Reports.jsx
index 10fe4f3..333dbf2 100644
--- a/frontend/src/screens/Reports.jsx
+++ b/frontend/src/screens/Reports.jsx
@@ -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) => {r.dept} },
{ key: 'open', label: 'Open Roles', sortable: true, align: 'center' },
{ key: 'apps', label: 'Applications', sortable: true, align: 'center', render: (r) => {r.apps} },
{ 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) => (
-
-
- {r.rate}%
-
- ),
+ 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` : —),
+ },
+ {
+ 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 (
+
+
+ {rate}%
+
+ )
+ },
+ },
+ ]
+
+ const costColumns = [
+ { key: 'type', label: 'Cost Type', sortable: true, render: (r) => {r.type} },
+ {
+ key: 'amount', label: 'Total', sortable: true, align: 'right',
+ render: (r) => {money(Math.round(r.amount))},
+ },
+ {
+ key: '_share', label: 'Share', sortable: true,
+ sortValue: (r) => r.amount,
+ render: (r) => {
+ const share = costSum ? Math.round((r.amount / costSum) * 100) : 0
+ return (
+
+
+ {share}%
+
+ )
+ },
},
]
@@ -97,20 +286,24 @@ export default function Reports() {
Reports
- Recruitment metrics and downloadable insights
+ Recruitment metrics across the selected window
-
-
-
-
+ setRangeKey(e.target.value)}>
+ {RANGES.map((r) => )}
- toast('Full report exported to PDF', 'success')}>
- Export Report
-
+ {kpisQuery.isError && (
+
+
+ {friendlyAuthError(kpisQuery.error, 'The server did not answer.')}
+ {' '}This screen needs the analytics.view permission.
+
+
+ )}
+
{cards.map((c) => )}
@@ -118,55 +311,112 @@ export default function Reports() {
- Hiring Funnel
Stage-by-stage conversion
- toast('Chart exported', 'info')}>
-
-
+
+ Hiring Funnel
+
+ Share reaching each stage{funnel.base ? ` · base ${funnel.base}` : ''}
+
+
-
-
-
- Time to Hire vs Fill
Monthly trend (days)
-
-
+ {funnelQuery.isPending ? (
+ Fetching stage counts.
+ ) : funnelQuery.isError ? (
+
+ {friendlyAuthError(funnelQuery.error, 'The server did not answer.')}
+
+ ) : !funnel.base ? (
+
+ The funnel fills in once applications arrive.
+
+ ) : (
+ <>
+
+
+ Derived from current stage counts, so rejected applications are
+ not counted at the stage they reached.
+
+ >
+ )}
+
+
+
+
+
+ Cycle Time
Days, current window vs prior
+
+
+ {kpisQuery.isPending ? (
+ Fetching cycle times.
+ ) : k?.time_to_hire == null && k?.time_to_fill == null ? (
+
+ Needs at least one hire and one closed requisition in the window.
+
+ ) : (
+ <>
+
+
+ >
+ )}
- Department Performance
Hiring breakdown by team
- toast('Table exported to CSV', 'success')}>
- CSV
-
+
+ Department Performance
+
+ {deptsQuery.data && deptsQuery.data.length > DEPT_CAP
+ ? `Top ${DEPT_CAP} of ${deptsQuery.data.length} departments`
+ : 'Hiring breakdown by team'}
+
+
-
+ {deptPending ? (
+
+ One read per department.
+
+ ) : deptRows.length === 0 ? (
+
+
+ Set a department on a requisition for it to appear here.
+
+
+ ) : (
+
+ )}
- Report Library
Generate a detailed report
-
-
- {REPORT_TYPES.map((r) => (
- toast(`Generating: ${r.name}`, 'info')}
- >
-
-
- {r.name}
- {r.desc}
-
- Generate
-
-
-
- ))}
+
+
+ Hiring Spend
+
+ {costSum ? `${money(Math.round(costSum))} recorded in this window` : 'From the hiring-cost ledger'}
+
+ {costsQuery.isPending ? (
+
+ Fetching the cost ledger.
+
+ ) : costsQuery.isError ? (
+
+
+ {friendlyAuthError(costsQuery.error, 'The cost ledger did not answer.')}
+ {' '}This card needs the jobs.view permission.
+
+
+ ) : costTotals.length === 0 ? (
+
+
+ Cost-per-hire stays blank until spend is logged against a requisition.
+
+
+ ) : (
+ ({ id: r.type, ...r }))} pageSize={10} />
+ )}
)
diff --git a/frontend/src/screens/ScoredCandidateProfile.jsx b/frontend/src/screens/ScoredCandidateProfile.jsx
index 5c74999..b0b96bc 100644
--- a/frontend/src/screens/ScoredCandidateProfile.jsx
+++ b/frontend/src/screens/ScoredCandidateProfile.jsx
@@ -1,6 +1,15 @@
/* The profile modal for candidate rows on /candidates (identity from
/candidate/fetch/users, detail from GET /candidate/fetch?user_id=). Distinct
- from CandidateProfile.jsx, which renders the 8-tab TalentPool modal. */
+ from CandidateProfile.jsx, which renders the 8-tab TalentPool modal.
+
+ SCORING IS A THIRD, INDEPENDENT READ: GET /pipeline/candidate/score/fetch,
+ the candidate's current ats_results row. It has to be, because the detail
+ payload is not a source of scoring at all — serialize_candidate_profile and
+ serialize_manual_candidate_profile both hardcode ai_score, matched_keywords,
+ missing_keywords, summary_critique and scored_at to null/[]
+ (backend/job/candidate/serializers.py:116, 174-185). Reading scoring from it
+ meant every candidate on this screen showed "Not scored yet" however many
+ times the engine had actually scored them. */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
@@ -12,6 +21,7 @@ import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
+import * as pipelineApi from '../api/pipeline'
const TABS = ['Overview', 'Scoring', 'File']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
@@ -41,6 +51,13 @@ function formatExperience(value, unit) {
return text
}
+/** ats_results.computed_at is an ISO 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)
+}
+
function useCandidateDetail(userId) {
return useQuery({
queryKey: qk.candidates.detail(userId),
@@ -49,11 +66,51 @@ function useCandidateDetail(userId) {
})
}
+/**
+ * The candidate's current ats_results row — the only scoring source this modal has.
+ *
+ * Sent WITHOUT job_post_id, for the same reason Talent Pool omits it: a row here
+ * is a candidate USER account with no job context (toCandidateUserView leaves
+ * jobId null), so pinning could only ever hide a score that exists under some
+ * other post. Unpinned, the endpoint answers with the newest current score the
+ * candidate has anywhere.
+ *
+ * Keyed by qk.pipeline.candidateScore, so re-opening the same candidate — or
+ * opening one already viewed in Talent Pool — repaints from cache.
+ */
+function useAtsResult(userId) {
+ return useQuery({
+ queryKey: qk.pipeline.candidateScore({ userId: userId ?? null }),
+ queryFn: () => pipelineApi.fetchCandidateScore({ userId }),
+ select: pipelineApi.toAtsScore,
+ enabled: Boolean(userId),
+ })
+}
+
+/**
+ * job_post_id -> title, so the score reads as "scored against Senior Backend
+ * 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.
+ */
+export function useJobTitles() {
+ return useQuery({
+ queryKey: qk.jobPosts.list(),
+ queryFn: async () => {
+ const res = await candidatesApi.listJobs()
+ const rows = Array.isArray(res?.data) ? res.data : []
+ return rows.map((row) => ({ id: row.id, title: row.title }))
+ },
+ select: (rows) => new Map(rows.map((row) => [String(row.id), row.title])),
+ })
+}
+
export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose, onAtsMatch }) {
const [tab, setTab] = useState('Overview')
const isLive = Boolean(c.userId)
const detail = useCandidateDetail(c.userId)
const live = detail.data ?? null
+ const ats = useAtsResult(c.userId)
+ const atsRow = ats.data ?? null
const view = useMemo(() => {
const currentTitle = stripSentinel(live?.current_title) ?? c.currentTitle ?? null
@@ -65,17 +122,20 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
live?.documents?.[0]?.name || c.filename || null
const matchSummary = live?.match_summary ?? null
const messageId = live?.message_id ?? null
- const aiScore = live?.ai_score ?? c.aiScore ?? null
+ // ats_results wins over the detail payload's denormalised copy, because it is
+ // the row the copy is made from — and on this screen the copy is always null.
+ const aiScore = atsRow?.overall_score ?? live?.ai_score ?? c.aiScore ?? null
const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? []
const missingSkills = live?.missing_keywords ?? c.missingSkills ?? []
const critique = live?.summary_critique ?? c.critique ?? null
const errorCode = live?.error_code ?? c.errorCode ?? null
const errorMessage = live?.error_message ?? live?.match_error ?? c.errorMessage ?? null
const scoredFor = live?.job_title ?? jobTitle ?? null
- const scored = live
+ const scored = atsRow != null || (live
? Boolean(live.scored_at || live.ai_score != null)
- : c.scoringStatus === 'completed'
+ : c.scoringStatus === 'completed')
return {
+ band: atsRow?.band || null,
name: c.name,
email: c.email,
applied: c.applied,
@@ -100,7 +160,7 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
sourceLabel: SOURCE_LABEL[source] ?? source ?? '—',
subtitle: filename || c.email || null,
}
- }, [c, live, jobTitle])
+ }, [c, live, atsRow, jobTitle])
// enabled:false stays pending forever in TanStack v5 — short-circuit when no userId.
const guard = !isLive ? null
@@ -146,8 +206,9 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
{view.aiScore != null && (
-
- AI Match
+
+ {/* The band replaces the static label only when the ATS row answered. */}
+ {view.band || 'AI Match'}
)}
@@ -157,7 +218,12 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
- {guard ?? (<>
+ {/* Scoring sits OUTSIDE `guard`: it renders from the ats_results query, so
+ a slow or failed detail fetch must not blank it, and its own loading
+ and error states belong to that query. */}
+ {tab === 'Scoring' && }
+
+ {tab !== 'Scoring' && (guard ?? (<>
{tab === 'Overview' && (
<>
@@ -168,67 +234,101 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
Source{view.sourceLabel}
Added On{view.applied ? fmtDate(view.applied) : '—'}
- {view.scored && (
+ {/* Gated on the list being non-empty, not on `scored`: the detail
+ payload never carries keywords, so keying it to the score would
+ render a heading over a dash for every scored candidate. */}
+ {view.matchedSkills.length > 0 && (
<>
Matched Skills
- {view.matchedSkills.length
- ? view.matchedSkills.map((s) => {s})
- : —}
+ {view.matchedSkills.map((s) => {s})}
>
)}
>
)}
- {tab === 'Scoring' && (
- view.scored ? (
- <>
- AI Assessment
- {view.critique ?? '—'}
-
- Matched Skills ({view.matchedSkills.length})
-
-
- {view.matchedSkills.length
- ? view.matchedSkills.map((s) => (
- {s}
- ))
- : —}
-
-
- Missing Skills ({view.missingSkills.length})
-
-
- {view.missingSkills.length
- ? view.missingSkills.map((s) => (
- {s}
- ))
- : None — full match}
-
- >
- ) : (
-
- This candidate has not been scored against a job post.
-
- )
- )}
-
{tab === 'File' && (
File Name{view.filename ?? '—'}
Source{view.sourceLabel}
- {view.messageId && (
- Inbox Message{view.messageId}
- )}
Detail{view.matchSummary ?? '—'}
{view.errorCode && (
Error{view.errorCode}
)}
)}
- >)}
+ >))}
)
}
+
+/**
+ * The ats_results row, and nothing else.
+ *
+ * What that table stores IS the result: overall_score, band, the job post it was
+ * computed against, and when (backend/inbox/models.py::AtsResults). The matched
+ * and missing keywords and the critique live on the `candidates` table, which
+ * this endpoint does not join — so they are absent here rather than rendered as
+ * a heading over a dash.
+ */
+function ScoringTab({ enabled, ats, fallbackJobTitle }) {
+ // Before the early returns: hook order cannot depend on query state.
+ const { data: jobTitles } = useJobTitles()
+ const row = ats.data ?? null
+
+ // enabled:false stays pending forever in TanStack v5, so a candidate with no
+ // userId must short-circuit rather than spin.
+ if (!enabled) {
+ return (
+
+ This candidate has no account to look a score up against.
+
+ )
+ }
+ if (ats.isPending) {
+ return Fetching the ATS result.
+ }
+ if (ats.isError) {
+ return (
+
+ {friendlyAuthError(ats.error, 'Please try again.')}
+
+ )
+ }
+ if (!row) {
+ return (
+
+ This candidate has not been scored against a job post.
+
+ )
+ }
+
+ // The uuid resolves to a title only once the jobs list is cached; the prop is
+ // the fallback, and it is '—' on this screen when the row carries no job.
+ const against = (row.job_post_id && jobTitles?.get(String(row.job_post_id))) || fallbackJobTitle || '—'
+
+ return (
+ <>
+
+ {/* overall_score is a float column; the ring and the label both want an int. */}
+
+
+ {row.band || 'Scored'}
+ ATS match score out of 100
+
+
+
+
+ Scored Against
+ {against}
+
+
+ Scored On
+ {fmtStamp(row.computed_at) ?? '—'}
+
+
+ >
+ )
+}
diff --git a/frontend/src/screens/Settings.jsx b/frontend/src/screens/Settings.jsx
index df39e41..1b338f6 100644
--- a/frontend/src/screens/Settings.jsx
+++ b/frontend/src/screens/Settings.jsx
@@ -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). The Permissions tab remains
+ chrome; Access Control is the authoritative RBAC surface.
============================================================ */
-import { useState } from 'react'
+import { useEffect, useMemo, 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,23 +19,42 @@ import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as rolesApi from '../api/roles'
import * as usersApi from '../api/users'
-import { roles as seedRoles } from '../data/seed'
+import * as orgSettingsApi from '../api/orgSettings'
+
+/** "job_board" -> "Job Board". Slugs are the source of truth; this is display only. */
+function humaniseSlug(slug) {
+ return String(slug || '')
+ .split(/[_-]/)
+ .filter(Boolean)
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(' ')
+}
const TABS = [
- 'General', 'Users', 'Roles', 'Permissions', 'Notifications',
+ 'General', 'Users', 'Permissions', 'Notifications',
'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 (
{title}
{desc}
@@ -52,9 +62,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 (
@@ -64,48 +121,92 @@ export default function Settings() {
Configure your workspace and team preferences
- toast('Settings saved', 'success')}>
- Save Changes
-
+ {showOrgSave && (
+ save.mutate()}
+ >
+ {save.isPending ? 'Saving…' : 'Save Changes'}
+
+ )}
({ key: t, label: t }))} />
- {tab === 'General' && }
+ {tab === 'General' && { saveRef.current = fn }} />}
{tab === 'Users' && }
- {tab === 'Roles' && }
{tab === 'Permissions' && }
- {tab === 'Notifications' && }
+ {tab === 'Notifications' && { saveRef.current = fn }} />}
{tab === 'Email Templates' && }
- {tab === 'Career Portal' && }
- {tab === 'Branding' && }
- {tab === 'Security' && }
+ {tab === 'Career Portal' && { saveRef.current = fn }} />}
+ {tab === 'Branding' && { saveRef.current = fn }} />}
+ {tab === 'Security' && { saveRef.current = fn }} />}
{tab === 'Appearance' && }
)
}
-function General() {
+function General({ registerSave }) {
+ const defaults = {
+ 'general.company_name': 'Utopia Brands Inc.',
+ 'general.website': 'https://utopiabrands.com',
+ 'general.industry': 'Consumer Goods',
+ 'general.company_size': '201–500',
+ 'general.timezone': '(GMT-08:00) Pacific Time',
+ 'general.currency': 'USD ($)',
+ 'general.auto_archive_stale_jobs': true,
+ 'general.duplicate_detection': true,
+ }
+ const { query, draft, setField, toPayload } = useOrgDraft(CATEGORY_BY_TAB.General, defaults)
+
+ useEffect(() => {
+ registerSave?.(() => orgSettingsApi.update(toPayload()))
+ })
+
+ if (query.isPending) {
+ return Fetching organisation settings.
+ }
+ if (query.isError) {
+ return (
+
+
+ {friendlyAuthError(query.error, 'This tab needs settings.view.')}
+
+
+ )
+ }
+
return (
-
-
+
+
+ setField('general.company_name', e.target.value)} />
+
+
+
+ setField('general.website', e.target.value)} />
+
-
+ setField('general.industry', e.target.value)}>
+
+
-
+ setField('general.company_size', e.target.value)}>
+
+
-
+ setField('general.timezone', e.target.value)}>
@@ -113,12 +214,24 @@ function General() {
-
+ setField('general.currency', e.target.value)}>
+
+
-
-
+ setField('general.auto_archive_stale_jobs', v)}
+ />
+ setField('general.duplicate_detection', v)}
+ />
)
@@ -209,19 +322,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 +332,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 +344,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({
@@ -359,217 +454,586 @@ function AssignRoleModal({ user, users, onClose }) {
)
}
-function Roles() {
+/* Module slug -> rail icon. Unlisted modules fall back to 'lock'. */
+const MODULE_ICONS = {
+ dashboard: 'dashboard', inbox: 'inbox', jobs: 'briefcase', candidates: 'users',
+ pipeline: 'pipeline', interviews: 'video', assessments: 'check-square', offers: 'offers',
+ reports: 'reports', analytics: 'analytics', job_board: 'grid', settings: 'settings',
+ rbac_users: 'shield', tasks: 'list',
+}
+const OTHER_GROUP = '__other__'
+
+const normaliseName = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_')
+
+/**
+ * Permission Matrix — bundles grouped by module in the rail, one edited at a time.
+ *
+ * The rail groups on the module slugs from the tag catalogue, NOT on a split at the
+ * first underscore. Longest match wins, so `Job_Board_Read` files under job_board
+ * instead of colliding with `Jobs_*`. Two bundles (Candidate_SelfService,
+ * Interviewer_Assigned) are role-shaped and span 4-5 modules; a name-prefix rule
+ * would file them under a module their tags never touch, so they get an explicit
+ * Cross-module group rather than a wrong home.
+ *
+ * The grid stays one bundle at a time: all bundles x all tags is ~5000 cells with
+ * under 5% ticked, which reads as scattered dots rather than a matrix.
+ */
+function Permissions() {
const { toast } = useToast()
- return (
-
-
- Roles
Define access levels
- toast('New role dialog', 'info')}>
- Add Role
-
+ const qc = useQueryClient()
+ const [bundleId, setBundleId] = useState(null)
+ const [openKey, setOpenKey] = useState(null)
+ const [filter, setFilter] = useState('')
+
+ const bundlesQuery = useQuery({
+ queryKey: qk.roles.permissions(),
+ queryFn: () => rolesApi.listPermissions().then((r) => r.data ?? []),
+ })
+ const tagsQuery = useQuery({
+ queryKey: qk.roles.tags(),
+ queryFn: () => rolesApi.listPermissionTags().then((r) => r.data ?? []),
+ })
+ /* Roles are read only for blast radius: is_system is true on all 44 rows, so it
+ discriminates nothing. How many roles hold a bundle actually varies. */
+ const rolesQuery = useQuery({
+ queryKey: qk.roles.list(),
+ queryFn: () => rolesApi.listRoles().then((r) => r.data ?? []),
+ })
+
+ const bundles = bundlesQuery.data ?? []
+ const tags = tagsQuery.data ?? []
+ const roles = rolesQuery.data ?? []
+
+ const holdersByBundle = useMemo(() => {
+ const map = new Map()
+ for (const r of roles) {
+ for (const id of r.permissions ?? []) {
+ const list = map.get(Number(id)) ?? []
+ list.push(r.role_name)
+ map.set(Number(id), list)
+ }
+ }
+ return map
+ }, [roles])
+
+ /* Axes in first-seen order, so the grid reads the way the catalogue does. */
+ const { modules, actions, byCell } = useMemo(() => {
+ const mods = []
+ const acts = []
+ const cells = new Map()
+ 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)
+ cells.set(`${t.module}.${t.action}`, t.id)
+ }
+ return { modules: mods, actions: acts, byCell: cells }
+ }, [tags])
+
+ const groups = useMemo(() => {
+ /* Longest slug first: `job_board` must beat `jobs` on Job_Board_Read. */
+ const ranked = [...modules].sort((a, b) => b.length - a.length)
+ const byKey = new Map()
+ const other = []
+ for (const b of bundles) {
+ const n = normaliseName(b.name)
+ const hit = ranked.find((s) => n === s || n.startsWith(`${s}_`))
+ if (!hit) { other.push(b); continue }
+ if (!byKey.has(hit)) byKey.set(hit, [])
+ byKey.get(hit).push(b)
+ }
+ /* Emit in module order so the rail matches the matrix row order. */
+ const out = modules.filter((m) => byKey.has(m)).map((m) => ({
+ key: m, label: humaniseSlug(m), icon: MODULE_ICONS[m] ?? 'lock', items: byKey.get(m),
+ }))
+ if (other.length) out.push({ key: OTHER_GROUP, label: 'Cross-module', icon: 'layers', items: other })
+ return out
+ }, [bundles, modules])
+
+ const matches = useMemo(() => {
+ const q = filter.trim().toLowerCase()
+ if (!q) return null
+ return bundles.filter(
+ (b) => b.name.toLowerCase().includes(q) || (b.description ?? '').toLowerCase().includes(q),
+ )
+ }, [bundles, filter])
+
+ const bundle = bundles.find((b) => b.id === bundleId) ?? (matches ?? bundles)[0] ?? bundles[0]
+ const activeKey = groups.find((g) => g.items.some((b) => b.id === bundle?.id))?.key
+ const open = openKey ?? activeKey
+
+ const granted = useMemo(() => new Set((bundle?.permission_tags ?? []).map(Number)), [bundle])
+
+ const save = useMutation({
+ mutationFn: (permission_tags) => rolesApi.updatePermissionTags({ id: bundle.id, permission_tags }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: qk.roles.all() })
+ toast('Permissions updated', 'success')
+ },
+ onError: (err) => toast(friendlyAuthError(err, 'Could not update permissions.'), 'error'),
+ })
+
+ /* The endpoint replaces permission_tags wholesale — that is what makes unticking
+ actually revoke — so send the whole set, never a delta. */
+ const commit = (next) => save.mutate([...next])
+ const toggle = (tagId) => {
+ const next = new Set(granted)
+ if (next.has(tagId)) next.delete(tagId)
+ else next.add(tagId)
+ commit(next)
+ }
+ const setRow = (m, on) => {
+ const next = new Set(granted)
+ for (const a of actions) {
+ const id = byCell.get(`${m}.${a}`)
+ if (!id) continue
+ if (on) next.add(id)
+ else next.delete(id)
+ }
+ commit(next)
+ }
+
+ if (bundlesQuery.isPending || tagsQuery.isPending) {
+ return (
+
+ Fetching the catalogue from the server…
+
+ )
+ }
+
+ if (bundlesQuery.isError || tagsQuery.isError) {
+ return (
+
+
+ {friendlyAuthError(bundlesQuery.error ?? tagsQuery.error, 'The server did not return the permission catalogue.')}
+ {' '}This tab needs the rbac_users.view permission.
+
+
+ )
+ }
+
+ if (!bundle) {
+ return (
+
+ Bundles are seeded server-side.
+
+ )
+ }
+
+ const holders = holdersByBundle.get(bundle.id) ?? []
+
+ const bundleRow = (b) => {
+ const held = holdersByBundle.get(b.id) ?? []
+ return (
+ setBundleId(b.id)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setBundleId(b.id) }
+ }}
+ >
+
+ {b.name}
+
+ {b.permission_tags?.length ?? 0} tags
+ {held.length ? ` · ${held.length} role${held.length > 1 ? 's' : ''}` : ' · unused'}
+
+
-
-
- {seedRoles.map((r) => (
-
-
-
-
- {r.name}{r.desc}
- {r.users} users{r.perms}
- toast(`Editing ${r.name} role`, 'info')}>
-
-
+ )
+ }
+
+ return (
+
+
+
+
+ Bundles · {bundles.length}
+
+
+ setFilter(e.target.value)}
+ aria-label="Filter permission bundles"
+ />
+
+
+
+ {/* Filtering flattens the tree — a hit inside a collapsed group would
+ otherwise be invisible. */}
+ {matches
+ ? (matches.length
+ ? matches.map(bundleRow)
+ : No bundle matches that filter.
)
+ : groups.map((g) => {
+ const isOpen = open === g.key
+ return (
+
+ setOpenKey(isOpen ? '' : g.key)}
+ role="button"
+ aria-expanded={isOpen}
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenKey(isOpen ? '' : g.key) }
+ }}
+ >
+
+
+
+
+ {g.label}
+ {g.items.length} bundles
+
+
+
+ {isOpen && g.items.map(bundleRow)}
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ {bundle.name}
+ {bundle.description || 'No description'}
- ))}
+
+
+ {save.isPending && Saving…}
+ {granted.size} / {tags.length}
+
+
+
+
+
+ {' '}
+ {holders.length ? (
+ <>
+ Held by {holders.length} role{holders.length > 1 ? 's' : ''} — {holders.join(', ')}.
+ Each gains or loses access on its next request.
+ >
+ ) : (
+ <>No role holds this bundle, so edits here change nobody’s access yet.>
+ )}
+
+
+
+
+
+
+
+ Module
+ {actions.map((a) => {humaniseSlug(a)} )}
+
+
+
+
+ {modules.map((m) => {
+ const ids = actions.map((a) => byCell.get(`${m}.${a}`)).filter(Boolean)
+ const on = ids.filter((id) => granted.has(id)).length
+ return (
+
+
+ {humaniseSlug(m)}
+ {on} of {ids.length}
+
+ {actions.map((a) => {
+ const tagId = byCell.get(`${m}.${a}`)
+ /* No tag, no cell. An unchecked box would imply a denial the
+ catalogue never expressed. */
+ if (!tagId) return —
+ return (
+
+
+
+ )
+ })}
+
+ setRow(m, on < ids.length)}
+ >
+ {on < ids.length ? 'All' : 'None'}
+
+
+
+ )
+ })}
+
+
)
}
-function Permissions() {
- const modules = ['Jobs', 'Candidates', 'Interviews', 'Offers', 'Reports', 'Settings']
- const perms = ['View', 'Create', 'Edit', 'Delete']
- const { toast } = useToast()
- return (
-
-
- Permission Matrix
Recruiter role
-
-
-
- This is a simplified view. The authoritative matrix — 13 modules × 8 actions, resolved from the
- server — lives on Access Control.
-
-
-
-
- Module {perms.map((p) => {p} )}
-
-
- {modules.map((m) => (
-
- {m}
- {perms.map((p) => (
-
-
-
- ))}
-
- ))}
-
-
-
-
- )
-}
+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 Fetching notification preferences.
+ }
+ if (query.isError) {
+ return (
+
+
+ {friendlyAuthError(query.error, 'This tab needs settings.view.')}
+
+
+ )
+ }
-function Notifications() {
return (
Email Notifications
-
-
-
-
+ setField('notifications.email_new_applications', v)} />
+ setField('notifications.email_interview_reminders', v)} />
+ setField('notifications.email_offer_responses', v)} />
+ setField('notifications.email_weekly_digest', v)} />
In-App Notifications
-
-
-
+ setField('notifications.inapp_mentions', v)} />
+ setField('notifications.inapp_stage_changes', v)} />
+ setField('notifications.inapp_task_assignments', v)} />
)
}
function EmailTemplates() {
- const { toast } = useToast()
- const templates = [
- 'Application Received', 'Interview Invitation', 'Assessment Assignment',
- 'Offer Letter', 'Rejection — Post Interview', 'Reference Request',
- ]
return (
-
- Email Templates
- toast('New template', 'info')}>
- New Template
-
-
-
- {templates.map((t) => (
-
-
-
-
- {t}Last edited 3 days ago
- Active
- toast('Editing template', 'info')}>
-
- ))}
-
+
+ Template CRUD exists on the backend but is out of scope for this wiring pass.
+ Use Access Control / org settings for other configuration.
+
)
}
-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 Fetching career portal settings.
+ }
+ if (query.isError) {
+ return (
+
+
+ {friendlyAuthError(query.error, 'This tab needs settings.view.')}
+
+
+ )
+ }
+
return (
-
-
-
+
+
+ setField('career_portal.url', e.target.value)} />
+
+
+
+ setField('career_portal.headline', e.target.value)} />
+
+
+
+ setField('career_portal.cta', e.target.value)} />
+
-
-
-
-
+ setField('career_portal.public_job_board', v)} />
+ setField('career_portal.one_click_apply', v)} />
+ setField('career_portal.show_salary', v)} />
+ setField('career_portal.enable_referrals', v)} />
)
}
-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 Fetching branding settings.
+ }
+ if (query.isError) {
+ return (
+
+
+ {friendlyAuthError(query.error, 'This tab needs settings.view.')}
+
+
+ )
+ }
+
return (
- Company Logo
Displayed on career pages and emails
-
- UB
- toast('Upload dialog', 'info')}>Upload
-
-
-
- Brand Color
Primary accent across the portal
+ Brand Color
Primary accent across the portal (persisted; not yet applied globally)
{colors.map((c) => (
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) }}
/>
))}
-
-
+
+
+ setField('branding.email_footer', e.target.value)} />
+
+
+
+ setField('branding.support_email', e.target.value)} />
+
)
}
-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 Fetching security settings.
+ }
+ if (query.isError) {
+ return (
+
+
+ {friendlyAuthError(query.error, 'This tab needs settings.view.')}
+
+
+ )
+ }
+
return (
- Not yet enforced. 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.
+ Not yet enforced. 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.
-
-
-
-
+ setField('security.two_factor_enabled', v)} />
+ setField('security.sso_enabled', v)} />
+ setField('security.ip_allowlist', v)} />
+ setField('security.audit_logging', v)} />
-
+ setField('security.session_timeout', e.target.value)}>
+
+
-
+ setField('security.password_policy', e.target.value)}>
+
+
Data Retention
Auto-delete candidate data after set period
-
+ setField('security.data_retention_months', e.target.value)}>
+
+
)
}
-/** The one tab in the prototype that actually did something. */
function Appearance() {
const { toast } = useToast()
const { setTheme, useSystemTheme } = useTheme()
@@ -618,9 +1082,6 @@ function Appearance() {
-
-
-
)
diff --git a/frontend/src/screens/Tasks.jsx b/frontend/src/screens/Tasks.jsx
index 597b2d3..ff4c488 100644
--- a/frontend/src/screens/Tasks.jsx
+++ b/frontend/src/screens/Tasks.jsx
@@ -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() {
Saved Searches
Quick candidate filters
- toast('New saved search', 'info')}>
+ setAddingSearch(true)}>
-
- {savedSearches.map((s) => (
- navigate('/candidates')}>
-
-
-
-
- {s.name}
- {s.filters}
+ {savedQuery.isPending && Fetching saved searches. }
+ {savedQuery.isError && (
+
+ {friendlyAuthError(savedQuery.error, 'Request failed')}
+
+ )}
+ {savedQuery.isSuccess && savedSearches.length === 0 && (
+
+ Save a candidate filter to reopen it later.
+
+ )}
+ {savedQuery.isSuccess && savedSearches.length > 0 && (
+
+ {savedSearches.map((s) => (
+
+ navigate('/candidates', { state: { savedSearch: s } })}
+ >
+
+
+ navigate('/candidates', { state: { savedSearch: s } })}
+ >
+ {s.name}
+ {s.summary}
+
+ {s.count != null && {s.count}}
+ {
+ e.stopPropagation()
+ deleteSearch.mutate(s.id)
+ }}
+ >
+
+
- {s.count}
-
- ))}
-
+ ))}
+
+ )}
+ {addingSearch && (
+ setAddingSearch(false)}
+ onSubmit={(body) => createSearch.mutate(body)}
+ />
+ )}
+
{detail && (
)
}
+
+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 (
+
+ Cancel
+
+ {busy ? 'Saving…' : 'Save'}
+
+ >
+ }
+ >
+
+
+ )
+}
diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css
index 124f191..505fe1b 100644
--- a/frontend/src/styles/styles.css
+++ b/frontend/src/styles/styles.css
@@ -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 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; }
@@ -883,6 +896,26 @@ canvas { width: 100%; max-width: 100%; display: block; }
.skill-matched { background: var(--success-soft); color: var(--success); }
.skill-missing { background: var(--danger-soft); color: var(--danger); }
+/* Per-job scored-candidate cards (JobCandidates.jsx). Every zone has a fixed
+ height so the grid rows align regardless of how much text a CV produced. */
+.cand-card { display: flex; flex-direction: column; cursor: pointer; transition: .15s; }
+.cand-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
+.cand-card > .card-body { display: flex; flex-direction: column; flex: 1; padding: 18px; }
+.cand-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
+.cand-id { flex: 1; min-width: 0; }
+.cand-name { font-weight: 700; font-size: 14.5px; letter-spacing: -.1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.cand-role { font-size: 12.5px; color: var(--text-3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.cand-skills { display: flex; flex-wrap: wrap; gap: 6px; align-content: flex-start; height: 54px; overflow: hidden; margin-bottom: 12px; }
+.cand-chip { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 10px; border-radius: 7px; font-size: 12px; font-weight: 600; background: var(--bg-sunken); color: var(--text-2); white-space: nowrap; }
+.cand-chip svg { width: 11px; height: 11px; }
+.cand-chip.miss { background: var(--danger-soft); color: var(--danger); }
+.cand-chip.more { background: transparent; color: var(--text-3); padding: 0 4px; }
+.cand-crit { font-size: 13px; line-height: 1.55; color: var(--text-2); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; height: 60px; margin-bottom: 14px; }
+.cand-foot { margin-top: auto; display: flex; align-items: center; gap: 10px; padding-top: 12px; border-top: 1px solid var(--border); }
+.cand-meta { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
+.cand-meta svg { width: 13px; height: 13px; }
+.cand-company { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-3); }
+
/* Platform publish card */
.platform-card { display: flex; align-items: center; gap: 14px; padding: 16px; border: 1px solid var(--border); border-radius: 14px; transition: .15s; cursor: pointer; background: var(--bg-elev); }
.platform-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
diff --git a/frontend/src/ui/EmailBody.jsx b/frontend/src/ui/EmailBody.jsx
new file mode 100644
index 0000000..2c80ee3
--- /dev/null
+++ b/frontend/src/ui/EmailBody.jsx
@@ -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
+${html}