Merge pull request 'feat(crewing): Phase 5a — verification queue (flagged)' (#73) from feat/crewing-verification into feat/crewing-signoff

Reviewed-on: #73
This commit is contained in:
shad0w 2026-06-22 18:53:24 +00:00
commit 7f35667fca
8 changed files with 410 additions and 0 deletions

View file

@ -186,6 +186,12 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **Screen:** a **Sign off** button on the crew-profile header (`/crewing/crew/[id]`, `sign_off_crew` holders — Site staff / MPO / Manager); on success it redirects to the Crew directory (the member is no longer `EMPLOYEE`).
- This closes **Phase 4** (E/F/G + K). Remaining roadmap: Phase 5 (verification + appraisal), Phase 6 (payroll, dashboards, notifications).
**Phase 5a — Verification (Epic I; spec §8.11/R11):** the office queue for site-entered records (Phase 5 ships as 5a verification → 5b appraisal).
- **Actions** (`crewing/verification/actions.ts`): `verifyDocument(id, approve, remarks)` (`verify_site_records` — MPO/Manager) sets a `SeafarerDocument`'s `verificationStatus` + `verifiedById`; `verifyBankEpf(crewMemberId, "bank"|"epf", approve, remarks)` (`verify_bank_epf` — Accounts) does the same for `BankDetail`/`EpfDetail`. Rejection requires remarks; both write a `CrewAction` (`RECORD_VERIFIED`/`RECORD_REJECTED`). No new models — the verification fields already existed (3b/4a).
- **Screen:** `/crewing/verification` — role-aware (MPO sees pending documents with expiry flags; Accounts sees pending bank/EPF), Verify / Reject-with-remarks. **Leave is not here** (it's a Manager approval, R11). Added to nav (MPO + Accounts + SuperUser, §7).
- **Deferred (per decision):** PPE / next-of-kin verification gates (low-risk; no `verificationStatus` on those models).
### 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`.

View file

@ -0,0 +1,84 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import type { Role } from "@prisma/client";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const PATH = "/crewing/verification";
async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
// ── Document verification (MPO / Manager) ──────────────────────────────────────
export async function verifyDocument(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_site_records");
if ("error" in g) return g;
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } });
if (!doc) return { error: "Document not found" };
if (doc.verificationStatus !== "PENDING") return { error: `This document is already ${doc.verificationStatus.toLowerCase()}` };
await db.seafarerDocument.update({
where: { id },
data: { verificationStatus: approve ? "VERIFIED" : "REJECTED", verifiedById: g.userId },
});
await db.crewAction.create({
data: {
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
actorId: g.userId,
crewMemberId: doc.crewMemberId,
note: remarks?.trim() || null,
metadata: { record: "document" },
},
});
revalidatePath(PATH);
revalidatePath(`/crewing/crew/${doc.crewMemberId}`);
return { ok: true };
}
// ── Bank / EPF verification (Accounts) ─────────────────────────────────────────
export async function verifyBankEpf(crewMemberId: string, kind: "bank" | "epf", approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_bank_epf");
if ("error" in g) return g;
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
const status = approve ? "VERIFIED" : "REJECTED";
if (kind === "bank") {
const rec = await db.bankDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
if (!rec) return { error: "Bank details not found" };
if (rec.verificationStatus !== "PENDING") return { error: `Bank details already ${rec.verificationStatus.toLowerCase()}` };
await db.bankDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
} else {
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
if (!rec) return { error: "EPF details not found" };
if (rec.verificationStatus !== "PENDING") return { error: `EPF details already ${rec.verificationStatus.toLowerCase()}` };
await db.epfDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
}
await db.crewAction.create({
data: {
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
actorId: g.userId,
crewMemberId,
note: remarks?.trim() || null,
metadata: { record: kind },
},
});
revalidatePath(PATH);
revalidatePath(`/crewing/crew/${crewMemberId}`);
return { ok: true };
}

View file

@ -0,0 +1,64 @@
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 { VerificationManager } from "./verification-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Verification" };
export default async function VerificationPage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
const canDocs = hasPermission(role, "verify_site_records");
const canBankEpf = hasPermission(role, "verify_bank_epf");
if (!canDocs && !canBankEpf) redirect("/dashboard");
const [docs, bank, epf] = await Promise.all([
canDocs
? db.seafarerDocument.findMany({
where: { verificationStatus: "PENDING" },
orderBy: { createdAt: "asc" },
include: {
crewMember: {
select: {
name: true,
assignments: { where: { status: { not: "SIGNED_OFF" } }, take: 1, include: { vessel: { select: { name: true } }, site: { select: { name: true } } } },
},
},
},
})
: [],
canBankEpf
? db.bankDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
: [],
canBankEpf
? db.epfDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
: [],
]);
return (
<VerificationManager
docs={docs.map((d) => {
const a = d.crewMember.assignments[0];
return {
id: d.id,
crewName: d.crewMember.name,
location: a?.vessel?.name ?? a?.site?.name ?? "—",
docType: d.docType,
number: d.number,
expiryDate: d.expiryDate?.toISOString() ?? null,
submitted: d.createdAt.toISOString(),
};
})}
bank={bank.map((b) => ({ crewMemberId: b.crewMemberId, crewName: b.crewMember.name, accountName: b.accountName, accountNumber: b.accountNumber, ifsc: b.ifsc, bankName: b.bankName }))}
epf={epf.map((e) => ({ crewMemberId: e.crewMemberId, crewName: e.crewMember.name, uan: e.uan, aadhaarLast4: e.aadhaarLast4, pfNumber: e.pfNumber }))}
canDocs={canDocs}
canBankEpf={canBankEpf}
/>
);
}

