/* The Forms tab of the candidate profile modal — the digitized paper annexures: Employee Requisition (Annexure A), Interview Analysis + Cultural Fit (the two halves of Annexure E), and the Offer (Annexure J fields on the offers table). Field and criterion labels are rendered from GET /forms/definitions — the backend is the single authority for the paper forms' exact wording. The interviewer fills a form; anyone with interviews.edit can amend it later (deliberately no author lock — HR corrects transcription mistakes). Availability is stage-gated to INTERVIEW / OFFER / HIRED (+ legacy APPROVED): the server rejects earlier stages with a 409, the gate here is just the friendly version. Forms attach to an application — the inbox row for email applicants, the manual_upload_candidate row for hand-added candidates. Layout system: .hf-* classes in styles.css. Rated criteria render as the paper's own table (scale header, radio-dot cells, the SECTION AVERAGE foot); the score summary is a stat-tile row with the combined overall as the hero. */ import { useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as formsApi from '../api/forms' import * as offersApi from '../api/offers' import { STAGE_FROM_STATUS } from '../api/pipeline' const WORK_LOCATIONS = ['Maymar Office', 'Head Office'] const WORK_TIMINGS = ['Morning', 'Afternoon', 'Evening', 'Night'] function titleCase(status) { const s = String(status || '') return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Shortlist' } function toDateInput(value) { if (!value) return '' const d = new Date(value) return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10) } /** Same shape as the profile's useProfileWrite, plus the forms/offers caches. */ function useFormsWrite({ userId, mutationFn, success, onDone }) { const qc = useQueryClient() const { toast } = useToast() return useMutation({ mutationFn, onSuccess: async () => { await qc.invalidateQueries({ queryKey: qk.forms.all() }) await qc.invalidateQueries({ queryKey: qk.offers.all() }) await qc.invalidateQueries({ queryKey: ['candidates', 'history', userId] }) toast(success, 'success') onDone?.() }, onError: (err) => toast(friendlyAuthError(err, 'Could not save the form. Please try again.'), 'error'), }) } export default function CandidateFormsTab({ userId, live }) { const { can } = useAuth() // Open on the process's first step; the switcher order IS the paper sequence. const [seg, setSeg] = useState('requisition') // Forms attach to an application: an inbox row for email applicants, or the // manual_upload_candidate row for hand-added / sourced candidates. Exactly // one of these keys is sent (backend XOR). const inboxId = live?.inbox_id ?? null const manualId = !inboxId ? (live?.manual_upload_candidate_id ?? null) : null const listParams = inboxId ? { inboxId } : { manualUploadCandidateId: manualId } const hasApplication = Boolean(inboxId || manualId) const stage = String(live?.application_status || '').toUpperCase() // 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(), queryFn: formsApi.definitions, enabled: hasApplication && unlocked, staleTime: Infinity, }) const formsQuery = useQuery({ queryKey: qk.forms.list(listParams), queryFn: () => formsApi.list(listParams), enabled: hasApplication && unlocked, }) // Hoisted above OfferSection so the switcher can show the offer's done-dot. const offersQuery = useQuery({ queryKey: qk.offers.list({ inboxId }), queryFn: () => offersApi.list({ inboxId }), enabled: Boolean(inboxId) && unlocked, }) if (!hasApplication) { return ( Hiring forms hang off an application record, and this candidate has none yet. ) } if (!unlocked) { return ( 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. ) } if (defsQuery.isPending || formsQuery.isPending) { return Fetching form definitions. } if (defsQuery.isError || formsQuery.isError) { return ( {friendlyAuthError(defsQuery.error || formsQuery.error, 'Please try again.')} ) } const defs = defsQuery.data?.data const rows = formsQuery.data?.data ?? [] const summary = formsQuery.data?.summary ?? null const offers = offersQuery.data?.data ?? [] // Spread into create payloads — exactly one key, matching the backend XOR. const link = inboxId ? { inbox_id: Number(inboxId) } : { manual_upload_candidate_id: manualId } const done = { requisition: rows.some((r) => r.form_type === 'requisition'), interview_analysis: rows.some((r) => r.form_type === 'interview_analysis'), cultural_fit: rows.some((r) => r.form_type === 'cultural_fit'), offer: offers.length > 0, } const segTabs = [ { key: 'requisition', label: 'Requisition' }, { key: 'interview_analysis', label: 'Interview Analysis' }, { key: 'cultural_fit', label: 'Cultural Fit' }, { key: 'offer', label: 'Offer' }, ] const evalCount = rows.filter( (r) => r.form_type === 'interview_analysis' || r.form_type === 'cultural_fit', ).length return ( <>
{segTabs.map((t) => ( ))}
{seg === 'requisition' && ( r.form_type === 'requisition')?.id ?? 'new'} def={defs.forms.requisition} defs={defs} rows={rows.filter((r) => r.form_type === 'requisition')} userId={userId} link={link} live={live} canCreate={can('interviews.create')} canEdit={can('interviews.edit')} /> )} {(seg === 'interview_analysis' || seg === 'cultural_fit') && ( r.form_type === seg)} userId={userId} link={link} live={live} canCreate={can('interviews.create')} canEdit={can('interviews.edit')} /> )} {seg === 'offer' && ( )} ) } /* ------------------------------------------------------------------ Annexure E's OVERALL SCORE SUMMARY — three section tiles plus the combined overall as the hero. Values are magnitudes on a fixed 1–4 scale, so each tile carries a thin single-hue meter; numbers stay in text ink. */ function ScoreTile({ label, value, hero, sub }) { const pct = value != null ? Math.max(0, Math.min(100, (value / 4) * 100)) : 0 return (
{label}
{value != null ? value : '—'} {value != null && / 4}
{sub &&
{sub}
}
) } function SummaryStrip({ summary, evalCount }) { if (!summary || !evalCount) return null return ( <>
Scores come from the {evalCount === 1 ? 'evaluation form' : `${evalCount} evaluation forms`} filed for this candidate — nothing is scored until an interviewer submits one.
) } /* ------------------------------------------------------------------ Shared bits */ function fieldLabel(def, key) { return def.fields.find((f) => f.key === key)?.label ?? key } function sectionAverage(ratings) { const values = Object.values(ratings).filter((v) => v != null) if (!values.length) return null return Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100 } function FormRowList({ rows, defs, onEdit, canEdit }) { if (!rows.length) return null return (
{rows.map((r) => (
{r.interviewer_name || r.created_by_name || 'Unknown'}
{toDateInput(r.form_date) || toDateInput(r.created_at)} {r.updated_at && r.updated_at !== r.created_at ? ' · revised' : ''}
{r.overall_score != null && {r.overall_score} / 4} {r.recommendation && ( {defs.recommendation_labels[r.recommendation] ?? r.recommendation} )} {canEdit && ( )}
))}
) } /* The paper's rating grid: scale header, one radio-dot per cell, average foot. */ function RatingTable({ section, defs, ratings, onRate }) { const average = sectionAverage(ratings) return (
Criteria
{[1, 2, 3, 4].map((n) => (
{defs.rating_labels[String(n)]} {n}
))}
{section.criteria.map((c) => (
{c.label}
{[1, 2, 3, 4].map((n) => (
))}
))}
{section.average_label || 'Section average'}
{average ?? '—'}
) } /* ------------------------------------------------------------------ Interview Analysis / Cultural Fit — data-driven off the definition's sections; both types share this component. */ function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, canCreate, canEdit }) { const { user } = useAuth() const [editing, setEditing] = useState(null) // null = closed, 'new' = create, else a row const blank = useMemo(() => { const fields = {} for (const f of def.fields) fields[f.key] = '' fields.interviewer_name = user?.name || '' fields.position_title = live?.job_title || '' const ratings = {} for (const s of def.sections) { ratings[s.key] = {} for (const c of s.criteria) ratings[s.key][c.key] = null } return { fields, ratings, recommendation: '', date: toDateInput(new Date().toISOString()) } }, [def, user, live]) const initial = useMemo(() => { if (!editing || editing === 'new') return blank const fields = { ...blank.fields } for (const key of Object.keys(fields)) { if (editing.fields?.[key] != null) fields[key] = String(editing.fields[key]) } const ratings = {} for (const s of def.sections) { ratings[s.key] = { ...blank.ratings[s.key] } } for (const s of editing.sections ?? []) { for (const c of s.criteria ?? []) { if (ratings[s.key] && c.key in ratings[s.key]) ratings[s.key][c.key] = c.rating ?? null } } return { fields, ratings, recommendation: editing.recommendation || '', date: toDateInput(editing.form_date) || blank.date, } }, [editing, blank, def]) return ( <> {rows.length ? ( setEditing(r)} /> ) : ( !editing && ( Fill it during or right after the interview — it replaces the paper form. ) )} {editing ? ( setEditing(null)} /> ) : ( canCreate && ( // Centered and full-size under the empty state so the CTA reads as // part of it; compact and left-aligned once a list sits above it.
) )} ) } function EvaluationEditor({ formType, def, defs, row, initial, userId, link, live, onClose }) { const [fields, setFields] = useState(initial.fields) const [ratings, setRatings] = useState(initial.ratings) const [recommendation, setRecommendation] = useState(initial.recommendation) const [date, setDate] = useState(initial.date) const setField = (k, v) => setFields((f) => ({ ...f, [k]: v })) const setRating = (sectionKey, critKey, value) => setRatings((r) => ({ ...r, [sectionKey]: { ...r[sectionKey], [critKey]: r[sectionKey][critKey] === value ? null : value }, })) const save = useFormsWrite({ userId, mutationFn: () => { const body = { form_date: date ? new Date(`${date}T00:00`).toISOString() : null, sections: def.sections.map((s) => ({ key: s.key, criteria: s.criteria.map((c) => ({ key: c.key, rating: ratings[s.key][c.key] })), })), fields, recommendation: recommendation || null, } if (row) return formsApi.update(row.id, body) return formsApi.create({ form_type: formType, ...link, ...body }) }, success: row ? `${def.title} form updated` : `${def.title} form saved`, onDone: onClose, }) const sectionNoteKey = { technical: 'technical_note', behavioral: 'behavioral_note', cultural: 'cultural_note' } return (
{ e.preventDefault(); save.mutate() }}>
Interview Details Summary
setField('interviewer_name', e.target.value)} />
setField('department', e.target.value)} />
setField('position_title', e.target.value)} />
setDate(e.target.value)} />
{'summary' in fields && (