Final slice of Phase 4 (the Epic K piece deferred from Phase 2). Ends a tour of duty and returns the crew member to the candidate pool as an ex-hand. Per Crewing-Implementation-Spec §5.3. Behind NEXT_PUBLIC_CREWING_ENABLED. What's in - Schema: CrewActionType += CREW_SIGNED_OFF (migration crewing_signoff). - signOffCrew(assignmentId, date, remarks) (crewing/crew/actions.ts, sign_off_crew): one transaction — assignment → SIGNED_OFF (+ signOffDate); append an internal ExperienceRecord (rank, on/off dates, computed durationMonths); flip the SAME CrewMember EMPLOYEE → EX_HAND (type/source EX_HAND), so they reappear in Candidates as a returning hand; CrewAction CREW_SIGNED_OFF; then auto-raise a SIGN_OFF backfill requisition via autoRaiseRequisition. - Screen: a "Sign off" button on the crew-profile header (sign_off_crew holders — site staff / MPO / Manager); on success redirects to the Crew directory. Tests & docs - Integration: signoff.test.ts (3) — SIGNED_OFF + experience + EX_HAND + SIGN_OFF backfill, already-signed-off guard, permission gating. type-check clean; full unit (241) + integration (195) green. - CLAUDE.md updated — completes Phase 4 (E/F/G + K). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
316 lines
13 KiB
TypeScript
316 lines
13 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 { 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<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 };
|
|
}
|
|
|
|
// ── 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<ActionResult> {
|
|
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 };
|
|
}
|