Candidate hiring forms (Annexure A/E/J), full-page profile, UX audit fixes
Deploy to S3 / deploy (push) Successful in 37s Details

Backend: new candidate_forms domain (requisition, interview analysis,
cultural fit) with XOR inbox/manual keys, server-recomputed section
averages and combined summary, INTERVIEW-stage gate (409), history
events, INTERVIEWS_* permissions + 008 RBAC seed; offers table gains
the seven Annexure-J fields.

Frontend: Forms tab in the candidate profile (paper-exact labels from
/forms/definitions, rating tables, score summary tiles, completion
dots); profile converted to a full page at /candidate/:userId opened
from Candidates, Talent Pool and Pipeline; live Advance Stage now calls
PATCH /candidate/stage; workflow-ordered tabs; responsive pass verified
by headless-Edge screenshots at 375-2400px; Stars import crash fix in
Interviews; Matching tab strip wraps on phones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/24/head
Talha Ahmed 2026-08-24 20:14:36 +05:00
parent 312c574896
commit f9eb9f1f24
29 changed files with 2665 additions and 55 deletions

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,8 +23,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-DoHV5rbL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BoTJFWhm.css">
<script type="module" crossorigin src="/assets/index-CsixWp5R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--H0MdQBQ.css">
</head>
<body>
<div id="root"></div>

View File

@ -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 (
<BrowserRouter>
@ -82,6 +85,14 @@ export default function App() {
/>
)
})}
<Route
path="/candidate/:userId"
element={
<RequireAuth permission="candidates.view">
<CandidatePage />
</RequireAuth>
}
/>
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />

62
frontend/src/api/forms.js Normal file
View File

@ -0,0 +1,62 @@
import { request } from '../lib/apiClient'
/* ============================================================
forms.js backend/candidate_forms/app.py.
The digitized hiring forms: Annexure A (Employee Requisition), and the two
halves of Annexure E Interview Analysis (technical + behavioral) and
Cultural Fit. Dual-key like assessments: exactly one of inbox_id /
manual_upload_candidate_id.
Permissioned with the interviews module tags (interviews.view to read,
interviews.create to fill, interviews.edit to amend). Creating is
stage-gated SERVER-side: the application must be at INTERVIEW / OFFER /
HIRED (or legacy APPROVED), else 409 the UI hint mirrors, never replaces,
that rule.
Field and criterion labels come from GET /forms/definitions, which is the
single authority for the paper forms' exact wording do not hardcode
labels in components.
============================================================ */
export const FORM_TYPES = ['requisition', 'interview_analysis', 'cultural_fit']
/** Mirror of backend candidate_forms/plugins.py FORM_READY_STATUSES. */
export const FORM_READY_STATUSES = ['INTERVIEW', 'OFFER', 'HIRED', 'APPROVED']
export function definitions() {
return request('/forms/definitions')
}
export function list({ formId, inboxId, manualUploadCandidateId, jobPostId, formType, top, skip } = {}) {
return request('/forms/fetch', {
params: {
form_id: formId,
inbox_id: inboxId,
manual_upload_candidate_id: manualUploadCandidateId,
job_post_id: jobPostId,
form_type: formType,
top,
skip,
},
})
}
export function create(body) {
return request('/forms/create', { method: 'POST', body })
}
export function update(formId, body) {
return request('/forms/update', {
method: 'PATCH',
params: { form_id: formId },
body,
})
}
export function remove(formId) {
return request('/forms/delete', {
method: 'DELETE',
params: { form_id: formId },
})
}

View File

