pelagia-portal/App/app/(portal)/crewing/applications/application-action-card.tsx
Hardik 3ec3a2b4ef
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 30s
feat(crewing): Phase 3b — recruitment pipeline (flagged)
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>
2026-06-22 18:49:12 +05:30

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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>
</>
);
}