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>
242 lines
9.2 KiB
TypeScript
242 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
|
import { raiseRequisition, convertReliefToRequisition } from "./actions";
|
|
import { REASON_OPTIONS, REASON_LABEL } from "./requisition-ui";
|
|
|
|
const INPUT =
|
|
"w-full 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";
|
|
|
|
type Opt = { id: string; name: string };
|
|
type RankOpt = { id: string; code: string; name: string };
|
|
|
|
// A single "Vessel / site" picker — values are encoded "v:<id>" / "s:<id>" so
|
|
// one control covers both cost axes (spec §9 modal). Returns "" when unset.
|
|
function LocationSelect({
|
|
value,
|
|
onChange,
|
|
vessels,
|
|
sites,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
vessels: Opt[];
|
|
sites: Opt[];
|
|
}) {
|
|
return (
|
|
<select className={INPUT} value={value} onChange={(e) => onChange(e.target.value)}>
|
|
<option value="">— Select vessel or site —</option>
|
|
{vessels.length > 0 && (
|
|
<optgroup label="Vessels">
|
|
{vessels.map((v) => (
|
|
<option key={v.id} value={`v:${v.id}`}>{v.name}</option>
|
|
))}
|
|
</optgroup>
|
|
)}
|
|
{sites.length > 0 && (
|
|
<optgroup label="Sites">
|
|
{sites.map((s) => (
|
|
<option key={s.id} value={`s:${s.id}`}>{s.name}</option>
|
|
))}
|
|
</optgroup>
|
|
)}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
function applyLocation(fd: FormData, location: string) {
|
|
if (location.startsWith("v:")) fd.set("vesselId", location.slice(2));
|
|
else if (location.startsWith("s:")) fd.set("siteId", location.slice(2));
|
|
}
|
|
|
|
// ── Raise requisition (MPO / Manager) ──────────────────────────────────────────
|
|
|
|
export function RaiseRequisitionButton({
|
|
ranks,
|
|
vessels,
|
|
sites,
|
|
}: {
|
|
ranks: RankOpt[];
|
|
vessels: Opt[];
|
|
sites: Opt[];
|
|
}) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const [rankId, setRankId] = useState("");
|
|
const [location, setLocation] = useState("");
|
|
const [reason, setReason] = useState(REASON_OPTIONS[0]);
|
|
const [neededBy, setNeededBy] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
|
|
function reset() {
|
|
setRankId(""); setLocation(""); setReason(REASON_OPTIONS[0]); setNeededBy(""); setNotes(""); setError("");
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setPending(true);
|
|
setError("");
|
|
const fd = new FormData();
|
|
fd.set("rankId", rankId);
|
|
applyLocation(fd, location);
|
|
fd.set("reason", reason);
|
|
if (neededBy) fd.set("neededBy", neededBy);
|
|
if (notes) fd.set("notes", notes);
|
|
|
|
const result = await raiseRequisition(fd);
|
|
setPending(false);
|
|
if ("error" in result) {
|
|
setError(result.error);
|
|
} else {
|
|
setOpen(false);
|
|
reset();
|
|
router.refresh();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
|
|
>
|
|
+ Raise requisition
|
|
</button>
|
|
<AdminDialog title="Raise requisition" open={open} onClose={() => setOpen(false)}>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
|
|
<select className={INPUT} value={rankId} onChange={(e) => setRankId(e.target.value)} required>
|
|
<option value="">— Select rank —</option>
|
|
{ranks.map((r) => (
|
|
<option key={r.id} value={r.id}>{r.code} — {r.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel / site *</label>
|
|
<LocationSelect value={location} onChange={setLocation} vessels={vessels} sites={sites} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
|
|
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
|
{REASON_OPTIONS.map((r) => (
|
|
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
|
|
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
|
|
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
|
|
</div>
|
|
</div>
|
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
|
<div className="flex justify-end gap-3 pt-1">
|
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
|
{pending ? "Raising…" : "Raise requisition"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</AdminDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Convert a relief request into a requisition (MPO / Manager) ─────────────────
|
|
|
|
export function ConvertReliefButton({
|
|
reliefRequestId,
|
|
label,
|
|
}: {
|
|
reliefRequestId: string;
|
|
label: string;
|
|
}) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [reason, setReason] = useState<typeof REASON_OPTIONS[number]>("REPLACEMENT");
|
|
const [neededBy, setNeededBy] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setPending(true);
|
|
setError("");
|
|
const fd = new FormData();
|
|
fd.set("reliefRequestId", reliefRequestId);
|
|
fd.set("reason", reason);
|
|
if (neededBy) fd.set("neededBy", neededBy);
|
|
if (notes) fd.set("notes", notes);
|
|
|
|
const result = await convertReliefToRequisition(fd);
|
|
setPending(false);
|
|
if ("error" in result) {
|
|
setError(result.error);
|
|
} else {
|
|
setOpen(false);
|
|
router.refresh();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
|
|
>
|
|
Open
|
|
</button>
|
|
<AdminDialog title="Convert to requisition" open={open} onClose={() => setOpen(false)}>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<p className="text-sm text-neutral-600">
|
|
Convert the relief request <span className="font-medium text-neutral-900">{label}</span> into an open
|
|
requisition so sourcing can begin.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
|
|
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
|
{REASON_OPTIONS.map((r) => (
|
|
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
|
|
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
|
|
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
|
|
</div>
|
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
|
<div className="flex justify-end gap-3 pt-1">
|
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
|
{pending ? "Converting…" : "Convert"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</AdminDialog>
|
|
</>
|
|
);
|
|
}
|