Compare commits

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

11 commits

Author SHA1 Message Date
378400391a Merge pull request 'feat(crewing): Phase 4a — crew records & profile + PPE (flagged)' (#69) from feat/crewing-crew-records into feat/crewing-onboarding
Reviewed-on: #69
2026-06-22 18:50:38 +00:00
23ec2b91ea Merge branch 'feat/crewing-onboarding' into feat/crewing-crew-records
All checks were successful
PR checks / checks (pull_request) Successful in 38s
PR checks / integration (pull_request) Successful in 29s
2026-06-22 18:50:20 +00:00
a8acf26eca Merge branch 'feat/crewing-pipeline' into feat/crewing-onboarding
All checks were successful
PR checks / checks (pull_request) Successful in 38s
PR checks / integration (pull_request) Successful in 28s
2026-06-22 18:49:58 +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
37b1debc9d feat(crewing): Phase 4a — crew records & profile + PPE (flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 28s
First slice of Phase 4 (stacked on 3c onboarding). The Crew directory and tabbed
crew profile with documents, bank/EPF (role-masked), next of kin, PPE and
experience, per Crewing-Implementation-Spec §8.7–8.8. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged.

What's in
- Schema (crewing_crew_records migration): SeafarerDocument, NextOfKin
  (isEmergency), ExperienceRecord, PpeIssue (PpeItem enum) — all on CrewMember;
  CrewActionType += DOCUMENT_UPLOADED/RECORD_UPDATED/PPE_ISSUED/PPE_RETURNED/
  EXPERIENCE_ADDED.
- PII masking (lib/crew-pii.ts, §6/§8.8): bank account + Aadhaar full only for
  Accounts/SuperUser, masked otherwise; salary hidden from site staff. Applied
  server-side before crossing to the client.
- Actions (crewing/crew/actions.ts): uploadDocument/deleteDocument, saveBankEpf,
  addNextOfKin/deleteNextOfKin, issuePpe/returnPpe, addExperience — guarded by
  upload_crew_records / issue_ppe, each writes a CrewAction.
- Screens: /crewing/crew (directory, search + vessel filter, ex-hands excluded)
  and /crewing/crew/[id] (tabbed profile: Documents · Bank & EPF · Next of kin ·
  PPE · Experience · Pay status). Crew added to the flag-gated nav (MGR/MPO/Site/
  Accounts).

Tests & docs
- Unit: crew-pii.test.ts (6). Integration: crew-records.test.ts (7) — documents,
  bank/EPF upsert, NoK, PPE issue/return, experience + permission gating.
  type-check clean; full unit (240) + integration (175) green.
- CLAUDE.md updated with the Phase 4a surface.

Deferred: site-staff own-site scoping (needs a User↔Site link); the records verify
queue (§8.11, Phase 5); Pay-status shows the salary structure only until payroll
(Phase 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:27:21 +05:30
c82efa71af feat(crewing): Phase 3c — onboarding (flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 28s
Final slice of Phase 3 (stacked on 3b pipeline). The onboarding transaction that
turns a SELECTED candidate into active crew, per Crewing-Implementation-Spec
§8.5/§9/§11. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged.

What's in
- Schema (crewing_onboarding migration): CrewAssignment + AssignmentStatus
  (ACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4); ContractLetter
  (salaryRestricted); SalaryStructure += assignmentId; CrewActionType +=
  CREW_ONBOARDED. Employee numbers CRW-xxxx via lib/employee-number.ts.
- Action (onboardCandidate, onboard_crew): one transaction off a SELECTED
  application — assign employeeId, create CrewAssignment(ACTIVE, signOnDate),
  bind the approved SalaryStructure (assignmentId + effectiveFrom), Application →
  ONBOARDED, Requisition → FILLED, CrewMember → EMPLOYEE (+ currentRank); contract
  letter stored after. Guards flag + permission + SELECTED state.
- Screen: the SELECTED action card's "Onboard to crew" modal (joining date,
  contract upload, starts-automatically chips); the CRW- number shows on the
  ONBOARDED card.

Tests & docs
- Integration: onboarding.test.ts (5) — full transaction, requisition FILLED +
  salary binding, joining-date + SELECTED-only guards, permission gating, sequential
  CRW- ids. type-check clean; full unit (234) + integration (168) green.
- CLAUDE.md updated with the Phase 3c surface.

Deferred: SITE_STAFF login creation for management ranks (grantsLogin) — a
follow-up; attendance/experience/PPE records begin in Phase 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:12:53 +05:30
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
50 changed files with 6860 additions and 75 deletions

View file

@ -120,13 +120,51 @@ 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.
**Phase 3c — Onboarding (Epic D; spec §8.5/§9/§11):**
- **Models:** `CrewAssignment` (a tour of duty, `AssignmentStatus` ACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4) and `ContractLetter` (`salaryRestricted`). `SalaryStructure` gained `assignmentId` (bound at onboarding). `CrewActionType += CREW_ONBOARDED`. Employee numbers `CRW-xxxx` via `lib/employee-number.ts`.
- **Action** (`onboardCandidate`, `onboard_crew`): one transaction off a `SELECTED` application — assign `employeeId`, create `CrewAssignment(ACTIVE, signOnDate)`, bind the approved `SalaryStructure` (`assignmentId` + `effectiveFrom`), `Application → ONBOARDED`, `Requisition → FILLED`, `CrewMember → EMPLOYEE` (+ `currentRank`); contract letter stored after. Onboarded crew leave the Candidates pool (the Crew directory is Phase 4).
- **Screen:** the SELECTED action card's **Onboard to crew** modal (joining date, contract upload, starts-automatically chips); the assigned `CRW-` number shows on the ONBOARDED card.
- **Deferred:** SITE_STAFF **login creation** for management ranks (grantsLogin) is a follow-up; attendance/experience/PPE records (the "starts automatically" chips) begin in Phase 4.
**Phase 4a — Crew records & profile + PPE (Epics E + F; spec §8.78.8):** Phase 4 (crew records, PPE, leave/attendance + sign-off) ships as **stacked sub-PRs** — 4a records/profile/PPE, 4b leave/attendance, 4c sign-off/experience.
- **Models:** `SeafarerDocument`, `NextOfKin` (`isEmergency`), `ExperienceRecord`, `PpeIssue` (`PpeItem` enum) — all on `CrewMember`. `CrewActionType += DOCUMENT_UPLOADED / RECORD_UPDATED / PPE_ISSUED / PPE_RETURNED / EXPERIENCE_ADDED`. (`BankDetail`/`EpfDetail` already exist from 3b.)
- **PII masking** (`lib/crew-pii.ts`, spec §6/§8.8): bank account number + Aadhaar are full only for **Accounts/SuperUser**, masked (`•••• 1234`) otherwise; salary hidden from **site staff**. Masking is applied **server-side** before data crosses to the client.
- **Actions** (`app/(portal)/crewing/crew/actions.ts`): `uploadDocument`/`deleteDocument`, `saveBankEpf`, `addNextOfKin`/`deleteNextOfKin`, `issuePpe`/`returnPpe`, `addExperience` — guarded by `upload_crew_records` / `issue_ppe`, each writes a `CrewAction`. Document/contract files via `buildStorageKey("crew-document", …)`.
- **Screens:** `/crewing/crew` (directory — active `EMPLOYEE` crew, search + vessel filter; ex-hands excluded) and `/crewing/crew/[id]` (tabbed profile: Documents · Bank & EPF · Next of kin · PPE · Experience · Pay status). **Crew** added to the flag-gated nav (MGR/MPO/Site/Accounts).
- **Deferred:** site-staff **own-site scoping** (needs a User↔Site link, not modelled — all crew show for now); the records **verify queue** (§8.11, Phase 5); the Pay-status tab shows the salary structure only until wage reports (Phase 6).
### 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,144 @@
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}
employeeNo={app.crewMember.employeeId}
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"),
onboard: hasPermission(role, "onboard_crew"),
}}
/>
{/* 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,611 @@
"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 } from "@/lib/requisition-service";
import { generateEmployeeId } from "@/lib/employee-number";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
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());
}
// ── Onboarding (Phase 3c, Epic D) ──────────────────────────────────────────────
// One transaction off a SELECTED application: assign the employee number, create
// the ACTIVE assignment, bind the approved salary, flip the application to
// ONBOARDED and the requisition to FILLED, and promote the candidate to EMPLOYEE.
// Login-account creation for management ranks is a deferred follow-up.
export async function onboardCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("onboard_crew");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const joiningStr = formData.get("joiningDate") as string;
if (!joiningStr) return { error: "A joining date is required" };
const app = await db.application.findUnique({
where: { id },
include: {
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
crewMember: { select: { id: true } },
},
});
if (!app) return { error: "Application not found" };
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
const joiningDate = new Date(joiningStr);
const result = await db.$transaction(async (tx) => {
const employeeId = await generateEmployeeId(tx);
const assignment = await tx.crewAssignment.create({
data: {
status: "ACTIVE",
signOnDate: joiningDate,
crewMemberId: app.crewMember.id,
rankId: app.requisition.rankId,
vesselId: app.requisition.vesselId,
siteId: app.requisition.siteId,
requisitionId: app.requisition.id,
},
});
// Bind the Manager-approved salary structure to the new assignment.
await tx.salaryStructure.updateMany({
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
});
await tx.application.update({
where: { id },
data: { stage: "ONBOARDED", actions: { create: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
});
await tx.requisition.update({
where: { id: app.requisition.id },
data: { status: "FILLED", filledAt: new Date(), actions: { create: { actionType: "REQUISITION_FILLED", actorId: g.userId } } },
});
await tx.crewMember.update({
where: { id: app.crewMember.id },
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
});
return { assignmentId: assignment.id, employeeId };
});
// Contract letter (optional) — stored after the core transaction.
const file = formData.get("contract");
if (file instanceof File && file.size > 0) {
const key = buildStorageKey("contract", result.assignmentId, file.name);
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
await db.contractLetter.create({
data: { assignmentId: result.assignmentId, fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" },
});
}
revalidateApp(id, app.requisition.id);
return { ok: true, id: result.employeeId };
}

View file

@ -0,0 +1,400 @@
"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,
onboardCandidate,
} 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;
employeeNo: string | null;
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;
onboard: 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>
{p.perms.onboard && <OnboardButton id={p.id} />}
</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">
Onboarded to crew{p.employeeNo ? <> · <span className="font-mono">{p.employeeNo}</span></> : null}.
</p>
</Card>
);
}
}
function OnboardButton({ id }: { id: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [joiningDate, setJoiningDate] = useState("");
const [contract, setContract] = useState<File | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
fd.set("applicationId", id);
fd.set("joiningDate", joiningDate);
if (contract) fd.set("contract", contract);
const res = await onboardCandidate(fd);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<>
<button className={PRIMARY} onClick={() => setOpen(true)}>Onboard to crew</button>
<AdminDialog title="Onboard to crew" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
<input type="date" className={INPUT} value={joiningDate} onChange={(e) => setJoiningDate(e.target.value)} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contract letter (optional)</label>
<input 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" onChange={(e) => setContract(e.target.files?.[0] ?? null)} />
</div>
<div className="rounded-md bg-neutral-50 border border-neutral-200 p-3">
<p className="text-xs font-medium text-neutral-600 mb-1">Starts automatically on confirm</p>
<p className="text-xs text-neutral-500">Employee number · salary &amp; victualing · attendance · experience · EPF/PF · PPE. (Attendance, experience and PPE records begin in a later phase.)</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={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !joiningDate} className={PRIMARY}>{pending ? "Onboarding…" : "Confirm onboarding"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
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,323 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import type { AssignmentStatus, GateResult, PpeItem, SeafarerDocType, SalaryRateBasis } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import {
uploadDocument, deleteDocument, saveBankEpf,
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience,
} 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 BTN = "rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
const LINKBTN = "text-xs font-medium text-danger-600 hover:underline";
const DOC_TYPES: SeafarerDocType[] = ["STCW","AADHAAR","PAN","PASSPORT","CDC","COC","PHOTOGRAPH","DRIVING_LICENSE","MEDICAL_FITNESS","CONTRACT_LETTER"];
const PPE_ITEMS: PpeItem[] = ["BOILER_SUIT","SAFETY_SHOES","HELMET","VEST","GLOVES","MASK","GOGGLES","TIFFIN","TORCH","WALKIE_TALKIE"];
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
type Doc = { id: string; docType: SeafarerDocType; number: string | null; issueDate: string | null; expiryDate: string | null; verificationStatus: GateResult; hasFile: boolean };
type Nok = { id: string; name: string; relationship: string | null; phone: string | null; address: string | null; isEmergency: boolean };
type Ppe = { id: string; item: PpeItem; size: string | null; quantity: number; issuedDate: string; returnedDate: string | null };
type Exp = { id: string; vesselType: string | null; rank: string | null; fromDate: string | null; toDate: string | null; durationMonths: number | null; source: string };
type Props = {
crew: { id: string; name: string; employeeId: string; rank: string; location: string; status: AssignmentStatus | null };
documents: Doc[];
bank: { accountName: string | null; accountNumber: string; ifsc: string | null; bankName: string | null };
epf: { uan: string | null; aadhaar: string; pfNumber: string | null };
nextOfKin: Nok[];
ppe: Ppe[];
experience: Exp[];
paystatus: { showSalary: boolean; salary: { basic: number; rateBasis: SalaryRateBasis; victualingPerDay: number; currency: string } | null };
ranks: { id: string; name: string }[];
perms: { editRecords: boolean; issuePpe: boolean };
};
const TABS = ["Documents", "Bank & EPF", "Next of kin", "PPE", "Experience", "Pay status"] as const;
type Tab = (typeof TABS)[number];
export function CrewProfile(p: Props) {
const [tab, setTab] = useState<Tab>("Documents");
const router = useRouter();
const refresh = () => router.refresh();
return (
<div className="max-w-4xl">
<Link href="/crewing/crew" 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" /> Crew
</Link>
<div className="mb-1 flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{p.crew.name}</h1>
{p.crew.status === "ACTIVE" && <Badge variant="success">Active</Badge>}
{p.crew.status === "ON_LEAVE" && <Badge variant="warning">On leave</Badge>}
</div>
<p className="text-sm text-neutral-500 mb-6"><span className="font-mono">{p.crew.employeeId}</span> · {p.crew.rank} · {p.crew.location}</p>
<div className="mb-5 flex flex-wrap gap-1 border-b border-neutral-200">
{TABS.map((t) => (
<button key={t} onClick={() => setTab(t)} className={cn("px-3 py-2 text-sm font-medium border-b-2 -mb-px", tab === t ? "border-primary-600 text-primary-700" : "border-transparent text-neutral-500 hover:text-neutral-800")}>
{t}
</button>
))}
</div>
{tab === "Documents" && <Documents crewId={p.crew.id} docs={p.documents} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "Bank & EPF" && <BankEpf crewId={p.crew.id} bank={p.bank} epf={p.epf} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "Next of kin" && <NextOfKinTab crewId={p.crew.id} rows={p.nextOfKin} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "PPE" && <PpeTab crewId={p.crew.id} rows={p.ppe} canIssue={p.perms.issuePpe} onDone={refresh} />}
{tab === "Experience" && <ExperienceTab crewId={p.crew.id} rows={p.experience} ranks={p.ranks} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "Pay status" && <PayStatus paystatus={p.paystatus} />}
</div>
);
}
function Section({ children }: { children: React.ReactNode }) {
return <div className="rounded-lg border border-neutral-200 bg-white p-4 space-y-3">{children}</div>;
}
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; }
function useRun(onDone: () => void) {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function run(fn: () => Promise<{ ok: true } | { error: string }>, after?: () => void) {
setPending(true); setError("");
const res = await fn();
setPending(false);
if ("error" in res) setError(res.error); else { after?.(); onDone(); }
}
return { pending, error, run };
}
function docStatus(d: Doc): { label: string; variant: "success" | "warning" | "danger" | "secondary" } {
if (d.expiryDate && new Date(d.expiryDate) < new Date()) return { label: "Expired", variant: "danger" };
if (d.verificationStatus === "VERIFIED") return { label: "Verified", variant: "success" };
if (d.verificationStatus === "REJECTED") return { label: "Rejected", variant: "danger" };
return { label: "Pending", variant: "warning" };
}
function Documents({ crewId, docs, canEdit, onDone }: { crewId: string; docs: Doc[]; canEdit: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" });
const [file, setFile] = useState<File | null>(null);
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
if (file) fd.set("file", file);
run(() => uploadDocument(fd), () => { setF({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" }); setFile(null); });
}
return (
<Section>
{docs.length === 0 ? <p className="text-sm text-neutral-400">No documents.</p> : (
<table className="w-full text-sm">
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Document</th><th>Number</th><th>Issued</th><th>Expires</th><th>Status</th><th></th></tr></thead>
<tbody>
{docs.map((d) => { const s = docStatus(d); return (
<tr key={d.id} className="border-b border-neutral-50 last:border-0">
<td className="py-2 text-neutral-800">{label(d.docType)}{d.hasFile && <span className="ml-1 text-xs text-neutral-400">file</span>}</td>
<td className="text-neutral-600">{d.number ?? "—"}</td>
<td className="text-neutral-600">{fmtDate(d.issueDate)}</td>
<td className="text-neutral-600">{fmtDate(d.expiryDate)}</td>
<td><Badge variant={s.variant}>{s.label}</Badge></td>
<td className="text-right">{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteDocument(d.id))}>Remove</button>}</td>
</tr>
); })}
</tbody>
</table>
)}
{canEdit && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<select className={INPUT} value={f.docType} onChange={(e) => setF({ ...f, docType: e.target.value })}>
{DOC_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
</select>
<input className={INPUT} placeholder="Number" value={f.number} onChange={(e) => setF({ ...f, number: e.target.value })} />
<label className="text-xs text-neutral-500">Issue date<input type="date" className={INPUT} value={f.issueDate} onChange={(e) => setF({ ...f, issueDate: e.target.value })} /></label>
<label className="text-xs text-neutral-500">Expiry date<input type="date" className={INPUT} value={f.expiryDate} onChange={(e) => setF({ ...f, expiryDate: e.target.value })} /></label>
<input type="file" className="col-span-2 text-sm" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add document"}</button></div>
</form>
)}
</Section>
);
}
function Row({ k, v }: { k: string; v: string | null }) {
return <div className="flex justify-between gap-4 py-1.5 border-b border-neutral-50 last:border-0"><span className="text-sm text-neutral-500">{k}</span><span className="text-sm text-neutral-900 font-mono">{v ?? "—"}</span></div>;
}
function BankEpf({ crewId, bank, epf, canEdit, onDone }: { crewId: string; bank: Props["bank"]; epf: Props["epf"]; canEdit: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [edit, setEdit] = useState(false);
const [f, setF] = useState({ accountName: bank.accountName ?? "", accountNumber: "", ifsc: bank.ifsc ?? "", bankName: bank.bankName ?? "", uan: epf.uan ?? "", aadhaarLast4: "", pfNumber: epf.pfNumber ?? "" });
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
run(() => saveBankEpf(fd), () => setEdit(false));
}
return (
<Section>
<div className="rounded-md bg-warning-50 border border-warning-200 px-3 py-2 text-xs text-warning-800">Sensitive account and Aadhaar numbers are masked unless you are Accounts.</div>
<Row k="Account name" v={bank.accountName} />
<Row k="Account number" v={bank.accountNumber} />
<Row k="IFSC" v={bank.ifsc} />
<Row k="Bank" v={bank.bankName} />
<Row k="UAN" v={epf.uan} />
<Row k="Aadhaar" v={epf.aadhaar} />
<Row k="PF number" v={epf.pfNumber} />
{canEdit && !edit && <button className="text-sm text-primary-600 hover:underline" onClick={() => setEdit(true)}>Edit bank & EPF</button>}
{canEdit && edit && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Account name" value={f.accountName} onChange={(e) => setF({ ...f, accountName: e.target.value })} />
<input className={INPUT} placeholder="Account number" value={f.accountNumber} onChange={(e) => setF({ ...f, accountNumber: e.target.value })} />
<input className={INPUT} placeholder="IFSC" value={f.ifsc} onChange={(e) => setF({ ...f, ifsc: e.target.value })} />
<input className={INPUT} placeholder="Bank name" value={f.bankName} onChange={(e) => setF({ ...f, bankName: e.target.value })} />
<input className={INPUT} placeholder="UAN" value={f.uan} onChange={(e) => setF({ ...f, uan: e.target.value })} />
<input className={INPUT} placeholder="Aadhaar (last 4)" value={f.aadhaarLast4} onChange={(e) => setF({ ...f, aadhaarLast4: e.target.value })} />
<input className={INPUT} placeholder="PF number" value={f.pfNumber} onChange={(e) => setF({ ...f, pfNumber: e.target.value })} />
<div className="col-span-2"><Err msg={error} /><div className="flex gap-2"><button className={BTN} disabled={pending}>{pending ? "Saving…" : "Save"}</button><button type="button" className="text-sm text-neutral-500" onClick={() => setEdit(false)}>Cancel</button></div></div>
</form>
)}
</Section>
);
}
function NextOfKinTab({ crewId, rows, canEdit, onDone }: { crewId: string; rows: Nok[]; canEdit: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ name: "", relationship: "", phone: "", address: "", isEmergency: false });
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
fd.set("name", f.name); if (f.relationship) fd.set("relationship", f.relationship); if (f.phone) fd.set("phone", f.phone); if (f.address) fd.set("address", f.address); if (f.isEmergency) fd.set("isEmergency", "true");
run(() => addNextOfKin(fd), () => setF({ name: "", relationship: "", phone: "", address: "", isEmergency: false }));
}
return (
<Section>
{rows.length === 0 ? <p className="text-sm text-neutral-400">No next of kin recorded.</p> : rows.map((n) => (
<div key={n.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
<div>
<p className="text-sm text-neutral-900">{n.name} {n.isEmergency && <Badge variant="danger">Emergency</Badge>}</p>
<p className="text-xs text-neutral-500">{[n.relationship, n.phone, n.address].filter(Boolean).join(" · ") || "—"}</p>
</div>
{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteNextOfKin(n.id))}>Remove</button>}
</div>
))}
{canEdit && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
<input className={INPUT} placeholder="Relationship" value={f.relationship} onChange={(e) => setF({ ...f, relationship: e.target.value })} />
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
<input className={INPUT} placeholder="Address" value={f.address} onChange={(e) => setF({ ...f, address: e.target.value })} />
<label className="col-span-2 flex items-center gap-2 text-sm text-neutral-600"><input type="checkbox" checked={f.isEmergency} onChange={(e) => setF({ ...f, isEmergency: e.target.checked })} /> Emergency contact</label>
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.name}>{pending ? "Adding…" : "Add"}</button></div>
</form>
)}
</Section>
);
}
function PpeTab({ crewId, rows, canIssue, onDone }: { crewId: string; rows: Ppe[]; canIssue: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" });
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
run(() => issuePpe(fd), () => setF({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" }));
}
return (
<Section>
{rows.length === 0 ? <p className="text-sm text-neutral-400">No PPE issued.</p> : (
<table className="w-full text-sm">
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Item</th><th>Size</th><th>Qty</th><th>Issued</th><th>Status</th><th></th></tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-neutral-50 last:border-0">
<td className="py-2 text-neutral-800">{label(r.item)}</td>
<td className="text-neutral-600">{r.size ?? "—"}</td>
<td className="text-neutral-600">{r.quantity}</td>
<td className="text-neutral-600">{fmtDate(r.issuedDate)}</td>
<td>{r.returnedDate ? <Badge variant="secondary">Returned</Badge> : <Badge variant="success">Issued</Badge>}</td>
<td className="text-right">{canIssue && !r.returnedDate && <button className="text-xs text-primary-600 hover:underline" onClick={() => run(() => returnPpe(r.id))}>Mark returned</button>}</td>
</tr>
))}
</tbody>
</table>
)}
{canIssue && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<select className={INPUT} value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })}>{PPE_ITEMS.map((i) => <option key={i} value={i}>{label(i)}</option>)}</select>
<input className={INPUT} placeholder="Size" value={f.size} onChange={(e) => setF({ ...f, size: e.target.value })} />
<input className={INPUT} type="number" min={1} placeholder="Qty" value={f.quantity} onChange={(e) => setF({ ...f, quantity: e.target.value })} />
<input className={INPUT} placeholder="Comment" value={f.comment} onChange={(e) => setF({ ...f, comment: e.target.value })} />
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Issuing…" : "Issue PPE"}</button></div>
</form>
)}
</Section>
);
}
function ExperienceTab({ crewId, rows, ranks, canEdit, onDone }: { crewId: string; rows: Exp[]; ranks: { id: string; name: string }[]; canEdit: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" });
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
run(() => addExperience(fd), () => setF({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" }));
}
return (
<Section>
{rows.length === 0 ? <p className="text-sm text-neutral-400">No experience records.</p> : rows.map((r) => (
<div key={r.id} className="border-b border-neutral-50 last:border-0 py-2">
<p className="text-sm text-neutral-900">{r.rank ?? "—"}{r.vesselType ? ` · ${r.vesselType}` : ""}</p>
<p className="text-xs text-neutral-500">{fmtDate(r.fromDate)} {fmtDate(r.toDate)}{r.durationMonths ? ` · ${r.durationMonths} mo` : ""} · {r.source}</p>
</div>
))}
{canEdit && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })}><option value="">Rank</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}</select>
<input className={INPUT} placeholder="Vessel type" value={f.vesselType} onChange={(e) => setF({ ...f, vesselType: e.target.value })} />
<label className="text-xs text-neutral-500">From<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} /></label>
<label className="text-xs text-neutral-500">To<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} /></label>
<input className={INPUT} type="number" min={0} placeholder="Duration (months)" value={f.durationMonths} onChange={(e) => setF({ ...f, durationMonths: e.target.value })} />
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add experience"}</button></div>
</form>
)}
</Section>
);
}
function PayStatus({ paystatus }: { paystatus: Props["paystatus"] }) {
return (
<Section>
{!paystatus.showSalary ? (
<p className="text-sm text-neutral-500">Net pay is visible to office roles only. Site staff see pay <em>status</em> once monthly wage reports are generated.</p>
) : paystatus.salary ? (
<>
<Row k="Basic" v={`${paystatus.salary.currency} ${paystatus.salary.basic.toLocaleString("en-IN")} / ${paystatus.salary.rateBasis.toLowerCase()}`} />
<Row k="Victualing / day" v={`${paystatus.salary.currency} ${paystatus.salary.victualingPerDay.toLocaleString("en-IN")}`} />
</>
) : (
<p className="text-sm text-neutral-400">No salary structure on file.</p>
)}
<p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Monthly pay rows (paid / processing) arrive with payroll wage reports in a later phase.</p>
</Section>
);
}

View file

@ -0,0 +1,98 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { canViewSalary, bankEpfValue } from "@/lib/crew-pii";
import { redirect, notFound } from "next/navigation";
import { CrewProfile } from "./crew-profile";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Crew profile" };
export default async function CrewProfilePage({ 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_crew_records")) redirect("/dashboard");
const { id } = await params;
const c = await db.crewMember.findUnique({
where: { id },
include: {
currentRank: { select: { name: true } },
documents: { orderBy: { createdAt: "desc" } },
bankDetail: true,
epfDetail: true,
nextOfKin: { orderBy: { createdAt: "asc" } },
ppeIssues: { orderBy: { issuedDate: "desc" } },
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
assignments: {
where: { status: { not: "SIGNED_OFF" } },
orderBy: { signOnDate: "desc" },
take: 1,
include: {
vessel: { select: { name: true } },
site: { select: { name: true } },
salaryStructures: { orderBy: { effectiveFrom: "desc" } },
},
},
},
});
if (!c) notFound();
if (c.status !== "EMPLOYEE") notFound(); // the Candidates page handles non-crew
const assignment = c.assignments[0] ?? null;
const showSalary = canViewSalary(role);
const currentSalary = assignment?.salaryStructures.find((s) => s.approvedById) ?? assignment?.salaryStructures[0] ?? null;
const ranks = await db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } });
return (
<CrewProfile
crew={{
id: c.id,
name: c.name,
employeeId: c.employeeId ?? "—",
rank: c.currentRank?.name ?? "—",
location: assignment?.vessel?.name ?? assignment?.site?.name ?? "—",
status: assignment?.status ?? null,
}}
documents={c.documents.map((d) => ({
id: d.id,
docType: d.docType,
number: d.number,
issueDate: d.issueDate?.toISOString() ?? null,
expiryDate: d.expiryDate?.toISOString() ?? null,
verificationStatus: d.verificationStatus,
hasFile: Boolean(d.fileKey),
}))}
bank={{
accountName: c.bankDetail?.accountName ?? null,
accountNumber: bankEpfValue(c.bankDetail?.accountNumber, role),
ifsc: c.bankDetail?.ifsc ?? null,
bankName: c.bankDetail?.bankName ?? null,
}}
epf={{
uan: c.epfDetail?.uan ?? null,
aadhaar: bankEpfValue(c.epfDetail?.aadhaarLast4, role),
pfNumber: c.epfDetail?.pfNumber ?? null,
}}
nextOfKin={c.nextOfKin.map((n) => ({ id: n.id, name: n.name, relationship: n.relationship, phone: n.phone, address: n.address, isEmergency: n.isEmergency }))}
ppe={c.ppeIssues.map((p) => ({ id: p.id, item: p.item, size: p.size, quantity: p.quantity, issuedDate: p.issuedDate.toISOString(), returnedDate: p.returnedDate?.toISOString() ?? null }))}
experience={c.experienceRecords.map((e) => ({ id: e.id, vesselType: e.vesselType, rank: e.rank?.name ?? null, fromDate: e.fromDate?.toISOString() ?? null, toDate: e.toDate?.toISOString() ?? null, durationMonths: e.durationMonths, source: e.source }))}
paystatus={{
showSalary,
salary: showSalary && currentSalary
? { basic: Number(currentSalary.basic), rateBasis: currentSalary.rateBasis, victualingPerDay: Number(currentSalary.victualingPerDay), currency: currentSalary.currency }
: null,
}}
ranks={ranks}
perms={{
editRecords: hasPermission(role, "upload_crew_records"),
issuePpe: hasPermission(role, "issue_ppe"),
}}
/>
);
}

View file

@ -0,0 +1,254 @@
"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 { SeafarerDocType, PpeItem } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const crewPath = (id: string) => `/crewing/crew/${id}`;
async function guard(permission: Permission): Promise<{ error: string } | { userId: string }> {
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 };
}
async function requireCrew(id: string) {
return db.crewMember.findUnique({ where: { id }, select: { id: true } });
}
// ── Documents ──────────────────────────────────────────────────────────────
const docSchema = z.object({
crewMemberId: z.string().min(1),
docType: z.nativeEnum(SeafarerDocType),
number: z.string().optional(),
issueDate: z.string().optional(),
expiryDate: z.string().optional(),
});
export async function uploadDocument(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = docSchema.safeParse({
crewMemberId: formData.get("crewMemberId"),
docType: formData.get("docType"),
number: (formData.get("number") as string) || undefined,
issueDate: (formData.get("issueDate") as string) || undefined,
expiryDate: (formData.get("expiryDate") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
let fileKey: string | null = null;
const file = formData.get("file");
if (file instanceof File && file.size > 0) {
fileKey = buildStorageKey("crew-document", d.crewMemberId, file.name);
await uploadBuffer(fileKey, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
}
await db.seafarerDocument.create({
data: {
crewMemberId: d.crewMemberId,
docType: d.docType,
number: d.number ?? null,
fileKey,
issueDate: d.issueDate ? new Date(d.issueDate) : null,
expiryDate: d.expiryDate ? new Date(d.expiryDate) : null,
},
});
await db.crewAction.create({ data: { actionType: "DOCUMENT_UPLOADED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { docType: d.docType } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function deleteDocument(id: string): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true } });
if (!doc) return { error: "Document not found" };
await db.seafarerDocument.delete({ where: { id } });
revalidatePath(crewPath(doc.crewMemberId));
return { ok: true };
}
// ── Bank & EPF ───────────────────────────────────────────────────────────────
const bankEpfSchema = z.object({
crewMemberId: z.string().min(1),
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(),
});
export async function saveBankEpf(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = bankEpfSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.$transaction(async (tx) => {
await tx.bankDetail.upsert({
where: { crewMemberId: d.crewMemberId },
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
create: { crewMemberId: d.crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
});
await tx.epfDetail.upsert({
where: { crewMemberId: d.crewMemberId },
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
create: { crewMemberId: d.crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
});
await tx.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "bank_epf" } } });
});
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
// ── Next of kin / emergency ────────────────────────────────────────────────
const nokSchema = z.object({
crewMemberId: z.string().min(1),
name: z.string().trim().min(1, "Name is required"),
relationship: z.string().optional(),
phone: z.string().optional(),
address: z.string().optional(),
isEmergency: z.boolean().optional(),
});
export async function addNextOfKin(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = nokSchema.safeParse({
crewMemberId: formData.get("crewMemberId"),
name: formData.get("name"),
relationship: (formData.get("relationship") as string) || undefined,
phone: (formData.get("phone") as string) || undefined,
address: (formData.get("address") as string) || undefined,
isEmergency: formData.get("isEmergency") === "on" || formData.get("isEmergency") === "true",
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.nextOfKin.create({
data: {
crewMemberId: d.crewMemberId,
name: d.name,
relationship: d.relationship ?? null,
phone: d.phone ?? null,
address: d.address ?? null,
isEmergency: d.isEmergency ?? false,
},
});
await db.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "next_of_kin" } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function deleteNextOfKin(id: string): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
if (!nok) return { error: "Record not found" };
await db.nextOfKin.delete({ where: { id } });
revalidatePath(crewPath(nok.crewMemberId));
return { ok: true };
}
// ── PPE ──────────────────────────────────────────────────────────────────────
const ppeSchema = z.object({
crewMemberId: z.string().min(1),
item: z.nativeEnum(PpeItem),
size: z.string().optional(),
quantity: z.coerce.number().int().min(1).default(1),
comment: z.string().optional(),
});
export async function issuePpe(formData: FormData): Promise<ActionResult> {
const g = await guard("issue_ppe");
if ("error" in g) return g;
const parsed = ppeSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.ppeIssue.create({
data: { crewMemberId: d.crewMemberId, item: d.item, size: d.size ?? null, quantity: d.quantity, comment: d.comment ?? null, issuedById: g.userId },
});
await db.crewAction.create({ data: { actionType: "PPE_ISSUED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { item: d.item } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function returnPpe(id: string): Promise<ActionResult> {
const g = await guard("issue_ppe");
if ("error" in g) return g;
const ppe = await db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, returnedDate: true } });
if (!ppe) return { error: "PPE record not found" };
if (ppe.returnedDate) return { error: "Already returned" };
await db.ppeIssue.update({ where: { id }, data: { returnedDate: new Date() } });
await db.crewAction.create({ data: { actionType: "PPE_RETURNED", actorId: g.userId, crewMemberId: ppe.crewMemberId } });
revalidatePath(crewPath(ppe.crewMemberId));
return { ok: true };
}
// ── Experience ─────────────────────────────────────────────────────────────
const expSchema = z.object({
crewMemberId: z.string().min(1),
vesselType: z.string().optional(),
rankId: z.string().optional(),
fromDate: z.string().optional(),
toDate: z.string().optional(),
durationMonths: z.coerce.number().int().min(0).optional(),
});
export async function addExperience(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = expSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.experienceRecord.create({
data: {
crewMemberId: d.crewMemberId,
vesselType: d.vesselType ?? null,
rankId: d.rankId || null,
fromDate: d.fromDate ? new Date(d.fromDate) : null,
toDate: d.toDate ? new Date(d.toDate) : null,
durationMonths: d.durationMonths ?? null,
source: "declared",
},
});
await db.crewAction.create({ data: { actionType: "EXPERIENCE_ADDED", actorId: g.userId, crewMemberId: d.crewMemberId } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}

View file

@ -0,0 +1,93 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { AssignmentStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
type CrewRow = {
id: string;
name: string;
employeeId: string;
rank: string;
location: string;
status: AssignmentStatus | null;
};
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 StatusBadge({ status }: { status: AssignmentStatus | null }) {
if (status === "ACTIVE") return <Badge variant="success">Active</Badge>;
if (status === "ON_LEAVE") return <Badge variant="warning">On leave</Badge>;
return <Badge variant="secondary"></Badge>;
}
export function CrewDirectory({ crew }: { crew: CrewRow[] }) {
const [search, setSearch] = useState("");
const [location, setLocation] = useState("ALL");
const locations = useMemo(
() => Array.from(new Set(crew.map((c) => c.location).filter((l) => l !== "—"))).sort(),
[crew]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return crew.filter((c) => {
if (location !== "ALL" && c.location !== location) return false;
if (q && !`${c.name} ${c.employeeId} ${c.rank}`.toLowerCase().includes(q)) return false;
return true;
});
}, [crew, search, location]);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Crew</h1>
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} active crew member{crew.length === 1 ? "" : "s"}</p>
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name, employee no or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
<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>
<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">Employee</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Vessel / site</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">
{crew.length === 0 ? "No crew onboarded yet." : "No crew match these filters."}
</td></tr>
) : (
filtered.map((c) => (
<tr key={c.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/crewing/crew/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId}</td>
<td className="px-4 py-3 text-neutral-700">{c.rank}</td>
<td className="px-4 py-3 text-neutral-700">{c.location}</td>
<td className="px-4 py-3"><StatusBadge status={c.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
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 { CrewDirectory } from "./crew-directory";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Crew" };
export default async function CrewPage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "view_crew_records")) redirect("/dashboard");
// NOTE: site-staff "own site only" scoping (§8.7) needs a User↔Site link that
// isn't modelled yet — deferred to a follow-up; for now all active crew show.
const crew = await db.crewMember.findMany({
where: { status: "EMPLOYEE" },
orderBy: { name: "asc" },
include: {
currentRank: { select: { name: true } },
assignments: {
where: { status: { not: "SIGNED_OFF" } },
orderBy: { signOnDate: "desc" },
take: 1,
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
},
},
});
const rows = crew.map((c) => {
const a = c.assignments[0];
return {
id: c.id,
name: c.name,
employeeId: c.employeeId ?? "—",
rank: c.currentRank?.name ?? "—",
location: a?.vessel?.name ?? a?.site?.name ?? "—",
status: a?.status ?? null,
};
});
return <CrewDirectory crew={rows} />;
}

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,9 @@ import {
UserCircle,
ShieldCheck,
Network,
ClipboardList,
UserSearch,
Contact,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -69,11 +72,17 @@ 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"] },
{ href: "/crewing/crew", label: "Crew", icon: Contact, roles: ["MANNING", "MANAGER", "SUPERUSER", "SITE_STAFF", "ACCOUNTS"] },
]
: [];
// ── 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);
}

28
App/lib/crew-pii.ts Normal file
View file

@ -0,0 +1,28 @@
import type { Role } from "@prisma/client";
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
// Bank account / EPF identity numbers are full only for Accounts (and SuperUser);
// masked for everyone else. Salary is hidden from site staff (office-only).
export function canViewFullBankEpf(role: Role): boolean {
return role === "ACCOUNTS" || role === "SUPERUSER";
}
export function canViewSalary(role: Role): boolean {
// Office roles see salary; site staff see status only (§6, R7).
return role !== "SITE_STAFF";
}
// "•••• 4471" — keep only the last `visible` chars; null/short values render "—".
export function maskTail(value: string | null | undefined, visible = 4): string {
if (!value) return "—";
const v = value.trim();
if (v.length <= visible) return "••••";
return `•••• ${v.slice(-visible)}`;
}
// Show the value in full only when allowed, else mask it.
export function bankEpfValue(value: string | null | undefined, role: Role): string {
if (!value) return "—";
return canViewFullBankEpf(role) ? value : maskTail(value);
}

View file

@ -0,0 +1,29 @@
/**
* Crew employee-number generator. Format: CRW-<id>, e.g. CRW-1000.
*
* Sequential, floored at 1000, scanning existing CrewMember.employeeId values.
* Assigned at onboarding (Phase 3c). Call inside the onboarding transaction to
* minimise the race window (the unique constraint is the backstop).
*/
import { db } from "@/lib/db";
import type { Prisma } from "@prisma/client";
const PREFIX = "CRW-";
const FLOOR = 999; // first generated id is 1000
export async function generateEmployeeId(
client: Prisma.TransactionClient | typeof db = db
): Promise<string> {
const rows = await client.crewMember.findMany({
where: { employeeId: { startsWith: PREFIX } },
select: { employeeId: true },
});
let maxId = FLOOR;
for (const { employeeId } of rows) {
if (!employeeId) continue;
const n = parseInt(employeeId.slice(PREFIX.length), 10);
if (!isNaN(n) && n > maxId) maxId = n;
}
return `${PREFIX}${maxId + 1}`;
}

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

@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "AssignmentStatus" AS ENUM ('ACTIVE', 'ON_LEAVE', 'SIGNED_OFF');
-- AlterEnum
ALTER TYPE "CrewActionType" ADD VALUE 'CREW_ONBOARDED';
-- AlterTable
ALTER TABLE "SalaryStructure" ADD COLUMN "assignmentId" TEXT;
-- CreateTable
CREATE TABLE "CrewAssignment" (
"id" TEXT NOT NULL,
"status" "AssignmentStatus" NOT NULL DEFAULT 'ACTIVE',
"signOnDate" TIMESTAMP(3) NOT NULL,
"signOffDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"crewMemberId" TEXT NOT NULL,
"rankId" TEXT NOT NULL,
"vesselId" TEXT,
"siteId" TEXT,
"requisitionId" TEXT,
CONSTRAINT "CrewAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContractLetter" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"fileKey" TEXT NOT NULL,
"salaryRestricted" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ContractLetter_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CrewAssignment_requisitionId_key" ON "CrewAssignment"("requisitionId");
-- CreateIndex
CREATE UNIQUE INDEX "ContractLetter_assignmentId_key" ON "ContractLetter"("assignmentId");
-- AddForeignKey
ALTER TABLE "SalaryStructure" ADD CONSTRAINT "SalaryStructure_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContractLetter" ADD CONSTRAINT "ContractLetter_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,93 @@
-- CreateEnum
CREATE TYPE "PpeItem" AS ENUM ('BOILER_SUIT', 'SAFETY_SHOES', 'HELMET', 'VEST', 'GLOVES', 'MASK', 'GOGGLES', 'TIFFIN', 'TORCH', 'WALKIE_TALKIE');
-- 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 'DOCUMENT_UPLOADED';
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_UPDATED';
ALTER TYPE "CrewActionType" ADD VALUE 'PPE_ISSUED';
ALTER TYPE "CrewActionType" ADD VALUE 'PPE_RETURNED';
ALTER TYPE "CrewActionType" ADD VALUE 'EXPERIENCE_ADDED';
-- CreateTable
CREATE TABLE "SeafarerDocument" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"docType" "SeafarerDocType" NOT NULL,
"number" TEXT,
"fileKey" TEXT,
"issueDate" TIMESTAMP(3),
"expiryDate" TIMESTAMP(3),
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
"verifiedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SeafarerDocument_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NextOfKin" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"relationship" TEXT,
"phone" TEXT,
"address" TEXT,
"isEmergency" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NextOfKin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExperienceRecord" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"vesselType" TEXT,
"rankId" TEXT,
"fromDate" TIMESTAMP(3),
"toDate" TIMESTAMP(3),
"durationMonths" INTEGER,
"source" TEXT NOT NULL DEFAULT 'declared',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExperienceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PpeIssue" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"item" "PpeItem" NOT NULL,
"size" TEXT,
"quantity" INTEGER NOT NULL DEFAULT 1,
"issuedDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"returnedDate" TIMESTAMP(3),
"issuedById" TEXT,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PpeIssue_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SeafarerDocument" ADD CONSTRAINT "SeafarerDocument_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NextOfKin" ADD CONSTRAINT "NextOfKin_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExperienceRecord" ADD CONSTRAINT "ExperienceRecord_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExperienceRecord" ADD CONSTRAINT "ExperienceRecord_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PpeIssue" ADD CONSTRAINT "PpeIssue_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -87,6 +87,167 @@ 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
CREW_ONBOARDED
DOCUMENT_UPLOADED
RECORD_UPDATED
PPE_ISSUED
PPE_RETURNED
EXPERIENCE_ADDED
}
// PPE kit items issued to crew (Phase 4a, Epic F). See Crewing-Data-Model §1.
enum PpeItem {
BOILER_SUIT
SAFETY_SHOES
HELMET
VEST
GLOVES
MASK
GOGGLES
TIFFIN
TORCH
WALKIE_TALKIE
}
// ─── 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
}
// A crew member's tour of duty (Phase 3c, Epic D). Created at onboarding; the
// leave/sign-off transitions land in Phase 4. See Crewing-Data-Model §4.
enum AssignmentStatus {
ACTIVE
ON_LEAVE
SIGNED_OFF
}
// ─── 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 +260,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 +297,21 @@ model Site {
purchaseOrders PurchaseOrder[]
inventory ItemInventory[]
consumption ItemConsumption[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
assignments CrewAssignment[]
}
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[]
assignments CrewAssignment[]
}
model Company {
@ -155,8 +325,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 +350,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 +363,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 +442,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 +593,16 @@ 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[]
docRequirements RankDocRequirement[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
assignments CrewAssignment[]
experienceRecords ExperienceRecord[]
}
// Which documents a rank is required (or conditionally required) to hold.
@ -442,3 +618,344 @@ 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[]
assignment CrewAssignment?
}
// 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?
assignments CrewAssignment[]
documents SeafarerDocument[]
nextOfKin NextOfKin[]
experienceRecords ExperienceRecord[]
ppeIssues PpeIssue[]
}
// ─── 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
// Bound to the assignment at onboarding (Phase 3c); null while still a proposal.
assignmentId String?
assignment CrewAssignment? @relation(fields: [assignmentId], references: [id])
}
// 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
}
// ─── Crewing onboarding (Phase 3c: Epic D) ──────────────────────────────────
// A single tour of duty, created at onboarding. Flips the requisition to FILLED
// and the crew member to EMPLOYEE. Leave/sign-off transitions arrive in Phase 4.
model CrewAssignment {
id String @id @default(cuid())
status AssignmentStatus @default(ACTIVE)
signOnDate DateTime
signOffDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
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])
// The requisition this assignment fills (one assignment per requisition).
requisitionId String? @unique
requisition Requisition? @relation(fields: [requisitionId], references: [id])
salaryStructures SalaryStructure[]
contractLetter ContractLetter?
}
// The signed contract for an assignment. `salaryRestricted` hides salary from
// site staff on the crew profile (Phase 4 display gating).
model ContractLetter {
id String @id @default(cuid())
assignmentId String @unique
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
fileKey String
salaryRestricted Boolean @default(true)
createdAt DateTime @default(now())
}
// ─── Crewing crew records (Phase 4a, Epics E + F) ───────────────────────────
// A held document on the crew profile (medical, passport, CDC, STCW, …). The
// verify queue (MPO/Accounts) lands in Phase 5; here we capture + display, with
// `verificationStatus` carried and "expired" derived from expiryDate in the UI.
model SeafarerDocument {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
docType SeafarerDocType
number String? // PII — masked in the UI for non-privileged roles
fileKey String?
issueDate DateTime?
expiryDate DateTime?
verificationStatus GateResult @default(PENDING)
verifiedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Next of kin / emergency contacts (§8.8). `isEmergency` marks the emergency row.
model NextOfKin {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
name String
relationship String?
phone String?
address String?
isEmergency Boolean @default(false)
createdAt DateTime @default(now())
}
// A tour-of-duty experience row — added manually or auto-appended at sign-off
// (Phase 4c). `source` is "internal" (a PPMS assignment) or "declared".
model ExperienceRecord {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
vesselType String?
rankId String?
rank Rank? @relation(fields: [rankId], references: [id])
fromDate DateTime?
toDate DateTime?
durationMonths Int?
source String @default("declared")
createdAt DateTime @default(now())
}
// PPE issued to a crew member (§8.8). A reissue is a new row; `returnedDate`
// marks a returned item. Optional ItemInventory draw-down is a later refinement.
model PpeIssue {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
item PpeItem
size String?
quantity Int @default(1)
issuedDate DateTime @default(now())
returnedDate DateTime?
issuedById String?
comment String?
createdAt DateTime @default(now())
}

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,130 @@
/**
* Integration tests for the Crewing Phase 4a crew-records actions (documents,
* bank/EPF, next of kin, PPE, experience). The records tables are new this phase,
* so afterEach wipes them.
*/
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 {
uploadDocument, deleteDocument, saveBankEpf,
addNextOfKin, issuePpe, returnPpe, addExperience,
} from "@/app/(portal)/crewing/crew/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let accountsId: string;
let siteStaffId: string;
let crewId: string;
const SS_EMAIL = "sitestaff@itcrew.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;
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITCREW-SS", email: SS_EMAIL, name: "SS Crew", role: "SITE_STAFF" } });
siteStaffId = ss.id;
});
afterEach(async () => {
await db.crewAction.deleteMany({});
await db.seafarerDocument.deleteMany({});
await db.nextOfKin.deleteMany({});
await db.ppeIssue.deleteMany({});
await db.experienceRecord.deleteMany({});
await db.bankDetail.deleteMany({});
await db.epfDetail.deleteMany({});
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
async function makeCrew() {
const c = await db.crewMember.create({ data: { name: "Active Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-T${Date.now() % 100000}` } });
crewId = c.id;
return c.id;
}
describe("documents", () => {
it("uploads and removes a document (with audit)", async () => {
const id = await makeCrew();
as(managerId, "MANAGER");
expect("ok" in (await uploadDocument(fd({ crewMemberId: id, docType: "PASSPORT", number: "P123", expiryDate: "2030-01-01" })))).toBe(true);
const doc = await db.seafarerDocument.findFirstOrThrow({ where: { crewMemberId: id } });
expect(doc.docType).toBe("PASSPORT");
expect(await db.crewAction.count({ where: { actionType: "DOCUMENT_UPLOADED" } })).toBe(1);
expect("ok" in (await deleteDocument(doc.id))).toBe(true);
expect(await db.seafarerDocument.count({ where: { crewMemberId: id } })).toBe(0);
});
it("is rejected for a role without upload_crew_records (accounts)", async () => {
const id = await makeCrew();
as(accountsId, "ACCOUNTS");
expect(await uploadDocument(fd({ crewMemberId: id, docType: "PASSPORT" }))).toEqual({ error: "Unauthorized" });
});
});
describe("bank & EPF", () => {
it("upserts bank and EPF details", async () => {
const id = await makeCrew();
as(managerId, "MANAGER");
expect("ok" in (await saveBankEpf(fd({ crewMemberId: id, accountNumber: "999888777", ifsc: "HDFC0009", uan: "UAN-1" })))).toBe(true);
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).accountNumber).toBe("999888777");
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).uan).toBe("UAN-1");
// Upsert again updates rather than duplicating.
await saveBankEpf(fd({ crewMemberId: id, accountNumber: "111", ifsc: "X", uan: "UAN-2" }));
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).accountNumber).toBe("111");
expect(await db.bankDetail.count({ where: { crewMemberId: id } })).toBe(1);
});
});
describe("next of kin", () => {
it("adds an emergency contact", async () => {
const id = await makeCrew();
as(siteStaffId, "SITE_STAFF"); // site staff can upload crew records
expect("ok" in (await addNextOfKin(fd({ crewMemberId: id, name: "Spouse", relationship: "Wife", isEmergency: "true" })))).toBe(true);
const nok = await db.nextOfKin.findFirstOrThrow({ where: { crewMemberId: id } });
expect(nok.isEmergency).toBe(true);
});
});
describe("PPE", () => {
it("issues PPE then marks it returned", async () => {
const id = await makeCrew();
as(siteStaffId, "SITE_STAFF");
expect("ok" in (await issuePpe(fd({ crewMemberId: id, item: "SAFETY_SHOES", size: "9", quantity: "1" })))).toBe(true);
const ppe = await db.ppeIssue.findFirstOrThrow({ where: { crewMemberId: id } });
expect(ppe.returnedDate).toBeNull();
expect("ok" in (await returnPpe(ppe.id))).toBe(true);
expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppe.id } })).returnedDate).not.toBeNull();
});
it("is rejected for a role without issue_ppe (accounts)", async () => {
const id = await makeCrew();
as(accountsId, "ACCOUNTS");
expect(await issuePpe(fd({ crewMemberId: id, item: "HELMET" }))).toEqual({ error: "Unauthorized" });
});
});
describe("experience", () => {
it("adds a declared experience record", async () => {
const id = await makeCrew();
as(managerId, "MANAGER");
expect("ok" in (await addExperience(fd({ crewMemberId: id, vesselType: "Dredger", durationMonths: "36" })))).toBe(true);
const e = await db.experienceRecord.findFirstOrThrow({ where: { crewMemberId: id } });
expect(e.source).toBe("declared");
expect(e.durationMonths).toBe(36);
});
});

