Compare commits
6 commits
chore/desi
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cac83013e | |||
| c32fb6979c | |||
| 4528c059aa | |||
| ff0539de92 | |||
| d0006a8fc7 | |||
| 6e25d701d2 |
24 changed files with 1466 additions and 5 deletions
|
|
@ -1,15 +1,22 @@
|
|||
name: PR checks
|
||||
|
||||
# Enforces the contribution policy on every PR into master (all gates hard):
|
||||
# Enforces the contribution policy on every PR into master — plus the crewing
|
||||
# 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)
|
||||
# - type-check is clean across the whole project (tests included)
|
||||
# - unit tests pass
|
||||
# - integration tests pass against an ephemeral Postgres (migrate + seed)
|
||||
# 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:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [master, "feat/crewing-*"]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
```
|
||||
|
||||
|
|
|
|||
187
App/app/(portal)/admin/ranks/actions.ts
Normal file
187
App/app/(portal)/admin/ranks/actions.ts
Normal file
|
|
@ -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<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 };
|
||||
}
|
||||
44
App/app/(portal)/admin/ranks/page.tsx
Normal file
44
App/app/(portal)/admin/ranks/page.tsx
Normal file
|
|
@ -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 <RanksManager ranks={rows} />;
|
||||
}
|
||||
132
App/app/(portal)/admin/ranks/rank-doc-panel.tsx
Normal file
132
App/app/(portal)/admin/ranks/rank-doc-panel.tsx
Normal file
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
184
App/app/(portal)/admin/ranks/rank-form.tsx
Normal file
184
App/app/(portal)/admin/ranks/rank-form.tsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
200
App/app/(portal)/admin/ranks/ranks-manager.tsx
Normal file
200
App/app/(portal)/admin/ranks/ranks-manager.tsx
Normal file
|
|
@ -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<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
SITE_STAFF: "Site Staff",
|
||||
};
|
||||
|
||||
export default async function SuperUserRequestsPage() {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
SITE_STAFF: "Site Staff",
|
||||
};
|
||||
|
||||
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
SITE_STAFF: "Site Staff",
|
||||
};
|
||||
|
||||
export default async function ProfilePage() {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const ROLE_LABELS: Record<Role, string> = {
|
|||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
SITE_STAFF: "Site Staff",
|
||||
};
|
||||
|
||||
const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { INVENTORY_ENABLED } from "@/lib/feature-flags";
|
||||
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
ShoppingCart,
|
||||
UserCircle,
|
||||
ShieldCheck,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
|
|
@ -67,11 +68,22 @@ const PURCHASING_MGMT: NavItem[] = [
|
|||
|
||||
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 ────────────────────────────────────────────────────
|
||||
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
||||
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "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)
|
||||
|
|
@ -90,6 +102,7 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
|||
|
||||
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 visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
|
||||
return (
|
||||
|
|
@ -115,6 +128,16 @@ 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 */}
|
||||
{!isAdmin && visibleMgrAdmin.length > 0 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,15 @@
|
|||
*
|
||||
* NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption)
|
||||
* 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 =
|
||||
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
|
||||
|
||||
export const CREWING_ENABLED =
|
||||
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const ROLE_PREFIX: Record<string, string> = {
|
|||
SUPERUSER: "SUP",
|
||||
AUDITOR: "AUD",
|
||||
ADMIN: "ADM",
|
||||
SITE_STAFF: "SIT",
|
||||
};
|
||||
|
||||
/** Find max existing number for prefix and return prefix-(max+1), zero-padded to 3 digits */
|
||||
|
|
|
|||
|
|
@ -20,9 +20,42 @@ export type Permission =
|
|||
| "create_vendor"
|
||||
| "manage_vessels_accounts"
|
||||
| "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";
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
// Purchasing / admin permissions (the original PPMS matrix). SITE_STAFF is a
|
||||
// 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"],
|
||||
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"],
|
||||
|
|
@ -77,8 +110,115 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"manage_products",
|
||||
"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 {
|
||||
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
-- 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;
|
||||
48
App/prisma/rank-data.ts
Normal file
48
App/prisma/rank-data.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// 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 },
|
||||
];
|
||||
42
App/prisma/rank-doc-data.ts
Normal file
42
App/prisma/rank-doc-data.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// 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;
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ enum Role {
|
|||
SUPERUSER
|
||||
AUDITOR
|
||||
ADMIN
|
||||
SITE_STAFF
|
||||
}
|
||||
|
||||
enum POStatus {
|
||||
|
|
@ -60,6 +61,32 @@ enum RequestStatus {
|
|||
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 {
|
||||
id String @id @default(cuid())
|
||||
employeeId String @unique
|
||||
|
|
@ -375,3 +402,43 @@ model Notification {
|
|||
userId String
|
||||
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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
import { PrismaClient, Role } from "@prisma/client";
|
||||
import { ACCOUNTING_CODES } from "./accounting-codes-data";
|
||||
import { seedRanks } from "./seed-ranks";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const hash = (p: string) => bcrypt.hash(p, 12);
|
||||
|
|
@ -235,6 +236,10 @@ async function main() {
|
|||
}).length;
|
||||
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.");
|
||||
}
|
||||
|
||||
|
|
|
|||
56
App/prisma/seed-ranks.ts
Normal file
56
App/prisma/seed-ranks.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// 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`);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { PrismaClient, Role } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { ACCOUNTING_CODES } from "./accounting-codes-data";
|
||||
import { seedRanks } from "./seed-ranks";
|
||||
|
||||
const db = new PrismaClient();
|
||||
|
||||
|
|
@ -203,6 +204,9 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Crewing: Ranks (hierarchical) + document requirements ───────────────────
|
||||
await seedRanks(db);
|
||||
|
||||
// Convenience variables for PO seed data below (map to real leaf codes)
|
||||
const accTechOps = { id: codeIdMap.get("401012")! }; // Spares- Others
|
||||
const accCrewMgt = { id: codeIdMap.get("500101")! }; // Salary
|
||||
|
|
|
|||
145
App/tests/integration/admin-ranks.test.ts
Normal file
145
App/tests/integration/admin-ranks.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
104
App/tests/unit/permissions-crewing.test.ts
Normal file
104
App/tests/unit/permissions-crewing.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue