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>
82 lines
4.5 KiB
TypeScript
82 lines
4.5 KiB
TypeScript
"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>
|
|
);
|
|
}
|