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>
113 lines
5.2 KiB
TypeScript
113 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
|
import {
|
|
approveSalary,
|
|
returnSalary,
|
|
selectCandidate,
|
|
returnSelection,
|
|
approveInterviewWaiver,
|
|
declineInterviewWaiver,
|
|
} from "../crewing/applications/actions";
|
|
|
|
export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER";
|
|
|
|
export type CrewApprovalItem = {
|
|
applicationId: string;
|
|
kind: CrewApprovalKind;
|
|
candidateName: string;
|
|
rank: string;
|
|
requisitionCode: string;
|
|
detail: string; // amount for salary, etc.
|
|
};
|
|
|
|
const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver" };
|
|
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary" } as const;
|
|
|
|
const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
|
|
SALARY: approveSalary,
|
|
SELECTION: selectCandidate,
|
|
WAIVER: approveInterviewWaiver,
|
|
};
|
|
const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => Promise<{ ok: true } | { error: string }>> = {
|
|
SALARY: returnSalary,
|
|
SELECTION: returnSelection,
|
|
WAIVER: declineInterviewWaiver,
|
|
};
|
|
|
|
function Row({ item }: { item: CrewApprovalItem }) {
|
|
const router = useRouter();
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [returnOpen, setReturnOpen] = useState(false);
|
|
const [reason, setReason] = useState("");
|
|
|
|
async function approve() {
|
|
setPending(true); setError("");
|
|
const res = await approveFn[item.kind](item.applicationId);
|
|
setPending(false);
|
|
if ("error" in res) setError(res.error); else router.refresh();
|
|
}
|
|
async function doReturn(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setPending(true); setError("");
|
|
const res = await returnFn[item.kind](item.applicationId, reason);
|
|
setPending(false);
|
|
if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
|
|
}
|
|
|
|
return (
|
|
<tr className="hover:bg-neutral-50">
|
|
<td className="px-4 py-3"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
|
|
<td className="px-4 py-3">
|
|
<Link href={`/crewing/applications/${item.applicationId}`} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
|
|
<span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
|
|
<button onClick={() => setReturnOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Return</button>
|
|
</div>
|
|
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
|
|
<AdminDialog title={`Return ${KIND_LABEL[item.kind].toLowerCase()}`} open={returnOpen} onClose={() => setReturnOpen(false)}>
|
|
<form onSubmit={doReturn} className="space-y-4 text-left">
|
|
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
|
|
<div className="flex justify-end gap-3">
|
|
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setReturnOpen(false)}>Cancel</button>
|
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Return</button>
|
|
</div>
|
|
</form>
|
|
</AdminDialog>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
|
|
return (
|
|
<div className="mt-8">
|
|
<h2 className="text-sm font-semibold text-neutral-900 mb-1">Crewing approvals</h2>
|
|
<p className="text-xs text-neutral-500 mb-3">{items.length} item{items.length === 1 ? "" : "s"} awaiting your decision</p>
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Kind</th>
|
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Candidate</th>
|
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Detail</th>
|
|
<th className="px-4 py-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-neutral-100">
|
|
{items.map((item) => <Row key={`${item.kind}-${item.applicationId}`} item={item} />)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|