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>
55 lines
2 KiB
TypeScript
55 lines
2 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 { z } from "zod";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
type ActionResult = { ok: true } | { error: string };
|
|
const PATH = "/admin/crew-strength";
|
|
|
|
async function guard(): Promise<{ error: string } | { ok: true }> {
|
|
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 { ok: true };
|
|
}
|
|
|
|
const schema = z.object({
|
|
vesselId: z.string().min(1, "Vessel is required"),
|
|
rankId: z.string().min(1, "Rank is required"),
|
|
minStrength: z.coerce.number().int().min(0, "Strength must be 0 or more").max(999),
|
|
});
|
|
|
|
// Per-vessel, per-rank required strength (drives leave-clash detection, R6).
|
|
export async function upsertRequirement(formData: FormData): Promise<ActionResult> {
|
|
const denied = await guard();
|
|
if ("error" in denied) return denied;
|
|
|
|
const parsed = schema.safeParse({
|
|
vesselId: formData.get("vesselId"),
|
|
rankId: formData.get("rankId"),
|
|
minStrength: formData.get("minStrength"),
|
|
});
|
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
const d = parsed.data;
|
|
|
|
await db.vesselRankRequirement.upsert({
|
|
where: { vesselId_rankId: { vesselId: d.vesselId, rankId: d.rankId } },
|
|
update: { minStrength: d.minStrength },
|
|
create: { vesselId: d.vesselId, rankId: d.rankId, minStrength: d.minStrength },
|
|
});
|
|
revalidatePath(PATH);
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function deleteRequirement(id: string): Promise<ActionResult> {
|
|
const denied = await guard();
|
|
if ("error" in denied) return denied;
|
|
await db.vesselRankRequirement.delete({ where: { id } }).catch(() => {});
|
|
revalidatePath(PATH);
|
|
return { ok: true };
|
|
}
|