Compare commits

..

No commits in common. "master" and "chore/design-system" have entirely different histories.

24 changed files with 5 additions and 1466 deletions

View file

@ -1,22 +1,15 @@
name: PR checks name: PR checks
# Enforces the contribution policy on every PR into master — plus the crewing # Enforces the contribution policy on every PR into master (all gates hard):
# stack branches (feat/crewing-*), which collect the stacked, feature-flagged
# crewing phases (foundations → requisitions → candidates → …) before they merge
# to master. Same hard gates:
# - code changes must ship with tests (docs/config/automation are exempt) # - code changes must ship with tests (docs/config/automation are exempt)
# - type-check is clean across the whole project (tests included) # - type-check is clean across the whole project (tests included)
# - unit tests pass # - unit tests pass
# - integration tests pass against an ephemeral Postgres (migrate + seed) # - integration tests pass against an ephemeral Postgres (migrate + seed)
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy". # Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
#
# Note: the workflow is evaluated from the branch under test, so the trigger list
# must match it. The feat/crewing-* glob covers every branch in the stack so each
# stacked phase PR is checked without further edits to this file.
on: on:
pull_request: pull_request:
branches: [master, "feat/crewing-*"] branches: [master]
jobs: jobs:
checks: checks:

View file

@ -118,16 +118,6 @@ 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. `/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 ### 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`. `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`.
@ -152,7 +142,6 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003) GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag 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. NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
``` ```

View file

@ -1,187 +0,0 @@
"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<boolean> {
if (rankId === candidateParentId) return true;
const all = await db.rank.findMany({ select: { id: true, parentId: true } });
const childrenOf = new Map<string, string[]>();
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
const denied = await guard();
if (denied) return denied;
await db.rankDocRequirement.delete({ where: { id } });
revalidatePath("/admin/ranks");
return { ok: true };
}

View file

@ -1,44 +0,0 @@
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 <RanksManager ranks={rows} />;
}

View file

@ -1,132 +0,0 @@
"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>
);
}

View file

