diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 010799f..bb51b0e 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -175,6 +175,11 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat - **Screens:** `/crewing/leave` (apply-on-behalf modal + requests list with Manager Approve/Decline) and `/crewing/attendance` (crew dropdown + month grid, tap-to-cycle Present/Absent/Leave/Half-day, Save). **Leave** + **Attendance** added to the flag-gated nav (Manager + Site staff only). - **Deferred:** the 6-month leave-planner timeline with clash bars (§8.9) is a lightweight list for now; hours/overtime attendance (A7) stays deferred. +**Crewing admin (office/admin management):** a new `manage_crew` permission (Manager + SuperUser + Admin) gates a small Administration surface: +- **Crew management** (`/admin/crew`): full CRUD over `CrewMember` (any status), and **direct placement** — `placeCrew` assigns a crew member to a vessel/site **without a requisition** (creates an `ACTIVE` `CrewAssignment`; promotes a candidate to `EMPLOYEE` with a `CRW-` number; blocked if they already have an active assignment). +- **Crew strength** (`/admin/crew-strength`): CRUD over `VesselRankRequirement` (the `minStrength` that drives R6 leave-clash detection). +- Both links sit under **Administration** (flag-gated, Manager/Admin/SuperUser). + ### GST Calculation `totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`. diff --git a/App/app/(portal)/admin/crew-strength/actions.ts b/App/app/(portal)/admin/crew-strength/actions.ts new file mode 100644 index 0000000..ed7cde6 --- /dev/null +++ b/App/app/(portal)/admin/crew-strength/actions.ts @@ -0,0 +1,55 @@ +"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 { + 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 { + const denied = await guard(); + if ("error" in denied) return denied; + await db.vesselRankRequirement.delete({ where: { id } }).catch(() => {}); + revalidatePath(PATH); + return { ok: true }; +} diff --git a/App/app/(portal)/admin/crew-strength/crew-strength-manager.tsx b/App/app/(portal)/admin/crew-strength/crew-strength-manager.tsx new file mode 100644 index 0000000..5d2cf27 --- /dev/null +++ b/App/app/(portal)/admin/crew-strength/crew-strength-manager.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { upsertRequirement, deleteRequirement } from "./actions"; + +const INPUT = "rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; +const BTN = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"; + +type Opt = { id: string; name: string }; +type RankOpt = { id: string; code: string; name: string }; +type Req = { id: string; vessel: string; rank: string; minStrength: number }; + +export function CrewStrengthManager({ requirements, vessels, ranks }: { requirements: Req[]; vessels: Opt[]; ranks: RankOpt[] }) { + const router = useRouter(); + const [f, setF] = useState({ vesselId: "", rankId: "", minStrength: "1" }); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setPending(true); setError(""); + const fd = new FormData(); + fd.set("vesselId", f.vesselId); fd.set("rankId", f.rankId); fd.set("minStrength", f.minStrength); + const res = await upsertRequirement(fd); + setPending(false); + if ("error" in res) setError(res.error); else { setF({ vesselId: "", rankId: "", minStrength: "1" }); router.refresh(); } + } + + return ( +
+
+

Crew strength

+

Required crew per rank, per vessel. Drives the leave-clash backfill — a leave that drops cover below the required strength auto-raises a requisition.

