Scaffolds EPFO/UAN verification the same way GST works — a standalone Playwright
proxy microservice + an /api proxy + an assisted affordance that records the
result. Aadhaar stays manual (UIDAI-restricted). Stacks on the follow-ups branch.
Behind NEXT_PUBLIC_CREWING_ENABLED.
What's in
- EpfoService/ (new microservice, GstService pattern): Express + Playwright.
POST /otp {uan} → session + OTP request; POST /verify {sessionId,uan,otp} →
member record; GET /health. EPFO is OTP-gated (no anonymous captcha lookup like
GST), so the handshake is two steps. Live portal navigation is gated behind
EPFO_LIVE (default STUB: OTP 000000 → matched) until real selectors/OTP are
validated. README documents the differences + that Aadhaar is out of scope.
- App: /api/epfo/otp + /api/epfo proxies (gated by verify_bank_epf) to
EPFO_SERVICE_URL. EpfDetail += epfoMemberName + epfoCheckedAt (migration
crewing_epfo_check). recordEpfoCheck action persists the EPFO result + audit.
- UI: an "EPFO check" affordance on the verification EPF rows — request OTP →
enter OTP → matched member → record. Aadhaar noted as manual-only.
Tests & docs
- Integration: verification.test.ts gains recordEpfoCheck (records name+timestamp,
Accounts-only gating). type-check clean; full unit (245) + integration (213)
green (RESEND_API_KEY unset).
- .env.example (EPFO_SERVICE_URL/EPFO_LIVE), CLAUDE.md, EpfoService/README.
Note: the EpfoService live portal selectors/OTP are stubbed and must be validated
against a real EPFO session before enabling EPFO_LIVE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
17 KiB
TypeScript
283 lines
17 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, verifyPpe, verifyNextOfKin, recordEpfoCheck } from "./actions";
|
|
import { verifyAppraisal } from "../appraisals/actions";
|
|
import type { PpeItem } from "@prisma/client";
|
|
|
|
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 };
|
|
type Ppe = { id: string; crewName: string; item: PpeItem; size: string | null };
|
|
type Nok = { id: string; crewName: string; name: string; relationship: 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>
|
|
);
|
|
}
|
|
|
|
// EPFO assisted lookup (Accounts): OTP handshake against EpfoService via /api/epfo,
|
|
// then record the returned member name onto the EpfDetail (A3). Aadhaar is not
|
|
// checked here (UIDAI-restricted — stays manual).
|
|
function EpfoAssist({ crewMemberId, uan }: { crewMemberId: string; uan: string | null }) {
|
|
const router = useRouter();
|
|
const [open, setOpen] = useState(false);
|
|
const [step, setStep] = useState<"start" | "otp" | "result">("start");
|
|
const [sessionId, setSessionId] = useState("");
|
|
const [mobileHint, setMobileHint] = useState("");
|
|
const [otp, setOtp] = useState("");
|
|
const [result, setResult] = useState<{ matched: boolean; name: string | null } | null>(null);
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
if (!uan) return null;
|
|
|
|
function reset() { setStep("start"); setSessionId(""); setOtp(""); setResult(null); setError(""); setMobileHint(""); }
|
|
|
|
async function requestOtp() {
|
|
setPending(true); setError("");
|
|
try {
|
|
const r = await fetch("/api/epfo/otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uan }) });
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error || "Failed to request OTP");
|
|
setSessionId(d.sessionId); setMobileHint(d.mobileHint || ""); setStep("otp");
|
|
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
|
|
setPending(false);
|
|
}
|
|
async function verify() {
|
|
setPending(true); setError("");
|
|
try {
|
|
const r = await fetch("/api/epfo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, uan, otp }) });
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error || "Lookup failed");
|
|
setResult({ matched: Boolean(d.matched), name: d.name ?? null }); setStep("result");
|
|
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
|
|
setPending(false);
|
|
}
|
|
async function record() {
|
|
setPending(true);
|
|
await recordEpfoCheck(crewMemberId, result?.name ?? null);
|
|
setPending(false); setOpen(false); reset(); router.refresh();
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button onClick={() => { reset(); setOpen(true); }} className="rounded-md border border-primary-300 px-3 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50">EPFO check</button>
|
|
<AdminDialog title="EPFO / UAN check" open={open} onClose={() => setOpen(false)}>
|
|
<div className="space-y-4 text-left">
|
|
<p className="text-sm text-neutral-600">Assisted UAN lookup via the EPFO portal. An OTP is sent to the member's registered mobile. <span className="text-neutral-400">(Aadhaar is verified manually — not via this check.)</span></p>
|
|
<p className="text-xs text-neutral-500">UAN: <span className="font-mono">{uan}</span></p>
|
|
|
|
{step === "start" && (
|
|
<button onClick={requestOtp} 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 ? "Requesting…" : "Request OTP"}</button>
|
|
)}
|
|
{step === "otp" && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-neutral-500">OTP sent to {mobileHint || "the registered mobile"}.</p>
|
|
<input className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" placeholder="Enter OTP" value={otp} onChange={(e) => setOtp(e.target.value)} />
|
|
<button onClick={verify} disabled={pending || !otp} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Checking…" : "Submit OTP"}</button>
|
|
</div>
|
|
)}
|
|
{step === "result" && (
|
|
<div className="space-y-2">
|
|
{result?.matched ? (
|
|
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Matched — EPFO member: <strong>{result.name}</strong></p>
|
|
) : (
|
|
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">No matching EPFO member for this UAN.</p>
|
|
)}
|
|
<button onClick={record} 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 ? "Recording…" : "Record result"}</button>
|
|
</div>
|
|
)}
|
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
|
</div>
|
|
</AdminDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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, ppe, nok, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; ppe: Ppe[]; nok: Nok[]; 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>
|
|
)}
|
|
|
|
{canDocs && (
|
|
<Card title="PPE" sub="Verify or reject issued PPE (MPO)." empty={ppe.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">Item</th><th className="px-4 py-3">Size</th><th className="px-4 py-3 w-32"></th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{ppe.map((r) => (
|
|
<tr key={r.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">{r.crewName}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{label(r.item)}</td>
|
|
<td className="px-4 py-3 text-neutral-600">{r.size ?? "—"}</td>
|
|
<td className="px-4 py-3"><Actions onVerify={() => verifyPpe(r.id, true)} onReject={(x) => verifyPpe(r.id, false, x)} /></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</Card>
|
|
)}
|
|
|
|
{canDocs && (
|
|
<Card title="Next of kin" sub="Verify or reject next-of-kin records (MPO)." empty={nok.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">Contact</th><th className="px-4 py-3">Relationship</th><th className="px-4 py-3 w-32"></th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{nok.map((r) => (
|
|
<tr key={r.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">{r.crewName}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{r.name}</td>
|
|
<td className="px-4 py-3 text-neutral-600">{r.relationship ?? "—"}</td>
|
|
<td className="px-4 py-3"><Actions onVerify={() => verifyNextOfKin(r.id, true)} onReject={(x) => verifyNextOfKin(r.id, false, x)} /></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">
|
|
<div className="flex flex-col items-end gap-1.5">
|
|
<EpfoAssist crewMemberId={e.crewMemberId} uan={e.uan} />
|
|
<Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} />
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|