@ -1,184 +0,0 @@
"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 (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
<input name="code" defaultValue={rank?.code} required maxLength={16} placeholder="e.g. SDO" className={INPUT} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
<input name="name" defaultValue={rank?.name} required placeholder="e.g. Sr. Dredge Operator" className={INPUT} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reports to</label>
<select name="parentId" defaultValue={rank?.parentId ?? ""} className={INPUT}>
<option value=""> Top of the org </option>
{parentOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.code} {r.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Category</label>
<select name="category" defaultValue={rank?.category ?? "OPERATIONAL"} className={INPUT}>
<option value="OPERATIONAL">Operational</option>
<option value="SUPPORT">Support</option>
</select>
</div>
</div>
<div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="isSeafarer" defaultChecked={rank?.isSeafarer ?? false} className="h-4 w-4" />
Seafarer (holds STCW / CDC etc.)
</label>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="grantsLogin" defaultChecked={rank?.grantsLogin ?? false} className="h-4 w-4" />
Grants portal login
</label>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Description</label>
<input name="description" defaultValue={rank?.description ?? ""} className={INPUT} placeholder="Optional" />
</div>
</div>
);
}
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<HTMLFormElement>) {
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 (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
>
+ Add Rank
</button>
<AdminDialog title="Add Rank" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<RankFormFields allRanks={allRanks} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Cancel
</button>
<button
type="submit"
disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
>
{pending ? "Creating…" : "Create Rank"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
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<HTMLFormElement>) {
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 (
<AdminDialog title="Edit Rank" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<RankFormFields rank={rank} allRanks={allRanks} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Cancel
</button>
<button
type="submit"
disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
>
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -1,200 +0,0 @@
"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>
);
}

View file

@ -22,7 +22,6 @@ const ROLE_LABELS: Record<string, string> = {
SUPERUSER: "SuperUser", SUPERUSER: "SuperUser",
AUDITOR: "Auditor", AUDITOR: "Auditor",
ADMIN: "Admin", ADMIN: "Admin",
SITE_STAFF: "Site Staff",
}; };
export default async function SuperUserRequestsPage() { export default async function SuperUserRequestsPage() {

View file

@ -30,7 +30,6 @@ const ROLE_LABELS: Record<string, string> = {
SUPERUSER: "SuperUser", SUPERUSER: "SuperUser",
AUDITOR: "Auditor", AUDITOR: "Auditor",
ADMIN: "Admin", ADMIN: "Admin",
SITE_STAFF: "Site Staff",
}; };
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"]; const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];

View file

@ -18,7 +18,6 @@ const ROLE_LABELS: Record<string, string> = {
SUPERUSER: "SuperUser", SUPERUSER: "SuperUser",
AUDITOR: "Auditor", AUDITOR: "Auditor",
ADMIN: "Admin", ADMIN: "Admin",
SITE_STAFF: "Site Staff",
}; };
export default async function ProfilePage() { export default async function ProfilePage() {

View file

@ -15,7 +15,6 @@ const ROLE_LABELS: Record<Role, string> = {
SUPERUSER: "SuperUser", SUPERUSER: "SuperUser",
AUDITOR: "Auditor", AUDITOR: "Auditor",
ADMIN: "Admin", ADMIN: "Admin",
SITE_STAFF: "Site Staff",
}; };
const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"]; const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"];

View file

@ -2,7 +2,7 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags"; import { INVENTORY_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
LayoutDashboard, LayoutDashboard,
@ -24,7 +24,6 @@ import {
ShoppingCart, ShoppingCart,
UserCircle, UserCircle,
ShieldCheck, ShieldCheck,
Network,
} from "lucide-react"; } from "lucide-react";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -68,22 +67,11 @@ const PURCHASING_MGMT: NavItem[] = [
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT]; const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
// Scaffold for the Crewing module. Phase 1 (Foundations) adds no top-level items
// here — its only screen, "Ranks & documents", lives under Administration. Later
// phases append Requisitions / Candidates / Crew / Leave / Attendance /
// Verification with their per-role visibility (see Crewing-Implementation-Spec §7).
const CREWING_ITEMS: NavItem[] = [];
// ── Administration section ──────────────────────────────────────────────────── // ── Administration section ────────────────────────────────────────────────────
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header // Vendors shown to MANAGER / ACCOUNTS under their own Administration header
const MANAGER_ADMIN_ITEMS: NavItem[] = [ const MANAGER_ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] }, { href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] }, { href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
...(CREWING_ENABLED
? [{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] }]
: []),
]; ];
// Full Administration section (ADMIN only) // Full Administration section (ADMIN only)
@ -102,7 +90,6 @@ export function Sidebar({ userRole }: { userRole: Role }) {
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole)); const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
return ( return (
@ -128,16 +115,6 @@ export function Sidebar({ userRole }: { userRole: Role }) {
</> </>
)} )}
{/* Crewing — only renders once the flag is on and items exist (later phases) */}
{visibleCrewing.length > 0 && (
<>
<SectionHeader label="Crewing" />
{visibleCrewing.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Vendors under Administration for MANAGER / ACCOUNTS */} {/* Vendors under Administration for MANAGER / ACCOUNTS */}
{!isAdmin && visibleMgrAdmin.length > 0 && ( {!isAdmin && visibleMgrAdmin.length > 0 && (
<> <>

View file

@ -4,15 +4,7 @@
* *
* NEXT_PUBLIC_INVENTORY_ENABLED=false hides inventory tracking (site qty/consumption) * NEXT_PUBLIC_INVENTORY_ENABLED=false hides inventory tracking (site qty/consumption)
* Vendor list, product catalogue, and cart remain available for PO creation regardless. * Vendor list, product catalogue, and cart remain available for PO creation regardless.
*
* NEXT_PUBLIC_CREWING_ENABLED=true exposes the Crewing module (crew/ranks/requisitions
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
* and wiki Crewing-Implementation-Spec.
*/ */
export const INVENTORY_ENABLED = export const INVENTORY_ENABLED =
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false"; process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
export const CREWING_ENABLED =
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";

View file

@ -6,7 +6,6 @@ export const ROLE_PREFIX: Record<string, string> = {
SUPERUSER: "SUP", SUPERUSER: "SUP",
AUDITOR: "AUD", AUDITOR: "AUD",
ADMIN: "ADM", ADMIN: "ADM",
SITE_STAFF: "SIT",
}; };
/** Find max existing number for prefix and return prefix-(max+1), zero-padded to 3 digits */ /** Find max existing number for prefix and return prefix-(max+1), zero-padded to 3 digits */

View file

@ -20,42 +20,9 @@ export type Permission =
| "create_vendor" | "create_vendor"
| "manage_vessels_accounts" | "manage_vessels_accounts"
| "manage_products" | "manage_products"
| "manage_sites" | "manage_sites";
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
| "raise_requisition"
| "request_relief_cover"
| "convert_relief_to_requisition"
| "cancel_requisition"
| "view_requisitions"
| "manage_candidates"
| "record_reference_check"
| "record_interview_result"
| "request_interview_waiver"
| "approve_interview_waiver"
| "approve_salary_structure"
| "select_candidate"
| "onboard_crew"
| "sign_off_crew"
| "view_crew_records"
| "upload_crew_records"
| "issue_ppe"
| "apply_leave"
| "decide_leave"
| "record_attendance"
| "view_attendance"
| "verify_site_records"
| "verify_bank_epf"
| "raise_appraisal"
| "verify_appraisal"
| "approve_appraisal"
| "generate_wage_report"
| "approve_wage_report"
| "view_wage_report"
| "manage_ranks";
// Purchasing / admin permissions (the original PPMS matrix). SITE_STAFF is a const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
// crewing-only role and holds no purchasing permissions.
const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"], TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"], MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors", "create_vendor"], ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors", "create_vendor"],
@ -110,115 +77,8 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_products", "manage_products",
"manage_sites", "manage_sites",
], ],
SITE_STAFF: [],
}; };
// Crewing permissions — a verbatim transcription of the §6 grant matrix in
// wiki Crewing-Implementation-Spec. Gating these is harmless until the screens
// land (the module is behind NEXT_PUBLIC_CREWING_ENABLED). Notes from the spec:
// MPO (MANNING) has NO attendance/leave; decide_leave/approve_* and selection are
// Manager-only; manage_ranks is Manager + Admin (not SuperUser).
const CREWING_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
TECHNICAL: [],
SITE_STAFF: [
"request_relief_cover",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"apply_leave",
"record_attendance",
"view_attendance",
"raise_appraisal",
],
MANNING: [
"raise_requisition",
"convert_relief_to_requisition",
"cancel_requisition",
"view_requisitions",
"manage_candidates",
"record_reference_check",
"record_interview_result",
"request_interview_waiver",
"onboard_crew",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"verify_site_records",
"verify_appraisal",
],
ACCOUNTS: ["view_crew_records", "verify_bank_epf", "view_wage_report"],
MANAGER: [
"raise_requisition",
"convert_relief_to_requisition",
"cancel_requisition",
"view_requisitions",
"manage_candidates",
"record_reference_check",
"record_interview_result",
"approve_interview_waiver",
"approve_salary_structure",
"select_candidate",
"onboard_crew",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"apply_leave",
"decide_leave",
"view_attendance",
"verify_site_records",
"raise_appraisal",
"verify_appraisal",
"approve_appraisal",
"generate_wage_report",
"approve_wage_report",
"view_wage_report",
"manage_ranks",
],
SUPERUSER: [
"raise_requisition",
"request_relief_cover",
"convert_relief_to_requisition",
"cancel_requisition",
"view_requisitions",
"manage_candidates",
"record_reference_check",
"record_interview_result",
"request_interview_waiver",
"approve_interview_waiver",
"approve_salary_structure",
"select_candidate",
"onboard_crew",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"apply_leave",
"decide_leave",
"record_attendance",
"view_attendance",
"verify_site_records",
"verify_bank_epf",
"raise_appraisal",
"verify_appraisal",
"approve_appraisal",
"generate_wage_report",
"approve_wage_report",
"view_wage_report",
],
AUDITOR: ["view_requisitions", "view_crew_records", "view_attendance", "view_wage_report"],
ADMIN: ["view_requisitions", "view_crew_records", "view_wage_report", "manage_ranks"],
};
const ROLE_PERMISSIONS: Record<Role, Permission[]> = Object.fromEntries(
(Object.keys(PO_ROLE_PERMISSIONS) as Role[]).map((role) => [
role,
[...PO_ROLE_PERMISSIONS[role], ...CREWING_ROLE_PERMISSIONS[role]],
])
) as Record<Role, Permission[]>;
export function hasPermission(role: Role, permission: Permission): boolean { export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false; return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
} }

