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>
132 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|