diff --git a/.gitignore b/.gitignore index d4f06bd..15acc6b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ tests/ tests/** */tests/** */tests/**/* +# Paper form source documents (Annexure A/E/J) — reference material, not code. +# Root-anchored: backend/candidate_forms/ is the forms domain package and IS tracked. +/candidate_forms/ diff --git a/backend/candidate_forms/app.py b/backend/candidate_forms/app.py new file mode 100644 index 0000000..37d5f3a --- /dev/null +++ b/backend/candidate_forms/app.py @@ -0,0 +1,122 @@ +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 candidate_forms.plugins import definitions_payload +from candidate_forms.views import CandidateForm +from db_setup import get_session +from users.permissions import PermissionTag, require_permission + +router = APIRouter() + + +class FormCreate(BaseModel): + form_type: str + inbox_id: int | None = None + manual_upload_candidate_id: str | None = None + job_post_id: str | None = None + interviewer_id: str | None = None + form_date: datetime | None = None + sections: list | None = None + fields: dict | None = None + recommendation: str | None = None + + +class FormUpdate(BaseModel): + interviewer_id: str | None = None + form_date: datetime | None = None + sections: list | None = None + fields: dict | None = None + recommendation: str | None = None + + +@router.get("/forms/definitions") +async def fetch_form_definitions( + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW)), +): + try: + return JSONResponse(content={"data": definitions_payload(), "status_code": 200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/forms/fetch") +async def fetch_forms( + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW)), + form_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), + form_type: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service = CandidateForm(session=session) + data, summary, total = await service.get_forms( + form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip, + ) + return JSONResponse( + content={"data": data, "summary": summary, "total": total, "status_code": 200} + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/forms/create") +async def create_form( + payload: FormCreate, + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service = CandidateForm(session=session) + data = await service.create_form(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("/forms/update") +async def update_form( + payload: FormUpdate, + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT)), + form_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service = CandidateForm(session=session) + data = await service.update_form( + form_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("/forms/delete") +async def delete_form( + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_DELETE)), + form_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service = CandidateForm(session=session) + data = await service.delete_form(form_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/candidate_forms/models.py b/backend/candidate_forms/models.py new file mode 100644 index 0000000..1071df5 --- /dev/null +++ b/backend/candidate_forms/models.py @@ -0,0 +1,134 @@ +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 CandidateForms(SQLModel, table=True): + """One digitized hiring form (Annexure A requisition, or one of the two + Annexure E evaluation forms). Exactly one of inbox_id / + manual_upload_candidate_id links it to an application; `sections` holds the + rated grids with server-recomputed averages, `fields` the scalar entries.""" + + __tablename__ = "candidate_forms" + + 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") + form_type: str = Field(index=True) + interviewer_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + form_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + sections: list | None = Field(default=None, sa_type=JSON) + fields: dict | None = Field(default=None, sa_type=JSON) + overall_score: float | None = Field(default=None) + recommendation: str | None = Field(default=None) + 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_form_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_forms( + cls, + session: AsyncSession, + *, + form_id=None, + inbox_id=None, + manual_upload_candidate_id=None, + job_post_id=None, + form_type=None, + top: int | None = None, + skip: int = 0, + ): + if form_id: + row = await cls.get_form_by_id(session, form_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 form_type: + statement = statement.where(cls.form_type == form_type) + 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 insert_form(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_form_by_id(session, row.id) + + @classmethod + async def update_form(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_form_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_form(cls, session: AsyncSession, record_id): + row = await cls.get_form_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/candidate_forms/plugins.py b/backend/candidate_forms/plugins.py new file mode 100644 index 0000000..9ab696d --- /dev/null +++ b/backend/candidate_forms/plugins.py @@ -0,0 +1,362 @@ +"""Pure helpers for the hiring forms domain — no FastAPI, no DB. + +FORM_DEFINITIONS is the single authority for section/criterion/field keys AND +their on-screen labels, which reproduce the paper annexures verbatim (Annexure A +Employee Requisition Form, Annexure E Interview Evaluation Form). The frontend +renders labels from /forms/definitions, and criterion labels are denormalized +into every saved row so historical records survive future renames. +""" + +FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit") + +RATING_MIN = 1 +RATING_MAX = 4 +RATING_LABELS = { + 1: "Below Average (1)", + 2: "Average (2)", + 3: "Good (3)", + 4: "Excellent (4)", +} +RATING_SCALE_NOTE = ( + "Rating Scale: 1 = Below Average | 2 = Average | 3 = Good | 4 = Excellent. " + "Tick the box that applies for each criterion." +) + +RECOMMENDATIONS = ( + "selected", + "hold", + "next_round", + "not_selected", + "other_position", + "offer_placement", +) +RECOMMENDATION_LABELS = { + "selected": "Selected", + "hold": "Hold for now", + "next_round": "Shortlist for next round", + "not_selected": "Not selected", + "other_position": "Consider for other position", + "offer_placement": "Offer Placement", +} + +# INTERVIEW onward; APPROVED is the legacy spelling the UI maps to Hired. +FORM_READY_STATUSES = ("INTERVIEW", "OFFER", "HIRED", "APPROVED") + +EMPLOYMENT_TYPES = ("permanent", "temporary", "contract", "internee") +EMPLOYMENT_TYPE_LABELS = { + "permanent": "Permanent", + "temporary": "Temporary", + "contract": "Contract", + "internee": "Internee", +} + +_EVALUATION_HEADER_FIELDS = [ + {"key": "interviewer_name", "label": "Interviewer Name", "kind": "text"}, + {"key": "department", "label": "Department/Division", "kind": "text"}, + {"key": "position_title", "label": "Position Interviewed For", "kind": "text"}, +] + +_EVALUATION_FOOTER_FIELDS = [ + {"key": "strengths", "label": "Key Strengths", "kind": "textarea"}, + {"key": "concerns", "label": "Main Concerns or Gaps", "kind": "textarea"}, + { + "key": "overall_observation", + "label": "Overall Observation of the Candidate", + "kind": "textarea", + }, +] + +FORM_DEFINITIONS = { + "interview_analysis": { + "title": "Interview Analysis", + "source": "Annexure E - Interview Evaluation Form", + "scale_note": RATING_SCALE_NOTE, + "sections": [ + { + "key": "technical", + "title": "TECHNICAL COMPETENCY ASSESSMENT", + "average_label": "TECHNICAL SECTION AVERAGE", + "criteria": [ + {"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"}, + {"key": "relevant_experience", "label": "Depth of Relevant Experience"}, + {"key": "problem_solving", "label": "Problem Solving & Analytical Reasoning"}, + {"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"}, + {"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"}, + ], + }, + { + "key": "behavioral", + "title": "BEHAVIORAL COMPETENCY ASSESSMENT", + "average_label": "BEHAVIORAL SECTION AVERAGE", + "criteria": [ + {"key": "communication", "label": "Communication & Clarity of Expression"}, + {"key": "active_listening", "label": "Active Listening & Comprehension"}, + {"key": "ownership", "label": "Ownership & Accountability"}, + {"key": "resilience", "label": "Resilience Under Pressure"}, + {"key": "learning_agility", "label": "Learning Agility & Coachability"}, + ], + }, + ], + "fields": ( + _EVALUATION_HEADER_FIELDS + + [ + {"key": "summary", "label": "BRIEF SUMMARY OF THE CANDIDATE", "kind": "textarea"}, + {"key": "technical_note", "label": "Technical Competency — Notes", "kind": "text"}, + {"key": "behavioral_note", "label": "Behavioral Competency — Notes", "kind": "text"}, + ] + + _EVALUATION_FOOTER_FIELDS + ), + "has_recommendation": True, + }, + "cultural_fit": { + "title": "Cultural Fit", + "source": "Annexure E - Interview Evaluation Form", + "scale_note": RATING_SCALE_NOTE, + "sections": [ + { + "key": "cultural", + "title": "CULTURAL FIT", + "average_label": "CULTURAL FIT SECTION", + "criteria": [ + {"key": "company_values", "label": "Alignment with Company Values"}, + {"key": "professionalism", "label": "Professionalism & Integrity"}, + {"key": "collaboration", "label": "Collaboration & Team Orientation"}, + {"key": "adaptability", "label": "Adaptability to Change"}, + {"key": "work_ethic", "label": "Work Ethic & Reliability"}, + ], + }, + ], + "fields": ( + _EVALUATION_HEADER_FIELDS + + [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}] + + _EVALUATION_FOOTER_FIELDS + ), + "has_recommendation": True, + }, + "requisition": { + "title": "Employee Requisition", + "source": "Annexure A - Employee Requisition Form", + "header_note": "To: Human Resource Department", + "sections": [], + "fields": [ + {"key": "department", "label": "From: (Dept.)", "kind": "text"}, + {"key": "job_title", "label": "Job Title", "kind": "text"}, + {"key": "date_needed", "label": "Date Needed", "kind": "date"}, + { + "key": "employment_type", + "label": "Permanent / Temporary / Contract / Internee", + "kind": "select", + "options": list(EMPLOYMENT_TYPES), + }, + {"key": "period_from", "label": "If not permanent, specify the period — From", "kind": "date"}, + {"key": "period_to", "label": "If not permanent, specify the period — To", "kind": "date"}, + { + "key": "jd_available", + "label": ( + "JD Available (JD is mandatory, TA team will not proceed with " + "sourcing until JD is provided)" + ), + "kind": "bool", + }, + {"key": "is_replacement", "label": "IF A REPLACEMENT, COMPLETE THE FOLLOWING", "kind": "bool"}, + {"key": "replacement_employee", "label": "Employee to be replaced", "kind": "text"}, + {"key": "replacement_grade", "label": "Grade", "kind": "text"}, + {"key": "replacement_job_title", "label": "Job Title (replaced employee)", "kind": "text"}, + {"key": "replacement_date_separated", "label": "Date Separated", "kind": "date"}, + { + "key": "headcount_justification", + "label": "IN CASE OF NEW/ADDITIONAL HEADCOUNT PLEASE PROVIDE JUSTIFICATION", + "kind": "textarea", + }, + {"key": "proposed_budget", "label": "PROPOSE BUDGET", "kind": "text"}, + {"key": "recommended_grade", "label": "RECOMMENDED GRADE", "kind": "text"}, + {"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"}, + {"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "kind": "text"}, + {"key": "recommended_employee_department", "label": "EMPLOYEE DEPARTMENT", "kind": "text"}, + {"key": "initiated_by", "label": "Initiated By — Name", "kind": "text"}, + {"key": "initiated_date", "label": "Initiated By — Date", "kind": "date"}, + {"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"}, + {"key": "recommended_date", "label": "Recommended By — Date", "kind": "date"}, + {"key": "approved_by", "label": "Approved By — Name (Director HR)", "kind": "text"}, + {"key": "approved_date", "label": "Approved By — Date", "kind": "date"}, + {"key": "vp_approved_by", "label": "Approved By — Name (VP/SVP)", "kind": "text"}, + {"key": "vp_approved_date", "label": "Approved By — Date (VP/SVP)", "kind": "date"}, + ], + "field_enums": {"employment_type": EMPLOYMENT_TYPES}, + "has_recommendation": False, + }, +} + + +def definitions_payload() -> dict: + """The response body for GET /forms/definitions.""" + return { + "form_types": list(FORM_TYPES), + "forms": FORM_DEFINITIONS, + "rating_labels": {str(k): v for k, v in RATING_LABELS.items()}, + "recommendations": list(RECOMMENDATIONS), + "recommendation_labels": dict(RECOMMENDATION_LABELS), + "employment_types": list(EMPLOYMENT_TYPES), + "employment_type_labels": dict(EMPLOYMENT_TYPE_LABELS), + "form_ready_statuses": list(FORM_READY_STATUSES), + } + + +def _coerce_rating(value): + if value in (None, ""): + return None + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"rating must be a number, got {value!r}") + if number != int(number): + raise ValueError(f"rating must be a whole number, got {value!r}") + rating = int(number) + if rating < RATING_MIN or rating > RATING_MAX: + raise ValueError(f"rating must be between {RATING_MIN} and {RATING_MAX}, got {rating}") + return rating + + +def _mean(values, digits=2): + values = [v for v in values if v is not None] + if not values: + return None + return round(sum(values) / len(values), digits) + + +def normalize_sections(form_type: str, sections): + """Validate submitted rated sections against the form definition and + recompute all derived numbers. Returns (normalized_sections, overall_score). + + Every definition section is emitted in definition order with denormalized + labels; submitted per-criterion ratings are merged in; client-sent averages + are discarded and recomputed (mean of the non-null ratings, 2 dp). The + overall score is the mean of the section averages. Raises ValueError on + unknown section/criterion keys or out-of-range ratings (422 material). + """ + definition = FORM_DEFINITIONS.get(form_type) + if definition is None: + raise ValueError(f"unknown form_type {form_type!r}") + if not definition["sections"]: + return None, None + if sections is None: + sections = [] + if not isinstance(sections, list): + raise ValueError("sections must be a list") + + known_sections = {s["key"]: s for s in definition["sections"]} + submitted = {} + for entry in sections: + if not isinstance(entry, dict): + raise ValueError("each section must be an object") + key = entry.get("key") + if key not in known_sections: + raise ValueError(f"unknown section {key!r} for {form_type}") + criteria = entry.get("criteria") or [] + if not isinstance(criteria, list): + raise ValueError("section criteria must be a list") + known_criteria = {c["key"] for c in known_sections[key]["criteria"]} + ratings = {} + for criterion in criteria: + if not isinstance(criterion, dict): + raise ValueError("each criterion must be an object") + ckey = criterion.get("key") + if ckey not in known_criteria: + raise ValueError(f"unknown criterion {ckey!r} in section {key!r}") + ratings[ckey] = _coerce_rating(criterion.get("rating")) + submitted[key] = ratings + + normalized = [] + section_averages = [] + for section_def in definition["sections"]: + ratings = submitted.get(section_def["key"], {}) + criteria = [ + { + "key": c["key"], + "label": c["label"], + "rating": ratings.get(c["key"]), + } + for c in section_def["criteria"] + ] + average = _mean([c["rating"] for c in criteria]) + if average is not None: + section_averages.append(average) + normalized.append( + { + "key": section_def["key"], + "title": section_def["title"], + "criteria": criteria, + "average": average, + } + ) + return normalized, _mean(section_averages) + + +def normalize_fields(form_type: str, fields): + """Keep only the definition's field keys, validate enums, coerce booleans.""" + definition = FORM_DEFINITIONS.get(form_type) + if definition is None: + raise ValueError(f"unknown form_type {form_type!r}") + if fields is None: + return {} + if not isinstance(fields, dict): + raise ValueError("fields must be an object") + + known = {f["key"]: f for f in definition["fields"]} + enums = definition.get("field_enums", {}) + normalized = {} + for key, value in fields.items(): + spec = known.get(key) + if spec is None: + continue + if value in (None, ""): + normalized[key] = None + continue + if key in enums: + value = str(value).strip().lower() + if value not in enums[key]: + raise ValueError(f"{key} must be one of {', '.join(enums[key])}") + elif spec["kind"] == "bool": + if isinstance(value, str): + value = value.strip().lower() in ("true", "yes", "1", "on") + else: + value = bool(value) + else: + value = str(value).strip() or None + normalized[key] = value + return normalized + + +def combined_summary(rows): + """Annexure E's OVERALL SCORE SUMMARY across the two evaluation forms. + + `rows` are candidate_forms records (attribute access: form_type, created_at, + sections). The latest interview_analysis row supplies the technical and + behavioral averages, the latest cultural_fit row the cultural average. + The combined overall (mean of the three section averages, 2 dp) appears + only once all three exist. Returns None when neither evaluation exists. + """ + latest = {} + for row in rows: + if row.form_type not in ("interview_analysis", "cultural_fit"): + continue + current = latest.get(row.form_type) + if current is None or (row.created_at and current.created_at and row.created_at > current.created_at): + latest[row.form_type] = row + if not latest: + return None + + averages = {"technical": None, "behavioral": None, "cultural": None} + for row in latest.values(): + for section in row.sections or []: + key = section.get("key") + if key in averages: + averages[key] = section.get("average") + + complete = all(v is not None for v in averages.values()) + return { + "technical_avg": averages["technical"], + "behavioral_avg": averages["behavioral"], + "cultural_avg": averages["cultural"], + "combined_overall": _mean(list(averages.values())) if complete else None, + } diff --git a/backend/candidate_forms/serializers.py b/backend/candidate_forms/serializers.py new file mode 100644 index 0000000..aa8362b --- /dev/null +++ b/backend/candidate_forms/serializers.py @@ -0,0 +1,32 @@ +def serialize_form( + row, + *, + candidate_name=None, + job_title=None, + interviewer_name=None, + created_by_name=None, +) -> dict: + """`candidate_name` / `job_title` / user names come from one batched lookup + in views — never a lazy per-row load.""" + 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, + "form_type": row.form_type, + "interviewer_id": str(row.interviewer_id) if row.interviewer_id else None, + "interviewer_name": interviewer_name, + "form_date": row.form_date.isoformat() if row.form_date else None, + "sections": list(row.sections) if row.sections else None, + "fields": dict(row.fields) if row.fields else {}, + "overall_score": row.overall_score, + "recommendation": row.recommendation, + "created_by": str(row.created_by) if row.created_by else None, + "created_by_name": created_by_name, + "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/candidate_forms/views.py b/backend/candidate_forms/views.py new file mode 100644 index 0000000..447a797 --- /dev/null +++ b/backend/candidate_forms/views.py @@ -0,0 +1,338 @@ +import logging +import uuid +from datetime import timezone + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from candidate_forms.models import CandidateForms, _now +from candidate_forms.plugins import ( + FORM_READY_STATUSES, + FORM_TYPES, + RECOMMENDATIONS, + combined_summary, + normalize_fields, + normalize_sections, +) +from candidate_forms.serializers import serialize_form +from inbox.models import Inbox +from job.candidate.models import Manual_UPLOAD_CANDIDATE +from job.history.enums import HistoryEvent +from job.history.views import HistoryRecorder +from job.job_post.models import JobPosts +from users.models import Users + +logger = logging.getLogger("candidate_forms") + + +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 + + +def _stage_value(status) -> str: + return str(getattr(status, "value", status) or "").upper() + + +class CandidateForm: + def __init__(self, session: AsyncSession): + self.session = session + + async def _validate_link(self, payload): + """Exactly one of inbox_id / manual_upload_candidate_id; both rows must + exist. Returns (inbox_id, manual_id, job_post_id, current_stage).""" + 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_with_message(self.session, inbox_id) + if link is None: + raise HTTPException(status_code=404, detail="Inbox record not found") + stage = _stage_value( + link.messages.application_status if link.messages is not None else None + ) + 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") + stage = _stage_value(manual.status) + + 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") + return inbox_id, manual_id, job_post_id, stage + + def _normalize_payload(self, form_type, payload): + """Shared create/update normalization. Returns the writable fields dict + for the keys present in `payload`.""" + fields = {} + if "sections" in payload: + try: + sections, overall = normalize_sections(form_type, payload.get("sections")) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + fields["sections"] = sections + fields["overall_score"] = overall + if "fields" in payload: + try: + fields["fields"] = normalize_fields(form_type, payload.get("fields")) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + if "recommendation" in payload: + recommendation = payload.get("recommendation") or None + if recommendation is not None and recommendation not in RECOMMENDATIONS: + raise HTTPException( + status_code=422, + detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}", + ) + fields["recommendation"] = recommendation + if "interviewer_id" in payload: + interviewer_id = _as_uuid(payload.get("interviewer_id")) + if payload.get("interviewer_id") and interviewer_id is None: + raise HTTPException(status_code=422, detail="Invalid interviewer_id") + fields["interviewer_id"] = interviewer_id + if "form_date" in payload: + fields["form_date"] = _aware(payload.get("form_date")) + return fields + + 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()} + + user_ids = {r.interviewer_id for r in rows if r.interviewer_id} + user_ids |= {r.created_by for r in rows if r.created_by} + users_by_id = {} + if user_ids: + result = await self.session.execute( + select(Users.id, Users.name).where(Users.id.in_(user_ids)) + ) + users_by_id = {uid: name for uid, name in result.all()} + return inbox_by_id, manual_by_id, jobs_by_id, users_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, users_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_form( + row, + candidate_name=name, + job_title=title, + interviewer_name=users_by_id.get(row.interviewer_id), + created_by_name=users_by_id.get(row.created_by), + ) + ) + return out + + async def get_forms( + self, + form_id=None, + inbox_id=None, + manual_upload_candidate_id=None, + job_post_id=None, + form_type=None, + top=None, + skip=0, + ): + if form_type and form_type not in FORM_TYPES: + raise HTTPException( + status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}" + ) + rows, total = await CandidateForms.fetch_forms( + self.session, + form_id=form_id, + inbox_id=inbox_id, + manual_upload_candidate_id=manual_upload_candidate_id, + job_post_id=job_post_id, + form_type=form_type, + top=top, + skip=skip or 0, + ) + + summary = None + if inbox_id is not None or manual_upload_candidate_id is not None: + if form_type: + # The filtered fetch may not include both evaluation forms. + summary_rows, _ = await CandidateForms.fetch_forms( + self.session, + inbox_id=inbox_id, + manual_upload_candidate_id=manual_upload_candidate_id, + ) + else: + summary_rows = rows + summary = combined_summary(summary_rows) + return await self._serialize_rows(rows), summary, total + + async def create_form(self, payload, current_user): + form_type = (payload.get("form_type") or "").strip() + if form_type not in FORM_TYPES: + raise HTTPException( + status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}" + ) + inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload) + if stage not in FORM_READY_STATUSES: + raise HTTPException( + status_code=409, + detail=( + "Forms unlock at the Interview stage — this candidate is at " + f"{stage or 'Shortlist'}" + ), + ) + + fields = { + "inbox_id": inbox_id, + "manual_upload_candidate_id": manual_id, + "job_post_id": job_post_id, + "form_type": form_type, + "created_by": _user_id(current_user), + } + fields.update( + self._normalize_payload( + form_type, + { + key: payload.get(key) + for key in ("sections", "fields", "recommendation", "interviewer_id", "form_date") + }, + ) + ) + if form_type != "requisition" and fields.get("interviewer_id") is None: + fields["interviewer_id"] = _user_id(current_user) + if fields.get("form_date") is None: + fields["form_date"] = _now() + + row = await CandidateForms.insert_form(self.session, fields) + await HistoryRecorder(self.session).record( + HistoryEvent.FORM_CREATED, + current_user=current_user, + inbox_id=inbox_id, + manual_upload_candidate_id=manual_id, + entity_type="candidate_form", + entity_id=row.id, + to_value=form_type, + commit=True, + ) + return (await self._serialize_rows([row]))[0] + + async def update_form(self, form_id, payload, current_user): + _user_id(current_user) + row = await CandidateForms.get_form_by_id(self.session, form_id) + if not row: + raise HTTPException(status_code=404, detail="Form not found") + + fields = self._normalize_payload(row.form_type, payload) + if not fields: + raise HTTPException(status_code=400, detail="No fields to update") + + updated = await CandidateForms.update_form(self.session, form_id, fields) + if not updated: + raise HTTPException(status_code=404, detail="Form not found") + await HistoryRecorder(self.session).record( + HistoryEvent.FORM_UPDATED, + current_user=current_user, + inbox_id=updated.inbox_id, + manual_upload_candidate_id=updated.manual_upload_candidate_id, + entity_type="candidate_form", + entity_id=updated.id, + to_value=updated.form_type, + commit=True, + ) + return (await self._serialize_rows([updated]))[0] + + async def delete_form(self, form_id, current_user): + _user_id(current_user) + row = await CandidateForms.soft_delete_form(self.session, form_id) + if not row: + raise HTTPException(status_code=404, detail="Form not found") + return {"id": str(row.id), "deleted": True} diff --git a/backend/job/history/enums.py b/backend/job/history/enums.py index 5a3cdc1..8d955d6 100644 --- a/backend/job/history/enums.py +++ b/backend/job/history/enums.py @@ -18,3 +18,5 @@ class HistoryEvent(str, Enum): CANDIDATE_IMPORTED = "candidate.imported" DOCUMENT_UPLOADED = "document.uploaded" ATS_SCORED = "ats.scored" + FORM_CREATED = "form.created" + FORM_UPDATED = "form.updated" diff --git a/backend/main.py b/backend/main.py index 9f221c8..ab61501 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,7 @@ from saved_search.app import router as saved_search_router from search.app import router as search_router from interview.app import router as interview_router from talent.app import router as talent_router +from candidate_forms.app import router as candidate_forms_router logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s") logger=logging.getLogger("main") @@ -106,3 +107,4 @@ app.include_router(saved_search_router) app.include_router(search_router) app.include_router(interview_router) app.include_router(talent_router) +app.include_router(candidate_forms_router) diff --git a/backend/migrations/manual/008_hiring_forms_rbac.sql b/backend/migrations/manual/008_hiring_forms_rbac.sql new file mode 100644 index 0000000..c3cabce --- /dev/null +++ b/backend/migrations/manual/008_hiring_forms_rbac.sql @@ -0,0 +1,52 @@ +-- 008_hiring_forms_rbac.sql +-- Manual one-shot: a `hiring_forms` bundle granting interviews.create/edit/delete +-- so staff roles can fill and amend the digitized hiring forms (Annexure A +-- requisition, Annexure E interview analysis + cultural fit) served by the +-- candidate_forms domain. The interviews.* tags themselves were seeded by 001; +-- the analytics_dashboard bundle only carries interviews.view, which is why the +-- write tags need this bundle. Mirrors 007's idempotent pattern; applied +-- automatically at startup by alembic_setup.run_manual_sql() and recorded in +-- manual_migrations. +-- +-- Users must log in again after this applies — permissions are resolved from +-- the DB per request, but the frontend caches the list from /users/me. + +-- ============================================================================= +-- 1. Bundle holding the interviews write tags +-- ============================================================================= +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'hiring_forms', + 'Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND tag_name IN ('interviews.create', 'interviews.edit', 'interviews.delete') + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'hiring_forms' +); + +-- ============================================================================= +-- 2. Attach the bundle to the staff roles (idempotent; same role list as 007) +-- ============================================================================= +UPDATE app.roles r +SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id), + updated_at = NOW() +FROM app.permissions p +WHERE p.name = 'hiring_forms' + AND r.role_name IN ( + 'system_administrator', + 'hr_administrator', + 'recruiter', + 'hiring_manager', + 'department_head', + 'ceo' + ) + AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id)); diff --git a/backend/offer/app.py b/backend/offer/app.py index 6bc8fff..d4132d9 100644 --- a/backend/offer/app.py +++ b/backend/offer/app.py @@ -27,6 +27,13 @@ class OfferCreate(BaseModel): equity_instrument: str | None = None start_date: datetime | None = None expiry_date: datetime | None = None + cadre: str | None = None + gross_salary_in_words: str | None = None + subsidized_services: str | None = None + probation_period: str | None = None + notice_period: str | None = None + work_location: str | None = None + work_timings: str | None = None change_reason: str | None = None @@ -41,6 +48,13 @@ class OfferUpdate(BaseModel): equity_instrument: str | None = None start_date: datetime | None = None expiry_date: datetime | None = None + cadre: str | None = None + gross_salary_in_words: str | None = None + subsidized_services: str | None = None + probation_period: str | None = None + notice_period: str | None = None + work_location: str | None = None + work_timings: str | None = None sent_at: datetime | None = None responded_at: datetime | None = None closed_at: datetime | None = None diff --git a/backend/offer/models.py b/backend/offer/models.py index d65707e..a6da752 100644 --- a/backend/offer/models.py +++ b/backend/offer/models.py @@ -25,6 +25,14 @@ class Offers(SQLModel, table=True): annual_bonus_pct: float | None = Field(default=None) equity_units: int | None = Field(default=None) equity_instrument: str | None = Field(default=None) + # Annexure J (offer email format) fields. + cadre: str | None = Field(default=None) + gross_salary_in_words: str | None = Field(default=None) + subsidized_services: str | None = Field(default=None) + probation_period: str | None = Field(default=None) + notice_period: str | None = Field(default=None) + work_location: str | None = Field(default=None) + work_timings: str | None = Field(default=None) start_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) expiry_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) diff --git a/backend/offer/plugins.py b/backend/offer/plugins.py index 8f210ac..006bdb0 100644 --- a/backend/offer/plugins.py +++ b/backend/offer/plugins.py @@ -1,4 +1,6 @@ def non_validation_values(): fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", - "equity_units","equity_instrument","start_date","expiry_date") + "equity_units","equity_instrument","start_date","expiry_date", + "cadre","gross_salary_in_words","subsidized_services","probation_period", + "notice_period","work_location","work_timings") return fields \ No newline at end of file diff --git a/backend/offer/serializers.py b/backend/offer/serializers.py index b1b0660..165f0b4 100644 --- a/backend/offer/serializers.py +++ b/backend/offer/serializers.py @@ -12,6 +12,13 @@ def serialize_offer(row) -> dict: "annual_bonus_pct": row.annual_bonus_pct, "equity_units": row.equity_units, "equity_instrument": row.equity_instrument, + "cadre": row.cadre, + "gross_salary_in_words": row.gross_salary_in_words, + "subsidized_services": row.subsidized_services, + "probation_period": row.probation_period, + "notice_period": row.notice_period, + "work_location": row.work_location, + "work_timings": row.work_timings, "start_date": row.start_date.isoformat() if row.start_date else None, "expiry_date": row.expiry_date.isoformat() if row.expiry_date else None, "sent_at": row.sent_at.isoformat() if row.sent_at else None, diff --git a/backend/offer/views.py b/backend/offer/views.py index 9595ae0..7c08a76 100644 --- a/backend/offer/views.py +++ b/backend/offer/views.py @@ -90,6 +90,8 @@ class Offer: "status","base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", "equity_units","equity_instrument","start_date","expiry_date","sent_at", "responded_at","closed_at","issued_by","inbox_id","job_post_id","candidate_user_id", + "cadre","gross_salary_in_words","subsidized_services","probation_period", + "notice_period","work_location","work_timings", ): if key not in payload: continue diff --git a/backend/tests/test_candidate_forms.py b/backend/tests/test_candidate_forms.py new file mode 100644 index 0000000..49e1a7a --- /dev/null +++ b/backend/tests/test_candidate_forms.py @@ -0,0 +1,162 @@ +"""Hermetic tests for the candidate_forms pure logic (plugins.py) — no DB, no +FastAPI. The rated-section math, unknown-key handling, and the Annexure E +combined summary are the parts a typo would silently corrupt.""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace + +import pytest + +from candidate_forms.plugins import ( + FORM_DEFINITIONS, + FORM_READY_STATUSES, + combined_summary, + definitions_payload, + normalize_fields, + normalize_sections, +) + + +def _sections(form_type: str, ratings_by_section: dict[str, dict[str, int | None]]): + return [ + {"key": key, "criteria": [{"key": ck, "rating": rv} for ck, rv in ratings.items()]} + for key, ratings in ratings_by_section.items() + ] + + +class TestNormalizeSections: + def test_recomputes_averages_and_overall(self): + sections = _sections( + "interview_analysis", + { + "technical": {"core_job_knowledge": 4, "relevant_experience": 3}, + "behavioral": {"communication": 2}, + }, + ) + normalized, overall = normalize_sections("interview_analysis", sections) + by_key = {s["key"]: s for s in normalized} + assert by_key["technical"]["average"] == 3.5 + assert by_key["behavioral"]["average"] == 2 + assert overall == 2.75 + + def test_client_sent_averages_are_discarded(self): + sections = _sections("cultural_fit", {"cultural": {"company_values": 4}}) + sections[0]["average"] = 1.0 # lying client + normalized, overall = normalize_sections("cultural_fit", sections) + assert normalized[0]["average"] == 4 + assert overall == 4 + + def test_emits_every_definition_criterion_with_labels(self): + normalized, overall = normalize_sections("interview_analysis", []) + assert [s["key"] for s in normalized] == ["technical", "behavioral"] + technical = normalized[0] + assert len(technical["criteria"]) == 5 + assert technical["criteria"][0]["label"] == "Core Job Knowledge & Domain Expertise" + assert technical["average"] is None + assert overall is None + + def test_unknown_section_rejected(self): + with pytest.raises(ValueError): + normalize_sections("cultural_fit", _sections("cultural_fit", {"technical": {}})) + + def test_unknown_criterion_rejected(self): + bad = _sections("cultural_fit", {"cultural": {"made_up": 3}}) + with pytest.raises(ValueError): + normalize_sections("cultural_fit", bad) + + @pytest.mark.parametrize("rating", [0, 5, -1, "high", 3.5]) + def test_out_of_range_ratings_rejected(self, rating): + bad = _sections("cultural_fit", {"cultural": {"company_values": rating}}) + with pytest.raises(ValueError): + normalize_sections("cultural_fit", bad) + + def test_string_and_null_ratings_coerced(self): + sections = _sections( + "cultural_fit", {"cultural": {"company_values": "3", "professionalism": None}} + ) + normalized, _ = normalize_sections("cultural_fit", sections) + ratings = {c["key"]: c["rating"] for c in normalized[0]["criteria"]} + assert ratings["company_values"] == 3 + assert ratings["professionalism"] is None + + def test_requisition_has_no_sections(self): + assert normalize_sections("requisition", None) == (None, None) + + +class TestNormalizeFields: + def test_unknown_keys_dropped_and_bools_coerced(self): + out = normalize_fields( + "requisition", + {"department": " IT ", "jd_available": "Yes", "bogus": "x", "is_replacement": False}, + ) + assert out == {"department": "IT", "jd_available": True, "is_replacement": False} + + def test_employment_type_enum_enforced(self): + assert normalize_fields("requisition", {"employment_type": "Contract"}) == { + "employment_type": "contract" + } + with pytest.raises(ValueError): + normalize_fields("requisition", {"employment_type": "freelance"}) + + def test_evaluation_note_fields_exist(self): + out = normalize_fields( + "interview_analysis", {"technical_note": "solid", "behavioral_note": "calm"} + ) + assert out == {"technical_note": "solid", "behavioral_note": "calm"} + assert normalize_fields("cultural_fit", {"cultural_note": "fits"}) == { + "cultural_note": "fits" + } + + +class TestCombinedSummary: + def _row(self, form_type, day, ratings_by_section): + sections, _ = normalize_sections(form_type, _sections(form_type, ratings_by_section)) + return SimpleNamespace( + form_type=form_type, created_at=datetime(2026, 1, day), sections=sections + ) + + def test_combined_needs_all_three_sections(self): + ia = self._row( + "interview_analysis", + 1, + {"technical": {"core_job_knowledge": 4}, "behavioral": {"communication": 2}}, + ) + assert combined_summary([ia])["combined_overall"] is None + + cf = self._row("cultural_fit", 2, {"cultural": {"company_values": 3}}) + summary = combined_summary([ia, cf]) + assert summary == { + "technical_avg": 4, + "behavioral_avg": 2, + "cultural_avg": 3, + "combined_overall": 3.0, + } + + def test_latest_row_per_type_wins(self): + old = self._row("cultural_fit", 1, {"cultural": {"company_values": 1}}) + new = self._row("cultural_fit", 5, {"cultural": {"company_values": 4}}) + assert combined_summary([old, new])["cultural_avg"] == 4 + + def test_no_evaluations_returns_none(self): + req = SimpleNamespace(form_type="requisition", created_at=datetime(2026, 1, 1), sections=None) + assert combined_summary([req]) is None + assert combined_summary([]) is None + + +class TestDefinitions: + def test_paper_parity_criterion_counts(self): + ia = FORM_DEFINITIONS["interview_analysis"] + cf = FORM_DEFINITIONS["cultural_fit"] + assert [len(s["criteria"]) for s in ia["sections"]] == [5, 5] + assert [len(s["criteria"]) for s in cf["sections"]] == [5] + + def test_stage_gate_vocabulary(self): + assert set(FORM_READY_STATUSES) == {"INTERVIEW", "OFFER", "HIRED", "APPROVED"} + + def test_payload_is_json_shaped(self): + payload = definitions_payload() + assert set(payload["form_types"]) == {"requisition", "interview_analysis", "cultural_fit"} + assert payload["recommendation_labels"]["next_round"] == "Shortlist for next round" + assert payload["rating_labels"]["1"] == "Below Average (1)" diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 612dd93..df37875 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,8 +23,8 @@ - - + +
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 96a71f7..b8ab919 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -42,6 +42,9 @@ const SCREENS = { help: lazy(() => import('./screens/Help')), } +// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path). +const CandidatePage = lazy(() => import('./screens/CandidatePage')) + export default function App() { return (+ Interview records attach to an email application — this candidate was added + manually, so scheduling is unavailable here. +
+ )} > ) } @@ -1038,6 +1149,12 @@ function ActivityTab({ userId, inboxId, rows }) { >+ The activity log attaches to an email application — this candidate was added + manually, so logging is unavailable here. +
+ )} > ) } @@ -1266,6 +1383,12 @@ function FeedbackTab({ userId, inboxId, rows }) { >+ Scorecards attach to an email application — this candidate was added manually, + so submitting is unavailable here. +
+ )} > ) } diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 067afd3..d1aece4 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -141,14 +141,18 @@ export default function Candidates() { const openProfile = useCallback( (c) => { - setProfileFor(c) qc.setQueryData(qk.seed.recentlyViewed(), (old = []) => { const next = [c.id, ...old.filter((id) => id !== c.id)].slice(0, 12) persist('tf-recent', next) return next }) + // Real candidates get the full profile PAGE; the modal stays only as the + // fallback for rows without a user account. + const uid = c.userId || c.id + if (uid) navigate(`/candidate/${uid}`) + else setProfileFor(c) }, - [qc], + [qc, navigate], ) // Deep links from Talent Pool, global search, dashboard… @@ -156,11 +160,8 @@ export default function Candidates() { const st = location.state if (!st) return if (st.openAdd) setAdding(true) - if (st.openCandidate) { - const c = candidates.find((x) => x.id === st.openCandidate) - if (c) openProfile(c) - } - }, [location.state, candidates, openProfile]) + if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true }) + }, [location.state, navigate]) const rows = useMemo(() => { const f = filters diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx index 4df4188..4f9720c 100644 --- a/frontend/src/screens/Interviews.jsx +++ b/frontend/src/screens/Interviews.jsx @@ -26,7 +26,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' import { Tabs } from '../ui/Tabs' -import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard } from '../ui/primitives' +import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, Stars } from '../ui/primitives' import { useToast } from '../ui/Toast' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index d8b9a1c..d28b2c0 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -394,6 +394,7 @@ export default function Matching() {