feat(crewing): Phase 4a — crew records & profile + PPE (flagged)

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>
This commit is contained in:
Hardik 2026-06-22 19:27:21 +05:30
parent c82efa71af
commit 37b1debc9d
12 changed files with 1222 additions and 11 deletions

View file

@ -158,6 +158,14 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **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
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.

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

@ -27,6 +27,7 @@ import {
Network,
ClipboardList,
UserSearch,
Contact,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -79,6 +80,7 @@ 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"] },
]
: [];

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,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

@ -146,6 +146,25 @@ enum CrewActionType {
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) ────────────────────────
@ -577,12 +596,13 @@ model Rank {
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
children Rank[] @relation("RankHierarchy")
docRequirements RankDocRequirement[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
assignments CrewAssignment[]
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.
@ -713,11 +733,15 @@ model CrewMember {
appliedRankId String?
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id])
actions CrewAction[]
applications Application[]
bankDetail BankDetail?
epfDetail EpfDetail?
assignments CrewAssignment[]
actions CrewAction[]
applications Application[]
bankDetail BankDetail?
epfDetail EpfDetail?
assignments CrewAssignment[]
documents SeafarerDocument[]
nextOfKin NextOfKin[]
experienceRecords ExperienceRecord[]
ppeIssues PpeIssue[]
}
// ─── Crewing recruitment pipeline models (Phase 3b) ─────────────────────────
@ -870,3 +894,68 @@ model ContractLetter {
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,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,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("—");
});
});
});