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>
88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
import type { RequisitionStatus, Role } from "@prisma/client";
|
|
|
|
// Requisition lifecycle state machine — mirrors the PO state machine
|
|
// (lib/po-state-machine.ts) and the reconciled spec (Crewing-Implementation-Spec
|
|
// §5.2): OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED,
|
|
// with CANCELLED reachable from OPEN/SHORTLISTING (Manager).
|
|
//
|
|
// The intermediate stage advances are driven by the recruitment pipeline that
|
|
// lands in Phase 3; they are modelled here now so the transitions, allowed
|
|
// roles and audit are settled and testable. Phase 2 wires raise (create OPEN)
|
|
// and cancel via server actions; selection is Manager-only (spec §6).
|
|
|
|
export type RequisitionAction =
|
|
| "start_shortlisting"
|
|
| "mark_proposing"
|
|
| "start_interviewing"
|
|
| "mark_selected"
|
|
| "mark_filled";
|
|
|
|
interface Transition {
|
|
to: RequisitionStatus;
|
|
allowedRoles: Role[];
|
|
requiresNote: boolean;
|
|
}
|
|
|
|
type TransitionMap = Partial<Record<RequisitionAction, Transition>>;
|
|
|
|
// MPO (MANNING) and Manager source recruitment; final selection is Manager-only.
|
|
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
|
|
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
|
|
|
|
const TRANSITIONS: Partial<Record<RequisitionStatus, TransitionMap>> = {
|
|
OPEN: {
|
|
start_shortlisting: { to: "SHORTLISTING", allowedRoles: SOURCING_ROLES, requiresNote: false },
|
|
},
|
|
SHORTLISTING: {
|
|
mark_proposing: { to: "PROPOSING", allowedRoles: SOURCING_ROLES, requiresNote: false },
|
|
},
|
|
PROPOSING: {
|
|
start_interviewing: { to: "INTERVIEWING", allowedRoles: SOURCING_ROLES, requiresNote: false },
|
|
},
|
|
INTERVIEWING: {
|
|
// Final selection of a candidate is a Manager approval (spec §6).
|
|
mark_selected: { to: "SELECTED", allowedRoles: MANAGER_ROLES, requiresNote: false },
|
|
},
|
|
SELECTED: {
|
|
// The onboarding side-effect (Phase 3) fills the vacancy.
|
|
mark_filled: { to: "FILLED", allowedRoles: SOURCING_ROLES, requiresNote: false },
|
|
},
|
|
};
|
|
|
|
export function getTransition(from: RequisitionStatus, action: RequisitionAction): Transition | null {
|
|
return TRANSITIONS[from]?.[action] ?? null;
|
|
}
|
|
|
|
export function canPerformAction(
|
|
from: RequisitionStatus,
|
|
action: RequisitionAction,
|
|
role: Role
|
|
): boolean {
|
|
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
|
|
}
|
|
|
|
export function getAvailableActions(status: RequisitionStatus, role: Role): RequisitionAction[] {
|
|
const map = TRANSITIONS[status];
|
|
if (!map) return [];
|
|
return (Object.keys(map) as RequisitionAction[]).filter((action) =>
|
|
canPerformAction(status, action, role)
|
|
);
|
|
}
|
|
|
|
export function requiresNote(from: RequisitionStatus, action: RequisitionAction): boolean {
|
|
return getTransition(from, action)?.requiresNote ?? false;
|
|
}
|
|
|
|
// ── Cancellation (orthogonal) ────────────────────────────────────────────────
|
|
// A requisition may be withdrawn while it is still early in the pipeline — OPEN
|
|
// or SHORTLISTING (spec §5.2) — and a reason is required. WHO may cancel is the
|
|
// `cancel_requisition` grant (spec §6: MPO + Manager + SuperUser); the actions
|
|
// enforce that permission, and CANCEL_ROLES mirrors it so the state machine and
|
|
// the matrix agree. Modelled separately from TRANSITIONS, like PO CANCEL_ROLES.
|
|
|
|
export const CANCEL_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
|
|
export const CANCELLABLE_FROM: RequisitionStatus[] = ["OPEN", "SHORTLISTING"];
|
|
|
|
export function canCancel(from: RequisitionStatus, role: Role): boolean {
|
|
return CANCELLABLE_FROM.includes(from) && CANCEL_ROLES.includes(role);
|
|
}
|