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

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