Office/admin crewing-management surface behind a new manage_crew permission (Manager + SuperUser + Admin). Stacks on 4b. Behind NEXT_PUBLIC_CREWING_ENABLED. What's in - Permission: manage_crew added to the §6 matrix (MGR/SU/ADMIN). - Direct placement (placeCrew): a Manager assigns a crew member to a vessel/site WITHOUT a requisition — creates an ACTIVE CrewAssignment, promotes a candidate to EMPLOYEE with a CRW- number (generateEmployeeId), blocked if already actively assigned. - Admin crew CRUD: createCrewMember / updateCrewMember / deleteCrewMember (delete blocked when assignments/applications exist). - Crew strength config: upsert/delete VesselRankRequirement (the minStrength that drives R6 leave-clash detection). - Screens under Administration (flag-gated, MGR/SU/ADMIN): /admin/crew (list + add/ edit/delete + Place modal) and /admin/crew-strength (requirement table + form). Tests & docs - Unit: permissions-crewing.test.ts gains a manage_crew check. Integration: crewing-admin.test.ts (9) — CRUD, delete guard, direct placement (+promotion, +active-assignment guard), strength upsert/delete, manage_crew gating. type-check clean; full unit (241) + integration (192) green. - CLAUDE.md updated with the crewing-admin surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
6.6 KiB
TypeScript
164 lines
6.6 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
|
import { generateEmployeeId } from "@/lib/employee-number";
|
|
import { CrewStatus, CandidateType, CandidateSource } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
|
const PATH = "/admin/crew";
|
|
|
|
async function guard(): 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, "manage_crew")) return { error: "Unauthorized" };
|
|
return { userId: session.user.id };
|
|
}
|
|
|
|
const crewSchema = z.object({
|
|
name: z.string().trim().min(1, "Name is required"),
|
|
status: z.nativeEnum(CrewStatus).default("CANDIDATE"),
|
|
type: z.nativeEnum(CandidateType).default("NEW"),
|
|
source: z.nativeEnum(CandidateSource).default("CAREERS"),
|
|
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
|
|
phone: z.string().optional(),
|
|
appliedRankId: z.string().optional(),
|
|
currentRankId: z.string().optional(),
|
|
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
|
|
});
|
|
|
|
function parse(formData: FormData) {
|
|
return crewSchema.safeParse({
|
|
name: formData.get("name"),
|
|
status: (formData.get("status") as string) || undefined,
|
|
type: (formData.get("type") as string) || undefined,
|
|
source: (formData.get("source") as string) || undefined,
|
|
email: (formData.get("email") as string) || undefined,
|
|
phone: (formData.get("phone") as string) || undefined,
|
|
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
|
|
currentRankId: (formData.get("currentRankId") as string) || undefined,
|
|
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
|
|
});
|
|
}
|
|
|
|
export async function createCrewMember(formData: FormData): Promise<ActionResult> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
const parsed = parse(formData);
|
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
const d = parsed.data;
|
|
|
|
const crew = await db.crewMember.create({
|
|
data: {
|
|
name: d.name, status: d.status, type: d.type, source: d.source,
|
|
email: d.email || null, phone: d.phone || null,
|
|
appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null,
|
|
experienceMonths: d.experienceMonths,
|
|
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
|
|
},
|
|
});
|
|
revalidatePath(PATH);
|
|
return { ok: true, id: crew.id };
|
|
}
|
|
|
|
export async function updateCrewMember(formData: FormData): Promise<ActionResult> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
const id = formData.get("id") as string;
|
|
if (!id) return { error: "Crew ID is required" };
|
|
const parsed = parse(formData);
|
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
const d = parsed.data;
|
|
if (!(await db.crewMember.findUnique({ where: { id }, select: { id: true } }))) return { error: "Crew member not found" };
|
|
|
|
await db.crewMember.update({
|
|
where: { id },
|
|
data: {
|
|
name: d.name, status: d.status, type: d.type, source: d.source,
|
|
email: d.email || null, phone: d.phone || null,
|
|
appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null,
|
|
experienceMonths: d.experienceMonths,
|
|
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
|
|
},
|
|
});
|
|
revalidatePath(PATH);
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function deleteCrewMember(id: string): Promise<ActionResult> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
const crew = await db.crewMember.findUnique({
|
|
where: { id },
|
|
select: { _count: { select: { assignments: true, applications: true } } },
|
|
});
|
|
if (!crew) return { error: "Crew member not found" };
|
|
if (crew._count.assignments > 0 || crew._count.applications > 0) {
|
|
return { error: "Cannot delete: this crew member has assignments or applications. Remove those first." };
|
|
}
|
|
await db.crewAction.deleteMany({ where: { crewMemberId: id } });
|
|
await db.crewMember.delete({ where: { id } });
|
|
revalidatePath(PATH);
|
|
return { ok: true };
|
|
}
|
|
|
|
// ── Direct placement (Manager) — assign crew to a vessel/site, no requisition ──
|
|
|
|
const placeSchema = z
|
|
.object({
|
|
crewMemberId: z.string().min(1, "Crew member is required"),
|
|
rankId: z.string().min(1, "Rank is required"),
|
|
vesselId: z.string().optional(),
|
|
siteId: z.string().optional(),
|
|
signOnDate: z.string().min(1, "Joining date is required"),
|
|
})
|
|
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), { message: "A vessel or site is required" });
|
|
|
|
export async function placeCrew(formData: FormData): Promise<ActionResult> {
|
|
const g = await guard();
|
|
if ("error" in g) return g;
|
|
|
|
const parsed = placeSchema.safeParse({
|
|
crewMemberId: formData.get("crewMemberId"),
|
|
rankId: formData.get("rankId"),
|
|
vesselId: (formData.get("vesselId") as string) || undefined,
|
|
siteId: (formData.get("siteId") as string) || undefined,
|
|
signOnDate: formData.get("signOnDate"),
|
|
});
|
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
const d = parsed.data;
|
|
|
|
const crew = await db.crewMember.findUnique({
|
|
where: { id: d.crewMemberId },
|
|
include: { assignments: { where: { status: { not: "SIGNED_OFF" } }, select: { id: true } } },
|
|
});
|
|
if (!crew) return { error: "Crew member not found" };
|
|
if (crew.assignments.length > 0) return { error: "This crew member already has an active assignment" };
|
|
|
|
await db.$transaction(async (tx) => {
|
|
await tx.crewAssignment.create({
|
|
data: {
|
|
status: "ACTIVE",
|
|
signOnDate: new Date(d.signOnDate),
|
|
crewMemberId: crew.id,
|
|
rankId: d.rankId,
|
|
vesselId: d.vesselId || null,
|
|
siteId: d.siteId || null,
|
|
},
|
|
});
|
|
// Promote a candidate/ex-hand to active crew (employee no.) on first placement.
|
|
const data: { status: "EMPLOYEE"; currentRankId: string; employeeId?: string } = { status: "EMPLOYEE", currentRankId: d.rankId };
|
|
if (!crew.employeeId) data.employeeId = await generateEmployeeId(tx);
|
|
await tx.crewMember.update({ where: { id: crew.id }, data });
|
|
await tx.crewAction.create({ data: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: crew.id, metadata: { direct: true } } });
|
|
});
|
|
|
|
revalidatePath(PATH);
|
|
revalidatePath("/crewing/crew");
|
|
return { ok: true };
|
|
}
|