pelagia-portal/App/app/(portal)/admin/ranks/rank-form.tsx
Hardik d0006a8fc7
All checks were successful
PR checks / checks (pull_request) Successful in 36s
PR checks / integration (pull_request) Successful in 28s
feat(crewing): foundations — SITE_STAFF role, ranks reference data + admin (flagged)
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>
2026-06-22 13:26:04 +05:30

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