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:
parent
c82efa71af
commit
37b1debc9d
12 changed files with 1222 additions and 11 deletions
|
|
@ -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.7–8.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`.
|
||||
|
|
|
|||
323
App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
Normal file
323
App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
App/app/(portal)/crewing/crew/[id]/page.tsx
Normal file
98
App/app/(portal)/crewing/crew/[id]/page.tsx
Normal 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"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
254
App/app/(portal)/crewing/crew/actions.ts
Normal file
254
App/app/(portal)/crewing/crew/actions.ts
Normal 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 };
|
||||
}
|
||||
93
App/app/(portal)/crewing/crew/crew-directory.tsx
Normal file
93
App/app/(portal)/crewing/crew/crew-directory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
App/app/(portal)/crewing/crew/page.tsx
Normal file
47
App/app/(portal)/crewing/crew/page.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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
28
App/lib/crew-pii.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
130
App/tests/integration/crew-records.test.ts
Normal file
130
App/tests/integration/crew-records.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
App/tests/unit/crew-pii.test.ts
Normal file
46
App/tests/unit/crew-pii.test.ts
Normal 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("—");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue