diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 6037430..1794f35 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -118,6 +118,16 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at `/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices. +### Crewing (feature-flagged) + +A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12); only the **Foundations** layer ships so far: + +- **Role:** `SITE_STAFF` (the new `Role` enum member) — PM / Assistant PM / Site In-charge log in as site staff and act on behalf of crew. MPO is `MANNING`. +- **Permissions:** `lib/permissions.ts` holds the full crewing grant matrix (spec §6) as the source of truth — `PO_ROLE_PERMISSIONS` + `CREWING_ROLE_PERMISSIONS` are merged into `ROLE_PERMISSIONS`. Notable rules: MPO has **no** attendance/leave; `decide_leave`/`approve_*`/`select_candidate` are Manager-only; `manage_ranks` is Manager + Admin. +- **Reference data:** `Rank` is a self-referential org-chart hierarchy (like `Account`), seeded from `prisma/rank-data.ts`; `RankDocRequirement` (seeded from `prisma/rank-doc-data.ts`) lists the documents each rank must hold. Both seed via the shared `prisma/seed-ranks.ts` in dev **and** prod seeds. `Rank.grantsLogin` is true only for the three management ranks. +- **Admin screen:** `/admin/ranks` ("Ranks & documents", gated by `manage_ranks` + the flag) — the rank hierarchy card + per-rank required-documents card. +- The sidebar has a flag-gated **Crewing** section scaffold (`CREWING_ITEMS`, empty until later phases) and the Ranks link under Administration. + ### GST Calculation `totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`. @@ -142,6 +152,7 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN GST_SERVICE_URL # GstService microservice (defaults to localhost:3003) NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag +NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default) NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod. ``` diff --git a/App/app/(portal)/admin/ranks/actions.ts b/App/app/(portal)/admin/ranks/actions.ts new file mode 100644 index 0000000..2f2bbf0 --- /dev/null +++ b/App/app/(portal)/admin/ranks/actions.ts @@ -0,0 +1,187 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { CREWING_ENABLED } from "@/lib/feature-flags"; +import { RankCategory, SeafarerDocType } from "@prisma/client"; +import { z } from "zod"; +import { revalidatePath } from "next/cache"; + +type ActionResult = { ok: true } | { error: string }; + +async function guard(): Promise<{ error: string } | null> { + if (!CREWING_ENABLED) return { error: "Crewing is not enabled" }; + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_ranks")) { + return { error: "Unauthorized" }; + } + return null; +} + +const rankSchema = z.object({ + code: z.string().trim().min(1, "Code is required").max(16, "Code is too long"), + name: z.string().trim().min(1, "Name is required"), + description: z.string().optional(), + parentId: z.string().optional(), + category: z.nativeEnum(RankCategory), + isSeafarer: z.boolean(), + grantsLogin: z.boolean(), +}); + +function parseRank(formData: FormData) { + return rankSchema.safeParse({ + code: formData.get("code"), + name: formData.get("name"), + description: (formData.get("description") as string) || undefined, + parentId: (formData.get("parentId") as string) || undefined, + category: formData.get("category"), + isSeafarer: formData.get("isSeafarer") === "on" || formData.get("isSeafarer") === "true", + grantsLogin: formData.get("grantsLogin") === "on" || formData.get("grantsLogin") === "true", + }); +} + +// True if `candidateParentId` is `rankId` itself or one of its descendants — +// setting it as the parent would create a cycle. +async function wouldCycle(rankId: string, candidateParentId: string): Promise { + if (rankId === candidateParentId) return true; + const all = await db.rank.findMany({ select: { id: true, parentId: true } }); + const childrenOf = new Map(); + for (const r of all) { + if (r.parentId) { + const list = childrenOf.get(r.parentId) ?? []; + list.push(r.id); + childrenOf.set(r.parentId, list); + } + } + const stack = [rankId]; + while (stack.length) { + const cur = stack.pop()!; + if (cur === candidateParentId) return true; + stack.push(...(childrenOf.get(cur) ?? [])); + } + return false; +} + +export async function createRank(formData: FormData): Promise { + const denied = await guard(); + if (denied) return denied; + + const parsed = parseRank(formData); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const data = parsed.data; + + const exists = await db.rank.findUnique({ where: { code: data.code } }); + if (exists) return { error: "A rank with that code already exists" }; + + await db.rank.create({ + data: { + code: data.code, + name: data.name, + description: data.description ?? null, + parentId: data.parentId ?? null, + category: data.category, + isSeafarer: data.isSeafarer, + grantsLogin: data.grantsLogin, + }, + }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +export async function updateRank(formData: FormData): Promise { + const denied = await guard(); + if (denied) return denied; + + const id = formData.get("id") as string; + if (!id) return { error: "Rank ID is required" }; + + const parsed = parseRank(formData); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const data = parsed.data; + + const conflict = await db.rank.findFirst({ where: { code: data.code, id: { not: id } } }); + if (conflict) return { error: "Another rank already uses that code" }; + + if (data.parentId && (await wouldCycle(id, data.parentId))) { + return { error: "A rank cannot report to itself or one of its sub-ranks" }; + } + + await db.rank.update({ + where: { id }, + data: { + code: data.code, + name: data.name, + description: data.description ?? null, + parentId: data.parentId ?? null, + category: data.category, + isSeafarer: data.isSeafarer, + grantsLogin: data.grantsLogin, + }, + }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +export async function deleteRank(id: string): Promise { + const denied = await guard(); + if (denied) return denied; + + const hasChildren = await db.rank.findFirst({ where: { parentId: id } }); + if (hasChildren) return { error: "Cannot delete: this rank has sub-ranks. Reassign or remove them first." }; + + // Document requirements cascade on delete. + await db.rank.delete({ where: { id } }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +export async function toggleRankActive(id: string): Promise { + const denied = await guard(); + if (denied) return denied; + + const rank = await db.rank.findUnique({ where: { id }, select: { isActive: true } }); + if (!rank) return { error: "Rank not found" }; + + await db.rank.update({ where: { id }, data: { isActive: !rank.isActive } }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +const docReqSchema = z.object({ + rankId: z.string().min(1), + docType: z.nativeEnum(SeafarerDocType), + isMandatory: z.boolean(), + note: z.string().optional(), +}); + +export async function addRankDocRequirement(formData: FormData): Promise { + const denied = await guard(); + if (denied) return denied; + + const parsed = docReqSchema.safeParse({ + rankId: formData.get("rankId"), + docType: formData.get("docType"), + isMandatory: formData.get("isMandatory") === "on" || formData.get("isMandatory") === "true", + note: (formData.get("note") as string) || undefined, + }); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const data = parsed.data; + + await db.rankDocRequirement.upsert({ + where: { rankId_docType: { rankId: data.rankId, docType: data.docType } }, + update: { isMandatory: data.isMandatory, note: data.note ?? null }, + create: { rankId: data.rankId, docType: data.docType, isMandatory: data.isMandatory, note: data.note ?? null }, + }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +export async function removeRankDocRequirement(id: string): Promise { + const denied = await guard(); + if (denied) return denied; + + await db.rankDocRequirement.delete({ where: { id } }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} diff --git a/App/app/(portal)/admin/ranks/page.tsx b/App/app/(portal)/admin/ranks/page.tsx new file mode 100644 index 0000000..9d1db72 --- /dev/null +++ b/App/app/(portal)/admin/ranks/page.tsx @@ -0,0 +1,44 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { CREWING_ENABLED } from "@/lib/feature-flags"; +import { redirect, notFound } from "next/navigation"; +import { RanksManager } from "./ranks-manager"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Ranks & Documents" }; + +export default async function AdminRanksPage() { + // Dark unless the crewing module is switched on. + if (!CREWING_ENABLED) notFound(); + + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_ranks")) redirect("/dashboard"); + + const ranks = await db.rank.findMany({ + orderBy: [{ name: "asc" }], + include: { docRequirements: { orderBy: { docType: "asc" } } }, + }); + + // Flatten to plain props (no Date/Decimal crosses the server→client boundary). + const rows = ranks.map((r) => ({ + id: r.id, + code: r.code, + name: r.name, + description: r.description, + category: r.category, + isSeafarer: r.isSeafarer, + grantsLogin: r.grantsLogin, + isActive: r.isActive, + parentId: r.parentId, + docRequirements: r.docRequirements.map((d) => ({ + id: d.id, + docType: d.docType, + isMandatory: d.isMandatory, + note: d.note, + })), + })); + + return ; +} diff --git a/App/app/(portal)/admin/ranks/rank-doc-panel.tsx b/App/app/(portal)/admin/ranks/rank-doc-panel.tsx new file mode 100644 index 0000000..593fa34 --- /dev/null +++ b/App/app/(portal)/admin/ranks/rank-doc-panel.tsx @@ -0,0 +1,132 @@ +"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; + +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 ( +
+ Select a rank to manage its required documents. +
+ ); + } + + async function handleAdd(e: React.FormEvent) { + 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 ( +
+
+
+

Required documents

+

{rank.code} — {rank.name}

+
+ +
+ + {adding && ( +
+ + + + {error &&

{error}

} + +
+ )} + + {rank.docRequirements.length === 0 ? ( +

No required documents for this rank.

+ ) : ( +
+ {rank.docRequirements.map((d) => ( +
+ {DOC_LABEL[d.docType] ?? d.docType} + {d.note && {d.note}} + + {d.isMandatory ? "Mandatory" : "Conditional"} + + +
+ ))} +
+ )} +
+ ); +} diff --git a/App/app/(portal)/admin/ranks/rank-form.tsx b/App/app/(portal)/admin/ranks/rank-form.tsx new file mode 100644 index 0000000..6af403f --- /dev/null +++ b/App/app/(portal)/admin/ranks/rank-form.tsx @@ -0,0 +1,184 @@ +"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 ( +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ ); +} + +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) { + 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 ( + <> + + setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ + ); +} + +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) { + 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 ( + setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ ); +} diff --git a/App/app/(portal)/admin/ranks/ranks-manager.tsx b/App/app/(portal)/admin/ranks/ranks-manager.tsx new file mode 100644 index 0000000..e4c537b --- /dev/null +++ b/App/app/(portal)/admin/ranks/ranks-manager.tsx @@ -0,0 +1,200 @@ +"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(); + 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 ( + <> + + setEditOpen(true)}>Edit + setToggleOpen(true)}> + {rank.isActive ? "Deactivate" : "Activate"} + + + setDeleteOpen(true)}>Delete + + + deleteRank(rank.id)} + /> +