Compare commits
3 commits
master
...
feat/crewi
| Author | SHA1 | Date | |
|---|---|---|---|
| 2348fdabe5 | |||
| feac86e3a3 | |||
| 0b2ed9ac07 |
17 changed files with 2042 additions and 71 deletions
|
|
@ -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.2–8.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
|
||||||
|
|
||||||
|
|
|
||||||
124
App/app/(portal)/crewing/requisitions/[id]/page.tsx
Normal file
124
App/app/(portal)/crewing/requisitions/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
App/app/(portal)/crewing/requisitions/actions.ts
Normal file
303
App/app/(portal)/crewing/requisitions/actions.ts
Normal 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 };
|
||||||
|
}
|
||||||
78
App/app/(portal)/crewing/requisitions/page.tsx
Normal file
78
App/app/(portal)/crewing/requisitions/page.tsx
Normal 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")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
App/app/(portal)/crewing/requisitions/requisition-form.tsx
Normal file
242
App/app/(portal)/crewing/requisitions/requisition-form.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
App/app/(portal)/crewing/requisitions/requisition-ui.ts
Normal file
52
App/app/(portal)/crewing/requisitions/requisition-ui.ts
Normal 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`;
|
||||||
|
}
|
||||||
204
App/app/(portal)/crewing/requisitions/requisitions-manager.tsx
Normal file
204
App/app/(portal)/crewing/requisitions/requisitions-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>`;
|
||||||
|
}
|
||||||
|
|
|
||||||
34
App/lib/requisition-number.ts
Normal file
34
App/lib/requisition-number.ts
Normal 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}`;
|
||||||
|
}
|
||||||
108
App/lib/requisition-service.ts
Normal file
108
App/lib/requisition-service.ts
Normal 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;
|
||||||
|
}
|
||||||
88
App/lib/requisition-state-machine.ts
Normal file
88
App/lib/requisition-state-machine.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
|
||||||
246
App/tests/integration/requisitions.test.ts
Normal file
246
App/tests/integration/requisitions.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
78
App/tests/unit/requisition-state-machine.test.ts
Normal file
78
App/tests/unit/requisition-state-machine.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue