diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 5889bfb..3906218 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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`. diff --git a/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx b/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx new file mode 100644 index 0000000..34c7033 --- /dev/null +++ b/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx @@ -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("Documents"); + const router = useRouter(); + const refresh = () => router.refresh(); + + return ( +
+ + Crew + + +
+

{p.crew.name}

+ {p.crew.status === "ACTIVE" && Active} + {p.crew.status === "ON_LEAVE" && On leave} +
+

{p.crew.employeeId} · {p.crew.rank} · {p.crew.location}

+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "Documents" && } + {tab === "Bank & EPF" && } + {tab === "Next of kin" && } + {tab === "PPE" && } + {tab === "Experience" && } + {tab === "Pay status" && } +
+ ); +} + +function Section({ children }: { children: React.ReactNode }) { + return
{children}
; +} +function Err({ msg }: { msg: string }) { return msg ?

{msg}

: 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(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 ( +
+ {docs.length === 0 ?

No documents.

: ( + + + + {docs.map((d) => { const s = docStatus(d); return ( + + + + + + + + + ); })} + +
DocumentNumberIssuedExpiresStatus
{label(d.docType)}{d.hasFile && file}{d.number ?? "—"}{fmtDate(d.issueDate)}{fmtDate(d.expiryDate)}{s.label}{canEdit && }
+ )} + {canEdit && ( +
+ + setF({ ...f, number: e.target.value })} /> + + + setFile(e.target.files?.[0] ?? null)} /> +
+
+ )} +
+ ); +} + +function Row({ k, v }: { k: string; v: string | null }) { + return
{k}{v ?? "—"}
; +} + +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 ( +
+
Sensitive — account and Aadhaar numbers are masked unless you are Accounts.
+ + + + + + + + {canEdit && !edit && } + {canEdit && edit && ( +
+ setF({ ...f, accountName: e.target.value })} /> + setF({ ...f, accountNumber: e.target.value })} /> + setF({ ...f, ifsc: e.target.value })} /> + setF({ ...f, bankName: e.target.value })} /> + setF({ ...f, uan: e.target.value })} /> + setF({ ...f, aadhaarLast4: e.target.value })} /> + setF({ ...f, pfNumber: e.target.value })} /> +
+
+ )} +
+ ); +} + +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 ( +
+ {rows.length === 0 ?

No next of kin recorded.

: rows.map((n) => ( +
+
+

{n.name} {n.isEmergency && Emergency}

+

{[n.relationship, n.phone, n.address].filter(Boolean).join(" · ") || "—"}

+
+ {canEdit && } +
+ ))} + {canEdit && ( +
+ setF({ ...f, name: e.target.value })} required /> + setF({ ...f, relationship: e.target.value })} /> + setF({ ...f, phone: e.target.value })} /> + setF({ ...f, address: e.target.value })} /> + +
+
+ )} +
+ ); +} + +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 ( +
+ {rows.length === 0 ?

No PPE issued.

: ( + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
ItemSizeQtyIssuedStatus
{label(r.item)}{r.size ?? "—"}{r.quantity}{fmtDate(r.issuedDate)}{r.returnedDate ? Returned : Issued}{canIssue && !r.returnedDate && }
+ )} + {canIssue && ( +
+ + setF({ ...f, size: e.target.value })} /> + setF({ ...f, quantity: e.target.value })} /> + setF({ ...f, comment: e.target.value })} /> +
+
+ )} +
+ ); +} + +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 ( +
+ {rows.length === 0 ?

No experience records.

: rows.map((r) => ( +
+

{r.rank ?? "—"}{r.vesselType ? ` · ${r.vesselType}` : ""}

+

{fmtDate(r.fromDate)} – {fmtDate(r.toDate)}{r.durationMonths ? ` · ${r.durationMonths} mo` : ""} · {r.source}

+
+ ))} + {canEdit && ( +
+ + setF({ ...f, vesselType: e.target.value })} /> + + + setF({ ...f, durationMonths: e.target.value })} /> +
+
+ )} +
+ ); +} + +function PayStatus({ paystatus }: { paystatus: Props["paystatus"] }) { + return ( +
+ {!paystatus.showSalary ? ( +

Net pay is visible to office roles only. Site staff see pay status once monthly wage reports are generated.

+ ) : paystatus.salary ? ( + <> + + + + ) : ( +

No salary structure on file.

+ )} +

Monthly pay rows (paid / processing) arrive with payroll wage reports in a later phase.

+
+ ); +} diff --git a/App/app/(portal)/crewing/crew/[id]/page.tsx b/App/app/(portal)/crewing/crew/[id]/page.tsx new file mode 100644 index 0000000..17d59dc --- /dev/null +++ b/App/app/(portal)/crewing/crew/[id]/page.tsx @@ -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 ( + ({ + 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"), + }} + /> + ); +} diff --git a/App/app/(portal)/crewing/crew/actions.ts b/App/app/(portal)/crewing/crew/actions.ts new file mode 100644 index 0000000..e4f6826 --- /dev/null +++ b/App/app/(portal)/crewing/crew/actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/App/app/(portal)/crewing/crew/crew-directory.tsx b/App/app/(portal)/crewing/crew/crew-directory.tsx new file mode 100644 index 0000000..2f82d17 --- /dev/null +++ b/App/app/(portal)/crewing/crew/crew-directory.tsx @@ -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 Active; + if (status === "ON_LEAVE") return On leave; + return ; +} + +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 ( +
+
+

Crew

+

{crew.length} active crew member{crew.length === 1 ? "" : "s"}

+
+ +
+ setSearch(e.target.value)} /> + +
+ +
+ + + + + + + + + + + + {filtered.length === 0 ? ( + + ) : ( + filtered.map((c) => ( + + + + + + + + )) + )} + +
NameEmployeeRankVessel / siteStatus
+ {crew.length === 0 ? "No crew onboarded yet." : "No crew match these filters."} +
+ {c.name} + {c.employeeId}{c.rank}{c.location}
+
+
+ ); +} diff --git a/App/app/(portal)/crewing/crew/page.tsx b/App/app/(portal)/crewing/crew/page.tsx new file mode 100644 index 0000000..e0d0e14 --- /dev/null +++ b/App/app/(portal)/crewing/crew/page.tsx @@ -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 ; +} diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index 8a630aa..27368a0 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -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"] }, ] : []; diff --git a/App/lib/crew-pii.ts b/App/lib/crew-pii.ts new file mode 100644 index 0000000..0550c5a --- /dev/null +++ b/App/lib/crew-pii.ts @@ -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); +} diff --git a/App/prisma/migrations/20260622135007_crewing_crew_records/migration.sql b/App/prisma/migrations/20260622135007_crewing_crew_records/migration.sql new file mode 100644 index 0000000..87da1c3 --- /dev/null +++ b/App/prisma/migrations/20260622135007_crewing_crew_records/migration.sql @@ -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; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 1233d8f..df9ff4f 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -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()) +} diff --git a/App/tests/integration/crew-records.test.ts b/App/tests/integration/crew-records.test.ts new file mode 100644 index 0000000..0f8bdd1 --- /dev/null +++ b/App/tests/integration/crew-records.test.ts @@ -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).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); + }); +}); diff --git a/App/tests/unit/crew-pii.test.ts b/App/tests/unit/crew-pii.test.ts new file mode 100644 index 0000000..0301f62 --- /dev/null +++ b/App/tests/unit/crew-pii.test.ts @@ -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("—"); + }); + }); +});