@ -124,6 +124,14 @@ export function toOfferView(row, { people, jobTitles } = {}) {
equity: equityLabel(row.equity_units, row.equity_instrument),
equityUnits: row.equity_units ?? null,
equityInstrument: row.equity_instrument || null,
// Annexure J (offer email format) fields.
cadre: row.cadre || null,
grossSalaryInWords: row.gross_salary_in_words || null,
subsidizedServices: row.subsidized_services || null,
probationPeriod: row.probation_period || null,
noticePeriod: row.notice_period || null,
workLocation: row.work_location || null,
workTimings: row.work_timings || 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,

View File

@ -98,6 +98,11 @@ export const qk = {
recruiters: (p = {}) => ['analytics', 'recruiters', p],
},
offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] },
forms: {
all: () => ['forms'],
list: (p = {}) => ['forms', 'list', p],
definitions: () => ['forms', 'definitions'],
},
interviews: {
all: () => ['interviews'],
range: (p = {}) => ['interviews', 'range', p],

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,24 @@
/* Full-page candidate profile /candidate/:userId.
The modal outgrew its box: ten tabs of forms, rating tables and audit trail
need a real page with a real URL (shareable, refresh-safe). This is a thin
shell over CandidateProfile in `variant="page"` mode: the identity shell
carries only the userId and the live detail query fills everything else.
Opened from Candidates, Talent Pool and the Pipeline board. */
import { useNavigate, useParams } from 'react-router-dom'
import CandidateProfile from './CandidateProfile'
export default function CandidatePage() {
const { userId } = useParams()
const navigate = useNavigate()
return (
<CandidateProfile
variant="page"
candidate={{ id: userId, userId, name: '' }}
onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))}
/>
)
}

View File

