pelagia-portal/App/app/(portal)/crewing/verification/verification-manager.tsx
Hardik c14a22588e
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 30s
feat(crewing): Phase 5b — appraisal (flagged)
Final slice of Phase 5. The appraisal lifecycle raise → verify → approve across
three role-gated surfaces, per Crewing-Implementation-Spec §5.4/§8.14. Stacks on
5a verification. Behind NEXT_PUBLIC_CREWING_ENABLED. Completes Phase 5.

What's in
- Schema: Appraisal (on CrewAssignment) + AppraisalStatus
  (DRAFT/SUBMITTED/MPO_VERIFIED/MANAGER_APPROVED/REJECTED); CrewActionType +=
  APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED. Migration crewing_appraisal.
- State machine lib/appraisal-state-machine.ts: verify (SUBMITTED→MPO_VERIFIED,
  MPO/Manager), approve (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject.
- Actions (crewing/appraisals/actions.ts): raiseAppraisal (raise_appraisal — PM/
  site staff), verifyAppraisal (verify_appraisal — MPO), approveAppraisal
  (approve_appraisal — Manager); reject paths require remarks; notifications
  APPRAISAL_FOR_VERIFICATION / APPRAISAL_FOR_APPROVAL.
- Three surfaces (§8.14): PM raises + tracks status on the crew-profile Appraisals
  tab; MPO verifies in the Verification queue (Appraisals section); Manager approves
  in the central /approvals queue (Appraisal kind).

Tests & docs
- Unit: appraisal-state-machine.test.ts (4). Integration: appraisal.test.ts (4) —
  raise→verify→approve happy path, MPO reject, permission gating (MPO can't raise,
  site staff can't verify, MPO can't approve). type-check clean; full unit (245) +
  integration (205) green (verified with RESEND_API_KEY unset).
- CLAUDE.md updated — completes Phase 5 (I + H).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:09:32 +05:30

160 lines
9.8 KiB
TypeScript

"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";
import { verifyAppraisal } from "../appraisals/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 };
type Appr = { id: string; crewName: string; rank: string; period: string; comments: 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, appraisals, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: 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>
)}
{canAppraisals && (
<Card title="Appraisals" sub="Verify or reject submitted appraisals (MPO)." empty={appraisals.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">Rank</th><th className="px-4 py-3">Period</th><th className="px-4 py-3">Comments</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{appraisals.map((a) => (
<tr key={a.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">{a.crewName}</td>
<td className="px-4 py-3 text-neutral-600">{a.rank}</td>
<td className="px-4 py-3 text-neutral-700">{a.period}</td>
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">{a.comments ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyAppraisal(a.id, true)} onReject={(r) => verifyAppraisal(a.id, false, r)} /></td>
</tr>
))}
</tbody>
</Card>
)}
</div>
);
}