415 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|