lead-to-policy/src/components/activities/DocumentsBody.tsx
2026-07-04 18:22:50 +05:30

415 lines
16 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react';
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../';
import type { BadgeTone, DocStatus, OcrField } from '../';
import { SchemaGuard } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import { OCR_FIELDS } from '../../api/forms';
import { crossCheck, type DocExtract, type DocKey } from '../../lib/kyc-match';
import type { ActivityBodyProps } from './types';
const ACT = ACTIVITIES.COLLECT_DOCUMENTS;
const F = ACT.fields;
const STATUS_TONE: Record<string, BadgeTone> = {
pending: 'neutral',
verified: 'success',
mismatch: 'warning',
incomplete: 'info',
};
const DOC_LABEL: Record<DocKey, string> = { pan: 'PAN', aadhaar: 'Aadhaar' };
function mismatchLine(f: { doc: DocKey; field: 'name' | 'dob'; captured: string; ocr: string }): string {
const what = f.field === 'name' ? 'name' : 'DOB';
const fmt = (v: string) => (f.field === 'name' ? `"${v || '—'}"` : v || '—');
return `${DOC_LABEL[f.doc]} ${what} ${fmt(f.ocr)} ≠ captured ${fmt(f.captured)}`;
}
function OcrCapture({
ocr,
docKey,
activityId,
instanceId,
onAutofill,
onFile,
onExtract,
}: {
ocr: (typeof OCR_FIELDS)[keyof typeof OCR_FIELDS];
docKey: DocKey;
activityId: string;
instanceId?: number | string;
onAutofill: (targetField: string, value: unknown) => void;
onFile: (file: File | null) => void;
onExtract: (doc: DocKey, ex: DocExtract) => void;
}) {
const { client } = useZino();
const inputRef = useRef<HTMLInputElement>(null);
const [status, setStatus] = useState<DocStatus>('empty');
const [fileName, setFileName] = useState<string | null>(null);
const [fields, setFields] = useState<OcrField[]>([]);
const [error, setError] = useState<string | null>(null);
function reset() {
setStatus('empty');
setFileName(null);
setFields([]);
setError(null);
onFile(null);
onExtract(docKey, { name: null, dob: null }); // clear this doc's KYC cross-check
}
async function handlePick(file: File) {
setFileName(file.name);
setStatus('processing');
setError(null);
onFile(file);
try {
const res = await client.extractOcr(file, { activityId, fieldId: ocr.id, instanceId });
const extracted = res.extracted ?? {};
for (const m of ocr.mappings) {
if (Object.prototype.hasOwnProperty.call(extracted, m.extraction_key)) {
onAutofill(m.target_field, extracted[m.extraction_key]);
}
}
const asStr = (v: unknown) => (v == null ? null : String(v));
onExtract(docKey, { name: asStr(extracted.full_name), dob: asStr(extracted.date_of_birth) });
setFields(
Object.entries(extracted)
.filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(([k, v]) => ({ label: k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), value: String(v), mono: /number|pan|aadhaar/i.test(k) })),
);
setStatus('extracted');
if (res.parse_error) setError(res.parse_error);
} catch (e) {
setStatus('empty');
setError(e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'OCR failed'));
onFile(null);
}
}
return (
<div className="h-full">
<input
ref={inputRef}
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handlePick(f);
e.target.value = '';
}}
/>
<DocumentUploadCard
docType={ocr.docLabel}
status={status}
fields={fields}
fileName={fileName}
onUpload={() => inputRef.current?.click()}
onConfirm={() => setStatus('verified')}
onReplace={() => inputRef.current?.click()}
onRemove={reset}
/>
{error && <div className="text-2xs text-amber-700 mt-1.5">{error}</div>}
</div>
);
}
export function DocumentsBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const schemaQ = useQuery(() => client.formSchema(ACT.uid, instanceId), [instanceId]);
const docStatusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.docStatus)?.options ?? [], [schemaQ.data]);
const [pan, setPan] = useState('');
const [aadhaar, setAadhaar] = useState('');
const [verifiedIncome, setVerifiedIncome] = useState(0);
const [docStatus, setDocStatus] = useState('verified');
const [files, setFiles] = useState<Record<string, File | null>>({});
const [ocrExtracts, setOcrExtracts] = useState<Partial<Record<DocKey, DocExtract>>>({});
const statusTouched = useRef(false);
useEffect(() => {
if (!record) return;
setPan(record.pan_number || '');
setAadhaar(record.aadhaar_number || '');
setVerifiedIncome((record.verified_income as number) || record.annual_income || 0);
setDocStatus(record.doc_status || 'verified');
setFiles({});
setOcrExtracts({});
statusTouched.current = false;
}, [record]);
const setFile = (fieldId: string) => (f: File | null) => setFiles((p) => ({ ...p, [fieldId]: f }));
const applyExtract = (doc: DocKey, ex: DocExtract) => setOcrExtracts((p) => ({ ...p, [doc]: ex }));
const kyc = useMemo(
() => crossCheck({ name: record?.lead_name, dob: record?.date_of_birth }, ocrExtracts),
[record, ocrExtracts],
);
useEffect(() => {
if (kyc.hasMismatch && !statusTouched.current) setDocStatus('mismatch');
}, [kyc.hasMismatch]);
const applyAutofill = (target: string, value: unknown) => {
const v = value == null ? '' : String(value);
if (target === F.panNumber) setPan(v.toUpperCase());
else if (target === F.aadhaarNumber) setAadhaar(v.replace(/\s/g, ''));
};
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
setSubmitting(true);
setResult(null);
const activityId = ACT.uid;
try {
const fileData: Record<string, unknown> = {};
for (const [fieldId, file] of Object.entries(files)) {
if (!file) continue;
const meta = await client.uploadFile(file, { activityId, fieldId, instanceId });
fileData[fieldId] = [meta];
}
const res = await client.performActivity(instanceId, activityId, {
[F.panNumber]: pan,
[F.aadhaarNumber]: aadhaar,
[F.verifiedIncome]: verifiedIncome,
[F.docStatus]: docStatus,
...fileData,
});
setResult({ ok: true, msg: 'Documents submitted — lead advanced to Documents Collected.' });
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
setSubmitting(false);
}
}
const checklist = [
{ doc: 'PAN number', done: !!pan },
{ doc: 'Aadhaar number', done: !!aadhaar },
{ doc: 'Verified income', done: verifiedIncome > 0 },
{ doc: 'Status confirmed', done: docStatus === 'verified' },
];
const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100);
return (
<SchemaGuard
loading={schemaQ.loading}
error={schemaQ.error}
action="collect documents for this lead"
>
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_320px]">
<div className="flex flex-col gap-[18px] min-w-0">
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{record ? (
<>
<Avatar name={record.lead_name ?? `Lead ${record.instance_id}`} size={40} />
<div className="flex-1">
<div className="text-base font-bold text-strong">
KYC for {record.lead_name ?? `Lead ${record.instance_id}`} · #{record.instance_id}
</div>
<div className="text-xs text-faint">Collected by Aria Eligibility · {record.current_state_name}</div>
</div>
</>
) : (
<div className="flex-1 text-sm text-faint">No lead record provided.</div>
)}
</div>
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
{kyc.hasMismatch ? (
<div className="flex items-start gap-2.5 text-sm text-amber-800 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
<AlertTriangle size={16} className="text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1">
<div className="font-semibold">Identity mismatch review before advancing</div>
<ul className="mt-1.5 list-disc pl-4 space-y-0.5">
{kyc.fields
.filter((f) => f.status === 'mismatch')
.map((f) => (
<li key={`${f.doc}-${f.field}`} className="font-numeric">
{mismatchLine(f)}
</li>
))}
</ul>
<div className="mt-1.5 text-xs text-amber-700">
Document status set to "Mismatch". Override only once you've confirmed the documents belong to this lead.
</div>
</div>
</div>
) : kyc.anyChecked ? (
<div className="flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3">
<ShieldCheck size={16} />
OCR identity matches the captured lead (name + DOB).
</div>
) : null}
<div className="grid grid-cols-2 gap-[18px]">
<OcrCapture
ocr={OCR_FIELDS.pan}
docKey="pan"
activityId={ACT.uid}
instanceId={instanceId}
onAutofill={applyAutofill}
onFile={setFile(OCR_FIELDS.pan.id)}
onExtract={applyExtract}
/>
<OcrCapture
ocr={OCR_FIELDS.aadhaar}
docKey="aadhaar"
activityId={ACT.uid}
instanceId={instanceId}
onAutofill={applyAutofill}
onFile={setFile(OCR_FIELDS.aadhaar.id)}
onExtract={applyExtract}
/>
</div>
<Card title="KYC details">
<div className="grid grid-cols-2 gap-4">
<Input label="PAN number" value={pan} onChange={(e) => setPan(e.target.value.toUpperCase())} placeholder="ABCDE1234F" />
<Input label="Aadhaar number" value={aadhaar} onChange={(e) => setAadhaar(e.target.value)} placeholder="XXXX XXXX XXXX" />
<Input
label="Verified income"
prefix="₹"
type="number"
value={verifiedIncome}
onChange={(e) => setVerifiedIncome(Number(e.target.value) || 0)}
/>
<SelectField
label="Document status"
required
options={docStatusOptions}
value={docStatus}
onChange={(v) => {
statusTouched.current = true;
setDocStatus(v as DocStatus);
}}
/>
</div>
<div className="mt-4">
<SalarySlipUpload value={files[F.salarySlip] ?? null} onChange={setFile(F.salarySlip)} />
</div>
<div className="flex items-start gap-3 bg-info-soft rounded-md px-3.5 py-3 mt-4">
<Info size={16} className="text-sky-600 shrink-0 mt-0.5" />
<div className="text-xs text-muted leading-normal">
Upload PAN / Aadhaar to auto-extract the numbers via OCR, then confirm the values and the verification
status before advancing. Files upload on submit.
</div>
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Submitting' : 'Submit documents'}
</Button>
</div>
</Card>
</div>
<div className="flex flex-col gap-4">
<section className="bg-card rounded-lg border border-border-subtle shadow-sm p-5">
<h3 className="mt-0 mb-3.5 text-sm font-semibold text-strong">Document checklist</h3>
<div className="flex flex-col gap-2.5">
{checklist.map((c) => (
<div key={c.doc} className="flex items-center justify-between">
<span className="text-sm text-body">{c.doc}</span>
<Badge tone={c.done ? 'success' : 'neutral'} dot={c.done}>
{c.done ? 'Done' : 'Pending'}
</Badge>
</div>
))}
</div>
<div className="h-px bg-border-subtle my-4" />
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-body">Identity check</span>
<Badge tone={kyc.hasMismatch ? 'warning' : kyc.anyChecked ? 'success' : 'neutral'} dot>
{kyc.hasMismatch ? 'Mismatch' : kyc.anyChecked ? 'Verified' : 'Awaiting OCR'}
</Badge>
</div>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-muted">Current status</span>
<Badge tone={STATUS_TONE[docStatus] ?? 'neutral'} dot>
{docStatusOptions.find((o) => o.value === docStatus)?.label ?? docStatus}
</Badge>
</div>
<div className="flex items-center justify-between mb-3.5 mt-3">
<span className="text-sm text-muted">KYC completion</span>
<span className="font-numeric font-extrabold text-xl text-sunrise-600">{completion}%</span>
</div>
<div className="h-2 bg-slate-100 rounded-pill overflow-hidden mb-[18px]">
<div className="h-full bg-sunrise transition-[width] duration-500" style={{ width: `${completion}%` }} />
</div>
</section>
{record?.annual_income ? (
<div className="bg-info-soft rounded-md px-3.5 py-3 text-xs text-muted">
Stated annual income on file: ₹{formatINR(record.annual_income)}.
</div>
) : null}
</div>
</div>
</SchemaGuard>
);
}
function SalarySlipUpload({ value, onChange }: { value: File | null; onChange: (f: File | null) => void }) {
const ref = useRef<HTMLInputElement>(null);
return (
<div>
<span className="text-sm font-medium text-muted">Salary slip</span>
<input
ref={ref}
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="hidden"
onChange={(e) => {
onChange(e.target.files?.[0] ?? null);
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => ref.current?.click()}
className="mt-1.5 w-full flex items-center gap-2.5 border border-dashed border-border-default rounded-md bg-slate-50 px-3.5 py-3 cursor-pointer text-left"
>
{value ? <FileText size={18} className="text-sunrise-600 shrink-0" /> : <UploadCloud size={18} className="text-sunrise-500 shrink-0" />}
<span className="text-sm text-body truncate flex-1">{value ? value.name : 'Upload salary slip (PDF / image)'}</span>
{value && (
<span
onClick={(e) => {
e.stopPropagation();
onChange(null);
}}
className="text-2xs font-semibold text-link"
>
Remove
</span>
)}
</button>
</div>
);
}