From 8982118eee10dfc3390457d72c14f80746382dc5 Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 21:59:31 +0530 Subject: [PATCH] =?UTF-8?q?feat(crewing):=20Phase=205a=20=E2=80=94=20verif?= =?UTF-8?q?ication=20queue=20(flagged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of Phase 5 (verification + appraisal). The office queue for verifying site-entered records, per Crewing-Implementation-Spec §8.11/R11. Stacks on 4c. Behind NEXT_PUBLIC_CREWING_ENABLED. What's in - Schema: CrewActionType += RECORD_VERIFIED/RECORD_REJECTED (migration crewing_verification_actions). No model changes — SeafarerDocument/BankDetail/ EpfDetail already carry verificationStatus + verifiedById (3b/4a). - Actions (crewing/verification/actions.ts): verifyDocument (verify_site_records — MPO/Manager) and verifyBankEpf (verify_bank_epf — Accounts) set verificationStatus VERIFIED/REJECTED + verifiedById; rejection requires remarks; each writes a CrewAction. Already-decided records are guarded. - Screen: /crewing/verification — role-aware (MPO: pending documents with expiry flags; Accounts: pending bank/EPF), Verify / Reject-with-remarks. Leave is not here (Manager approval, R11). Verification added to nav (MPO + Accounts + SU, §7). Tests & docs - Integration: verification.test.ts (6) — doc verify/reject + already-decided guard, bank/EPF verify, permission gating (Accounts can't verify docs, MPO can't verify bank/EPF). type-check clean; full unit (241) + integration (201) green (verified with RESEND_API_KEY unset, mimicking CI). - CLAUDE.md updated. Deferred (per decision): PPE / next-of-kin verification gates. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/CLAUDE.md | 6 + .../(portal)/crewing/verification/actions.ts | 84 +++++++++++ .../(portal)/crewing/verification/page.tsx | 64 ++++++++ .../verification/verification-manager.tsx | 139 ++++++++++++++++++ App/components/layout/sidebar.tsx | 2 + .../migration.sql | 10 ++ App/prisma/schema.prisma | 2 + App/tests/integration/verification.test.ts | 103 +++++++++++++ 8 files changed, 410 insertions(+) create mode 100644 App/app/(portal)/crewing/verification/actions.ts create mode 100644 App/app/(portal)/crewing/verification/page.tsx create mode 100644 App/app/(portal)/crewing/verification/verification-manager.tsx create mode 100644 App/prisma/migrations/20260622162555_crewing_verification_actions/migration.sql create mode 100644 App/tests/integration/verification.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index b5a26ad..9ae7198 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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`. diff --git a/App/app/(portal)/crewing/verification/actions.ts b/App/app/(portal)/crewing/verification/actions.ts new file mode 100644 index 0000000..f4b6306 --- /dev/null +++ b/App/app/(portal)/crewing/verification/actions.ts @@ -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 { + 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 { + 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 }; +} diff --git a/App/app/(portal)/crewing/verification/page.tsx b/App/app/(portal)/crewing/verification/page.tsx new file mode 100644 index 0000000..60372ac --- /dev/null +++ b/App/app/(portal)/crewing/verification/page.tsx @@ -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 ( + { + 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} + /> + ); +} diff --git a/App/app/(portal)/crewing/verification/verification-manager.tsx b/App/app/(portal)/crewing/verification/verification-manager.tsx new file mode 100644 index 0000000..6588bef --- /dev/null +++ b/App/app/(portal)/crewing/verification/verification-manager.tsx @@ -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 ( +
+
+ + +
+ {error &&

{error}

} + setOpen(false)}> +
+