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>
200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import type { RankCategory, SeafarerDocType } from "@prisma/client";
|
|
import { AddRankButton, EditRankButton } from "./rank-form";
|
|
import { RankDocPanel } from "./rank-doc-panel";
|
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
|
import { deleteRank, toggleRankActive } from "./actions";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type DocReqRow = {
|
|
id: string;
|
|
docType: SeafarerDocType;
|
|
isMandatory: boolean;
|
|
note: string | null;
|
|
};
|
|
|
|
export type RankRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
description: string | null;
|
|
category: RankCategory;
|
|
isSeafarer: boolean;
|
|
grantsLogin: boolean;
|
|
isActive: boolean;
|
|
parentId: string | null;
|
|
docRequirements: DocReqRow[];
|
|
};
|
|
|
|
type TreeNode = RankRow & { children: TreeNode[] };
|
|
|
|
function buildTree(ranks: RankRow[]): TreeNode[] {
|
|
const byId = new Map<string, TreeNode>();
|
|
ranks.forEach((r) => byId.set(r.id, { ...r, children: [] }));
|
|
const roots: TreeNode[] = [];
|
|
byId.forEach((node) => {
|
|
if (node.parentId && byId.has(node.parentId)) {
|
|
byId.get(node.parentId)!.children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
});
|
|
const sortRec = (nodes: TreeNode[]) => {
|
|
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
|
nodes.forEach((n) => sortRec(n.children));
|
|
};
|
|
sortRec(roots);
|
|
return roots;
|
|
}
|
|
|
|
function RankActionsMenu({ rank, allRanks }: { rank: RankRow; allRanks: RankRow[] }) {
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
const [toggleOpen, setToggleOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<RowActionsMenu>
|
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
|
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
|
{rank.isActive ? "Deactivate" : "Activate"}
|
|
</RowActionsItem>
|
|
<RowActionsSeparator />
|
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
|
</RowActionsMenu>
|
|
<EditRankButton rank={rank} allRanks={allRanks} open={editOpen} onOpenChange={setEditOpen} />
|
|
<DeleteConfirmDialog
|
|
open={deleteOpen}
|
|
onOpenChange={setDeleteOpen}
|
|
label={`${rank.code} — ${rank.name}`}
|
|
onConfirm={() => deleteRank(rank.id)}
|
|
/>
|
|
<ConfirmDialog
|
|
open={toggleOpen}
|
|
onOpenChange={setToggleOpen}
|
|
title={rank.isActive ? `Deactivate ${rank.name}?` : `Activate ${rank.name}?`}
|
|
description={
|
|
rank.isActive
|
|
? `${rank.name} will be hidden from new requisitions and crew records.`
|
|
: `${rank.name} will become available again.`
|
|
}
|
|
confirmLabel={rank.isActive ? "Deactivate" : "Activate"}
|
|
onConfirm={() => toggleRankActive(rank.id)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function RankRowView({
|
|
node,
|
|
depth,
|
|
allRanks,
|
|
selectedId,
|
|
onSelect,
|
|
}: {
|
|
node: TreeNode;
|
|
depth: number;
|
|
allRanks: RankRow[];
|
|
selectedId: string | null;
|
|
onSelect: (id: string) => void;
|
|
}) {
|
|
const isSelected = node.id === selectedId;
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
"flex items-center gap-2 px-3 py-2 border-b border-neutral-100 last:border-0 cursor-pointer",
|
|
isSelected ? "bg-primary-50" : "hover:bg-neutral-50"
|
|
)}
|
|
style={{ paddingLeft: 12 + depth * 20 }}
|
|
onClick={() => onSelect(node.id)}
|
|
>
|
|
<span className="font-mono text-xs text-neutral-400 w-12 shrink-0">{node.code}</span>
|
|
<span className={cn("text-sm flex-1", node.isActive ? "text-neutral-900" : "text-neutral-400 line-through")}>
|
|
{node.name}
|
|
</span>
|
|
{node.grantsLogin && (
|
|
<span className="rounded-full bg-primary-100 text-primary-700 px-2 py-0.5 text-xs font-medium">Login</span>
|
|
)}
|
|
{node.isSeafarer && (
|
|
<span className="rounded-full bg-neutral-100 text-neutral-600 px-2 py-0.5 text-xs font-medium">Seafarer</span>
|
|
)}
|
|
<span className="rounded-full bg-neutral-100 text-neutral-500 px-2 py-0.5 text-xs">{node.category}</span>
|
|
<span className="text-xs text-neutral-400 w-16 text-right shrink-0">
|
|
{node.docRequirements.length} doc{node.docRequirements.length === 1 ? "" : "s"}
|
|
</span>
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<RankActionsMenu rank={node} allRanks={allRanks} />
|
|
</div>
|
|
</div>
|
|
{node.children.map((child) => (
|
|
<RankRowView
|
|
key={child.id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
allRanks={allRanks}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function RanksManager({ ranks }: { ranks: RankRow[] }) {
|
|
const tree = buildTree(ranks);
|
|
const [selectedId, setSelectedId] = useState<string | null>(ranks[0]?.id ?? null);
|
|
const selected = ranks.find((r) => r.id === selectedId) ?? null;
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-neutral-900">Ranks & Documents</h1>
|
|
<p className="text-sm text-neutral-500 mt-0.5">
|
|
{ranks.length} ranks · the crew org chart and the documents each rank must hold
|
|
</p>
|
|
</div>
|
|
<AddRankButton allRanks={ranks} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
|
{/* Rank hierarchy card */}
|
|
<div className="lg:col-span-3 rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
|
<h2 className="text-sm font-semibold text-neutral-900">Rank hierarchy</h2>
|
|
<p className="text-xs text-neutral-500 mt-0.5">
|
|
The org chart. <span className="text-primary-700 font-medium">Login</span> ranks (PM, Assistant PM, Site
|
|
In-charge) map to a portal account; all others are crew records.
|
|
</p>
|
|
</div>
|
|
{tree.length === 0 ? (
|
|
<p className="px-4 py-12 text-center text-neutral-400">No ranks yet. Add a top-level rank to begin.</p>
|
|
) : (
|
|
<div>
|
|
{tree.map((node) => (
|
|
<RankRowView
|
|
key={node.id}
|
|
node={node}
|
|
depth={0}
|
|
allRanks={ranks}
|
|
selectedId={selectedId}
|
|
onSelect={setSelectedId}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Required documents card */}
|
|
<div className="lg:col-span-2">
|
|
<RankDocPanel rank={selected} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|