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>
74 lines
3 KiB
TypeScript
74 lines
3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
BOARD_STAGES,
|
|
canPerformAction,
|
|
canReject,
|
|
getAvailableActions,
|
|
getTransition,
|
|
} from "@/lib/application-pipeline";
|
|
|
|
// The gated 7-stage recruitment pipeline (Crewing-Implementation-Spec §5.1).
|
|
describe("Application pipeline state machine", () => {
|
|
it("has the 7 board stages in order", () => {
|
|
expect(BOARD_STAGES).toEqual([
|
|
"SHORTLISTED",
|
|
"COMPETENCY_AND_REFERENCES",
|
|
"DOC_VERIFICATION",
|
|
"SALARY_AGREEMENT",
|
|
"PROPOSED",
|
|
"INTERVIEW",
|
|
"SELECTED",
|
|
]);
|
|
});
|
|
|
|
describe("sourcing advances (MPO/Manager)", () => {
|
|
it("MPO walks the early stages", () => {
|
|
expect(getTransition("SHORTLISTED", "start_competency")?.to).toBe("COMPETENCY_AND_REFERENCES");
|
|
expect(canPerformAction("SHORTLISTED", "start_competency", "MANNING")).toBe(true);
|
|
expect(getTransition("COMPETENCY_AND_REFERENCES", "verify_competency")?.to).toBe("DOC_VERIFICATION");
|
|
expect(getTransition("DOC_VERIFICATION", "verify_docs")?.to).toBe("SALARY_AGREEMENT");
|
|
expect(getTransition("PROPOSED", "propose_accepted")?.to).toBe("INTERVIEW");
|
|
});
|
|
});
|
|
|
|
describe("Manager-gated advances (spec §6)", () => {
|
|
it("salary approval is Manager-only", () => {
|
|
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "MANAGER")).toBe(true);
|
|
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "SUPERUSER")).toBe(true);
|
|
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "MANNING")).toBe(false);
|
|
expect(getTransition("SALARY_AGREEMENT", "approve_salary")?.to).toBe("PROPOSED");
|
|
});
|
|
|
|
it("selection is Manager-only", () => {
|
|
expect(canPerformAction("INTERVIEW", "select", "MANAGER")).toBe(true);
|
|
expect(canPerformAction("INTERVIEW", "select", "MANNING")).toBe(false);
|
|
expect(getTransition("INTERVIEW", "select")?.to).toBe("SELECTED");
|
|
});
|
|
});
|
|
|
|
it("rejects actions on the wrong stage", () => {
|
|
expect(getTransition("SHORTLISTED", "select")).toBeNull();
|
|
expect(getTransition("SELECTED", "approve_salary")).toBeNull();
|
|
});
|
|
|
|
it("offers MPO only sourcing actions, Manager the gated ones", () => {
|
|
expect(getAvailableActions("SALARY_AGREEMENT", "MANNING")).toHaveLength(0);
|
|
expect(getAvailableActions("SALARY_AGREEMENT", "MANAGER")).toEqual(["approve_salary"]);
|
|
expect(getAvailableActions("SHORTLISTED", "SITE_STAFF")).toHaveLength(0);
|
|
});
|
|
|
|
describe("rejection (orthogonal)", () => {
|
|
it("MPO/Manager can reject from any active stage", () => {
|
|
expect(canReject("COMPETENCY_AND_REFERENCES", "MANNING")).toBe(true);
|
|
expect(canReject("INTERVIEW", "MANAGER")).toBe(true);
|
|
});
|
|
it("cannot reject once selected/onboarded/already rejected", () => {
|
|
expect(canReject("SELECTED", "MANAGER")).toBe(false);
|
|
expect(canReject("ONBOARDED", "MANAGER")).toBe(false);
|
|
expect(canReject("REJECTED", "MANAGER")).toBe(false);
|
|
});
|
|
it("site staff cannot reject", () => {
|
|
expect(canReject("SHORTLISTED", "SITE_STAFF")).toBe(false);
|
|
});
|
|
});
|
|
});
|