+
+ +
+
+ + +
+
+ + +
+
+ + setF({ ...f, minStrength: e.target.value })} required /> +
+ + {error &&

{error}

} +
+ +
+ + + + + + + + + + + {requirements.length === 0 ? ( + + ) : requirements.map((r) => ( + + + + + + + ))} + +
VesselRankMin strength
No requirements set. Unconfigured rank/vessel pairs default to a strength of 1.
{r.vessel}{r.rank}{r.minStrength} + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/crew-strength/page.tsx b/App/app/(portal)/admin/crew-strength/page.tsx new file mode 100644 index 0000000..2db3d8a --- /dev/null +++ b/App/app/(portal)/admin/crew-strength/page.tsx @@ -0,0 +1,34 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { CREWING_ENABLED } from "@/lib/feature-flags"; +import { redirect, notFound } from "next/navigation"; +import { CrewStrengthManager } from "./crew-strength-manager"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Crew strength" }; + +export default async function CrewStrengthPage() { + if (!CREWING_ENABLED) notFound(); + + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_crew")) redirect("/dashboard"); + + const [requirements, vessels, ranks] = await Promise.all([ + db.vesselRankRequirement.findMany({ + orderBy: [{ vessel: { name: "asc" } }, { rank: { name: "asc" } }], + include: { vessel: { select: { name: true } }, rank: { select: { name: true } } }, + }), + db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), + db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }), + ]); + + return ( + ({ id: r.id, vessel: r.vessel.name, rank: r.rank.name, minStrength: r.minStrength }))} + vessels={vessels} + ranks={ranks} + /> + ); +} diff --git a/App/app/(portal)/admin/crew/actions.ts b/App/app/(portal)/admin/crew/actions.ts new file mode 100644 index 0000000..688fce3 --- /dev/null +++ b/App/app/(portal)/admin/crew/actions.ts @@ -0,0 +1,164 @@ +"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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/App/app/(portal)/admin/crew/admin-crew-manager.tsx b/App/app/(portal)/admin/crew/admin-crew-manager.tsx new file mode 100644 index 0000000..2fe7d7d --- /dev/null +++ b/App/app/(portal)/admin/crew/admin-crew-manager.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client"; +import { Badge } from "@/components/ui/badge"; +import { AdminDialog } from "@/components/ui/admin-dialog"; +import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu"; +import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; +import { createCrewMember, updateCrewMember, deleteCrewMember, placeCrew } from "./actions"; + +const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; +const BTN = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"; +const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"; + +const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"]; +const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"]; +const TYPES: CandidateType[] = ["NEW", "EX_HAND"]; +const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase()); + +type Opt = { id: string; name: string }; +type RankOpt = { id: string; code: string; name: string }; +type Crew = { + id: string; name: string; status: CrewStatus; type: CandidateType; source: CandidateSource; + email: string | null; phone: string | null; employeeId: string | null; + appliedRankId: string | null; currentRankId: string | null; currentRank: string | null; + experienceMonths: number; hasActiveAssignment: boolean; removable: boolean; +}; + +const STATUS_VARIANT: Record = { + PROSPECT: "outline", CANDIDATE: "default", EMPLOYEE: "success", EX_HAND: "secondary", BLACKLISTED: "danger", +}; + +export function AdminCrewManager({ crew, ranks, vessels, sites }: { crew: Crew[]; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[] }) { + const [search, setSearch] = useState(""); + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return crew.filter((c) => !q || `${c.name} ${c.employeeId ?? ""}`.toLowerCase().includes(q)); + }, [crew, search]); + + return ( +
+
+
+

Crew management

+

{crew.length} crew records · create, edit, place onto a vessel/site, or remove

+
+ +
+ + setSearch(e.target.value)} /> + +
+ + + + + + + + + + + + {filtered.length === 0 ? ( + + ) : filtered.map((c) => )} + +
NameEmployeeStatusRank
No crew records.
+
+
+ ); +} + +function Row({ c, ranks, vessels, sites }: { c: Crew; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[] }) { + const [editOpen, setEditOpen] = useState(false); + const [placeOpen, setPlaceOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + return ( + + {c.name} + {c.employeeId ?? "—"} + {label(c.status)} + {c.currentRank ?? "—"} + + + setEditOpen(true)}>Edit + {!c.hasActiveAssignment && setPlaceOpen(true)}>Place onto vessel/site} + + setDeleteOpen(true)}>Delete + + + + deleteCrewMember(c.id)} /> + + + ); +} + +function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt[]; editing?: Crew; open?: boolean; onOpenChange?: (v: boolean) => void }) { + const router = useRouter(); + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : internalOpen; + const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen; + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [f, setF] = useState({ + name: editing?.name ?? "", status: editing?.status ?? "CANDIDATE", type: editing?.type ?? "NEW", source: editing?.source ?? "CAREERS", + email: editing?.email ?? "", phone: editing?.phone ?? "", appliedRankId: editing?.appliedRankId ?? "", currentRankId: editing?.currentRankId ?? "", + experienceMonths: String(editing?.experienceMonths ?? 0), + }); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setPending(true); setError(""); + const fd = new FormData(); + if (editing) fd.set("id", editing.id); + Object.entries(f).forEach(([k, v]) => v !== "" && fd.set(k, String(v))); + const res = await (editing ? updateCrewMember(fd) : createCrewMember(fd)); + setPending(false); + if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); } + } + + return ( + <> + {!isControlled && } + setOpen(false)}> +
+
+ setF({ ...f, name: e.target.value })} required /> + + + + + + setF({ ...f, email: e.target.value })} /> + setF({ ...f, phone: e.target.value })} /> + setF({ ...f, experienceMonths: e.target.value })} /> +
+ {error &&

{error}

} +
+ + +
+
+
+ + ); +} + +function PlaceDialog({ crew, ranks, vessels, sites, open, onOpenChange }: { crew: Crew; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[]; open: boolean; onOpenChange: (v: boolean) => void }) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const [f, setF] = useState({ rankId: crew.currentRankId ?? crew.appliedRankId ?? "", location: "", signOnDate: "" }); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setPending(true); setError(""); + const fd = new FormData(); + fd.set("crewMemberId", crew.id); + fd.set("rankId", f.rankId); + if (f.location.startsWith("v:")) fd.set("vesselId", f.location.slice(2)); + else if (f.location.startsWith("s:")) fd.set("siteId", f.location.slice(2)); + fd.set("signOnDate", f.signOnDate); + const res = await placeCrew(fd); + setPending(false); + if ("error" in res) setError(res.error); else { onOpenChange(false); router.refresh(); } + } + + return ( + onOpenChange(false)}> +
+

