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 = { pending: 'neutral', verified: 'success', mismatch: 'warning', incomplete: 'info', }; const DOC_LABEL: Record = { 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(null); const [status, setStatus] = useState('empty'); const [fileName, setFileName] = useState(null); const [fields, setFields] = useState([]); const [error, setError] = useState(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 (
{ const f = e.target.files?.[0]; if (f) handlePick(f); e.target.value = ''; }} /> inputRef.current?.click()} onConfirm={() => setStatus('verified')} onReplace={() => inputRef.current?.click()} onRemove={reset} /> {error &&
{error}
}
); } 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>({}); const [ocrExtracts, setOcrExtracts] = useState>>({}); 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 = {}; 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 (
{record ? ( <>
KYC for {record.lead_name ?? `Lead ${record.instance_id}`} · #{record.instance_id}
Collected by Aria Eligibility · {record.current_state_name}
) : (
No lead record provided.
)}
{result && (
{result.ok && } {result.msg}
)} {kyc.hasMismatch ? (
Identity mismatch — review before advancing
    {kyc.fields .filter((f) => f.status === 'mismatch') .map((f) => (
  • {mismatchLine(f)}
  • ))}
Document status set to "Mismatch". Override only once you've confirmed the documents belong to this lead.
) : kyc.anyChecked ? (
OCR identity matches the captured lead (name + DOB).
) : null}
setPan(e.target.value.toUpperCase())} placeholder="ABCDE1234F" /> setAadhaar(e.target.value)} placeholder="XXXX XXXX XXXX" /> setVerifiedIncome(Number(e.target.value) || 0)} /> { statusTouched.current = true; setDocStatus(v as DocStatus); }} />
Upload PAN / Aadhaar to auto-extract the numbers via OCR, then confirm the values and the verification status before advancing. Files upload on submit.

Document checklist

{checklist.map((c) => (
{c.doc} {c.done ? 'Done' : 'Pending'}
))}
Identity check {kyc.hasMismatch ? 'Mismatch' : kyc.anyChecked ? 'Verified' : 'Awaiting OCR'}
Current status {docStatusOptions.find((o) => o.value === docStatus)?.label ?? docStatus}
KYC completion {completion}%
{record?.annual_income ? (
Stated annual income on file: ₹{formatINR(record.annual_income)}.
) : null}
); } function SalarySlipUpload({ value, onChange }: { value: File | null; onChange: (f: File | null) => void }) { const ref = useRef(null); return (
Salary slip { onChange(e.target.files?.[0] ?? null); e.target.value = ''; }} />
); }