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>
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
"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 };
|
|
}
|