/* 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 (
)
}
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 (
)
}
/* 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 (
)
}
/* ------------------------------------------------------------------
Annexure A — Employee Requisition. One form per application (the latest row
is loaded for amendment); the approval chain is typed name + date, not a
workflow engine. */
const SIGN_SLOTS = [
{ nameKey: 'initiated_by', dateKey: 'initiated_date', role: 'Initiated By' },
{ nameKey: 'recommended_by', dateKey: 'recommended_date', role: 'Recommended By · Director' },
{ nameKey: 'approved_by', dateKey: 'approved_date', role: 'Approved By · Director HR' },
{ nameKey: 'vp_approved_by', dateKey: 'vp_approved_date', role: 'Approved By · VP/SVP' },
]
function RequisitionForm({ def, defs, rows, userId, link, live, canCreate, canEdit }) {
const row = rows[0] ?? null
const allowed = row ? canEdit : canCreate
const initial = useMemo(() => {
const fields = {}
for (const f of def.fields) {
const saved = row?.fields?.[f.key]
if (f.kind === 'bool') fields[f.key] = saved === true ? 'yes' : saved === false ? 'no' : ''
else fields[f.key] = saved != null ? String(saved) : ''
}
if (!fields.job_title) fields.job_title = row ? '' : live?.job_title || ''
return { fields, date: toDateInput(row?.form_date) || toDateInput(new Date().toISOString()) }
}, [def, row, live])
const [fields, setFields] = useState(initial.fields)
const [date, setDate] = useState(initial.date)
const [errors, setErrors] = useState({})
const set = (k, v) => setFields((f) => ({ ...f, [k]: v }))
const save = useFormsWrite({
userId,
mutationFn: () => {
const payload = {}
for (const f of def.fields) {
const value = fields[f.key]
if (f.kind === 'bool') payload[f.key] = value === '' ? null : value === 'yes'
else payload[f.key] = value === '' ? null : value
}
const body = {
form_date: date ? new Date(`${date}T00:00`).toISOString() : null,
fields: payload,
}
if (row) return formsApi.update(row.id, body)
return formsApi.create({ form_type: 'requisition', ...link, ...body })
},
success: row ? 'Requisition form updated' : 'Requisition form saved',
})
function submit() {
const next = {}
if (!fields.job_title.trim()) next.job_title = 'Enter the job title'
if (fields.jd_available === 'no') {
next.jd_available = 'JD is mandatory — the TA team will not proceed without it'
}
setErrors(next)
if (Object.keys(next).length) return
save.mutate()
}
const notPermanent = fields.employment_type && fields.employment_type !== 'permanent'
const label = (key) => fieldLabel(def, key)
return (
)
}
/* ------------------------------------------------------------------
Annexure J — the offer email's remuneration table, written onto the existing
offers record (drafted here, ISSUED from the Offers screen). */
function OfferSection({ userId, inboxId, live, offersQuery }) {
const { can } = useAuth()
// The offers table hard-requires an inbox row (offers.inbox_id NOT NULL), so
// manually added candidates cannot carry an offer record yet.
if (!inboxId) {
return (
This candidate was added manually — the offers ledger links to an email
application, so draft their offer letter outside the app for now.
)
}
if (offersQuery.isPending) {
return Fetching offer records.
}
if (offersQuery.isError) {
return (
{friendlyAuthError(offersQuery.error, 'Please try again.')}
)
}
const offers = offersQuery.data?.data ?? []
const offer = offers[0] ?? null
const allowed = offer ? can('offers.edit') : can('offers.create')
if (!offer && !live?.assigned_job_post_id) {
return (
Creating an offer needs an assigned job post — assign one from the Inbox or Pipeline first.
)
}
return (
)
}
function OfferEditor({ offer, userId, inboxId, live, allowed }) {
const [form, setForm] = useState({
cadre: offer?.cadre || '',
base: offer?.base_salary != null && offer.base_salary !== 0 ? String(offer.base_salary) : '',
grossInWords: offer?.gross_salary_in_words || '',
subsidized: offer?.subsidized_services || 'Pick n Drop',
probation: offer?.probation_period || 'Three months from the date of joining',
notice: offer?.notice_period || 'One month from the date of resigning',
location: offer?.work_location || '',
timings: offer?.work_timings || '',
startDate: toDateInput(offer?.start_date),
expiryDate: toDateInput(offer?.expiry_date),
})
const [errors, setErrors] = useState({})
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const save = useFormsWrite({
userId,
mutationFn: () => {
const body = {
base_salary: form.base === '' ? null : Number(form.base),
currency: 'PKR',
salary_period: 'month',
cadre: form.cadre || null,
gross_salary_in_words: form.grossInWords || null,
subsidized_services: form.subsidized || null,
probation_period: form.probation || null,
notice_period: form.notice || null,
work_location: form.location || null,
work_timings: form.timings || null,
start_date: form.startDate ? new Date(`${form.startDate}T00:00`).toISOString() : null,
expiry_date: form.expiryDate ? new Date(`${form.expiryDate}T00:00`).toISOString() : null,
}
if (offer) return offersApi.update(offer.id, body)
return offersApi.create({
inbox_id: Number(inboxId),
job_post_id: live.assigned_job_post_id,
candidate_user_id: userId,
status: 'draft',
...body,
})
},
success: offer ? 'Offer updated' : 'Offer draft created',
})
function submit() {
const next = {}
const base = form.base === '' ? null : Number(form.base)
if (base == null || !Number.isFinite(base) || base <= 0) next.base = 'Enter the monthly gross salary'
setErrors(next)
if (Object.keys(next).length) return
save.mutate()
}
return (
)
}