feat(crewing): admin crew management — direct placement, CRUD, strength config
All checks were successful
PR checks / checks (pull_request) Successful in 39s
PR checks / integration (pull_request) Successful in 28s

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>
This commit is contained in:
Hardik 2026-06-22 21:23:31 +05:30
parent 040a66488d
commit bb5f4126b0
11 changed files with 754 additions and 3 deletions

View file

@ -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`.

View file

@ -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<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 };
}

View file

@ -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 (
<div>
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Crew strength</h1>
<p className="text-sm text-neutral-500 mt-0.5">Required crew per rank, per vessel. Drives the leave-clash backfill a leave that drops cover below the required strength auto-raises a requisition.</p>
</div>
<form onSubmit={submit} className="mb-5 flex flex-wrap items-end gap-3 rounded-lg border border-neutral-200 bg-white p-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel</label>
<select className={INPUT} value={f.vesselId} onChange={(e) => setF({ ...f, vesselId: e.target.value })} required><option value=""> Vessel </option>{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank</label>
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })} required><option value=""> Rank </option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Min strength</label>
<input className={`${INPUT} w-28`} type="number" min={0} value={f.minStrength} onChange={(e) => setF({ ...f, minStrength: e.target.value })} required />
</div>
<button className={BTN} disabled={pending || !f.vesselId || !f.rankId}>{pending ? "Saving…" : "Set requirement"}</button>
{error && <p className="w-full text-sm text-danger-700">{error}</p>}
</form>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Vessel</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Min strength</th>
<th className="px-4 py-3 w-20"></th>
</tr>
</thead>
<tbody>
{requirements.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-12 text-center text-neutral-400">No requirements set. Unconfigured rank/vessel pairs default to a strength of 1.</td></tr>
) : requirements.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 text-neutral-800">{r.vessel}</td>
<td className="px-4 py-3 text-neutral-700">{r.rank}</td>
<td className="px-4 py-3 font-semibold text-neutral-900">{r.minStrength}</td>
<td className="px-4 py-3 text-right">
<button className="text-xs font-medium text-danger-600 hover:underline" onClick={async () => { await deleteRequirement(r.id); router.refresh(); }}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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 (
<CrewStrengthManager
requirements={requirements.map((r) => ({ id: r.id, vessel: r.vessel.name, rank: r.rank.name, minStrength: r.minStrength }))}
vessels={vessels}
ranks={ranks}
/>
);
}

View file

@ -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<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 };
}

View file

@ -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<CrewStatus, "outline" | "default" | "success" | "secondary" | "danger"> = {
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 (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Crew management</h1>
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} crew records · create, edit, place onto a vessel/site, or remove</p>
</div>
<CrewFormButton ranks={ranks} />
</div>
<input className={`${INPUT} mb-4 max-w-sm`} placeholder="Search name or employee no…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Employee</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3 w-12"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">No crew records.</td></tr>
) : filtered.map((c) => <Row key={c.id} c={c} ranks={ranks} vessels={vessels} sites={sites} />)}
</tbody>
</table>
</div>
</div>
);
}
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 (
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{c.name}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId ?? "—"}</td>
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{label(c.status)}</Badge></td>
<td className="px-4 py-3 text-neutral-700">{c.currentRank ?? "—"}</td>
<td className="px-4 py-3 text-right">
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
{!c.hasActiveAssignment && <RowActionsItem onClick={() => setPlaceOpen(true)}>Place onto vessel/site</RowActionsItem>}
<RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<CrewFormButton ranks={ranks} editing={c} open={editOpen} onOpenChange={setEditOpen} />
<PlaceDialog crew={c} ranks={ranks} vessels={vessels} sites={sites} open={placeOpen} onOpenChange={setPlaceOpen} />
<DeleteConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} label={c.name} onConfirm={() => deleteCrewMember(c.id)} />
</td>
</tr>
);
}
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 && <button className={BTN} onClick={() => setOpen(true)}>+ Add crew</button>}
<AdminDialog title={editing ? "Edit crew member" : "Add crew member"} open={isOpen} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value as CandidateType })}>{TYPES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
<input className={INPUT} type="number" min={0} placeholder="Experience (months)" value={f.experienceMonths} onChange={(e) => setF({ ...f, experienceMonths: e.target.value })} />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !f.name} className={BTN}>{pending ? "Saving…" : editing ? "Save changes" : "Add crew"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
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 (
<AdminDialog title={`Place ${crew.name}`} open={open} onClose={() => onOpenChange(false)}>
<form onSubmit={submit} className="space-y-3">
<p className="text-sm text-neutral-600">Assign this crew member directly to a vessel/site no requisition needed. A candidate is promoted to active crew with an employee number.</p>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })} required><option value=""> Rank </option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel / site *</label>
<select className={INPUT} value={f.location} onChange={(e) => setF({ ...f, location: e.target.value })} required>
<option value=""> Select </option>
{vessels.length > 0 && <optgroup label="Vessels">{vessels.map((v) => <option key={v.id} value={`v:${v.id}`}>{v.name}</option>)}</optgroup>}
{sites.length > 0 && <optgroup label="Sites">{sites.map((s) => <option key={s.id} value={`s:${s.id}`}>{s.name}</option>)}</optgroup>}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
<input type="date" className={INPUT} value={f.signOnDate} onChange={(e) => setF({ ...f, signOnDate: e.target.value })} required />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className={SECONDARY} onClick={() => onOpenChange(false)}>Cancel</button>
<button type="submit" disabled={pending || !f.rankId || !f.location || !f.signOnDate} className={BTN}>{pending ? "Placing…" : "Place crew"}</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -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 (
<AdminCrewManager
crew={crew.map((c) => ({
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}
/>
);
}

View file

@ -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[] },
]
: []),
];

View file

@ -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<Role, Permission[]> = {
"approve_wage_report",
"view_wage_report",
"manage_ranks",
"manage_crew",
],
SUPERUSER: [
"raise_requisition",
@ -207,9 +211,10 @@ const CREWING_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"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<Role, Permission[]> = Object.fromEntries(

View file

@ -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<unknown>).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" });
});
});

View file

@ -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);