+
setNavOpen(false)} />
+
+
+ )}
+
-
-
+ setNavOpen(true)} />
+
diff --git a/src/lib/kyc-match.ts b/src/lib/kyc-match.ts
new file mode 100644
index 0000000..fb450c7
--- /dev/null
+++ b/src/lib/kyc-match.ts
@@ -0,0 +1,179 @@
+// KYC cross-check — pure, client-side. Compares the name + DOB the OCR reads off
+// the PAN / Aadhaar against the name + DOB captured for the lead.
+//
+// Why client-side: the OCR'd name/DOB exist for one moment only — in the browser,
+// right after `/ocr-extract` returns. They are never persisted (pan_doc/aadhaar_doc
+// hold only file metadata; only the *number* is stored). So the check runs here, at
+// collection time. See ai-employee-leadtopolicy/43_doc_ocr_crosscheck_plan.md.
+
+export type Verdict = 'match' | 'mismatch' | 'unknown';
+
+export type DocKey = 'pan' | 'aadhaar';
+
+export interface DocExtract {
+ name?: string | null;
+ dob?: string | null; // OCR returns YYYY-MM-DD per the deployed ocr_config
+}
+
+export interface FieldVerdict {
+ doc: DocKey;
+ field: 'name' | 'dob';
+ status: Verdict;
+ captured: string;
+ ocr: string;
+ score?: number; // name only
+}
+
+export interface CrossCheck {
+ fields: FieldVerdict[];
+ hasMismatch: boolean; // any field is a mismatch
+ anyChecked: boolean; // any field is match|mismatch (i.e. OCR produced something)
+}
+
+// ── name matching ────────────────────────────────────────────────────────────
+
+const HONORIFICS = new Set(['mr', 'mrs', 'ms', 'dr', 'shri', 'smt', 'kumari']);
+
+// Lowercase, strip non-letters, drop honorifics, sort tokens (order-insensitive).
+export function normName(s?: string | null): string {
+ if (!s) return '';
+ return String(s)
+ .toLowerCase()
+ .replace(/[^a-z\s]/g, ' ')
+ .split(/\s+/)
+ .filter((t) => t && !HONORIFICS.has(t))
+ .sort()
+ .join(' ')
+ .trim();
+}
+
+function levenshtein(a: string, b: string): number {
+ if (a === b) return 0;
+ if (!a.length) return b.length;
+ if (!b.length) return a.length;
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
+ for (let i = 1; i <= a.length; i++) {
+ const curr = [i];
+ for (let j = 1; j <= b.length; j++) {
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
+ }
+ prev = curr;
+ }
+ return prev[b.length];
+}
+
+function ratio(a: string, b: string): number {
+ const m = Math.max(a.length, b.length);
+ return m === 0 ? 1 : 1 - levenshtein(a, b) / m;
+}
+
+// Two name tokens are compatible if equal, one is an initial prefixing the other,
+// or they are within a small edit distance (catches OCR typos like rabi/ravi → no).
+function tokenMatch(x: string, y: string): boolean {
+ if (x === y) return true;
+ if ((x.length === 1 && y.startsWith(x)) || (y.length === 1 && x.startsWith(y))) return true;
+ return ratio(x, y) >= 0.85;
+}
+
+export const NAME_THRESHOLD = 0.82;
+
+// Min of two-way token coverage: every token on each side must find a partner.
+// Penalises both extra and missing tokens, so "Ravi" vs "Ravi Kumar" scores 0.5.
+function nameScore(a: string, b: string): number {
+ const A = a.split(' ').filter(Boolean);
+ const B = b.split(' ').filter(Boolean);
+ if (!A.length || !B.length) return 0;
+ const cover = (P: string[], Q: string[]) => P.filter((p) => Q.some((q) => tokenMatch(p, q))).length / P.length;
+ return Math.min(cover(A, B), cover(B, A));
+}
+
+export function nameVerdict(captured?: string | null, ocr?: string | null): { status: Verdict; score: number } {
+ const a = normName(captured);
+ const b = normName(ocr);
+ if (!a || !b) return { status: 'unknown', score: 0 };
+ if (a === b) return { status: 'match', score: 1 };
+ const score = nameScore(a, b);
+ return { status: score >= NAME_THRESHOLD ? 'match' : 'mismatch', score };
+}
+
+// ── DOB matching ─────────────────────────────────────────────────────────────
+
+interface Ymd {
+ y: number;
+ m: number;
+ d: number;
+}
+
+// Captured DOB is an IST-midnight instant (the date input runs in IST, so a picked
+// "1990-05-15" is stored as "1990-05-14T18:30:00.000Z"). Recover the calendar date
+// the user actually picked by reading the instant in Asia/Kolkata — pinned explicitly
+// so the verdict is identical regardless of the host/browser timezone.
+const IST_FMT = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Kolkata',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+});
+
+function capturedCalendar(iso?: string | null): Ymd | null {
+ if (!iso) return null;
+ const dt = new Date(iso);
+ if (Number.isNaN(dt.getTime())) return null;
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(IST_FMT.format(dt)); // en-CA → "YYYY-MM-DD"
+ if (!m) return null;
+ return { y: +m[1], m: +m[2], d: +m[3] };
+}
+
+// Parse the OCR DOB *literally* — never `new Date(ymd)`, which treats a date-only
+// string as UTC midnight and tz-shifts the day. Accept YYYY-MM-DD (the config spec)
+// and the DD/MM/YYYY a card may print.
+function parseOcrDob(s?: string | null): Ymd | null {
+ if (!s) return null;
+ const t = String(s).trim();
+ let m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/.exec(t);
+ if (m) return { y: +m[1], m: +m[2], d: +m[3] };
+ m = /^(\d{1,2})[-/](\d{1,2})[-/](\d{4})/.exec(t);
+ if (m) return { y: +m[3], m: +m[2], d: +m[1] };
+ return null;
+}
+
+export function dobVerdict(capturedIso?: string | null, ocrDob?: string | null): Verdict {
+ const cap = capturedCalendar(capturedIso);
+ const ocr = parseOcrDob(ocrDob);
+ if (!cap || !ocr) return 'unknown';
+ return cap.y === ocr.y && cap.m === ocr.m && cap.d === ocr.d ? 'match' : 'mismatch';
+}
+
+// Human-readable captured DOB (YYYY-MM-DD) for banner text.
+export function capturedDobLabel(iso?: string | null): string {
+ const c = capturedCalendar(iso);
+ if (!c) return '';
+ return `${c.y}-${String(c.m).padStart(2, '0')}-${String(c.d).padStart(2, '0')}`;
+}
+
+// ── aggregate ──────────────────────────────────────────────────────────────
+
+export function crossCheck(
+ captured: { name?: string | null; dob?: string | null },
+ docs: Partial
>,
+): CrossCheck {
+ const fields: FieldVerdict[] = [];
+ (Object.keys(docs) as DocKey[]).forEach((doc) => {
+ const ex = docs[doc];
+ if (!ex) return;
+ const nv = nameVerdict(captured.name, ex.name);
+ if (nv.status !== 'unknown') {
+ fields.push({ doc, field: 'name', status: nv.status, score: nv.score, captured: captured.name ?? '', ocr: ex.name ?? '' });
+ }
+ const dv = dobVerdict(captured.dob, ex.dob);
+ if (dv !== 'unknown') {
+ fields.push({ doc, field: 'dob', status: dv, captured: capturedDobLabel(captured.dob), ocr: String(ex.dob ?? '') });
+ }
+ });
+ return {
+ fields,
+ hasMismatch: fields.some((f) => f.status === 'mismatch'),
+ anyChecked: fields.length > 0,
+ };
+}
diff --git a/src/lib/underwriting.ts b/src/lib/underwriting.ts
new file mode 100644
index 0000000..7629e3f
--- /dev/null
+++ b/src/lib/underwriting.ts
@@ -0,0 +1,117 @@
+// Underwriting decision-support — pure, client-side. No backend call.
+// Mirrors the rules described in 40_underwriting_routing_plan.md (Pri 5a):
+// financial: sum_assured vs (income × cap) medical: sum_assured vs NML(age)
+import type { LeadRecord } from '../api/types';
+
+// Max sum-assured as a multiple of annual income before financial underwriting kicks in.
+// Uniform 20× today (matches tbl_products.sa_income_multiple_max seed).
+export const CAP = 20;
+
+// Non-medical-limit grid: max SA (₹) allowed without medical tests, by age band.
+// TODO: replace with the real ABSLI non-medical-limit grid before go-live (PLACEHOLDER).
+export const NML_GRID: ReadonlyArray<{ maxAge: number; nml: number }> = [
+ { maxAge: 35, nml: 10_000_000 },
+ { maxAge: 45, nml: 5_000_000 },
+ { maxAge: 55, nml: 3_000_000 },
+ { maxAge: 200, nml: 1_000_000 },
+];
+
+export type IncomeSource = 'verified' | 'declared' | 'none';
+
+export interface UwAssessment {
+ ok: boolean; // enough data to say anything
+ age: number | null;
+ sumAssured: number;
+ income: number;
+ incomeSource: IncomeSource;
+ incomeMultiple: number | null;
+ cap: number;
+ financialRequired: boolean;
+ nml: number | null;
+ medicalRequired: boolean;
+ route: string[];
+ financialText: string;
+ medicalText: string;
+}
+
+function ageFrom(dob?: string | null): number | null {
+ if (!dob) return null;
+ const b = new Date(dob);
+ if (Number.isNaN(b.getTime())) return null;
+ const n = new Date();
+ let age = n.getFullYear() - b.getFullYear();
+ if (n < new Date(n.getFullYear(), b.getMonth(), b.getDate())) age--;
+ return age;
+}
+
+function nmlFor(age: number): number {
+ for (const band of NML_GRID) if (age <= band.maxAge) return band.nml;
+ return NML_GRID[NML_GRID.length - 1].nml;
+}
+
+type AssessInput = Pick<
+ LeadRecord,
+ 'date_of_birth' | 'annual_income' | 'verified_income' | 'sum_assured'
+>;
+
+export function assess(lead: AssessInput): UwAssessment {
+ const age = ageFrom(lead.date_of_birth);
+ const sumAssured = Number(lead.sum_assured) || 0;
+ const verified = Number(lead.verified_income) || 0;
+ const declared = Number(lead.annual_income) || 0;
+ const income = verified > 0 ? verified : declared;
+ const incomeSource: IncomeSource = verified > 0 ? 'verified' : declared > 0 ? 'declared' : 'none';
+
+ // financial axis
+ let incomeMultiple: number | null = null;
+ let financialRequired = false;
+ let financialText: string;
+ if (income <= 0) {
+ financialText = 'Income unverified — cannot assess affordability';
+ } else {
+ incomeMultiple = sumAssured / income;
+ financialRequired = incomeMultiple > CAP;
+ const verdict = financialRequired
+ ? 'OVER → financial UW'
+ : incomeMultiple >= CAP * 0.9
+ ? 'at limit'
+ : 'within limit';
+ financialText = `${incomeMultiple.toFixed(1)}× ${incomeSource} income (cap ${CAP}×) — ${verdict}`;
+ }
+
+ // medical axis
+ let nml: number | null = null;
+ let medicalRequired = false;
+ let medicalText: string;
+ if (age == null) {
+ medicalText = 'DOB missing — cannot assess medical limit';
+ } else {
+ nml = nmlFor(age);
+ medicalRequired = sumAssured > nml;
+ medicalText = `age ${age}, NML ₹${nml.toLocaleString('en-IN')} — ${
+ medicalRequired ? 'SA over → medical tests required' : 'non-medical'
+ }`;
+ }
+
+ const route: string[] = [];
+ if (medicalRequired) route.push('medical');
+ if (financialRequired) route.push('financial');
+ if (route.length === 0) route.push('non-medical');
+
+ const ok = sumAssured > 0 && (age != null || income > 0);
+ return {
+ ok,
+ age,
+ sumAssured,
+ income,
+ incomeSource,
+ incomeMultiple,
+ cap: CAP,
+ financialRequired,
+ nml,
+ medicalRequired,
+ route,
+ financialText,
+ medicalText,
+ };
+}
diff --git a/src/screens/AssignScreen.tsx b/src/screens/AssignScreen.tsx
index 2c87ea0..5fac15a 100644
--- a/src/screens/AssignScreen.tsx
+++ b/src/screens/AssignScreen.tsx
@@ -1,16 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
-import { CheckCircle2, MapPin, UserCheck } from 'lucide-react';
-import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, Input, Select } from '../components';
+import { CheckCircle2, UserCheck } from 'lucide-react';
+import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, Select } from '../components';
import { LookupField } from '../components/form';
import type { LookupValue } from '../components/form';
import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads';
import { decisionToCard } from '../api/adapters';
-import { ACTIVITY_DEFS } from '../api/forms';
+import { fieldFromSchema } from '../api/schema';
+import { ACTIVITY_META } from '../api/forms';
-const DEF = ACTIVITY_DEFS.assign;
-const OWNER = DEF.fields.find((f) => f.id === 'owner_agent_input')!;
+const DEF = ACTIVITY_META.assign;
export function AssignScreen() {
const { client } = useZino();
@@ -20,6 +20,12 @@ export function AssignScreen() {
const { records, refetch } = useLeads();
+ // Lookup field config (templates / display+storage cols) comes from the live
+ // form schema, not hardcoded.
+ const schemaQ = useQuery(() => client.formSchema(DEF.uid), []);
+ const REGION = useMemo(() => fieldFromSchema(schemaQ.data, 'assigned_region_input'), [schemaQ.data]);
+ const OWNER = useMemo(() => fieldFromSchema(schemaQ.data, 'owner_agent_input'), [schemaQ.data]);
+
// Assign Lead is valid at the Qualified state.
const eligible = useMemo(() => records.filter((r) => r.current_state_name === 'Qualified'), [records]);
@@ -31,11 +37,11 @@ export function AssignScreen() {
const record = useMemo(() => records.find((r) => String(r.instance_id) === selectedId), [records, selectedId]);
- const [region, setRegion] = useState('');
+ const [region, setRegion] = useState(null);
const [agent, setAgent] = useState(null);
useEffect(() => {
if (!record) return;
- setRegion(record.assigned_region || record.state_region || '');
+ setRegion((record.assigned_region as LookupValue) ?? null);
setAgent((record.owner_agent as LookupValue) ?? null);
}, [record]);
@@ -44,7 +50,7 @@ export function AssignScreen() {
async function submit() {
if (!record) return;
- if (!region.trim()) {
+ if (!region) {
setResult({ ok: false, msg: 'Assigned region is required.' });
return;
}
@@ -65,8 +71,8 @@ export function AssignScreen() {
}
return (
-
-
+
+
{/* context strip + lead picker */}
{record ? (
@@ -126,25 +132,34 @@ export function AssignScreen() {
-
}
- value={region}
- onChange={(e) => setRegion(e.target.value)}
- placeholder="e.g. West / Maharashtra"
- />
-
+ {REGION && (
+
+ )}
+ {OWNER && (
+
+ )}
+ {schemaQ.loading &&
Loading form…
}
- {agent?.region ? String(agent.region) : region || 'Pick a region & agent'}
+ {agent?.region ? String(agent.region) : region?.region ? String(region.region) : 'Pick a region & agent'}
diff --git a/src/screens/DocumentsScreen.tsx b/src/screens/DocumentsScreen.tsx
index f581371..3b3c82c 100644
--- a/src/screens/DocumentsScreen.tsx
+++ b/src/screens/DocumentsScreen.tsx
@@ -1,12 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
-import { CheckCircle2, FileText, Info, UploadCloud } from 'lucide-react';
+import { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react';
import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, Select } from '../components';
import type { BadgeTone, DocStatus, OcrField } from '../components';
-import { useZino } from '../api/provider';
+import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads';
-import { ACTIVITIES, DOC_STATUS_OPTIONS } from '../api/config';
+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';
const ACT = ACTIVITIES.COLLECT_DOCUMENTS;
const F = ACT.fields;
@@ -18,20 +20,33 @@ const STATUS_TONE: Record
= {
incomplete: 'info',
};
+const DOC_LABEL: Record = { pan: 'PAN', aadhaar: 'Aadhaar' };
+
+// One human-readable line per mismatched field for the banner.
+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)}`;
+}
+
// One OCR document: file picker → /ocr-extract → autofills its mapped target
// field(s). The picked File is held and uploaded at submit time.
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);
@@ -54,6 +69,10 @@ function OcrCapture({
onAutofill(m.target_field, extracted[m.extraction_key]);
}
}
+ // Surface the OCR'd identity (name + DOB) for the cross-check. These are
+ // extracted but never persisted, so this is the only point they exist.
+ const asStr = (v: unknown) => (v == null ? null : String(v));
+ onExtract(docKey, { name: asStr(extracted.full_name), dob: asStr(extracted.date_of_birth) });
// Show every extracted key in the card for confirmation.
setFields(
Object.entries(extracted)
@@ -118,12 +137,20 @@ export function DocumentsScreen() {
const record = useMemo(() => records.find((r) => String(r.instance_id) === selectedId), [records, selectedId]);
+ // doc_status options from the live form schema, not hardcoded.
+ const schemaQ = useQuery(() => client.formSchema(ACT.uid), []);
+ 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');
// Picked-but-not-yet-uploaded files, keyed by field id.
const [files, setFiles] = useState>({});
+ // OCR'd identity per doc — held only in memory for the cross-check (never persisted).
+ const [ocrExtracts, setOcrExtracts] = useState>>({});
+ // True once the agent edits the status by hand — stops the auto-mismatch nudge.
+ const statusTouched = useRef(false);
useEffect(() => {
if (!record) return;
@@ -132,9 +159,25 @@ export function DocumentsScreen() {
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 }));
+
+ // Cross-check the OCR'd name + DOB against the captured lead identity.
+ const kyc = useMemo(
+ () => crossCheck({ name: record?.lead_name, dob: record?.date_of_birth }, ocrExtracts),
+ [record, ocrExtracts],
+ );
+
+ // Advisory nudge: first time the identity disagrees, flag the status as Mismatch.
+ // The agent can still override (statusTouched). No hard block — matches the goal of
+ // surfacing it rather than trusting the docs blindly.
+ useEffect(() => {
+ if (kyc.hasMismatch && !statusTouched.current) setDocStatus('mismatch');
+ }, [kyc.hasMismatch]);
// OCR autofills its mapped target text field.
const applyAutofill = (target: string, value: unknown) => {
@@ -185,8 +228,8 @@ export function DocumentsScreen() {
const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100);
return (
-
-
+
+
{record ? (
<>
@@ -239,21 +282,52 @@ export function DocumentsScreen() {
)}
+ {/* KYC identity cross-check — OCR'd name + DOB vs the captured lead */}
+ {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}
+
{/* OCR document capture */}
@@ -271,9 +345,12 @@ export function DocumentsScreen() {
@@ -314,10 +391,17 @@ export function DocumentsScreen() {
+
+ Identity check
+
+ {kyc.hasMismatch ? 'Mismatch' : kyc.anyChecked ? 'Verified' : 'Awaiting OCR'}
+
+
+
Current status
- {DOC_STATUS_OPTIONS.find((o) => o.value === docStatus)?.label ?? docStatus}
+ {docStatusOptions.find((o) => o.value === docStatus)?.label ?? docStatus}
diff --git a/src/screens/IssuePolicyScreen.tsx b/src/screens/IssuePolicyScreen.tsx
new file mode 100644
index 0000000..e79219e
--- /dev/null
+++ b/src/screens/IssuePolicyScreen.tsx
@@ -0,0 +1,222 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { CheckCircle2 } from 'lucide-react';
+import { Avatar, Button, Card, formatINR, Input } from '../components';
+import { useLeads } from '../api/leads';
+import { useZino } from '../api/provider';
+import { productOf } from '../api/adapters';
+import { ACTIVITIES } from '../api/config';
+
+const F = ACTIVITIES.ISSUE_POLICY.fields;
+
+function todayISO(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+// issue date + term years -> ISO date (maturity). Empty if inputs invalid.
+function addYears(iso: string, years: number): string {
+ if (!iso || !years) return '';
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return '';
+ d.setFullYear(d.getFullYear() + years);
+ return d.toISOString().slice(0, 10);
+}
+
+export function IssuePolicyScreen() {
+ const { client } = useZino();
+ const navigate = useNavigate();
+ const [params] = useSearchParams();
+ const instanceParam = params.get('instance');
+
+ const { records, refetch } = useLeads();
+
+ // Issue Policy advances from Underwriting (after an approved verdict — Issuance Guard 947).
+ const eligible = useMemo(
+ () => records.filter((r) => r.current_state_name === 'Underwriting'),
+ [records],
+ );
+
+ const [selectedId, setSelectedId] = useState
('');
+ useEffect(() => {
+ if (instanceParam) setSelectedId(instanceParam);
+ else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
+ }, [instanceParam, eligible, selectedId]);
+
+ const record = useMemo(
+ () => records.find((r) => String(r.instance_id) === selectedId),
+ [records, selectedId],
+ );
+
+ const [issueDate, setIssueDate] = useState(todayISO());
+ const [term, setTerm] = useState(20);
+ const [premium, setPremium] = useState(0);
+
+ useEffect(() => {
+ if (!record) return;
+ setIssueDate(record.policy_issue_date || todayISO());
+ setTerm(record.policy_term || 20);
+ setPremium(record.premium_amount || 0);
+ }, [record]);
+
+ // commencement = issue date; maturity = issue + term (computed client-side).
+ const commencement = issueDate;
+ const maturity = useMemo(() => addYears(issueDate, term), [issueDate, term]);
+
+ const [submitting, setSubmitting] = useState(false);
+ const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
+
+ async function submit() {
+ if (!record) return;
+ setSubmitting(true);
+ setResult(null);
+ try {
+ // policy_number is backend-generated (id_gen POL-YYYY-####) — do NOT send it.
+ await client.performActivity(record.instance_id, ACTIVITIES.ISSUE_POLICY.uid, {
+ [F.issueDate]: issueDate,
+ [F.term]: term,
+ [F.maturity]: maturity,
+ [F.premium]: premium,
+ });
+ setResult({ ok: true, msg: 'Policy issued — number generated automatically.' });
+ refetch();
+ } catch (e) {
+ setResult({
+ ok: false,
+ msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed'),
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ const lead = record;
+
+ return (
+
+
+ {/* context strip + lead picker */}
+
+ {lead ? (
+ <>
+
+
+
+ {lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
+
+
+ {lead.current_state_name ?? '—'} · {productOf(lead.recommended_product) ?? '—'} · SA ₹
+ {formatINR(lead.sum_assured ?? 0)}
+
+
+ >
+ ) : (
+
Select a lead at the Underwriting stage.
+ )}
+
+
+
+ {result && (
+
+ {result.ok && }
+ {result.msg}
+ {result.ok && lead && (
+
+ )}
+
+ )}
+
+
+
+ setIssueDate(e.target.value)}
+ />
+ setTerm(Number(e.target.value) || 0)}
+ />
+ setPremium(Number(e.target.value) || 0)}
+ />
+
+
+ Policy number is auto-generated on issue (POL-YYYY-####).
+
+
+
+
+
+
+
+ {/* RIGHT — policy summary (dates computed in-app) */}
+
+
+
+ Policy summary
+
+
+ Policy number
+
+ {lead?.policy_number ?? 'auto on issue'}
+
+
+
+ Risk commencement
+ {commencement || '—'}
+
+
+ Term
+ {term ? `${term} yrs` : '—'}
+
+
+ Maturity date
+ {maturity || '—'}
+
+
+
+ Premium
+ ₹{formatINR(premium)}
+
+
+
+ Risk commencement = issue date. Maturity = issue date + term, computed in-app.
+
+
+
+ );
+}
diff --git a/src/screens/Lead360Screen.tsx b/src/screens/Lead360Screen.tsx
index 54f0f50..a33c5a3 100644
--- a/src/screens/Lead360Screen.tsx
+++ b/src/screens/Lead360Screen.tsx
@@ -1,9 +1,9 @@
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
-import { Ban, TriangleAlert, CheckCircle2, CircleDashed } from 'lucide-react';
+import { Ban, CheckCircle2, CircleDashed, Download } from 'lucide-react';
import {
- AIDecisionCard,
+ AIDecisionStream,
Avatar,
Badge,
Button,
@@ -19,7 +19,7 @@ import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads';
import { recordToLead, decisionToCard, auditToTimeline } from '../api/adapters';
import { STAGES, ONBOARD_ACTIVITY_UID } from '../api/config';
-import { ACTIVITY_DEFS, DISQUALIFY_STATES, STATE_NEXT } from '../api/forms';
+import { ACTIVITY_META, DISQUALIFY_STATES, STATE_NEXT } from '../api/forms';
import type { LeadRecord } from '../api/types';
/** Contextual next-step action for a lead, driven by its current state.
@@ -31,7 +31,7 @@ function NextActionPanel({ record, onAdvanced }: { record: LeadRecord; onAdvance
const [showDisqualify, setShowDisqualify] = useState(false);
const state = record.current_state_name ?? '';
const next = STATE_NEXT[state];
- const inlineDef = next && !next.route ? ACTIVITY_DEFS[next.activity] : undefined;
+ const inlineMeta = next && !next.route && next.activity ? ACTIVITY_META[next.activity] : undefined;
const canDisqualify = DISQUALIFY_STATES.has(state);
return (
@@ -50,7 +50,7 @@ function NextActionPanel({ record, onAdvanced }: { record: LeadRecord; onAdvance
{showDisqualify ? (
@@ -60,8 +60,8 @@ function NextActionPanel({ record, onAdvanced }: { record: LeadRecord; onAdvance
{next.label ?? 'Continue'}
- ) : inlineDef ? (
-
+ ) : inlineMeta ? (
+
) : (
{state === 'Disqualified' ? 'This lead was disqualified.' : 'Lifecycle complete — no further action.'}
@@ -99,6 +99,7 @@ export function Lead360Screen() {
() => client.aiDecisions(instanceId!),
[instanceId],
instanceId != null,
+ 12_000, // poll: async AI decisions land without a manual reload
);
const auditQ = useQuery(() => client.audit(instanceId!), [instanceId], instanceId != null);
@@ -119,14 +120,17 @@ export function Lead360Screen() {
const decisions = (decisionsQ.data ?? []).map(decisionToCard);
const audit = auditQ.data ?? [];
const timeline = auditToTimeline(audit);
- const escalations = decisions.filter((d) => d.confidence < 0.7);
- // welcome_pack_sent / onboarding_notes are `local` fields — they persist in the
- // Onboard activity submission (audit feed), not the lead recordview. Pull them
- // from the audit entry so Customer 360 can show the onboarding outcome.
+ // 5d onboarding outcome: welcome_email_status + policy_doc are written by the
+ // Onboard trigger onto the lead record. onboarding_notes is still a `local`
+ // activity field, so it lives in the audit feed, not the recordview.
const onboard = audit.find((e) => e.activity_id === ONBOARD_ACTIVITY_UID)?.data;
- const onboarded = !!onboard;
- const welcomePackSent = onboard?.welcome_pack_sent === true;
+ const welcomeStatus =
+ typeof record.welcome_email_status === 'string' ? record.welcome_email_status : null;
+ const policyDocUrl = record.policy_doc?.[0]?.url ?? null;
+ const onboarded = !!onboard || !!welcomeStatus || !!policyDocUrl;
+ const welcomeSent = welcomeStatus === 'sent';
+ const welcomeFailed = welcomeStatus === 'failed';
const onboardingNotes =
typeof onboard?.onboarding_notes === 'string' ? onboard.onboarding_notes : '';
const issueDate = record.policy_issue_date ? new Date(record.policy_issue_date) : null;
@@ -142,7 +146,7 @@ export function Lead360Screen() {
: null;
return (
-
+
{/* LEFT — profile */}
@@ -183,8 +187,9 @@ export function Lead360Screen() {
- {/* CENTER — workflow + current activity */}
-
+ {/* CENTER — workflow + current activity. min-w-0 lets the 1fr track shrink
+ instead of forcing the grid wider than its container. */}
+
@@ -224,8 +229,8 @@ export function Lead360Screen() {
- {welcomePackSent ? 'Onboarded' : 'Pending pack'}
+
+ {welcomeSent ? 'Welcome sent' : welcomeFailed ? 'Welcome failed' : 'Welcome pending'}
}
>
@@ -242,15 +247,30 @@ export function Lead360Screen() {
- {welcomePackSent ? (
+ {welcomeSent ? (
+ ) : welcomeFailed ? (
+
) : (
)}
- Welcome pack {welcomePackSent ? 'sent' : 'not sent yet'}
+ Welcome email {welcomeSent ? 'sent' : welcomeFailed ? 'failed to send' : 'pending'}
+ {policyDocUrl && (
+
+ )}
{onboardingNotes && (
Onboarding notes
@@ -279,35 +299,13 @@ export function Lead360Screen() {
- {/* RIGHT — AI decision stream + escalations */}
-
-
-
AI decision stream
- {decisions.length} actions
-
-
- {escalations.length > 0 && (
-
-
-
-
- {escalations.length} escalation{escalations.length > 1 ? 's' : ''} need you
-
-
- {escalations[0].employee} · confidence {Math.round(escalations[0].confidence * 100)}%.
-
-
-
- )}
-
- {decisionsQ.loading &&
Loading decisions…
}
- {!decisionsQ.loading && decisions.length === 0 && (
-
No AI decisions for this lead yet.
- )}
- {decisions.map((d, i) => (
-
- ))}
-
+ {/* RIGHT — AI decision stream + escalations.
+ At lg it drops to a full-width row beneath profile + center; at xl it's the 3rd column. */}
+
);
}
diff --git a/src/screens/NewLeadScreen.tsx b/src/screens/NewLeadScreen.tsx
index 319957d..a6289cb 100644
--- a/src/screens/NewLeadScreen.tsx
+++ b/src/screens/NewLeadScreen.tsx
@@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom';
import { Card } from '../components';
import { ActivityForm } from '../components/form';
import { useLeads } from '../api/leads';
-import { ACTIVITY_DEFS } from '../api/forms';
+import { ACTIVITY_META } from '../api/forms';
/** Capture Lead — the INIT activity. Creates a new instance via /start. */
export function NewLeadScreen() {
@@ -13,7 +13,7 @@ export function NewLeadScreen() {
{
refetch();
diff --git a/src/screens/PipelineScreen.tsx b/src/screens/PipelineScreen.tsx
index df49947..44f0248 100644
--- a/src/screens/PipelineScreen.tsx
+++ b/src/screens/PipelineScreen.tsx
@@ -226,14 +226,14 @@ export function PipelineScreen() {
)}
{/* KPI row */}
-
+
{kpis.map((k, i) => (
))}
{/* charts */}
-
+
Live}>
@@ -264,25 +264,27 @@ export function PipelineScreen() {
{loading ? (
Loading leads…
) : view === 'table' ? (
-
-
+
+
+ Lead
+ Score
+ Segment
+ Channel
+ Owner
+ Last action
+
+ {leads.length ? (
+ leads.map((l) =>
openLead(l)} />)
+ ) : (
+ No leads yet.
)}
- >
- Lead
- Score
- Segment
- Channel
- Owner
- Last action
- {leads.length ? (
- leads.map((l) =>
openLead(l)} />)
- ) : (
- No leads yet.
- )}
) : (
diff --git a/src/screens/RecommendScreen.tsx b/src/screens/RecommendScreen.tsx
index ed6e7f8..5e23258 100644
--- a/src/screens/RecommendScreen.tsx
+++ b/src/screens/RecommendScreen.tsx
@@ -12,10 +12,13 @@ import {
RupeeAmount,
Select,
} from '../components';
+import { LookupField } from '../components/form';
+import type { LookupValue } from '../components/form';
import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads';
-import { decisionToCard } from '../api/adapters';
-import { ACTIVITIES, FREQUENCY_OPTIONS, PRODUCT_OPTIONS, RIDER_OPTIONS } from '../api/config';
+import { decisionToCard, regionOf } from '../api/adapters';
+import { fieldFromSchema } from '../api/schema';
+import { ACTIVITIES } from '../api/config';
import type { LeadRecord } from '../api/types';
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
@@ -52,6 +55,13 @@ export function RecommendScreen() {
const { records, refetch } = useLeads();
+ // Field config (product lookup template, frequency + rider options) from the
+ // live form schema, not hardcoded.
+ const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid), []);
+ const productField = useMemo(() => fieldFromSchema(schemaQ.data, F.product), [schemaQ.data]);
+ const freqOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumFrequency)?.options ?? [], [schemaQ.data]);
+ const riderOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.riders)?.options ?? [], [schemaQ.data]);
+
// Leads where Recommend Product is valid (state = Meeting Scheduled).
const eligible = useMemo(
() => records.filter((r) => r.current_state_name === 'Meeting Scheduled'),
@@ -70,7 +80,7 @@ export function RecommendScreen() {
);
// Form state — seeded from the instance's existing values when present.
- const [product, setProduct] = useState('term');
+ const [product, setProduct] = useState
(null);
const [sum, setSum] = useState(2000000);
const [freq, setFreq] = useState('annual');
const [premium, setPremium] = useState(0);
@@ -80,7 +90,7 @@ export function RecommendScreen() {
useEffect(() => {
if (!record) return;
- setProduct(record.recommended_product || 'term');
+ setProduct((record.recommended_product as LookupValue) ?? null);
setSum(record.sum_assured || 2000000);
setFreq(record.premium_frequency || 'annual');
setRiders(toRiderValues(record.riders));
@@ -100,6 +110,10 @@ export function RecommendScreen() {
async function submit() {
if (!record) return;
+ if (!product) {
+ setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
+ return;
+ }
setSubmitting(true);
setResult(null);
try {
@@ -124,8 +138,8 @@ export function RecommendScreen() {
const incomeMultiple = lead?.annual_income ? (sum / lead.annual_income).toFixed(1) : '—';
return (
-
-
+
+
{/* context strip + lead picker */}
{lead ? (
@@ -136,7 +150,7 @@ export function RecommendScreen() {
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
- {lead.assigned_region ?? lead.city ?? '—'} · {lead.product_interest ?? 'No stated interest'} · income ₹
+ {regionOf(lead.assigned_region) ?? lead.city ?? '—'} · {lead.product_interest ?? 'No stated interest'} · income ₹
{formatINR(lead.annual_income ?? 0)}
@@ -184,12 +198,20 @@ export function RecommendScreen() {
-