From 0b2ed9ac079f74cd70c754887d856489465396f8 Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 15:42:07 +0530 Subject: [PATCH 01/22] =?UTF-8?q?feat(crewing):=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?requisitions=20+=20relief=20requests=20(flagged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of the Crewing module per wiki Crewing-Implementation-Spec §12 (build order item 2). Everything stays behind NEXT_PUBLIC_CREWING_ENABLED; production is unchanged. Schema is added incrementally — this lands the requisition lifecycle layer. What's in - Schema: Requisition (OPEN→SHORTLISTING→PROPOSING→INTERVIEWING→SELECTED→FILLED, →CANCELLED), ReliefRequest, CrewAction (the POAction mirror) + their enums. Migration crewing_requisitions. - State machine: lib/requisition-state-machine.ts mirrors po-state-machine (selection Manager-only; orthogonal cancel from OPEN/SHORTLISTING by cancel_requisition holders, §6). Codes REQ-9000… via lib/requisition-number.ts. - Actions: raise/cancel/transition + requestReliefCover/convertReliefToRequisition, each guarding flag+permission+state, writing a CrewAction and notifying. Shared autoRaiseRequisition() (lib/requisition-service.ts) is the backfill entry point for sign-off / leave-clash (later phases). - Notifier: notifyCrew() PO-independent path + CrewNotificationEvent. - Screens: /crewing/requisitions (list + Raise modal + relief convert) and /crewing/requisitions/[id] (detail). Requisitions added to the flag-gated Crewing sidebar (Manager + MPO, §7). Tests & docs - Unit: requisition-state-machine.test.ts (11). - Integration: requisitions.test.ts (15) — raise/cancel/transition, relief request + convert, auto-raise, permission gating. - CLAUDE.md "Crewing" section updated with the Phase 2 surface. Deferred: sign-off/experience (Epic K, §12 item 2) depends on the crew/assignment models from Phase 3/4; autoRaiseRequisition() is ready for it. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/CLAUDE.md | 12 +- .../crewing/requisitions/[id]/page.tsx | 124 +++++++ .../requisitions/[id]/withdraw-button.tsx | 62 ++++ .../(portal)/crewing/requisitions/actions.ts | 303 ++++++++++++++++++ .../(portal)/crewing/requisitions/page.tsx | 78 +++++ .../crewing/requisitions/requisition-form.tsx | 242 ++++++++++++++ .../crewing/requisitions/requisition-ui.ts | 52 +++ .../requisitions/requisitions-manager.tsx | 204 ++++++++++++ App/components/layout/sidebar.tsx | 15 +- App/lib/notifier.ts | 105 ++++++ App/lib/requisition-number.ts | 34 ++ App/lib/requisition-service.ts | 108 +++++++ App/lib/requisition-state-machine.ts | 88 +++++ .../migration.sql | 101 ++++++ App/prisma/schema.prisma | 261 +++++++++++---- App/tests/integration/requisitions.test.ts | 246 ++++++++++++++ .../unit/requisition-state-machine.test.ts | 78 +++++ 17 files changed, 2042 insertions(+), 71 deletions(-) create mode 100644 App/app/(portal)/crewing/requisitions/[id]/page.tsx create mode 100644 App/app/(portal)/crewing/requisitions/[id]/withdraw-button.tsx create mode 100644 App/app/(portal)/crewing/requisitions/actions.ts create mode 100644 App/app/(portal)/crewing/requisitions/page.tsx create mode 100644 App/app/(portal)/crewing/requisitions/requisition-form.tsx create mode 100644 App/app/(portal)/crewing/requisitions/requisition-ui.ts create mode 100644 App/app/(portal)/crewing/requisitions/requisitions-manager.tsx create mode 100644 App/lib/requisition-number.ts create mode 100644 App/lib/requisition-service.ts create mode 100644 App/lib/requisition-state-machine.ts create mode 100644 App/prisma/migrations/20260622095543_crewing_requisitions/migration.sql create mode 100644 App/tests/integration/requisitions.test.ts create mode 100644 App/tests/unit/requisition-state-machine.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 1794f35..df91c5d 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -120,13 +120,21 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at ### 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`. - **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. - **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 diff --git a/App/app/(portal)/crewing/requisitions/[id]/page.tsx b/App/app/(portal)/crewing/requisitions/[id]/page.tsx new file mode 100644 index 0000000..ba0ec19 --- /dev/null +++ b/App/app/(portal)/crewing/requisitions/[id]/page.tsx @@ -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 ( +
+ + Requisitions + + +
+
+
+

{req.rank.name} — {location}

+ {STATUS_LABEL[req.status]} +
+

+ {req.code} · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago +

+
+ {canWithdraw && } +
+ + {req.autoRaised && ( +
+ This requisition was auto-raised by the system ({REASON_LABEL[req.reason]}). No manual action + was needed to open it. +
+ )} + + {req.sourceReliefRequest && ( +
+ Converted from a relief request raised by{" "} + {req.sourceReliefRequest.requestedBy.name}. +
+ )} + +
+ {/* Vacancy details */} +
+
+

Vacancy details

+
+
+ {details.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ {req.notes && ( +
+

Notes

+

{req.notes}

+
+ )} +
+ + {/* Candidates — populated by the recruitment pipeline (Phase 3) */} +
+
+

Candidates

+
+

+ The recruitment pipeline arrives in a later phase. Candidates attached to this + requisition will appear here. +

+
+
+
+ ); +} diff --git a/App/app/(portal)/crewing/requisitions/[id]/withdraw-button.tsx b/App/app/(portal)/crewing/requisitions/[id]/withdraw-button.tsx new file mode 100644 index 0000000..4672a36 --- /dev/null +++ b/App/app/(portal)/crewing/requisitions/[id]/withdraw-button.tsx @@ -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 ( + <> + + setOpen(false)}> +
+

+ Withdrawing closes this requisition. A reason is required and is recorded on the audit trail. +

+
+ +