View file

@ -0,0 +1,129 @@
/**
* Integration tests for the Crewing Phase 3c onboarding action. Onboarding is the
* side-effecting transaction off a SELECTED application (assignment + employeeId +
* salary binding + requisition FILLED + crew EMPLOYEE).
*/
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 { onboardCandidate } from "@/app/(portal)/crewing/applications/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let siteStaffId: string;
let rankId: string;
let vesselId: string;
const SS_EMAIL = "sitestaff@itonb.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
let seq = 0;
async function selectedApplication() {
seq += 1;
const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } });
const cand = await db.crewMember.create({ data: { name: "Selected Sam", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } });
const app = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } });
await db.salaryStructure.create({ data: { applicationId: app.id, rateBasis: "MONTHLY", basic: 50000, approvedById: managerId } });
return { appId: app.id, reqId: req.id, candId: cand.id };
}
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: "ITONB-SS", email: SS_EMAIL, name: "SS Onb", role: "SITE_STAFF" } });
siteStaffId = ss.id;
rankId = (await db.rank.findFirstOrThrow()).id;
vesselId = (await db.vessel.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.contractLetter.deleteMany({});
await db.crewAction.deleteMany({});
await db.salaryStructure.deleteMany({});
await db.applicationGate.deleteMany({});
await db.referenceCheck.deleteMany({});
await db.crewAssignment.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("onboardCandidate", () => {
it("onboards a SELECTED candidate end-to-end in one transaction", async () => {
const { appId, reqId, candId } = await selectedApplication();
as(managerId, "MANAGER");
const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }));
expect("ok" in res && res.ok).toBe(true);
const assignment = await db.crewAssignment.findFirstOrThrow({ where: { crewMemberId: candId } });
expect(assignment.status).toBe("ACTIVE");
expect(assignment.requisitionId).toBe(reqId);
expect(assignment.rankId).toBe(rankId);
const cm = await db.crewMember.findUniqueOrThrow({ where: { id: candId } });
expect(cm.status).toBe("EMPLOYEE");
expect(cm.employeeId).toMatch(/^CRW-\d+$/);
expect(cm.currentRankId).toBe(rankId);
expect((await db.application.findUniqueOrThrow({ where: { id: appId } })).stage).toBe("ONBOARDED");
expect((await db.requisition.findUniqueOrThrow({ where: { id: reqId } })).status).toBe("FILLED");
const sal = await db.salaryStructure.findFirstOrThrow({ where: { applicationId: appId } });
expect(sal.assignmentId).toBe(assignment.id);
expect(sal.effectiveFrom).not.toBeNull();
const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "CREW_ONBOARDED" } });
expect(action.actorId).toBe(managerId);
});
it("requires a joining date", async () => {
const { appId } = await selectedApplication();
as(managerId, "MANAGER");
const res = await onboardCandidate(fd({ applicationId: appId }));
expect("error" in res).toBe(true);
expect(await db.crewAssignment.count()).toBe(0);
});
it("only onboards from SELECTED", async () => {
const { appId } = await selectedApplication();
await db.application.update({ where: { id: appId }, data: { stage: "INTERVIEW" } });
as(managerId, "MANAGER");
const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }));
expect("error" in res).toBe(true);
expect(await db.crewAssignment.count()).toBe(0);
});
it("is rejected for roles without onboard_crew (site staff, accounts)", async () => {
const { appId } = await selectedApplication();
as(siteStaffId, "SITE_STAFF");
expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
as(managerId, "ACCOUNTS");
expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
expect(await db.crewAssignment.count()).toBe(0);
});
it("assigns sequential CRW- employee numbers", async () => {
const a = await selectedApplication();
const b = await selectedApplication();
as(managerId, "MANAGER");
await onboardCandidate(fd({ applicationId: a.appId, joiningDate: "2026-07-01" }));
await onboardCandidate(fd({ applicationId: b.appId, joiningDate: "2026-07-02" }));
const ids = (await db.crewMember.findMany({ where: { employeeId: { not: null } }, select: { employeeId: true } })).map((c) => c.employeeId);
expect(new Set(ids).size).toBe(2);
expect(ids.every((i) => /^CRW-\d+$/.test(i!))).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,46 @@
import { describe, it, expect } from "vitest";
import { maskTail, canViewFullBankEpf, canViewSalary, bankEpfValue } from "@/lib/crew-pii";
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
describe("crew PII masking", () => {
describe("maskTail", () => {
it("keeps the last 4 by default", () => {
expect(maskTail("123456789")).toBe("•••• 6789");
});
it("renders — for empty values", () => {
expect(maskTail(null)).toBe("—");
expect(maskTail("")).toBe("—");
});
it("fully masks values at or under the visible length", () => {
expect(maskTail("12")).toBe("••••");
expect(maskTail("1234")).toBe("••••");
});
});
describe("canViewFullBankEpf", () => {
it("only Accounts and SuperUser see full bank/EPF", () => {
expect(canViewFullBankEpf("ACCOUNTS")).toBe(true);
expect(canViewFullBankEpf("SUPERUSER")).toBe(true);
expect(canViewFullBankEpf("MANAGER")).toBe(false);
expect(canViewFullBankEpf("MANNING")).toBe(false);
expect(canViewFullBankEpf("SITE_STAFF")).toBe(false);
});
});
describe("canViewSalary", () => {
it("hides salary from site staff only", () => {
expect(canViewSalary("SITE_STAFF")).toBe(false);
expect(canViewSalary("MANAGER")).toBe(true);
expect(canViewSalary("ACCOUNTS")).toBe(true);
expect(canViewSalary("MANNING")).toBe(true);
});
});
describe("bankEpfValue", () => {
it("shows full to Accounts, masked to others, — when empty", () => {
expect(bankEpfValue("123456789", "ACCOUNTS")).toBe("123456789");
expect(bankEpfValue("123456789", "MANAGER")).toBe("•••• 6789");
expect(bankEpfValue(null, "ACCOUNTS")).toBe("—");
});
});
});

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