View file

@ -1,49 +0,0 @@
-- CreateEnum
CREATE TYPE "RankCategory" AS ENUM ('OPERATIONAL', 'SUPPORT');
-- CreateEnum
CREATE TYPE "SeafarerDocType" AS ENUM ('STCW', 'AADHAAR', 'PAN', 'PASSPORT', 'CDC', 'COC', 'PHOTOGRAPH', 'DRIVING_LICENSE', 'MEDICAL_FITNESS', 'CONTRACT_LETTER');
-- AlterEnum
ALTER TYPE "Role" ADD VALUE 'SITE_STAFF';
-- CreateTable
CREATE TABLE "Rank" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"category" "RankCategory" NOT NULL DEFAULT 'OPERATIONAL',
"isSeafarer" BOOLEAN NOT NULL DEFAULT false,
"grantsLogin" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"parentId" TEXT,
CONSTRAINT "Rank_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RankDocRequirement" (
"id" TEXT NOT NULL,
"rankId" TEXT NOT NULL,
"docType" "SeafarerDocType" NOT NULL,
"isMandatory" BOOLEAN NOT NULL DEFAULT true,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RankDocRequirement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Rank_code_key" ON "Rank"("code");
-- CreateIndex
CREATE UNIQUE INDEX "RankDocRequirement_rankId_docType_key" ON "RankDocRequirement"("rankId", "docType");
-- AddForeignKey
ALTER TABLE "Rank" ADD CONSTRAINT "Rank_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RankDocRequirement" ADD CONSTRAINT "RankDocRequirement_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,48 +0,0 @@
// Crew rank hierarchy (org chart) — captured from Crewing.excalidraw and the
// tree in wiki Crewing-Data-Model §2. A self-referential tree like the Account
// accounting-code hierarchy: `parentCode = null` is the top of the org.
//
// grantsLogin = true → only PM, Assistant PM, Site In-charge (the three ranks
// that map to a SITE_STAFF portal login). Everyone else is
// a crew member / data subject with no account.
// isSeafarer = true → the dredging/engine/deck crew who hold seafarer documents
// (STCW/CDC/COC/medical). Management & shore-support do not.
// category → OPERATIONAL vs SUPPORT (the classDef "sup" nodes).
import type { RankCategory } from "@prisma/client";
export type RankEntry = {
code: string;
name: string;
parentCode: string | null;
category: RankCategory;
isSeafarer: boolean;
grantsLogin: boolean;
};
export const RANKS: RankEntry[] = [
// ── Management (portal logins) ──────────────────────────────────────────────
{ code: "PM", name: "PM", parentCode: null, category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "APM", name: "Assistant PM", parentCode: "PM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "SIC", name: "Site In-charge", parentCode: "APM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
// ── Shore support (no login, no seafarer docs) ──────────────────────────────
{ code: "ACC", name: "Accountant", parentCode: "APM", category: "SUPPORT", isSeafarer: false, grantsLogin: false },
{ code: "DRV", name: "Driver", parentCode: "APM", category: "SUPPORT", isSeafarer: false, grantsLogin: false },
{ code: "COOK", name: "Cook", parentCode: "APM", category: "SUPPORT", isSeafarer: false, grantsLogin: false },
{ code: "CKH", name: "Cook Helper", parentCode: "COOK", category: "SUPPORT", isSeafarer: false, grantsLogin: false },
// ── Operational crew (seafarers) ────────────────────────────────────────────
{ code: "DIC", name: "Dredger In-charge", parentCode: "SIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SDO", name: "Sr. Dredge Operator", parentCode: "DIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "PLS", name: "Pipeline Supervisor", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "PLA", name: "Pipeline Assistant", parentCode: "PLS", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "JDO", name: "Jr. Dredge Operator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "ERO", name: "Engine Room Operator", parentCode: "JDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "DH", name: "Deck Hand", parentCode: "ERO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "TR", name: "Trainee", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "MB", name: "Mess Boy", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "ELE", name: "Electrician", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SFB", name: "Sr. Fabricator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "FW", name: "Fabricator / Welder", parentCode: "SFB", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
];

View file

@ -1,42 +0,0 @@
// Default required-document set per rank, derived from the rank flags in
// rank-data.ts. Drives candidate vetting and crew uploads (RankDocRequirement).
// - Every crew member: Aadhaar, PAN, photograph.
// - Seafarers additionally: STCW, CDC, passport, medical fitness (mandatory);
// COC is conditional (officer/senior ranks only).
// - Driver: driving licence (mandatory).
// Editable afterwards at /admin/ranks; this is just the seeded baseline.
import type { SeafarerDocType } from "@prisma/client";
import { RANKS } from "./rank-data";
export type RankDocReq = {
rankCode: string;
docType: SeafarerDocType;
isMandatory: boolean;
note?: string;
};
const COMMON: { docType: SeafarerDocType; isMandatory: boolean }[] = [
{ docType: "AADHAAR", isMandatory: true },
{ docType: "PAN", isMandatory: true },
{ docType: "PHOTOGRAPH", isMandatory: true },
];
const SEAFARER: { docType: SeafarerDocType; isMandatory: boolean; note?: string }[] = [
{ docType: "STCW", isMandatory: true },
{ docType: "CDC", isMandatory: true },
{ docType: "PASSPORT", isMandatory: true },
{ docType: "MEDICAL_FITNESS", isMandatory: true },
{ docType: "COC", isMandatory: false, note: "Officer / senior ranks only" },
];
export const RANK_DOC_REQUIREMENTS: RankDocReq[] = RANKS.flatMap((rank) => {
const reqs: RankDocReq[] = COMMON.map((c) => ({ rankCode: rank.code, ...c }));
if (rank.isSeafarer) {
reqs.push(...SEAFARER.map((s) => ({ rankCode: rank.code, ...s })));
}
if (rank.code === "DRV") {
reqs.push({ rankCode: "DRV", docType: "DRIVING_LICENSE", isMandatory: true });
}
return reqs;
});

View file

@ -15,7 +15,6 @@ enum Role {
SUPERUSER SUPERUSER
AUDITOR AUDITOR
ADMIN ADMIN
SITE_STAFF
} }
enum POStatus { enum POStatus {
@ -61,32 +60,6 @@ enum RequestStatus {
DENIED DENIED
} }
// ─── Crewing (feature-flagged: NEXT_PUBLIC_CREWING_ENABLED) ──────────────────
// Phase 1 (Foundations) lands only the reference-data layer. The lifecycle
// models/enums (Requisition, Application, Assignment, …) arrive in later phases.
// See wiki Crewing-Implementation-Spec §12.
// Org-chart grouping for a Rank. Drives reporting/segmentation, not login.
enum RankCategory {
OPERATIONAL
SUPPORT
}
// The seafarer/crew document set a rank may be required to hold. Drives
// candidate vetting and crew uploads via RankDocRequirement.
enum SeafarerDocType {
STCW
AADHAAR
PAN
PASSPORT
CDC
COC
PHOTOGRAPH
DRIVING_LICENSE
MEDICAL_FITNESS
CONTRACT_LETTER
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
employeeId String @unique employeeId String @unique
@ -402,43 +375,3 @@ model Notification {
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
} }
// ─── Crewing reference data ──────────────────────────────────────────────────
// The crew org hierarchy. A self-referential tree (parent/children), exactly
// like the Account accounting-code hierarchy. Reference data managed at
// /admin/ranks. `grantsLogin` is true only for the three management ranks
// (PM, Assistant PM, Site In-charge) — every other rank is a crew member /
// data subject with no portal account. See Crewing-Data-Model §2.
model Rank {
id String @id @default(cuid())
code String @unique
name String
description String?
category RankCategory @default(OPERATIONAL)
isSeafarer Boolean @default(false)
grantsLogin Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parentId String?
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
children Rank[] @relation("RankHierarchy")
docRequirements RankDocRequirement[]
}
// Which documents a rank is required (or conditionally required) to hold.
// `isMandatory = false` is the "conditional" tag in the UI.
model RankDocRequirement {
id String @id @default(cuid())
rankId String
rank Rank @relation(fields: [rankId], references: [id], onDelete: Cascade)
docType SeafarerDocType
isMandatory Boolean @default(true)
note String?
createdAt DateTime @default(now())
@@unique([rankId, docType])
}

View file

@ -13,7 +13,6 @@
import { PrismaClient, Role } from "@prisma/client"; import { PrismaClient, Role } from "@prisma/client";
import { ACCOUNTING_CODES } from "./accounting-codes-data"; import { ACCOUNTING_CODES } from "./accounting-codes-data";
import { seedRanks } from "./seed-ranks";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
const hash = (p: string) => bcrypt.hash(p, 12); const hash = (p: string) => bcrypt.hash(p, 12);
@ -236,10 +235,6 @@ async function main() {
}).length; }).length;
console.log(`${ACCOUNTING_CODES.length} codes (${leafCount} selectable leaf items)`); console.log(`${ACCOUNTING_CODES.length} codes (${leafCount} selectable leaf items)`);
// ── Crewing reference data (ranks + document rules) ──────────────────────────
console.log("\n⚓ Seeding crew ranks…");
await seedRanks(db);
console.log("\n✅ Production seed complete."); console.log("\n✅ Production seed complete.");
} }

