Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
2348fdabe5 Merge pull request 'feat(crewing): Phase 2 — requisitions + relief requests (flagged)' (#65) from feat/crewing-requisitions into feat/crewing-foundations
Reviewed-on: #65
2026-06-22 18:48:04 +00:00
feac86e3a3 Merge branch 'feat/crewing-foundations' into feat/crewing-requisitions
All checks were successful
PR checks / checks (pull_request) Successful in 36s
PR checks / integration (pull_request) Successful in 29s
2026-06-22 18:46:58 +00:00
0b2ed9ac07 feat(crewing): Phase 2 — requisitions + relief requests (flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 37s
PR checks / integration (pull_request) Successful in 28s
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>
2026-06-22 18:22:59 +05:30
17 changed files with 2042 additions and 71 deletions

View file

@ -120,13 +120,21 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at
### Crewing (feature-flagged) ### Crewing (feature-flagged)
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12); only the **Foundations** layer ships so far: A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12). **Foundations** and **Requisitions** ship so far:
- **Role:** `SITE_STAFF` (the new `Role` enum member) — PM / Assistant PM / Site In-charge log in as site staff and act on behalf of crew. MPO is `MANNING`. - **Role:** `SITE_STAFF` (the new `Role` enum member) — PM / Assistant PM / Site In-charge log in as site staff and act on behalf of crew. MPO is `MANNING`.
- **Permissions:** `lib/permissions.ts` holds the full crewing grant matrix (spec §6) as the source of truth — `PO_ROLE_PERMISSIONS` + `CREWING_ROLE_PERMISSIONS` are merged into `ROLE_PERMISSIONS`. Notable rules: MPO has **no** attendance/leave; `decide_leave`/`approve_*`/`select_candidate` are Manager-only; `manage_ranks` is Manager + Admin. - **Permissions:** `lib/permissions.ts` holds the full crewing grant matrix (spec §6) as the source of truth — `PO_ROLE_PERMISSIONS` + `CREWING_ROLE_PERMISSIONS` are merged into `ROLE_PERMISSIONS`. Notable rules: MPO has **no** attendance/leave; `decide_leave`/`approve_*`/`select_candidate` are Manager-only; `manage_ranks` is Manager + Admin.
- **Reference data:** `Rank` is a self-referential org-chart hierarchy (like `Account`), seeded from `prisma/rank-data.ts`; `RankDocRequirement` (seeded from `prisma/rank-doc-data.ts`) lists the documents each rank must hold. Both seed via the shared `prisma/seed-ranks.ts` in dev **and** prod seeds. `Rank.grantsLogin` is true only for the three management ranks. - **Reference data:** `Rank` is a self-referential org-chart hierarchy (like `Account`), seeded from `prisma/rank-data.ts`; `RankDocRequirement` (seeded from `prisma/rank-doc-data.ts`) lists the documents each rank must hold. Both seed via the shared `prisma/seed-ranks.ts` in dev **and** prod seeds. `Rank.grantsLogin` is true only for the three management ranks.
- **Admin screen:** `/admin/ranks` ("Ranks & documents", gated by `manage_ranks` + the flag) — the rank hierarchy card + per-rank required-documents card. - **Admin screen:** `/admin/ranks` ("Ranks & documents", gated by `manage_ranks` + the flag) — the rank hierarchy card + per-rank required-documents card.
- The sidebar has a flag-gated **Crewing** section scaffold (`CREWING_ITEMS`, empty until later phases) and the Ranks link under Administration.
**Phase 2 — Requisitions + relief (spec §5.2/§8.28.3):**
- **Models:** `Requisition` (lifecycle `OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED`, `→ CANCELLED`), `ReliefRequest` (site-flagged gap the office converts), and `CrewAction` (the crewing audit trail — the `POAction` mirror). `Requisition.autoRaised` marks system-raised vacancies.
- **State machine:** `lib/requisition-state-machine.ts` mirrors `po-state-machine.ts` (`TRANSITIONS`, `canPerformAction`, `getAvailableActions`; orthogonal `CANCEL_ROLES`/`canCancel`). Final selection is Manager-only; withdraw is allowed from OPEN/SHORTLISTING by `cancel_requisition` holders (MPO + Manager, per §6). Codes (`REQ-9000…`) come from `lib/requisition-number.ts`.
- **Actions** (`app/(portal)/crewing/requisitions/actions.ts`): `raiseRequisition`, `cancelRequisition`, `transitionRequisition`, `requestReliefCover`, `convertReliefToRequisition` — each guards flag + permission + state, writes a `CrewAction`, and notifies via `notifyCrew`. The shared `autoRaiseRequisition()` in `lib/requisition-service.ts` is the backfill entry point sign-off / leave-clash (later phases) will call.
- **Screens:** `/crewing/requisitions` (list + Raise modal + "Relief requests from sites" convert) and `/crewing/requisitions/[id]` (detail; the recruitment pipeline is a later phase). **Requisitions** is in the flag-gated sidebar **Crewing** section (`CREWING_ITEMS`, Manager + MPO). The Ranks link stays under Administration.
- **Notifications:** `lib/notifier.ts` `notifyCrew()` is the PO-independent path (writes `Notification` rows with a null `poId`); `CrewNotificationEvent` covers `REQUISITION_RAISED` / `RELIEF_REQUESTED` / `RELIEF_CONVERTED`.
- **Deferred:** sign-off / experience-record (Epic K) is part of spec §12 item 2 but depends on the crew/assignment models from Phase 3/4, so it lands with those. `autoRaiseRequisition()` is already in place for it.
### GST Calculation ### GST Calculation

View file