Assign this crew member directly to a vessel/site — no requisition needed. A candidate is promoted to active crew with an employee number.

+
+ + +
+
+ + +
+
+ + setF({ ...f, signOnDate: e.target.value })} required /> +
+ {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/crew/page.tsx b/App/app/(portal)/admin/crew/page.tsx new file mode 100644 index 0000000..e5f8d3e --- /dev/null +++ b/App/app/(portal)/admin/crew/page.tsx @@ -0,0 +1,56 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { CREWING_ENABLED } from "@/lib/feature-flags"; +import { redirect, notFound } from "next/navigation"; +import { AdminCrewManager } from "./admin-crew-manager"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Crew management" }; + +export default async function AdminCrewPage() { + if (!CREWING_ENABLED) notFound(); + + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_crew")) redirect("/dashboard"); + + const [crew, ranks, vessels, sites] = await Promise.all([ + db.crewMember.findMany({ + orderBy: { name: "asc" }, + include: { + currentRank: { select: { name: true } }, + appliedRank: { select: { name: true } }, + assignments: { where: { status: { not: "SIGNED_OFF" } }, select: { id: true }, take: 1 }, + _count: { select: { assignments: true, applications: true } }, + }, + }), + db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }), + db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), + db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), + ]); + + return ( + ({ + id: c.id, + name: c.name, + status: c.status, + type: c.type, + source: c.source, + email: c.email, + phone: c.phone, + employeeId: c.employeeId, + appliedRankId: c.appliedRankId, + currentRankId: c.currentRankId, + currentRank: c.currentRank?.name ?? null, + experienceMonths: c.experienceMonths, + hasActiveAssignment: c.assignments.length > 0, + removable: c._count.assignments === 0 && c._count.applications === 0, + }))} + ranks={ranks} + vessels={vessels} + sites={sites} + /> + ); +} diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index 3e43b68..8ab5328 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -30,6 +30,8 @@ import { Contact, CalendarDays, CalendarCheck, + UserCog, + Gauge, } from "lucide-react"; import type { Role } from "@prisma/client"; @@ -95,7 +97,11 @@ const MANAGER_ADMIN_ITEMS: NavItem[] = [ { href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] }, // Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN). ...(CREWING_ENABLED - ? [{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] }] + ? [ + { href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] }, + { href: "/admin/crew", label: "Crew management", icon: UserCog, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] }, + { href: "/admin/crew-strength", label: "Crew strength", icon: Gauge, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] }, + ] : []), ]; diff --git a/App/lib/permissions.ts b/App/lib/permissions.ts index bf297bb..9ff85aa 100644 --- a/App/lib/permissions.ts +++ b/App/lib/permissions.ts @@ -51,7 +51,10 @@ export type Permission = | "generate_wage_report" | "approve_wage_report" | "view_wage_report" - | "manage_ranks"; + | "manage_ranks" + // Office/admin crew management — direct placement (no requisition), crew CRUD, + // and per-vessel rank-strength config. Held by Manager + Admin (+ SuperUser). + | "manage_crew"; // Purchasing / admin permissions (the original PPMS matrix). SITE_STAFF is a // crewing-only role and holds no purchasing permissions. @@ -176,6 +179,7 @@ const CREWING_ROLE_PERMISSIONS: Record = { "approve_wage_report", "view_wage_report", "manage_ranks", + "manage_crew", ], SUPERUSER: [ "raise_requisition", @@ -207,9 +211,10 @@ const CREWING_ROLE_PERMISSIONS: Record = { "generate_wage_report", "approve_wage_report", "view_wage_report", + "manage_crew", ], AUDITOR: ["view_requisitions", "view_crew_records", "view_attendance", "view_wage_report"], - ADMIN: ["view_requisitions", "view_crew_records", "view_wage_report", "manage_ranks"], + ADMIN: ["view_requisitions", "view_crew_records", "view_wage_report", "manage_ranks", "manage_crew"], }; const ROLE_PERMISSIONS: Record = Object.fromEntries( diff --git a/App/tests/integration/crewing-admin.test.ts b/App/tests/integration/crewing-admin.test.ts new file mode 100644 index 0000000..5b187c3 --- /dev/null +++ b/App/tests/integration/crewing-admin.test.ts @@ -0,0 +1,134 @@ +/** + * Integration tests for the crewing-admin actions: admin crew CRUD, Manager + * direct placement (no requisition), and per-vessel/per-rank strength config — + * all gated by the new manage_crew permission. + */ +import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { createCrewMember, updateCrewMember, deleteCrewMember, placeCrew } from "@/app/(portal)/admin/crew/actions"; +import { upsertRequirement, deleteRequirement } from "@/app/(portal)/admin/crew-strength/actions"; +import { makeSession, getSeedUser, fd } from "./helpers"; +import type { Role } from "@prisma/client"; + +let managerId: string; +let adminId: string; +let siteStaffId: string; +let rankId: string; +let vesselId: string; + +const SS_EMAIL = "sitestaff@itadm.local"; +const as = (userId: string, role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); + +beforeAll(async () => { + managerId = (await getSeedUser("manager@pelagia.local")).id; + adminId = (await getSeedUser("admin@pelagia.local")).id; + const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITADM-SS", email: SS_EMAIL, name: "SS Adm", role: "SITE_STAFF" } }); + siteStaffId = ss.id; + rankId = (await db.rank.findFirstOrThrow()).id; + vesselId = (await db.vessel.findFirstOrThrow()).id; +}); + +afterEach(async () => { + await db.crewAction.deleteMany({}); + await db.crewAssignment.deleteMany({}); + await db.vesselRankRequirement.deleteMany({}); + await db.crewMember.deleteMany({}); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await db.user.deleteMany({ where: { email: SS_EMAIL } }); +}); + +describe("admin crew CRUD (manage_crew)", () => { + it("admin creates and edits a crew member", async () => { + as(adminId, "ADMIN"); + const res = await createCrewMember(fd({ name: "Direct Hire", status: "CANDIDATE", source: "WALK_IN" })); + expect("ok" in res && res.ok).toBe(true); + const c = await db.crewMember.findFirstOrThrow({ where: { name: "Direct Hire" } }); + expect(c.source).toBe("WALK_IN"); + + await updateCrewMember(fd({ id: c.id, name: "Direct Hire", status: "BLACKLISTED", source: "WALK_IN" })); + expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("BLACKLISTED"); + }); + + it("is rejected for roles without manage_crew (site staff)", async () => { + as(siteStaffId, "SITE_STAFF"); + expect(await createCrewMember(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" }); + expect(await db.crewMember.count()).toBe(0); + }); + + it("blocks deletion of crew with assignments", async () => { + as(managerId, "MANAGER"); + const c = await db.crewMember.create({ data: { name: "Has Assignment", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date(), crewMemberId: c.id, rankId, vesselId } }); + expect("error" in (await deleteCrewMember(c.id))).toBe(true); + expect(await db.crewMember.findUnique({ where: { id: c.id } })).not.toBeNull(); + }); + + it("deletes a crew member with no assignments/applications", async () => { + as(managerId, "MANAGER"); + const c = await db.crewMember.create({ data: { name: "Removable", status: "CANDIDATE", type: "NEW", source: "CAREERS" } }); + expect("ok" in (await deleteCrewMember(c.id))).toBe(true); + expect(await db.crewMember.findUnique({ where: { id: c.id } })).toBeNull(); + }); +}); + +describe("direct placement (Manager, no requisition)", () => { + it("places a candidate → ACTIVE assignment + promoted to EMPLOYEE with a CRW- number", async () => { + as(managerId, "MANAGER"); + const c = await db.crewMember.create({ data: { name: "To Place", status: "CANDIDATE", type: "NEW", source: "CAREERS" } }); + const res = await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" })); + expect("ok" in res && res.ok).toBe(true); + + const assignment = await db.crewAssignment.findFirstOrThrow({ where: { crewMemberId: c.id } }); + expect(assignment.status).toBe("ACTIVE"); + expect(assignment.requisitionId).toBeNull(); // no requisition + const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id } }); + expect(after.status).toBe("EMPLOYEE"); + expect(after.employeeId).toMatch(/^CRW-\d+$/); + expect(after.currentRankId).toBe(rankId); + }); + + it("refuses to place crew that already has an active assignment", async () => { + as(managerId, "MANAGER"); + const c = await db.crewMember.create({ data: { name: "Already Placed", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date(), crewMemberId: c.id, rankId, vesselId } }); + expect("error" in (await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" })))).toBe(true); + }); + + it("is rejected for roles without manage_crew", async () => { + as(siteStaffId, "SITE_STAFF"); + const c = await db.crewMember.create({ data: { name: "X", status: "CANDIDATE", type: "NEW", source: "CAREERS" } }); + expect(await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" }); + }); +}); + +describe("crew strength config (manage_crew)", () => { + it("upserts and removes a vessel/rank requirement", async () => { + as(managerId, "MANAGER"); + expect("ok" in (await upsertRequirement(fd({ vesselId, rankId, minStrength: "3" })))).toBe(true); + let req = await db.vesselRankRequirement.findUniqueOrThrow({ where: { vesselId_rankId: { vesselId, rankId } } }); + expect(req.minStrength).toBe(3); + // Upsert updates in place. + await upsertRequirement(fd({ vesselId, rankId, minStrength: "5" })); + req = await db.vesselRankRequirement.findUniqueOrThrow({ where: { vesselId_rankId: { vesselId, rankId } } }); + expect(req.minStrength).toBe(5); + expect(await db.vesselRankRequirement.count()).toBe(1); + + expect("ok" in (await deleteRequirement(req.id))).toBe(true); + expect(await db.vesselRankRequirement.count()).toBe(0); + }); + + it("is rejected for roles without manage_crew", async () => { + as(siteStaffId, "SITE_STAFF"); + expect(await upsertRequirement(fd({ vesselId, rankId, minStrength: "2" }))).toEqual({ error: "Unauthorized" }); + }); +}); diff --git a/App/tests/unit/permissions-crewing.test.ts b/App/tests/unit/permissions-crewing.test.ts index 1f883d9..746fea3 100644 --- a/App/tests/unit/permissions-crewing.test.ts +++ b/App/tests/unit/permissions-crewing.test.ts @@ -67,6 +67,15 @@ describe("Crewing permissions (spec §6)", () => { expect(hasPermission("ACCOUNTS", "record_attendance")).toBe(false); }); + it("manage_crew is Manager + SuperUser + Admin (office crew management)", () => { + expect(hasPermission("MANAGER", "manage_crew")).toBe(true); + expect(hasPermission("SUPERUSER", "manage_crew")).toBe(true); + expect(hasPermission("ADMIN", "manage_crew")).toBe(true); + expect(hasPermission("SITE_STAFF", "manage_crew")).toBe(false); + expect(hasPermission("MANNING", "manage_crew")).toBe(false); + expect(hasPermission("ACCOUNTS", "manage_crew")).toBe(false); + }); + it("manage_ranks is Manager + Admin only (not SuperUser)", () => { expect(hasPermission("MANAGER", "manage_ranks")).toBe(true); expect(hasPermission("ADMIN", "manage_ranks")).toBe(true);