Second slice of Phase 3 (stacked on 3a candidates). The gated 7-stage recruitment pipeline per Crewing-Implementation-Spec §5.1/§8.4–8.5/§8.13. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged. What's in - Schema (crewing_pipeline migration): Application (one per requisition+candidate) + 7-stage ApplicationStage; ApplicationGate (SALARY/SELECTION/WAIVER pending = Manager queue items); ReferenceCheck; effective-dated SalaryStructure (attached to the Application now, bound to the assignment in 3c); minimal BankDetail/EpfDetail captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). CrewAction += applicationId; pipeline CrewActionTypes. - State machine: lib/application-pipeline.ts — sourcing advances MPO/Manager; approve_salary + select are Manager-only; orthogonal canReject; BOARD_STAGES. - Actions: addApplication (first candidate → requisition SHORTLISTING), advanceStage, recordReferenceCheck, verifyDocuments (bank/EPF), agreeSalary→approveSalary/returnSalary, recordInterviewResult, requestInterviewWaiver→approve/decline, selectCandidate (→ requisition SELECTED)/returnSelection, rejectApplication. Waiver never automatic (R2). Notifications SALARY/SELECTION/WAIVER + CANDIDATE_PROPOSED. - Screens: pipeline board per requisition (7 columns + Add candidate); application workhorse (7-step stepper + adaptive per-stage action card); "Open pipeline" on the requisition detail. Central /approvals gains a crewing section (inline Approve/Return) for one unified Manager queue (§8.13 R8). Tests & docs - Unit: application-pipeline.test.ts (9). Integration: applications.test.ts (10) — full happy path, salary/selection/waiver approvals + Manager-only gating, failed interview, reject, site-staff lockout. type-check clean; full unit (234) + integration (163) green. - CLAUDE.md "Crewing" updated with the Phase 3b surface. Deferred: onboarding (Epic D, Phase 3c) — SELECTED → ONBOARDED, CrewAssignment, employeeId, requisition → FILLED, salary bound to the assignment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
5.8 KiB
TypeScript
138 lines
5.8 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 } } } },
|
|
_count: { select: { applications: 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>
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
href={`/crewing/requisitions/${req.id}/pipeline`}
|
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
|
>
|
|
Open pipeline
|
|
</Link>
|
|
{canWithdraw && <WithdrawRequisitionButton id={req.id} />}
|
|
</div>
|
|
</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 — the recruitment pipeline (Phase 3b) */}
|
|
<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>
|
|
<div className="px-4 py-8 text-center">
|
|
<p className="text-2xl font-semibold text-neutral-900">{req._count.applications}</p>
|
|
<p className="text-sm text-neutral-500 mt-0.5 mb-4">
|
|
candidate{req._count.applications === 1 ? "" : "s"} in the pipeline
|
|
</p>
|
|
<Link href={`/crewing/requisitions/${req.id}/pipeline`} className="text-sm font-medium text-primary-600 hover:underline">
|
|
Open recruitment pipeline →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|