@ -33,9 +33,16 @@ import { seedQuery } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as formsApi from '../api/forms'
import * as pipelineApi from '../api/pipeline'
import CandidateFormsTab from './CandidateForms'
import { companies, fmtDate, moneyK, pick } from '../data/seed'
const TABS = ['Overview', 'Resume', 'Timeline', 'History', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
/* Workflow order: learn (Overview, Resume, Documents) interview (Interview,
Forms, Feedback) track (Notes, Activity) audit (Timeline, History). */
const TABS = ['Overview', 'Resume', 'Documents', 'Interview', 'Forms', 'Feedback', 'Notes', 'Activity', 'Timeline', 'History']
// Forward progression for the live Advance button. Rejected has no next stage.
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
@ -114,8 +121,10 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
*/
export default function CandidateProfile({
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
variant = 'modal',
}) {
const { toast } = useToast()
const { can } = useAuth()
const [tab, setTab] = useState('Overview')
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
@ -135,6 +144,17 @@ export default function CandidateProfile({
// which is the one the header is describing.
const inboxId = live?.inbox_id ?? null
// Same key as the Forms tab's own query, so the tab count and the tab body
// share one fetch. Fetching is not stage-gated (only creating is). Manual
// candidates key by their manual_upload_candidate row instead of inbox.
const manualFormsId = !inboxId ? (live?.manual_upload_candidate_id ?? null) : null
const formsParams = inboxId ? { inboxId } : { manualUploadCandidateId: manualFormsId }
const formsQuery = useQuery({
queryKey: qk.forms.list(formsParams),
queryFn: () => formsApi.list(formsParams),
enabled: isLive && Boolean(inboxId || manualFormsId) && can('interviews.view'),
})
const favorite = live ? Boolean(live.favorite) : c.favorite
const setFavorite = useProfileWrite({
userId: c.userId,
@ -164,8 +184,45 @@ export default function CandidateProfile({
const title = live?.job_title || c.currentTitle
const company = live?.currentCompany || c.currentCompany
// Live stage comes from the application record, not from whatever card
// opened the modal c.stage goes stale the moment the stage moves.
const rawStatus = String(live?.application_status || '').toUpperCase()
const stageLabel = isLive
? (pipelineApi.STAGE_FROM_STATUS[rawStatus] ?? 'Shortlist')
: c.stage
const stageIdx = KANBAN_ORDER.indexOf(stageLabel)
const nextStage = stageIdx >= 0 && stageIdx < KANBAN_ORDER.length - 1
? KANBAN_ORDER[stageIdx + 1]
: null
// The REAL stage move (PATCH /candidate/stage) the seed-only onAdvance walk
// is kept for seed candidates only. Requires pipeline.edit server-side.
const qc = useQueryClient()
const advanceLive = useProfileWrite({
userId: c.userId,
mutationFn: () => pipelineApi.changeStage({
inboxId: live?.inbox_id ?? undefined,
manualUploadId: live?.inbox_id ? undefined : (live?.manual_upload_candidate_id ?? undefined),
toStage: pipelineApi.STATUS_FROM_STAGE[nextStage],
changeReason: 'advanced from candidate profile',
}),
success: () => `Moved to ${nextStage}`,
onDone: () => {
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
qc.invalidateQueries({ queryKey: qk.forms.all() })
},
})
// The hero experience chip: live experience is free text ("6 years"), seed is
// a number. Render nothing rather than a bare "yrs exp".
const expRaw = live?.experience ?? c.experience
const expChip = expRaw == null || expRaw === ''
? null
: Number.isFinite(Number(expRaw)) ? `${expRaw} yrs exp` : String(expRaw)
const counts = live && {
Interview: live.interviews?.length ?? 0,
Forms: formsQuery.data?.total ?? 0,
Notes: live.notes?.length ?? 0,
Activity: live.activity?.length ?? 0,
Documents: live.documents?.length ?? 0,
@ -186,13 +243,7 @@ export default function CandidateProfile({
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
) : null
return (
<Modal
title="Candidate Profile"
subtitle={c.id}
size="modal-lg"
onClose={onClose}
footer={
const actions = (
<>
<button
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
@ -211,23 +262,43 @@ export default function CandidateProfile({
<Icon name="sparkles" /> {scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}
</button>
)}
{onAtsMatch && (
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
</button>
)}
{isLive ? (
<button
className="btn btn-primary"
disabled={!live || !nextStage || advanceLive.isPending || !can('pipeline.edit')}
data-tip={!can('pipeline.edit') ? 'Needs pipeline.edit' : undefined}
onClick={() => advanceLive.mutate()}
>
<Icon name="check" />{' '}
{advanceLive.isPending
? 'Moving…'
: nextStage ? `Advance to ${nextStage}`
: stageLabel === 'Rejected' ? 'Rejected' : 'Pipeline complete'}
</button>
) : (
<button className="btn btn-primary" onClick={() => { onAdvance(c); onClose() }}>
<Icon name="check" /> Advance Stage
</button>
)}
</>
}
>
)
const body = (
<>
<div className="profile-hero">
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<Avatar name={live?.name || c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{live?.name || c.name}</div>
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
<div className="ph-tags">
<Badge>{c.stage}</Badge> <Badge className="b-gray">{live?.source || c.source}</Badge>
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
{stageLabel && <Badge>{stageLabel}</Badge>}{' '}
{(live?.source || c.source) && <Badge className="b-gray">{live?.source || c.source}</Badge>}
{expChip && <span className="badge b-plain b-indigo badge-plain">{expChip}</span>}
</div>
</div>
{/* No score anywhere -> the whole block goes, rather than a ring drawn
@ -247,6 +318,7 @@ export default function CandidateProfile({
<Tabs
value={tab}
onChange={setTab}
className="tabs tabs-wrap"
tabs={TABS.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
/>
</div>
@ -273,7 +345,7 @@ export default function CandidateProfile({
disabled={setRating.isPending}
onChange={(n) => setRating.mutate(n)}
/>
<span className="cell-sub">{rating.toFixed(1)} / 5.0</span>
<span className="cell-sub">{rating ? `${rating.toFixed(1)} / 5.0` : 'Not rated'}</span>
</div>
</div>
<Info label="Applications" val={live.job_posts?.length || 0} />
@ -420,6 +492,14 @@ export default function CandidateProfile({
)
)))}
{tab === 'Forms' && (guard || (live ? (
<CandidateFormsTab userId={c.userId} live={live} />
) : (
<EmptyState icon="file" title="Live candidates only">
Hiring forms attach to real applications.
</EmptyState>
)))}
{tab === 'Notes' && (guard || (live ? (
<NotesTab userId={c.userId} rows={live.notes ?? []} />
) : (
@ -531,6 +611,31 @@ export default function CandidateProfile({
</>
)))}
</div>
</>
)
if (variant === 'page') {
return (
<div className="cand-page">
<div className="cand-page-bar">
<button className="btn btn-secondary btn-sm" onClick={onClose}>
<Icon name="chevron-left" /> Back
</button>
<div className="cand-page-crumb">
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
</div>
<div className="cand-page-actions">{actions}</div>
</div>
<div className="card">
<div className="card-body">{body}</div>
</div>
</div>
)
}
return (
<Modal title="Candidate Profile" subtitle={c.id} size="modal-lg" onClose={onClose} footer={actions}>
{body}
</Modal>
)
}
@ -847,6 +952,12 @@ function InterviewTab({ userId, inboxId, rows }) {
>
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
</button>
{!inboxId && (
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
Interview records attach to an email application this candidate was added
manually, so scheduling is unavailable here.
</p>
)}
</>
)
}
@ -1038,6 +1149,12 @@ function ActivityTab({ userId, inboxId, rows }) {
>
<Icon name="plus" /> {create.isPending ? 'Logging…' : 'Log Activity'}
</button>
{!inboxId && (
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
The activity log attaches to an email application this candidate was added
manually, so logging is unavailable here.
</p>
)}
</>
)
}
@ -1266,6 +1383,12 @@ function FeedbackTab({ userId, inboxId, rows }) {
>
<Icon name="plus" /> {create.isPending ? 'Submitting…' : 'Submit Scorecard'}
</button>
{!inboxId && (
<p className="text-muted" style={{ marginTop: 8, fontSize: 12.5 }}>
Scorecards attach to an email application this candidate was added manually,
so submitting is unavailable here.
</p>
)}
</>
)
}

View File

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

View File

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

View File

@ -394,6 +394,7 @@ export default function Matching() {
<Tabs
value={tab}
onChange={(t) => { setTab(t); setSelectedId(null) }}
className="tabs tabs-wrap"
tabs={TABS.map((t) => ({
key: t.key,
label: t.label,

View File

@ -241,10 +241,10 @@ export default function Pipeline() {
onClick={() => {
// Don't open the profile on the click that ends a drag.
if (draggingId) return
// The Candidates screen keys its rows by users.id, so an
// application with no linked account cannot deep-link.
// The profile page keys off users.id, so an application
// with no linked account cannot deep-link.
if (!c.userId) return
navigate('/candidates', { state: { openCandidate: c.userId } })
navigate(`/candidate/${c.userId}`)
}}
>
<div className="k-card-top">

View File

@ -31,6 +31,7 @@
============================================================ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
@ -124,6 +125,14 @@ export default function TalentPool() {
const [dept, setDept] = useState('')
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
const navigate = useNavigate()
// Real candidates get the full profile PAGE; the in-place modal remains only
// for seed cards that have no user account to deep-link.
const openProfile = (c) => {
if (c.userId) navigate(`/candidate/${c.userId}`)
else setProfileFor(c)
}
const query = useQuery({
queryKey: qk.candidates.list({ limit: FETCH_LIMIT }),
@ -238,7 +247,7 @@ export default function TalentPool() {
key={c.id}
className="card"
style={{ cursor: 'pointer' }}
onClick={() => setProfileFor(c)}
onClick={() => openProfile(c)}
>
<div className="card-body">
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
@ -268,7 +277,7 @@ export default function TalentPool() {
<AtsMatch
candidate={atsFor}
onClose={() => setAtsFor(null)}
onProfile={(c) => { setAtsFor(null); setProfileFor(c) }}
onProfile={(c) => { setAtsFor(null); openProfile(c) }}
/>
)}

View File

@ -1406,3 +1406,98 @@ canvas { width: 100%; max-width: 100%; display: block; }
@media (max-width: 640px) {
.g-kpi-7 { grid-template-columns: 1fr; }
}
/* ============================================================
Hiring forms the candidate profile Forms tab (CandidateForms.jsx).
Digitized paper annexures: requisition, interview analysis, cultural
fit, offer. Namespaced .hf-* ; consumes only global tokens so both
themes come for free. */
/* Score summary: stat tiles, hero = combined overall */
.hf-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; }
.hf-tile { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; min-width: 0; }
.hf-tile .hf-k { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hf-tile .hf-v { font-size: 20px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; }
.hf-tile .hf-v small { font-size: 12px; font-weight: 600; color: var(--text-3); margin-left: 2px; }
.hf-tile .hf-sub { font-size: 11.5px; color: var(--text-3); margin-top: 4px; }
.hf-tile.hero { background: var(--primary-soft); border-color: var(--border-strong); }
.hf-meter { height: 4px; border-radius: 2px; background: var(--border); margin-top: 8px; overflow: hidden; }
.hf-meter i { display: block; height: 100%; border-radius: 2px; background: var(--primary); }
/* Bordered section card, titled like the paper form's section headers */
.hf-block { border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-top: 14px; }
.hf-block-title { font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--text-3); margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
.hf-block-title label { display: flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; color: var(--text-2); }
.hf-note { font-size: 12.5px; color: var(--text-3); margin: 2px 0 10px; }
/* Rating table: the paper grid — scale header, radio-dot cells, average foot */
.hf-rate { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.hf-rate-head, .hf-rate-row, .hf-rate-foot { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 96px); align-items: center; }
.hf-rate-head { background: var(--bg-sunken); }
.hf-rate-head > div { padding: 8px 10px; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--text-3); text-align: center; line-height: 1.25; }
.hf-rate-head > div:first-child { text-align: left; }
.hf-rate-row { border-top: 1px solid var(--border); }
.hf-rate-row > div:first-child { padding: 9px 10px; font-size: 13px; }
.hf-rate-cell { display: flex; justify-content: center; }
.hf-dot { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--border-strong); background: var(--bg-elev); cursor: pointer; transition: border-color .12s, background .12s, box-shadow .12s; padding: 0; }
.hf-dot:hover { border-color: var(--primary); }
.hf-dot.on { background: var(--primary); border-color: var(--primary); box-shadow: inset 0 0 0 3.5px var(--bg-elev); }
.hf-rate-foot { border-top: 1px solid var(--border); background: var(--bg-sunken); }
.hf-rate-foot > div:first-child { padding: 8px 10px; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .3px; color: var(--text-3); }
.hf-rate-foot .hf-avg { grid-column: 2 / -1; text-align: center; font-size: 13.5px; font-weight: 700; font-variant-numeric: tabular-nums; padding: 8px 0; }
/* Completion dot on the form switcher */
.hf-done { width: 6px; height: 6px; border-radius: 50%; background: var(--success); display: inline-block; margin-left: 7px; vertical-align: middle; }
/* Approvals: four signature slots */
.hf-sign-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.hf-sign { display: flex; flex-direction: column; gap: 6px; }
.hf-sign .hf-sign-role { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px; color: var(--text-3); }
@media (max-width: 860px) {
/* Rating columns keep their full 96px width here the word headers still
fit. The shrink + numeric headers happen together at 640px. */
.hf-summary { grid-template-columns: repeat(2, 1fr); }
.hf-sign-grid { grid-template-columns: repeat(2, 1fr); }
}
/* Modal tab strips: wrap instead of clipping behind a horizontal scrollbar
ten tabs do not fit the 860px profile modal. */
.tabs-wrap { flex-wrap: wrap; overflow-x: visible; }
/* Full-page candidate profile (/candidate/:userId).
The page centers itself with a generous cap so ultrawide monitors don't get
a mile-wide form, and everything below the cap is fluid no fixed widths. */
.cand-page { max-width: 1440px; margin: 0 auto; }
.cand-page-bar { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; }
.cand-page-crumb { font-size: 13.5px; color: var(--text-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cand-page-crumb span { margin: 0 2px; }
.cand-page-crumb strong { color: var(--text); font-weight: 600; }
.cand-page-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cand-page-actions .star-btn { margin-right: 0 !important; }
/* The four-form switcher must wrap rather than overflow on narrow screens. */
.cand-page .seg { flex-wrap: wrap; }
@media (max-width: 640px) {
.cand-page-actions { width: 100%; }
.cand-page-actions .btn { flex: 1 1 auto; justify-content: center; }
.hf-sign-grid { grid-template-columns: 1fr; }
.hf-summary { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 400px) {
.hf-summary { grid-template-columns: 1fr; }
}
/* Rating-table scale header: full words down to 640px, bare numbers below. */
.hf-scale-short { display: none; }
@media (max-width: 640px) {
.hf-scale-full { display: none; }
.hf-scale-short { display: inline; font-size: 12px; }
.hf-rate-head, .hf-rate-row, .hf-rate-foot { grid-template-columns: minmax(0, 1fr) repeat(4, 44px); }
}
/* Empty-state CTA on the hiring forms: full-size, and full-width on phones. */
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
@media (max-width: 640px) {
.hf-cta { width: 100%; max-width: 420px; justify-content: center; }
}