1034 lines
39 KiB
JavaScript
1034 lines
39 KiB
JavaScript
/* 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 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 (
|
||
<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 & 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>
|
||
)
|
||
}
|