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>
52 lines
1.4 KiB
TypeScript
52 lines
1.4 KiB
TypeScript
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
|
|
import type { BadgeProps } from "@/components/ui/badge";
|
|
|
|
type Variant = NonNullable<BadgeProps["variant"]>;
|
|
|
|
// Status → badge variant (Crewing-Implementation-Spec §8.2).
|
|
export const STATUS_VARIANT: Record<RequisitionStatus, Variant> = {
|
|
OPEN: "outline",
|
|
SHORTLISTING: "default",
|
|
PROPOSING: "default",
|
|
INTERVIEWING: "warning",
|
|
SELECTED: "default",
|
|
FILLED: "success",
|
|
CANCELLED: "danger",
|
|
};
|
|
|
|
export const STATUS_LABEL: Record<RequisitionStatus, string> = {
|
|
OPEN: "Open",
|
|
SHORTLISTING: "Shortlisting",
|
|
PROPOSING: "Proposing",
|
|
INTERVIEWING: "Interviewing",
|
|
SELECTED: "Selected",
|
|
FILLED: "Filled",
|
|
CANCELLED: "Cancelled",
|
|
};
|
|
|
|
export const REASON_LABEL: Record<RequisitionReason, string> = {
|
|
NEW_VACANCY: "New vacancy",
|
|
REPLACEMENT: "Replacement",
|
|
LEAVE: "Leave cover",
|
|
SIGN_OFF: "Sign-off",
|
|
END_OF_CONTRACT: "End of contract",
|
|
OTHER: "Other",
|
|
};
|
|
|
|
export const REASON_OPTIONS: RequisitionReason[] = [
|
|
"NEW_VACANCY",
|
|
"REPLACEMENT",
|
|
"LEAVE",
|
|
"SIGN_OFF",
|
|
"END_OF_CONTRACT",
|
|
"OTHER",
|
|
];
|
|
|
|
// Compact "age" label (e.g. "3d", "5h", "12m") relative to now.
|
|
export function ageLabel(iso: string): string {
|
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
|
if (mins < 60) return `${Math.max(mins, 0)}m`;
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return `${hrs}h`;
|
|
return `${Math.floor(hrs / 24)}d`;
|
|
}
|