diff --git a/.gitignore b/.gitignore index 8720553..274ae0d 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,10 @@ Utopia-ai-hr-ats-portal 1.pem # Local-only Compose overrides (never deployed) docker.local.env tests/** +**/.env** + +# 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/ +frontend/dist/** */ +docker.local.frontend/dist/** */ \ No newline at end of file diff --git a/backend/candidate_forms/views.py b/backend/candidate_forms/views.py index 447a797..fffdd92 100644 --- a/backend/candidate_forms/views.py +++ b/backend/candidate_forms/views.py @@ -18,7 +18,7 @@ from candidate_forms.plugins import ( ) from candidate_forms.serializers import serialize_form from inbox.models import Inbox -from job.candidate.models import Manual_UPLOAD_CANDIDATE +from job.candidate.models import Interviews, Manual_UPLOAD_CANDIDATE from job.history.enums import HistoryEvent from job.history.views import HistoryRecorder from job.job_post.models import JobPosts @@ -263,13 +263,23 @@ class CandidateForm: ) 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'}" - ), - ) + # A scheduled interview also unlocks the forms: the paperwork belongs + # to the interview, not to which kanban column the card sits in. + # (Interview records link only to inbox rows, so manual-upload + # candidates unlock by stage alone.) + has_interview = False + if inbox_id is not None: + rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id) + has_interview = bool(rows) + if not has_interview: + raise HTTPException( + status_code=409, + detail=( + "Forms unlock once the candidate reaches the Interview stage " + f"or has an interview scheduled — this candidate is at " + f"{stage or 'Shortlist'} with no interview on record" + ), + ) fields = { "inbox_id": inbox_id, diff --git a/frontend/src/screens/CandidateForms.jsx b/frontend/src/screens/CandidateForms.jsx index 391cd71..4547a28 100644 --- a/frontend/src/screens/CandidateForms.jsx +++ b/frontend/src/screens/CandidateForms.jsx @@ -72,7 +72,10 @@ export default function CandidateFormsTab({ userId, live }) { const listParams = inboxId ? { inboxId } : { manualUploadCandidateId: manualId } const hasApplication = Boolean(inboxId || manualId) const stage = String(live?.application_status || '').toUpperCase() - const unlocked = formsApi.FORM_READY_STATUSES.includes(stage) + // Stage-ready OR an interview on the books — scheduling an interview is what + // makes the paperwork relevant, wherever the kanban card sits. + const hasInterview = (live?.interviews?.length ?? 0) > 0 + const unlocked = formsApi.FORM_READY_STATUSES.includes(stage) || hasInterview const defsQuery = useQuery({ queryKey: qk.forms.definitions(), @@ -102,8 +105,9 @@ export default function CandidateFormsTab({ userId, live }) { if (!unlocked) { return ( - This candidate is at {STAGE_FROM_STATUS[stage] ?? titleCase(stage)}. Move them along - the pipeline to fill the requisition, evaluation and offer forms. + This candidate is at {STAGE_FROM_STATUS[stage] ?? titleCase(stage)} with no interview + on record. Schedule an interview on the Interview tab, or move them along the + pipeline, to fill the requisition, evaluation and offer forms. ) }