View file

@ -1,56 +0,0 @@
// Shared, idempotent seeding of the crewing reference data (ranks + their
// required-document rules). Used by both the dev seed (seed.ts) and the
// production seed (seed-prod.ts). Two passes mirror the accounting-code seed:
// upsert every rank by code, then link parents, then upsert doc requirements.
import type { PrismaClient } from "@prisma/client";
import { RANKS } from "./rank-data";
import { RANK_DOC_REQUIREMENTS } from "./rank-doc-data";
export async function seedRanks(db: PrismaClient) {
const rankIdMap = new Map<string, string>();
// Pass 1: upsert all ranks (no parent yet) to obtain ids.
for (const r of RANKS) {
const rec = await db.rank.upsert({
where: { code: r.code },
update: {
name: r.name,
category: r.category,
isSeafarer: r.isSeafarer,
grantsLogin: r.grantsLogin,
},
create: {
code: r.code,
name: r.name,
category: r.category,
isSeafarer: r.isSeafarer,
grantsLogin: r.grantsLogin,
},
});
rankIdMap.set(r.code, rec.id);
}
// Pass 2: link parent relationships.
for (const r of RANKS) {
if (r.parentCode) {
const parentId = rankIdMap.get(r.parentCode);
if (parentId) {
await db.rank.update({ where: { code: r.code }, data: { parentId } });
}
}
}
// Document requirements (keyed by the rank + docType compound unique).
for (const req of RANK_DOC_REQUIREMENTS) {
const rankId = rankIdMap.get(req.rankCode);
if (!rankId) continue;
await db.rankDocRequirement.upsert({
where: { rankId_docType: { rankId, docType: req.docType } },
update: { isMandatory: req.isMandatory, note: req.note ?? null },
create: { rankId, docType: req.docType, isMandatory: req.isMandatory, note: req.note ?? null },
});
}
console.log(`${RANKS.length} ranks, ${RANK_DOC_REQUIREMENTS.length} document requirements`);
}