@ -0,0 +1,124 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { canCancel } from "@/lib/requisition-state-machine";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { WithdrawRequisitionButton } from "./withdraw-button";
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "../requisition-ui";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Requisition" };
export default async function RequisitionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "view_requisitions")) redirect("/dashboard");
const { id } = await params;
const req = await db.requisition.findUnique({
where: { id },
include: {
rank: { select: { name: true, code: true } },
vessel: { select: { name: true } },
site: { select: { name: true } },
raisedBy: { select: { name: true } },
sourceReliefRequest: { select: { id: true, requestedBy: { select: { name: true } } } },
},
});
if (!req) notFound();
const location = req.vessel?.name ?? req.site?.name ?? "—";
const canWithdraw = hasPermission(session.user.role, "cancel_requisition") && canCancel(req.status, session.user.role);
const details: [string, string][] = [
["Requisition", req.code],
["Rank", `${req.rank.name} (${req.rank.code})`],
["Vessel / site", location],
["Reason", REASON_LABEL[req.reason]],
["Raised by", req.autoRaised ? "System (auto-raised)" : req.raisedBy?.name ?? "—"],
["Raised", `${ageLabel(req.createdAt.toISOString())} ago`],
["Needed by", req.neededBy ? req.neededBy.toLocaleDateString() : "—"],
];
if (req.status === "CANCELLED" && req.cancellationReason) {
details.push(["Withdrawn", req.cancellationReason]);
}
return (
<div className="max-w-4xl">
<Link href="/crewing/requisitions" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Requisitions
</Link>
<div className="mb-6 flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{req.rank.name} {location}</h1>
<Badge variant={STATUS_VARIANT[req.status]}>{STATUS_LABEL[req.status]}</Badge>
</div>
<p className="text-sm text-neutral-500 mt-1">
<span className="font-mono">{req.code}</span> · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago
</p>
</div>
{canWithdraw && <WithdrawRequisitionButton id={req.id} />}
</div>
{req.autoRaised && (
<div className="mb-6 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-800">
This requisition was <strong>auto-raised by the system</strong> ({REASON_LABEL[req.reason]}). No manual action
was needed to open it.
</div>
)}
{req.sourceReliefRequest && (
<div className="mb-6 rounded-lg border border-primary-200 bg-primary-50 px-4 py-3 text-sm text-primary-800">
Converted from a relief request raised by{" "}
<strong>{req.sourceReliefRequest.requestedBy.name}</strong>.
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Vacancy details */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Vacancy details</h2>
</div>
<dl className="divide-y divide-neutral-100">
{details.map(([k, v]) => (
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
<dt className="text-sm text-neutral-500">{k}</dt>
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
</div>
))}
</dl>
{req.notes && (
<div className="px-4 py-3 border-t border-neutral-100">
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
<p className="text-sm text-neutral-700">{req.notes}</p>
</div>
)}
</div>
{/* Candidates — populated by the recruitment pipeline (Phase 3) */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Candidates</h2>
</div>
<p className="px-4 py-12 text-center text-sm text-neutral-400">
The recruitment pipeline arrives in a later phase. Candidates attached to this
requisition will appear here.
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { cancelRequisition } from "../actions";
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";
export function WithdrawRequisitionButton({ id }: { id: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [reason, setReason] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const result = await cancelRequisition(id, reason);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
router.refresh();
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50"
>
Withdraw
</button>
<AdminDialog title="Withdraw requisition" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-neutral-600">
Withdrawing closes this requisition. A reason is required and is recorded on the audit trail.
</p>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason *</label>
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required />
</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-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">
{pending ? "Withdrawing…" : "Withdraw requisition"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,303 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import {
canCancel,
canPerformAction,
getTransition,
type RequisitionAction,
} from "@/lib/requisition-state-machine";
import {
createRequisitionTx,
getMpoRecipients,
getOfficeRecipients,
requisitionLocationLabel,
} from "@/lib/requisition-service";
import { notifyCrew } from "@/lib/notifier";
import { RequisitionReason } from "@prisma/client";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const LIST_PATH = "/crewing/requisitions";
// Crewing flag + permission guard. Returns the actor on success.
async function guard(
permission: Permission
): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
// ── Raise a requisition (MPO / Manager) ───────────────────────────────────────
const raiseSchema = z
.object({
rankId: z.string().min(1, "Rank is required"),
vesselId: z.string().optional(),
siteId: z.string().optional(),
reason: z.nativeEnum(RequisitionReason).default("NEW_VACANCY"),
neededBy: z.string().optional(),
notes: z.string().optional(),
})
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), {
message: "A vessel or site is required",
});
export async function raiseRequisition(formData: FormData): Promise<ActionResult> {
const g = await guard("raise_requisition");
if ("error" in g) return g;
const parsed = raiseSchema.safeParse({
rankId: formData.get("rankId"),
vesselId: (formData.get("vesselId") as string) || undefined,
siteId: (formData.get("siteId") as string) || undefined,
reason: (formData.get("reason") as string) || undefined,
neededBy: (formData.get("neededBy") as string) || undefined,
notes: (formData.get("notes") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const requisition = await db.$transaction((tx) =>
createRequisitionTx(tx, {
rankId: d.rankId,
vesselId: d.vesselId || null,
siteId: d.siteId || null,
reason: d.reason,
neededBy: d.neededBy ? new Date(d.neededBy) : null,
notes: d.notes || null,
raisedById: g.userId,
})
);
// Notify the MPO pool so it can start sourcing (spec §11). Don't self-notify.
const recipients = (await getMpoRecipients()).filter((u) => u.id !== g.userId);
if (recipients.length) {
const loc = requisitionLocationLabel(requisition);
await notifyCrew({
event: "REQUISITION_RAISED",
recipients,
subject: `Requisition ${requisition.code} raised`,
body: `A ${requisition.rank.name} vacancy on ${loc} has been raised (${requisition.code}).`,
link: `${LIST_PATH}/${requisition.id}`,
});
}
revalidatePath(LIST_PATH);
return { ok: true, id: requisition.id };
}
// ── Withdraw / cancel a requisition (Manager, from OPEN/SHORTLISTING) ──────────
export async function cancelRequisition(id: string, reason: string): Promise<ActionResult> {
const g = await guard("cancel_requisition");
if ("error" in g) return g;
const trimmed = reason?.trim();
if (!trimmed) return { error: "A reason is required to withdraw a requisition" };
const req = await db.requisition.findUnique({ where: { id }, select: { status: true } });
if (!req) return { error: "Requisition not found" };
if (!canCancel(req.status, g.role)) {
return { error: `A requisition cannot be withdrawn once it is ${req.status}` };
}
await db.requisition.update({
where: { id },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
cancellationReason: trimmed,
actions: {
create: { actionType: "REQUISITION_CANCELLED", actorId: g.userId, note: trimmed },
},
},
});
revalidatePath(LIST_PATH);
revalidatePath(`${LIST_PATH}/${id}`);
return { ok: true };
}
// ── Advance a requisition through the pipeline stages ──────────────────────────
// Phase 2 exposes the transitions; the recruitment pipeline (Phase 3) drives
// them as candidates progress. Role gating comes from the state machine.
export async function transitionRequisition(
id: string,
action: RequisitionAction
): Promise<ActionResult> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const req = await db.requisition.findUnique({ where: { id }, select: { status: true } });
if (!req) return { error: "Requisition not found" };
const transition = getTransition(req.status, action);
if (!transition) return { error: `Cannot ${action} from ${req.status}` };
if (!canPerformAction(req.status, action, session.user.role)) return { error: "Unauthorized" };
await db.requisition.update({
where: { id },
data: {
status: transition.to,
filledAt: transition.to === "FILLED" ? new Date() : undefined,
actions: {
create: {
actionType: transition.to === "FILLED" ? "REQUISITION_FILLED" : "REQUISITION_ADVANCED",
actorId: session.user.id,
metadata: { from: req.status, to: transition.to },
},
},
},
});
revalidatePath(LIST_PATH);
revalidatePath(`${LIST_PATH}/${id}`);
return { ok: true };
}
// ── Relief cover request (site staff) ──────────────────────────────────────────
// Site staff flag a foreseen gap; the office converts it into a requisition. The
// site-staff origination UI lands with the Leave/clash screen (Phase 4); the
// action exists now so the office-side convert flow and auto-raise share a path.
const reliefSchema = z
.object({
rankId: z.string().min(1, "Rank is required"),
vesselId: z.string().optional(),
siteId: z.string().optional(),
note: z.string().optional(),
})
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), {
message: "A vessel or site is required",
});
export async function requestReliefCover(formData: FormData): Promise<ActionResult> {
const g = await guard("request_relief_cover");
if ("error" in g) return g;
const parsed = reliefSchema.safeParse({
rankId: formData.get("rankId"),
vesselId: (formData.get("vesselId") as string) || undefined,
siteId: (formData.get("siteId") as string) || undefined,
note: (formData.get("note") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const relief = await db.$transaction(async (tx) => {
const created = await tx.reliefRequest.create({
data: {
rankId: d.rankId,
vesselId: d.vesselId || null,
siteId: d.siteId || null,
note: d.note || null,
requestedById: g.userId,
},
include: { rank: true, vessel: true, site: true },
});
// CrewAction has no relief relation; record the id in metadata.
await tx.crewAction.create({
data: {
actionType: "RELIEF_REQUESTED",
actorId: g.userId,
metadata: { reliefRequestId: created.id, rankId: d.rankId },
},
});
return created;
});
const recipients = await getOfficeRecipients();
if (recipients.length) {
const loc = requisitionLocationLabel(relief);
await notifyCrew({
event: "RELIEF_REQUESTED",
recipients,
subject: `Relief cover requested — ${relief.rank.name} on ${loc}`,
body: `A site has requested relief cover for a ${relief.rank.name} on ${loc}. Convert it to a requisition to start sourcing.`,
link: LIST_PATH,
});
}
revalidatePath(LIST_PATH);
return { ok: true, id: relief.id };
}
// ── Convert a relief request into a requisition (MPO / Manager) ────────────────
const convertSchema = z.object({
reliefRequestId: z.string().min(1, "Relief request is required"),
reason: z.nativeEnum(RequisitionReason).default("REPLACEMENT"),
neededBy: z.string().optional(),
notes: z.string().optional(),
});
export async function convertReliefToRequisition(formData: FormData): Promise<ActionResult> {
const g = await guard("convert_relief_to_requisition");
if ("error" in g) return g;
const parsed = convertSchema.safeParse({
reliefRequestId: formData.get("reliefRequestId"),
reason: (formData.get("reason") as string) || undefined,
neededBy: (formData.get("neededBy") as string) || undefined,
notes: (formData.get("notes") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const relief = await db.reliefRequest.findUnique({ where: { id: d.reliefRequestId } });
if (!relief) return { error: "Relief request not found" };
if (relief.status !== "OPEN") return { error: "This relief request has already been handled" };
const requisition = await db.$transaction(async (tx) => {
const req = await createRequisitionTx(tx, {
rankId: relief.rankId,
vesselId: relief.vesselId,
siteId: relief.siteId,
reason: d.reason,
neededBy: d.neededBy ? new Date(d.neededBy) : null,
notes: d.notes || null,
raisedById: g.userId,
});
await tx.reliefRequest.update({
where: { id: relief.id },
data: { status: "CONVERTED", convertedRequisitionId: req.id },
});
await tx.crewAction.create({
data: {
actionType: "RELIEF_CONVERTED",
actorId: g.userId,
requisitionId: req.id,
metadata: { reliefRequestId: relief.id },
},
});
return req;
});
// Let the requester know their relief request became a requisition.
const requester = await db.user.findUnique({ where: { id: relief.requestedById } });
if (requester && requester.isActive && requester.id !== g.userId) {
const loc = requisitionLocationLabel(requisition);
await notifyCrew({
event: "RELIEF_CONVERTED",
recipients: [requester],
subject: `Relief cover converted — ${requisition.code}`,
body: `Your relief request for a ${requisition.rank.name} on ${loc} is now requisition ${requisition.code}.`,
link: `${LIST_PATH}/${requisition.id}`,
});
}
revalidatePath(LIST_PATH);
return { ok: true, id: requisition.id };
}

View file

@ -0,0 +1,78 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { RequisitionsManager } from "./requisitions-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Requisitions" };
export default async function RequisitionsPage() {
// Dark unless the crewing module is switched on.
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "view_requisitions")) redirect("/dashboard");
const role = session.user.role;
const [requisitions, reliefRequests, ranks, vessels, sites] = await Promise.all([
db.requisition.findMany({
orderBy: { createdAt: "desc" },
include: {
rank: { select: { name: true } },
vessel: { select: { name: true } },
site: { select: { name: true } },
raisedBy: { select: { name: true } },
},
}),
db.reliefRequest.findMany({
where: { status: "OPEN" },
orderBy: { createdAt: "desc" },
include: {
rank: { select: { name: true } },
vessel: { select: { name: true } },
site: { select: { name: true } },
requestedBy: { select: { name: true } },
},
}),
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Flatten to plain props — no Date/Decimal crosses the server→client boundary.
const rows = requisitions.map((r) => ({
id: r.id,
code: r.code,
status: r.status,
reason: r.reason,
autoRaised: r.autoRaised,
rankName: r.rank.name,
location: r.vessel?.name ?? r.site?.name ?? "—",
raisedBy: r.raisedBy?.name ?? "System",
createdAt: r.createdAt.toISOString(),
}));
const relief = reliefRequests.map((r) => ({
id: r.id,
rankName: r.rank.name,
location: r.vessel?.name ?? r.site?.name ?? "—",
note: r.note,
requestedBy: r.requestedBy.name,
createdAt: r.createdAt.toISOString(),
}));
return (
<RequisitionsManager
requisitions={rows}
reliefRequests={relief}
ranks={ranks}
vessels={vessels}
sites={sites}
canRaise={hasPermission(role, "raise_requisition")}
canConvert={hasPermission(role, "convert_relief_to_requisition")}
/>
);
}

View file

@ -0,0 +1,242 @@
"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>
</>
);
}

View file

@ -0,0 +1,52 @@
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`;
}

View file

@ -0,0 +1,204 @@
"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>
);
}

View file

@ -25,6 +25,7 @@ import {
UserCircle, UserCircle,
ShieldCheck, ShieldCheck,
Network, Network,
ClipboardList,
} from "lucide-react"; } from "lucide-react";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -69,11 +70,15 @@ const PURCHASING_MGMT: NavItem[] = [
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT]; const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
// ── Crewing section (feature-flagged) ───────────────────────────────────────── // ── Crewing section (feature-flagged) ─────────────────────────────────────────
// Scaffold for the Crewing module. Phase 1 (Foundations) adds no top-level items // Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
// here — its only screen, "Ranks & documents", lives under Administration. Later // Crewing-Implementation-Spec §7); later phases append Candidates / Crew / Leave
// phases append Requisitions / Candidates / Crew / Leave / Attendance / // / Attendance / Verification with their per-role visibility. "Ranks & documents"
// Verification with their per-role visibility (see Crewing-Implementation-Spec §7). // lives under Administration.
const CREWING_ITEMS: NavItem[] = []; const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
? [
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
]
: [];
// ── Administration section ──────────────────────────────────────────────────── // ── Administration section ────────────────────────────────────────────────────
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header // Vendors shown to MANAGER / ACCOUNTS under their own Administration header

View file

@ -21,6 +21,15 @@ export type NotificationEvent =
| "RECEIPT_CONFIRMED" | "RECEIPT_CONFIRMED"
| "PARTIAL_RECEIPT_CONFIRMED"; | "PARTIAL_RECEIPT_CONFIRMED";
// Crewing notification events (Crewing-Implementation-Spec §4.5/§11). These are
// not tied to a PurchaseOrder, so they go through notifyCrew() and store a
// Notification row with a null poId. Extended per phase; Phase 2 covers
// requisitions + relief.
export type CrewNotificationEvent =
| "REQUISITION_RAISED"
| "RELIEF_REQUESTED"
| "RELIEF_CONVERTED";
interface NotifyParams { interface NotifyParams {
event: NotificationEvent; event: NotificationEvent;
po: PurchaseOrder & { submitter: User }; po: PurchaseOrder & { submitter: User };
@ -398,3 +407,99 @@ function buildHtml(
</body> </body>
</html>`; </html>`;
} }
// ── Crewing notifications ──────────────────────────────────────────────────────
// A PO-independent path: callers compose the subject/body/link (which embed the
// crewing entity details) and pick recipients. Mirrors notify()'s dev-console /
// Resend / Notification-row behaviour, but writes rows with a null poId.
interface CrewNotifyParams {
event: CrewNotificationEvent;
recipients: User[];
subject: string;
body: string;
link?: string;
}
const CREW_ACTION_LABEL: Record<CrewNotificationEvent, string> = {
REQUISITION_RAISED: "View Requisition",
RELIEF_REQUESTED: "View Requisitions",
RELIEF_CONVERTED: "View Requisition",
};
export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {
await Promise.allSettled(
recipients.map(async (recipient) => {
let status = "sent";
if (isDev) {
console.log(
`\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${body}\n Link: ${APP_URL}${link ?? ""}\n`
);
} else {
try {
const { error } = await resend!.emails.send({
from: FROM,
to: recipient.email,
subject,
html: buildCrewHtml(event, recipient, subject, body, link),
});
if (error) status = "failed";
} catch {
status = "failed";
}
}
await db.notification.create({
data: { subject, body, link: link ?? null, status, userId: recipient.id },
});
})
);
}
function buildCrewHtml(
event: CrewNotificationEvent,
recipient: User,
subject: string,
body: string,
link?: string
): string {
const actionUrl = link ? `${APP_URL}${link}` : APP_URL;
const actionLabel = CREW_ACTION_LABEL[event] ?? "Open PPMS";
return `<!DOCTYPE html>
<html>
<head><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,-apple-system,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 16px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
<!-- Header -->
<tr><td style="background:#1d4ed8;padding:20px 32px;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">PPMS</span>
<span style="font-size:13px;color:#93c5fd;margin-left:10px;">Crewing</span>
</td></tr>
<!-- Body -->
<tr><td style="padding:32px;">
<p style="margin:0 0 20px;font-size:15px;color:#111827;">Hi ${recipient.name},</p>
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">${body}</p>
<table cellpadding="0" cellspacing="0">
<tr><td style="background:#2563eb;border-radius:8px;">
<a href="${actionUrl}" style="display:inline-block;padding:12px 24px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${actionLabel} </a>
</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="background:#f8fafc;border-top:1px solid #e5e7eb;padding:16px 32px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">${subject}</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}

View file

@ -0,0 +1,34 @@
/**
* Requisition code generator. Format: REQ-<id>, e.g. REQ-9000.
*
* The id is a globally sequential integer floored at 9000 (mirroring the PO
* numbering convention in lib/po-number.ts) so generated codes never collide
* with any future imported/historical numbering. Call inside the same
* transaction that creates the requisition to minimise race windows.
*/
import { db } from "@/lib/db";
import type { Prisma } from "@prisma/client";
const PREFIX = "REQ-";
const FLOOR = 8999; // first generated id is 9000
/** Next sequential requisition id by scanning existing REQ- codes. */
async function nextRequisitionId(client: Prisma.TransactionClient | typeof db): Promise<number> {
const rows = await client.requisition.findMany({ select: { code: true } });
let maxId = FLOOR;
for (const { code } of rows) {
if (!code.startsWith(PREFIX)) continue;
const n = parseInt(code.slice(PREFIX.length), 10);
if (!isNaN(n) && n > maxId) maxId = n;
}
return maxId + 1;
}
/** Generate the next requisition code (e.g. "REQ-9000"). */
export async function generateRequisitionCode(
client: Prisma.TransactionClient | typeof db = db
): Promise<string> {
const id = await nextRequisitionId(client);
return `${PREFIX}${id}`;
}

View file

@ -0,0 +1,108 @@
/**
* Requisition service helpers shared by the crewing server actions and by the
* system auto-raise paths (sign-off / end-of-contract / leave-clash backfill,
* Phase 3/4). Kept out of the "use server" action module so non-action code can
* import the auto-raise helper. See Crewing-Implementation-Spec §5.2/§5.3 (R6).
*/
import { db } from "@/lib/db";
import { generateRequisitionCode } from "@/lib/requisition-number";
import { notifyCrew } from "@/lib/notifier";
import type { Prisma, RequisitionReason, User } from "@prisma/client";
type Tx = Prisma.TransactionClient;
export interface NewRequisitionInput {
rankId: string;
vesselId?: string | null;
siteId?: string | null;
reason: RequisitionReason;
neededBy?: Date | null;
notes?: string | null;
raisedById?: string | null; // null = system-raised
autoRaised?: boolean;
}
type RequisitionWithRefs = Prisma.RequisitionGetPayload<{
include: { rank: true; vessel: true; site: true };
}>;
/**
* Core requisition creator run inside a transaction. Generates the code and
* writes the REQUISITION_RAISED CrewAction. Callers own notification + any
* relief-request linking afterwards.
*/
export async function createRequisitionTx(
tx: Tx,
input: NewRequisitionInput
): Promise<RequisitionWithRefs> {
const code = await generateRequisitionCode(tx);
return tx.requisition.create({
data: {
code,
reason: input.reason,
autoRaised: input.autoRaised ?? false,
neededBy: input.neededBy ?? null,
notes: input.notes ?? null,
rankId: input.rankId,
vesselId: input.vesselId ?? null,
siteId: input.siteId ?? null,
raisedById: input.raisedById ?? null,
actions: {
create: {
actionType: "REQUISITION_RAISED",
actorId: input.raisedById ?? null,
metadata: input.autoRaised ? { auto: true, reason: input.reason } : undefined,
},
},
},
include: { rank: true, vessel: true, site: true },
});
}
/** Human label for a requisition's cost axis (vessel preferred, else site). */
export function requisitionLocationLabel(r: {
vessel: { name: string } | null;
site: { name: string } | null;
}): string {
return r.vessel?.name ?? r.site?.name ?? "—";
}
/** Office recipients (MPO sources recruitment; Manager oversees). */
export function getOfficeRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANNING", "MANAGER", "SUPERUSER"] } },
});
}
/** MPO recipients — for "requisition raised → MPO" (spec §11). */
export function getMpoRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANNING", "SUPERUSER"] } },
});
}
/**
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
* (later phases) all funnel through here. See spec §5.2/§5.3 (R6).
*/
export async function autoRaiseRequisition(
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">
): Promise<RequisitionWithRefs> {
const requisition = await db.$transaction((tx) =>
createRequisitionTx(tx, { ...input, raisedById: null, autoRaised: true })
);
const recipients = await getOfficeRecipients();
const loc = requisitionLocationLabel(requisition);
await notifyCrew({
event: "REQUISITION_RAISED",
recipients,
subject: `Requisition ${requisition.code} auto-raised`,
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
link: `/crewing/requisitions/${requisition.id}`,
});
return requisition;
}

