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>
98 lines
4 KiB
TypeScript
98 lines
4 KiB
TypeScript
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"),
|
|
}}
|
|
/>
|
|
);
|
|
}
|