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..ae1815b 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -16,6 +16,27 @@ router = APIRouter() class AssignJobPostBody(BaseModel): job_post_id: str | None = None + +class ProcessingStateBody(BaseModel): + processing_state: str + + +class DuplicateBody(BaseModel): + is_duplicate: bool + + +class EmailSendBody(BaseModel): + to: str + subject: str + body: str + content_type: str | None = "html" + inbox_id: int | None = None + + +class EmailReplyBody(BaseModel): + record_id: str + body: str + @router.get("/email/fetch") async def fetch_email( top:int=Query(100), @@ -176,3 +197,84 @@ async def get_all_applications( raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + + +@router.get("/inbox/counts") +async def get_inbox_counts( + current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + data=await service.get_counts() + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/inbox/{record_id}/processing-state") +async def set_processing_state( + record_id: str, + payload: ProcessingStateBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + data=await service.set_processing_state(record_id,payload.processing_state) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/inbox/{record_id}/duplicate") +async def set_duplicate( + record_id: str, + payload: DuplicateBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + data=await service.set_duplicate(record_id,payload.is_duplicate) + return JSONResponse(content={"data":data,"total":1,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/email/send") +async def send_email( + payload: EmailSendBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + data=await service.send_email(payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/email/reply") +async def reply_email( + payload: EmailReplyBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + try: + service=Email(session=session) + data=await service.reply_email(payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index f355f43..6cd863a 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) @@ -679,6 +682,52 @@ class Inbox_Messages(SQLModel, table=True): await session.refresh(row) return row + @classmethod + async def count_processing(cls, session: AsyncSession): + statement = select( + func.count().label("all_count"), + func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712 + func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"), + func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"), + func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"), + func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 + func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"), + func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"), + ) + row = (await session.execute(statement)).one() + return { + "all": int(row.all_count or 0), + "unread": int(row.unread or 0), + "imported": int(row.imported or 0), + "processed": int(row.processed or 0), + "rejected": int(row.rejected or 0), + "duplicates": int(row.duplicates or 0), + "assigned": int(row.assigned or 0), + "unassigned": int(row.unassigned or 0), + } + + @classmethod + async def set_processing_state(cls, session: AsyncSession, record_id, processing_state: str): + row = await cls.get_inbox_message_by_id(session, record_id) + if not row: + return None + row.processing_state = processing_state + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def set_duplicate(cls, session: AsyncSession, record_id, is_duplicate: bool): + row = await cls.get_inbox_message_by_id(session, record_id) + if not row: + return None + row.is_duplicate = bool(is_duplicate) + session.add(row) + await session.commit() + await session.refresh(row) + return row + class SourceChannels(SQLModel, table=True): __tablename__ = "source_channels" 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..0bc19ee 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -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), diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 873d88d..712a529 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -1,4 +1,5 @@ import logging +import uuid import httpx,os from fastapi import HTTPException from inbox.enums import Candidate_application_Status @@ -10,6 +11,7 @@ from inbox.plugins import ( fetch_message_read_status, load_message_files, request_email_confirmation, + send_mail, ) from dotenv import load_dotenv load_dotenv() @@ -218,3 +220,97 @@ class Email: await Inbox_Messages.apply_read_status(self.session,[status]) refreshed=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) return serialize_message(refreshed) + + async def get_counts(self): + return await Inbox_Messages.count_processing(self.session) + + async def set_processing_state(self,record_id,processing_state): + allowed=("unread","imported","processed","rejected") + if processing_state not in allowed: + raise HTTPException(status_code=422,detail=f"processing_state must be one of {', '.join(allowed)}") + message=await Inbox_Messages.set_processing_state(self.session,record_id,processing_state) + if not message: + raise HTTPException(status_code=404,detail="Message not found") + return serialize_application(message) + + async def set_duplicate(self,record_id,is_duplicate): + if not isinstance(is_duplicate,bool): + raise HTTPException(status_code=422,detail="is_duplicate must be a boolean") + message=await Inbox_Messages.set_duplicate(self.session,record_id,is_duplicate) + if not message: + raise HTTPException(status_code=404,detail="Message not found") + return serialize_application(message) + + async def send_email(self,payload,current_user): + to_email=(payload.get("to") or "").strip() + subject=(payload.get("subject") or "").strip() + body=payload.get("body") or "" + content_type=(payload.get("content_type") or "html").strip() or "html" + if not to_email: + raise HTTPException(status_code=422,detail="to is required") + if not subject: + raise HTTPException(status_code=422,detail="subject is required") + if not body: + raise HTTPException(status_code=422,detail="body is required") + try: + await send_mail(to_email,subject,body,content_type=content_type) + except (httpx.HTTPError,RuntimeError) as e: + raise HTTPException(status_code=502,detail="Failed to send email") from e + inbox_id=payload.get("inbox_id") + job_post_id=None + try: + from notifications.models import Notifications + uid=None + raw=current_user.get("id") if current_user else None + if raw: + uid=uuid.UUID(str(raw)) + if uid: + await Notifications.insert_notification(self.session,{ + "user_id":uid, + "kind":"message", + "title":"Email sent", + "body":f"Sent “{subject}” to {to_email}", + "link_path":"/inbox", + "inbox_id":int(inbox_id) if inbox_id is not None else None, + "job_post_id":job_post_id, + }) + except Exception as exc: + logger.warning("notification insert skipped: %s",exc) + return {"accepted":True,"to":to_email,"subject":subject} + + async def reply_email(self,payload,current_user): + record_id=payload.get("record_id") + body=payload.get("body") or "" + if not record_id: + raise HTTPException(status_code=422,detail="record_id is required") + if not str(body).strip(): + raise HTTPException(status_code=422,detail="body is required") + message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) + if not message: + raise HTTPException(status_code=404,detail="Message not found") + to_email=(message.message_from or "").strip() + if not to_email: + raise HTTPException(status_code=422,detail="Message has no sender address") + original=(message.message_subject or "").strip() + subject=original if original.lower().startswith("re:") else f"Re: {original}" if original else "Re:" + try: + await send_mail(to_email,subject,body,content_type="html") + except (httpx.HTTPError,RuntimeError) as e: + raise HTTPException(status_code=502,detail="Failed to send email") from e + try: + from notifications.models import Notifications + uid=None + raw=current_user.get("id") if current_user else None + if raw: + uid=uuid.UUID(str(raw)) + if uid: + await Notifications.insert_notification(self.session,{ + "user_id":uid, + "kind":"message", + "title":"Email sent", + "body":f"Replied to {to_email}", + "link_path":"/inbox", + }) + except Exception as exc: + logger.warning("notification insert skipped: %s",exc) + return {"accepted":True,"to":to_email,"subject":subject} 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..c7735e7 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") @@ -85,3 +89,7 @@ app.include_router(candidate_router) app.include_router(analytics_router) app.include_router(offer_router) app.include_router(tasks_router) +app.include_router(assessments_router) +app.include_router(org_settings_router) +app.include_router(saved_search_router) +app.include_router(search_router) 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/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/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/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 59405ff..fd0cdbc 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,8 +23,8 @@ - - + +
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..e337259 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -73,3 +73,61 @@ export function assignJobPost(recordId, jobPostId) { export function rematch(recordId) { return request(`/inbox/${recordId}/match`, { method: 'POST' }) } + +/** Tab badges — `{all, unread, imported, processed, rejected, duplicates, assigned, unassigned}`. */ +export function counts() { + return request('/inbox/counts') +} + +/** + * The counts object itself, unwrapped. + * + * Every consumer must go through this. The Inbox tabs and the sidebar badge + * share one React Query key (qk.mailbox.counts), and React Query caches on the + * key alone — so two queryFns returning different shapes overwrite each other. + * That is precisely what happened: the badge's queryFn returned a number, the + * tabs' returned the object, and whichever ran last defined the cache. When the + * object won, the badge tried to render it as a child and React threw #31. + * A consumer that wants one field selects it with `select`, never by narrowing + * the fetcher. + */ +export async function fetchCounts() { + const res = await counts() + return res?.data ?? {} +} + +/** `processing_state` is `unread|imported|processed|rejected`. Requires inbox.edit. */ +export function setProcessingState(recordId, processingState) { + return request(`/inbox/${recordId}/processing-state`, { + method: 'PATCH', + body: { processing_state: processingState }, + }) +} + +export function setDuplicate(recordId, isDuplicate) { + return request(`/inbox/${recordId}/duplicate`, { + method: 'PATCH', + body: { is_duplicate: isDuplicate }, + }) +} + +export function sendEmail({ to, subject, body, contentType = 'html', inboxId } = {}) { + return request('/email/send', { + method: 'POST', + body: { + to, + subject, + body, + content_type: contentType, + inbox_id: inboxId, + }, + }) +} + +/** Reply sends a new message with a `Re:` subject — no thread headers. */ +export function replyEmail({ recordId, body } = {}) { + return request('/email/reply', { + method: 'POST', + body: { record_id: recordId, body }, + }) +} 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/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/queryKeys.js b/frontend/src/lib/queryKeys.js index 24b768a..273736b 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -22,6 +22,35 @@ export const qk = { applications: (p = {}) => ['mailbox', 'applications', p], message: (id) => ['mailbox', 'message', id], assignments: (p = {}) => ['mailbox', 'assignments', p], + counts: () => ['mailbox', 'counts'], + }, + assessments: { + all: () => ['assessments'], + list: (p = {}) => ['assessments', 'list', p], + counts: () => ['assessments', 'counts'], + }, + notifications: { + all: () => ['notifications'], + list: (p = {}) => ['notifications', 'list', p], + }, + managers: { + all: () => ['managers'], + list: () => ['managers', 'list'], + }, + orgSettings: { + all: () => ['orgSettings'], + list: (p = {}) => ['orgSettings', 'list', p], + }, + savedSearches: { + all: () => ['savedSearches'], + list: (p = {}) => ['savedSearches', 'list', p], + }, + search: { + query: (p = {}) => ['search', p], + }, + feedbackTemplates: { + all: () => ['feedbackTemplates'], + list: () => ['feedbackTemplates', 'list'], }, jobPosts: { all: () => ['jobPosts'], @@ -56,7 +85,17 @@ export const qk = { recruiters: (p = {}) => ['analytics', 'recruiters', p], }, offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] }, - interviews: { all: () => ['interviews'], range: (p = {}) => ['interviews', 'range', p] }, + interviews: { + all: () => ['interviews'], + range: (p = {}) => ['interviews', 'range', p], + byInbox: (inboxId) => ['interviews', 'inbox', inboxId], + }, + costs: { all: () => ['costs'], list: (p = {}) => ['costs', 'list', p] }, + assignments: { + all: () => ['assignments'], + job: (jobPostId) => ['assignments', 'job', jobPostId], + application: (inboxId) => ['assignments', 'application', inboxId], + }, activity: { all: () => ['activity'], feed: (p = {}) => ['activity', 'feed', p] }, tasks: { all: () => ['tasks'], 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 && ( + + )} } >
- +
{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={ - <> - - - - } - > -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
+ 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 ( + + + + + } + > +
{ e.preventDefault(); submit() }}> +
+
+ + + {form.errors.inbox_id} +
+
+ + +
+
+ + +
+
+ + form.setField('due_at', e.target.value)} + /> +
+
+
+
+ ) +} 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() {
+
-
+ {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..0c937a5 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -27,6 +27,7 @@ import Modal from '../ui/Modal' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { useToast } from '../ui/Toast' +import { useAuth } from '../auth/AuthContext' import { seedQuery } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' @@ -202,9 +203,6 @@ export default function CandidateProfile({ - @@ -453,7 +451,7 @@ export default function CandidateProfile({ )))} {tab === 'Documents' && (guard || (live ? ( - + ) : (
{[ @@ -721,16 +719,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 +728,79 @@ function NotesTab({ userId, rows }) { ) } +/** + * One note, editable in place via PATCH /notes/update. + * + * Editing is offered only on the signed-in user's OWN notes. The route does not + * check authorship and does not reassign `created_by`, so anyone with + * candidates.edit could silently rewrite a colleague's words under that + * colleague's name. Gating it here is the honest read of what the endpoint does. + */ +function NoteRow({ note: n, userId }) { + const { user } = useAuth() + const [editing, setEditing] = useState(false) + const [text, setText] = useState(n.note ?? '') + + const mine = Boolean(user?.id && n.created_by && String(user.id) === String(n.created_by)) + + const save = useProfileWrite({ + userId, + mutationFn: () => candidatesApi.updateNote(n.id, text.trim()), + success: 'Note updated', + onDone: () => setEditing(false), + }) + + if (editing) { + return ( +
+ +
+
+