pelagia-portal/App/app/(portal)/admin/ranks/rank-doc-panel.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

132 lines
5.1 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { SeafarerDocType } from "@prisma/client";
import type { RankRow } from "./ranks-manager";
import { addRankDocRequirement, removeRankDocRequirement } from "./actions";
// Listed (not imported as a runtime enum) to keep @prisma/client out of the client bundle.
const DOC_TYPES: { value: SeafarerDocType; label: string }[] = [
{ value: "STCW", label: "STCW" },
{ value: "AADHAAR", label: "Aadhaar" },
{ value: "PAN", label: "PAN" },
{ value: "PASSPORT", label: "Passport" },
{ value: "CDC", label: "CDC" },
{ value: "COC", label: "COC" },
{ value: "PHOTOGRAPH", label: "Photograph" },
{ value: "DRIVING_LICENSE", label: "Driving licence" },
{ value: "MEDICAL_FITNESS", label: "Medical fitness" },
{ value: "CONTRACT_LETTER", label: "Contract letter" },
];
const DOC_LABEL = Object.fromEntries(DOC_TYPES.map((d) => [d.value, d.label])) as Record<SeafarerDocType, string>;
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";
export function RankDocPanel({ rank }: { rank: RankRow | null }) {
const router = useRouter();
const [adding, setAdding] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
if (!rank) {
return (
<div className="rounded-lg border border-neutral-200 bg-white p-8 text-center text-sm text-neutral-400">
Select a rank to manage its required documents.
</div>
);
}
async function handleAdd(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData(e.currentTarget);
fd.set("rankId", rank!.id);
const result = await addRankDocRequirement(fd);
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
setPending(false);
setAdding(false);
router.refresh();
}
}
async function handleRemove(id: string) {
await removeRankDocRequirement(id);
router.refresh();
}
return (
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50 flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-neutral-900">Required documents</h2>
<p className="text-xs text-neutral-500 mt-0.5">{rank.code} {rank.name}</p>
</div>
<button
onClick={() => setAdding((v) => !v)}
className="rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100"
>
{adding ? "Close" : "+ Add"}
</button>
</div>
{adding && (
<form onSubmit={handleAdd} className="px-4 py-3 border-b border-neutral-100 bg-neutral-50/50 space-y-2">
<select name="docType" className={INPUT} defaultValue={DOC_TYPES[0].value}>
{DOC_TYPES.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="isMandatory" defaultChecked className="h-4 w-4" />
Mandatory (uncheck for conditional)
</label>
<input name="note" className={INPUT} placeholder="Note (optional)" />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<button
type="submit"
disabled={pending}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
>
{pending ? "Saving…" : "Add requirement"}
</button>
</form>
)}
{rank.docRequirements.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-neutral-400">No required documents for this rank.</p>
) : (
<div>
{rank.docRequirements.map((d) => (
<div key={d.id} className="flex items-center gap-2 px-4 py-2.5 border-b border-neutral-100 last:border-0">
<span className="text-sm text-neutral-900 flex-1">{DOC_LABEL[d.docType] ?? d.docType}</span>
{d.note && <span className="text-xs text-neutral-400 max-w-[10rem] truncate">{d.note}</span>}
<span
className={
d.isMandatory
? "rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-xs font-medium"
: "rounded-full bg-neutral-100 text-neutral-500 px-2 py-0.5 text-xs font-medium"
}
>
{d.isMandatory ? "Mandatory" : "Conditional"}
</span>
<button
onClick={() => handleRemove(d.id)}
className="text-xs text-danger-700 hover:underline"
title="Remove"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
);
}