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>
201 lines
12 KiB
TypeScript
201 lines
12 KiB
TypeScript
"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>
|
|
);
|
|
}
|