View file

@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { SeafarerDocType } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { verifyDocument, verifyBankEpf } from "./actions";
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
const isExpired = (iso: string | null) => Boolean(iso && new Date(iso) < new Date());
type Doc = { id: string; crewName: string; location: string; docType: SeafarerDocType; number: string | null; expiryDate: string | null; submitted: string };
type Bank = { crewMemberId: string; crewName: string; accountName: string | null; accountNumber: string | null; ifsc: string | null; bankName: string | null };
type Epf = { crewMemberId: string; crewName: string; uan: string | null; aadhaarLast4: string | null; pfNumber: string | null };
function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
async function verify() {
setPending(true); setError("");
const res = await onVerify();
setPending(false);
if ("error" in res) setError(res.error); else router.refresh();
}
async function reject(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await onReject(reason);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<div className="text-right">
<div className="flex justify-end gap-2">
<button onClick={verify} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Verify</button>
<button onClick={() => setOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Reject</button>
</div>
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
<AdminDialog title="Reject record" open={open} onClose={() => setOpen(false)}>
<form onSubmit={reject} className="space-y-4 text-left">
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for rejection" />
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>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">Reject</button>
</div>
</form>
</AdminDialog>
</div>
);
}
function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) {
return (
<div className="mb-8">
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
<p className="text-xs text-neutral-500 mt-0.5 mb-3">{sub}</p>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
{empty ? <p className="px-4 py-10 text-center text-sm text-neutral-400">Nothing awaiting verification.</p> : (
<table className="w-full text-sm">{children}</table>
)}
</div>
</div>
);
}
export function VerificationManager({ docs, bank, epf, canDocs, canBankEpf }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; canDocs: boolean; canBankEpf: boolean }) {
return (
<div className="max-w-4xl">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Verification</h1>
<p className="text-sm text-neutral-500 mt-0.5">Site-entered records awaiting office verification.</p>
</div>
{canDocs && (
<Card title="Documents" sub="Verify or reject crew documents (MPO)." empty={docs.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Vessel / site</th><th className="px-4 py-3">Document</th><th className="px-4 py-3">Expiry</th><th className="px-4 py-3">Submitted</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{docs.map((d) => (
<tr key={d.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{d.crewName}</td>
<td className="px-4 py-3 text-neutral-600">{d.location}</td>
<td className="px-4 py-3 text-neutral-700">{label(d.docType)}{d.number ? ` · ${d.number}` : ""}</td>
<td className="px-4 py-3">{d.expiryDate ? <span className={isExpired(d.expiryDate) ? "text-danger-700 font-medium" : "text-neutral-600"}>{fmt(d.expiryDate)}{isExpired(d.expiryDate) ? " · expired" : ""}</span> : "—"}</td>
<td className="px-4 py-3 text-neutral-500">{fmt(d.submitted)}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyDocument(d.id, true)} onReject={(r) => verifyDocument(d.id, false, r)} /></td>
</tr>
))}
</tbody>
</Card>
)}
{canBankEpf && (
<Card title="Bank details" sub="Verify or reject crew bank details (Accounts)." empty={bank.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Account</th><th className="px-4 py-3">IFSC</th><th className="px-4 py-3">Bank</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{bank.map((b) => (
<tr key={b.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{b.crewName}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{b.accountNumber ?? "—"}{b.accountName ? ` (${b.accountName})` : ""}</td>
<td className="px-4 py-3 text-neutral-600">{b.ifsc ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600">{b.bankName ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(b.crewMemberId, "bank", true)} onReject={(r) => verifyBankEpf(b.crewMemberId, "bank", false, r)} /></td>
</tr>
))}
</tbody>
</Card>
)}
{canBankEpf && (
<Card title="EPF details" sub="Verify or reject crew EPF / identity details (Accounts)." empty={epf.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">UAN</th><th className="px-4 py-3">Aadhaar</th><th className="px-4 py-3">PF no.</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{epf.map((e) => (
<tr key={e.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{e.crewName}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.uan ?? "—"}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.aadhaarLast4 ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600">{e.pfNumber ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} /></td>
</tr>
))}
</tbody>
</Card>
)}
</div>
);
}

View file

@ -32,6 +32,7 @@ import {
CalendarCheck,
UserCog,
Gauge,
BadgeCheck,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -87,6 +88,7 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
{ href: "/crewing/crew", label: "Crew", icon: Contact, roles: ["MANNING", "MANAGER", "SUPERUSER", "SITE_STAFF", "ACCOUNTS"] },
{ href: "/crewing/leave", label: "Leave", icon: CalendarDays, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
{ href: "/crewing/attendance", label: "Attendance", icon: CalendarCheck, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
{ href: "/crewing/verification", label: "Verification", icon: BadgeCheck, roles: ["MANNING", "SUPERUSER", "ACCOUNTS"] },
]
: [];

View file

@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_VERIFIED';
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_REJECTED';

View file

@ -155,6 +155,8 @@ enum CrewActionType {
LEAVE_DECIDED
ATTENDANCE_RECORDED
CREW_SIGNED_OFF
RECORD_VERIFIED
RECORD_REJECTED
}
// ─── Crewing leave & attendance (Phase 4b, Epic G) ──────────────────────────

View file

@ -0,0 +1,103 @@
/**
* Integration tests for Crewing Phase 5a verification: documents (MPO) and
* bank/EPF (Accounts), with role gating per §6/§8.11.
*/
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { verifyDocument, verifyBankEpf } from "@/app/(portal)/crewing/verification/actions";
import { makeSession, getSeedUser } from "./helpers";
import type { Role } from "@prisma/client";
let manningId: string;
let accountsId: string;
let siteStaffId: string;
const SS_EMAIL = "sitestaff@itver.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
async function crewWithRecords() {
const c = await db.crewMember.create({ data: { name: "To Verify", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
const doc = await db.seafarerDocument.create({ data: { crewMemberId: c.id, docType: "PASSPORT", number: "P999" } });
await db.bankDetail.create({ data: { crewMemberId: c.id, accountNumber: "123456789", ifsc: "HDFC0001" } });
await db.epfDetail.create({ data: { crewMemberId: c.id, uan: "UAN-1" } });
return { crewId: c.id, docId: doc.id };
}
beforeAll(async () => {
manningId = (await getSeedUser("manning@pelagia.local")).id;
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITVER-SS", email: SS_EMAIL, name: "SS Ver", role: "SITE_STAFF" } });
siteStaffId = ss.id;
});
afterEach(async () => {
await db.crewAction.deleteMany({});
await db.seafarerDocument.deleteMany({});
await db.bankDetail.deleteMany({});
await db.epfDetail.deleteMany({});
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("document verification (MPO)", () => {
it("verifies a document with an audit row", async () => {
const { crewId, docId } = await crewWithRecords();
as(manningId, "MANNING");
expect("ok" in (await verifyDocument(docId, true))).toBe(true);
const d = await db.seafarerDocument.findUniqueOrThrow({ where: { id: docId } });
expect(d.verificationStatus).toBe("VERIFIED");
expect(d.verifiedById).toBe(manningId);
expect(await db.crewAction.count({ where: { crewMemberId: crewId, actionType: "RECORD_VERIFIED" } })).toBe(1);
});
it("rejection requires a reason and records it", async () => {
const { docId } = await crewWithRecords();
as(manningId, "MANNING");
expect("error" in (await verifyDocument(docId, false))).toBe(true);
expect("ok" in (await verifyDocument(docId, false, "Illegible scan"))).toBe(true);
expect((await db.seafarerDocument.findUniqueOrThrow({ where: { id: docId } })).verificationStatus).toBe("REJECTED");
});
it("won't re-verify an already-decided document", async () => {
const { docId } = await crewWithRecords();
as(manningId, "MANNING");
await verifyDocument(docId, true);
expect("error" in (await verifyDocument(docId, true))).toBe(true);
});
it("is rejected for roles without verify_site_records (accounts, site staff)", async () => {
const { docId } = await crewWithRecords();
as(accountsId, "ACCOUNTS");
expect(await verifyDocument(docId, true)).toEqual({ error: "Unauthorized" });
as(siteStaffId, "SITE_STAFF");
expect(await verifyDocument(docId, true)).toEqual({ error: "Unauthorized" });
});
});
describe("bank/EPF verification (Accounts)", () => {
it("Accounts verifies bank and EPF", async () => {
const { crewId } = await crewWithRecords();
as(accountsId, "ACCOUNTS");
expect("ok" in (await verifyBankEpf(crewId, "bank", true))).toBe(true);
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } })).verificationStatus).toBe("VERIFIED");
expect("ok" in (await verifyBankEpf(crewId, "epf", true))).toBe(true);
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } })).verificationStatus).toBe("VERIFIED");
});
it("is rejected for the MPO (no verify_bank_epf)", async () => {
const { crewId } = await crewWithRecords();
as(manningId, "MANNING");
expect(await verifyBankEpf(crewId, "bank", true)).toEqual({ error: "Unauthorized" });
});
});