Second slice of Phase 3 (stacked on 3a candidates). The gated 7-stage recruitment pipeline per Crewing-Implementation-Spec §5.1/§8.4–8.5/§8.13. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged. What's in - Schema (crewing_pipeline migration): Application (one per requisition+candidate) + 7-stage ApplicationStage; ApplicationGate (SALARY/SELECTION/WAIVER pending = Manager queue items); ReferenceCheck; effective-dated SalaryStructure (attached to the Application now, bound to the assignment in 3c); minimal BankDetail/EpfDetail captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). CrewAction += applicationId; pipeline CrewActionTypes. - State machine: lib/application-pipeline.ts — sourcing advances MPO/Manager; approve_salary + select are Manager-only; orthogonal canReject; BOARD_STAGES. - Actions: addApplication (first candidate → requisition SHORTLISTING), advanceStage, recordReferenceCheck, verifyDocuments (bank/EPF), agreeSalary→approveSalary/returnSalary, recordInterviewResult, requestInterviewWaiver→approve/decline, selectCandidate (→ requisition SELECTED)/returnSelection, rejectApplication. Waiver never automatic (R2). Notifications SALARY/SELECTION/WAIVER + CANDIDATE_PROPOSED. - Screens: pipeline board per requisition (7 columns + Add candidate); application workhorse (7-step stepper + adaptive per-stage action card); "Open pipeline" on the requisition detail. Central /approvals gains a crewing section (inline Approve/Return) for one unified Manager queue (§8.13 R8). Tests & docs - Unit: application-pipeline.test.ts (9). Integration: applications.test.ts (10) — full happy path, salary/selection/waiver approvals + Manager-only gating, failed interview, reject, site-staff lockout. type-check clean; full unit (234) + integration (163) green. - CLAUDE.md "Crewing" updated with the Phase 3b surface. Deferred: onboarding (Epic D, Phase 3c) — SELECTED → ONBOARDED, CrewAssignment, employeeId, requisition → FILLED, salary bound to the assignment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
347 lines
16 KiB
TypeScript
347 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import type { ApplicationStage, InterviewOutcome, SalaryRateBasis } from "@prisma/client";
|
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
|
import {
|
|
advanceStage,
|
|
agreeSalary,
|
|
approveSalary,
|
|
returnSalary,
|
|
verifyDocuments,
|
|
recordReferenceCheck,
|
|
recordInterviewResult,
|
|
requestInterviewWaiver,
|
|
approveInterviewWaiver,
|
|
selectCandidate,
|
|
returnSelection,
|
|
rejectApplication,
|
|
} from "./actions";
|
|
|
|
const INPUT =
|
|
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
|
const PRIMARY = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
|
const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60";
|
|
const DANGER = "rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60";
|
|
|
|
export type ActionCardProps = {
|
|
id: string;
|
|
stage: ApplicationStage;
|
|
isExHand: boolean;
|
|
interviewResult: InterviewOutcome;
|
|
interviewWaived: boolean;
|
|
rejectedReason: string | null;
|
|
salaryPending: boolean;
|
|
waiverPending: boolean;
|
|
selectionPending: boolean;
|
|
salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null;
|
|
perms: {
|
|
manage: boolean;
|
|
recordReference: boolean;
|
|
recordInterview: boolean;
|
|
requestWaiver: boolean;
|
|
approveSalary: boolean;
|
|
approveWaiver: boolean;
|
|
select: boolean;
|
|
};
|
|
};
|
|
|
|
function useAction() {
|
|
const router = useRouter();
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
async function run(fn: () => Promise<{ ok: true } | { error: string }>) {
|
|
setPending(true);
|
|
setError("");
|
|
const res = await fn();
|
|
setPending(false);
|
|
if ("error" in res) setError(res.error);
|
|
else router.refresh();
|
|
return res;
|
|
}
|
|
return { pending, error, run };
|
|
}
|
|
|
|
function Card({ title, sub, children }: { title: string; sub?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
|
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
|
|
{sub && <p className="text-xs text-neutral-500 mt-0.5">{sub}</p>}
|
|
</div>
|
|
<div className="p-4 space-y-3">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RejectButton({ id }: { id: string }) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [reason, setReason] = useState("");
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setPending(true); setError("");
|
|
const res = await rejectApplication(id, reason);
|
|
setPending(false);
|
|
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
|
}
|
|
return (
|
|
<>
|
|
<button className={DANGER} onClick={() => setOpen(true)}>Reject</button>
|
|
<AdminDialog title="Reject candidate" open={open} onClose={() => setOpen(false)}>
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<p className="text-sm text-neutral-600">Rejecting removes this candidate from the pipeline. The reason is recorded.</p>
|
|
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
|
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
|
<div className="flex justify-end gap-3">
|
|
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
|
<button type="submit" disabled={pending} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Rejecting…" : "Reject"}</button>
|
|
</div>
|
|
</form>
|
|
</AdminDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Err({ msg }: { msg: string }) {
|
|
return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null;
|
|
}
|
|
|
|
export function ApplicationActionCard(p: ActionCardProps) {
|
|
const { run, pending, error } = useAction();
|
|
const canReject = p.perms.manage && !["SELECTED", "ONBOARDED", "REJECTED"].includes(p.stage);
|
|
|
|
// Reference-check form state (COMPETENCY_AND_REFERENCES).
|
|
const [ref, setRef] = useState({ refereeName: "", refereeContact: "", outcome: "positive", note: "" });
|
|
// Bank/EPF form state (DOC_VERIFICATION).
|
|
const [docs, setDocs] = useState({ accountName: "", accountNumber: "", ifsc: "", bankName: "", uan: "", aadhaarLast4: "", pfNumber: "" });
|
|
// Salary form state (SALARY_AGREEMENT).
|
|
const [sal, setSal] = useState({ rateBasis: "MONTHLY", basic: "", victualingPerDay: "0", currency: "INR" });
|
|
|
|
function fdFrom(obj: Record<string, string>, extra?: Record<string, string>) {
|
|
const fd = new FormData();
|
|
Object.entries({ ...obj, ...extra }).forEach(([k, v]) => fd.set(k, v));
|
|
return fd;
|
|
}
|
|
|
|
const footer = (
|
|
<>
|
|
<Err msg={error} />
|
|
{canReject && (
|
|
<div className="flex justify-end pt-1">
|
|
<RejectButton id={p.id} />
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
switch (p.stage) {
|
|
case "SHORTLISTED":
|
|
return (
|
|
<Card title="Shortlisted" sub="Begin vetting: competency & references.">
|
|
{p.perms.manage && (
|
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "start_competency"))}>
|
|
Start competency & references
|
|
</button>
|
|
)}
|
|
{footer}
|
|
</Card>
|
|
);
|
|
|
|
case "COMPETENCY_AND_REFERENCES":
|
|
return (
|
|
<Card title="Competency & references" sub="Record reference checks, then verify to continue.">
|
|
{p.perms.recordReference && (
|
|
<div className="space-y-2 rounded-md border border-neutral-200 p-3">
|
|
<p className="text-xs font-medium text-neutral-600">Add a reference check</p>
|
|
<input className={INPUT} placeholder="Referee name" value={ref.refereeName} onChange={(e) => setRef({ ...ref, refereeName: e.target.value })} />
|
|
<input className={INPUT} placeholder="Referee contact (optional)" value={ref.refereeContact} onChange={(e) => setRef({ ...ref, refereeContact: e.target.value })} />
|
|
<input className={INPUT} placeholder="Note (optional)" value={ref.note} onChange={(e) => setRef({ ...ref, note: e.target.value })} />
|
|
<button className={SECONDARY} disabled={pending || !ref.refereeName} onClick={() => run(() => recordReferenceCheck(fdFrom(ref, { applicationId: p.id }))).then((r) => { if ("ok" in r) setRef({ refereeName: "", refereeContact: "", outcome: "positive", note: "" }); })}>
|
|
Save reference
|
|
</button>
|
|
</div>
|
|
)}
|
|
{p.perms.manage && (
|
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "verify_competency"))}>
|
|
Verify & continue to documents
|
|
</button>
|
|
)}
|
|
{footer}
|
|
</Card>
|
|
);
|
|
|
|
case "DOC_VERIFICATION":
|
|
return (
|
|
<Card title="Documents" sub="MPO collects & verifies documents, bank and EPF.">
|
|
{p.perms.manage ? (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input className={INPUT} placeholder="Account name" value={docs.accountName} onChange={(e) => setDocs({ ...docs, accountName: e.target.value })} />
|
|
<input className={INPUT} placeholder="Account number" value={docs.accountNumber} onChange={(e) => setDocs({ ...docs, accountNumber: e.target.value })} />
|
|
<input className={INPUT} placeholder="IFSC" value={docs.ifsc} onChange={(e) => setDocs({ ...docs, ifsc: e.target.value })} />
|
|
<input className={INPUT} placeholder="Bank name" value={docs.bankName} onChange={(e) => setDocs({ ...docs, bankName: e.target.value })} />
|
|
<input className={INPUT} placeholder="UAN" value={docs.uan} onChange={(e) => setDocs({ ...docs, uan: e.target.value })} />
|
|
<input className={INPUT} placeholder="Aadhaar (last 4)" value={docs.aadhaarLast4} onChange={(e) => setDocs({ ...docs, aadhaarLast4: e.target.value })} />
|
|
<input className={INPUT} placeholder="PF number" value={docs.pfNumber} onChange={(e) => setDocs({ ...docs, pfNumber: e.target.value })} />
|
|
</div>
|
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => verifyDocuments(fdFrom(docs, { applicationId: p.id })))}>
|
|
Verify & continue to salary
|
|
</button>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-neutral-500">Awaiting document verification by the MPO.</p>
|
|
)}
|
|
{footer}
|
|
</Card>
|
|
);
|
|
|
|
case "SALARY_AGREEMENT":
|
|
if (p.salaryPending) {
|
|
return (
|
|
<Card title="Salary" sub="Office-only; the Manager approves.">
|
|
<p className="text-sm text-neutral-600">
|
|
Proposed: <strong>{p.salary?.currency} {p.salary?.basic}</strong> / {p.salary?.rateBasis.toLowerCase()} · victualing {p.salary?.currency} {p.salary?.victualingPerDay}/day
|
|
</p>
|
|
{p.perms.approveSalary ? (
|
|
<div className="flex gap-2">
|
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveSalary(p.id))}>Approve salary</button>
|
|
<ReturnButton label="Return salary" onReturn={(reason) => returnSalary(p.id, reason)} />
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Awaiting Manager approval.</p>
|
|
)}
|
|
{footer}
|
|
</Card>
|
|
);
|
|
}
|
|
return (
|
|
<Card title="Salary" sub="Office-only; the Manager approves.">
|
|
{p.perms.manage ? (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<select className={INPUT} value={sal.rateBasis} onChange={(e) => setSal({ ...sal, rateBasis: e.target.value })}>
|
|
<option value="MONTHLY">Per month</option>
|
|
<option value="DAILY">Per day</option>
|
|
</select>
|
|
<input className={INPUT} type="number" placeholder="Basic" value={sal.basic} onChange={(e) => setSal({ ...sal, basic: e.target.value })} />
|
|
<input className={INPUT} type="number" placeholder="Victualing / day" value={sal.victualingPerDay} onChange={(e) => setSal({ ...sal, victualingPerDay: e.target.value })} />
|
|
</div>
|
|
<button className={PRIMARY} disabled={pending || !sal.basic} onClick={() => run(() => agreeSalary(fdFrom(sal, { applicationId: p.id })))}>
|
|
Agree salary & send for approval
|
|
</button>
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-neutral-500">Awaiting the MPO to agree the salary.</p>
|
|
)}
|
|
{footer}
|
|
</Card>
|
|
);
|
|
|
|
case "PROPOSED":
|
|
return (
|
|
<Card title="Proposed" sub="Awaiting the candidate's acceptance.">
|
|
{p.perms.manage && (
|
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "propose_accepted"))}>
|
|
Candidate accepted — schedule interview
|
|
</button>
|
|
)}
|
|
{footer}
|
|
</Card>
|
|
);
|
|
|
|
case "INTERVIEW":
|
|
return (
|
|
<Card title="Interview" sub="MPO records the result; the Manager approves the selection.">
|
|
{/* Interview result row */}
|
|
{p.interviewResult === "PENDING" && !p.interviewWaived && p.perms.recordInterview && (
|
|
<div className="flex gap-2">
|
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, true))}>Interview passed</button>
|
|
<button className={DANGER} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, false))}>Interview failed</button>
|
|
</div>
|
|
)}
|
|
{/* Waiver (ex-hand) */}
|
|
{p.isExHand && !p.interviewWaived && p.interviewResult === "PENDING" && !p.waiverPending && p.perms.requestWaiver && (
|
|
<button className={SECONDARY} disabled={pending} onClick={() => run(() => requestInterviewWaiver(p.id))}>Request interview waiver → Manager</button>
|
|
)}
|
|
{p.waiverPending && (
|
|
p.perms.approveWaiver ? (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-warning-700">Waiver requested.</span>
|
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveInterviewWaiver(p.id))}>Approve waiver</button>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Interview waiver awaiting Manager approval.</p>
|
|
)
|
|
)}
|
|
{/* Selection row */}
|
|
{(p.interviewResult === "ACCEPTED" || p.interviewWaived) && (
|
|
p.perms.select ? (
|
|
<div className="flex gap-2">
|
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => selectCandidate(p.id))}>Approve — select</button>
|
|
<ReturnButton label="Return" onReturn={(reason) => returnSelection(p.id, reason)} />
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">{p.interviewWaived ? "Interview waived" : "Interview passed"} — awaiting Manager selection.</p>
|
|
)
|
|
)}
|
|
{footer}
|
|
</Card>
|
|
);
|
|
|
|
case "SELECTED":
|
|
return (
|
|
<Card title="Selected" sub="Ready to onboard.">
|
|
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Candidate selected.</p>
|
|
<button className={PRIMARY} disabled title="Onboarding arrives in the next phase (3c)">Onboard to crew (next phase)</button>
|
|
</Card>
|
|
);
|
|
|
|
case "REJECTED":
|
|
return (
|
|
<Card title="Rejected">
|
|
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{p.rejectedReason ?? "This candidate was rejected."}</p>
|
|
</Card>
|
|
);
|
|
|
|
default:
|
|
return (
|
|
<Card title="Onboarded">
|
|
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">This candidate has been onboarded.</p>
|
|
</Card>
|
|
);
|
|
}
|
|
}
|
|
|
|
function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [reason, setReason] = useState("");
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setPending(true); setError("");
|
|
const res = await onReturn(reason);
|
|
setPending(false);
|
|
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
|
}
|
|
return (
|
|
<>
|
|
<button type="button" className={SECONDARY} onClick={() => setOpen(true)}>{label}</button>
|
|
<AdminDialog title={label} open={open} onClose={() => setOpen(false)}>
|
|
<form onSubmit={submit} className="space-y-4">
|
|
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
|
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
|
<div className="flex justify-end gap-3">
|
|
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
|
<button type="submit" disabled={pending} className={PRIMARY}>{pending ? "Returning…" : "Return"}</button>
|
|
</div>
|
|
</form>
|
|
</AdminDialog>
|
|
</>
|
|
);
|
|
}
|