Rank held applies to every candidate, not just ex-hands; it auto-updates for returning crew on sign-off. Ex-hand designation is decoupled from the Source dropdown and owned by the office: - Candidate form: drop the EX_HAND source option, relabel "Rank held (ex-hands)" to "Rank held". addCandidate always intakes NEW/CANDIDATE (ex-hand recognition still reuses an existing EX_HAND row); updateCandidate no longer rewrites type/status, so an admin-set EX_HAND or onboarded EMPLOYEE is never clobbered by a candidate edit. - Admin crew form: the type NEW/EX_HAND select becomes an "Ex-hand (returning crew)" checkbox -- the only place ex-hand is tagged. - List/detail ex-hand indicators key on type === EX_HAND (not source). - Sign-off preserves the original recruitment source when flipping to EX_HAND. - Tests seed EX_HAND rows directly; assert candidate intake stays NEW. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
329 lines
14 KiB
TypeScript
329 lines
14 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, notifyAutoRaised } 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, docType: true } });
|
|
if (!doc) return { error: "Document not found" };
|
|
await db.$transaction(async (tx) => {
|
|
await tx.seafarerDocument.delete({ where: { id } });
|
|
await tx.crewAction.create({
|
|
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: doc.crewMemberId, metadata: { record: "document", docType: doc.docType } },
|
|
});
|
|
});
|
|
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.$transaction(async (tx) => {
|
|
await tx.nextOfKin.delete({ where: { id } });
|
|
await tx.crewAction.create({
|
|
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: nok.crewMemberId, metadata: { record: "next_of_kin" } },
|
|
});
|
|
});
|
|
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);
|
|
|
|
// Sign-off + the backfill requisition commit atomically (spec §5.3/§11): the
|
|
// seat can never become vacant without its backfill being raised.
|
|
const backfill = 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. The ex-hand flag lives on type/status — their original
|
|
// source (how they were first recruited) is preserved. currentRank (rank
|
|
// held) is refreshed to the tour they just signed off from.
|
|
await tx.crewMember.update({
|
|
where: { id: assignment.crewMemberId },
|
|
data: { status: "EX_HAND", type: "EX_HAND", currentRankId: assignment.rankId },
|
|
});
|
|
await tx.crewAction.create({
|
|
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
|
});
|
|
return autoRaiseRequisition(
|
|
{ rankId: assignment.rankId, vesselId: assignment.vesselId, siteId: assignment.siteId, reason: "SIGN_OFF" },
|
|
tx
|
|
);
|
|
});
|
|
// Notify the office after the transaction commits.
|
|
await notifyAutoRaised(backfill);
|
|
|
|
revalidatePath(crewPath(assignment.crewMemberId));
|
|
revalidatePath("/crewing/crew");
|
|
return { ok: true };
|
|
}
|