View file

@ -0,0 +1,88 @@
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);
}

View file

@ -0,0 +1,101 @@
-- CreateEnum
CREATE TYPE "RequisitionStatus" AS ENUM ('OPEN', 'SHORTLISTING', 'PROPOSING', 'INTERVIEWING', 'SELECTED', 'FILLED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "RequisitionReason" AS ENUM ('NEW_VACANCY', 'REPLACEMENT', 'LEAVE', 'SIGN_OFF', 'END_OF_CONTRACT', 'OTHER');
-- CreateEnum
CREATE TYPE "ReliefRequestStatus" AS ENUM ('OPEN', 'CONVERTED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "CrewActionType" AS ENUM ('REQUISITION_RAISED', 'REQUISITION_ADVANCED', 'REQUISITION_FILLED', 'REQUISITION_CANCELLED', 'RELIEF_REQUESTED', 'RELIEF_CONVERTED', 'RELIEF_CANCELLED');
-- CreateTable
CREATE TABLE "Requisition" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"status" "RequisitionStatus" NOT NULL DEFAULT 'OPEN',
"reason" "RequisitionReason" NOT NULL DEFAULT 'NEW_VACANCY',
"autoRaised" BOOLEAN NOT NULL DEFAULT false,
"neededBy" TIMESTAMP(3),
"notes" TEXT,
"cancelledAt" TIMESTAMP(3),
"cancellationReason" TEXT,
"filledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"rankId" TEXT NOT NULL,
"vesselId" TEXT,
"siteId" TEXT,
"raisedById" TEXT,
CONSTRAINT "Requisition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReliefRequest" (
"id" TEXT NOT NULL,
"status" "ReliefRequestStatus" NOT NULL DEFAULT 'OPEN',
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"rankId" TEXT NOT NULL,
"vesselId" TEXT,
"siteId" TEXT,
"requestedById" TEXT NOT NULL,
"convertedRequisitionId" TEXT,
CONSTRAINT "ReliefRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CrewAction" (
"id" TEXT NOT NULL,
"actionType" "CrewActionType" NOT NULL,
"note" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actorId" TEXT,
"requisitionId" TEXT,
CONSTRAINT "CrewAction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Requisition_code_key" ON "Requisition"("code");
-- CreateIndex
CREATE UNIQUE INDEX "ReliefRequest_convertedRequisitionId_key" ON "ReliefRequest"("convertedRequisitionId");
-- AddForeignKey
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_raisedById_fkey" FOREIGN KEY ("raisedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_convertedRequisitionId_fkey" FOREIGN KEY ("convertedRequisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -87,6 +87,51 @@ enum SeafarerDocType {
CONTRACT_LETTER CONTRACT_LETTER
} }
// ─── Crewing lifecycle (Phase 2: Requisitions + relief) ─────────────────────
// Requisition lifecycle — Crewing-Implementation-Spec §5.2. The intermediate
// stages (SHORTLISTING…SELECTED) are advanced by the recruitment pipeline that
// lands in Phase 3; Phase 2 wires OPEN, CANCELLED and the FILLED terminal.
enum RequisitionStatus {
OPEN
SHORTLISTING
PROPOSING
INTERVIEWING
SELECTED
FILLED
CANCELLED
}
// Why a vacancy exists. LEAVE / SIGN_OFF / END_OF_CONTRACT are the system
// auto-raise reasons (§5.2/§5.3); the rest are raised manually by MPO/Manager.
enum RequisitionReason {
NEW_VACANCY
REPLACEMENT
LEAVE
SIGN_OFF
END_OF_CONTRACT
OTHER
}
// A foreseen-gap flag raised by site staff (§8.2 "Relief requests from sites").
// The office converts an OPEN relief request into a real requisition.
enum ReliefRequestStatus {
OPEN
CONVERTED
CANCELLED
}
// Crewing audit-trail action types — the CrewAction mirror of ActionType for
// POAction (§4.5/§11). Extended per phase; Phase 2 covers requisition + relief.
enum CrewActionType {
REQUISITION_RAISED
REQUISITION_ADVANCED
REQUISITION_FILLED
REQUISITION_CANCELLED
RELIEF_REQUESTED
RELIEF_CONVERTED
RELIEF_CANCELLED
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
employeeId String @unique employeeId String @unique
@ -105,6 +150,9 @@ model User {
consumption ItemConsumption[] consumption ItemConsumption[]
superUserRequests SuperUserRequest[] @relation("Requester") superUserRequests SuperUserRequest[] @relation("Requester")
resolvedRequests SuperUserRequest[] @relation("RequestResolver") resolvedRequests SuperUserRequest[] @relation("RequestResolver")
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
reliefRequested ReliefRequest[] @relation("ReliefRequester")
crewActions CrewAction[]
} }
model SuperUserRequest { model SuperUserRequest {
@ -133,6 +181,8 @@ model Site {
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
inventory ItemInventory[] inventory ItemInventory[]
consumption ItemConsumption[] consumption ItemConsumption[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
} }
model Vessel { model Vessel {
@ -142,6 +192,8 @@ model Vessel {
isActive Boolean @default(true) isActive Boolean @default(true)
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
} }
model Company { model Company {
@ -427,6 +479,8 @@ model Rank {
children Rank[] @relation("RankHierarchy") children Rank[] @relation("RankHierarchy")
docRequirements RankDocRequirement[] docRequirements RankDocRequirement[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
} }
// Which documents a rank is required (or conditionally required) to hold. // Which documents a rank is required (or conditionally required) to hold.
@ -442,3 +496,82 @@ model RankDocRequirement {
@@unique([rankId, docType]) @@unique([rankId, docType])
} }
// ─── Crewing lifecycle models (Phase 2) ──────────────────────────────────────
// A vacancy to be filled for a rank on a vessel/site. Raised manually by
// MPO/Manager, or auto-raised by the system on a leave clash / sign-off / EOC
// (autoRaised = true). The recruitment pipeline (Phase 3) attaches candidates
// and drives the intermediate stages. See Crewing-Implementation-Spec §5.2/§8.
model Requisition {
id String @id @default(cuid())
code String @unique // mono id, e.g. REQ-9000
status RequisitionStatus @default(OPEN)
reason RequisitionReason @default(NEW_VACANCY)
autoRaised Boolean @default(false)
neededBy DateTime?
notes String?
cancelledAt DateTime?
cancellationReason String?
filledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rankId String
rank Rank @relation(fields: [rankId], references: [id])
vesselId String?
vessel Vessel? @relation(fields: [vesselId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
// Null when the system auto-raised it.
raisedById String?
raisedBy User? @relation("RequisitionRaiser", fields: [raisedById], references: [id])
// The site relief request this requisition was converted from, if any.
sourceReliefRequest ReliefRequest? @relation("ReliefConversion")
actions CrewAction[]
}
// A foreseen-gap flag from a site (site staff), pending office conversion into a
// Requisition. Complementary, proactive channel to the auto-raised LEAVE
// requisition. See Crewing-Implementation-Spec §8.2 (R3/R6).
model ReliefRequest {
id String @id @default(cuid())
status ReliefRequestStatus @default(OPEN)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rankId String
rank Rank @relation(fields: [rankId], references: [id])
vesselId String?
vessel Vessel? @relation(fields: [vesselId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
requestedById String
requestedBy User @relation("ReliefRequester", fields: [requestedById], references: [id])
// Set when an MPO/Manager converts it; one relief request → one requisition.
convertedRequisitionId String? @unique
convertedRequisition Requisition? @relation("ReliefConversion", fields: [convertedRequisitionId], references: [id])
}
// Crewing audit trail — one row per transition / verification (mirror of
// POAction). Entity relations are added per phase; Phase 2 links requisitions.
model CrewAction {
id String @id @default(cuid())
actionType CrewActionType
note String?
metadata Json?
createdAt DateTime @default(now())
// Null for system-performed actions (auto-raise).
actorId String?
actor User? @relation(fields: [actorId], references: [id])
requisitionId String?
requisition Requisition? @relation(fields: [requisitionId], references: [id])
}

View file

@ -0,0 +1,246 @@
/**
* Integration tests for the Crewing Phase 2 requisition + relief server actions:
* raise / cancel / transition, relief request + convert, and the shared
* autoRaiseRequisition helper. Mirrors the admin-ranks test setup.
*
* The Requisition/ReliefRequest/CrewAction tables are introduced in this phase,
* so afterEach wipes them wholesale (no pre-existing rows to preserve).
*/
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import {
raiseRequisition,
cancelRequisition,
transitionRequisition,
requestReliefCover,
convertReliefToRequisition,
} from "@/app/(portal)/crewing/requisitions/actions";
import { autoRaiseRequisition } from "@/lib/requisition-service";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let manningId: string;
let siteStaffId: string;
let rankId: string;
let vesselId: string;
const SS_EMAIL = "sitestaff@itreq.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id;
manningId = (await getSeedUser("manning@pelagia.local")).id;
const ss = await db.user.upsert({
where: { email: SS_EMAIL },
update: { role: "SITE_STAFF", isActive: true },
create: { employeeId: "ITREQ-SS", email: SS_EMAIL, name: "Site Staff Test", role: "SITE_STAFF" },
});
siteStaffId = ss.id;
rankId = (await db.rank.findFirstOrThrow()).id;
vesselId = (await db.vessel.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.crewAction.deleteMany({});
await db.reliefRequest.deleteMany({});
await db.requisition.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("raiseRequisition", () => {
it("creates an OPEN requisition with a REQ- code and an audit action", async () => {
as(managerId, "MANAGER");
const res = await raiseRequisition(fd({ rankId, vesselId, reason: "NEW_VACANCY", notes: "Urgent" }));
expect("ok" in res && res.ok).toBe(true);
const req = await db.requisition.findFirstOrThrow({ include: { actions: true } });
expect(req.status).toBe("OPEN");
expect(req.code).toMatch(/^REQ-\d+$/);
expect(req.autoRaised).toBe(false);
expect(req.raisedById).toBe(managerId);
expect(req.actions).toHaveLength(1);
expect(req.actions[0].actionType).toBe("REQUISITION_RAISED");
});
it("requires a vessel or site", async () => {
as(managerId, "MANAGER");
const res = await raiseRequisition(fd({ rankId, reason: "NEW_VACANCY" }));
expect("error" in res).toBe(true);
expect(await db.requisition.count()).toBe(0);
});
it("is rejected for a role without raise_requisition (site staff)", async () => {
as(siteStaffId, "SITE_STAFF");
const res = await raiseRequisition(fd({ rankId, vesselId }));
expect(res).toEqual({ error: "Unauthorized" });
expect(await db.requisition.count()).toBe(0);
});
});
describe("cancelRequisition", () => {
it("a Manager withdraws an OPEN requisition with a reason", async () => {
as(managerId, "MANAGER");
await raiseRequisition(fd({ rankId, vesselId }));
const req = await db.requisition.findFirstOrThrow();
const res = await cancelRequisition(req.id, "Vacancy no longer needed");
expect("ok" in res && res.ok).toBe(true);
const after = await db.requisition.findUniqueOrThrow({ where: { id: req.id } });
expect(after.status).toBe("CANCELLED");
expect(after.cancellationReason).toBe("Vacancy no longer needed");
expect(after.cancelledAt).not.toBeNull();
});
it("requires a reason", async () => {
as(managerId, "MANAGER");
await raiseRequisition(fd({ rankId, vesselId }));
const req = await db.requisition.findFirstOrThrow();
const res = await cancelRequisition(req.id, " ");
expect("error" in res).toBe(true);
});
it("cannot withdraw once past shortlisting", async () => {
as(managerId, "MANAGER");
await raiseRequisition(fd({ rankId, vesselId }));
const req = await db.requisition.findFirstOrThrow();
await db.requisition.update({ where: { id: req.id }, data: { status: "INTERVIEWING" } });
const res = await cancelRequisition(req.id, "too late");
expect("error" in res).toBe(true);
expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("INTERVIEWING");
});
it("the MPO may also withdraw (holds cancel_requisition per §6)", async () => {
as(managerId, "MANAGER");
await raiseRequisition(fd({ rankId, vesselId }));
const req = await db.requisition.findFirstOrThrow();
as(manningId, "MANNING");
const res = await cancelRequisition(req.id, "sourced elsewhere");
expect("ok" in res && res.ok).toBe(true);
expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("CANCELLED");
});
it("is rejected for a role without cancel_requisition (site staff)", async () => {
as(managerId, "MANAGER");
await raiseRequisition(fd({ rankId, vesselId }));
const req = await db.requisition.findFirstOrThrow();
as(siteStaffId, "SITE_STAFF");
const res = await cancelRequisition(req.id, "nope");
expect(res).toEqual({ error: "Unauthorized" });
});
});
describe("transitionRequisition", () => {
it("Manager selects from INTERVIEWING; MPO cannot", async () => {
as(managerId, "MANAGER");
await raiseRequisition(fd({ rankId, vesselId }));
const req = await db.requisition.findFirstOrThrow();
await db.requisition.update({ where: { id: req.id }, data: { status: "INTERVIEWING" } });
as(manningId, "MANNING");
expect(await transitionRequisition(req.id, "mark_selected")).toEqual({ error: "Unauthorized" });
as(managerId, "MANAGER");
const ok = await transitionRequisition(req.id, "mark_selected");
expect("ok" in ok && ok.ok).toBe(true);
expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("SELECTED");
});
it("marks FILLED and stamps filledAt", async () => {
as(managerId, "MANAGER");
await raiseRequisition(fd({ rankId, vesselId }));
const req = await db.requisition.findFirstOrThrow();
await db.requisition.update({ where: { id: req.id }, data: { status: "SELECTED" } });
as(manningId, "MANNING");
const res = await transitionRequisition(req.id, "mark_filled");
expect("ok" in res && res.ok).toBe(true);
const after = await db.requisition.findUniqueOrThrow({ where: { id: req.id }, include: { actions: true } });
expect(after.status).toBe("FILLED");
expect(after.filledAt).not.toBeNull();
expect(after.actions.some((a) => a.actionType === "REQUISITION_FILLED")).toBe(true);
});
});
describe("relief requests", () => {
it("site staff raise an OPEN relief request with an audit action", async () => {
as(siteStaffId, "SITE_STAFF");
const res = await requestReliefCover(fd({ rankId, vesselId, note: "Chief going on leave" }));
expect("ok" in res && res.ok).toBe(true);
const relief = await db.reliefRequest.findFirstOrThrow();
expect(relief.status).toBe("OPEN");
expect(relief.requestedById).toBe(siteStaffId);
const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "RELIEF_REQUESTED" } });
expect((action.metadata as { reliefRequestId: string }).reliefRequestId).toBe(relief.id);
});
it("is rejected for the MPO (no request_relief_cover)", async () => {
as(manningId, "MANNING");
const res = await requestReliefCover(fd({ rankId, vesselId }));
expect(res).toEqual({ error: "Unauthorized" });
expect(await db.reliefRequest.count()).toBe(0);
});
it("MPO converts a relief request into a requisition and links them", async () => {
as(siteStaffId, "SITE_STAFF");
await requestReliefCover(fd({ rankId, vesselId, note: "cover" }));
const relief = await db.reliefRequest.findFirstOrThrow();
as(manningId, "MANNING");
const res = await convertReliefToRequisition(fd({ reliefRequestId: relief.id, reason: "REPLACEMENT" }));
expect("ok" in res && res.ok).toBe(true);
const after = await db.reliefRequest.findUniqueOrThrow({ where: { id: relief.id } });
expect(after.status).toBe("CONVERTED");
expect(after.convertedRequisitionId).not.toBeNull();
const req = await db.requisition.findUniqueOrThrow({
where: { id: after.convertedRequisitionId! },
include: { actions: true, sourceReliefRequest: true },
});
expect(req.status).toBe("OPEN");
expect(req.reason).toBe("REPLACEMENT");
expect(req.sourceReliefRequest?.id).toBe(relief.id);
expect(req.actions.some((a) => a.actionType === "RELIEF_CONVERTED")).toBe(true);
});
it("refuses to convert an already-handled relief request", async () => {
as(siteStaffId, "SITE_STAFF");
await requestReliefCover(fd({ rankId, vesselId }));
const relief = await db.reliefRequest.findFirstOrThrow();
as(manningId, "MANNING");
await convertReliefToRequisition(fd({ reliefRequestId: relief.id }));
const second = await convertReliefToRequisition(fd({ reliefRequestId: relief.id }));
expect("error" in second).toBe(true);
});
});
describe("autoRaiseRequisition (shared helper)", () => {
it("creates an autoRaised OPEN requisition with no human actor", async () => {
const req = await autoRaiseRequisition({ rankId, vesselId, reason: "LEAVE" });
const stored = await db.requisition.findUniqueOrThrow({ where: { id: req.id }, include: { actions: true } });
expect(stored.autoRaised).toBe(true);
expect(stored.raisedById).toBeNull();
expect(stored.reason).toBe("LEAVE");
expect(stored.status).toBe("OPEN");
expect(stored.actions[0].actionType).toBe("REQUISITION_RAISED");
expect(stored.actions[0].actorId).toBeNull();
});
});

View file

@ -0,0 +1,78 @@
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);
});
});
});