"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 { autoRaiseRequisition } from "@/lib/requisition-service"; import { SeafarerDocType, PpeItem } from "@prisma/client"; import { z } from "zod"; import { revalidatePath } from "next/cache"; // Whole months between two dates (floored), min 0 — for the experience record. function monthsBetween(from: Date, to: Date): number { const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) - (to.getDate() < from.getDate() ? 1 : 0); return Math.max(0, months); } 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 }; } // ── Sign off (Phase 4c, Epic K) ──────────────────────────────────────────────── // Ends a tour of duty: assignment → SIGNED_OFF, append an internal EXPERIENCE_RECORD, // flip the crew member back to EX_HAND (so they return to the Candidates pool), and // auto-raise a SIGN_OFF backfill requisition (reuses the Phase-2 helper). export async function signOffCrew(assignmentId: string, signOffDate: string, remarks?: string): Promise { const g = await guard("sign_off_crew"); if ("error" in g) return g; if (!signOffDate) return { error: "A sign-off date is required" }; const assignment = await db.crewAssignment.findUnique({ where: { id: assignmentId }, include: { vessel: { select: { name: true } }, site: { select: { name: true } } }, }); if (!assignment) return { error: "Assignment not found" }; if (assignment.status === "SIGNED_OFF") return { error: "This crew member has already signed off" }; const off = new Date(signOffDate); await db.$transaction(async (tx) => { await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } }); await tx.experienceRecord.create({ data: { crewMemberId: assignment.crewMemberId, rankId: assignment.rankId, vesselType: assignment.vessel?.name ?? assignment.site?.name ?? null, fromDate: assignment.signOnDate, toDate: off, durationMonths: monthsBetween(assignment.signOnDate, off), source: "internal", }, }); // Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand. await tx.crewMember.update({ where: { id: assignment.crewMemberId }, data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId }, }); await tx.crewAction.create({ data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null }, }); }); // The seat is now vacant → auto-raise a backfill requisition (spec §5.3). await autoRaiseRequisition({ rankId: assignment.rankId, vesselId: assignment.vesselId, siteId: assignment.siteId, reason: "SIGN_OFF", }); revalidatePath(crewPath(assignment.crewMemberId)); revalidatePath("/crewing/crew"); return { ok: true }; }