Second slice of the Crewing module per wiki Crewing-Implementation-Spec §12 (build order item 2). Everything stays behind NEXT_PUBLIC_CREWING_ENABLED; production is unchanged. Schema is added incrementally — this lands the requisition lifecycle layer. What's in - Schema: Requisition (OPEN→SHORTLISTING→PROPOSING→INTERVIEWING→SELECTED→FILLED, →CANCELLED), ReliefRequest, CrewAction (the POAction mirror) + their enums. Migration crewing_requisitions. - State machine: lib/requisition-state-machine.ts mirrors po-state-machine (selection Manager-only; orthogonal cancel from OPEN/SHORTLISTING by cancel_requisition holders, §6). Codes REQ-9000… via lib/requisition-number.ts. - Actions: raise/cancel/transition + requestReliefCover/convertReliefToRequisition, each guarding flag+permission+state, writing a CrewAction and notifying. Shared autoRaiseRequisition() (lib/requisition-service.ts) is the backfill entry point for sign-off / leave-clash (later phases). - Notifier: notifyCrew() PO-independent path + CrewNotificationEvent. - Screens: /crewing/requisitions (list + Raise modal + relief convert) and /crewing/requisitions/[id] (detail). Requisitions added to the flag-gated Crewing sidebar (Manager + MPO, §7). Tests & docs - Unit: requisition-state-machine.test.ts (11). - Integration: requisitions.test.ts (15) — raise/cancel/transition, relief request + convert, auto-raise, permission gating. - CLAUDE.md "Crewing" section updated with the Phase 2 surface. Deferred: sign-off/experience (Epic K, §12 item 2) depends on the crew/assignment models from Phase 3/4; autoRaiseRequisition() is ready for it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
3.2 KiB
TypeScript
78 lines
3.2 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
canCancel,
|
|
canPerformAction,
|
|
getAvailableActions,
|
|
getTransition,
|
|
} from "@/lib/requisition-state-machine";
|
|
|
|
// The requisition lifecycle (Crewing-Implementation-Spec §5.2):
|
|
// OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED,
|
|
// CANCELLED reachable from OPEN/SHORTLISTING (Manager). Selection is Manager-only.
|
|
describe("Requisition state machine", () => {
|
|
describe("forward transitions", () => {
|
|
it("MPO can start shortlisting an OPEN requisition", () => {
|
|
expect(canPerformAction("OPEN", "start_shortlisting", "MANNING")).toBe(true);
|
|
expect(getTransition("OPEN", "start_shortlisting")?.to).toBe("SHORTLISTING");
|
|
});
|
|
|
|
it("MPO advances through proposing and interviewing", () => {
|
|
expect(canPerformAction("SHORTLISTING", "mark_proposing", "MANNING")).toBe(true);
|
|
expect(canPerformAction("PROPOSING", "start_interviewing", "MANNING")).toBe(true);
|
|
});
|
|
|
|
it("final selection is Manager-only (spec §6)", () => {
|
|
expect(canPerformAction("INTERVIEWING", "mark_selected", "MANAGER")).toBe(true);
|
|
expect(canPerformAction("INTERVIEWING", "mark_selected", "SUPERUSER")).toBe(true);
|
|
expect(canPerformAction("INTERVIEWING", "mark_selected", "MANNING")).toBe(false);
|
|
});
|
|
|
|
it("onboarding fills the vacancy from SELECTED", () => {
|
|
expect(getTransition("SELECTED", "mark_filled")?.to).toBe("FILLED");
|
|
expect(canPerformAction("SELECTED", "mark_filled", "MANNING")).toBe(true);
|
|
});
|
|
|
|
it("rejects actions on the wrong source state", () => {
|
|
expect(canPerformAction("OPEN", "mark_selected", "MANAGER")).toBe(false);
|
|
expect(getTransition("FILLED", "mark_filled")).toBeNull();
|
|
expect(getTransition("CANCELLED", "start_shortlisting")).toBeNull();
|
|
});
|
|
|
|
it("site staff and accounts can perform no transitions", () => {
|
|
for (const status of ["OPEN", "SHORTLISTING", "INTERVIEWING", "SELECTED"] as const) {
|
|
expect(getAvailableActions(status, "SITE_STAFF")).toHaveLength(0);
|
|
expect(getAvailableActions(status, "ACCOUNTS")).toHaveLength(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("getAvailableActions", () => {
|
|
it("offers shortlisting on OPEN to the MPO", () => {
|
|
expect(getAvailableActions("OPEN", "MANNING")).toEqual(["start_shortlisting"]);
|
|
});
|
|
|
|
it("offers nothing once FILLED", () => {
|
|
expect(getAvailableActions("FILLED", "MANAGER")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("cancellation (orthogonal)", () => {
|
|
it("MPO and Manager can withdraw from OPEN or SHORTLISTING (matrix §6)", () => {
|
|
expect(canCancel("OPEN", "MANAGER")).toBe(true);
|
|
expect(canCancel("SHORTLISTING", "SUPERUSER")).toBe(true);
|
|
expect(canCancel("OPEN", "MANNING")).toBe(true);
|
|
});
|
|
|
|
it("cannot be withdrawn once past shortlisting", () => {
|
|
expect(canCancel("PROPOSING", "MANAGER")).toBe(false);
|
|
expect(canCancel("INTERVIEWING", "MANAGER")).toBe(false);
|
|
expect(canCancel("FILLED", "MANAGER")).toBe(false);
|
|
expect(canCancel("CANCELLED", "MANAGER")).toBe(false);
|
|
});
|
|
|
|
it("site staff and accounts may never withdraw", () => {
|
|
expect(canCancel("OPEN", "SITE_STAFF")).toBe(false);
|
|
expect(canCancel("OPEN", "ACCOUNTS")).toBe(false);
|
|
});
|
|
});
|
|
});
|