feat(crewing): admin crew management — direct placement, CRUD, strength config #71
11 changed files with 754 additions and 3 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
55
App/app/(portal)/admin/crew-strength/actions.ts
Normal file
55
App/app/(portal)/admin/crew-strength/actions.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
34
App/app/(portal)/admin/crew-strength/page.tsx
Normal file
34
App/app/(portal)/admin/crew-strength/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
164
App/app/(portal)/admin/crew/actions.ts
Normal file
164
App/app/(portal)/admin/crew/actions.ts
Normal 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 };
|
||||
}
|
||||
201
App/app/(portal)/admin/crew/admin-crew-manager.tsx
Normal file
201
App/app/(portal)/admin/crew/admin-crew-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
App/app/(portal)/admin/crew/page.tsx
Normal file
56
App/app/(portal)/admin/crew/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
134
App/tests/integration/crewing-admin.test.ts
Normal file
134
App/tests/integration/crewing-admin.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue