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>
204 lines
7.9 KiB
TypeScript
204 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { RaiseRequisitionButton, ConvertReliefButton } from "./requisition-form";
|
|
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "./requisition-ui";
|
|
|
|
type RequisitionRow = {
|
|
id: string;
|
|
code: string;
|
|
status: RequisitionStatus;
|
|
reason: RequisitionReason;
|
|
autoRaised: boolean;
|
|
rankName: string;
|
|
location: string;
|
|
raisedBy: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type ReliefRow = {
|
|
id: string;
|
|
rankName: string;
|
|
location: string;
|
|
note: string | null;
|
|
requestedBy: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type Opt = { id: string; name: string };
|
|
type RankOpt = { id: string; code: string; name: string };
|
|
|
|
const INPUT =
|
|
"rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
|
|
|
const STATUS_FILTERS: RequisitionStatus[] = [
|
|
"OPEN", "SHORTLISTING", "PROPOSING", "INTERVIEWING", "SELECTED", "FILLED", "CANCELLED",
|
|
];
|
|
|
|
export function RequisitionsManager({
|
|
requisitions,
|
|
reliefRequests,
|
|
ranks,
|
|
vessels,
|
|
sites,
|
|
canRaise,
|
|
canConvert,
|
|
}: {
|
|
requisitions: RequisitionRow[];
|
|
reliefRequests: ReliefRow[];
|
|
ranks: RankOpt[];
|
|
vessels: Opt[];
|
|
sites: Opt[];
|
|
canRaise: boolean;
|
|
canConvert: boolean;
|
|
}) {
|
|
const [search, setSearch] = useState("");
|
|
const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL");
|
|
const [location, setLocation] = useState("ALL");
|
|
|
|
const locations = useMemo(
|
|
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
|
|
[requisitions]
|
|
);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return requisitions.filter((r) => {
|
|
if (status !== "ALL" && r.status !== status) return false;
|
|
if (location !== "ALL" && r.location !== location) return false;
|
|
if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
|
|
return true;
|
|
});
|
|
}, [requisitions, search, status, location]);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-neutral-900">Requisitions</h1>
|
|
<p className="text-sm text-neutral-500 mt-0.5">
|
|
{requisitions.length} requisition{requisitions.length === 1 ? "" : "s"} · vacancies being sourced and filled
|
|
</p>
|
|
</div>
|
|
{canRaise && <RaiseRequisitionButton ranks={ranks} vessels={vessels} sites={sites} />}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
|
<input
|
|
className={`${INPUT} flex-1 min-w-[200px]`}
|
|
placeholder="Search code, rank or location…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
<select className={INPUT} value={status} onChange={(e) => setStatus(e.target.value as typeof status)}>
|
|
<option value="ALL">All statuses</option>
|
|
{STATUS_FILTERS.map((s) => (
|
|
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
|
|
))}
|
|
</select>
|
|
<select className={INPUT} value={location} onChange={(e) => setLocation(e.target.value)}>
|
|
<option value="ALL">All vessels / sites</option>
|
|
{locations.map((l) => (
|
|
<option key={l} value={l}>{l}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Requisitions table */}
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
|
<th className="px-4 py-3">Requisition</th>
|
|
<th className="px-4 py-3">Vessel / site</th>
|
|
<th className="px-4 py-3">Rank</th>
|
|
<th className="px-4 py-3">Reason</th>
|
|
<th className="px-4 py-3">Raised by</th>
|
|
<th className="px-4 py-3">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
|
|
No requisitions match these filters.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filtered.map((r) => (
|
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
|
<td className="px-4 py-3">
|
|
<Link href={`/crewing/requisitions/${r.id}`} className="block">
|
|
<span className="font-mono text-xs text-neutral-900">{r.code}</span>
|
|
<span className="ml-2 text-xs text-neutral-400">{ageLabel(r.createdAt)} ago</span>
|
|
{r.autoRaised && (
|
|
<span className="ml-2 rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-[10px] font-medium">
|
|
Auto
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</td>
|
|
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
|
|
<td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td>
|
|
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Relief requests from sites (spec §8.2 / R3 / R6) */}
|
|
<div className="mt-8">
|
|
<h2 className="text-sm font-semibold text-neutral-900">Relief requests from sites</h2>
|
|
<p className="text-xs text-neutral-500 mt-0.5 mb-3">
|
|
Foreseen gaps flagged by site staff. Convert one into a requisition to start sourcing.
|
|
</p>
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
|
<th className="px-4 py-3">Vessel / site</th>
|
|
<th className="px-4 py-3">Rank</th>
|
|
<th className="px-4 py-3">Note</th>
|
|
<th className="px-4 py-3">Requested by</th>
|
|
<th className="px-4 py-3 w-20"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{reliefRequests.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
|
|
No open relief requests.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
reliefRequests.map((r) => (
|
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
|
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
|
|
<td className="px-4 py-3 text-neutral-500">{r.note ?? "—"}</td>
|
|
<td className="px-4 py-3 text-neutral-500">{r.requestedBy}</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{canConvert && (
|
|
<ConvertReliefButton reliefRequestId={r.id} label={`${r.rankName} on ${r.location}`} />
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|