pelagia-portal/App/app/(portal)/crewing/requisitions/[id]/page.tsx
Hardik 0b2ed9ac07
All checks were successful
PR checks / checks (pull_request) Successful in 37s
PR checks / integration (pull_request) Successful in 28s
feat(crewing): Phase 2 — requisitions + relief requests (flagged)
Second slice of the Crewing module per wiki Crewing-Implementation-Spec §12
(build order item 2). Everything stays behind NEXT_PUBLIC_CREWING_ENABLED;
production is unchanged. Schema is added incrementally — this lands the
requisition lifecycle layer.

What's in
- Schema: Requisition (OPEN→SHORTLISTING→PROPOSING→INTERVIEWING→SELECTED→FILLED,
  →CANCELLED), ReliefRequest, CrewAction (the POAction mirror) + their enums.
  Migration crewing_requisitions.
- State machine: lib/requisition-state-machine.ts mirrors po-state-machine
  (selection Manager-only; orthogonal cancel from OPEN/SHORTLISTING by
  cancel_requisition holders, §6). Codes REQ-9000… via lib/requisition-number.ts.
- Actions: raise/cancel/transition + requestReliefCover/convertReliefToRequisition,
  each guarding flag+permission+state, writing a CrewAction and notifying. Shared
  autoRaiseRequisition() (lib/requisition-service.ts) is the backfill entry point
  for sign-off / leave-clash (later phases).
- Notifier: notifyCrew() PO-independent path + CrewNotificationEvent.
- Screens: /crewing/requisitions (list + Raise modal + relief convert) and
  /crewing/requisitions/[id] (detail). Requisitions added to the flag-gated
  Crewing sidebar (Manager + MPO, §7).

Tests & docs
- Unit: requisition-state-machine.test.ts (11).
- Integration: requisitions.test.ts (15) — raise/cancel/transition, relief
  request + convert, auto-raise, permission gating.
- CLAUDE.md "Crewing" section updated with the Phase 2 surface.

Deferred: sign-off/experience (Epic K, §12 item 2) depends on the crew/assignment
models from Phase 3/4; autoRaiseRequisition() is ready for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:22:59 +05:30

124 lines
5.2 KiB
TypeScript

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>
);
}