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)}> +
+