Phase 1 of the Crewing module per wiki Crewing-Implementation-Spec §12, all dark behind NEXT_PUBLIC_CREWING_ENABLED (off by default — production unchanged). - schema: add SITE_STAFF to Role; add Rank (self-referential org hierarchy, like Account) + RankDocRequirement, RankCategory & SeafarerDocType enums. - permissions: full §6 crewing grant matrix (PO_ROLE_PERMISSIONS + CREWING_ROLE_PERMISSIONS merged); SITE_STAFF row; MPO has no attendance/leave, approvals are Manager-only, manage_ranks is Manager+Admin. - feature flag: CREWING_ENABLED (opt-in "true"). - nav: flag-gated Crewing section scaffold + "Ranks & documents" under Admin. - reference data: rank-data.ts + rank-doc-data.ts seeded via shared seed-ranks.ts in both dev and prod seeds (19 ranks, 118 doc requirements). - screen: /admin/ranks — rank hierarchy card + per-rank required-documents card. - role-label/prefix maps updated for the new role. Tests: unit (permission matrix + flag), integration (ranks admin CRUD, parent linking, cycle/children guards, doc-requirement upsert/remove, permission gating). Docs: CLAUDE.md "Crewing (feature-flagged)" section + env var. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
6.6 KiB
TypeScript
184 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
|
import { createRank, updateRank } from "./actions";
|
|
import type { RankRow } from "./ranks-manager";
|
|
|
|
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";
|
|
|
|
function RankFormFields({ rank, allRanks }: { rank?: RankRow; allRanks: RankRow[] }) {
|
|
const parentOptions = allRanks.filter((r) => !rank || r.id !== rank.id);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
|
|
<input name="code" defaultValue={rank?.code} required maxLength={16} placeholder="e.g. SDO" className={INPUT} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
|
|
<input name="name" defaultValue={rank?.name} required placeholder="e.g. Sr. Dredge Operator" className={INPUT} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reports to</label>
|
|
<select name="parentId" defaultValue={rank?.parentId ?? ""} className={INPUT}>
|
|
<option value="">— Top of the org —</option>
|
|
{parentOptions.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">Category</label>
|
|
<select name="category" defaultValue={rank?.category ?? "OPERATIONAL"} className={INPUT}>
|
|
<option value="OPERATIONAL">Operational</option>
|
|
<option value="SUPPORT">Support</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-6 pt-1">
|
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
|
<input type="checkbox" name="isSeafarer" defaultChecked={rank?.isSeafarer ?? false} className="h-4 w-4" />
|
|
Seafarer (holds STCW / CDC etc.)
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
|
<input type="checkbox" name="grantsLogin" defaultChecked={rank?.grantsLogin ?? false} className="h-4 w-4" />
|
|
Grants portal login
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Description</label>
|
|
<input name="description" defaultValue={rank?.description ?? ""} className={INPUT} placeholder="Optional" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AddRankButton({ allRanks }: { allRanks: RankRow[] }) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setPending(true);
|
|
setError("");
|
|
const result = await createRank(new FormData(e.currentTarget));
|
|
if ("error" in result) {
|
|
setError(result.error);
|
|
setPending(false);
|
|
} else {
|
|
setPending(false);
|
|
setOpen(false);
|
|
router.refresh();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
|
|
>
|
|
+ Add Rank
|
|
</button>
|
|
<AdminDialog title="Add Rank" open={open} onClose={() => setOpen(false)}>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<RankFormFields allRanks={allRanks} />
|
|
{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"
|
|
onClick={() => setOpen(false)}
|
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={pending}
|
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
|
|
>
|
|
{pending ? "Creating…" : "Create Rank"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</AdminDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function EditRankButton({
|
|
rank,
|
|
allRanks,
|
|
open: controlledOpen,
|
|
onOpenChange,
|
|
}: {
|
|
rank: RankRow;
|
|
allRanks: RankRow[];
|
|
open?: boolean;
|
|
onOpenChange?: (v: boolean) => void;
|
|
}) {
|
|
const router = useRouter();
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const isControlled = controlledOpen !== undefined;
|
|
const open = isControlled ? controlledOpen : internalOpen;
|
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
|
|
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setPending(true);
|
|
setError("");
|
|
const fd = new FormData(e.currentTarget);
|
|
fd.set("id", rank.id);
|
|
const result = await updateRank(fd);
|
|
if ("error" in result) {
|
|
setError(result.error);
|
|
setPending(false);
|
|
} else {
|
|
setPending(false);
|
|
setOpen(false);
|
|
router.refresh();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AdminDialog title="Edit Rank" open={open} onClose={() => setOpen(false)}>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<RankFormFields rank={rank} allRanks={allRanks} />
|
|
{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"
|
|
onClick={() => setOpen(false)}
|
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={pending}
|
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
|
|
>
|
|
{pending ? "Saving…" : "Save Changes"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</AdminDialog>
|
|
);
|
|
}
|