schema-driven forms + tier-1 fixes

This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-23 11:20:31 +05:30
parent 714f8ab8ff
commit f35e0ce5d2
31 changed files with 1957 additions and 517 deletions

2
.gitignore vendored
View File

@ -17,3 +17,5 @@ yarn-error.log*
# Local env (base URL etc.) # Local env (base URL etc.)
.env .env
*.tsbuildinfo

View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Build the ABSLI policy-schedule / welcome-kit .docx template for app 385.
Dependency-free: writes a minimal-but-valid WordprocessingML package by hand.
Each {{placeholder}} is emitted as ONE contiguous run so docx-service's
placeholder substitution resolves it cleanly (it also handles fragmented runs,
but single runs are the safe path).
Output: ai-employee-leadtopolicy/policy_schedule.docx
Upload it via Studio > Templates (app 385); docx-service substitutes the
{{vars}} mapped in the 44d generateDoc node.
Placeholders (all sourced from companion tbl_wf_385_lead_to_policy at 44d):
policy_number lead_name plan_name sum_assured premium_amount
premium_frequency policy_term commencement_date maturity_date
advisor_name issue_date
"""
import os
import zipfile
from xml.sax.saxutils import escape
OUT = os.path.join(os.path.dirname(__file__), "..", "..",
".supacode", "repos", "sm2", "lead-to-policy",
"ai-employee-leadtopolicy", "policy_schedule.docx")
# When run from the aria-console scripts/ dir the relative hop above is brittle;
# allow an explicit override.
OUT = os.environ.get("POLICY_DOCX_OUT", OUT)
NSW = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
# ---- run / paragraph helpers -------------------------------------------------
def run(text, *, bold=False, size=None, color=None):
rpr = ""
props = []
if bold:
props.append("<w:b/>")
if size: # half-points
props.append(f'<w:sz w:val="{size}"/><w:szCs w:val="{size}"/>')
if color:
props.append(f'<w:color w:val="{color}"/>')
if props:
rpr = "<w:rPr>" + "".join(props) + "</w:rPr>"
return (f'<w:r>{rpr}<w:t xml:space="preserve">{escape(text)}</w:t></w:r>')
def para(runs, *, align=None, space_after=120, shading=None):
ppr_bits = []
if align:
ppr_bits.append(f'<w:jc w:val="{align}"/>')
if shading:
ppr_bits.append(f'<w:shd w:val="clear" w:color="auto" w:fill="{shading}"/>')
ppr_bits.append(f'<w:spacing w:after="{space_after}"/>')
ppr = "<w:pPr>" + "".join(ppr_bits) + "</w:pPr>"
body = runs if isinstance(runs, str) else "".join(runs)
return f"<w:p>{ppr}{body}</w:p>"
def cell(text_runs, *, w=4500, fill=None):
shd = f'<w:shd w:val="clear" w:color="auto" w:fill="{fill}"/>' if fill else ""
tcpr = f'<w:tcPr><w:tcW w:w="{w}" w:type="dxa"/>{shd}</w:tcPr>'
return f"<w:tc>{tcpr}{para(text_runs, space_after=40)}</w:tc>"
def kv_row(label, placeholder):
left = cell([run(label, bold=True, size=20, color="475569")], w=3600, fill="F1F5F9")
right = cell([run("{{" + placeholder + "}}", size=20, color="0F172A")], w=5400)
return f"<w:tr>{left}{right}</w:tr>"
def table(rows):
borders = (
"<w:tblBorders>"
'<w:top w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:left w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:bottom w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:right w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:insideH w:val="single" w:sz="4" w:color="E2E8F0"/>'
'<w:insideV w:val="single" w:sz="4" w:color="E2E8F0"/>'
"</w:tblBorders>"
)
tblpr = f'<w:tblPr><w:tblW w:w="9000" w:type="dxa"/>{borders}</w:tblPr>'
grid = '<w:tblGrid><w:gridCol w:w="3600"/><w:gridCol w:w="5400"/></w:tblGrid>'
return f"<w:tbl>{tblpr}{grid}{''.join(rows)}</w:tbl>"
# ---- document body -----------------------------------------------------------
body_parts = [
para([run("Aditya Birla Sun Life Insurance", bold=True, size=32, color="9A1F40")],
align="center", space_after=40),
para([run("Policy Schedule & Welcome Kit", bold=True, size=24, color="475569")],
align="center", space_after=240),
para([run("Dear ", size=22),
run("{{lead_name}}", bold=True, size=22),
run(",", size=22)]),
para([run(
"Welcome to the Aditya Birla Sun Life Insurance family. We are delighted to "
"confirm that your policy has been issued. Please find your policy schedule "
"below. Kindly retain this document for your records.", size=22)],
space_after=240),
para([run("Policy Schedule", bold=True, size=24, color="9A1F40")], space_after=120),
table([
kv_row("Policy Number", "policy_number"),
kv_row("Plan", "plan_name"),
kv_row("Life Assured", "lead_name"),
kv_row("Sum Assured (INR)", "sum_assured"),
kv_row("Premium (INR)", "premium_amount"),
kv_row("Premium Frequency", "premium_frequency"),
kv_row("Policy Term (years)", "policy_term"),
kv_row("Risk Commencement Date", "commencement_date"),
kv_row("Maturity Date", "maturity_date"),
kv_row("Date of Issue", "issue_date"),
kv_row("Your Advisor", "advisor_name"),
]),
para([run("", size=22)], space_after=200),
para([run(
"This is a computer-generated welcome document. The benefits, terms and "
"conditions are governed by the policy contract. For any assistance, please "
"contact your advisor named above or reach us at care.abslifeinsurance@adityabirlacapital.com.",
size=18, color="64748B")], space_after=240),
para([run("Warm regards,", size=22)], space_after=40),
para([run("Team Aditya Birla Sun Life Insurance", bold=True, size=22)]),
]
document_xml = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
f'<w:document xmlns:w="{NSW}">'
"<w:body>"
+ "".join(body_parts)
+ '<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>'
'<w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134"'
' w:header="709" w:footer="709" w:gutter="0"/></w:sectPr>'
"</w:body></w:document>"
)
CONTENT_TYPES = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
"</Types>"
)
RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="word/document.xml"/>'
"</Relationships>"
)
DOC_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
"</Relationships>"
)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", CONTENT_TYPES)
z.writestr("_rels/.rels", RELS)
z.writestr("word/document.xml", document_xml)
z.writestr("word/_rels/document.xml.rels", DOC_RELS)
print("wrote", os.path.abspath(OUT))

View File

@ -0,0 +1,64 @@
// Runnable assertions for src/lib/kyc-match.ts (no test runner is configured).
// node scripts/kyc-match.check.ts
// Lives outside src/ so it isn't part of the `tsc -b` app build.
import assert from 'node:assert/strict';
import { crossCheck, dobVerdict, nameVerdict, normName } from '../src/lib/kyc-match.ts';
let n = 0;
const ok = (label: string, cond: boolean) => {
n++;
if (!cond) {
console.error('FAIL:', label);
process.exitCode = 1;
}
};
// normName: order-insensitive, honorific/punct strip
assert.equal(normName('Mr. Ravi Kumar'), 'kumar ravi');
assert.equal(normName('KUMAR, RAVI'), 'kumar ravi');
// name: exact / case / order → match
ok('exact name', nameVerdict('Ravi Kumar', 'ravi kumar').status === 'match');
ok('reordered name', nameVerdict('Ravi Kumar', 'Kumar Ravi').status === 'match');
// initials → match
ok('initial expands', nameVerdict('R Kumar', 'Ravi Kumar').status === 'match');
ok('middle initial', nameVerdict('Ravi K Sharma', 'Ravi Kumar Sharma').status === 'match');
// typo / wrong person → mismatch
ok('typo name', nameVerdict('Rabi Kumar', 'Ravi Kumar').status === 'mismatch');
ok('wrong person', nameVerdict('Ravi Kumar', 'Suresh Patel').status === 'mismatch');
ok('extra surname missing', nameVerdict('Ravi', 'Ravi Kumar').status === 'mismatch');
// empty side → unknown
ok('empty ocr', nameVerdict('Ravi Kumar', '').status === 'unknown');
ok('empty captured', nameVerdict('', 'Ravi Kumar').status === 'unknown');
// DOB: captured IST-midnight instant ("…T18:30Z" → 1990-05-15 IST) vs OCR YYYY-MM-DD
// (literal compare, no tz shift). Normalization is pinned to Asia/Kolkata.
ok('dob match', dobVerdict('1990-05-14T18:30:00.000Z', '1990-05-15') === 'match');
ok('dob mismatch day', dobVerdict('1990-05-14T18:30:00.000Z', '1990-05-16') === 'mismatch');
ok('dob mismatch year', dobVerdict('1990-05-14T18:30:00.000Z', '1991-05-15') === 'mismatch');
// DD/MM/YYYY as a card may print
ok('dob dd/mm/yyyy', dobVerdict('1990-05-14T18:30:00.000Z', '15/05/1990') === 'match');
// null / unparseable → unknown
ok('dob null', dobVerdict('1990-05-14T18:30:00.000Z', null) === 'unknown');
ok('dob garbage', dobVerdict('1990-05-14T18:30:00.000Z', 'unknown') === 'unknown');
// aggregate
const clean = crossCheck(
{ name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' },
{ pan: { name: 'RAVI KUMAR', dob: '1990-05-15' }, aadhaar: { name: 'Ravi Kumar', dob: '1990-05-15' } },
);
ok('clean: 4 checked', clean.fields.length === 4);
ok('clean: no mismatch', !clean.hasMismatch && clean.anyChecked);
const bad = crossCheck(
{ name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' },
{ pan: { name: 'Ravi Kumar', dob: '1990-05-15' }, aadhaar: { name: 'Suresh Patel', dob: '1985-01-01' } },
);
ok('bad: hasMismatch', bad.hasMismatch);
ok('bad: 2 aadhaar mismatches', bad.fields.filter((f) => f.status === 'mismatch').length === 2);
// nothing OCR'd yet
const empty = crossCheck({ name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' }, {});
ok('empty: nothing checked', !empty.anyChecked && !empty.hasMismatch);
if (!process.exitCode) console.log(`kyc-match: all checks passed (${n} cases)`);

View File

@ -6,6 +6,8 @@ import { Lead360Screen } from './screens/Lead360Screen';
import { AssignScreen } from './screens/AssignScreen'; import { AssignScreen } from './screens/AssignScreen';
import { RecommendScreen } from './screens/RecommendScreen'; import { RecommendScreen } from './screens/RecommendScreen';
import { DocumentsScreen } from './screens/DocumentsScreen'; import { DocumentsScreen } from './screens/DocumentsScreen';
import { UnderwritingScreen } from './screens/UnderwritingScreen';
import { IssuePolicyScreen } from './screens/IssuePolicyScreen';
import { NewLeadScreen } from './screens/NewLeadScreen'; import { NewLeadScreen } from './screens/NewLeadScreen';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
import { useZino } from './api/provider'; import { useZino } from './api/provider';
@ -39,6 +41,8 @@ export function App() {
<Route path="assign" element={<AssignScreen />} /> <Route path="assign" element={<AssignScreen />} />
<Route path="recommend" element={<RecommendScreen />} /> <Route path="recommend" element={<RecommendScreen />} />
<Route path="documents" element={<DocumentsScreen />} /> <Route path="documents" element={<DocumentsScreen />} />
<Route path="underwriting" element={<UnderwritingScreen />} />
<Route path="issue" element={<IssuePolicyScreen />} />
<Route path="*" element={<Navigate to="/pipeline" replace />} /> <Route path="*" element={<Navigate to="/pipeline" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -61,6 +61,41 @@ function toChannel(raw?: string | null): Channel {
return CHANNELS[(raw ?? '').toLowerCase()] ?? 'Web'; return CHANNELS[(raw ?? '').toLowerCase()] ?? 'Web';
} }
// Several recordview fields arrive as structured objects, not scalars. These
// flatten each to a display string; screens must route reads through them
// rather than rendering the raw value (a raw object crashes React).
// Phone from the phone-input widget: {dial_code, phone, phone_with_dial_code}.
function toPhone(raw: unknown): string {
if (typeof raw === 'string') return raw;
if (raw && typeof raw === 'object') {
const o = raw as { phone_with_dial_code?: string; phone?: string };
return o.phone_with_dial_code ?? o.phone ?? '—';
}
return '—';
}
// assigned_region is a tbl_branches lookup: {region}. Returns undefined when
// absent so it composes with `??` fallback chains.
export function regionOf(raw: unknown): string | undefined {
if (typeof raw === 'string') return raw || undefined;
if (raw && typeof raw === 'object') {
return (raw as { region?: string }).region || undefined;
}
return undefined;
}
// recommended_product is a product-catalog lookup; plan_name is its display
// column (older rows may carry a {name} or a plain string).
export function productOf(raw: unknown): string | undefined {
if (typeof raw === 'string') return raw || undefined;
if (raw && typeof raw === 'object') {
const o = raw as { plan_name?: string; name?: string };
return o.plan_name ?? o.name ?? undefined;
}
return undefined;
}
// state -> a friendly "last action" line + tone for the pipeline list. // state -> a friendly "last action" line + tone for the pipeline list.
const STATE_ACTION: Record<string, { action: string; tone: Tone }> = { const STATE_ACTION: Record<string, { action: string; tone: Tone }> = {
'New Lead': { action: 'Awaiting qualification', tone: 'neutral' }, 'New Lead': { action: 'Awaiting qualification', tone: 'neutral' },
@ -87,8 +122,8 @@ export function recordToLead(r: LeadRecord): Lead {
return { return {
id: String(r.instance_id), id: String(r.instance_id),
name: r.lead_name ?? `Lead ${r.instance_id}`, name: r.lead_name ?? `Lead ${r.instance_id}`,
city: r.city ?? r.state_region ?? r.assigned_region ?? '—', city: r.city ?? r.state_region ?? regionOf(r.assigned_region) ?? '—',
phone: r.phone ?? '—', phone: toPhone(r.phone),
email: r.email ?? '—', email: r.email ?? '—',
dob: formatDate(r.date_of_birth), dob: formatDate(r.date_of_birth),
age: ageFrom(r.date_of_birth), age: ageFrom(r.date_of_birth),

View File

@ -3,6 +3,7 @@ import type {
AiDecision, AiDecision,
ApiError, ApiError,
AuditEntry, AuditEntry,
FormScreenResponse,
LoginResponse, LoginResponse,
RecordViewParams, RecordViewParams,
RecordViewResponse, RecordViewResponse,
@ -163,6 +164,18 @@ export class ZinoClient {
).then((r) => r.decisions ?? []); ).then((r) => r.decisions ?? []);
} }
// --- Form schema (view-service) ---
/** Live activity form config fields, types, options, lookup/ocr config and
* the designer layout. The single source of truth for rendering a form. */
formSchema(activityId: string, instanceId?: number | string): Promise<FormScreenResponse> {
return this.request<FormScreenResponse>('POST', `/app/${APP_ID}/view/form-screens`, {
activity_id: activityId,
device_type: 'desktop',
...(instanceId != null ? { instance_id: instanceId } : {}),
});
}
// --- Workflow execution (core) --- // --- Workflow execution (core) ---
startInstance(activityUid: string, data: Record<string, unknown>): Promise<ActivityResult> { startInstance(activityUid: string, data: Record<string, unknown>): Promise<ActivityResult> {

View File

@ -15,9 +15,10 @@ export const DETAIL_VIEW_IDS = {
LEAD: 'aa96f572-b480-4623-ae9c-9ce0c44b8626', LEAD: 'aa96f572-b480-4623-ae9c-9ce0c44b8626',
} as const; } as const;
// Onboard activity uid — its submitted fields (welcome_pack_sent, onboarding_notes) // Onboard activity uid. The trigger writes welcome_email_status + policy_doc
// are `local`, so they never reach the lead recordview. They DO persist in the // onto the lead record (read from the recordview). onboarding_notes stays a
// activity submission, surfaced via the audit feed; Customer 360 reads them there. // `local` field — it persists only in the activity submission, surfaced via the
// audit feed, where Customer 360 reads it. (welcome_pack_sent is dead.)
export const ONBOARD_ACTIVITY_UID = 'ae415f4e-a33e-432e-882f-733238de8bcc'; export const ONBOARD_ACTIVITY_UID = 'ae415f4e-a33e-432e-882f-733238de8bcc';
// Activity uids + their field ids (data{} keys). From introspect. // Activity uids + their field ids (data{} keys). From introspect.
@ -25,7 +26,7 @@ export const ACTIVITIES = {
RECOMMEND_PRODUCT: { RECOMMEND_PRODUCT: {
uid: 'c0e2004e-2d70-45f2-9cd5-734331010906', uid: 'c0e2004e-2d70-45f2-9cd5-734331010906',
fields: { fields: {
product: 'recommended_product_input', // select product: 'recommended_product_input', // lookup → tbl_products (see PRODUCT_LOOKUP)
sumAssured: 'sum_assured_input', // number sumAssured: 'sum_assured_input', // number
premiumAmount: 'premium_amount_input', // number premiumAmount: 'premium_amount_input', // number
premiumFrequency: 'premium_frequency_input', // select premiumFrequency: 'premium_frequency_input', // select
@ -45,38 +46,27 @@ export const ACTIVITIES = {
docStatus: 'doc_status_input', // select (req) docStatus: 'doc_status_input', // select (req)
}, },
}, },
SUBMIT_UNDERWRITING: {
uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730',
fields: {
status: 'underwriting_status_input', // select (req) — the verdict
ref: 'underwriting_ref_input', // id_gen (backend-generated UW-YYYY-####; do NOT send)
},
},
ISSUE_POLICY: {
uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489',
fields: {
issueDate: 'policy_issue_date_input', // date (req)
term: 'policy_term_input', // number (years)
maturity: 'maturity_date_input', // date (computed = issue + term)
premium: 'premium_amount_confirm', // number
number: 'policy_number_input', // id_gen (backend-generated POL-YYYY-####; do NOT send)
},
},
} as const; } as const;
// Allowed select/multiselect values (label -> stored value). // Select options + lookup templates are no longer hardcoded here — screens
export const PRODUCT_OPTIONS = [ // read them live from the form schema (api/schema.ts → fieldFromSchema).
{ label: 'Term', value: 'term' },
{ label: 'Whole Life', value: 'whole_life' },
{ label: 'ULIP', value: 'ulip' },
{ label: 'Endowment', value: 'endowment' },
{ label: 'Child', value: 'child' },
{ label: 'Pension', value: 'pension' },
] as const;
export const FREQUENCY_OPTIONS = [
{ label: 'Monthly', value: 'monthly' },
{ label: 'Quarterly', value: 'quarterly' },
{ label: 'Half-Yearly', value: 'half_yearly' },
{ label: 'Annual', value: 'annual' },
] as const;
export const RIDER_OPTIONS = [
{ label: 'Critical Illness', value: 'critical_illness' },
{ label: 'Accidental Death', value: 'accidental_death' },
{ label: 'Waiver of Premium', value: 'waiver_premium' },
{ label: 'Income Benefit', value: 'income_benefit' },
] as const;
export const DOC_STATUS_OPTIONS = [
{ label: 'Pending', value: 'pending' },
{ label: 'Verified', value: 'verified' },
{ label: 'Mismatch', value: 'mismatch' },
{ label: 'Incomplete', value: 'incomplete' },
] as const;
// Canonical ordered lifecycle — matches live `current_state_name` 1:1 so a // Canonical ordered lifecycle — matches live `current_state_name` 1:1 so a
// state name maps straight to a stepper/kanban index. Terminal "Disqualified" // state name maps straight to a stepper/kanban index. Terminal "Disqualified"

View File

@ -1,6 +1,6 @@
// Declarative activity-form specs for app 385, mirrored 1:1 from the deployed // Activity metadata + the per-state "next action" map. Field schemas are no
// workflow config (`activity_data_fields`). Drives the generic <ActivityForm> // longer mirrored here — <ActivityForm> fetches them live from the form-screens
// and the contextual "Next action" panel on Lead 360. // endpoint (see api/schema.ts), so the form layout never drifts from config.
export type FieldType = export type FieldType =
| 'text' | 'text'
@ -27,6 +27,8 @@ export interface OcrMapping {
target_field: string; target_field: string;
} }
/** A renderable form field. Built at runtime by api/schema.ts from the live
* form-screens config; consumed by <ActivityForm> and the bespoke screens. */
export interface FieldDef { export interface FieldDef {
id: string; id: string;
label: string; label: string;
@ -46,240 +48,80 @@ export interface FieldDef {
ocrMappings?: OcrMapping[]; ocrMappings?: OcrMapping[];
} }
export interface ActivityDef { /** Activity display copy + uid. Fields come from the schema, not from here. */
export interface ActivityMeta {
uid: string; uid: string;
name: string; name: string;
submitLabel: string; submitLabel: string;
/** Past-tense confirmation, e.g. "advanced to Qualified". */ /** Past-tense confirmation, e.g. "advanced to Qualified". */
done: string; done: string;
fields: FieldDef[];
} }
const CHANNEL_OPTIONS: FieldOption[] = [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'web', label: 'Web' },
{ value: 'referral', label: 'Referral' },
{ value: 'agency', label: 'Agency' },
{ value: 'bancassurance', label: 'Bancassurance' },
{ value: 'direct', label: 'Direct' },
{ value: 'event', label: 'Event' },
];
const LANGUAGE_OPTIONS: FieldOption[] = [
{ value: 'english', label: 'English' },
{ value: 'hindi', label: 'Hindi' },
{ value: 'tamil', label: 'Tamil' },
{ value: 'telugu', label: 'Telugu' },
{ value: 'kannada', label: 'Kannada' },
{ value: 'marathi', label: 'Marathi' },
{ value: 'bengali', label: 'Bengali' },
];
const PRODUCT_OPTIONS: FieldOption[] = [
{ value: 'term', label: 'Term' },
{ value: 'whole_life', label: 'Whole Life' },
{ value: 'ulip', label: 'ULIP' },
{ value: 'endowment', label: 'Endowment' },
{ value: 'child', label: 'Child' },
{ value: 'pension', label: 'Pension' },
];
// Activity keys are stable, human-readable handles used by STATE_NEXT. // Activity keys are stable, human-readable handles used by STATE_NEXT.
export const ACTIVITY_DEFS = { export const ACTIVITY_META = {
capture: { capture: {
uid: 'cc1a73d5-61e1-4416-af30-2b1eeb623a58', uid: 'cc1a73d5-61e1-4416-af30-2b1eeb623a58',
name: 'Capture Lead', name: 'Capture Lead',
submitLabel: 'Create lead', submitLabel: 'Create lead',
done: 'created — added to the pipeline as a New Lead', done: 'created — added to the pipeline as a New Lead',
fields: [
{ id: 'lead_name_input', label: 'Lead name', type: 'text', required: true, placeholder: 'Full name' },
{ id: 'phone_input', label: 'Phone', type: 'phone', required: true, placeholder: '+91 …' },
{ id: 'email_input', label: 'Email', type: 'email', placeholder: 'name@example.com' },
{ id: 'date_of_birth_input', label: 'Date of birth', type: 'date', required: true },
{ id: 'annual_income_input', label: 'Annual income', type: 'number', required: true, prefix: '₹' },
{ id: 'occupation_input', label: 'Occupation', type: 'text' },
{ id: 'city_input', label: 'City', type: 'text' },
{ id: 'state_region_input', label: 'State / region', type: 'text' },
{ id: 'preferred_language_input', label: 'Preferred language', type: 'select', options: LANGUAGE_OPTIONS },
{ id: 'product_interest_input', label: 'Product interest', type: 'select', options: PRODUCT_OPTIONS },
{ id: 'channel_source_input', label: 'Channel source', type: 'select', required: true, options: CHANNEL_OPTIONS },
],
}, },
qualify: { qualify: {
uid: '6b741e44-37f2-4bb0-90ab-ed139f03b7b3', uid: '6b741e44-37f2-4bb0-90ab-ed139f03b7b3',
name: 'Qualify Lead', name: 'Qualify Lead',
submitLabel: 'Qualify lead', submitLabel: 'Qualify lead',
done: 'advanced to Qualified', done: 'advanced to Qualified',
fields: [
{ id: 'lead_score_input', label: 'Lead score', type: 'number', required: true, placeholder: '0100' },
{
id: 'lead_segment_input',
label: 'Lead segment',
type: 'select',
required: true,
options: [
{ value: 'hot', label: 'Hot' },
{ value: 'warm', label: 'Warm' },
{ value: 'cold', label: 'Cold' },
],
},
{ id: 'annual_income_input', label: 'Annual income', type: 'number', required: true, prefix: '₹' },
{ id: 'date_of_birth_input', label: 'Date of birth', type: 'date', required: true },
{ id: 'qualify_notes', label: 'Qualification notes', type: 'longtext' },
],
}, },
assign: { assign: {
uid: 'da61aaa1-003b-4601-9f42-f93ff230497f', uid: 'da61aaa1-003b-4601-9f42-f93ff230497f',
name: 'Assign Lead', name: 'Assign Lead',
submitLabel: 'Assign lead', submitLabel: 'Assign lead',
done: 'advanced to Assigned', done: 'advanced to Assigned',
fields: [
{ id: 'assigned_region_input', label: 'Assigned region', type: 'text', required: true, placeholder: 'e.g. West / Maharashtra' },
{
id: 'owner_agent_input',
label: 'Owner agent',
type: 'lookup',
seedKey: 'owner_agent',
lookupTemplate: '6ad51dbe-f0b7-4b78-a5f0-00555fac1597',
lookupDisplay: ['name', 'region'],
lookupStorage: ['id', 'name'],
},
],
}, },
contact: { contact: {
uid: 'fea6b3a8-846d-4f6f-8e92-033c4cf4b6e2', uid: 'fea6b3a8-846d-4f6f-8e92-033c4cf4b6e2',
name: 'Log First Contact', name: 'Log First Contact',
submitLabel: 'Log contact', submitLabel: 'Log contact',
done: 'advanced to Contacted', done: 'advanced to Contacted',
fields: [
{
id: 'contact_outcome_input',
label: 'Contact outcome',
type: 'select',
required: true,
options: [
{ value: 'connected', label: 'Connected' },
{ value: 'no_answer', label: 'No Answer' },
{ value: 'call_back', label: 'Call Back' },
{ value: 'not_interested', label: 'Not Interested' },
{ value: 'wrong_number', label: 'Wrong Number' },
],
},
{ id: 'next_followup_at_input', label: 'Next follow-up at', type: 'datetime' },
{ id: 'contact_notes_input', label: 'Contact notes', type: 'longtext' },
],
}, },
schedule: { schedule: {
uid: 'c444d32f-ed6a-4e36-b8b7-b82e73a141d4', uid: 'c444d32f-ed6a-4e36-b8b7-b82e73a141d4',
name: 'Schedule Meeting', name: 'Schedule Meeting',
submitLabel: 'Schedule meeting', submitLabel: 'Schedule meeting',
done: 'advanced to Meeting Scheduled', done: 'advanced to Meeting Scheduled',
fields: [
{ id: 'meeting_datetime_input', label: 'Meeting date & time', type: 'datetime', required: true },
{
id: 'meeting_mode_input',
label: 'Meeting mode',
type: 'select',
required: true,
options: [
{ value: 'branch', label: 'Branch' },
{ value: 'video', label: 'Video' },
{ value: 'home_visit', label: 'Home Visit' },
{ value: 'phone', label: 'Phone' },
],
},
{ id: 'meeting_address_input', label: 'Meeting address', type: 'text', placeholder: 'Branch / link / address' },
],
}, },
assess: { assess: {
uid: 'ba3d3e7f-0d3f-49f2-a156-6cace33c228a', uid: 'ba3d3e7f-0d3f-49f2-a156-6cace33c228a',
name: 'Assess Eligibility', name: 'Assess Eligibility',
submitLabel: 'Submit assessment', submitLabel: 'Submit assessment',
done: 'advanced to Eligibility Assessed', done: 'advanced to Eligibility Assessed',
fields: [
{
id: 'eligibility_status_input',
label: 'Eligibility status',
type: 'select',
required: true,
options: [
{ value: 'Eligible', label: 'Eligible' },
{ value: 'Refer', label: 'Refer' },
{ value: 'Ineligible', label: 'Ineligible' },
],
},
{ id: 'eligibility_notes_input', label: 'Eligibility notes', type: 'longtext' },
],
}, },
underwrite: { underwrite: {
uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730', uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730',
name: 'Submit Underwriting', name: 'Submit Underwriting',
submitLabel: 'Submit underwriting', submitLabel: 'Submit underwriting',
done: 'advanced to Underwriting', done: 'advanced to Underwriting',
fields: [
{ id: 'underwriting_ref_input', label: 'Underwriting reference', type: 'text', required: true, placeholder: 'UW-…' },
{
id: 'underwriting_status_input',
label: 'Underwriting status',
type: 'select',
required: true,
options: [
{ value: 'submitted', label: 'Submitted' },
{ value: 'approved', label: 'Approved' },
{ value: 'counter_offer', label: 'Counter-Offer' },
{ value: 'declined', label: 'Declined' },
{ value: 'referred', label: 'Referred' },
],
},
],
}, },
issue: { issue: {
uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489', uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489',
name: 'Issue Policy', name: 'Issue Policy',
submitLabel: 'Issue policy', submitLabel: 'Issue policy',
done: 'advanced to Policy Issued', done: 'advanced to Policy Issued',
fields: [
{ id: 'policy_number_input', label: 'Policy number', type: 'text', required: true, placeholder: 'POL-…' },
{ id: 'policy_issue_date_input', label: 'Policy issue date', type: 'date', required: true },
{ id: 'premium_amount_confirm', label: 'Premium amount', type: 'number', prefix: '₹', seedKey: 'premium_amount' },
],
}, },
onboard: { onboard: {
uid: 'ae415f4e-a33e-432e-882f-733238de8bcc', uid: 'ae415f4e-a33e-432e-882f-733238de8bcc',
name: 'Onboard Customer', name: 'Onboard Customer',
submitLabel: 'Complete onboarding', submitLabel: 'Complete onboarding',
done: 'advanced to Customer 360', done: 'advanced to Customer 360',
fields: [
{ id: 'welcome_pack_sent', label: 'Welcome pack sent', type: 'boolean' },
{ id: 'onboarding_notes', label: 'Onboarding notes', type: 'longtext' },
],
}, },
disqualify: { disqualify: {
uid: 'ea99fc60-caba-45c1-afc1-7386a2fa9220', uid: 'ea99fc60-caba-45c1-afc1-7386a2fa9220',
name: 'Disqualify', name: 'Disqualify',
submitLabel: 'Disqualify lead', submitLabel: 'Disqualify lead',
done: 'moved to Disqualified', done: 'moved to Disqualified',
fields: [
{
id: 'disqualify_reason_input',
label: 'Disqualify reason',
type: 'select',
required: true,
options: [
{ value: 'budget', label: 'Budget' },
{ value: 'not_interested', label: 'Not Interested' },
{ value: 'ineligible', label: 'Ineligible' },
{ value: 'unreachable', label: 'Unreachable' },
{ value: 'duplicate', label: 'Duplicate' },
{ value: 'bought_elsewhere', label: 'Bought Elsewhere' },
],
},
{ id: 'disqualify_notes_input', label: 'Disqualify notes', type: 'longtext' },
],
}, },
} satisfies Record<string, ActivityDef>; } satisfies Record<string, ActivityMeta>;
export type ActivityKey = keyof typeof ACTIVITY_DEFS; export type ActivityKey = keyof typeof ACTIVITY_META;
// OCR field configs for Collect Documents (extraction → mapped target inputs). // OCR field configs for Collect Documents (extraction → mapped target inputs).
export const OCR_FIELDS: Record<string, { id: string; docLabel: string; mappings: OcrMapping[] }> = { export const OCR_FIELDS: Record<string, { id: string; docLabel: string; mappings: OcrMapping[] }> = {
@ -296,8 +138,10 @@ export const OCR_FIELDS: Record<string, { id: string; docLabel: string; mappings
}; };
// Per-state next action. `route` → dedicated rich screen; else inline form. // Per-state next action. `route` → dedicated rich screen; else inline form.
// Routed steps (recommend / documents / underwriting / issue) render their own
// screen, so `activity` is only set when the step renders an inline form.
export interface NextAction { export interface NextAction {
activity: ActivityKey; activity?: ActivityKey;
route?: string; route?: string;
/** Button label for routed steps. */ /** Button label for routed steps. */
label?: string; label?: string;
@ -308,11 +152,11 @@ export const STATE_NEXT: Record<string, NextAction> = {
Qualified: { activity: 'assign', route: '/assign', label: 'Assign lead' }, Qualified: { activity: 'assign', route: '/assign', label: 'Assign lead' },
Assigned: { activity: 'contact' }, Assigned: { activity: 'contact' },
Contacted: { activity: 'schedule' }, Contacted: { activity: 'schedule' },
'Meeting Scheduled': { activity: 'qualify', route: '/recommend', label: 'Recommend product' }, 'Meeting Scheduled': { route: '/recommend', label: 'Recommend product' },
'Product Recommended': { activity: 'qualify', route: '/documents', label: 'Collect documents' }, 'Product Recommended': { route: '/documents', label: 'Collect documents' },
'Documents Collected': { activity: 'assess' }, 'Documents Collected': { activity: 'assess' },
'Eligibility Assessed': { activity: 'underwrite' }, 'Eligibility Assessed': { route: '/underwriting', label: 'Submit underwriting' },
Underwriting: { activity: 'issue' }, Underwriting: { route: '/issue', label: 'Issue policy' },
'Policy Issued': { activity: 'onboard' }, 'Policy Issued': { activity: 'onboard' },
}; };

View File

@ -74,11 +74,14 @@ export interface QueryState<T> {
/** /**
* Fire `fn` whenever a dependency in `deps` changes. Tracks loading/error and * Fire `fn` whenever a dependency in `deps` changes. Tracks loading/error and
* ignores results from a stale call. `enabled=false` short-circuits. * ignores results from a stale call. `enabled=false` short-circuits.
* `refetchMs` (opt-in) polls on that interval; the interval is cleared on
* unmount, dep change, or when disabled.
*/ */
export function useQuery<T>( export function useQuery<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
deps: unknown[], deps: unknown[],
enabled = true, enabled = true,
refetchMs?: number,
): QueryState<T> { ): QueryState<T> {
const [data, setData] = useState<T | null>(null); const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(enabled); const [loading, setLoading] = useState(enabled);
@ -111,5 +114,14 @@ export function useQuery<T>(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, tick, enabled]); }, [...deps, tick, enabled]);
// Opt-in background polling. Kept separate from the fetch effect so the
// interval doesn't restart on every refetch; ticking triggers it instead.
useEffect(() => {
if (!enabled || !refetchMs || refetchMs <= 0) return;
const id = setInterval(() => setTick((t) => t + 1), refetchMs);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, enabled, refetchMs]);
return { data, loading, error, refetch }; return { data, loading, error, refetch };
} }

79
src/api/schema.ts Normal file
View File

@ -0,0 +1,79 @@
// Maps a live form-screens response onto aria's FieldDef shape, so forms render
// from config instead of hand-mirrored defs. The designer's `grid_config`
// decides which fields are user-facing; `fields[]` is the metadata pool.
import type { FieldDef, FieldType } from './forms';
import type { FormScreenField, FormScreenResponse } from './types';
const TYPE_MAP: Record<string, FieldType> = {
text: 'text',
email: 'email',
phone: 'phone',
number: 'number',
date: 'date',
datetime: 'datetime',
longtext: 'longtext',
select: 'select',
multiselect: 'multiselect',
boolean: 'boolean',
lookup: 'lookup',
ocr: 'ocr',
file: 'file',
};
/** One server field FieldDef, or null if it's not a user input (id_gen,
* disabled/computed, or an unknown type). */
function toFieldDef(f: FormScreenField): FieldDef | null {
if (f.properties?.disabled) return null; // server-generated (id_gen)
const type = TYPE_MAP[f.data_type];
if (!type) return null; // id_gen + anything unmapped
const p = f.properties ?? {};
const def: FieldDef = {
id: f.id,
label: f.name,
type,
required: !!f.mandatory,
seedKey: f.mapped_workflow_field ?? f.id.replace(/_input$/, ''),
};
if (type === 'select' || type === 'multiselect') {
def.options = p.options ?? [];
}
if (type === 'lookup' && p.lookup_config) {
def.lookupTemplate = p.lookup_config.rdbms_template_uuid;
def.lookupDisplay = (p.lookup_config.display_columns ?? []).map((c) => c.name);
def.lookupStorage = (p.lookup_config.storage_columns ?? []).map((c) => c.name);
}
if (type === 'ocr' && p.ocr_config) {
def.docLabel = p.ocr_config.template ?? f.name;
def.ocrMappings = p.ocr_config.field_mappings ?? [];
}
return def;
}
/** User-facing fields for a form, in the designer's layout order. */
export function schemaToFields(resp: FormScreenResponse): FieldDef[] {
const byUid = new Map(resp.fields.map((f) => [f.uid, f]));
const grid = (resp.grid_config ?? []).filter((g) => g.type === 'field');
const ordered: FormScreenField[] = grid.length
? grid
.slice()
.sort((a, b) => a.y - b.y || a.x - b.x)
.map((g) => byUid.get(g.fieldUid))
.filter((f): f is FormScreenField => !!f)
: resp.fields;
return ordered.map(toFieldDef).filter((d): d is FieldDef => d !== null);
}
/** Single field's def by id for bespoke screens that need one field's
* lookup template / options / mapping without rendering the whole form. */
export function fieldFromSchema(
resp: FormScreenResponse | null | undefined,
id: string,
): FieldDef | undefined {
const f = resp?.fields.find((x) => x.id === id);
return f ? toFieldDef(f) ?? undefined : undefined;
}

View File

@ -44,6 +44,51 @@ export interface RecordViewParams {
filters?: Array<{ field_key: string; value: string; data_type?: string }>; filters?: Array<{ field_key: string; value: string; data_type?: string }>;
} }
// --- Form schema (POST /app/{appId}/view/form-screens) ---
// The live activity form config. `fields` is the metadata pool; `grid_config`
// is the designer's layout and decides which fields are user-facing.
export interface FormScreenFieldProps {
options?: Array<{ label: string; value: string }>;
lookup_config?: {
rdbms_template_uuid: string;
display_columns?: Array<{ name: string }>;
storage_columns?: Array<{ name: string }>;
};
ocr_config?: {
template?: string;
field_mappings?: Array<{ target_field: string; extraction_key: string }>;
};
source?: string;
disabled?: boolean;
}
export interface FormScreenField {
id: string;
uid: string;
name: string;
data_type: string;
mandatory?: boolean;
/** Companion/record column this field writes — the canonical seed key. */
mapped_workflow_field?: string;
type?: string; // 'mapped' | 'local'
properties?: FormScreenFieldProps | null;
}
export interface FormScreenGridItem {
type: string; // 'field' | ...
fieldUid: string;
x: number;
y: number;
}
export interface FormScreenResponse {
activity_uid: string;
activity_name: string;
fields: FormScreenField[];
grid_config?: FormScreenGridItem[];
}
// --- Audit (GET /app/{appId}/view/audit) --- // --- Audit (GET /app/{appId}/view/audit) ---
export interface AuditEntry { export interface AuditEntry {
@ -84,12 +129,42 @@ export interface ActivityResult {
error?: string; error?: string;
} }
// Generated policy schedule .docx, written by the Onboard trigger (policy_doc
// is a jsonb array on the lead record).
export interface PolicyDoc {
url: string;
blob_path?: string;
mime_type?: string;
size_bytes?: number;
original_name?: string;
}
// Several recordview fields come back as structured objects, not scalars.
// Normalize via the helpers in adapters.ts before rendering/seeding.
export interface PhoneValue {
dial_code?: string;
phone?: string;
phone_with_dial_code?: string;
}
export interface RegionValue {
region?: string;
}
// `recommended_product` is a lookup into the product catalog (plan_name is the
// display column); `owner_agent` is a {id,name} lookup.
export interface ProductLookup {
id?: number;
plan_name?: string;
[key: string]: unknown;
}
// A row from the all-leads record view, typed for what we read. // A row from the all-leads record view, typed for what we read.
export interface LeadRecord { export interface LeadRecord {
instance_id: number; instance_id: number;
current_state_name?: string | null; current_state_name?: string | null;
lead_name?: string | null; lead_name?: string | null;
phone?: string | null; phone?: string | PhoneValue | null;
email?: string | null; email?: string | null;
city?: string | null; city?: string | null;
state_region?: string | null; state_region?: string | null;
@ -102,11 +177,11 @@ export interface LeadRecord {
lead_segment?: string | null; lead_segment?: string | null;
channel_source?: string | null; channel_source?: string | null;
owner_agent?: { id: number; name: string } | null; owner_agent?: { id: number; name: string } | null;
assigned_region?: string | null; assigned_region?: string | RegionValue | null;
sum_assured?: number | null; sum_assured?: number | null;
premium_amount?: number | null; premium_amount?: number | null;
premium_frequency?: string | null; premium_frequency?: string | null;
recommended_product?: string | null; recommended_product?: string | ProductLookup | null;
riders?: string | string[] | null; riders?: string | string[] | null;
recommendation_notes?: string | null; recommendation_notes?: string | null;
meeting_datetime?: string | null; meeting_datetime?: string | null;
@ -114,9 +189,17 @@ export interface LeadRecord {
doc_status?: string | null; doc_status?: string | null;
eligibility_status?: string | null; eligibility_status?: string | null;
eligibility_notes?: string | null; eligibility_notes?: string | null;
verified_income?: number | null;
underwriting_ref?: string | null;
underwriting_status?: string | null;
pan_number?: string | null; pan_number?: string | null;
aadhaar_number?: string | null; aadhaar_number?: string | null;
policy_number?: string | null; policy_number?: string | null;
policy_issue_date?: string | null; policy_issue_date?: string | null;
policy_term?: number | null;
maturity_date?: string | null;
// Written by the Onboard trigger (5d), read off the lead record.
welcome_email_status?: string | null;
policy_doc?: PolicyDoc[] | null;
[key: string]: unknown; [key: string]: unknown;
} }

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2 } from 'lucide-react'; import { CheckCircle2 } from 'lucide-react';
import { Button } from '../core/Button'; import { Button } from '../core/Button';
import { Input } from '../core/Input'; import { Input } from '../core/Input';
@ -6,13 +6,15 @@ import { Select } from '../core/Select';
import { MultiSelect } from '../core/MultiSelect'; import { MultiSelect } from '../core/MultiSelect';
import { LookupField } from './LookupField'; import { LookupField } from './LookupField';
import type { LookupValue } from './LookupField'; import type { LookupValue } from './LookupField';
import { useZino } from '../../api/provider'; import { useQuery, useZino } from '../../api/provider';
import type { ActivityDef, FieldDef } from '../../api/forms'; import { schemaToFields } from '../../api/schema';
import type { ActivityMeta, FieldDef } from '../../api/forms';
import type { LeadRecord } from '../../api/types'; import type { LeadRecord } from '../../api/types';
import type { ActivityResult } from '../../api/types'; import type { ActivityResult } from '../../api/types';
export interface ActivityFormProps { export interface ActivityFormProps {
def: ActivityDef; /** Activity display copy + uid; fields are fetched live from the schema. */
meta: ActivityMeta;
/** Omit for the INIT activity (Capture Lead) — submits via /start. */ /** Omit for the INIT activity (Capture Lead) — submits via /start. */
instanceId?: number | string; instanceId?: number | string;
/** Seed values from an existing record. */ /** Seed values from an existing record. */
@ -48,29 +50,42 @@ function seedFor(field: FieldDef, record?: LeadRecord | null): unknown {
} }
} }
/** Renders an activity's fields from its declarative spec and submits via /** Renders an activity's fields from the live form-screens schema and submits
* performActivity (or startInstance when no instanceId is given). */ * via performActivity (or startInstance when no instanceId is given). */
export function ActivityForm({ def, instanceId, record, onSuccess, successMessage }: ActivityFormProps) { export function ActivityForm({ meta, instanceId, record, onSuccess, successMessage }: ActivityFormProps) {
const { client } = useZino(); const { client } = useZino();
const [values, setValues] = useState<Record<string, unknown>>(() => { const schemaQ = useQuery(
const init: Record<string, unknown> = {}; () => client.formSchema(meta.uid, instanceId),
def.fields.forEach((f) => (init[f.id] = seedFor(f, record))); [meta.uid, instanceId],
return init; );
}); const fields = useMemo<FieldDef[]>(
() => (schemaQ.data ? schemaToFields(schemaQ.data) : []),
[schemaQ.data],
);
const [values, setValues] = useState<Record<string, unknown>>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
// Seed once the schema loads (and re-seed if the record changes).
useEffect(() => {
if (!fields.length) return;
const init: Record<string, unknown> = {};
fields.forEach((f) => (init[f.id] = seedFor(f, record)));
setValues(init);
}, [fields, record]);
const set = (id: string, v: unknown) => setValues((p) => ({ ...p, [id]: v })); const set = (id: string, v: unknown) => setValues((p) => ({ ...p, [id]: v }));
const missing = useMemo( const missing = useMemo(
() => () =>
def.fields.filter((f) => { fields.filter((f) => {
if (!f.required) return false; if (!f.required) return false;
const v = values[f.id]; const v = values[f.id];
if (Array.isArray(v)) return v.length === 0; if (Array.isArray(v)) return v.length === 0;
return v === '' || v === null || v === undefined; return v === '' || v === null || v === undefined;
}), }),
[def.fields, values], [fields, values],
); );
async function submit() { async function submit() {
@ -83,7 +98,7 @@ export function ActivityForm({ def, instanceId, record, onSuccess, successMessag
// Drop empty optional values so we don't overwrite with blanks. // Drop empty optional values so we don't overwrite with blanks.
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
for (const f of def.fields) { for (const f of fields) {
const v = values[f.id]; const v = values[f.id];
if (v === '' || v === null || v === undefined) continue; if (v === '' || v === null || v === undefined) continue;
if (Array.isArray(v) && v.length === 0) continue; if (Array.isArray(v) && v.length === 0) continue;
@ -94,9 +109,9 @@ export function ActivityForm({ def, instanceId, record, onSuccess, successMessag
try { try {
const res = const res =
instanceId != null instanceId != null
? await client.performActivity(instanceId, def.uid, data) ? await client.performActivity(instanceId, meta.uid, data)
: await client.startInstance(def.uid, data); : await client.startInstance(meta.uid, data);
setResult({ ok: true, msg: successMessage ?? `${def.name} ${def.done}.` }); setResult({ ok: true, msg: successMessage ?? `${meta.name} ${meta.done}.` });
onSuccess?.(res); onSuccess?.(res);
} catch (e) { } catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') }); setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
@ -105,16 +120,27 @@ export function ActivityForm({ def, instanceId, record, onSuccess, successMessag
} }
} }
if (schemaQ.loading) {
return <div className="py-6 text-sm text-faint">Loading form</div>;
}
if (schemaQ.error || !fields.length) {
return (
<div className="py-4 text-sm text-amber-700">
{schemaQ.error ? `Couldn't load the form: ${schemaQ.error}` : 'This activity has no input fields.'}
</div>
);
}
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{def.fields.map((f) => ( {fields.map((f) => (
<Field <Field
key={f.id} key={f.id}
field={f} field={f}
value={values[f.id]} value={values[f.id]}
onChange={(v) => set(f.id, v)} onChange={(v) => set(f.id, v)}
activityId={def.uid} activityId={meta.uid}
instanceId={instanceId} instanceId={instanceId}
/> />
))} ))}
@ -135,7 +161,7 @@ export function ActivityForm({ def, instanceId, record, onSuccess, successMessag
<div> <div>
<Button variant="primary" disabled={submitting || result?.ok} onClick={submit}> <Button variant="primary" disabled={submitting || result?.ok} onClick={submit}>
{submitting ? 'Submitting…' : result?.ok ? 'Done' : def.submitLabel} {submitting ? 'Submitting…' : result?.ok ? 'Done' : meta.submitLabel}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, X } from 'lucide-react'; import { ChevronDown, X } from 'lucide-react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
import { useZino } from '../../api/provider'; import { useZino } from '../../api/provider';
@ -23,7 +24,8 @@ export interface LookupFieldProps {
} }
/** Searchable RDBMS-lookup picker (owner agent etc.). Mirrors the renderer's /** Searchable RDBMS-lookup picker (owner agent etc.). Mirrors the renderer's
* FFLookupField: projects the picked row onto storage + display columns. */ * FFLookupField: projects the picked row onto storage + display columns. The
* dropdown renders in a portal so it isn't clipped by a Card's overflow. */
export function LookupField({ export function LookupField({
label, label,
required, required,
@ -41,7 +43,9 @@ export function LookupField({
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [rows, setRows] = useState<Array<Record<string, unknown>>>([]); const [rows, setRows] = useState<Array<Record<string, unknown>>>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const boxRef = useRef<HTMLLabelElement>(null); const triggerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const labelOf = (src: LookupValue | Record<string, unknown> | null) => const labelOf = (src: LookupValue | Record<string, unknown> | null) =>
src src
@ -51,6 +55,38 @@ export function LookupField({
.join(', ') .join(', ')
: ''; : '';
// Collapse rows that project to the same stored value — e.g. many branches
// sharing one region show "North" once. Lookups whose storage carries a
// unique id never collapse.
const dedupe = (list: Array<Record<string, unknown>>) => {
const cols = storageCols.length ? storageCols : displayCols;
const seen = new Set<string>();
return list.filter((row) => {
const sig = JSON.stringify(cols.map((c) => row[c] ?? null));
if (seen.has(sig)) return false;
seen.add(sig);
return true;
});
};
// Anchor the portal panel under the trigger; track scroll/resize while open.
useLayoutEffect(() => {
if (!open) return;
const place = () => {
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setPos({ top: r.bottom + 4, left: r.left, width: r.width });
};
place();
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
return () => {
window.removeEventListener('scroll', place, true);
window.removeEventListener('resize', place);
};
}, [open]);
// Debounced fetch while the dropdown is open. // Debounced fetch while the dropdown is open.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -60,7 +96,7 @@ export function LookupField({
client client
.lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50 }) .lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50 })
.then((r) => { .then((r) => {
if (live) setRows(r.records ?? []); if (live) setRows(dedupe(r.records ?? []));
}) })
.catch(() => { .catch(() => {
if (live) setRows([]); if (live) setRows([]);
@ -75,11 +111,13 @@ export function LookupField({
}; };
}, [open, search, templateUid, activityId, fieldId, instanceId, client]); }, [open, search, templateUid, activityId, fieldId, instanceId, client]);
// Close on outside click. // Close on outside click — ignore clicks inside the trigger or the portal panel.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onDoc = (e: MouseEvent) => { const onDoc = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); const t = e.target as Node;
if (triggerRef.current?.contains(t) || panelRef.current?.contains(t)) return;
setOpen(false);
}; };
document.addEventListener('mousedown', onDoc); document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc);
@ -99,7 +137,7 @@ export function LookupField({
const current = labelOf(value); const current = labelOf(value);
return ( return (
<label className="flex flex-col gap-1.5 font-sans relative" ref={boxRef}> <label className="flex flex-col gap-1.5 font-sans">
{label && ( {label && (
<span className="text-sm font-medium text-muted"> <span className="text-sm font-medium text-muted">
{label} {label}
@ -107,6 +145,7 @@ export function LookupField({
</span> </span>
)} )}
<div <div
ref={triggerRef}
className="flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border border-border-default focus-ring cursor-pointer" className="flex items-center gap-2 bg-card rounded-md px-3 h-[42px] border border-border-default focus-ring cursor-pointer"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
> >
@ -129,37 +168,44 @@ export function LookupField({
)} )}
</div> </div>
{open && ( {open &&
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-card border border-border-default rounded-md shadow-lg overflow-hidden"> pos &&
<div className="p-2 border-b border-border-subtle"> createPortal(
<input <div
autoFocus ref={panelRef}
value={search} style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 50 }}
onChange={(e) => setSearch(e.target.value)} className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
placeholder="Search…" >
className="w-full h-9 px-2.5 border border-border-default rounded-md outline-none text-sm focus:border-sunrise-500" <div className="p-2 border-b border-border-subtle">
/> <input
</div> autoFocus
<div className="max-h-56 overflow-auto"> value={search}
{loading ? ( onChange={(e) => setSearch(e.target.value)}
<div className="px-3 py-3 text-sm text-faint">Searching</div> placeholder="Search…"
) : rows.length ? ( className="w-full h-9 px-2.5 border border-border-default rounded-md outline-none text-sm focus:border-sunrise-500"
rows.map((row, i) => ( />
<button </div>
type="button" <div className="max-h-56 overflow-auto">
key={i} {loading ? (
onClick={() => pick(row)} <div className="px-3 py-3 text-sm text-faint">Searching</div>
className="w-full text-left px-3 py-2.5 text-sm text-body hover:bg-slate-50 border-none bg-transparent cursor-pointer" ) : rows.length ? (
> rows.map((row, i) => (
{labelOf(row) || `Row ${i + 1}`} <button
</button> type="button"
)) key={i}
) : ( onClick={() => pick(row)}
<div className="px-3 py-3 text-sm text-faint">No matches.</div> className="w-full text-left px-3 py-2.5 text-sm text-body hover:bg-slate-50 border-none bg-transparent cursor-pointer"
)} >
</div> {labelOf(row) || `Row ${i + 1}`}
</div> </button>
)} ))
) : (
<div className="px-3 py-3 text-sm text-faint">No matches.</div>
)}
</div>
</div>,
document.body,
)}
</label> </label>
); );
} }

View File

@ -4,6 +4,7 @@ import { ChevronRight, FileText } from 'lucide-react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
import { Avatar } from '../core/Avatar'; import { Avatar } from '../core/Avatar';
import { ConfidenceRing } from './ConfidenceRing'; import { ConfidenceRing } from './ConfidenceRing';
import { ReasoningBody } from './ReasoningBody';
export type Citation = string | { title: string }; export type Citation = string | { title: string };
@ -102,8 +103,8 @@ export function AIDecisionCard({
{open ? 'Hide full reasoning' : 'Show full reasoning'} {open ? 'Hide full reasoning' : 'Show full reasoning'}
</button> </button>
{open && ( {open && (
<div className="mt-2.5 p-3.5 bg-sunk rounded-md text-sm leading-relaxed text-body"> <div className="mt-2.5 px-4 py-3.5 bg-sunk rounded-lg max-h-[320px] overflow-y-auto">
{reasoning} <ReasoningBody text={reasoning} accent={accent} accentSoft={accentSoft} />
</div> </div>
)} )}
</div> </div>

View File

@ -0,0 +1,171 @@
import { useState } from 'react';
import { ChevronDown, FileText, TriangleAlert } from 'lucide-react';
import { cn } from '../../lib/cn';
import { Avatar } from '../core/Avatar';
import { ReasoningBody } from './ReasoningBody';
import type { Decision } from '../../types';
export interface AIDecisionStreamProps {
decisions: Decision[];
loading?: boolean;
/** Autonomy threshold. @default 0.7 */
threshold?: number;
className?: string;
}
/**
* AI DECISION STREAM the stacked-decision view.
* A single-open accordion: each row is a scannable summary (employee, activity,
* confidence, one-line reasoning); expanding reveals the full reasoning in a
* height-capped, scrollable box so a wall of text never blows out the column.
*/
export function AIDecisionStream({
decisions,
loading = false,
threshold = 0.7,
className,
}: AIDecisionStreamProps) {
// Single-open accordion; newest (index 0) open by default.
const [openIdx, setOpenIdx] = useState(0);
const escalations = decisions.filter((d) => d.confidence < threshold);
return (
<div className={cn('flex flex-col gap-3.5', className)}>
<div className="flex items-center justify-between">
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3>
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy bg-navy-900 px-2 py-1 rounded-pill">
{decisions.length} action{decisions.length === 1 ? '' : 's'}
</span>
</div>
{escalations.length > 0 && (
<div className="bg-escalated-soft border border-amber-300 rounded-md px-3.5 py-3 flex gap-3">
<TriangleAlert size={18} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<div className="text-sm font-bold text-amber-700">
{escalations.length} escalation{escalations.length > 1 ? 's' : ''} need you
</div>
<div className="text-xs text-muted mt-0.5">
{escalations[0].employee} · confidence {Math.round(escalations[0].confidence * 100)}%.
</div>
</div>
</div>
)}
{loading && <div className="text-sm text-faint">Loading decisions</div>}
{!loading && decisions.length === 0 && (
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
)}
{decisions.length > 0 && (
<div className="bg-card rounded-lg border border-border-subtle shadow-sm overflow-hidden font-sans divide-y divide-border-subtle">
{decisions.map((d, i) => (
<DecisionRow
key={i}
decision={d}
threshold={threshold}
open={openIdx === i}
onToggle={() => setOpenIdx((cur) => (cur === i ? -1 : i))}
/>
))}
</div>
)}
</div>
);
}
function DecisionRow({
decision: d,
threshold,
open,
onToggle,
}: {
decision: Decision;
threshold: number;
open: boolean;
onToggle: () => void;
}) {
const autonomous = d.confidence >= threshold;
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const accentSoft = autonomous ? 'var(--status-autonomous-soft)' : 'var(--status-escalated-soft)';
const pct = Math.round(d.confidence * 100);
return (
<div className="relative">
{/* accent rail on the open row */}
{open && (
<span
className={cn('absolute top-0 left-0 bottom-0 w-1', autonomous && 'bg-sunrise')}
style={autonomous ? undefined : { background: accent }}
/>
)}
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className={cn(
'w-full flex items-start gap-3 text-left px-3.5 py-3 cursor-pointer bg-transparent border-none transition-colors',
open ? 'bg-sunk/60' : 'hover:bg-sunk/40',
)}
>
<Avatar name={d.employee} ai size={30} className="mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-strong truncate">{d.employee}</span>
{/* confidence chip */}
<span
className="ml-auto shrink-0 inline-flex items-center gap-1 text-2xs font-bold tracking-[0.02em] px-1.5 py-0.5 rounded-pill nums"
style={{ background: accentSoft, color: accent }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
{pct}%
</span>
<ChevronDown
size={15}
className={cn('shrink-0 text-faint transition-transform duration-200', open && 'rotate-180')}
/>
</div>
<div className="text-2xs text-faint mt-0.5">
{d.activity}
{d.time && <span> · {d.time}</span>}
</div>
<p className={cn('m-0 mt-1.5 text-sm leading-snug text-body', !open && 'line-clamp-2')}>
{d.summary}
</p>
</div>
</button>
{open && (d.reasoning || d.citations.length > 0) && (
<div className="px-3.5 pb-4">
{d.reasoning && (
<div className="px-4 py-3.5 bg-sunk rounded-lg max-h-[300px] overflow-y-auto">
<ReasoningBody text={d.reasoning} accent={accent} accentSoft={accentSoft} />
</div>
)}
{d.citations.length > 0 && (
<div className="mt-3">
<div className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint mb-[7px]">
Cited knowledge
</div>
<div className="flex flex-wrap gap-[7px]">
{d.citations.map((c, i) => (
<span
key={i}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted bg-card border border-border-default rounded-sm px-2.5 py-[5px]"
>
<FileText size={12} className="text-sunrise-500" />
{c}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,98 @@
import { cn } from '../../lib/cn';
export interface ReasoningParts {
/** Lead-in prose before the first enumerated point. */
intro: string;
/** The enumerated reasoning points. */
steps: string[];
}
/**
* Parse an AI reasoning blob into a lead-in + enumerated steps.
* Handles the common "(1) … (2) …" / "1) … 2) …" inline enumeration and
* "-"/"•"-prefixed bullet lines. Falls back to plain prose (no steps) for
* anything else, so short / unstructured reasoning still renders cleanly.
*/
export function parseReasoning(raw: string): ReasoningParts {
const text = (raw ?? '').trim();
if (!text) return { intro: '', steps: [] };
// Inline numbered enumeration — "(1) …" or "1) …". Only digit-in-paren forms,
// so "(18-65 band)", "(WARM segment)", "60/100" never false-match.
const markers = [...text.matchAll(/(?:^|\s)\(?(\d{1,2})\)\s+/g)];
if (markers.length >= 2) {
const intro = text.slice(0, markers[0].index).trim();
const steps: string[] = [];
for (let i = 0; i < markers.length; i++) {
const start = markers[i].index! + markers[i][0].length;
const end = i + 1 < markers.length ? markers[i + 1].index! : text.length;
const seg = text.slice(start, end).trim().replace(/[,;]\s*$/, '');
if (seg) steps.push(seg);
}
return { intro, steps };
}
// Newline bullets — "- ", "• ", "* ".
const lines = text.split(/\n+/).map((l) => l.trim()).filter(Boolean);
const isBullet = (l: string) => /^[-•*]\s+/.test(l);
if (lines.filter(isBullet).length >= 2) {
const introLines: string[] = [];
let i = 0;
while (i < lines.length && !isBullet(lines[i])) introLines.push(lines[i++]);
const steps = lines.slice(i).filter(isBullet).map((l) => l.replace(/^[-•*]\s+/, ''));
return { intro: introLines.join(' '), steps };
}
return { intro: text, steps: [] };
}
export interface ReasoningBodyProps {
text: string;
/** Accent color for the step badges (matches the decision's autonomy state). */
accent?: string;
accentSoft?: string;
className?: string;
}
/** Renders an AI reasoning blob as a lead-in paragraph + numbered step list. */
export function ReasoningBody({
text,
accent = 'var(--status-autonomous)',
accentSoft = 'var(--status-autonomous-soft)',
className,
}: ReasoningBodyProps) {
const { intro, steps } = parseReasoning(text);
// No enumeration — render as prose paragraphs.
if (steps.length === 0) {
const paras = text.split(/\n+/).map((p) => p.trim()).filter(Boolean);
return (
<div className={cn('text-sm leading-relaxed text-body', className)}>
{paras.map((p, i) => (
<p key={i} className={cn('m-0', i > 0 && 'mt-2')}>
{p}
</p>
))}
</div>
);
}
return (
<div className={cn('text-sm leading-relaxed text-body', className)}>
{intro && <p className="m-0 mb-3">{intro}</p>}
<ol className="m-0 p-0 list-none flex flex-col gap-2.5">
{steps.map((s, i) => (
<li key={i} className="flex gap-2.5">
<span
className="shrink-0 mt-px w-5 h-5 rounded-full text-[11px] font-bold flex items-center justify-center nums"
style={{ background: accentSoft, color: accent }}
>
{i + 1}
</span>
<span className="flex-1 leading-snug">{s}</span>
</li>
))}
</ol>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { cn } from '../../lib/cn'; import { cn } from '../../lib/cn';
import { WORKFLOW_STAGES } from '../../data/mockData'; import { STAGES } from '../../api/config';
export interface WorkflowStepperProps { export interface WorkflowStepperProps {
stages?: readonly string[]; stages?: readonly string[];
@ -13,7 +13,7 @@ export interface WorkflowStepperProps {
/** Workflow state stepper across the lead-to-policy lifecycle. */ /** Workflow state stepper across the lead-to-policy lifecycle. */
export function WorkflowStepper({ export function WorkflowStepper({
stages = WORKFLOW_STAGES, stages = STAGES,
current = 0, current = 0,
orientation = 'horizontal', orientation = 'horizontal',
className, className,

View File

@ -14,6 +14,10 @@ export { TimelineEntry } from './TimelineEntry';
export type { TimelineEntryProps } from './TimelineEntry'; export type { TimelineEntryProps } from './TimelineEntry';
export { AIDecisionCard } from './AIDecisionCard'; export { AIDecisionCard } from './AIDecisionCard';
export type { AIDecisionCardProps, Citation } from './AIDecisionCard'; export type { AIDecisionCardProps, Citation } from './AIDecisionCard';
export { AIDecisionStream } from './AIDecisionStream';
export type { AIDecisionStreamProps } from './AIDecisionStream';
export { ReasoningBody, parseReasoning } from './ReasoningBody';
export type { ReasoningBodyProps, ReasoningParts } from './ReasoningBody';
export { LeadRow, LEAD_GRID } from './LeadRow'; export { LeadRow, LEAD_GRID } from './LeadRow';
export type { LeadRowProps } from './LeadRow'; export type { LeadRowProps } from './LeadRow';
export { DocumentUploadCard } from './DocumentUploadCard'; export { DocumentUploadCard } from './DocumentUploadCard';

View File

@ -1,104 +0,0 @@
// Shared mock data for the Aria console. Indian context throughout.
import type {
AiEmployee,
Decision,
FunnelStage,
Kpi,
Lead,
SegmentDatum,
} from '../types';
/** Lead-to-policy lifecycle stages, indexed by `Lead.stage`. */
export const WORKFLOW_STAGES = [
'New Lead',
'Qualified',
'Assigned',
'Contacted',
'Meeting Scheduled',
'Recommended',
'Underwriting',
'Policy Issued',
] as const;
export const LEADS: Lead[] = [
{ id: 'L-2041', name: 'Priya Sharma', city: 'Pune', phone: '+91 98220 41xxx', email: 'priya.sharma@gmail.com', dob: '14 Aug 1990', age: 34, income: 2400000, occupation: 'Product Manager', language: 'Hindi', interest: 'Term Life', score: 87, segment: 'Hot', channel: 'WhatsApp', owner: 'Aria Engage', ownerAi: true, stage: 4, lastAction: 'Scheduled meeting · Thu 6:30 PM', lastTone: 'accent' },
{ id: 'L-2042', name: 'Arjun Reddy', city: 'Bengaluru', phone: '+91 99860 12xxx', email: 'arjun.reddy@outlook.com', dob: '02 Mar 1986', age: 39, income: 3600000, occupation: 'Software Architect', language: 'English', interest: 'ULIP', score: 64, segment: 'Warm', channel: 'Web', owner: 'Aria', ownerAi: true, stage: 1, lastAction: 'Escalated — income mismatch', lastTone: 'warning' },
{ id: 'L-2043', name: 'Meera Iyer', city: 'Mumbai', phone: '+91 98190 77xxx', email: 'meera.iyer@gmail.com', dob: '29 Nov 1994', age: 30, income: 1200000, occupation: 'Architect', language: 'Marathi', interest: 'Endowment', score: 41, segment: 'Cold', channel: 'Referral', owner: 'Rohan Mehta', ownerAi: false, stage: 0, lastAction: 'Awaiting first contact', lastTone: 'neutral' },
{ id: 'L-2044', name: 'Vikram Nair', city: 'Kochi', phone: '+91 94470 30xxx', email: 'vikram.nair@yahoo.com', dob: '17 Jun 1982', age: 42, income: 5200000, occupation: 'Business Owner', language: 'Malayalam', interest: 'Whole Life', score: 79, segment: 'Hot', channel: 'Agency', owner: 'Aria Underwrite', ownerAi: true, stage: 6, lastAction: 'Underwriting in progress', lastTone: 'accent' },
{ id: 'L-2045', name: 'Sneha Patel', city: 'Ahmedabad', phone: '+91 90990 55xxx', email: 'sneha.patel@gmail.com', dob: '08 Jan 1992', age: 33, income: 1800000, occupation: 'CA', language: 'Gujarati', interest: 'Child Plan', score: 72, segment: 'Warm', channel: 'Bancassurance', owner: 'Aria Engage', ownerAi: true, stage: 3, lastAction: 'Sent product brochure', lastTone: 'success' },
{ id: 'L-2046', name: 'Rahul Verma', city: 'Delhi', phone: '+91 98110 22xxx', email: 'rahul.verma@gmail.com', dob: '23 Sep 1979', age: 45, income: 4100000, occupation: 'Consultant', language: 'Hindi', interest: 'Pension', score: 68, segment: 'Warm', channel: 'Direct', owner: 'Aria', ownerAi: true, stage: 2, lastAction: 'Qualified — assigned to agent', lastTone: 'success' },
{ id: 'L-2047', name: 'Ananya Das', city: 'Kolkata', phone: '+91 98300 64xxx', email: 'ananya.das@gmail.com', dob: '11 Dec 1996', age: 28, income: 950000, occupation: 'Designer', language: 'Bengali', interest: 'Term Life', score: 38, segment: 'Cold', channel: 'Event', owner: 'Aria', ownerAi: true, stage: 0, lastAction: 'Captured at expo booth', lastTone: 'neutral' },
{ id: 'L-2048', name: 'Karthik Rao', city: 'Hyderabad', phone: '+91 99490 18xxx', email: 'karthik.rao@gmail.com', dob: '05 May 1988', age: 37, income: 2800000, occupation: 'Doctor', language: 'Telugu', interest: 'Term Life', score: 91, segment: 'Hot', channel: 'WhatsApp', owner: 'Aria Engage', ownerAi: true, stage: 7, lastAction: 'Policy issued · ₹1.5 Cr', lastTone: 'success' },
{ id: 'L-2049', name: 'Divya Menon', city: 'Chennai', phone: '+91 98410 73xxx', email: 'divya.menon@gmail.com', dob: '19 Feb 1991', age: 34, income: 2100000, occupation: 'HR Lead', language: 'Tamil', interest: 'ULIP', score: 81, segment: 'Hot', channel: 'Web', owner: 'Aria Engage', ownerAi: true, stage: 5, lastAction: 'Recommended Aria Smart Shield', lastTone: 'accent' },
{ id: 'L-2050', name: 'Imran Sheikh', city: 'Lucknow', phone: '+91 94150 26xxx', email: 'imran.sheikh@gmail.com', dob: '30 Jul 1984', age: 40, income: 3300000, occupation: 'Civil Engineer', language: 'Hindi', interest: 'Whole Life', score: 76, segment: 'Hot', channel: 'Referral', owner: 'Aria Underwrite', ownerAi: true, stage: 6, lastAction: 'Underwriting in progress', lastTone: 'accent' },
{ id: 'L-2051', name: 'Neha Joshi', city: 'Indore', phone: '+91 90390 41xxx', email: 'neha.joshi@gmail.com', dob: '12 Oct 1993', age: 31, income: 1500000, occupation: 'Teacher', language: 'Hindi', interest: 'Child Plan', score: 59, segment: 'Warm', channel: 'Bancassurance', owner: 'Aria', ownerAi: true, stage: 1, lastAction: 'Qualified — score 59', lastTone: 'success' },
{ id: 'L-2052', name: 'Sanjay Gupta', city: 'Jaipur', phone: '+91 99280 67xxx', email: 'sanjay.gupta@gmail.com', dob: '08 Apr 1977', age: 48, income: 6200000, occupation: 'Business Owner', language: 'Hindi', interest: 'Pension', score: 83, segment: 'Hot', channel: 'Agency', owner: 'Rohan Mehta', ownerAi: false, stage: 3, lastAction: 'Brochure shared via email', lastTone: 'success' },
{ id: 'L-2053', name: 'Pooja Reddy', city: 'Visakhapatnam', phone: '+91 89850 12xxx', email: 'pooja.reddy@gmail.com', dob: '25 Jun 1995', age: 29, income: 1100000, occupation: 'Analyst', language: 'Telugu', interest: 'Term Life', score: 48, segment: 'Cold', channel: 'Web', owner: 'Aria', ownerAi: true, stage: 2, lastAction: 'Assigned to agent', lastTone: 'neutral' },
];
export const KPIS: Kpi[] = [
{ label: 'Total leads', value: '2,847', delta: '12%', deltaDir: 'up' },
{ label: 'Hot leads', value: '24', unit: '%', delta: '4%', deltaDir: 'up' },
{ label: 'Conversion rate', value: '18.4', unit: '%', delta: '2.1%', deltaDir: 'up' },
{ label: 'AI-handled', value: '78', unit: '%', accent: true },
];
export const FUNNEL: FunnelStage[] = [
{ stage: 'New Lead', count: 2847, pct: 100 },
{ stage: 'Qualified', count: 1980, pct: 70 },
{ stage: 'Assigned', count: 1540, pct: 54 },
{ stage: 'Contacted', count: 1120, pct: 39 },
{ stage: 'Meeting', count: 680, pct: 24 },
{ stage: 'Recommended', count: 520, pct: 18 },
{ stage: 'Underwriting', count: 410, pct: 14 },
{ stage: 'Issued', count: 312, pct: 11 },
];
export const SEGMENTS: SegmentDatum[] = [
{ label: 'Hot', count: 684, color: 'var(--sunrise-500)' },
{ label: 'Warm', count: 1138, color: 'var(--amber-500)' },
{ label: 'Cold', count: 1025, color: 'var(--slate-400)' },
];
/** Decision stream for the Lead 360 screen (Priya Sharma). */
export const DECISIONS: Decision[] = [
{
employee: 'Aria Engage',
role: 'Calls & Scheduling',
activity: 'Scheduled meeting',
time: '9:02 AM',
confidence: 0.88,
summary: 'Booked a video consultation for Thu 6:30 PM — the customers stated preference.',
reasoning:
'WhatsApp reply indicated availability after 6 PM on weekdays. Cross-checked the assigned agents calendar and booked the first open slot, then sent an ICS invite and a WhatsApp confirmation.',
citations: ['Customer Preferences', 'Agent Calendar'],
},
{
employee: 'Aria',
role: 'Lead Qualifier',
activity: 'Qualified lead',
time: '8:47 AM',
confidence: 0.91,
summary: 'High-intent Term Life lead. Score 87 — routed to Aria Engage for outreach.',
reasoning:
'WhatsApp enquiry mentioned a new home loan of ₹80,00,000 and a dependent child — strong protection need. Declared income ₹24,00,000 supports a sum assured up to ₹2 Cr. Matched the Term Life ICP at 0.91 confidence.',
citations: ['Lead Scoring Model v3', 'Term Life ICP', 'Income Multiplier Matrix'],
},
{
employee: 'Aria Underwrite',
role: 'Underwriting',
activity: 'Pre-eligibility check',
time: '8:49 AM',
confidence: 0.58,
summary: 'Pre-eligibility looks favourable, but medical history is incomplete. Escalated to a human agent.',
reasoning:
'Age 34 and non-smoker declaration support standard rates. However, the proposal lacks a recent health declaration and no medical reports are on file — confidence below the 0.70 autonomy threshold. Flagged for agent-led tele-underwriting.',
citations: ['Underwriting Guidelines v4.2'],
},
];
export const AI_EMPLOYEES: AiEmployee[] = [
{ name: 'Aria', role: 'Qualifier', active: 42 },
{ name: 'Aria Engage', role: 'Calls & schedule', active: 31 },
{ name: 'Aria Underwrite', role: 'Underwriting', active: 18 },
];

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Bell, FileText, LogOut, Search, Sparkles, UserCheck, Users } from 'lucide-react'; import { Bell, FileText, FileSignature, LogOut, Menu, Search, ShieldCheck, Sparkles, UserCheck, Users, X } from 'lucide-react';
import { cn } from '../lib/cn'; import { cn } from '../lib/cn';
import { Avatar } from '../components/core'; import { Avatar } from '../components/core';
import { AI_EMPLOYEES } from '../api/config'; import { AI_EMPLOYEES } from '../api/config';
@ -22,6 +22,8 @@ const NAV: NavItem[] = [
{ to: '/assign', label: 'Assign', Icon: UserCheck, title: 'Assign leads', subtitle: 'Route qualified leads to a sales agent' }, { to: '/assign', label: 'Assign', Icon: UserCheck, title: 'Assign leads', subtitle: 'Route qualified leads to a sales agent' },
{ to: '/recommend', label: 'Recommend', Icon: Sparkles, title: 'Recommend product', subtitle: 'Build and send a product recommendation' }, { to: '/recommend', label: 'Recommend', Icon: Sparkles, title: 'Recommend product', subtitle: 'Build and send a product recommendation' },
{ to: '/documents', label: 'Documents', Icon: FileText, title: 'Document collection', subtitle: 'KYC capture with OCR auto-extraction' }, { to: '/documents', label: 'Documents', Icon: FileText, title: 'Document collection', subtitle: 'KYC capture with OCR auto-extraction' },
{ to: '/underwriting', label: 'Underwriting', Icon: ShieldCheck, title: 'Underwriting', subtitle: 'Risk assessment & verdict' },
{ to: '/issue', label: 'Issue Policy', Icon: FileSignature, title: 'Issue policy', subtitle: 'Generate policy number & dates' },
]; ];
interface RosterEntry { interface RosterEntry {
@ -60,9 +62,10 @@ function AriaLogo() {
); );
} }
function Sidebar({ activeTo, onNav, roster }: { activeTo: string; onNav: (to: string) => void; roster: RosterEntry[] }) { /** Sidebar contents — shared between the static desktop rail and the mobile drawer. */
function SidebarInner({ activeTo, onNav, roster }: { activeTo: string; onNav: (to: string) => void; roster: RosterEntry[] }) {
return ( return (
<aside className="w-[248px] shrink-0 bg-navy-grad flex flex-col p-4 gap-6 font-sans"> <>
<div className="px-2 py-1"> <div className="px-2 py-1">
<AriaLogo /> <AriaLogo />
</div> </div>
@ -106,18 +109,25 @@ function Sidebar({ activeTo, onNav, roster }: { activeTo: string; onNav: (to: st
</div> </div>
))} ))}
</div> </div>
</aside> </>
); );
} }
function Topbar({ title, subtitle }: { title: string; subtitle: string }) { function Topbar({ title, subtitle, onMenu }: { title: string; subtitle: string; onMenu: () => void }) {
const { user, logout } = useZino(); const { user, logout } = useZino();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<header className="h-16 shrink-0 bg-card border-b border-border-subtle flex items-center px-7 gap-5 font-sans"> <header className="h-16 shrink-0 bg-card border-b border-border-subtle flex items-center px-4 lg:px-7 gap-3 lg:gap-5 font-sans">
<button
onClick={onMenu}
aria-label="Open menu"
className="lg:hidden w-[38px] h-[38px] -ml-1 rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center shrink-0"
>
<Menu size={20} />
</button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="m-0 text-lg font-bold text-strong whitespace-nowrap">{title}</h1> <h1 className="m-0 text-lg font-bold text-strong truncate">{title}</h1>
{subtitle && <div className="text-xs text-faint mt-px">{subtitle}</div>} {subtitle && <div className="text-xs text-faint mt-px truncate">{subtitle}</div>}
</div> </div>
<div className="flex items-center gap-3.5"> <div className="flex items-center gap-3.5">
<button className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center"> <button className="w-[38px] h-[38px] rounded-md border border-border-subtle bg-card cursor-pointer text-muted flex items-center justify-center">
@ -131,7 +141,7 @@ function Topbar({ title, subtitle }: { title: string; subtitle: string }) {
</div> </div>
<div className="flex items-center gap-2 pl-1.5"> <div className="flex items-center gap-2 pl-1.5">
<Avatar name={user?.name || 'User'} size={36} /> <Avatar name={user?.name || 'User'} size={36} />
<div className="leading-tight whitespace-nowrap"> <div className="hidden md:block leading-tight whitespace-nowrap">
<div className="text-sm font-semibold text-strong">{user?.name || 'User'}</div> <div className="text-sm font-semibold text-strong">{user?.name || 'User'}</div>
<div className="text-2xs text-faint">{user?.email || ''}</div> <div className="text-2xs text-faint">{user?.email || ''}</div>
</div> </div>
@ -170,12 +180,37 @@ export function Shell() {
const roster = useMemo(() => buildRoster(leads), [leads]); const roster = useMemo(() => buildRoster(leads), [leads]);
const subtitle = active.to === '/pipeline' ? pipelineSubtitle(leads) : active.subtitle; const subtitle = active.to === '/pipeline' ? pipelineSubtitle(leads) : active.subtitle;
const [navOpen, setNavOpen] = useState(false);
// Close the mobile drawer whenever the route changes.
useEffect(() => setNavOpen(false), [path]);
return ( return (
<div className="flex h-screen overflow-hidden bg-app font-sans"> <div className="flex h-screen overflow-hidden bg-app font-sans">
<Sidebar activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} /> {/* Static rail — lg and up. */}
<aside className="hidden lg:flex w-[248px] shrink-0 bg-navy-grad flex-col p-4 gap-6 font-sans">
<SidebarInner activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
</aside>
{/* Drawer — below lg. */}
{navOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setNavOpen(false)} />
<aside className="absolute inset-y-0 left-0 w-[248px] max-w-[82%] bg-navy-grad flex flex-col p-4 gap-6 font-sans shadow-2xl">
<button
onClick={() => setNavOpen(false)}
aria-label="Close menu"
className="absolute top-3 right-3 w-9 h-9 rounded-md text-on-navy-muted hover:text-on-navy flex items-center justify-center bg-transparent border-none cursor-pointer"
>
<X size={20} />
</button>
<SidebarInner activeTo={navMatch?.to ?? ''} onNav={(to) => navigate(to)} roster={roster} />
</aside>
</div>
)}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<Topbar title={active.title} subtitle={subtitle} /> <Topbar title={active.title} subtitle={subtitle} onMenu={() => setNavOpen(true)} />
<main className="flex-1 overflow-auto p-7"> <main className="flex-1 overflow-auto p-4 lg:p-7">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

179
src/lib/kyc-match.ts Normal file
View File

@ -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<Record<DocKey, DocExtract>>,
): 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,
};
}

117
src/lib/underwriting.ts Normal file
View File

@ -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,
};
}

View File

@ -1,16 +1,16 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { CheckCircle2, MapPin, UserCheck } from 'lucide-react'; import { CheckCircle2, UserCheck } from 'lucide-react';
import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, Input, Select } from '../components'; import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, Select } from '../components';
import { LookupField } from '../components/form'; import { LookupField } from '../components/form';
import type { LookupValue } from '../components/form'; import type { LookupValue } from '../components/form';
import { useQuery, useZino } from '../api/provider'; import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads'; import { useLeads } from '../api/leads';
import { decisionToCard } from '../api/adapters'; 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 DEF = ACTIVITY_META.assign;
const OWNER = DEF.fields.find((f) => f.id === 'owner_agent_input')!;
export function AssignScreen() { export function AssignScreen() {
const { client } = useZino(); const { client } = useZino();
@ -20,6 +20,12 @@ export function AssignScreen() {
const { records, refetch } = useLeads(); 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. // Assign Lead is valid at the Qualified state.
const eligible = useMemo(() => records.filter((r) => r.current_state_name === 'Qualified'), [records]); 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 record = useMemo(() => records.find((r) => String(r.instance_id) === selectedId), [records, selectedId]);
const [region, setRegion] = useState(''); const [region, setRegion] = useState<LookupValue | null>(null);
const [agent, setAgent] = useState<LookupValue | null>(null); const [agent, setAgent] = useState<LookupValue | null>(null);
useEffect(() => { useEffect(() => {
if (!record) return; if (!record) return;
setRegion(record.assigned_region || record.state_region || ''); setRegion((record.assigned_region as LookupValue) ?? null);
setAgent((record.owner_agent as LookupValue) ?? null); setAgent((record.owner_agent as LookupValue) ?? null);
}, [record]); }, [record]);
@ -44,7 +50,7 @@ export function AssignScreen() {
async function submit() { async function submit() {
if (!record) return; if (!record) return;
if (!region.trim()) { if (!region) {
setResult({ ok: false, msg: 'Assigned region is required.' }); setResult({ ok: false, msg: 'Assigned region is required.' });
return; return;
} }
@ -65,8 +71,8 @@ export function AssignScreen() {
} }
return ( return (
<div className="grid grid-cols-[1fr_340px] gap-[18px] max-w-[1140px] mx-auto items-start"> <div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_340px]">
<div className="flex flex-col gap-[18px]"> <div className="flex flex-col gap-[18px] min-w-0">
{/* context strip + lead picker */} {/* context strip + lead picker */}
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm"> <div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{record ? ( {record ? (
@ -126,25 +132,34 @@ export function AssignScreen() {
<Card title="Assign to agent"> <Card title="Assign to agent">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Input {REGION && (
label="Assigned region" <LookupField
required label={REGION.label}
prefix={<MapPin size={15} />} required={REGION.required}
value={region} templateUid={REGION.lookupTemplate!}
onChange={(e) => setRegion(e.target.value)} displayCols={REGION.lookupDisplay ?? []}
placeholder="e.g. West / Maharashtra" storageCols={REGION.lookupStorage ?? []}
/> activityId={DEF.uid}
<LookupField fieldId={REGION.id}
label="Owner agent" instanceId={record?.instance_id}
templateUid={OWNER.lookupTemplate!} value={region}
displayCols={OWNER.lookupDisplay ?? []} onChange={setRegion}
storageCols={OWNER.lookupStorage ?? []} />
activityId={DEF.uid} )}
fieldId={OWNER.id} {OWNER && (
instanceId={record?.instance_id} <LookupField
value={agent} label={OWNER.label}
onChange={setAgent} templateUid={OWNER.lookupTemplate!}
/> displayCols={OWNER.lookupDisplay ?? []}
storageCols={OWNER.lookupStorage ?? []}
activityId={DEF.uid}
fieldId={OWNER.id}
instanceId={record?.instance_id}
value={agent}
onChange={setAgent}
/>
)}
{schemaQ.loading && <div className="col-span-full text-sm text-faint">Loading form</div>}
</div> </div>
<div className="flex gap-2.5 mt-5"> <div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}> <Button variant="primary" disabled={!record || submitting} onClick={submit}>
@ -167,7 +182,7 @@ export function AssignScreen() {
{agent ? String(agent.name ?? '—') : 'No agent yet'} {agent ? String(agent.name ?? '—') : 'No agent yet'}
</div> </div>
<div className="text-sm text-on-navy-muted truncate"> <div className="text-sm text-on-navy-muted truncate">
{agent?.region ? String(agent.region) : region || 'Pick a region & agent'} {agent?.region ? String(agent.region) : region?.region ? String(region.region) : 'Pick a region & agent'}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,12 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; 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 { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, Select } from '../components';
import type { BadgeTone, DocStatus, OcrField } 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 { 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 { OCR_FIELDS } from '../api/forms';
import { crossCheck, type DocExtract, type DocKey } from '../lib/kyc-match';
const ACT = ACTIVITIES.COLLECT_DOCUMENTS; const ACT = ACTIVITIES.COLLECT_DOCUMENTS;
const F = ACT.fields; const F = ACT.fields;
@ -18,20 +20,33 @@ const STATUS_TONE: Record<string, BadgeTone> = {
incomplete: 'info', incomplete: 'info',
}; };
const DOC_LABEL: Record<DocKey, string> = { 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 // One OCR document: file picker → /ocr-extract → autofills its mapped target
// field(s). The picked File is held and uploaded at submit time. // field(s). The picked File is held and uploaded at submit time.
function OcrCapture({ function OcrCapture({
ocr, ocr,
docKey,
activityId, activityId,
instanceId, instanceId,
onAutofill, onAutofill,
onFile, onFile,
onExtract,
}: { }: {
ocr: (typeof OCR_FIELDS)[keyof typeof OCR_FIELDS]; ocr: (typeof OCR_FIELDS)[keyof typeof OCR_FIELDS];
docKey: DocKey;
activityId: string; activityId: string;
instanceId?: number | string; instanceId?: number | string;
onAutofill: (targetField: string, value: unknown) => void; onAutofill: (targetField: string, value: unknown) => void;
onFile: (file: File | null) => void; onFile: (file: File | null) => void;
onExtract: (doc: DocKey, ex: DocExtract) => void;
}) { }) {
const { client } = useZino(); const { client } = useZino();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -54,6 +69,10 @@ function OcrCapture({
onAutofill(m.target_field, extracted[m.extraction_key]); 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. // Show every extracted key in the card for confirmation.
setFields( setFields(
Object.entries(extracted) Object.entries(extracted)
@ -118,12 +137,20 @@ export function DocumentsScreen() {
const record = useMemo(() => records.find((r) => String(r.instance_id) === selectedId), [records, selectedId]); 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 [pan, setPan] = useState('');
const [aadhaar, setAadhaar] = useState(''); const [aadhaar, setAadhaar] = useState('');
const [verifiedIncome, setVerifiedIncome] = useState(0); const [verifiedIncome, setVerifiedIncome] = useState(0);
const [docStatus, setDocStatus] = useState('verified'); const [docStatus, setDocStatus] = useState('verified');
// Picked-but-not-yet-uploaded files, keyed by field id. // Picked-but-not-yet-uploaded files, keyed by field id.
const [files, setFiles] = useState<Record<string, File | null>>({}); const [files, setFiles] = useState<Record<string, File | null>>({});
// OCR'd identity per doc — held only in memory for the cross-check (never persisted).
const [ocrExtracts, setOcrExtracts] = useState<Partial<Record<DocKey, DocExtract>>>({});
// True once the agent edits the status by hand — stops the auto-mismatch nudge.
const statusTouched = useRef(false);
useEffect(() => { useEffect(() => {
if (!record) return; if (!record) return;
@ -132,9 +159,25 @@ export function DocumentsScreen() {
setVerifiedIncome((record.verified_income as number) || record.annual_income || 0); setVerifiedIncome((record.verified_income as number) || record.annual_income || 0);
setDocStatus(record.doc_status || 'verified'); setDocStatus(record.doc_status || 'verified');
setFiles({}); setFiles({});
setOcrExtracts({});
statusTouched.current = false;
}, [record]); }, [record]);
const setFile = (fieldId: string) => (f: File | null) => setFiles((p) => ({ ...p, [fieldId]: f })); 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. // OCR autofills its mapped target text field.
const applyAutofill = (target: string, value: unknown) => { 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); const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100);
return ( return (
<div className="grid grid-cols-[1fr_320px] gap-[18px] max-w-[1180px] mx-auto items-start"> <div className="grid gap-[18px] max-w-[1180px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_320px]">
<div className="flex flex-col gap-[18px]"> <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"> <div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{record ? ( {record ? (
<> <>
@ -239,21 +282,52 @@ export function DocumentsScreen() {
</div> </div>
)} )}
{/* KYC identity cross-check — OCR'd name + DOB vs the captured lead */}
{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 youve 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}
{/* OCR document capture */} {/* OCR document capture */}
<div className="grid grid-cols-2 gap-[18px]"> <div className="grid grid-cols-2 gap-[18px]">
<OcrCapture <OcrCapture
ocr={OCR_FIELDS.pan} ocr={OCR_FIELDS.pan}
docKey="pan"
activityId={ACT.uid} activityId={ACT.uid}
instanceId={record?.instance_id} instanceId={record?.instance_id}
onAutofill={applyAutofill} onAutofill={applyAutofill}
onFile={setFile(OCR_FIELDS.pan.id)} onFile={setFile(OCR_FIELDS.pan.id)}
onExtract={applyExtract}
/> />
<OcrCapture <OcrCapture
ocr={OCR_FIELDS.aadhaar} ocr={OCR_FIELDS.aadhaar}
docKey="aadhaar"
activityId={ACT.uid} activityId={ACT.uid}
instanceId={record?.instance_id} instanceId={record?.instance_id}
onAutofill={applyAutofill} onAutofill={applyAutofill}
onFile={setFile(OCR_FIELDS.aadhaar.id)} onFile={setFile(OCR_FIELDS.aadhaar.id)}
onExtract={applyExtract}
/> />
</div> </div>
@ -271,9 +345,12 @@ export function DocumentsScreen() {
<Select <Select
label="Document status" label="Document status"
required required
options={DOC_STATUS_OPTIONS as unknown as Array<{ value: string; label: string }>} options={docStatusOptions}
value={docStatus} value={docStatus}
onChange={(e) => setDocStatus(e.target.value)} onChange={(e) => {
statusTouched.current = true;
setDocStatus(e.target.value);
}}
/> />
</div> </div>
@ -314,10 +391,17 @@ export function DocumentsScreen() {
<div className="h-px bg-border-subtle my-4" /> <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"> <div className="flex items-center justify-between mb-1">
<span className="text-sm text-muted">Current status</span> <span className="text-sm text-muted">Current status</span>
<Badge tone={STATUS_TONE[docStatus] ?? 'neutral'} dot> <Badge tone={STATUS_TONE[docStatus] ?? 'neutral'} dot>
{DOC_STATUS_OPTIONS.find((o) => o.value === docStatus)?.label ?? docStatus} {docStatusOptions.find((o) => o.value === docStatus)?.label ?? docStatus}
</Badge> </Badge>
</div> </div>

View File

@ -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<string>('');
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 (
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{/* context strip + lead picker */}
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{lead ? (
<>
<Avatar name={lead.lead_name ?? `Lead ${lead.instance_id}`} size={40} />
<div className="flex-1">
<div className="text-base font-bold text-strong">
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
</div>
<div className="text-xs text-faint">
{lead.current_state_name ?? '—'} · {productOf(lead.recommended_product) ?? '—'} · SA
{formatINR(lead.sum_assured ?? 0)}
</div>
</div>
</>
) : (
<div className="flex-1 text-sm text-faint">Select a lead at the Underwriting stage.</div>
)}
<select
value={selectedId}
onChange={(e) => {
setSelectedId(e.target.value);
setResult(null);
}}
className="w-[260px] font-sans text-base text-strong border border-border-default rounded-md px-3 py-2.5 outline-none focus:border-sunrise-500"
>
<option value="">{eligible.length ? 'Choose lead…' : 'No eligible leads'}</option>
{records.map((r) => (
<option key={r.instance_id} value={String(r.instance_id)}>
#{r.instance_id} {r.lead_name ?? ''} · {r.current_state_name ?? ''}
</option>
))}
</select>
</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}
{result.ok && lead && (
<button
className="ml-auto text-sm font-semibold text-link underline"
onClick={() => navigate(`/leads/${lead.instance_id}`)}
>
View lead
</button>
)}
</div>
)}
<Card title="Issue policy">
<div className="grid grid-cols-2 gap-4">
<Input
label="Policy issue date"
type="date"
value={issueDate}
onChange={(e) => setIssueDate(e.target.value)}
/>
<Input
label="Policy term (years)"
type="number"
value={term}
onChange={(e) => setTerm(Number(e.target.value) || 0)}
/>
<Input
label="Premium amount"
prefix="₹"
type="number"
value={premium}
onChange={(e) => setPremium(Number(e.target.value) || 0)}
/>
</div>
<div className="text-xs text-faint mt-2">
Policy number is auto-generated on issue (POL-YYYY-####).
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
{submitting ? 'Issuing…' : 'Issue policy'}
</Button>
</div>
</Card>
</div>
{/* RIGHT — policy summary (dates computed in-app) */}
<div className="flex flex-col gap-4">
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
Policy summary
</div>
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Policy number</span>
<span className="text-sm font-semibold text-on-navy nums">
{lead?.policy_number ?? 'auto on issue'}
</span>
</div>
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Risk commencement</span>
<span className="text-sm font-semibold text-on-navy nums">{commencement || '—'}</span>
</div>
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Term</span>
<span className="text-sm font-semibold text-on-navy nums">{term ? `${term} yrs` : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-on-navy-muted">Maturity date</span>
<span className="text-sm font-semibold text-on-navy nums">{maturity || '—'}</span>
</div>
<div className="h-px bg-[rgba(255,255,255,0.12)] my-4" />
<div className="flex justify-between">
<span className="text-sm text-on-navy-muted">Premium</span>
<span className="text-sm font-semibold text-on-navy nums">{formatINR(premium)}</span>
</div>
</div>
<div className="text-xs text-faint px-1">
Risk commencement = issue date. Maturity = issue date + term, computed in-app.
</div>
</div>
</div>
);
}

View File

@ -1,9 +1,9 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Ban, TriangleAlert, CheckCircle2, CircleDashed } from 'lucide-react'; import { Ban, CheckCircle2, CircleDashed, Download } from 'lucide-react';
import { import {
AIDecisionCard, AIDecisionStream,
Avatar, Avatar,
Badge, Badge,
Button, Button,
@ -19,7 +19,7 @@ import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads'; import { useLeads } from '../api/leads';
import { recordToLead, decisionToCard, auditToTimeline } from '../api/adapters'; import { recordToLead, decisionToCard, auditToTimeline } from '../api/adapters';
import { STAGES, ONBOARD_ACTIVITY_UID } from '../api/config'; 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'; import type { LeadRecord } from '../api/types';
/** Contextual next-step action for a lead, driven by its current state. /** 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 [showDisqualify, setShowDisqualify] = useState(false);
const state = record.current_state_name ?? ''; const state = record.current_state_name ?? '';
const next = STATE_NEXT[state]; 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); const canDisqualify = DISQUALIFY_STATES.has(state);
return ( return (
@ -50,7 +50,7 @@ function NextActionPanel({ record, onAdvanced }: { record: LeadRecord; onAdvance
{showDisqualify ? ( {showDisqualify ? (
<ActivityForm <ActivityForm
def={ACTIVITY_DEFS.disqualify} meta={ACTIVITY_META.disqualify}
instanceId={record.instance_id} instanceId={record.instance_id}
onSuccess={onAdvanced} onSuccess={onAdvanced}
/> />
@ -60,8 +60,8 @@ function NextActionPanel({ record, onAdvanced }: { record: LeadRecord; onAdvance
{next.label ?? 'Continue'} {next.label ?? 'Continue'}
</Button> </Button>
</div> </div>
) : inlineDef ? ( ) : inlineMeta ? (
<ActivityForm def={inlineDef} instanceId={record.instance_id} record={record} onSuccess={onAdvanced} /> <ActivityForm meta={inlineMeta} instanceId={record.instance_id} record={record} onSuccess={onAdvanced} />
) : ( ) : (
<div className="text-sm text-faint"> <div className="text-sm text-faint">
{state === 'Disqualified' ? 'This lead was disqualified.' : 'Lifecycle complete — no further action.'} {state === 'Disqualified' ? 'This lead was disqualified.' : 'Lifecycle complete — no further action.'}
@ -99,6 +99,7 @@ export function Lead360Screen() {
() => client.aiDecisions(instanceId!), () => client.aiDecisions(instanceId!),
[instanceId], [instanceId],
instanceId != null, instanceId != null,
12_000, // poll: async AI decisions land without a manual reload
); );
const auditQ = useQuery(() => client.audit(instanceId!), [instanceId], instanceId != null); const auditQ = useQuery(() => client.audit(instanceId!), [instanceId], instanceId != null);
@ -119,14 +120,17 @@ export function Lead360Screen() {
const decisions = (decisionsQ.data ?? []).map(decisionToCard); const decisions = (decisionsQ.data ?? []).map(decisionToCard);
const audit = auditQ.data ?? []; const audit = auditQ.data ?? [];
const timeline = auditToTimeline(audit); 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 // 5d onboarding outcome: welcome_email_status + policy_doc are written by the
// Onboard activity submission (audit feed), not the lead recordview. Pull them // Onboard trigger onto the lead record. onboarding_notes is still a `local`
// from the audit entry so Customer 360 can show the onboarding outcome. // 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 onboard = audit.find((e) => e.activity_id === ONBOARD_ACTIVITY_UID)?.data;
const onboarded = !!onboard; const welcomeStatus =
const welcomePackSent = onboard?.welcome_pack_sent === true; 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 = const onboardingNotes =
typeof onboard?.onboarding_notes === 'string' ? onboard.onboarding_notes : ''; typeof onboard?.onboarding_notes === 'string' ? onboard.onboarding_notes : '';
const issueDate = record.policy_issue_date ? new Date(record.policy_issue_date) : null; const issueDate = record.policy_issue_date ? new Date(record.policy_issue_date) : null;
@ -142,7 +146,7 @@ export function Lead360Screen() {
: null; : null;
return ( return (
<div className="grid grid-cols-[300px_1fr_380px] gap-[18px] max-w-[1320px] mx-auto items-start"> <div className="grid gap-[18px] max-w-[1320px] mx-auto items-start grid-cols-1 lg:grid-cols-[280px_1fr] xl:grid-cols-[300px_1fr_380px]">
{/* LEFT — profile */} {/* LEFT — profile */}
<div className="flex flex-col gap-[18px]"> <div className="flex flex-col gap-[18px]">
<Card> <Card>
@ -183,8 +187,9 @@ export function Lead360Screen() {
</Card> </Card>
</div> </div>
{/* CENTER — workflow + current activity */} {/* CENTER workflow + current activity. min-w-0 lets the 1fr track shrink
<div className="flex flex-col gap-[18px]"> instead of forcing the grid wider than its container. */}
<div className="flex flex-col gap-[18px] min-w-0">
<Card title="Workflow"> <Card title="Workflow">
<div className="overflow-x-auto pb-1"> <div className="overflow-x-auto pb-1">
<WorkflowStepper stages={STAGES} current={Math.max(0, lead.stage)} /> <WorkflowStepper stages={STAGES} current={Math.max(0, lead.stage)} />
@ -224,8 +229,8 @@ export function Lead360Screen() {
<Card <Card
title="Policy & onboarding" title="Policy & onboarding"
action={ action={
<Badge tone={welcomePackSent ? 'success' : 'warning'} dot> <Badge tone={welcomeSent ? 'success' : welcomeFailed ? 'danger' : 'warning'} dot>
{welcomePackSent ? 'Onboarded' : 'Pending pack'} {welcomeSent ? 'Welcome sent' : welcomeFailed ? 'Welcome failed' : 'Welcome pending'}
</Badge> </Badge>
} }
> >
@ -242,15 +247,30 @@ export function Lead360Screen() {
</div> </div>
</div> </div>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
{welcomePackSent ? ( {welcomeSent ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" /> <CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : welcomeFailed ? (
<Ban size={16} className="text-ruby-600 shrink-0" />
) : ( ) : (
<CircleDashed size={16} className="text-faint shrink-0" /> <CircleDashed size={16} className="text-faint shrink-0" />
)} )}
<span className="text-sm text-strong"> <span className="text-sm text-strong">
Welcome pack {welcomePackSent ? 'sent' : 'not sent yet'} Welcome email {welcomeSent ? 'sent' : welcomeFailed ? 'failed to send' : 'pending'}
</span> </span>
</div> </div>
{policyDocUrl && (
<div className="mt-3">
<a
href={policyDocUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-sunrise-600 hover:underline"
>
<Download size={15} className="shrink-0" />
Download policy schedule
</a>
</div>
)}
{onboardingNotes && ( {onboardingNotes && (
<div className="mt-3 pt-3 border-t border-border-subtle"> <div className="mt-3 pt-3 border-t border-border-subtle">
<div className="text-2xs text-faint mb-1.5">Onboarding notes</div> <div className="text-2xs text-faint mb-1.5">Onboarding notes</div>
@ -279,35 +299,13 @@ export function Lead360Screen() {
</Card> </Card>
</div> </div>
{/* RIGHT — AI decision stream + escalations */} {/* RIGHT AI decision stream + escalations.
<div className="flex flex-col gap-3.5"> At lg it drops to a full-width row beneath profile + center; at xl it's the 3rd column. */}
<div className="flex items-center justify-between"> <AIDecisionStream
<h3 className="m-0 text-sm font-bold text-strong">AI decision stream</h3> decisions={decisions}
<Badge tone="navy">{decisions.length} actions</Badge> loading={decisionsQ.loading}
</div> className="min-w-0 lg:col-span-2 xl:col-span-1"
/>
{escalations.length > 0 && (
<div className="bg-escalated-soft border border-amber-300 rounded-md px-3.5 py-3 flex gap-3">
<TriangleAlert size={18} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<div className="text-sm font-bold text-amber-700">
{escalations.length} escalation{escalations.length > 1 ? 's' : ''} need you
</div>
<div className="text-xs text-muted mt-0.5">
{escalations[0].employee} · confidence {Math.round(escalations[0].confidence * 100)}%.
</div>
</div>
</div>
)}
{decisionsQ.loading && <div className="text-sm text-faint">Loading decisions</div>}
{!decisionsQ.loading && decisions.length === 0 && (
<div className="text-sm text-faint">No AI decisions for this lead yet.</div>
)}
{decisions.map((d, i) => (
<AIDecisionCard key={i} {...d} defaultExpanded={i === 0} />
))}
</div>
</div> </div>
); );
} }

View File

@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom';
import { Card } from '../components'; import { Card } from '../components';
import { ActivityForm } from '../components/form'; import { ActivityForm } from '../components/form';
import { useLeads } from '../api/leads'; 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. */ /** Capture Lead — the INIT activity. Creates a new instance via /start. */
export function NewLeadScreen() { export function NewLeadScreen() {
@ -13,7 +13,7 @@ export function NewLeadScreen() {
<div className="max-w-[820px] mx-auto"> <div className="max-w-[820px] mx-auto">
<Card title="New lead"> <Card title="New lead">
<ActivityForm <ActivityForm
def={ACTIVITY_DEFS.capture} meta={ACTIVITY_META.capture}
successMessage="Lead created — opening the lead…" successMessage="Lead created — opening the lead…"
onSuccess={(res) => { onSuccess={(res) => {
refetch(); refetch();

View File

@ -226,14 +226,14 @@ export function PipelineScreen() {
)} )}
{/* KPI row */} {/* KPI row */}
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{kpis.map((k, i) => ( {kpis.map((k, i) => (
<KpiCard key={i} {...k} /> <KpiCard key={i} {...k} />
))} ))}
</div> </div>
{/* charts */} {/* charts */}
<div className="grid grid-cols-[1.7fr_1fr] gap-4"> <div className="grid grid-cols-1 lg:grid-cols-[1.7fr_1fr] gap-4">
<Card title="Pipeline funnel" action={<Badge tone="neutral">Live</Badge>}> <Card title="Pipeline funnel" action={<Badge tone="neutral">Live</Badge>}>
<FunnelChart data={funnel} /> <FunnelChart data={funnel} />
</Card> </Card>
@ -264,25 +264,27 @@ export function PipelineScreen() {
{loading ? ( {loading ? (
<div className="p-10 text-center text-sm text-faint">Loading leads</div> <div className="p-10 text-center text-sm text-faint">Loading leads</div>
) : view === 'table' ? ( ) : view === 'table' ? (
<div> <div className="overflow-x-auto">
<div <div className="min-w-[760px]">
className={cn( <div
'grid gap-3 px-[18px] py-[11px] bg-slate-50 border-b border-border-subtle text-[10px] font-bold uppercase tracking-[0.06em] text-faint', className={cn(
LEAD_GRID, 'grid gap-3 px-[18px] py-[11px] bg-slate-50 border-b border-border-subtle text-[10px] font-bold uppercase tracking-[0.06em] text-faint',
LEAD_GRID,
)}
>
<span>Lead</span>
<span>Score</span>
<span>Segment</span>
<span>Channel</span>
<span>Owner</span>
<span>Last action</span>
</div>
{leads.length ? (
leads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
) : (
<div className="p-10 text-center text-sm text-faint">No leads yet.</div>
)} )}
>
<span>Lead</span>
<span>Score</span>
<span>Segment</span>
<span>Channel</span>
<span>Owner</span>
<span>Last action</span>
</div> </div>
{leads.length ? (
leads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
) : (
<div className="p-10 text-center text-sm text-faint">No leads yet.</div>
)}
</div> </div>
) : ( ) : (
<div className="p-5"> <div className="p-5">

View File

@ -12,10 +12,13 @@ import {
RupeeAmount, RupeeAmount,
Select, Select,
} from '../components'; } from '../components';
import { LookupField } from '../components/form';
import type { LookupValue } from '../components/form';
import { useQuery, useZino } from '../api/provider'; import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads'; import { useLeads } from '../api/leads';
import { decisionToCard } from '../api/adapters'; import { decisionToCard, regionOf } from '../api/adapters';
import { ACTIVITIES, FREQUENCY_OPTIONS, PRODUCT_OPTIONS, RIDER_OPTIONS } from '../api/config'; import { fieldFromSchema } from '../api/schema';
import { ACTIVITIES } from '../api/config';
import type { LeadRecord } from '../api/types'; import type { LeadRecord } from '../api/types';
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields; const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
@ -52,6 +55,13 @@ export function RecommendScreen() {
const { records, refetch } = useLeads(); 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). // Leads where Recommend Product is valid (state = Meeting Scheduled).
const eligible = useMemo( const eligible = useMemo(
() => records.filter((r) => r.current_state_name === 'Meeting Scheduled'), () => 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. // Form state — seeded from the instance's existing values when present.
const [product, setProduct] = useState('term'); const [product, setProduct] = useState<LookupValue | null>(null);
const [sum, setSum] = useState(2000000); const [sum, setSum] = useState(2000000);
const [freq, setFreq] = useState('annual'); const [freq, setFreq] = useState('annual');
const [premium, setPremium] = useState(0); const [premium, setPremium] = useState(0);
@ -80,7 +90,7 @@ export function RecommendScreen() {
useEffect(() => { useEffect(() => {
if (!record) return; if (!record) return;
setProduct(record.recommended_product || 'term'); setProduct((record.recommended_product as LookupValue) ?? null);
setSum(record.sum_assured || 2000000); setSum(record.sum_assured || 2000000);
setFreq(record.premium_frequency || 'annual'); setFreq(record.premium_frequency || 'annual');
setRiders(toRiderValues(record.riders)); setRiders(toRiderValues(record.riders));
@ -100,6 +110,10 @@ export function RecommendScreen() {
async function submit() { async function submit() {
if (!record) return; if (!record) return;
if (!product) {
setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
return;
}
setSubmitting(true); setSubmitting(true);
setResult(null); setResult(null);
try { try {
@ -124,8 +138,8 @@ export function RecommendScreen() {
const incomeMultiple = lead?.annual_income ? (sum / lead.annual_income).toFixed(1) : '—'; const incomeMultiple = lead?.annual_income ? (sum / lead.annual_income).toFixed(1) : '—';
return ( return (
<div className="grid grid-cols-[1fr_360px] gap-[18px] max-w-[1140px] mx-auto items-start"> <div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px]"> <div className="flex flex-col gap-[18px] min-w-0">
{/* context strip + lead picker */} {/* context strip + lead picker */}
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm"> <div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{lead ? ( {lead ? (
@ -136,7 +150,7 @@ export function RecommendScreen() {
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id} {lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
</div> </div>
<div className="text-xs text-faint"> <div className="text-xs text-faint">
{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)} {formatINR(lead.annual_income ?? 0)}
</div> </div>
</div> </div>
@ -184,12 +198,20 @@ export function RecommendScreen() {
<Card title="Recommend product"> <Card title="Recommend product">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Select {productField && (
label="Product type" <LookupField
options={PRODUCT_OPTIONS as unknown as Array<{ value: string; label: string }>} label={productField.label}
value={product} required={productField.required}
onChange={(e) => setProduct(e.target.value)} templateUid={productField.lookupTemplate!}
/> displayCols={productField.lookupDisplay ?? []}
storageCols={productField.lookupStorage ?? []}
activityId={ACTIVITIES.RECOMMEND_PRODUCT.uid}
fieldId={F.product}
instanceId={record?.instance_id}
value={product}
onChange={setProduct}
/>
)}
<Input <Input
label="Sum assured" label="Sum assured"
prefix="₹" prefix="₹"
@ -199,7 +221,7 @@ export function RecommendScreen() {
/> />
<Select <Select
label="Premium frequency" label="Premium frequency"
options={FREQUENCY_OPTIONS as unknown as Array<{ value: string; label: string }>} options={freqOptions}
value={freq} value={freq}
onChange={(e) => setFreq(e.target.value)} onChange={(e) => setFreq(e.target.value)}
/> />
@ -217,7 +239,7 @@ export function RecommendScreen() {
<div className="col-span-full"> <div className="col-span-full">
<MultiSelect <MultiSelect
label="Riders" label="Riders"
options={RIDER_OPTIONS as unknown as Array<{ value: string; label: string }>} options={riderOptions}
value={riders} value={riders}
onChange={setRiders} onChange={setRiders}
/> />
@ -251,7 +273,7 @@ export function RecommendScreen() {
</div> </div>
<RupeeAmount value={effectivePremium} size="hero" tone="onNavy" /> <RupeeAmount value={effectivePremium} size="hero" tone="onNavy" />
<div className="text-sm text-on-navy-muted mt-1.5"> <div className="text-sm text-on-navy-muted mt-1.5">
{(FREQUENCY_OPTIONS.find((f) => f.value === freq)?.label ?? freq).toLowerCase()} · excl. 18% GST {(freqOptions.find((f) => f.value === freq)?.label ?? freq).toLowerCase()} · excl. 18% GST
</div> </div>
<div className="h-px bg-[rgba(255,255,255,0.12)] my-4" /> <div className="h-px bg-[rgba(255,255,255,0.12)] my-4" />
<div className="flex justify-between mb-2"> <div className="flex justify-between mb-2">

View File

@ -0,0 +1,226 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CheckCircle2 } from 'lucide-react';
import { Avatar, Button, Card, formatINR, Select } from '../components';
import { useLeads } from '../api/leads';
import { useQuery, useZino } from '../api/provider';
import { fieldFromSchema } from '../api/schema';
import { ACTIVITIES } from '../api/config';
import { assess } from '../lib/underwriting';
const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
export function UnderwritingScreen() {
const { client } = useZino();
const navigate = useNavigate();
const [params] = useSearchParams();
const instanceParam = params.get('instance');
const { records, refetch } = useLeads();
// Status options from the live form schema, not hardcoded.
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid), []);
const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]);
// Leads where Submit Underwriting is valid (state = Eligibility Assessed).
const eligible = useMemo(
() => records.filter((r) => r.current_state_name === 'Eligibility Assessed'),
[records],
);
const [selectedId, setSelectedId] = useState<string>('');
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 [status, setStatus] = useState('approved');
useEffect(() => {
if (record?.underwriting_status) setStatus(String(record.underwriting_status));
}, [record]);
// Decision support — computed client-side, recomputes whenever the lead changes.
const a = useMemo(() => (record ? assess(record) : null), [record]);
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 {
// underwriting_ref is backend-generated (id_gen UW-YYYY-####) — send only the verdict.
await client.performActivity(record.instance_id, ACTIVITIES.SUBMIT_UNDERWRITING.uid, {
[F.status]: status,
});
setResult({ ok: true, msg: 'Underwriting verdict submitted.' });
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 (
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{/* context strip + lead picker */}
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{lead ? (
<>
<Avatar name={lead.lead_name ?? `Lead ${lead.instance_id}`} size={40} />
<div className="flex-1">
<div className="text-base font-bold text-strong">
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
</div>
<div className="text-xs text-faint">
{lead.current_state_name ?? '—'} · eligibility {lead.eligibility_status ?? '—'} · SA
{formatINR(lead.sum_assured ?? 0)}
</div>
</div>
</>
) : (
<div className="flex-1 text-sm text-faint">Select a lead at the Eligibility Assessed stage.</div>
)}
<Select
options={[
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
...records.map((r) => ({
value: String(r.instance_id),
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
})),
]}
value={selectedId}
onChange={(e) => {
setSelectedId(e.target.value);
setResult(null);
}}
className="w-[260px]"
/>
</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}
{result.ok && lead && (
<button
className="ml-auto text-sm font-semibold text-link underline"
onClick={() => navigate(`/leads/${lead.instance_id}`)}
>
View lead
</button>
)}
</div>
)}
<Card title="Underwriting decision">
{lead?.eligibility_notes && (
<div className="text-sm text-muted bg-app border border-border-subtle rounded-md px-3.5 py-2.5 mb-4">
<span className="font-semibold text-strong">Eligibility notes:</span> {lead.eligibility_notes}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<Select
label="Underwriting status"
options={statusOptions}
value={status}
onChange={(e) => setStatus(e.target.value)}
/>
</div>
<div className="text-xs text-faint mt-2">
Underwriting reference is auto-generated on submit (UW-YYYY-####).
</div>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Record verdict'}
</Button>
</div>
</Card>
</div>
{/* RIGHT — auto-computed assessment, visible on open */}
<div className="flex flex-col gap-4">
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-3.5">
Underwriting assessment
</div>
{a && a.ok ? (
<>
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Applicant age</span>
<span className="text-sm font-semibold text-on-navy nums">{a.age ?? '—'}</span>
</div>
<div className="flex justify-between mb-2">
<span className="text-sm text-on-navy-muted">Sum assured</span>
<span className="text-sm font-semibold text-on-navy nums">{formatINR(a.sumAssured)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-on-navy-muted">Eligibility</span>
<span className="text-sm font-semibold text-on-navy">{lead?.eligibility_status ?? '—'}</span>
</div>
<div className="h-px bg-[rgba(255,255,255,0.12)] my-4" />
<div className="mb-3">
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-1">
Financial
</div>
<div className={a.financialRequired ? 'text-sm font-semibold text-sunrise-300' : 'text-sm text-emerald-300'}>
{a.financialText}
</div>
</div>
<div className="mb-3">
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted mb-1">
Medical
</div>
<div className={a.medicalRequired ? 'text-sm font-semibold text-sunrise-300' : 'text-sm text-emerald-300'}>
{a.medicalText}
</div>
</div>
<div className="flex items-center gap-2 mt-4">
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted">Route</span>
{a.route.map((r) => (
<span
key={r}
className="text-xs font-semibold text-on-navy bg-[rgba(255,255,255,0.1)] rounded px-2 py-0.5"
>
{r}
</span>
))}
</div>
</>
) : (
<div className="text-sm text-on-navy-muted">
{record ? 'Not enough data (DOB / income / sum assured) to assess.' : 'Select a lead at Eligibility Assessed.'}
</div>
)}
</div>
<div className="text-xs text-faint px-1">
Assessment is advisory and computed in-app from the lead's data. NML limits are
placeholder values pending the real ABSLI grid.
</div>
</div>
</div>
);
}

View File

@ -1 +0,0 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/api/adapters.ts","./src/api/client.ts","./src/api/config.ts","./src/api/forms.ts","./src/api/leads.tsx","./src/api/provider.tsx","./src/api/types.ts","./src/components/index.ts","./src/components/core/avatar.tsx","./src/components/core/badge.tsx","./src/components/core/button.tsx","./src/components/core/card.tsx","./src/components/core/input.tsx","./src/components/core/multiselect.tsx","./src/components/core/select.tsx","./src/components/core/index.ts","./src/components/form/activityform.tsx","./src/components/form/lookupfield.tsx","./src/components/form/index.ts","./src/components/insurance/aidecisioncard.tsx","./src/components/insurance/channelbadge.tsx","./src/components/insurance/confidencering.tsx","./src/components/insurance/documentuploadcard.tsx","./src/components/insurance/kpicard.tsx","./src/components/insurance/leadrow.tsx","./src/components/insurance/rupeeamount.tsx","./src/components/insurance/segmentbadge.tsx","./src/components/insurance/timelineentry.tsx","./src/components/insurance/workflowstepper.tsx","./src/components/insurance/index.ts","./src/data/mockdata.ts","./src/layout/shell.tsx","./src/lib/cn.ts","./src/pages/login.tsx","./src/screens/assignscreen.tsx","./src/screens/documentsscreen.tsx","./src/screens/lead360screen.tsx","./src/screens/newleadscreen.tsx","./src/screens/pipelinescreen.tsx","./src/screens/recommendscreen.tsx"],"version":"5.9.3"}