View file

@ -1,7 +1,6 @@
import { PrismaClient, Role } from "@prisma/client"; import { PrismaClient, Role } from "@prisma/client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { ACCOUNTING_CODES } from "./accounting-codes-data"; import { ACCOUNTING_CODES } from "./accounting-codes-data";
import { seedRanks } from "./seed-ranks";
const db = new PrismaClient(); const db = new PrismaClient();
@ -204,9 +203,6 @@ async function main() {
} }
} }
// ─── Crewing: Ranks (hierarchical) + document requirements ───────────────────
await seedRanks(db);
// Convenience variables for PO seed data below (map to real leaf codes) // Convenience variables for PO seed data below (map to real leaf codes)
const accTechOps = { id: codeIdMap.get("401012")! }; // Spares- Others const accTechOps = { id: codeIdMap.get("401012")! }; // Spares- Others
const accCrewMgt = { id: codeIdMap.get("500101")! }; // Salary const accCrewMgt = { id: codeIdMap.get("500101")! }; // Salary

View file

@ -1,145 +0,0 @@
/**
* Integration tests for the Ranks & Documents admin server actions (Crewing
* Phase 1 foundations). Covers create/update/delete, parent linking, the
* cycle/children guards, doc-requirement add/remove, and permission gating.
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
// The actions are gated by the crewing flag; force it on for the test run.
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import {
createRank,
updateRank,
deleteRank,
addRankDocRequirement,
removeRankDocRequirement,
} from "@/app/(portal)/admin/ranks/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
const PREFIX = "ITRANK_";
let managerId: string;
const asManager = () =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
beforeAll(async () => {
const mgr = await getSeedUser("manager@pelagia.local");
managerId = mgr.id;
});
afterEach(async () => {
// Break self-relations before deleting so parent/child FK order never bites.
const rows = await db.rank.findMany({ where: { code: { startsWith: PREFIX } }, select: { id: true } });
const ids = rows.map((r) => r.id);
if (ids.length) {
await db.rankDocRequirement.deleteMany({ where: { rankId: { in: ids } } });
await db.rank.updateMany({ where: { id: { in: ids } }, data: { parentId: null } });
await db.rank.deleteMany({ where: { id: { in: ids } } });
}
vi.clearAllMocks();
});
describe("createRank", () => {
it("creates a rank with its flags", async () => {
asManager();
const res = await createRank(
fd({ code: `${PREFIX}PM`, name: "Test PM", category: "OPERATIONAL", grantsLogin: "on" })
);
expect(res).toEqual({ ok: true });
const rank = await db.rank.findUnique({ where: { code: `${PREFIX}PM` } });
expect(rank?.grantsLogin).toBe(true);
expect(rank?.isSeafarer).toBe(false);
expect(rank?.category).toBe("OPERATIONAL");
});
it("rejects a duplicate code", async () => {
asManager();
await createRank(fd({ code: `${PREFIX}DUP`, name: "One", category: "SUPPORT" }));
const res = await createRank(fd({ code: `${PREFIX}DUP`, name: "Two", category: "SUPPORT" }));
expect("error" in res).toBe(true);
});
it("is rejected for roles without manage_ranks", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "SITE_STAFF"));
const res = await createRank(fd({ code: `${PREFIX}NO`, name: "Nope", category: "OPERATIONAL" }));
expect(res).toEqual({ error: "Unauthorized" });
expect(await db.rank.findUnique({ where: { code: `${PREFIX}NO` } })).toBeNull();
});
});
describe("updateRank — parent linking & cycle guard", () => {
it("links a child to its parent", async () => {
asManager();
await createRank(fd({ code: `${PREFIX}P`, name: "Parent", category: "OPERATIONAL" }));
const parent = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}P` } });
await createRank(fd({ code: `${PREFIX}C`, name: "Child", category: "OPERATIONAL" }));
const child = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}C` } });
const res = await updateRank(
fd({ id: child.id, code: `${PREFIX}C`, name: "Child", category: "OPERATIONAL", parentId: parent.id })
);
expect(res).toEqual({ ok: true });
expect((await db.rank.findUnique({ where: { id: child.id } }))?.parentId).toBe(parent.id);
});
it("refuses to make a rank its own ancestor", async () => {
asManager();
await createRank(fd({ code: `${PREFIX}A`, name: "A", category: "OPERATIONAL" }));
const a = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}A` } });
await createRank(fd({ code: `${PREFIX}B`, name: "B", category: "OPERATIONAL", parentId: a.id }));
const b = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}B` } });
// Try to make A report to B (its own descendant) → cycle.
const res = await updateRank(fd({ id: a.id, code: `${PREFIX}A`, name: "A", category: "OPERATIONAL", parentId: b.id }));
expect("error" in res).toBe(true);
});
});
describe("deleteRank", () => {
it("blocks deletion when the rank has sub-ranks", async () => {
asManager();
await createRank(fd({ code: `${PREFIX}TOP`, name: "Top", category: "OPERATIONAL" }));
const top = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}TOP` } });
await createRank(fd({ code: `${PREFIX}SUB`, name: "Sub", category: "OPERATIONAL", parentId: top.id }));
const res = await deleteRank(top.id);
expect("error" in res).toBe(true);
expect(await db.rank.findUnique({ where: { id: top.id } })).not.toBeNull();
});
it("deletes a leaf rank and cascades its doc requirements", async () => {
asManager();
await createRank(fd({ code: `${PREFIX}LEAF`, name: "Leaf", category: "OPERATIONAL" }));
const leaf = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}LEAF` } });
await addRankDocRequirement(fd({ rankId: leaf.id, docType: "PASSPORT", isMandatory: "on" }));
const res = await deleteRank(leaf.id);
expect(res).toEqual({ ok: true });
expect(await db.rankDocRequirement.findMany({ where: { rankId: leaf.id } })).toHaveLength(0);
});
});
describe("rank document requirements", () => {
it("adds (upserting) and removes a requirement", async () => {
asManager();
await createRank(fd({ code: `${PREFIX}DOC`, name: "Doc", category: "OPERATIONAL", isSeafarer: "on" }));
const rank = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}DOC` } });
await addRankDocRequirement(fd({ rankId: rank.id, docType: "STCW", isMandatory: "on" }));
// Upsert: same docType again flips it to conditional rather than duplicating.
await addRankDocRequirement(fd({ rankId: rank.id, docType: "STCW", isMandatory: "false" }));
const reqs = await db.rankDocRequirement.findMany({ where: { rankId: rank.id } });
expect(reqs).toHaveLength(1);
expect(reqs[0].isMandatory).toBe(false);
const rm = await removeRankDocRequirement(reqs[0].id);
expect(rm).toEqual({ ok: true });
expect(await db.rankDocRequirement.findMany({ where: { rankId: rank.id } })).toHaveLength(0);
});
});

View file

@ -1,104 +0,0 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { hasPermission } from "@/lib/permissions";
// Verifies the crewing rows of the §6 grant matrix in the wiki
// Crewing-Implementation-Spec are wired up exactly as written.
describe("Crewing permissions (spec §6)", () => {
it("SITE_STAFF holds its site-level grants", () => {
for (const p of [
"request_relief_cover",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"apply_leave",
"record_attendance",
"view_attendance",
"raise_appraisal",
] as const) {
expect(hasPermission("SITE_STAFF", p)).toBe(true);
}
});
it("SITE_STAFF cannot raise requisitions or decide leave or do any purchasing", () => {
expect(hasPermission("SITE_STAFF", "raise_requisition")).toBe(false);
expect(hasPermission("SITE_STAFF", "decide_leave")).toBe(false);
expect(hasPermission("SITE_STAFF", "create_po")).toBe(false);
expect(hasPermission("SITE_STAFF", "manage_ranks")).toBe(false);
});
it("MPO (MANNING) has NO attendance or leave access (R5/R1)", () => {
expect(hasPermission("MANNING", "record_attendance")).toBe(false);
expect(hasPermission("MANNING", "view_attendance")).toBe(false);
expect(hasPermission("MANNING", "apply_leave")).toBe(false);
expect(hasPermission("MANNING", "decide_leave")).toBe(false);
});
it("MPO sources recruitment but never gives final approvals", () => {
expect(hasPermission("MANNING", "raise_requisition")).toBe(true);
expect(hasPermission("MANNING", "manage_candidates")).toBe(true);
expect(hasPermission("MANNING", "record_interview_result")).toBe(true);
expect(hasPermission("MANNING", "verify_site_records")).toBe(true);
// Approvals are Manager-only:
expect(hasPermission("MANNING", "approve_salary_structure")).toBe(false);
expect(hasPermission("MANNING", "select_candidate")).toBe(false);
expect(hasPermission("MANNING", "approve_interview_waiver")).toBe(false);
});
it("Manager owns every crewing approval gate (R1/R2/R8)", () => {
for (const p of [
"decide_leave",
"approve_interview_waiver",
"approve_salary_structure",
"select_candidate",
"approve_appraisal",
"approve_wage_report",
"generate_wage_report",
] as const) {
expect(hasPermission("MANAGER", p)).toBe(true);
}
});
it("Accounts verifies bank/EPF and sees wages only (R11)", () => {
expect(hasPermission("ACCOUNTS", "verify_bank_epf")).toBe(true);
expect(hasPermission("ACCOUNTS", "view_wage_report")).toBe(true);
expect(hasPermission("ACCOUNTS", "view_crew_records")).toBe(true);
expect(hasPermission("ACCOUNTS", "verify_site_records")).toBe(false);
expect(hasPermission("ACCOUNTS", "record_attendance")).toBe(false);
});
it("manage_ranks is Manager + Admin only (not SuperUser)", () => {
expect(hasPermission("MANAGER", "manage_ranks")).toBe(true);
expect(hasPermission("ADMIN", "manage_ranks")).toBe(true);
expect(hasPermission("SUPERUSER", "manage_ranks")).toBe(false);
expect(hasPermission("MANNING", "manage_ranks")).toBe(false);
});
it("Auditor keeps read-only crewing visibility", () => {
expect(hasPermission("AUDITOR", "view_requisitions")).toBe(true);
expect(hasPermission("AUDITOR", "view_crew_records")).toBe(true);
expect(hasPermission("AUDITOR", "view_wage_report")).toBe(true);
expect(hasPermission("AUDITOR", "raise_requisition")).toBe(false);
});
});
describe("CREWING_ENABLED flag", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it("defaults off when the env var is unset", async () => {
vi.resetModules();
vi.stubEnv("NEXT_PUBLIC_CREWING_ENABLED", "");
const flags = await import("@/lib/feature-flags");
expect(flags.CREWING_ENABLED).toBe(false);
});
it("is on only for the exact string \"true\"", async () => {
vi.resetModules();
vi.stubEnv("NEXT_PUBLIC_CREWING_ENABLED", "true");
const flags = await import("@/lib/feature-flags");
expect(flags.CREWING_ENABLED).toBe(true);
});
});