HR-ATS-Portal/frontend/src/screens/CandidateForms.jsx

1034 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* 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 (
<EmptyState icon="file" title="No application to attach forms to">
Hiring forms hang off an application record, and this candidate has none yet.
</EmptyState>
)
}
if (!unlocked) {
return (
<EmptyState icon="lock" title="Forms unlock at the Interview stage">
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.
</EmptyState>
)
}
if (defsQuery.isPending || formsQuery.isPending) {
return <EmptyState icon="refresh" title="Loading forms…">Fetching form definitions.</EmptyState>
}
if (defsQuery.isError || formsQuery.isError) {
return (
<EmptyState icon="alert" title="Could not load the forms">
{friendlyAuthError(defsQuery.error || formsQuery.error, 'Please try again.')}
</EmptyState>
)
}
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 (
<>
<SummaryStrip summary={summary} evalCount={evalCount} />
<div className="seg" style={{ marginBottom: 4 }}>
{segTabs.map((t) => (
<button
type="button"
key={t.key}
className={t.key === seg ? 'active' : ''}
onClick={() => setSeg(t.key)}
>
{t.label}
{done[t.key] && <span className="hf-done" title="Filed" />}
</button>
))}
</div>
{seg === 'requisition' && (
<RequisitionForm
// Remount when the saved row appears/changes so the editor flips from
// create to amend mode (state is seeded on mount only).
key={rows.find((r) => 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') && (
<RatedEvaluationForm
key={seg}
formType={seg}
def={defs.forms[seg]}
defs={defs}
rows={rows.filter((r) => r.form_type === seg)}
userId={userId}
link={link}
live={live}
canCreate={can('interviews.create')}
canEdit={can('interviews.edit')}
/>
)}
{seg === 'offer' && (
<OfferSection userId={userId} inboxId={inboxId} live={live} offersQuery={offersQuery} />
)}
</>
)
}
/* ------------------------------------------------------------------
Annexure E's OVERALL SCORE SUMMARY — three section tiles plus the combined
overall as the hero. Values are magnitudes on a fixed 14 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 (
<div className={`hf-tile${hero ? ' hero' : ''}`}>
<div className="hf-k" title={label}>{label}</div>
<div className="hf-v">
{value != null ? value : '—'}
{value != null && <small>/ 4</small>}
</div>
<div className="hf-meter"><i style={{ width: `${pct}%` }} /></div>
{sub && <div className="hf-sub">{sub}</div>}
</div>
)
}
function SummaryStrip({ summary, evalCount }) {
if (!summary || !evalCount) return null
return (
<>
<div className="hf-summary" style={{ marginBottom: 6 }}>
<ScoreTile label="Technical" value={summary.technical_avg} />
<ScoreTile label="Behavioral" value={summary.behavioral_avg} />
<ScoreTile label="Cultural Fit" value={summary.cultural_avg} />
<ScoreTile
label="Combined Overall"
value={summary.combined_overall}
hero
sub={summary.combined_overall == null ? 'Complete both evaluations' : null}
/>
</div>
<div className="hf-note" style={{ marginBottom: 14 }}>
Scores come from the {evalCount === 1 ? 'evaluation form' : `${evalCount} evaluation forms`} filed
for this candidate nothing is scored until an interviewer submits one.
</div>
</>
)
}
/* ------------------------------------------------------------------
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 (
<div className="list-tight" style={{ marginTop: 14 }}>
{rows.map((r) => (
<div className="list-row" key={r.id}>
<div className="lr-main">
<div className="lr-title">{r.interviewer_name || r.created_by_name || 'Unknown'}</div>
<div className="lr-sub">
{toDateInput(r.form_date) || toDateInput(r.created_at)}
{r.updated_at && r.updated_at !== r.created_at ? ' · revised' : ''}
</div>
</div>
<div className="lr-right" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{r.overall_score != null && <Badge>{r.overall_score} / 4</Badge>}
{r.recommendation && (
<Badge className="b-gray">
{defs.recommendation_labels[r.recommendation] ?? r.recommendation}
</Badge>
)}
{canEdit && (
<button className="act-btn" data-tip="Amend this form" aria-label="Amend this form" onClick={() => onEdit(r)}>
<Icon name="edit" />
</button>
)}
</div>
</div>
))}
</div>
)
}
/* 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 (
<div className="hf-rate">
<div className="hf-rate-head">
<div>Criteria</div>
{[1, 2, 3, 4].map((n) => (
<div key={n}>
<span className="hf-scale-full">{defs.rating_labels[String(n)]}</span>
<span className="hf-scale-short" title={defs.rating_labels[String(n)]}>{n}</span>
</div>
))}
</div>
{section.criteria.map((c) => (
<div className="hf-rate-row" key={c.key}>
<div>{c.label}</div>
{[1, 2, 3, 4].map((n) => (
<div className="hf-rate-cell" key={n}>
<button
type="button"
className={`hf-dot${ratings[c.key] === n ? ' on' : ''}`}
aria-label={`${c.label}: ${defs.rating_labels[String(n)]}`}
onClick={() => onRate(c.key, n)}
/>
</div>
))}
</div>
))}
<div className="hf-rate-foot">
<div>{section.average_label || 'Section average'}</div>
<div className="hf-avg">{average ?? '—'}</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------
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 ? (
<FormRowList rows={rows} defs={defs} canEdit={canEdit} onEdit={(r) => setEditing(r)} />
) : (
!editing && (
<EmptyState icon="file" title={`No ${def.title} form yet`}>
Fill it during or right after the interview it replaces the paper form.
</EmptyState>
)
)}
{editing ? (
<EvaluationEditor
key={editing === 'new' ? 'new' : editing.id}
formType={formType}
def={def}
defs={defs}
row={editing === 'new' ? null : editing}
initial={initial}
userId={userId}
link={link}
live={live}
onClose={() => 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.
<div style={{ display: 'flex', justifyContent: rows.length ? 'flex-start' : 'center', marginTop: rows.length ? 12 : 0 }}>
<button
className={`btn btn-primary${rows.length ? ' btn-sm' : ' hf-cta'}`}
onClick={() => setEditing('new')}
>
<Icon name="plus" /> Fill {def.title} Form
</button>
</div>
)
)}
</>
)
}
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 (
<form noValidate onSubmit={(e) => { e.preventDefault(); save.mutate() }}>
<div className="hf-block">
<div className="hf-block-title">Interview Details Summary</div>
<div className="form-grid">
<div className="form-field">
<label>Candidate's Name</label>
<input value={live?.name || ''} readOnly disabled />
</div>
<div className="form-field">
<label>{fieldLabel(def, 'interviewer_name')}</label>
<input
value={fields.interviewer_name}
onChange={(e) => setField('interviewer_name', e.target.value)}
/>
</div>
<div className="form-field">
<label>{fieldLabel(def, 'department')}</label>
<input value={fields.department} onChange={(e) => setField('department', e.target.value)} />
</div>
<div className="form-field">
<label>{fieldLabel(def, 'position_title')}</label>
<input
value={fields.position_title}
onChange={(e) => setField('position_title', e.target.value)}
/>
</div>
<div className="form-field">
<label>Interview Date</label>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
{'summary' in fields && (
<div className="form-field">
<label>{fieldLabel(def, 'summary')}</label>
<textarea
rows={2}
style={{ minHeight: 44 }}
value={fields.summary}
onChange={(e) => setField('summary', e.target.value)}
/>
</div>
)}
</div>
</div>
{def.sections.map((s) => (
<div className="hf-block" key={s.key}>
<div className="hf-block-title">{s.title}</div>
<div className="hf-note">{def.scale_note}</div>
<RatingTable
section={s}
defs={defs}
ratings={ratings[s.key]}
onRate={(critKey, n) => setRating(s.key, critKey, n)}
/>
{sectionNoteKey[s.key] in fields && (
<div className="form-field" style={{ marginTop: 10 }}>
<label>Notes</label>
<input
placeholder="Optional note for the score summary"
value={fields[sectionNoteKey[s.key]]}
onChange={(e) => setField(sectionNoteKey[s.key], e.target.value)}
/>
</div>
)}
</div>
))}
<div className="hf-block">
<div className="hf-block-title">Assessment Summary</div>
<div className="form-grid">
{['strengths', 'concerns'].map((key) => (
<div className="form-field" key={key}>
<label>{fieldLabel(def, key)}</label>
<textarea
rows={2}
style={{ minHeight: 56 }}
value={fields[key]}
onChange={(e) => setField(key, e.target.value)}
/>
</div>
))}
<div className="form-field col-span-2">
<label>{fieldLabel(def, 'overall_observation')}</label>
<textarea
rows={2}
style={{ minHeight: 56 }}
value={fields.overall_observation}
onChange={(e) => setField('overall_observation', e.target.value)}
/>
</div>
</div>
</div>
{def.has_recommendation && (
<div className="hf-block">
<div className="hf-block-title">Final Recommendation</div>
<div className="seg" style={{ flexWrap: 'wrap' }}>
{defs.recommendations.map((r) => (
<button
type="button"
key={r}
className={r === recommendation ? 'active' : ''}
onClick={() => setRecommendation(r === recommendation ? '' : r)}
>
{defs.recommendation_labels[r]}
</button>
))}
</div>
</div>
)}
<div className="flex items-center gap-8" style={{ marginTop: 14 }}>
<button className="btn btn-primary btn-sm" disabled={save.isPending} type="submit">
{save.isPending ? 'Saving' : row ? 'Save Changes' : 'Save Form'}
</button>
<button className="btn btn-secondary btn-sm" disabled={save.isPending} type="button" onClick={onClose}>
Cancel
</button>
</div>
</form>
)
}
/* ------------------------------------------------------------------
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 (
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="hf-block">
<div className="hf-block-title">Position Request</div>
<div className="hf-note">{def.header_note}</div>
<div className="form-grid">
<div className="form-field">
<label>{label('department')}</label>
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
</div>
<div className="form-field">
<label>{label('job_title')} <span className="req">*</span></label>
<input
className={errors.job_title ? 'err' : ''}
value={fields.job_title}
onChange={(e) => set('job_title', e.target.value)}
/>
<FieldError>{errors.job_title}</FieldError>
</div>
<div className="form-field">
<label>Date</label>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="form-field">
<label>{label('date_needed')}</label>
<input type="date" value={fields.date_needed} onChange={(e) => set('date_needed', e.target.value)} />
</div>
<div className="form-field">
<label>{label('employment_type')}</label>
<select value={fields.employment_type} onChange={(e) => set('employment_type', e.target.value)}>
<option value="">—</option>
{defs.employment_types.map((t) => (
<option key={t} value={t}>{defs.employment_type_labels[t]}</option>
))}
</select>
</div>
<div className="form-field">
<label>{label('jd_available')}</label>
<select
className={errors.jd_available ? 'err' : ''}
value={fields.jd_available}
onChange={(e) => set('jd_available', e.target.value)}
>
<option value="">—</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
<FieldError>{errors.jd_available}</FieldError>
</div>
{notPermanent && (
<>
<div className="form-field">
<label>{label('period_from')}</label>
<input type="date" value={fields.period_from} onChange={(e) => set('period_from', e.target.value)} />
</div>
<div className="form-field">
<label>{label('period_to')}</label>
<input type="date" value={fields.period_to} onChange={(e) => set('period_to', e.target.value)} />
</div>
</>
)}
</div>
</div>
<div className="hf-block">
<div className="hf-block-title">
<label>
<input
type="checkbox"
checked={fields.is_replacement === 'yes'}
onChange={(e) => set('is_replacement', e.target.checked ? 'yes' : '')}
/>
{label('is_replacement')}
</label>
</div>
{fields.is_replacement === 'yes' && (
<div className="form-grid">
<div className="form-field">
<label>{label('replacement_employee')}</label>
<input
value={fields.replacement_employee}
onChange={(e) => set('replacement_employee', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('replacement_grade')}</label>
<input
value={fields.replacement_grade}
onChange={(e) => set('replacement_grade', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('replacement_job_title')}</label>
<input
value={fields.replacement_job_title}
onChange={(e) => set('replacement_job_title', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('replacement_date_separated')}</label>
<input
type="date"
value={fields.replacement_date_separated}
onChange={(e) => set('replacement_date_separated', e.target.value)}
/>
</div>
</div>
)}
</div>
<div className="hf-block">
<div className="hf-block-title">New / Additional Headcount</div>
<div className="form-grid">
<div className="form-field col-span-2">
<label>{label('headcount_justification')}</label>
<textarea
rows={2}
style={{ minHeight: 56 }}
value={fields.headcount_justification}
onChange={(e) => set('headcount_justification', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('proposed_budget')}</label>
<input value={fields.proposed_budget} onChange={(e) => set('proposed_budget', e.target.value)} />
</div>
<div className="form-field">
<label>{label('recommended_grade')}</label>
<input value={fields.recommended_grade} onChange={(e) => set('recommended_grade', e.target.value)} />
</div>
</div>
</div>
<div className="hf-block">
<div className="hf-block-title">
<label>
<input
type="checkbox"
checked={fields.internal_recommendation === 'yes'}
onChange={(e) => set('internal_recommendation', e.target.checked ? 'yes' : '')}
/>
{label('internal_recommendation')}
</label>
</div>
{fields.internal_recommendation === 'yes' && (
<div className="form-grid">
<div className="form-field">
<label>{label('recommended_employee_name')}</label>
<input
value={fields.recommended_employee_name}
onChange={(e) => set('recommended_employee_name', e.target.value)}
/>
</div>
<div className="form-field">
<label>{label('recommended_employee_department')}</label>
<input
value={fields.recommended_employee_department}
onChange={(e) => set('recommended_employee_department', e.target.value)}
/>
</div>
</div>
)}
</div>
<div className="hf-block">
<div className="hf-block-title">Approvals</div>
<div className="hf-sign-grid">
{SIGN_SLOTS.map((slot) => (
<div key={slot.nameKey} className="hf-sign">
<span className="hf-sign-role">{slot.role}</span>
<input
placeholder="Name"
value={fields[slot.nameKey]}
onChange={(e) => set(slot.nameKey, e.target.value)}
/>
<input
type="date"
value={fields[slot.dateKey]}
onChange={(e) => set(slot.dateKey, e.target.value)}
/>
</div>
))}
</div>
</div>
<div className="flex items-center gap-8" style={{ marginTop: 14 }}>
<button className="btn btn-primary btn-sm" disabled={!allowed || save.isPending} type="submit">
{save.isPending ? 'Saving' : row ? 'Save Changes' : 'Save Form'}
</button>
{row && (
<span className="text-muted" style={{ fontSize: 12 }}>
Filed {toDateInput(row.created_at)} by {row.created_by_name || 'unknown'}
</span>
)}
</div>
</form>
)
}
/* ------------------------------------------------------------------
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 (
<EmptyState icon="briefcase" title="Offers need an email application">
This candidate was added manually the offers ledger links to an email
application, so draft their offer letter outside the app for now.
</EmptyState>
)
}
if (offersQuery.isPending) {
return <EmptyState icon="refresh" title="Loading offer…">Fetching offer records.</EmptyState>
}
if (offersQuery.isError) {
return (
<EmptyState icon="alert" title="Could not load offers">
{friendlyAuthError(offersQuery.error, 'Please try again.')}
</EmptyState>
)
}
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 (
<EmptyState icon="briefcase" title="No assigned job">
Creating an offer needs an assigned job post assign one from the Inbox or Pipeline first.
</EmptyState>
)
}
return (
<OfferEditor
key={offer?.id ?? 'new'}
offer={offer}
userId={userId}
inboxId={inboxId}
live={live}
allowed={allowed}
/>
)
}
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 (
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
<div className="hf-block">
<div className="hf-block-title">
Position &amp; Cadre
{offer && <Badge className="b-gray">{offer.status}</Badge>}
</div>
<div className="form-grid">
<div className="form-field">
<label>Offered Position</label>
<input value={live?.job_title || ''} readOnly disabled />
</div>
<div className="form-field">
<label>Cadre (L-X)</label>
<input placeholder="L-3" value={form.cadre} onChange={(e) => set('cadre', e.target.value)} />
</div>
</div>
</div>
<div className="hf-block">
<div className="hf-block-title">Remuneration Package</div>
<div className="form-grid">
<div className="form-field">
<label>Monthly Gross Salary (PKR) <span className="req">*</span></label>
<input
type="number" min="0" placeholder="000000"
className={errors.base ? 'err' : ''}
value={form.base}
onChange={(e) => set('base', e.target.value)}
/>
<FieldError>{errors.base}</FieldError>
</div>
<div className="form-field">
<label>PKR Amount in words Only</label>
<input value={form.grossInWords} onChange={(e) => set('grossInWords', e.target.value)} />
</div>
<div className="form-field">
<label>Subsidized Services</label>
<input value={form.subsidized} onChange={(e) => set('subsidized', e.target.value)} />
</div>
<div className="form-field">
<label>Probation Period</label>
<input value={form.probation} onChange={(e) => set('probation', e.target.value)} />
</div>
<div className="form-field">
<label>Notice Period</label>
<input value={form.notice} onChange={(e) => set('notice', e.target.value)} />
</div>
<div className="form-field">
<label>Location</label>
<select value={form.location} onChange={(e) => set('location', e.target.value)}>
<option value=""></option>
{WORK_LOCATIONS.map((l) => <option key={l}>{l}</option>)}
</select>
</div>
<div className="form-field">
<label>Timings</label>
<select value={form.timings} onChange={(e) => set('timings', e.target.value)}>
<option value=""></option>
{WORK_TIMINGS.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
</div>
</div>
<div className="hf-block">
<div className="hf-block-title">Dates</div>
<div className="form-grid">
<div className="form-field">
<label>Start Date</label>
<input type="date" value={form.startDate} onChange={(e) => set('startDate', e.target.value)} />
</div>
<div className="form-field">
<label>Expiration Date</label>
<input type="date" value={form.expiryDate} onChange={(e) => set('expiryDate', e.target.value)} />
</div>
</div>
</div>
<p className="text-muted" style={{ marginTop: 12, fontSize: 13 }}>
Saved as a draft on the offers ledger issuing the offer email stays on the Offers screen
(<code>offers.approve</code>) and keeps the annexure's fixed wording, including the
24-hour acceptance window.
</p>
<button className="btn btn-primary btn-sm" disabled={!allowed || save.isPending} type="submit">
{save.isPending ? 'Saving…' : offer ? 'Save Changes' : 'Save Draft'}
</button>
</form>
)
}