Compare commits

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

7 commits

Author SHA1 Message Date
55d69a291a Merge pull request 'feat(crewing): Phase 3b — recruitment pipeline (flagged)' (#67) from feat/crewing-pipeline into feat/crewing-candidates
Reviewed-on: #67
2026-06-22 18:49:29 +00:00
79eba5505c Merge branch 'feat/crewing-candidates' into feat/crewing-pipeline
All checks were successful
PR checks / checks (pull_request) Successful in 39s
PR checks / integration (pull_request) Successful in 28s
2026-06-22 18:49:11 +00:00
136cb798f5 Merge branch 'feat/crewing-requisitions' into feat/crewing-candidates
All checks were successful
PR checks / checks (pull_request) Successful in 37s
PR checks / integration (pull_request) Successful in 27s
2026-06-22 18:48:12 +00:00
feac86e3a3 Merge branch 'feat/crewing-foundations' into feat/crewing-requisitions
All checks were successful
PR checks / checks (pull_request) Successful in 36s
PR checks / integration (pull_request) Successful in 29s
2026-06-22 18:46:58 +00:00
3ec3a2b4ef feat(crewing): Phase 3b — recruitment pipeline (flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 30s
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>
2026-06-22 18:49:12 +05:30
be6db075dc feat(crewing): Phase 3a — candidates / talent pool (flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 36s
PR checks / integration (pull_request) Successful in 27s
First slice of Phase 3 (Epics B/C/D shipped as stacked sub-PRs). Adds the
CrewMember talent-pool spine and the Candidates screens. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged. Stacks on the requisitions
branch (Phase 2).

What's in
- Schema (crewing_candidates migration): CrewMember (spine) + CrewStatus,
  CandidateType, CandidateSource enums; CrewAction gains a nullable crewMemberId;
  CrewActionType += CANDIDATE_ADDED/UPDATED. employeeId is assigned at onboarding
  (3c), so it's nullable here.
- Actions (crewing/candidates/actions.ts): addCandidate / updateCandidate —
  guard flag + manage_candidates, write a CrewAction, optional CV upload via
  buildStorageKey("cv", …) + uploadBuffer (no parsing — A2 deferred). EX_HAND
  source ⇒ type/status EX_HAND; edits never downgrade an EMPLOYEE.
- Screens: /crewing/candidates (master list with search/source/rank-applied/
  min-experience filters as removable chips + match count + Clear all; Add-candidate
  modal) and /crewing/candidates/[id] (profile; pipeline stepper is 3b). Candidates
  added to the flag-gated Crewing nav (Manager + MPO).

Tests & docs
- Integration: candidates.test.ts (7) — add/update, ex-hand derivation, employee
  no-downgrade, permission gating. type-check clean; full unit (225) + integration
  (153) suites green.
- CLAUDE.md "Crewing" section updated with the Phase 3a surface.

Deferred: public careers intake API (A2, §13 open question); CV parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:23:01 +05:30
0b2ed9ac07 feat(crewing): Phase 2 — requisitions + relief requests (flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 37s
PR checks / integration (pull_request) Successful in 28s
Second slice of the Crewing module per wiki Crewing-Implementation-Spec §12
(build order item 2). Everything stays behind NEXT_PUBLIC_CREWING_ENABLED;
production is unchanged. Schema is added incrementally — this lands the
requisition lifecycle layer.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:22:59 +05:30
38 changed files with 5234 additions and 74 deletions

View file

@ -120,13 +120,36 @@ 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.28.3):**
- **Models:** `Requisition` (lifecycle `OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED`, `→ CANCELLED`), `ReliefRequest` (site-flagged gap the office converts), and `CrewAction` (the crewing audit trail — the `POAction` mirror). `Requisition.autoRaised` marks system-raised vacancies.
- **State machine:** `lib/requisition-state-machine.ts` mirrors `po-state-machine.ts` (`TRANSITIONS`, `canPerformAction`, `getAvailableActions`; orthogonal `CANCEL_ROLES`/`canCancel`). Final selection is Manager-only; withdraw is allowed from OPEN/SHORTLISTING by `cancel_requisition` holders (MPO + Manager, per §6). Codes (`REQ-9000…`) come from `lib/requisition-number.ts`.
- **Actions** (`app/(portal)/crewing/requisitions/actions.ts`): `raiseRequisition`, `cancelRequisition`, `transitionRequisition`, `requestReliefCover`, `convertReliefToRequisition` — each guards flag + permission + state, writes a `CrewAction`, and notifies via `notifyCrew`. The shared `autoRaiseRequisition()` in `lib/requisition-service.ts` is the backfill entry point sign-off / leave-clash (later phases) will call.
- **Screens:** `/crewing/requisitions` (list + Raise modal + "Relief requests from sites" convert) and `/crewing/requisitions/[id]` (detail; the recruitment pipeline is a later phase). **Requisitions** is in the flag-gated sidebar **Crewing** section (`CREWING_ITEMS`, Manager + MPO). The Ranks link stays under Administration.
- **Notifications:** `lib/notifier.ts` `notifyCrew()` is the PO-independent path (writes `Notification` rows with a null `poId`); `CrewNotificationEvent` covers `REQUISITION_RAISED` / `RELIEF_REQUESTED` / `RELIEF_CONVERTED`.
- **Deferred:** sign-off / experience-record (Epic K) is part of spec §12 item 2 but depends on the crew/assignment models from Phase 3/4, so it lands with those. `autoRaiseRequisition()` is already in place for it.
**Phase 3a — Candidates (Epic B; spec §8.6):** Phase 3 (candidate intake + 7-stage pipeline + onboarding) ships as **stacked sub-PRs** — 3a candidates, 3b pipeline, 3c onboarding.
- **Model:** `CrewMember` is the talent-pool spine — one row per person, created on first contact and kept through `CANDIDATE → EMPLOYEE → EX_HAND` (`CrewStatus`). `employeeId` is assigned only at onboarding (3c). `CandidateType` (NEW/EX_HAND) and `CandidateSource` derive from the chosen source; `currentRankId` (rank held) + `appliedRankId` (rank applied for). `CrewAction` gained a nullable `crewMemberId` (it now references at most one entity).
- **Actions** (`app/(portal)/crewing/candidates/actions.ts`): `addCandidate` / `updateCandidate` — guard flag + `manage_candidates`, write a `CrewAction`, optional CV upload via `buildStorageKey("cv", …)` + `uploadBuffer`. An EX_HAND source maps to `type/status = EX_HAND`; an edit never downgrades an `EMPLOYEE`.
- **Screens:** `/crewing/candidates` (master list with search / source / rank-applied / min-experience filters rendered as removable chips + match count + Clear all; Add-candidate modal) and `/crewing/candidates/[id]` (profile; the 7-stage pipeline/stepper is 3b). **Candidates** added to the flag-gated Crewing nav (Manager + MPO).
- **Deferred:** the public careers intake API (A2, §13 open question) — 3a uses the internal Add-candidate modal only; CVs are stored but not parsed.
**Phase 3b — Recruitment pipeline (Epic C; spec §5.1/§8.48.5/§8.13):**
- **Models:** `Application` (one per requisition+candidate) drives the 7-stage `ApplicationStage` (`SHORTLISTED → COMPETENCY_AND_REFERENCES → DOC_VERIFICATION → SALARY_AGREEMENT → PROPOSED → INTERVIEW → SELECTED`; `→ REJECTED`; `ONBOARDED` is 3c). `ApplicationGate` records each vetting gate — `SALARY` / `SELECTION` / `WAIVER` gates with `result=PENDING` are the Manager's queue items. `ReferenceCheck`, effective-dated `SalaryStructure` (attached to the Application in 3b; bound to the assignment in 3c), and minimal `BankDetail` / `EpfDetail` captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). `CrewAction` gained `applicationId`.
- **State machine:** `lib/application-pipeline.ts` (mirrors po/requisition machines) — sourcing advances are MPO/Manager; `approve_salary` and `select` are Manager-only; `canReject` is orthogonal. `BOARD_STAGES` is the 7 columns.
- **Actions** (`app/(portal)/crewing/applications/actions.ts`): `addApplication` (first candidate moves the requisition OPEN→SHORTLISTING), `advanceStage`, `recordReferenceCheck`, `verifyDocuments` (captures bank/EPF), `agreeSalary``approveSalary`/`returnSalary`, `recordInterviewResult`, `requestInterviewWaiver``approveInterviewWaiver`/`declineInterviewWaiver`, `selectCandidate`/`returnSelection` (sets requisition→SELECTED), `rejectApplication`. Waiver is **never automatic** (R2). Notifications: `SALARY_FOR_APPROVAL` / `SELECTION_FOR_APPROVAL` / `WAIVER_REQUESTED` (+ `CANDIDATE_PROPOSED`).
- **Screens:** pipeline board per requisition (`/crewing/requisitions/[id]/pipeline`, 7 columns + Add-candidate), the application workhorse (`/crewing/applications/[id]` — 7-step stepper + adaptive per-stage action card), and an **"Open pipeline"** action on the requisition detail.
- **Central approvals (§8.13 R8):** `/approvals` now also lists pending crewing gates (Salary / Selection / Waiver) with inline Approve/Return, alongside POs — one unified Manager queue.
### GST Calculation

View file

@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { AdminDialog } from "@/components/ui/admin-dialog";
import {
approveSalary,
returnSalary,
selectCandidate,
returnSelection,
approveInterviewWaiver,
declineInterviewWaiver,
} from "../crewing/applications/actions";
export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER";
export type CrewApprovalItem = {
applicationId: string;
kind: CrewApprovalKind;
candidateName: string;
rank: string;
requisitionCode: string;
detail: string; // amount for salary, etc.
};
const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver" };
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary" } as const;
const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: approveSalary,
SELECTION: selectCandidate,
WAIVER: approveInterviewWaiver,
};
const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: returnSalary,
SELECTION: returnSelection,
WAIVER: declineInterviewWaiver,
};
function Row({ item }: { item: CrewApprovalItem }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [returnOpen, setReturnOpen] = useState(false);
const [reason, setReason] = useState("");
async function approve() {
setPending(true); setError("");
const res = await approveFn[item.kind](item.applicationId);
setPending(false);
if ("error" in res) setError(res.error); else router.refresh();
}
async function doReturn(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await returnFn[item.kind](item.applicationId, reason);
setPending(false);
if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
}
return (
<tr className="hover:bg-neutral-50">
<td className="px-4 py-3"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
<td className="px-4 py-3">
<Link href={`/crewing/applications/${item.applicationId}`} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
<span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
</td>
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
<button onClick={() => setReturnOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Return</button>
</div>
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
<AdminDialog title={`Return ${KIND_LABEL[item.kind].toLowerCase()}`} open={returnOpen} onClose={() => setReturnOpen(false)}>
<form onSubmit={doReturn} className="space-y-4 text-left">
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setReturnOpen(false)}>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">Return</button>
</div>
</form>
</AdminDialog>
</td>
</tr>
);
}
export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
return (
<div className="mt-8">
<h2 className="text-sm font-semibold text-neutral-900 mb-1">Crewing approvals</h2>
<p className="text-xs text-neutral-500 mb-3">{items.length} item{items.length === 1 ? "" : "s"} awaiting your decision</p>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Kind</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Candidate</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Detail</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{items.map((item) => <Row key={`${item.kind}-${item.applicationId}`} item={item} />)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -5,6 +5,8 @@ import { redirect } from "next/navigation";
import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
import { ApprovalsSearch } from "./approvals-search";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { CrewingApprovals, type CrewApprovalItem, type CrewApprovalKind } from "./crewing-approvals";
import { Suspense } from "react";
import type { Metadata } from "next";
@ -49,6 +51,49 @@ export default async function ApprovalsPage({ searchParams }: Props) {
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Crewing approvals (spec §8.13 R8) — the same unified Manager queue. Pending
// SALARY / SELECTION / WAIVER gates surface here alongside POs.
const role = session.user.role;
const showCrewing =
CREWING_ENABLED &&
(hasPermission(role, "approve_salary_structure") ||
hasPermission(role, "select_candidate") ||
hasPermission(role, "approve_interview_waiver"));
const crewGates = showCrewing
? await db.applicationGate.findMany({
where: { result: "PENDING", gate: { in: ["SALARY", "SELECTION", "WAIVER"] } },
orderBy: { createdAt: "asc" },
include: {
application: {
include: {
crewMember: { select: { name: true } },
requisition: { select: { code: true, rank: { select: { name: true } } } },
salaryStructures: { where: { approvedById: null }, orderBy: { createdAt: "desc" }, take: 1 },
},
},
},
})
: [];
const crewItems: CrewApprovalItem[] = crewGates.map((g) => {
const sal = g.application.salaryStructures[0];
const detail =
g.gate === "SALARY" && sal
? `${sal.currency} ${Number(sal.basic).toLocaleString("en-IN")} / ${sal.rateBasis.toLowerCase()}`
: g.gate === "WAIVER"
? "Returning crew — interview waiver"
: "Interview cleared";
return {
applicationId: g.applicationId,
kind: g.gate as CrewApprovalKind,
candidateName: g.application.crewMember.name,
rank: g.application.requisition.rank.name,
requisitionCode: g.application.requisition.code,
detail,
};
});
return (
<div>
<div className="mb-4">
@ -137,6 +182,8 @@ export default async function ApprovalsPage({ searchParams }: Props) {
</div>
</>
)}
{showCrewing && crewItems.length > 0 && <CrewingApprovals items={crewItems} />}
</div>
);
}

View file

@ -0,0 +1,142 @@
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 Link from "next/link";
import { ArrowLeft, Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ApplicationActionCard } from "../application-action-card";
import { STAGE_ORDER, STAGE_LABEL, STAGE_VARIANT, stageIndex } from "../application-ui";
import { experienceLabel } from "../../candidates/candidate-ui";
import { cn } from "@/lib/utils";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Application" };
export default async function ApplicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
if (!hasPermission(role, "view_requisitions") && !hasPermission(role, "manage_candidates")) redirect("/dashboard");
const { id } = await params;
const app = await db.application.findUnique({
where: { id },
include: {
requisition: { include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } },
crewMember: { include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } } },
gates: true,
salaryStructures: { orderBy: { createdAt: "desc" } },
},
});
if (!app) notFound();
const gate = (t: string) => app.gates.find((g) => g.gate === t);
const salaryPending = gate("SALARY")?.result === "PENDING";
const waiverPending = gate("WAIVER")?.result === "PENDING";
const selectionPending = gate("SELECTION")?.result === "PENDING";
const proposed = app.salaryStructures.find((s) => !s.approvedById) ?? app.salaryStructures[0] ?? null;
const loc = app.requisition.vessel?.name ?? app.requisition.site?.name ?? "—";
const curIdx = stageIndex(app.stage);
return (
<div className="max-w-4xl">
<Link href={`/crewing/requisitions/${app.requisition.id}/pipeline`} 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" /> Pipeline · {app.requisition.code}
</Link>
<div className="mb-6 flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{app.crewMember.name}</h1>
<Badge variant={STAGE_VARIANT[app.stage]}>{STAGE_LABEL[app.stage]}</Badge>
{app.crewMember.type === "EX_HAND" && (
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
)}
</div>
<p className="text-sm text-neutral-500 -mt-4 mb-6">
{app.requisition.rank.name} · {loc} · <span className="font-mono">{app.requisition.code}</span>
</p>
{/* 7-step stepper */}
<div className="mb-6 flex flex-wrap gap-2">
{STAGE_ORDER.map((s, i) => {
const done = curIdx > i || app.stage === "ONBOARDED";
const current = curIdx === i;
return (
<div key={s} className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium",
done ? "bg-success-100 text-success-700" : current ? "bg-primary-100 text-primary-700" : "bg-neutral-100 text-neutral-400"
)}>
{done && <Check className="h-3 w-3" />}
{STAGE_LABEL[s]}
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Adaptive action card */}
<ApplicationActionCard
id={app.id}
stage={app.stage}
isExHand={app.crewMember.type === "EX_HAND"}
interviewResult={app.interviewResult}
interviewWaived={app.interviewWaived}
rejectedReason={app.rejectedReason}
salaryPending={salaryPending}
waiverPending={waiverPending}
selectionPending={selectionPending}
salary={proposed ? {
rateBasis: proposed.rateBasis,
basic: Number(proposed.basic),
victualingPerDay: Number(proposed.victualingPerDay),
currency: proposed.currency,
approved: Boolean(proposed.approvedById),
} : null}
perms={{
manage: hasPermission(role, "manage_candidates"),
recordReference: hasPermission(role, "record_reference_check"),
recordInterview: hasPermission(role, "record_interview_result"),
requestWaiver: hasPermission(role, "request_interview_waiver"),
approveSalary: hasPermission(role, "approve_salary_structure"),
approveWaiver: hasPermission(role, "approve_interview_waiver"),
select: hasPermission(role, "select_candidate"),
}}
/>
{/* Profile */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden h-fit">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
</div>
<dl className="divide-y divide-neutral-100">
{([
["Rank applied", app.crewMember.appliedRank?.name ?? app.requisition.rank.name],
["Last rank held", app.crewMember.currentRank?.name ?? "—"],
["Experience", experienceLabel(app.crewMember.experienceMonths)],
["Source", app.crewMember.source],
] as [string, string][]).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>
{app.crewMember.type === "EX_HAND" && (
<div className="px-4 py-3 border-t border-neutral-100 text-xs text-purple-700 bg-purple-50">
Returning crew prior docs/bank/tour on file; interview may be waived with Manager approval.
</div>
)}
<div className="px-4 py-3 border-t border-neutral-100">
<Link href={`/crewing/candidates/${app.crewMember.id}`} className="text-sm text-primary-600 hover:underline">
View full candidate profile
</Link>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,537 @@
"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 {
canPerformAction,
canReject,
getTransition,
type ApplicationAction,
} from "@/lib/application-pipeline";
import { getManagerRecipients, getMpoRecipients } from "@/lib/requisition-service";
import { notifyCrew } from "@/lib/notifier";
import { SalaryRateBasis } 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 appPath = (id: string) => `/crewing/applications/${id}`;
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 };
}
// Load an application with the bits the actions need; null if missing.
async function loadApp(id: string) {
return db.application.findUnique({
where: { id },
include: {
requisition: { select: { id: true, status: true, code: true, rank: { select: { name: true } } } },
crewMember: { select: { id: true, name: true, type: true } },
},
});
}
function revalidateApp(applicationId: string, requisitionId: string) {
revalidatePath(appPath(applicationId));
revalidatePath(`/crewing/requisitions/${requisitionId}/pipeline`);
revalidatePath("/approvals");
}
// ── Add a candidate to a requisition's pipeline ────────────────────────────────
export async function addApplication(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const requisitionId = formData.get("requisitionId") as string;
const crewMemberId = formData.get("crewMemberId") as string;
if (!requisitionId || !crewMemberId) return { error: "Requisition and candidate are required" };
const [requisition, candidate, existing] = await Promise.all([
db.requisition.findUnique({ where: { id: requisitionId }, select: { status: true } }),
db.crewMember.findUnique({ where: { id: crewMemberId }, select: { type: true } }),
db.application.findUnique({ where: { requisitionId_crewMemberId: { requisitionId, crewMemberId } }, select: { id: true } }),
]);
if (!requisition) return { error: "Requisition not found" };
if (!candidate) return { error: "Candidate not found" };
if (requisition.status === "CANCELLED" || requisition.status === "FILLED") {
return { error: `Cannot add candidates to a ${requisition.status} requisition` };
}
if (existing) return { error: "This candidate is already in the pipeline for this requisition" };
const application = await db.application.create({
data: {
requisitionId,
crewMemberId,
type: candidate.type,
stage: "SHORTLISTED",
actions: { create: { actionType: "APPLICATION_CREATED", actorId: g.userId, crewMemberId, requisitionId } },
},
});
// First candidate moves the requisition from OPEN into sourcing.
if (requisition.status === "OPEN") {
await db.requisition.update({
where: { id: requisitionId },
data: {
status: "SHORTLISTING",
actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SHORTLISTING" } } },
},
});
}
revalidateApp(application.id, requisitionId);
return { ok: true, id: application.id };
}
// ── Sourcing stage advances (MPO/Manager) ──────────────────────────────────────
// start_competency, verify_competency, propose_accepted. verify_docs / approve_salary /
// select have dedicated actions below.
export async function advanceStage(id: string, action: ApplicationAction): Promise<ActionResult> {
if (action !== "start_competency" && action !== "verify_competency" && action !== "propose_accepted") {
return { error: "Use the dedicated action for this step" };
}
const g = await guard("manage_candidates");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const transition = getTransition(app.stage, action);
if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
await db.application.update({
where: { id },
data: {
stage: transition.to,
// Completing the competency & references stage records its gate.
...(action === "verify_competency"
? { gates: { create: { gate: "COMPETENCY_REFERENCE", result: "VERIFIED", decidedById: g.userId } } }
: {}),
actions: {
create: {
actionType: action === "verify_competency" ? "GATE_PASSED" : action === "propose_accepted" ? "CANDIDATE_PROPOSED" : "GATE_PASSED",
actorId: g.userId,
crewMemberId: app.crewMemberId,
metadata: { from: app.stage, to: transition.to },
},
},
},
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
const referenceSchema = z.object({
refereeName: z.string().trim().min(1, "Referee name is required"),
refereeContact: z.string().optional(),
outcome: z.string().optional(),
note: z.string().optional(),
});
export async function recordReferenceCheck(formData: FormData): Promise<ActionResult> {
const g = await guard("record_reference_check");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const parsed = referenceSchema.safeParse({
refereeName: formData.get("refereeName"),
refereeContact: (formData.get("refereeContact") as string) || undefined,
outcome: (formData.get("outcome") as string) || undefined,
note: (formData.get("note") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
await db.referenceCheck.create({
data: {
applicationId: id,
refereeName: parsed.data.refereeName,
refereeContact: parsed.data.refereeContact ?? null,
outcome: parsed.data.outcome ?? null,
note: parsed.data.note ?? null,
recordedById: g.userId,
},
});
await db.crewAction.create({
data: { actionType: "REFERENCE_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMemberId },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── DOC_VERIFICATION: capture bank/EPF + verify documents → SALARY_AGREEMENT ────
const docsSchema = z.object({
accountName: z.string().optional(),
accountNumber: z.string().optional(),
ifsc: z.string().optional(),
bankName: z.string().optional(),
uan: z.string().optional(),
aadhaarLast4: z.string().optional(),
pfNumber: z.string().optional(),
note: z.string().optional(),
});
export async function verifyDocuments(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const transition = getTransition(app.stage, "verify_docs");
if (!transition) return { error: `Cannot verify documents from ${app.stage}` };
const parsed = docsSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const crewMemberId = app.crewMember.id;
await db.$transaction(async (tx) => {
// Capture bank / EPF (PII — encryption deferred to Phase 4).
await tx.bankDetail.upsert({
where: { crewMemberId },
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
create: { crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
});
await tx.epfDetail.upsert({
where: { crewMemberId },
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
create: { crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
});
await tx.application.update({
where: { id },
data: {
stage: transition.to,
gates: {
create: { gate: "DOCUMENT", result: "VERIFIED", decidedById: g.userId, note: d.note ?? null },
},
actions: { create: { actionType: "GATE_PASSED", actorId: g.userId, crewMemberId, metadata: { gate: "DOCUMENT" } } },
},
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── SALARY_AGREEMENT: MPO agrees → Manager approves ────────────────────────────
const salarySchema = z.object({
rateBasis: z.nativeEnum(SalaryRateBasis).default("MONTHLY"),
basic: z.coerce.number().positive("Basic must be greater than 0"),
victualingPerDay: z.coerce.number().min(0).default(0),
currency: z.string().default("INR"),
});
export async function agreeSalary(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.stage !== "SALARY_AGREEMENT") return { error: `Salary can only be agreed at SALARY_AGREEMENT (currently ${app.stage})` };
const parsed = salarySchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
await db.$transaction(async (tx) => {
// One live proposed structure per application — replace any prior draft.
await tx.salaryStructure.deleteMany({ where: { applicationId: id, approvedById: null } });
await tx.salaryStructure.create({
data: {
applicationId: id,
rateBasis: d.rateBasis,
basic: d.basic,
victualingPerDay: d.victualingPerDay,
currency: d.currency,
},
});
// Salary gate goes PENDING for the Manager's queue.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
update: { result: "PENDING", decidedById: null, note: null },
create: { applicationId: id, gate: "SALARY", result: "PENDING" },
});
await tx.crewAction.create({
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id },
});
});
const managers = await getManagerRecipients();
await notifyCrew({
event: "SALARY_FOR_APPROVAL",
recipients: managers,
subject: `Salary for approval — ${app.crewMember.name}`,
body: `${app.crewMember.name}'s salary for ${app.requisition.rank.name} (${app.requisition.code}) is ready for your approval.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function approveSalary(id: string): Promise<ActionResult> {
const g = await guard("approve_salary_structure");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canPerformAction(app.stage, "approve_salary", g.role)) return { error: `Cannot approve salary from ${app.stage}` };
await db.$transaction(async (tx) => {
await tx.salaryStructure.updateMany({ where: { applicationId: id, approvedById: null }, data: { approvedById: g.userId } });
await tx.applicationGate.update({
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
data: { result: "VERIFIED", decidedById: g.userId },
});
await tx.application.update({
where: { id },
data: {
stage: "PROPOSED",
actions: { create: { actionType: "SALARY_APPROVED", actorId: g.userId, crewMemberId: app.crewMember.id } },
},
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function returnSalary(id: string, reason: string): Promise<ActionResult> {
const g = await guard("approve_salary_structure");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to return for revision" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.applicationGate.updateMany({
where: { applicationId: id, gate: "SALARY" },
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
});
await db.crewAction.create({
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── INTERVIEW: MPO records result / requests waiver → Manager selects ──────────
export async function recordInterviewResult(id: string, accepted: boolean, note?: string): Promise<ActionResult> {
const g = await guard("record_interview_result");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.stage !== "INTERVIEW") return { error: `Interview results are recorded at the INTERVIEW stage (currently ${app.stage})` };
if (!accepted) {
// A failed interview rejects the application.
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, note?.trim() || "Interview not passed");
}
await db.$transaction(async (tx) => {
await tx.application.update({ where: { id }, data: { interviewResult: "ACCEPTED" } });
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "INTERVIEW" } },
update: { result: "VERIFIED", decidedById: g.userId },
create: { applicationId: id, gate: "INTERVIEW", result: "VERIFIED", decidedById: g.userId },
});
// Selection now pending for the Manager.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "PENDING", decidedById: null },
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
});
await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: note?.trim() || null } });
});
const managers = await getManagerRecipients();
await notifyCrew({
event: "SELECTION_FOR_APPROVAL",
recipients: managers,
subject: `Selection for approval — ${app.crewMember.name}`,
body: `${app.crewMember.name} passed the interview for ${app.requisition.rank.name} (${app.requisition.code}) and awaits your selection.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function requestInterviewWaiver(id: string, note?: string): Promise<ActionResult> {
const g = await guard("request_interview_waiver");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.crewMember.type !== "EX_HAND") return { error: "Interview waivers are only for returning crew (ex-hands)" };
if (app.stage !== "INTERVIEW") return { error: `Waivers are requested at the INTERVIEW stage (currently ${app.stage})` };
await db.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
update: { result: "PENDING", decidedById: null, note: note?.trim() || null },
create: { applicationId: id, gate: "WAIVER", result: "PENDING", note: note?.trim() || null },
});
await db.crewAction.create({ data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
const managers = await getManagerRecipients();
await notifyCrew({
event: "WAIVER_REQUESTED",
recipients: managers,
subject: `Interview waiver requested — ${app.crewMember.name}`,
body: `An interview waiver is requested for returning crew ${app.crewMember.name} (${app.requisition.code}). Approve or decline.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function approveInterviewWaiver(id: string): Promise<ActionResult> {
const g = await guard("approve_interview_waiver");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.$transaction(async (tx) => {
await tx.application.update({ where: { id }, data: { interviewWaived: true } });
await tx.applicationGate.update({
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
data: { result: "VERIFIED", decidedById: g.userId },
});
// Waived → selection is now pending.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "PENDING", decidedById: null },
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
});
await tx.crewAction.create({ data: { actionType: "WAIVER_APPROVED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function declineInterviewWaiver(id: string, reason: string): Promise<ActionResult> {
const g = await guard("approve_interview_waiver");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to decline" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.applicationGate.updateMany({
where: { applicationId: id, gate: "WAIVER" },
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
});
await db.crewAction.create({
data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function selectCandidate(id: string): Promise<ActionResult> {
const g = await guard("select_candidate");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canPerformAction(app.stage, "select", g.role)) return { error: `Cannot select from ${app.stage}` };
const full = await db.application.findUniqueOrThrow({ where: { id }, select: { interviewResult: true, interviewWaived: true } });
if (full.interviewResult !== "ACCEPTED" && !full.interviewWaived) {
return { error: "Record an interview result (or a Manager-approved waiver) before selecting" };
}
await db.$transaction(async (tx) => {
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "VERIFIED", decidedById: g.userId },
create: { applicationId: id, gate: "SELECTION", result: "VERIFIED", decidedById: g.userId },
});
await tx.application.update({
where: { id },
data: { stage: "SELECTED", actions: { create: { actionType: "CANDIDATE_SELECTED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
});
// The requisition moves to SELECTED (onboarding flips it to FILLED in 3c).
await tx.requisition.update({
where: { id: app.requisition.id },
data: { status: "SELECTED", actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SELECTED" } } } },
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function returnSelection(id: string, reason: string): Promise<ActionResult> {
const g = await guard("select_candidate");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to return" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.$transaction(async (tx) => {
await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── Rejection (orthogonal) ─────────────────────────────────────────────────────
async function rejectApplicationInternal(
id: string,
crewMemberId: string,
requisitionId: string,
userId: string,
reason: string
): Promise<ActionResult> {
await db.application.update({
where: { id },
data: {
stage: "REJECTED",
rejectedReason: reason,
rejectedAt: new Date(),
actions: { create: { actionType: "APPLICATION_REJECTED", actorId: userId, crewMemberId, note: reason } },
},
});
revalidateApp(id, requisitionId);
return { ok: true };
}
export async function rejectApplication(id: string, reason: string): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to reject" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canReject(app.stage, g.role)) return { error: `Cannot reject from ${app.stage}` };
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim());
}

View file

@ -0,0 +1,347 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { ApplicationStage, InterviewOutcome, SalaryRateBasis } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import {
advanceStage,
agreeSalary,
approveSalary,
returnSalary,
verifyDocuments,
recordReferenceCheck,
recordInterviewResult,
requestInterviewWaiver,
approveInterviewWaiver,
selectCandidate,
returnSelection,
rejectApplication,
} 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";
const PRIMARY = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60";
const DANGER = "rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60";
export type ActionCardProps = {
id: string;
stage: ApplicationStage;
isExHand: boolean;
interviewResult: InterviewOutcome;
interviewWaived: boolean;
rejectedReason: string | null;
salaryPending: boolean;
waiverPending: boolean;
selectionPending: boolean;
salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null;
perms: {
manage: boolean;
recordReference: boolean;
recordInterview: boolean;
requestWaiver: boolean;
approveSalary: boolean;
approveWaiver: boolean;
select: boolean;
};
};
function useAction() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function run(fn: () => Promise<{ ok: true } | { error: string }>) {
setPending(true);
setError("");
const res = await fn();
setPending(false);
if ("error" in res) setError(res.error);
else router.refresh();
return res;
}
return { pending, error, run };
}
function Card({ title, sub, children }: { title: string; sub?: string; children: React.ReactNode }) {
return (
<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">{title}</h2>
{sub && <p className="text-xs text-neutral-500 mt-0.5">{sub}</p>}
</div>
<div className="p-4 space-y-3">{children}</div>
</div>
);
}
function RejectButton({ id }: { id: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await rejectApplication(id, reason);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<>
<button className={DANGER} onClick={() => setOpen(true)}>Reject</button>
<AdminDialog title="Reject candidate" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<p className="text-sm text-neutral-600">Rejecting removes this candidate from the pipeline. The reason is recorded.</p>
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
{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">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>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 ? "Rejecting…" : "Reject"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
function Err({ msg }: { msg: string }) {
return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null;
}
export function ApplicationActionCard(p: ActionCardProps) {
const { run, pending, error } = useAction();
const canReject = p.perms.manage && !["SELECTED", "ONBOARDED", "REJECTED"].includes(p.stage);
// Reference-check form state (COMPETENCY_AND_REFERENCES).
const [ref, setRef] = useState({ refereeName: "", refereeContact: "", outcome: "positive", note: "" });
// Bank/EPF form state (DOC_VERIFICATION).
const [docs, setDocs] = useState({ accountName: "", accountNumber: "", ifsc: "", bankName: "", uan: "", aadhaarLast4: "", pfNumber: "" });
// Salary form state (SALARY_AGREEMENT).
const [sal, setSal] = useState({ rateBasis: "MONTHLY", basic: "", victualingPerDay: "0", currency: "INR" });
function fdFrom(obj: Record<string, string>, extra?: Record<string, string>) {
const fd = new FormData();
Object.entries({ ...obj, ...extra }).forEach(([k, v]) => fd.set(k, v));
return fd;
}
const footer = (
<>
<Err msg={error} />
{canReject && (
<div className="flex justify-end pt-1">
<RejectButton id={p.id} />
</div>
)}
</>
);
switch (p.stage) {
case "SHORTLISTED":
return (
<Card title="Shortlisted" sub="Begin vetting: competency & references.">
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "start_competency"))}>
Start competency &amp; references
</button>
)}
{footer}
</Card>
);
case "COMPETENCY_AND_REFERENCES":
return (
<Card title="Competency & references" sub="Record reference checks, then verify to continue.">
{p.perms.recordReference && (
<div className="space-y-2 rounded-md border border-neutral-200 p-3">
<p className="text-xs font-medium text-neutral-600">Add a reference check</p>
<input className={INPUT} placeholder="Referee name" value={ref.refereeName} onChange={(e) => setRef({ ...ref, refereeName: e.target.value })} />
<input className={INPUT} placeholder="Referee contact (optional)" value={ref.refereeContact} onChange={(e) => setRef({ ...ref, refereeContact: e.target.value })} />
<input className={INPUT} placeholder="Note (optional)" value={ref.note} onChange={(e) => setRef({ ...ref, note: e.target.value })} />
<button className={SECONDARY} disabled={pending || !ref.refereeName} onClick={() => run(() => recordReferenceCheck(fdFrom(ref, { applicationId: p.id }))).then((r) => { if ("ok" in r) setRef({ refereeName: "", refereeContact: "", outcome: "positive", note: "" }); })}>
Save reference
</button>
</div>
)}
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "verify_competency"))}>
Verify &amp; continue to documents
</button>
)}
{footer}
</Card>
);
case "DOC_VERIFICATION":
return (
<Card title="Documents" sub="MPO collects & verifies documents, bank and EPF.">
{p.perms.manage ? (
<>
<div className="grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Account name" value={docs.accountName} onChange={(e) => setDocs({ ...docs, accountName: e.target.value })} />
<input className={INPUT} placeholder="Account number" value={docs.accountNumber} onChange={(e) => setDocs({ ...docs, accountNumber: e.target.value })} />
<input className={INPUT} placeholder="IFSC" value={docs.ifsc} onChange={(e) => setDocs({ ...docs, ifsc: e.target.value })} />
<input className={INPUT} placeholder="Bank name" value={docs.bankName} onChange={(e) => setDocs({ ...docs, bankName: e.target.value })} />
<input className={INPUT} placeholder="UAN" value={docs.uan} onChange={(e) => setDocs({ ...docs, uan: e.target.value })} />
<input className={INPUT} placeholder="Aadhaar (last 4)" value={docs.aadhaarLast4} onChange={(e) => setDocs({ ...docs, aadhaarLast4: e.target.value })} />
<input className={INPUT} placeholder="PF number" value={docs.pfNumber} onChange={(e) => setDocs({ ...docs, pfNumber: e.target.value })} />
</div>
<button className={PRIMARY} disabled={pending} onClick={() => run(() => verifyDocuments(fdFrom(docs, { applicationId: p.id })))}>
Verify &amp; continue to salary
</button>
</>
) : (
<p className="text-sm text-neutral-500">Awaiting document verification by the MPO.</p>
)}
{footer}
</Card>
);
case "SALARY_AGREEMENT":
if (p.salaryPending) {
return (
<Card title="Salary" sub="Office-only; the Manager approves.">
<p className="text-sm text-neutral-600">
Proposed: <strong>{p.salary?.currency} {p.salary?.basic}</strong> / {p.salary?.rateBasis.toLowerCase()} · victualing {p.salary?.currency} {p.salary?.victualingPerDay}/day
</p>
{p.perms.approveSalary ? (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveSalary(p.id))}>Approve salary</button>
<ReturnButton label="Return salary" onReturn={(reason) => returnSalary(p.id, reason)} />
</div>
) : (
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Awaiting Manager approval.</p>
)}
{footer}
</Card>
);
}
return (
<Card title="Salary" sub="Office-only; the Manager approves.">
{p.perms.manage ? (
<>
<div className="grid grid-cols-2 gap-2">
<select className={INPUT} value={sal.rateBasis} onChange={(e) => setSal({ ...sal, rateBasis: e.target.value })}>
<option value="MONTHLY">Per month</option>
<option value="DAILY">Per day</option>
</select>
<input className={INPUT} type="number" placeholder="Basic" value={sal.basic} onChange={(e) => setSal({ ...sal, basic: e.target.value })} />
<input className={INPUT} type="number" placeholder="Victualing / day" value={sal.victualingPerDay} onChange={(e) => setSal({ ...sal, victualingPerDay: e.target.value })} />
</div>
<button className={PRIMARY} disabled={pending || !sal.basic} onClick={() => run(() => agreeSalary(fdFrom(sal, { applicationId: p.id })))}>
Agree salary &amp; send for approval
</button>
</>
) : (
<p className="text-sm text-neutral-500">Awaiting the MPO to agree the salary.</p>
)}
{footer}
</Card>
);
case "PROPOSED":
return (
<Card title="Proposed" sub="Awaiting the candidate's acceptance.">
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "propose_accepted"))}>
Candidate accepted schedule interview
</button>
)}
{footer}
</Card>
);
case "INTERVIEW":
return (
<Card title="Interview" sub="MPO records the result; the Manager approves the selection.">
{/* Interview result row */}
{p.interviewResult === "PENDING" && !p.interviewWaived && p.perms.recordInterview && (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, true))}>Interview passed</button>
<button className={DANGER} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, false))}>Interview failed</button>
</div>
)}
{/* Waiver (ex-hand) */}
{p.isExHand && !p.interviewWaived && p.interviewResult === "PENDING" && !p.waiverPending && p.perms.requestWaiver && (
<button className={SECONDARY} disabled={pending} onClick={() => run(() => requestInterviewWaiver(p.id))}>Request interview waiver Manager</button>
)}
{p.waiverPending && (
p.perms.approveWaiver ? (
<div className="flex items-center gap-2">
<span className="text-sm text-warning-700">Waiver requested.</span>
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveInterviewWaiver(p.id))}>Approve waiver</button>
</div>
) : (
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Interview waiver awaiting Manager approval.</p>
)
)}
{/* Selection row */}
{(p.interviewResult === "ACCEPTED" || p.interviewWaived) && (
p.perms.select ? (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => selectCandidate(p.id))}>Approve select</button>
<ReturnButton label="Return" onReturn={(reason) => returnSelection(p.id, reason)} />
</div>
) : (
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">{p.interviewWaived ? "Interview waived" : "Interview passed"} awaiting Manager selection.</p>
)
)}
{footer}
</Card>
);
case "SELECTED":
return (
<Card title="Selected" sub="Ready to onboard.">
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Candidate selected.</p>
<button className={PRIMARY} disabled title="Onboarding arrives in the next phase (3c)">Onboard to crew (next phase)</button>
</Card>
);
case "REJECTED":
return (
<Card title="Rejected">
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{p.rejectedReason ?? "This candidate was rejected."}</p>
</Card>
);
default:
return (
<Card title="Onboarded">
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">This candidate has been onboarded.</p>
</Card>
);
}
}
function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await onReturn(reason);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<>
<button type="button" className={SECONDARY} onClick={() => setOpen(true)}>{label}</button>
<AdminDialog title={label} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
{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">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className={PRIMARY}>{pending ? "Returning…" : "Return"}</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,47 @@
import type { ApplicationStage } from "@prisma/client";
import type { BadgeProps } from "@/components/ui/badge";
type Variant = NonNullable<BadgeProps["variant"]>;
// The 7 board columns in order (mirrors lib/application-pipeline BOARD_STAGES;
// kept here as a client-safe constant for the stepper/board UI).
export const STAGE_ORDER: ApplicationStage[] = [
"SHORTLISTED",
"COMPETENCY_AND_REFERENCES",
"DOC_VERIFICATION",
"SALARY_AGREEMENT",
"PROPOSED",
"INTERVIEW",
"SELECTED",
];
export const STAGE_LABEL: Record<ApplicationStage, string> = {
SHORTLISTED: "Shortlisted",
COMPETENCY_AND_REFERENCES: "Competency & references",
DOC_VERIFICATION: "Documents",
SALARY_AGREEMENT: "Salary",
PROPOSED: "Proposed",
INTERVIEW: "Interview",
SELECTED: "Selected",
REJECTED: "Rejected",
ONBOARDED: "Onboarded",
};
export const STAGE_VARIANT: Record<ApplicationStage, Variant> = {
SHORTLISTED: "outline",
COMPETENCY_AND_REFERENCES: "default",
DOC_VERIFICATION: "default",
SALARY_AGREEMENT: "warning",
PROPOSED: "default",
INTERVIEW: "warning",
SELECTED: "success",
REJECTED: "danger",
ONBOARDED: "success",
};
// Index of a stage within the 7-step flow (1 for REJECTED; 7 for ONBOARDED).
export function stageIndex(stage: ApplicationStage): number {
if (stage === "REJECTED") return -1;
if (stage === "ONBOARDED") return STAGE_ORDER.length;
return STAGE_ORDER.indexOf(stage);
}

View file

@ -0,0 +1,97 @@
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 Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SOURCE_LABEL, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "../candidate-ui";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Candidate" };
export default async function CandidateDetailPage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
const { id } = await params;
const c = await db.crewMember.findUnique({
where: { id },
include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } },
});
if (!c) notFound();
const profile: [string, string][] = [
["Rank applied", c.appliedRank?.name ?? "—"],
["Last rank held", c.currentRank?.name ?? "—"],
["Experience", experienceLabel(c.experienceMonths)],
["Vessel type", c.vesselTypeExperience ?? "—"],
["Source", SOURCE_LABEL[c.source]],
["Email", c.email ?? "—"],
["Phone", c.phone ?? "—"],
];
return (
<div className="max-w-4xl">
<Link href="/crewing/candidates" 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" /> Candidates
</Link>
<div className="mb-6 flex items-start justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
{c.source === "EX_HAND" && (
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
)}
</div>
</div>
{c.source === "EX_HAND" && (
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
<strong>Returning crew.</strong> Prior documents, bank details and tour history are on file from earlier
assignments; the interview may be waived with Manager approval (recruitment pipeline next phase).
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Profile */}
<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">Profile</h2>
</div>
<dl className="divide-y divide-neutral-100">
{profile.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>
{c.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">{c.notes}</p>
</div>
)}
</div>
{/* 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">Recruitment</h2>
</div>
<p className="px-4 py-12 text-center text-sm text-neutral-400">
The 7-stage recruitment pipeline (shortlist competency &amp; references docs
salary proposed interview selected) arrives in the next phase. Applications
against requisitions will appear here.
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
"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 { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { CandidateSource } 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/candidates";
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 };
}
const candidateSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
source: z.nativeEnum(CandidateSource).default("CAREERS"),
appliedRankId: z.string().optional(),
currentRankId: z.string().optional(),
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
vesselTypeExperience: z.string().optional(),
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
phone: z.string().optional(),
notes: z.string().optional(),
});
function parse(formData: FormData) {
return candidateSchema.safeParse({
name: formData.get("name"),
source: (formData.get("source") as string) || undefined,
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
currentRankId: (formData.get("currentRankId") as string) || undefined,
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
vesselTypeExperience: (formData.get("vesselTypeExperience") as string) || undefined,
email: (formData.get("email") as string) || undefined,
phone: (formData.get("phone") as string) || undefined,
notes: (formData.get("notes") as string) || undefined,
});
}
// An EX_HAND source means a returning crew member; everyone else is NEW. The
// CrewStatus follows: ex-hands sit in the pool as EX_HAND, the rest as CANDIDATE.
function derive(source: CandidateSource) {
const isExHand = source === "EX_HAND";
return { type: isExHand ? "EX_HAND" : "NEW", status: isExHand ? "EX_HAND" : "CANDIDATE" } as const;
}
// Store an optional CV upload and return its storage key (null if none).
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
const file = formData.get("cv");
if (!(file instanceof File) || file.size === 0) return null;
const key = buildStorageKey("cv", crewMemberId, file.name);
const buffer = Buffer.from(await file.arrayBuffer());
await uploadBuffer(key, buffer, file.type || "application/octet-stream");
return key;
}
export async function addCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const { type, status } = derive(d.source);
const candidate = await db.crewMember.create({
data: {
name: d.name,
source: d.source,
type,
status,
appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
vesselTypeExperience: d.vesselTypeExperience || null,
email: d.email || null,
phone: d.phone || null,
notes: d.notes || null,
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
},
});
const cvKey = await storeCv(formData, candidate.id);
if (cvKey) await db.crewMember.update({ where: { id: candidate.id }, data: { cvKey } });
revalidatePath(LIST_PATH);
return { ok: true, id: candidate.id };
}
export async function updateCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("id") as string;
if (!id) return { error: "Candidate ID is required" };
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const { type, status } = derive(d.source);
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
if (!existing) return { error: "Candidate not found" };
const cvKey = await storeCv(formData, id);
await db.crewMember.update({
where: { id },
data: {
name: d.name,
source: d.source,
// Don't downgrade an onboarded employee back to a candidate via an edit.
type,
status: existing.status === "EMPLOYEE" ? existing.status : status,
appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
vesselTypeExperience: d.vesselTypeExperience || null,
email: d.email || null,
phone: d.phone || null,
notes: d.notes || null,
...(cvKey ? { cvKey } : {}),
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
},
});
revalidatePath(LIST_PATH);
revalidatePath(`${LIST_PATH}/${id}`);
return { ok: true, id };
}

View file

@ -0,0 +1,256 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { CandidateSource } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { addCandidate, updateCandidate } from "./actions";
import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-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 RankOpt = { id: string; code: string; name: string };
export type EditableCandidate = {
id: string;
name: string;
source: CandidateSource;
appliedRankId: string | null;
currentRankId: string | null;
experienceMonths: number;
vesselTypeExperience: string | null;
email: string | null;
phone: string | null;
notes: string | null;
};
function CandidateFields({
ranks,
state,
set,
fileRef,
}: {
ranks: RankOpt[];
state: FieldState;
set: <K extends keyof FieldState>(k: K, v: FieldState[K]) => void;
fileRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
<input className={INPUT} value={state.name} onChange={(e) => set("name", e.target.value)} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
{SOURCE_OPTIONS.map((s) => (
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank applied for</label>
<select className={INPUT} value={state.appliedRankId} onChange={(e) => set("appliedRankId", e.target.value)}>
<option value=""></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">Rank held (ex-hands)</label>
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
<option value=""></option>
{ranks.map((r) => (
<option key={r.id} value={r.id}>{r.code} {r.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Experience (months)</label>
<input type="number" min={0} className={INPUT} value={state.experienceMonths} onChange={(e) => set("experienceMonths", e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel type</label>
<input className={INPUT} value={state.vesselTypeExperience} onChange={(e) => set("vesselTypeExperience", e.target.value)} placeholder="e.g. Dredger" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Email</label>
<input type="email" className={INPUT} value={state.email} onChange={(e) => set("email", e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Phone</label>
<input className={INPUT} value={state.phone} onChange={(e) => set("phone", e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">CV (PDF/DOC, optional)</label>
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" className="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
<input className={INPUT} value={state.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Optional" />
</div>
</div>
);
}
type FieldState = {
name: string;
source: CandidateSource;
appliedRankId: string;
currentRankId: string;
experienceMonths: string;
vesselTypeExperience: string;
email: string;
phone: string;
notes: string;
};
function emptyState(): FieldState {
return {
name: "", source: "CAREERS", appliedRankId: "", currentRankId: "",
experienceMonths: "0", vesselTypeExperience: "", email: "", phone: "", notes: "",
};
}
function stateFrom(c: EditableCandidate): FieldState {
return {
name: c.name,
source: c.source,
appliedRankId: c.appliedRankId ?? "",
currentRankId: c.currentRankId ?? "",
experienceMonths: String(c.experienceMonths),
vesselTypeExperience: c.vesselTypeExperience ?? "",
email: c.email ?? "",
phone: c.phone ?? "",
notes: c.notes ?? "",
};
}
function buildFormData(state: FieldState, file: File | undefined, id?: string): FormData {
const fd = new FormData();
if (id) fd.set("id", id);
fd.set("name", state.name);
fd.set("source", state.source);
if (state.appliedRankId) fd.set("appliedRankId", state.appliedRankId);
if (state.currentRankId) fd.set("currentRankId", state.currentRankId);
fd.set("experienceMonths", state.experienceMonths || "0");
if (state.vesselTypeExperience) fd.set("vesselTypeExperience", state.vesselTypeExperience);
if (state.email) fd.set("email", state.email);
if (state.phone) fd.set("phone", state.phone);
if (state.notes) fd.set("notes", state.notes);
if (file && file.size > 0) fd.set("cv", file);
return fd;
}
export function AddCandidateButton({ ranks }: { ranks: RankOpt[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [state, setState] = useState<FieldState>(emptyState);
const fileRef = useRef<HTMLInputElement | null>(null);
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const result = await addCandidate(buildFormData(state, fileRef.current?.files?.[0]));
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
setState(emptyState());
if (fileRef.current) fileRef.current.value = "";
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"
>
+ Add candidate
</button>
<AdminDialog title="Add candidate" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
{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 ? "Adding…" : "Add candidate"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditCandidateButton({
candidate,
ranks,
open,
onOpenChange,
}: {
candidate: EditableCandidate;
ranks: RankOpt[];
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [state, setState] = useState<FieldState>(() => stateFrom(candidate));
const fileRef = useRef<HTMLInputElement | null>(null);
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const result = await updateCandidate(buildFormData(state, fileRef.current?.files?.[0], candidate.id));
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
onOpenChange(false);
router.refresh();
}
}
return (
<AdminDialog title="Edit candidate" open={open} onClose={() => onOpenChange(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
{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={() => onOpenChange(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 ? "Saving…" : "Save changes"}
</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -0,0 +1,38 @@
import type { CandidateSource, CrewStatus } from "@prisma/client";
import type { BadgeProps } from "@/components/ui/badge";
type Variant = NonNullable<BadgeProps["variant"]>;
export const SOURCE_LABEL: Record<CandidateSource, string> = {
CAREERS: "Careers",
EX_HAND: "Ex-hand",
WALK_IN: "Walk-in",
REFERRAL: "Referral",
OTHER: "Other",
};
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
export const STATUS_LABEL: Record<CrewStatus, string> = {
PROSPECT: "Prospect",
CANDIDATE: "Candidate",
EMPLOYEE: "Employee",
EX_HAND: "Ex-hand",
BLACKLISTED: "Blacklisted",
};
export const STATUS_VARIANT: Record<CrewStatus, Variant> = {
PROSPECT: "outline",
CANDIDATE: "default",
EMPLOYEE: "success",
EX_HAND: "secondary",
BLACKLISTED: "danger",
};
// Compact experience label, e.g. "3y 6m", "8m", "—".
export function experienceLabel(months: number): string {
if (!months) return "—";
const y = Math.floor(months / 12);
const m = months % 12;
return [y ? `${y}y` : "", m ? `${m}m` : ""].filter(Boolean).join(" ") || "0m";
}

View file

@ -0,0 +1,169 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { CandidateSource, CrewStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
import { SOURCE_LABEL, SOURCE_OPTIONS, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "./candidate-ui";
type CandidateRow = {
id: string;
name: string;
source: CandidateSource;
status: CrewStatus;
appliedRankId: string | null;
appliedRank: string | null;
currentRankId: string | null;
currentRank: string | null;
experienceMonths: number;
vesselTypeExperience: string | null;
email: string | null;
phone: string | null;
notes: string | null;
hasCv: boolean;
};
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";
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 text-primary-700 px-2.5 py-1 text-xs font-medium">
{label}
<button onClick={onClear} className="text-primary-400 hover:text-primary-700" aria-label="Remove filter"></button>
</span>
);
}
function toEditable(c: CandidateRow): EditableCandidate {
return {
id: c.id, name: c.name, source: c.source,
appliedRankId: c.appliedRankId, currentRankId: c.currentRankId,
experienceMonths: c.experienceMonths, vesselTypeExperience: c.vesselTypeExperience,
email: c.email, phone: c.phone, notes: c.notes,
};
}
function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
const [editOpen, setEditOpen] = useState(false);
return (
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
</td>
<td className="px-4 py-3">
<span className={c.source === "EX_HAND" ? "text-purple-700 font-medium text-sm" : "text-neutral-600 text-sm"}>
{SOURCE_LABEL[c.source]}
</span>
</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge></td>
<td className="px-4 py-3 text-right">
<div onClick={(e) => e.stopPropagation()}>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
</RowActionsMenu>
</div>
<EditCandidateButton candidate={toEditable(c)} ranks={ranks} open={editOpen} onOpenChange={setEditOpen} />
</td>
</tr>
);
}
export function CandidatesManager({ candidates, ranks }: { candidates: CandidateRow[]; ranks: RankOpt[] }) {
const [search, setSearch] = useState("");
const [source, setSource] = useState<"ALL" | CandidateSource>("ALL");
const [appliedRankId, setAppliedRankId] = useState("ALL");
const [minExp, setMinExp] = useState("");
const minExpMonths = minExp ? Math.max(0, parseInt(minExp, 10) || 0) : 0;
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return candidates.filter((c) => {
if (source !== "ALL" && c.source !== source) return false;
if (appliedRankId !== "ALL" && c.appliedRankId !== appliedRankId) return false;
if (minExpMonths && c.experienceMonths < minExpMonths) return false;
if (q && !`${c.name} ${c.appliedRank ?? ""} ${c.currentRank ?? ""}`.toLowerCase().includes(q)) return false;
return true;
});
}, [candidates, search, source, appliedRankId, minExpMonths]);
const rankName = (id: string) => ranks.find((r) => r.id === id)?.name ?? id;
const hasFilters = Boolean(search) || source !== "ALL" || appliedRankId !== "ALL" || Boolean(minExp);
const clearAll = () => { setSearch(""); setSource("ALL"); setAppliedRankId("ALL"); setMinExp(""); };
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Candidates</h1>
<p className="text-sm text-neutral-500 mt-0.5">
{candidates.length} in the talent pool · careers applicants, ex-hands, walk-ins and referrals
</p>
</div>
<AddCandidateButton ranks={ranks} />
</div>
{/* Filters */}
<div className="mb-3 flex flex-wrap items-center gap-3">
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
<select className={INPUT} value={source} onChange={(e) => setSource(e.target.value as typeof source)}>
<option value="ALL">All sources</option>
{SOURCE_OPTIONS.map((s) => <option key={s} value={s}>{SOURCE_LABEL[s]}</option>)}
</select>
<select className={INPUT} value={appliedRankId} onChange={(e) => setAppliedRankId(e.target.value)}>
<option value="ALL">Any rank applied</option>
{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}
</select>
<input type="number" min={0} className={`${INPUT} w-40`} placeholder="Min exp (months)" value={minExp} onChange={(e) => setMinExp(e.target.value)} />
</div>
{/* Active filter chips + match count */}
{hasFilters && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{search && <Chip label={`${search}`} onClear={() => setSearch("")} />}
{source !== "ALL" && <Chip label={`Source: ${SOURCE_LABEL[source]}`} onClear={() => setSource("ALL")} />}
{appliedRankId !== "ALL" && <Chip label={`Rank: ${rankName(appliedRankId)}`} onClear={() => setAppliedRankId("ALL")} />}
{minExp && <Chip label={`${minExp} mo`} onClear={() => setMinExp("")} />}
<span className="text-xs text-neutral-500">{filtered.length} match{filtered.length === 1 ? "" : "es"}</span>
<button onClick={clearAll} className="text-xs font-medium text-primary-600 hover:underline">Clear all</button>
</div>
)}
<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">Name</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Rank held</th>
<th className="px-4 py-3">Rank applied</th>
<th className="px-4 py-3">Experience</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 w-12"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
{candidates.length === 0 ? "No candidates yet. Add the first to the pool." : "No candidates match these filters."}
</td>
</tr>
) : (
filtered.map((c) => <CandidateRowView key={c.id} c={c} ranks={ranks} />)
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
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 { CandidatesManager } from "./candidates-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Candidates" };
export default async function CandidatesPage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
const [candidates, ranks] = await Promise.all([
db.crewMember.findMany({
// Active employees live in the Crew directory (Phase 4); the pool is
// everyone still a candidate / ex-hand (spec §8.6 R9).
where: { status: { not: "EMPLOYEE" } },
orderBy: { createdAt: "desc" },
include: {
appliedRank: { select: { name: true } },
currentRank: { select: { name: true } },
},
}),
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
]);
const rows = candidates.map((c) => ({
id: c.id,
name: c.name,
source: c.source,
status: c.status,
appliedRankId: c.appliedRankId,
appliedRank: c.appliedRank?.name ?? null,
currentRankId: c.currentRankId,
currentRank: c.currentRank?.name ?? null,
experienceMonths: c.experienceMonths,
vesselTypeExperience: c.vesselTypeExperience,
email: c.email,
phone: c.phone,
notes: c.notes,
hasCv: Boolean(c.cvKey),
}));
return <CandidatesManager candidates={rows} ranks={ranks} />;
}

View file

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

View file

@ -0,0 +1,63 @@
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 { PipelineBoard } from "./pipeline-board";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Recruitment pipeline" };
export default async function PipelinePage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
if (!hasPermission(role, "view_requisitions")) redirect("/dashboard");
const { id } = await params;
const requisition = await db.requisition.findUnique({
where: { id },
include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
});
if (!requisition) notFound();
const applications = await db.application.findMany({
where: { requisitionId: id },
include: { crewMember: { select: { id: true, name: true, type: true, experienceMonths: true } } },
orderBy: { createdAt: "asc" },
});
const canManage = hasPermission(role, "manage_candidates");
// Candidates available to add: in the pool (not employees) and not already applied here.
const appliedIds = new Set(applications.map((a) => a.crewMemberId));
const pool = canManage
? (await db.crewMember.findMany({
where: { status: { not: "EMPLOYEE" } },
orderBy: { name: "asc" },
select: { id: true, name: true, type: true },
})).filter((c) => !appliedIds.has(c.id))
: [];
return (
<PipelineBoard
requisition={{
id: requisition.id,
code: requisition.code,
rank: requisition.rank.name,
location: requisition.vessel?.name ?? requisition.site?.name ?? "—",
status: requisition.status,
}}
applications={applications.map((a) => ({
id: a.id,
stage: a.stage,
crewName: a.crewMember.name,
isExHand: a.crewMember.type === "EX_HAND",
experienceMonths: a.crewMember.experienceMonths,
}))}
pool={pool}
canManage={canManage}
/>
);
}

View file

@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import type { ApplicationStage, RequisitionStatus } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { STAGE_ORDER, STAGE_LABEL } from "../../../applications/application-ui";
import { addApplication } from "../../../applications/actions";
type AppCard = { id: string; stage: ApplicationStage; crewName: string; isExHand: boolean; experienceMonths: number };
type PoolItem = { id: string; name: string; type: string };
export function PipelineBoard({
requisition,
applications,
pool,
canManage,
}: {
requisition: { id: string; code: string; rank: string; location: string; status: RequisitionStatus };
applications: AppCard[];
pool: PoolItem[];
canManage: boolean;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [crewMemberId, setCrewMemberId] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function add(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
fd.set("requisitionId", requisition.id);
fd.set("crewMemberId", crewMemberId);
const res = await addApplication(fd);
setPending(false);
if ("error" in res) setError(res.error);
else { setOpen(false); setCrewMemberId(""); router.refresh(); }
}
const byStage = (s: ApplicationStage) => applications.filter((a) => a.stage === s);
const rejected = applications.filter((a) => a.stage === "REJECTED");
return (
<div>
<Link href={`/crewing/requisitions/${requisition.id}`} 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" /> Requisition
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">{requisition.rank} {requisition.location}</h1>
<p className="text-sm text-neutral-500 mt-0.5">Recruitment pipeline · <span className="font-mono">{requisition.code}</span> · {applications.length} candidate{applications.length === 1 ? "" : "s"}</p>
</div>
{canManage && (
<button onClick={() => setOpen(true)} className="rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-50">
+ Add candidate
</button>
)}
</div>
<div className="flex gap-3 overflow-x-auto pb-4">
{STAGE_ORDER.map((s) => {
const cards = byStage(s);
return (
<div key={s} className="w-56 shrink-0">
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">{STAGE_LABEL[s]}</span>
<span className="text-xs text-neutral-400">{cards.length}</span>
</div>
<div className="space-y-2 min-h-[60px] rounded-lg bg-neutral-50 p-2">
{cards.map((a) => (
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="block rounded-md border border-neutral-200 bg-white p-3 hover:border-primary-300 hover:shadow-sm transition">
<p className="text-sm font-medium text-neutral-900">{a.crewName}</p>
<p className="text-xs text-neutral-500 mt-0.5">
{Math.floor(a.experienceMonths / 12)} yrs
{a.isExHand && <span className="ml-1 text-purple-600">· ex-hand</span>}
</p>
</Link>
))}
{cards.length === 0 && <p className="text-center text-xs text-neutral-300 py-2"></p>}
</div>
</div>
);
})}
</div>
{rejected.length > 0 && (
<div className="mt-6">
<p className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Rejected ({rejected.length})</p>
<div className="flex flex-wrap gap-2">
{rejected.map((a) => (
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-500 hover:bg-neutral-50">
{a.crewName}
</Link>
))}
</div>
</div>
)}
<AdminDialog title="Add candidate to pipeline" open={open} onClose={() => setOpen(false)}>
<form onSubmit={add} className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Candidate</label>
<select className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" value={crewMemberId} onChange={(e) => setCrewMemberId(e.target.value)} required>
<option value=""> Select from the pool </option>
{pool.map((c) => (
<option key={c.id} value={c.id}>{c.name}{c.type === "EX_HAND" ? " (ex-hand)" : ""}</option>
))}
</select>
{pool.length === 0 && <p className="mt-1 text-xs text-neutral-400">No available candidates. Add candidates from the Candidates page first.</p>}
</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">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !crewMemberId} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Adding…" : "Add to pipeline"}</button>
</div>
</form>
</AdminDialog>
</div>
);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,242 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { raiseRequisition, convertReliefToRequisition } from "./actions";
import { REASON_OPTIONS, REASON_LABEL } from "./requisition-ui";
const INPUT =
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
type Opt = { id: string; name: string };
type RankOpt = { id: string; code: string; name: string };
// A single "Vessel / site" picker — values are encoded "v:<id>" / "s:<id>" so
// one control covers both cost axes (spec §9 modal). Returns "" when unset.
function LocationSelect({
value,
onChange,
vessels,
sites,
}: {
value: string;
onChange: (v: string) => void;
vessels: Opt[];
sites: Opt[];
}) {
return (
<select className={INPUT} value={value} onChange={(e) => onChange(e.target.value)}>
<option value=""> Select vessel or site </option>
{vessels.length > 0 && (
<optgroup label="Vessels">
{vessels.map((v) => (
<option key={v.id} value={`v:${v.id}`}>{v.name}</option>
))}
</optgroup>
)}
{sites.length > 0 && (
<optgroup label="Sites">
{sites.map((s) => (
<option key={s.id} value={`s:${s.id}`}>{s.name}</option>
))}
</optgroup>
)}
</select>
);
}
function applyLocation(fd: FormData, location: string) {
if (location.startsWith("v:")) fd.set("vesselId", location.slice(2));
else if (location.startsWith("s:")) fd.set("siteId", location.slice(2));
}
// ── Raise requisition (MPO / Manager) ──────────────────────────────────────────
export function RaiseRequisitionButton({
ranks,
vessels,
sites,
}: {
ranks: RankOpt[];
vessels: Opt[];
sites: Opt[];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [rankId, setRankId] = useState("");
const [location, setLocation] = useState("");
const [reason, setReason] = useState(REASON_OPTIONS[0]);
const [neededBy, setNeededBy] = useState("");
const [notes, setNotes] = useState("");
function reset() {
setRankId(""); setLocation(""); setReason(REASON_OPTIONS[0]); setNeededBy(""); setNotes(""); setError("");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData();
fd.set("rankId", rankId);
applyLocation(fd, location);
fd.set("reason", reason);
if (neededBy) fd.set("neededBy", neededBy);
if (notes) fd.set("notes", notes);
const result = await raiseRequisition(fd);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
reset();
router.refresh();
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
>
+ Raise requisition
</button>
<AdminDialog title="Raise requisition" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
<select className={INPUT} value={rankId} onChange={(e) => setRankId(e.target.value)} required>
<option value=""> Select rank </option>
{ranks.map((r) => (
<option key={r.id} value={r.id}>{r.code} {r.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel / site *</label>
<LocationSelect value={location} onChange={setLocation} vessels={vessels} sites={sites} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
{REASON_OPTIONS.map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
</div>
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Raising…" : "Raise requisition"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
// ── Convert a relief request into a requisition (MPO / Manager) ─────────────────
export function ConvertReliefButton({
reliefRequestId,
label,
}: {
reliefRequestId: string;
label: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [reason, setReason] = useState<typeof REASON_OPTIONS[number]>("REPLACEMENT");
const [neededBy, setNeededBy] = useState("");
const [notes, setNotes] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData();
fd.set("reliefRequestId", reliefRequestId);
fd.set("reason", reason);
if (neededBy) fd.set("neededBy", neededBy);
if (notes) fd.set("notes", notes);
const result = await convertReliefToRequisition(fd);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
router.refresh();
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
>
Open
</button>
<AdminDialog title="Convert to requisition" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-neutral-600">
Convert the relief request <span className="font-medium text-neutral-900">{label}</span> into an open
requisition so sourcing can begin.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
{REASON_OPTIONS.map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Converting…" : "Convert"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,52 @@
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
import type { BadgeProps } from "@/components/ui/badge";
type Variant = NonNullable<BadgeProps["variant"]>;
// Status → badge variant (Crewing-Implementation-Spec §8.2).
export const STATUS_VARIANT: Record<RequisitionStatus, Variant> = {
OPEN: "outline",
SHORTLISTING: "default",
PROPOSING: "default",
INTERVIEWING: "warning",
SELECTED: "default",
FILLED: "success",
CANCELLED: "danger",
};
export const STATUS_LABEL: Record<RequisitionStatus, string> = {
OPEN: "Open",
SHORTLISTING: "Shortlisting",
PROPOSING: "Proposing",
INTERVIEWING: "Interviewing",
SELECTED: "Selected",
FILLED: "Filled",
CANCELLED: "Cancelled",
};
export const REASON_LABEL: Record<RequisitionReason, string> = {
NEW_VACANCY: "New vacancy",
REPLACEMENT: "Replacement",
LEAVE: "Leave cover",
SIGN_OFF: "Sign-off",
END_OF_CONTRACT: "End of contract",
OTHER: "Other",
};
export const REASON_OPTIONS: RequisitionReason[] = [
"NEW_VACANCY",
"REPLACEMENT",
"LEAVE",
"SIGN_OFF",
"END_OF_CONTRACT",
"OTHER",
];
// Compact "age" label (e.g. "3d", "5h", "12m") relative to now.
export function ageLabel(iso: string): string {
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
if (mins < 60) return `${Math.max(mins, 0)}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
return `${Math.floor(hrs / 24)}d`;
}

View file

@ -0,0 +1,204 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { RaiseRequisitionButton, ConvertReliefButton } from "./requisition-form";
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "./requisition-ui";
type RequisitionRow = {
id: string;
code: string;
status: RequisitionStatus;
reason: RequisitionReason;
autoRaised: boolean;
rankName: string;
location: string;
raisedBy: string;
createdAt: string;
};
type ReliefRow = {
id: string;
rankName: string;
location: string;
note: string | null;
requestedBy: string;
createdAt: string;
};
type Opt = { id: string; name: string };
type RankOpt = { id: string; code: string; name: string };
const INPUT =
"rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const STATUS_FILTERS: RequisitionStatus[] = [
"OPEN", "SHORTLISTING", "PROPOSING", "INTERVIEWING", "SELECTED", "FILLED", "CANCELLED",
];
export function RequisitionsManager({
requisitions,
reliefRequests,
ranks,
vessels,
sites,
canRaise,
canConvert,
}: {
requisitions: RequisitionRow[];
reliefRequests: ReliefRow[];
ranks: RankOpt[];
vessels: Opt[];
sites: Opt[];
canRaise: boolean;
canConvert: boolean;
}) {
const [search, setSearch] = useState("");
const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL");
const [location, setLocation] = useState("ALL");
const locations = useMemo(
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
[requisitions]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return requisitions.filter((r) => {
if (status !== "ALL" && r.status !== status) return false;
if (location !== "ALL" && r.location !== location) return false;
if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
return true;
});
}, [requisitions, search, status, location]);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Requisitions</h1>
<p className="text-sm text-neutral-500 mt-0.5">
{requisitions.length} requisition{requisitions.length === 1 ? "" : "s"} · vacancies being sourced and filled
</p>
</div>
{canRaise && <RaiseRequisitionButton ranks={ranks} vessels={vessels} sites={sites} />}
</div>
{/* Filters */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<input
className={`${INPUT} flex-1 min-w-[200px]`}
placeholder="Search code, rank or location…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className={INPUT} value={status} onChange={(e) => setStatus(e.target.value as typeof status)}>
<option value="ALL">All statuses</option>
{STATUS_FILTERS.map((s) => (
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
))}
</select>
<select className={INPUT} value={location} onChange={(e) => setLocation(e.target.value)}>
<option value="ALL">All vessels / sites</option>
{locations.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</div>
{/* Requisitions table */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Requisition</th>
<th className="px-4 py-3">Vessel / site</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Reason</th>
<th className="px-4 py-3">Raised by</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
No requisitions match these filters.
</td>
</tr>
) : (
filtered.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/crewing/requisitions/${r.id}`} className="block">
<span className="font-mono text-xs text-neutral-900">{r.code}</span>
<span className="ml-2 text-xs text-neutral-400">{ageLabel(r.createdAt)} ago</span>
{r.autoRaised && (
<span className="ml-2 rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-[10px] font-medium">
Auto
</span>
)}
</Link>
</td>
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
<td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td>
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Relief requests from sites (spec §8.2 / R3 / R6) */}
<div className="mt-8">
<h2 className="text-sm font-semibold text-neutral-900">Relief requests from sites</h2>
<p className="text-xs text-neutral-500 mt-0.5 mb-3">
Foreseen gaps flagged by site staff. Convert one into a requisition to start sourcing.
</p>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Vessel / site</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Note</th>
<th className="px-4 py-3">Requested by</th>
<th className="px-4 py-3 w-20"></th>
</tr>
</thead>
<tbody>
{reliefRequests.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
No open relief requests.
</td>
</tr>
) : (
reliefRequests.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
<td className="px-4 py-3 text-neutral-500">{r.note ?? "—"}</td>
<td className="px-4 py-3 text-neutral-500">{r.requestedBy}</td>
<td className="px-4 py-3 text-right">
{canConvert && (
<ConvertReliefButton reliefRequestId={r.id} label={`${r.rankName} on ${r.location}`} />
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,99 @@
import type { ApplicationStage, Role } from "@prisma/client";
// Recruitment pipeline state machine (Crewing-Implementation-Spec §5.1) — mirrors
// po-state-machine / requisition-state-machine. The 7 board stages advance in
// order; ONBOARDED is the terminal system state set at onboarding (Phase 3c);
// REJECTED is an orthogonal branch reachable from any active stage.
//
// Stage advances are modelled here. The within-stage work — recording reference
// checks, capturing bank/EPF, agreeing the salary, recording the interview
// result, requesting a waiver — happens in server actions; this machine governs
// when a candidate may move to the next column and who may move them.
//
// Manager-gated advances (spec §6): SALARY_AGREEMENT → PROPOSED (salary approval)
// and INTERVIEW → SELECTED (final selection) are Manager-only. The interview
// waiver is a separate Manager-approved action (R2), never automatic.
export type ApplicationAction =
| "start_competency" // SHORTLISTED → COMPETENCY_AND_REFERENCES
| "verify_competency" // COMPETENCY_AND_REFERENCES → DOC_VERIFICATION
| "verify_docs" // DOC_VERIFICATION → SALARY_AGREEMENT
| "approve_salary" // SALARY_AGREEMENT → PROPOSED (Manager)
| "propose_accepted" // PROPOSED → INTERVIEW
| "select" // INTERVIEW → SELECTED (Manager)
| "onboard"; // SELECTED → ONBOARDED (Phase 3c)
interface Transition {
to: ApplicationStage;
allowedRoles: Role[];
}
type TransitionMap = Partial<Record<ApplicationAction, Transition>>;
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
const TRANSITIONS: Partial<Record<ApplicationStage, TransitionMap>> = {
SHORTLISTED: {
start_competency: { to: "COMPETENCY_AND_REFERENCES", allowedRoles: SOURCING_ROLES },
},
COMPETENCY_AND_REFERENCES: {
verify_competency: { to: "DOC_VERIFICATION", allowedRoles: SOURCING_ROLES },
},
DOC_VERIFICATION: {
verify_docs: { to: "SALARY_AGREEMENT", allowedRoles: SOURCING_ROLES },
},
SALARY_AGREEMENT: {
// Manager approves the agreed salary structure (spec §6).
approve_salary: { to: "PROPOSED", allowedRoles: MANAGER_ROLES },
},
PROPOSED: {
propose_accepted: { to: "INTERVIEW", allowedRoles: SOURCING_ROLES },
},
INTERVIEW: {
// Final selection is a Manager approval (spec §6). The action enforces that
// the interview was accepted or a Manager-approved waiver is in place (R2).
select: { to: "SELECTED", allowedRoles: MANAGER_ROLES },
},
SELECTED: {
// The onboarding side-effect (Phase 3c) moves SELECTED → ONBOARDED.
onboard: { to: "ONBOARDED", allowedRoles: SOURCING_ROLES },
},
};
// The 7 visible board columns, in order (spec §8.4). ONBOARDED/REJECTED are not
// board columns — they are terminal/branch states.
export const BOARD_STAGES: ApplicationStage[] = [
"SHORTLISTED",
"COMPETENCY_AND_REFERENCES",
"DOC_VERIFICATION",
"SALARY_AGREEMENT",
"PROPOSED",
"INTERVIEW",
"SELECTED",
];
export function getTransition(from: ApplicationStage, action: ApplicationAction): Transition | null {
return TRANSITIONS[from]?.[action] ?? null;
}
export function canPerformAction(from: ApplicationStage, action: ApplicationAction, role: Role): boolean {
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
}
export function getAvailableActions(stage: ApplicationStage, role: Role): ApplicationAction[] {
const map = TRANSITIONS[stage];
if (!map) return [];
return (Object.keys(map) as ApplicationAction[]).filter((a) => canPerformAction(stage, a, role));
}
// ── Rejection (orthogonal) ───────────────────────────────────────────────────
// A candidate may be rejected with remarks from any active stage (not once
// SELECTED/ONBOARDED, and not again if already REJECTED), by MPO or Manager.
export const REJECT_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
const TERMINAL: ApplicationStage[] = ["SELECTED", "ONBOARDED", "REJECTED"];
export function canReject(from: ApplicationStage, role: Role): boolean {
return !TERMINAL.includes(from) && REJECT_ROLES.includes(role);
}

View file

@ -21,6 +21,19 @@ export type NotificationEvent =
| "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"
| "CANDIDATE_PROPOSED"
| "SALARY_FOR_APPROVAL"
| "SELECTION_FOR_APPROVAL"
| "WAIVER_REQUESTED";
interface NotifyParams {
event: NotificationEvent;
po: PurchaseOrder & { submitter: User };
@ -398,3 +411,103 @@ function buildHtml(
</body>
</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",
CANDIDATE_PROPOSED: "View Candidate",
SALARY_FOR_APPROVAL: "Review Salary",
SELECTION_FOR_APPROVAL: "Review Selection",
WAIVER_REQUESTED: "Review Waiver",
};
export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {
await Promise.allSettled(
recipients.map(async (recipient) => {
let status = "sent";
if (isDev) {
console.log(
`\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${body}\n Link: ${APP_URL}${link ?? ""}\n`
);
} else {
try {
const { error } = await resend!.emails.send({
from: FROM,
to: recipient.email,
subject,
html: buildCrewHtml(event, recipient, subject, body, link),
});
if (error) status = "failed";
} catch {
status = "failed";
}
}
await db.notification.create({
data: { subject, body, link: link ?? null, status, userId: recipient.id },
});
})
);
}
function buildCrewHtml(
event: CrewNotificationEvent,
recipient: User,
subject: string,
body: string,
link?: string
): string {
const actionUrl = link ? `${APP_URL}${link}` : APP_URL;
const actionLabel = CREW_ACTION_LABEL[event] ?? "Open PPMS";
return `<!DOCTYPE html>
<html>
<head><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,-apple-system,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 16px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
<!-- Header -->
<tr><td style="background:#1d4ed8;padding:20px 32px;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">PPMS</span>
<span style="font-size:13px;color:#93c5fd;margin-left:10px;">Crewing</span>
</td></tr>
<!-- Body -->
<tr><td style="padding:32px;">
<p style="margin:0 0 20px;font-size:15px;color:#111827;">Hi ${recipient.name},</p>
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">${body}</p>
<table cellpadding="0" cellspacing="0">
<tr><td style="background:#2563eb;border-radius:8px;">
<a href="${actionUrl}" style="display:inline-block;padding:12px 24px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${actionLabel} </a>
</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="background:#f8fafc;border-top:1px solid #e5e7eb;padding:16px 32px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">${subject}</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}

View file

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

View file

@ -0,0 +1,115 @@
/**
* 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"] } },
});
}
/** Manager recipients — for the approval gates (salary / selection / waiver). */
export function getManagerRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANAGER", "SUPERUSER"] } },
});
}
/**
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
* (later phases) all funnel through here. See spec §5.2/§5.3 (R6).
*/
export async function autoRaiseRequisition(
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">
): Promise<RequisitionWithRefs> {
const requisition = await db.$transaction((tx) =>
createRequisitionTx(tx, { ...input, raisedById: null, autoRaised: true })
);
const recipients = await getOfficeRecipients();
const loc = requisitionLocationLabel(requisition);
await notifyCrew({
event: "REQUISITION_RAISED",
recipients,
subject: `Requisition ${requisition.code} auto-raised`,
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
link: `/crewing/requisitions/${requisition.id}`,
});
return requisition;
}

View file

@ -0,0 +1,88 @@
import type { RequisitionStatus, Role } from "@prisma/client";
// Requisition lifecycle state machine — mirrors the PO state machine
// (lib/po-state-machine.ts) and the reconciled spec (Crewing-Implementation-Spec
// §5.2): OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED,
// with CANCELLED reachable from OPEN/SHORTLISTING (Manager).
//
// The intermediate stage advances are driven by the recruitment pipeline that
// lands in Phase 3; they are modelled here now so the transitions, allowed
// roles and audit are settled and testable. Phase 2 wires raise (create OPEN)
// and cancel via server actions; selection is Manager-only (spec §6).
export type RequisitionAction =
| "start_shortlisting"
| "mark_proposing"
| "start_interviewing"
| "mark_selected"
| "mark_filled";
interface Transition {
to: RequisitionStatus;
allowedRoles: Role[];
requiresNote: boolean;
}
type TransitionMap = Partial<Record<RequisitionAction, Transition>>;
// MPO (MANNING) and Manager source recruitment; final selection is Manager-only.
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
const TRANSITIONS: Partial<Record<RequisitionStatus, TransitionMap>> = {
OPEN: {
start_shortlisting: { to: "SHORTLISTING", allowedRoles: SOURCING_ROLES, requiresNote: false },
},
SHORTLISTING: {
mark_proposing: { to: "PROPOSING", allowedRoles: SOURCING_ROLES, requiresNote: false },
},
PROPOSING: {
start_interviewing: { to: "INTERVIEWING", allowedRoles: SOURCING_ROLES, requiresNote: false },
},
INTERVIEWING: {
// Final selection of a candidate is a Manager approval (spec §6).
mark_selected: { to: "SELECTED", allowedRoles: MANAGER_ROLES, requiresNote: false },
},
SELECTED: {
// The onboarding side-effect (Phase 3) fills the vacancy.
mark_filled: { to: "FILLED", allowedRoles: SOURCING_ROLES, requiresNote: false },
},
};
export function getTransition(from: RequisitionStatus, action: RequisitionAction): Transition | null {
return TRANSITIONS[from]?.[action] ?? null;
}
export function canPerformAction(
from: RequisitionStatus,
action: RequisitionAction,
role: Role
): boolean {
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
}
export function getAvailableActions(status: RequisitionStatus, role: Role): RequisitionAction[] {
const map = TRANSITIONS[status];
if (!map) return [];
return (Object.keys(map) as RequisitionAction[]).filter((action) =>
canPerformAction(status, action, role)
);
}
export function requiresNote(from: RequisitionStatus, action: RequisitionAction): boolean {
return getTransition(from, action)?.requiresNote ?? false;
}
// ── Cancellation (orthogonal) ────────────────────────────────────────────────
// A requisition may be withdrawn while it is still early in the pipeline — OPEN
// or SHORTLISTING (spec §5.2) — and a reason is required. WHO may cancel is the
// `cancel_requisition` grant (spec §6: MPO + Manager + SuperUser); the actions
// enforce that permission, and CANCEL_ROLES mirrors it so the state machine and
// the matrix agree. Modelled separately from TRANSITIONS, like PO CANCEL_ROLES.
export const CANCEL_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
export const CANCELLABLE_FROM: RequisitionStatus[] = ["OPEN", "SHORTLISTING"];
export function canCancel(from: RequisitionStatus, role: Role): boolean {
return CANCELLABLE_FROM.includes(from) && CANCEL_ROLES.includes(role);
}

View file

@ -44,13 +44,15 @@ export async function generateDownloadUrl(
}
export function buildStorageKey(
type: "po-document" | "receipt",
poId: string,
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
// phases — see Crewing-Implementation-Spec §4.5.
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
ownerId: string,
fileName: string
): string {
const timestamp = Date.now();
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
return `${type}/${poId}/${timestamp}-${safe}`;
return `${type}/${ownerId}/${timestamp}-${safe}`;
}
export function buildSignatureKey(userId: string, ext: string): string {

View file

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

View file

@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "CrewStatus" AS ENUM ('PROSPECT', 'CANDIDATE', 'EMPLOYEE', 'EX_HAND', 'BLACKLISTED');
-- CreateEnum
CREATE TYPE "CandidateType" AS ENUM ('NEW', 'EX_HAND');
-- CreateEnum
CREATE TYPE "CandidateSource" AS ENUM ('CAREERS', 'EX_HAND', 'WALK_IN', 'REFERRAL', 'OTHER');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_ADDED';
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_UPDATED';
-- AlterTable
ALTER TABLE "CrewAction" ADD COLUMN "crewMemberId" TEXT;
-- CreateTable
CREATE TABLE "CrewMember" (
"id" TEXT NOT NULL,
"employeeId" TEXT,
"name" TEXT NOT NULL,
"status" "CrewStatus" NOT NULL DEFAULT 'CANDIDATE',
"type" "CandidateType" NOT NULL DEFAULT 'NEW',
"source" "CandidateSource" NOT NULL DEFAULT 'CAREERS',
"email" TEXT,
"phone" TEXT,
"dob" TIMESTAMP(3),
"experienceMonths" INTEGER NOT NULL DEFAULT 0,
"vesselTypeExperience" TEXT,
"cvKey" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"currentRankId" TEXT,
"appliedRankId" TEXT,
CONSTRAINT "CrewMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CrewMember_employeeId_key" ON "CrewMember"("employeeId");
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewMember" ADD CONSTRAINT "CrewMember_currentRankId_fkey" FOREIGN KEY ("currentRankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewMember" ADD CONSTRAINT "CrewMember_appliedRankId_fkey" FOREIGN KEY ("appliedRankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -0,0 +1,168 @@
-- CreateEnum
CREATE TYPE "ApplicationStage" AS ENUM ('SHORTLISTED', 'COMPETENCY_AND_REFERENCES', 'DOC_VERIFICATION', 'SALARY_AGREEMENT', 'PROPOSED', 'INTERVIEW', 'SELECTED', 'REJECTED', 'ONBOARDED');
-- CreateEnum
CREATE TYPE "ApplicationGateType" AS ENUM ('COMPETENCY_REFERENCE', 'DOCUMENT', 'SALARY', 'INTERVIEW', 'WAIVER', 'SELECTION');
-- CreateEnum
CREATE TYPE "GateResult" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED');
-- CreateEnum
CREATE TYPE "InterviewOutcome" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "SalaryRateBasis" AS ENUM ('MONTHLY', 'DAILY');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'APPLICATION_CREATED';
ALTER TYPE "CrewActionType" ADD VALUE 'GATE_PASSED';
ALTER TYPE "CrewActionType" ADD VALUE 'GATE_FAILED';
ALTER TYPE "CrewActionType" ADD VALUE 'REFERENCE_RECORDED';
ALTER TYPE "CrewActionType" ADD VALUE 'SALARY_AGREED';
ALTER TYPE "CrewActionType" ADD VALUE 'SALARY_APPROVED';
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_PROPOSED';
ALTER TYPE "CrewActionType" ADD VALUE 'INTERVIEW_RECORDED';
ALTER TYPE "CrewActionType" ADD VALUE 'WAIVER_REQUESTED';
ALTER TYPE "CrewActionType" ADD VALUE 'WAIVER_APPROVED';
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_SELECTED';
ALTER TYPE "CrewActionType" ADD VALUE 'APPLICATION_REJECTED';
-- AlterTable
ALTER TABLE "CrewAction" ADD COLUMN "applicationId" TEXT;
-- CreateTable
CREATE TABLE "Application" (
"id" TEXT NOT NULL,
"stage" "ApplicationStage" NOT NULL DEFAULT 'SHORTLISTED',
"type" "CandidateType" NOT NULL DEFAULT 'NEW',
"interviewResult" "InterviewOutcome" NOT NULL DEFAULT 'PENDING',
"interviewWaived" BOOLEAN NOT NULL DEFAULT false,
"rejectedReason" TEXT,
"rejectedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"requisitionId" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
CONSTRAINT "Application_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApplicationGate" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"gate" "ApplicationGateType" NOT NULL,
"result" "GateResult" NOT NULL DEFAULT 'PENDING',
"note" TEXT,
"decidedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApplicationGate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReferenceCheck" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"refereeName" TEXT NOT NULL,
"refereeContact" TEXT,
"outcome" TEXT,
"note" TEXT,
"recordedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReferenceCheck_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalaryStructure" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"rateBasis" "SalaryRateBasis" NOT NULL DEFAULT 'MONTHLY',
"basic" DECIMAL(12,2) NOT NULL,
"victualingPerDay" DECIMAL(12,2) NOT NULL DEFAULT 0,
"allowances" JSONB,
"currency" TEXT NOT NULL DEFAULT 'INR',
"effectiveFrom" TIMESTAMP(3),
"effectiveTo" TIMESTAMP(3),
"approvedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalaryStructure_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BankDetail" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"accountName" TEXT,
"accountNumber" TEXT,
"ifsc" TEXT,
"bankName" TEXT,
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
"verifiedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BankDetail_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EpfDetail" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"uan" TEXT,
"aadhaarLast4" TEXT,
"pfNumber" TEXT,
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
"verifiedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EpfDetail_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Application_requisitionId_crewMemberId_key" ON "Application"("requisitionId", "crewMemberId");
-- CreateIndex
CREATE UNIQUE INDEX "ApplicationGate_applicationId_gate_key" ON "ApplicationGate"("applicationId", "gate");
-- CreateIndex
CREATE UNIQUE INDEX "BankDetail_crewMemberId_key" ON "BankDetail"("crewMemberId");
-- CreateIndex
CREATE UNIQUE INDEX "EpfDetail_crewMemberId_key" ON "EpfDetail"("crewMemberId");
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Application" ADD CONSTRAINT "Application_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Application" ADD CONSTRAINT "Application_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApplicationGate" ADD CONSTRAINT "ApplicationGate_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReferenceCheck" ADD CONSTRAINT "ReferenceCheck_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalaryStructure" ADD CONSTRAINT "SalaryStructure_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BankDetail" ADD CONSTRAINT "BankDetail_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EpfDetail" ADD CONSTRAINT "EpfDetail_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -87,6 +87,139 @@ enum SeafarerDocType {
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,
// Phase 3a adds candidate intake.
enum CrewActionType {
REQUISITION_RAISED
REQUISITION_ADVANCED
REQUISITION_FILLED
REQUISITION_CANCELLED
RELIEF_REQUESTED
RELIEF_CONVERTED
RELIEF_CANCELLED
CANDIDATE_ADDED
CANDIDATE_UPDATED
APPLICATION_CREATED
GATE_PASSED
GATE_FAILED
REFERENCE_RECORDED
SALARY_AGREED
SALARY_APPROVED
CANDIDATE_PROPOSED
INTERVIEW_RECORDED
WAIVER_REQUESTED
WAIVER_APPROVED
CANDIDATE_SELECTED
APPLICATION_REJECTED
}
// ─── Crewing recruitment pipeline (Phase 3b: Epic C) ────────────────────────
// The gated 7-stage application pipeline (Crewing-Implementation-Spec §5.1).
// ONBOARDED is the terminal system state set at onboarding (Phase 3c);
// REJECTED is the branch reachable from any active stage.
enum ApplicationStage {
SHORTLISTED
COMPETENCY_AND_REFERENCES
DOC_VERIFICATION
SALARY_AGREEMENT
PROPOSED
INTERVIEW
SELECTED
REJECTED
ONBOARDED
}
// A vetting gate on an application. SALARY / SELECTION / WAIVER are the
// Manager-decided gates that surface in the central Approvals queue (§8.13).
enum ApplicationGateType {
COMPETENCY_REFERENCE
DOCUMENT
SALARY
INTERVIEW
WAIVER
SELECTION
}
enum GateResult {
PENDING
VERIFIED
REJECTED
}
// MPO's recorded interview outcome (Manager then approves selection).
enum InterviewOutcome {
PENDING
ACCEPTED
REJECTED
}
// Salary capture basis — the other is derived (R10/A4). Effective-dated.
enum SalaryRateBasis {
MONTHLY
DAILY
}
// ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
// A CrewMember is the talent-pool spine: a row exists from first contact and
// persists through CANDIDATE → EMPLOYEE → EX_HAND. `employeeId` is assigned only
// at onboarding (Phase 3c). See Crewing-Data-Model §4 + Implementation-Spec §8.6.
enum CrewStatus {
PROSPECT
CANDIDATE
EMPLOYEE
EX_HAND
BLACKLISTED
}
// NEW applicants vs returning EX_HAND crew (drives the ex-hand affordances).
enum CandidateType {
NEW
EX_HAND
}
// Where the candidate came from (the §8.6 "Source" column; ex-hand renders purple).
enum CandidateSource {
CAREERS
EX_HAND
WALK_IN
REFERRAL
OTHER
}
model User {
id String @id @default(cuid())
employeeId String @unique
@ -99,12 +232,15 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submittedPOs PurchaseOrder[] @relation("Submitter")
actions POAction[]
notifications Notification[]
consumption ItemConsumption[]
superUserRequests SuperUserRequest[] @relation("Requester")
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
submittedPOs PurchaseOrder[] @relation("Submitter")
actions POAction[]
notifications Notification[]
consumption ItemConsumption[]
superUserRequests SuperUserRequest[] @relation("Requester")
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
reliefRequested ReliefRequest[] @relation("ReliefRequester")
crewActions CrewAction[]
}
model SuperUserRequest {
@ -133,15 +269,19 @@ model Site {
purchaseOrders PurchaseOrder[]
inventory ItemInventory[]
consumption ItemConsumption[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
}
model Vessel {
id String @id @default(cuid())
name String
code String @unique
isActive Boolean @default(true)
id String @id @default(cuid())
name String
code String @unique
isActive Boolean @default(true)
purchaseOrders PurchaseOrder[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
}
model Company {
@ -155,8 +295,8 @@ model Company {
email String?
invoiceEmail String?
invoiceAddress String?
logoKey String? // storage key for uploaded logo image (top of exported POs)
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
logoKey String? // storage key for uploaded logo image (top of exported POs)
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -180,12 +320,12 @@ model Account {
}
model VendorContact {
id String @id @default(cuid())
id String @id @default(cuid())
name String
role String?
mobile String?
email String?
isPrimary Boolean @default(false)
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
vendorId String
@ -193,17 +333,17 @@ model VendorContact {
}
model Vendor {
id String @id @default(cuid())
name String
vendorId String? @unique
address String?
pincode String?
gstin String?
latitude Float?
longitude Float?
isVerified Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
id String @id @default(cuid())
name String
vendorId String? @unique
address String?
pincode String?
gstin String?
latitude Float?
longitude Float?
isVerified Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
contacts VendorContact[]
purchaseOrders PurchaseOrder[]
@ -272,51 +412,51 @@ model ItemConsumption {
}
model PurchaseOrder {
id String @id @default(cuid())
poNumber String @unique
title String
status POStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2)
currency String @default("INR")
dateRequired DateTime?
projectCode String?
managerNote String?
paymentRef String?
paymentDate DateTime?
paidAmount Decimal? @db.Decimal(12, 2)
piQuotationNo String?
piQuotationDate DateTime?
requisitionNo String?
requisitionDate DateTime?
placeOfDelivery String?
tcDelivery String?
tcDispatch String?
tcInspection String?
tcTransitInsurance String?
tcPaymentTerms String?
tcOthers String?
poDate DateTime?
submittedAt DateTime?
approvedAt DateTime?
paidAt DateTime?
closedAt DateTime?
cancelledAt DateTime?
cancellationReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
poNumber String @unique
title String
status POStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2)
currency String @default("INR")
dateRequired DateTime?
projectCode String?
managerNote String?
paymentRef String?
paymentDate DateTime?
paidAmount Decimal? @db.Decimal(12, 2)
piQuotationNo String?
piQuotationDate DateTime?
requisitionNo String?
requisitionDate DateTime?
placeOfDelivery String?
tcDelivery String?
tcDispatch String?
tcInspection String?
tcTransitInsurance String?
tcPaymentTerms String?
tcOthers String?
poDate DateTime?
submittedAt DateTime?
approvedAt DateTime?
paidAt DateTime?
closedAt DateTime?
cancelledAt DateTime?
cancellationReason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submitterId String
submitter User @relation("Submitter", fields: [submitterId], references: [id])
submitter User @relation("Submitter", fields: [submitterId], references: [id])
vesselId String
vessel Vessel @relation(fields: [vesselId], references: [id])
vessel Vessel @relation(fields: [vesselId], references: [id])
accountId String
account Account @relation(fields: [accountId], references: [id])
account Account @relation(fields: [accountId], references: [id])
companyId String?
company Company? @relation(fields: [companyId], references: [id])
vendorId String?
vendor Vendor? @relation(fields: [vendorId], references: [id])
vendor Vendor? @relation(fields: [vendorId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
site Site? @relation(fields: [siteId], references: [id])
// Supersede: a cancelled PO may be linked to the existing PO that replaces it.
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
@ -423,10 +563,14 @@ model Rank {
updatedAt DateTime @updatedAt
parentId String?
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
children Rank[] @relation("RankHierarchy")
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
children Rank[] @relation("RankHierarchy")
docRequirements RankDocRequirement[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
}
// Which documents a rank is required (or conditionally required) to hold.
@ -442,3 +586,230 @@ model RankDocRequirement {
@@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[]
applications Application[]
}
// 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,
// Phase 3a adds candidates. A row references at most one entity (the rest null).
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])
crewMemberId String?
crewMember CrewMember? @relation(fields: [crewMemberId], references: [id])
applicationId String?
application Application? @relation(fields: [applicationId], references: [id])
}
// The talent-pool spine (Phase 3a, Epic B). One row per person, created the
// moment they enter the pool and kept through CANDIDATE → EMPLOYEE → EX_HAND, so
// an ex-hand's history/documents are already on file. `employeeId` is assigned
// at onboarding (Phase 3c). The recruitment pipeline (Applications, Phase 3b)
// and crew records (Phase 4) hang off this model. See Crewing-Data-Model §4.
model CrewMember {
id String @id @default(cuid())
employeeId String? @unique // assigned at onboarding (Phase 3c)
name String
status CrewStatus @default(CANDIDATE)
type CandidateType @default(NEW)
source CandidateSource @default(CAREERS)
email String?
phone String?
dob DateTime?
experienceMonths Int @default(0)
vesselTypeExperience String? // free-text "vessel type" from the Add-candidate modal
cvKey String? // storage key for an uploaded CV (no parsing yet — A2 deferred)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Rank held / last held (ex-hands) and the rank being applied for.
currentRankId String?
currentRank Rank? @relation("CrewCurrentRank", fields: [currentRankId], references: [id])
appliedRankId String?
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id])
actions CrewAction[]
applications Application[]
bankDetail BankDetail?
epfDetail EpfDetail?
}
// ─── Crewing recruitment pipeline models (Phase 3b) ─────────────────────────
// A candidate's application against one requisition — the gated pipeline spine
// (spec §5.1/§8.48.5). One application per (requisition, candidate).
model Application {
id String @id @default(cuid())
stage ApplicationStage @default(SHORTLISTED)
type CandidateType @default(NEW)
interviewResult InterviewOutcome @default(PENDING)
interviewWaived Boolean @default(false) // set true only on Manager-approved waiver (R2)
rejectedReason String?
rejectedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requisitionId String
requisition Requisition @relation(fields: [requisitionId], references: [id])
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
gates ApplicationGate[]
referenceChecks ReferenceCheck[]
salaryStructures SalaryStructure[]
actions CrewAction[]
@@unique([requisitionId, crewMemberId])
}
// One row per vetting gate. SALARY / SELECTION / WAIVER gates with result PENDING
// are the Manager's central Approvals-queue items (§8.13). `decidedById` is a
// denormalised actor id — the audited actor lives on the CrewAction.
model ApplicationGate {
id String @id @default(cuid())
applicationId String
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
gate ApplicationGateType
result GateResult @default(PENDING)
note String?
decidedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([applicationId, gate])
}
// Competency & reference checks recorded by the MPO at the COMPETENCY_AND_REFERENCES gate.
model ReferenceCheck {
id String @id @default(cuid())
applicationId String
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
refereeName String
refereeContact String?
outcome String? // free-text / "positive" | "negative"
note String?
recordedById String?
createdAt DateTime @default(now())
}
// The salary agreed at SALARY_AGREEMENT, sent for Manager approval. Effective-dated
// (R10/A4) and attached to the Application in 3b; onboarding (3c) binds it to the
// CrewAssignment. `approvedById` is set when the Manager approves the SALARY gate.
model SalaryStructure {
id String @id @default(cuid())
applicationId String
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
rateBasis SalaryRateBasis @default(MONTHLY)
basic Decimal @db.Decimal(12, 2)
victualingPerDay Decimal @default(0) @db.Decimal(12, 2)
allowances Json?
currency String @default("INR")
effectiveFrom DateTime?
effectiveTo DateTime?
approvedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Bank details captured at DOC_VERIFICATION (needed downstream for payroll).
// NOTE: PII — field-level encryption/masking is a Phase-4 task (§11); stored
// plainly for now behind the crewing flag.
model BankDetail {
id String @id @default(cuid())
crewMemberId String @unique
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
accountName String?
accountNumber String?
ifsc String?
bankName String?
verificationStatus GateResult @default(PENDING) // verified by Accounts in a later phase
verifiedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// EPF / identity details captured at DOC_VERIFICATION. PII note as BankDetail.
model EpfDetail {
id String @id @default(cuid())
crewMemberId String @unique
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
uan String?
aadhaarLast4 String?
pfNumber String?
verificationStatus GateResult @default(PENDING)
verifiedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View file

@ -0,0 +1,209 @@
/**
* Integration tests for the Crewing Phase 3b recruitment pipeline actions.
* The Application/Gate/Salary/Bank/EPF tables are introduced in this phase, so
* afterEach wipes the crewing lifecycle tables wholesale.
*/
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 {
addApplication,
advanceStage,
recordReferenceCheck,
verifyDocuments,
agreeSalary,
approveSalary,
recordInterviewResult,
requestInterviewWaiver,
approveInterviewWaiver,
selectCandidate,
rejectApplication,
} from "@/app/(portal)/crewing/applications/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { ApplicationStage, Role } from "@prisma/client";
let managerId: string;
let manningId: string;
let siteStaffId: string;
let rankId: string;
let vesselId: string;
const SS_EMAIL = "sitestaff@itapp.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
let seq = 0;
async function freshRequisition() {
seq += 1;
return db.requisition.create({ data: { code: `REQ-T${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "OPEN" } });
}
async function freshCandidate(type: "NEW" | "EX_HAND" = "NEW") {
return db.crewMember.create({ data: { name: type === "EX_HAND" ? "Ex Hand" : "New Cand", type, status: type === "EX_HAND" ? "EX_HAND" : "CANDIDATE", source: type === "EX_HAND" ? "EX_HAND" : "CAREERS", appliedRankId: rankId } });
}
async function newApplication(type: "NEW" | "EX_HAND" = "NEW") {
const [req, cand] = await Promise.all([freshRequisition(), freshCandidate(type)]);
as(managerId, "MANAGER");
const res = await addApplication(fd({ requisitionId: req.id, crewMemberId: cand.id }));
if (!("ok" in res)) throw new Error("addApplication failed");
return { applicationId: res.id!, requisitionId: req.id, crewMemberId: cand.id };
}
const setStage = (id: string, stage: ApplicationStage) => db.application.update({ where: { id }, data: { stage } });
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: "ITAPP-SS", email: SS_EMAIL, name: "SS App", 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.salaryStructure.deleteMany({});
await db.applicationGate.deleteMany({});
await db.referenceCheck.deleteMany({});
await db.application.deleteMany({});
await db.bankDetail.deleteMany({});
await db.epfDetail.deleteMany({});
await db.requisition.deleteMany({});
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("addApplication", () => {
it("creates a SHORTLISTED application and moves the requisition into SHORTLISTING", async () => {
const { applicationId, requisitionId } = await newApplication();
const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } });
expect(app.stage).toBe("SHORTLISTED");
expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SHORTLISTING");
});
it("rejects a duplicate candidate on the same requisition", async () => {
const { requisitionId, crewMemberId } = await newApplication();
as(managerId, "MANAGER");
const res = await addApplication(fd({ requisitionId, crewMemberId }));
expect("error" in res).toBe(true);
});
});
describe("happy path to PROPOSED", () => {
it("walks shortlist → competency → docs(+bank/EPF) → salary → manager approval", async () => {
const { applicationId, crewMemberId } = await newApplication();
as(manningId, "MANNING");
expect("ok" in (await advanceStage(applicationId, "start_competency"))).toBe(true);
await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" }));
expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true);
expect("ok" in (await verifyDocuments(fd({ applicationId, accountNumber: "123456", ifsc: "HDFC0001", uan: "UAN99" })))).toBe(true);
// Bank/EPF captured at the docs gate
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId } })).accountNumber).toBe("123456");
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId } })).uan).toBe("UAN99");
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT");
// MPO agrees salary → SALARY gate pending
await agreeSalary(fd({ applicationId, rateBasis: "MONTHLY", basic: "45000" }));
const gate = await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SALARY" } });
expect(gate.result).toBe("PENDING");
// MPO cannot approve salary
as(manningId, "MANNING");
expect(await approveSalary(applicationId)).toEqual({ error: "Unauthorized" });
// Manager approves → PROPOSED, structure approved
as(managerId, "MANAGER");
expect("ok" in (await approveSalary(applicationId))).toBe(true);
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("PROPOSED");
expect((await db.salaryStructure.findFirstOrThrow({ where: { applicationId } })).approvedById).toBe(managerId);
});
});
describe("interview → selection", () => {
it("MPO records pass → Manager selects → SELECTED + requisition SELECTED", async () => {
const { applicationId, requisitionId } = await newApplication();
await setStage(applicationId, "INTERVIEW");
as(manningId, "MANNING");
expect("ok" in (await recordInterviewResult(applicationId, true))).toBe(true);
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SELECTION" } })).result).toBe("PENDING");
// MPO cannot select
expect(await selectCandidate(applicationId)).toEqual({ error: "Unauthorized" });
as(managerId, "MANAGER");
expect("ok" in (await selectCandidate(applicationId))).toBe(true);
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED");
expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SELECTED");
});
it("a failed interview rejects the application", async () => {
const { applicationId } = await newApplication();
await setStage(applicationId, "INTERVIEW");
as(manningId, "MANNING");
await recordInterviewResult(applicationId, false, "Did not meet the bar");
const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } });
expect(app.stage).toBe("REJECTED");
expect(app.rejectedReason).toBe("Did not meet the bar");
});
it("cannot select before an interview result or waiver", async () => {
const { applicationId } = await newApplication();
await setStage(applicationId, "INTERVIEW");
as(managerId, "MANAGER");
const res = await selectCandidate(applicationId);
expect("error" in res).toBe(true);
});
});
describe("interview waiver (ex-hands, R2)", () => {
it("MPO requests, Manager approves, then selection works without an interview", async () => {
const { applicationId } = await newApplication("EX_HAND");
await setStage(applicationId, "INTERVIEW");
as(manningId, "MANNING");
expect("ok" in (await requestInterviewWaiver(applicationId, "20 yrs with us"))).toBe(true);
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "WAIVER" } })).result).toBe("PENDING");
as(managerId, "MANAGER");
expect("ok" in (await approveInterviewWaiver(applicationId))).toBe(true);
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).interviewWaived).toBe(true);
expect("ok" in (await selectCandidate(applicationId))).toBe(true);
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED");
});
it("is refused for a non-ex-hand candidate", async () => {
const { applicationId } = await newApplication("NEW");
await setStage(applicationId, "INTERVIEW");
as(manningId, "MANNING");
const res = await requestInterviewWaiver(applicationId);
expect("error" in res).toBe(true);
});
});
describe("rejection", () => {
it("MPO rejects from a mid stage", async () => {
const { applicationId } = await newApplication();
await setStage(applicationId, "DOC_VERIFICATION");
as(manningId, "MANNING");
expect("ok" in (await rejectApplication(applicationId, "Docs not in order"))).toBe(true);
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("REJECTED");
});
it("site staff cannot drive the pipeline", async () => {
const { applicationId } = await newApplication();
as(siteStaffId, "SITE_STAFF");
expect(await advanceStage(applicationId, "start_competency")).toEqual({ error: "Unauthorized" });
expect(await rejectApplication(applicationId, "x")).toEqual({ error: "Unauthorized" });
});
});

View file

@ -0,0 +1,122 @@
/**
* Integration tests for the Crewing Phase 3a candidate server actions
* (addCandidate / updateCandidate). Mirrors the requisitions test setup.
*
* The CrewMember table is introduced in this phase, so afterEach wipes it (and
* its CrewAction rows) 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 }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let siteStaffId: string;
let rankId: string;
const SS_EMAIL = "sitestaff@itcand.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;
const ss = await db.user.upsert({
where: { email: SS_EMAIL },
update: { role: "SITE_STAFF", isActive: true },
create: { employeeId: "ITCAND-SS", email: SS_EMAIL, name: "Site Staff Cand", role: "SITE_STAFF" },
});
siteStaffId = ss.id;
rankId = (await db.rank.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.crewAction.deleteMany({ where: { crewMemberId: { not: null } } });
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("addCandidate", () => {
it("adds a NEW candidate with an audit action and sensible defaults", async () => {
as(managerId, "MANAGER");
const res = await addCandidate(fd({ name: "Asha Rao", source: "CAREERS", appliedRankId: rankId, experienceMonths: "60" }));
expect("ok" in res && res.ok).toBe(true);
const c = await db.crewMember.findFirstOrThrow({ include: { actions: true } });
expect(c.name).toBe("Asha Rao");
expect(c.type).toBe("NEW");
expect(c.status).toBe("CANDIDATE");
expect(c.appliedRankId).toBe(rankId);
expect(c.experienceMonths).toBe(60);
expect(c.employeeId).toBeNull();
expect(c.actions[0].actionType).toBe("CANDIDATE_ADDED");
expect(c.actions[0].actorId).toBe(managerId);
});
it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
const c = await db.crewMember.findFirstOrThrow();
expect(c.type).toBe("EX_HAND");
expect(c.status).toBe("EX_HAND");
});
it("requires a name", async () => {
as(managerId, "MANAGER");
const res = await addCandidate(fd({ name: " ", source: "CAREERS" }));
expect("error" in res).toBe(true);
expect(await db.crewMember.count()).toBe(0);
});
it("is rejected for roles without manage_candidates (site staff, accounts)", async () => {
as(siteStaffId, "SITE_STAFF");
expect(await addCandidate(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
as(managerId, "ACCOUNTS");
expect(await addCandidate(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
expect(await db.crewMember.count()).toBe(0);
});
});
describe("updateCandidate", () => {
it("edits fields and writes a CANDIDATE_UPDATED action", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Edit Me", source: "CAREERS", experienceMonths: "12" }));
const c = await db.crewMember.findFirstOrThrow();
const res = await updateCandidate(fd({ id: c.id, name: "Edited Name", source: "REFERRAL", experienceMonths: "24" }));
expect("ok" in res && res.ok).toBe(true);
const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id }, include: { actions: true } });
expect(after.name).toBe("Edited Name");
expect(after.source).toBe("REFERRAL");
expect(after.experienceMonths).toBe(24);
expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true);
});
it("does not downgrade an onboarded EMPLOYEE back to a candidate", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Hired Hannah", source: "CAREERS" }));
const c = await db.crewMember.findFirstOrThrow();
await db.crewMember.update({ where: { id: c.id }, data: { status: "EMPLOYEE" } });
await updateCandidate(fd({ id: c.id, name: "Hired Hannah", source: "CAREERS" }));
expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("EMPLOYEE");
});
it("rejects an unknown id", async () => {
as(managerId, "MANAGER");
const res = await updateCandidate(fd({ id: "nonexistent", name: "X", source: "CAREERS" }));
expect("error" in res).toBe(true);
});
});

View file

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

View file

@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import {
BOARD_STAGES,
canPerformAction,
canReject,
getAvailableActions,
getTransition,
} from "@/lib/application-pipeline";
// The gated 7-stage recruitment pipeline (Crewing-Implementation-Spec §5.1).
describe("Application pipeline state machine", () => {
it("has the 7 board stages in order", () => {
expect(BOARD_STAGES).toEqual([
"SHORTLISTED",
"COMPETENCY_AND_REFERENCES",
"DOC_VERIFICATION",
"SALARY_AGREEMENT",
"PROPOSED",
"INTERVIEW",
"SELECTED",
]);
});
describe("sourcing advances (MPO/Manager)", () => {
it("MPO walks the early stages", () => {
expect(getTransition("SHORTLISTED", "start_competency")?.to).toBe("COMPETENCY_AND_REFERENCES");
expect(canPerformAction("SHORTLISTED", "start_competency", "MANNING")).toBe(true);
expect(getTransition("COMPETENCY_AND_REFERENCES", "verify_competency")?.to).toBe("DOC_VERIFICATION");
expect(getTransition("DOC_VERIFICATION", "verify_docs")?.to).toBe("SALARY_AGREEMENT");
expect(getTransition("PROPOSED", "propose_accepted")?.to).toBe("INTERVIEW");
});
});
describe("Manager-gated advances (spec §6)", () => {
it("salary approval is Manager-only", () => {
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "MANAGER")).toBe(true);
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "SUPERUSER")).toBe(true);
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "MANNING")).toBe(false);
expect(getTransition("SALARY_AGREEMENT", "approve_salary")?.to).toBe("PROPOSED");
});
it("selection is Manager-only", () => {
expect(canPerformAction("INTERVIEW", "select", "MANAGER")).toBe(true);
expect(canPerformAction("INTERVIEW", "select", "MANNING")).toBe(false);
expect(getTransition("INTERVIEW", "select")?.to).toBe("SELECTED");
});
});
it("rejects actions on the wrong stage", () => {
expect(getTransition("SHORTLISTED", "select")).toBeNull();
expect(getTransition("SELECTED", "approve_salary")).toBeNull();
});
it("offers MPO only sourcing actions, Manager the gated ones", () => {
expect(getAvailableActions("SALARY_AGREEMENT", "MANNING")).toHaveLength(0);
expect(getAvailableActions("SALARY_AGREEMENT", "MANAGER")).toEqual(["approve_salary"]);
expect(getAvailableActions("SHORTLISTED", "SITE_STAFF")).toHaveLength(0);
});
describe("rejection (orthogonal)", () => {
it("MPO/Manager can reject from any active stage", () => {
expect(canReject("COMPETENCY_AND_REFERENCES", "MANNING")).toBe(true);
expect(canReject("INTERVIEW", "MANAGER")).toBe(true);
});
it("cannot reject once selected/onboarded/already rejected", () => {
expect(canReject("SELECTED", "MANAGER")).toBe(false);
expect(canReject("ONBOARDED", "MANAGER")).toBe(false);
expect(canReject("REJECTED", "MANAGER")).toBe(false);
});
it("site staff cannot reject", () => {
expect(canReject("SHORTLISTED", "SITE_STAFF")).toBe(false);
});
});
});

View file

@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import {
canCancel,
canPerformAction,
getAvailableActions,
getTransition,
} from "@/lib/requisition-state-machine";
// The requisition lifecycle (Crewing-Implementation-Spec §5.2):
// OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED,
// CANCELLED reachable from OPEN/SHORTLISTING (Manager). Selection is Manager-only.
describe("Requisition state machine", () => {
describe("forward transitions", () => {
it("MPO can start shortlisting an OPEN requisition", () => {
expect(canPerformAction("OPEN", "start_shortlisting", "MANNING")).toBe(true);
expect(getTransition("OPEN", "start_shortlisting")?.to).toBe("SHORTLISTING");
});
it("MPO advances through proposing and interviewing", () => {
expect(canPerformAction("SHORTLISTING", "mark_proposing", "MANNING")).toBe(true);
expect(canPerformAction("PROPOSING", "start_interviewing", "MANNING")).toBe(true);
});
it("final selection is Manager-only (spec §6)", () => {
expect(canPerformAction("INTERVIEWING", "mark_selected", "MANAGER")).toBe(true);
expect(canPerformAction("INTERVIEWING", "mark_selected", "SUPERUSER")).toBe(true);
expect(canPerformAction("INTERVIEWING", "mark_selected", "MANNING")).toBe(false);
});
it("onboarding fills the vacancy from SELECTED", () => {
expect(getTransition("SELECTED", "mark_filled")?.to).toBe("FILLED");
expect(canPerformAction("SELECTED", "mark_filled", "MANNING")).toBe(true);
});
it("rejects actions on the wrong source state", () => {
expect(canPerformAction("OPEN", "mark_selected", "MANAGER")).toBe(false);
expect(getTransition("FILLED", "mark_filled")).toBeNull();
expect(getTransition("CANCELLED", "start_shortlisting")).toBeNull();
});
it("site staff and accounts can perform no transitions", () => {
for (const status of ["OPEN", "SHORTLISTING", "INTERVIEWING", "SELECTED"] as const) {
expect(getAvailableActions(status, "SITE_STAFF")).toHaveLength(0);
expect(getAvailableActions(status, "ACCOUNTS")).toHaveLength(0);
}
});
});
describe("getAvailableActions", () => {
it("offers shortlisting on OPEN to the MPO", () => {
expect(getAvailableActions("OPEN", "MANNING")).toEqual(["start_shortlisting"]);
});
it("offers nothing once FILLED", () => {
expect(getAvailableActions("FILLED", "MANAGER")).toHaveLength(0);
});
});
describe("cancellation (orthogonal)", () => {
it("MPO and Manager can withdraw from OPEN or SHORTLISTING (matrix §6)", () => {
expect(canCancel("OPEN", "MANAGER")).toBe(true);
expect(canCancel("SHORTLISTING", "SUPERUSER")).toBe(true);
expect(canCancel("OPEN", "MANNING")).toBe(true);
});
it("cannot be withdrawn once past shortlisting", () => {
expect(canCancel("PROPOSING", "MANAGER")).toBe(false);
expect(canCancel("INTERVIEWING", "MANAGER")).toBe(false);
expect(canCancel("FILLED", "MANAGER")).toBe(false);
expect(canCancel("CANCELLED", "MANAGER")).toBe(false);
});
it("site staff and accounts may never withdraw", () => {
expect(canCancel("OPEN", "SITE_STAFF")).toBe(false);
expect(canCancel("OPEN", "ACCOUNTS")).toBe(false);
});
});
});