diff --git a/.gitignore b/.gitignore index 0857eda..272ebba 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ yarn-error.log* # Local env (base URL etc.) .env + +*.tsbuildinfo diff --git a/scripts/build_policy_docx.py b/scripts/build_policy_docx.py new file mode 100644 index 0000000..a87b628 --- /dev/null +++ b/scripts/build_policy_docx.py @@ -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("") + if size: # half-points + props.append(f'') + if color: + props.append(f'') + if props: + rpr = "" + "".join(props) + "" + return (f'{rpr}{escape(text)}') + + +def para(runs, *, align=None, space_after=120, shading=None): + ppr_bits = [] + if align: + ppr_bits.append(f'') + if shading: + ppr_bits.append(f'') + ppr_bits.append(f'') + ppr = "" + "".join(ppr_bits) + "" + body = runs if isinstance(runs, str) else "".join(runs) + return f"{ppr}{body}" + + +def cell(text_runs, *, w=4500, fill=None): + shd = f'' if fill else "" + tcpr = f'{shd}' + return f"{tcpr}{para(text_runs, space_after=40)}" + + +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"{left}{right}" + + +def table(rows): + borders = ( + "" + '' + '' + '' + '' + '' + '' + "" + ) + tblpr = f'{borders}' + grid = '' + return f"{tblpr}{grid}{''.join(rows)}" + + +# ---- 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 = ( + '\n' + f'' + "" + + "".join(body_parts) + + '' + '' + "" +) + +CONTENT_TYPES = ( + '\n' + '' + '' + '' + '' + "" +) + +RELS = ( + '\n' + '' + '' + "" +) + +DOC_RELS = ( + '\n' + '' + "" +) + +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)) diff --git a/scripts/kyc-match.check.ts b/scripts/kyc-match.check.ts new file mode 100644 index 0000000..b7593dd --- /dev/null +++ b/scripts/kyc-match.check.ts @@ -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)`); diff --git a/src/App.tsx b/src/App.tsx index 59bf513..11232c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import { Lead360Screen } from './screens/Lead360Screen'; import { AssignScreen } from './screens/AssignScreen'; import { RecommendScreen } from './screens/RecommendScreen'; import { DocumentsScreen } from './screens/DocumentsScreen'; +import { UnderwritingScreen } from './screens/UnderwritingScreen'; +import { IssuePolicyScreen } from './screens/IssuePolicyScreen'; import { NewLeadScreen } from './screens/NewLeadScreen'; import { Login } from './pages/Login'; import { useZino } from './api/provider'; @@ -39,6 +41,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/api/adapters.ts b/src/api/adapters.ts index b6b485b..2139b1f 100644 --- a/src/api/adapters.ts +++ b/src/api/adapters.ts @@ -61,6 +61,41 @@ function toChannel(raw?: string | null): Channel { 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. const STATE_ACTION: Record = { 'New Lead': { action: 'Awaiting qualification', tone: 'neutral' }, @@ -87,8 +122,8 @@ export function recordToLead(r: LeadRecord): Lead { return { id: String(r.instance_id), name: r.lead_name ?? `Lead ${r.instance_id}`, - city: r.city ?? r.state_region ?? r.assigned_region ?? '—', - phone: r.phone ?? '—', + city: r.city ?? r.state_region ?? regionOf(r.assigned_region) ?? '—', + phone: toPhone(r.phone), email: r.email ?? '—', dob: formatDate(r.date_of_birth), age: ageFrom(r.date_of_birth), diff --git a/src/api/client.ts b/src/api/client.ts index 639d791..9a005ef 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -3,6 +3,7 @@ import type { AiDecision, ApiError, AuditEntry, + FormScreenResponse, LoginResponse, RecordViewParams, RecordViewResponse, @@ -163,6 +164,18 @@ export class ZinoClient { ).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 { + return this.request('POST', `/app/${APP_ID}/view/form-screens`, { + activity_id: activityId, + device_type: 'desktop', + ...(instanceId != null ? { instance_id: instanceId } : {}), + }); + } + // --- Workflow execution (core) --- startInstance(activityUid: string, data: Record): Promise { diff --git a/src/api/config.ts b/src/api/config.ts index 64fab9f..c34b29f 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -15,9 +15,10 @@ export const DETAIL_VIEW_IDS = { LEAD: 'aa96f572-b480-4623-ae9c-9ce0c44b8626', } as const; -// Onboard activity uid — its submitted fields (welcome_pack_sent, onboarding_notes) -// are `local`, so they never reach the lead recordview. They DO persist in the -// activity submission, surfaced via the audit feed; Customer 360 reads them there. +// Onboard activity uid. The trigger writes welcome_email_status + policy_doc +// onto the lead record (read from the recordview). onboarding_notes stays a +// `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'; // Activity uids + their field ids (data{} keys). From introspect. @@ -25,7 +26,7 @@ export const ACTIVITIES = { RECOMMEND_PRODUCT: { uid: 'c0e2004e-2d70-45f2-9cd5-734331010906', fields: { - product: 'recommended_product_input', // select + product: 'recommended_product_input', // lookup → tbl_products (see PRODUCT_LOOKUP) sumAssured: 'sum_assured_input', // number premiumAmount: 'premium_amount_input', // number premiumFrequency: 'premium_frequency_input', // select @@ -45,38 +46,27 @@ export const ACTIVITIES = { 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; -// Allowed select/multiselect values (label -> stored value). -export const PRODUCT_OPTIONS = [ - { 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; +// Select options + lookup templates are no longer hardcoded here — screens +// read them live from the form schema (api/schema.ts → fieldFromSchema). // Canonical ordered lifecycle — matches live `current_state_name` 1:1 so a // state name maps straight to a stepper/kanban index. Terminal "Disqualified" diff --git a/src/api/forms.ts b/src/api/forms.ts index 6370215..fa08e63 100644 --- a/src/api/forms.ts +++ b/src/api/forms.ts @@ -1,6 +1,6 @@ -// Declarative activity-form specs for app 385, mirrored 1:1 from the deployed -// workflow config (`activity_data_fields`). Drives the generic -// and the contextual "Next action" panel on Lead 360. +// Activity metadata + the per-state "next action" map. Field schemas are no +// longer mirrored here — fetches them live from the form-screens +// endpoint (see api/schema.ts), so the form layout never drifts from config. export type FieldType = | 'text' @@ -27,6 +27,8 @@ export interface OcrMapping { target_field: string; } +/** A renderable form field. Built at runtime by api/schema.ts from the live + * form-screens config; consumed by and the bespoke screens. */ export interface FieldDef { id: string; label: string; @@ -46,240 +48,80 @@ export interface FieldDef { ocrMappings?: OcrMapping[]; } -export interface ActivityDef { +/** Activity display copy + uid. Fields come from the schema, not from here. */ +export interface ActivityMeta { uid: string; name: string; submitLabel: string; /** Past-tense confirmation, e.g. "advanced to Qualified". */ 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. -export const ACTIVITY_DEFS = { +export const ACTIVITY_META = { capture: { uid: 'cc1a73d5-61e1-4416-af30-2b1eeb623a58', name: 'Capture Lead', submitLabel: 'Create 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: { uid: '6b741e44-37f2-4bb0-90ab-ed139f03b7b3', name: 'Qualify Lead', submitLabel: 'Qualify lead', done: 'advanced to Qualified', - fields: [ - { id: 'lead_score_input', label: 'Lead score', type: 'number', required: true, placeholder: '0–100' }, - { - 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: { uid: 'da61aaa1-003b-4601-9f42-f93ff230497f', name: 'Assign Lead', submitLabel: 'Assign lead', 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: { uid: 'fea6b3a8-846d-4f6f-8e92-033c4cf4b6e2', name: 'Log First Contact', submitLabel: 'Log contact', 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: { uid: 'c444d32f-ed6a-4e36-b8b7-b82e73a141d4', name: 'Schedule Meeting', submitLabel: 'Schedule meeting', 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: { uid: 'ba3d3e7f-0d3f-49f2-a156-6cace33c228a', name: 'Assess Eligibility', submitLabel: 'Submit assessment', 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: { uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730', name: 'Submit Underwriting', submitLabel: 'Submit 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: { uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489', name: 'Issue Policy', submitLabel: 'Issue policy', 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: { uid: 'ae415f4e-a33e-432e-882f-733238de8bcc', name: 'Onboard Customer', submitLabel: 'Complete onboarding', 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: { uid: 'ea99fc60-caba-45c1-afc1-7386a2fa9220', name: 'Disqualify', submitLabel: 'Disqualify lead', 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; +} satisfies Record; -export type ActivityKey = keyof typeof ACTIVITY_DEFS; +export type ActivityKey = keyof typeof ACTIVITY_META; // OCR field configs for Collect Documents (extraction → mapped target inputs). export const OCR_FIELDS: Record = { @@ -296,8 +138,10 @@ export const OCR_FIELDS: Record = { Qualified: { activity: 'assign', route: '/assign', label: 'Assign lead' }, Assigned: { activity: 'contact' }, Contacted: { activity: 'schedule' }, - 'Meeting Scheduled': { activity: 'qualify', route: '/recommend', label: 'Recommend product' }, - 'Product Recommended': { activity: 'qualify', route: '/documents', label: 'Collect documents' }, + 'Meeting Scheduled': { route: '/recommend', label: 'Recommend product' }, + 'Product Recommended': { route: '/documents', label: 'Collect documents' }, 'Documents Collected': { activity: 'assess' }, - 'Eligibility Assessed': { activity: 'underwrite' }, - Underwriting: { activity: 'issue' }, + 'Eligibility Assessed': { route: '/underwriting', label: 'Submit underwriting' }, + Underwriting: { route: '/issue', label: 'Issue policy' }, 'Policy Issued': { activity: 'onboard' }, }; diff --git a/src/api/provider.tsx b/src/api/provider.tsx index 9324e04..9ae3150 100644 --- a/src/api/provider.tsx +++ b/src/api/provider.tsx @@ -74,11 +74,14 @@ export interface QueryState { /** * Fire `fn` whenever a dependency in `deps` changes. Tracks loading/error and * 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( fn: () => Promise, deps: unknown[], enabled = true, + refetchMs?: number, ): QueryState { const [data, setData] = useState(null); const [loading, setLoading] = useState(enabled); @@ -111,5 +114,14 @@ export function useQuery( // eslint-disable-next-line react-hooks/exhaustive-deps }, [...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 }; } diff --git a/src/api/schema.ts b/src/api/schema.ts new file mode 100644 index 0000000..2604e61 --- /dev/null +++ b/src/api/schema.ts @@ -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 = { + 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; +} diff --git a/src/api/types.ts b/src/api/types.ts index 5c7e88d..0312403 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -44,6 +44,51 @@ export interface RecordViewParams { 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) --- export interface AuditEntry { @@ -84,12 +129,42 @@ export interface ActivityResult { 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. export interface LeadRecord { instance_id: number; current_state_name?: string | null; lead_name?: string | null; - phone?: string | null; + phone?: string | PhoneValue | null; email?: string | null; city?: string | null; state_region?: string | null; @@ -102,11 +177,11 @@ export interface LeadRecord { lead_segment?: string | null; channel_source?: string | null; owner_agent?: { id: number; name: string } | null; - assigned_region?: string | null; + assigned_region?: string | RegionValue | null; sum_assured?: number | null; premium_amount?: number | null; premium_frequency?: string | null; - recommended_product?: string | null; + recommended_product?: string | ProductLookup | null; riders?: string | string[] | null; recommendation_notes?: string | null; meeting_datetime?: string | null; @@ -114,9 +189,17 @@ export interface LeadRecord { doc_status?: string | null; eligibility_status?: string | null; eligibility_notes?: string | null; + verified_income?: number | null; + underwriting_ref?: string | null; + underwriting_status?: string | null; pan_number?: string | null; aadhaar_number?: string | null; policy_number?: 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; } diff --git a/src/components/form/ActivityForm.tsx b/src/components/form/ActivityForm.tsx index 16ddcf1..4fdbe1f 100644 --- a/src/components/form/ActivityForm.tsx +++ b/src/components/form/ActivityForm.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { CheckCircle2 } from 'lucide-react'; import { Button } from '../core/Button'; import { Input } from '../core/Input'; @@ -6,13 +6,15 @@ import { Select } from '../core/Select'; import { MultiSelect } from '../core/MultiSelect'; import { LookupField } from './LookupField'; import type { LookupValue } from './LookupField'; -import { useZino } from '../../api/provider'; -import type { ActivityDef, FieldDef } from '../../api/forms'; +import { useQuery, useZino } from '../../api/provider'; +import { schemaToFields } from '../../api/schema'; +import type { ActivityMeta, FieldDef } from '../../api/forms'; import type { LeadRecord } from '../../api/types'; import type { ActivityResult } from '../../api/types'; 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. */ instanceId?: number | string; /** 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 - * performActivity (or startInstance when no instanceId is given). */ -export function ActivityForm({ def, instanceId, record, onSuccess, successMessage }: ActivityFormProps) { +/** Renders an activity's fields from the live form-screens schema and submits + * via performActivity (or startInstance when no instanceId is given). */ +export function ActivityForm({ meta, instanceId, record, onSuccess, successMessage }: ActivityFormProps) { const { client } = useZino(); - const [values, setValues] = useState>(() => { - const init: Record = {}; - def.fields.forEach((f) => (init[f.id] = seedFor(f, record))); - return init; - }); + const schemaQ = useQuery( + () => client.formSchema(meta.uid, instanceId), + [meta.uid, instanceId], + ); + const fields = useMemo( + () => (schemaQ.data ? schemaToFields(schemaQ.data) : []), + [schemaQ.data], + ); + + const [values, setValues] = useState>({}); const [submitting, setSubmitting] = useState(false); 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 = {}; + 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 missing = useMemo( () => - def.fields.filter((f) => { + fields.filter((f) => { if (!f.required) return false; const v = values[f.id]; if (Array.isArray(v)) return v.length === 0; return v === '' || v === null || v === undefined; }), - [def.fields, values], + [fields, values], ); 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. const data: Record = {}; - for (const f of def.fields) { + for (const f of fields) { const v = values[f.id]; if (v === '' || v === null || v === undefined) continue; if (Array.isArray(v) && v.length === 0) continue; @@ -94,9 +109,9 @@ export function ActivityForm({ def, instanceId, record, onSuccess, successMessag try { const res = instanceId != null - ? await client.performActivity(instanceId, def.uid, data) - : await client.startInstance(def.uid, data); - setResult({ ok: true, msg: successMessage ?? `${def.name} ${def.done}.` }); + ? await client.performActivity(instanceId, meta.uid, data) + : await client.startInstance(meta.uid, data); + setResult({ ok: true, msg: successMessage ?? `${meta.name} ${meta.done}.` }); onSuccess?.(res); } catch (e) { 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
Loading form…
; + } + if (schemaQ.error || !fields.length) { + return ( +
+ {schemaQ.error ? `Couldn't load the form: ${schemaQ.error}` : 'This activity has no input fields.'} +
+ ); + } + return (
- {def.fields.map((f) => ( + {fields.map((f) => ( set(f.id, v)} - activityId={def.uid} + activityId={meta.uid} instanceId={instanceId} /> ))} @@ -135,7 +161,7 @@ export function ActivityForm({ def, instanceId, record, onSuccess, successMessag
diff --git a/src/components/form/LookupField.tsx b/src/components/form/LookupField.tsx index d1d328f..537034a 100644 --- a/src/components/form/LookupField.tsx +++ b/src/components/form/LookupField.tsx @@ -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 { cn } from '../../lib/cn'; import { useZino } from '../../api/provider'; @@ -23,7 +24,8 @@ export interface LookupFieldProps { } /** 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({ label, required, @@ -41,7 +43,9 @@ export function LookupField({ const [search, setSearch] = useState(''); const [rows, setRows] = useState>>([]); const [loading, setLoading] = useState(false); - const boxRef = useRef(null); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null); const labelOf = (src: LookupValue | Record | null) => src @@ -51,6 +55,38 @@ export function LookupField({ .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>) => { + const cols = storageCols.length ? storageCols : displayCols; + const seen = new Set(); + 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. useEffect(() => { if (!open) return; @@ -60,7 +96,7 @@ export function LookupField({ client .lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50 }) .then((r) => { - if (live) setRows(r.records ?? []); + if (live) setRows(dedupe(r.records ?? [])); }) .catch(() => { if (live) setRows([]); @@ -75,11 +111,13 @@ export function LookupField({ }; }, [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(() => { if (!open) return; 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); return () => document.removeEventListener('mousedown', onDoc); @@ -99,7 +137,7 @@ export function LookupField({ const current = labelOf(value); return ( -
))} - + ); } -function Topbar({ title, subtitle }: { title: string; subtitle: string }) { +function Topbar({ title, subtitle, onMenu }: { title: string; subtitle: string; onMenu: () => void }) { const { user, logout } = useZino(); const navigate = useNavigate(); return ( -
+
+
-

{title}

- {subtitle &&
{subtitle}
} +

{title}

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

AI decision stream

- {decisions.length} actions -
- - {escalations.length > 0 && ( -
- -
-
- {escalations.length} escalation{escalations.length > 1 ? 's' : ''} need you -
-
- {escalations[0].employee} · confidence {Math.round(escalations[0].confidence * 100)}%. -
-
-
- )} - - {decisionsQ.loading &&
Loading decisions…
} - {!decisionsQ.loading && decisions.length === 0 && ( -
No AI decisions for this lead yet.
- )} - {decisions.map((d, i) => ( - - ))} -
+ {/* RIGHT — AI decision stream + escalations. + At lg it drops to a full-width row beneath profile + center; at xl it's the 3rd column. */} +
); } diff --git a/src/screens/NewLeadScreen.tsx b/src/screens/NewLeadScreen.tsx index 319957d..a6289cb 100644 --- a/src/screens/NewLeadScreen.tsx +++ b/src/screens/NewLeadScreen.tsx @@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom'; import { Card } from '../components'; import { ActivityForm } from '../components/form'; import { useLeads } from '../api/leads'; -import { ACTIVITY_DEFS } from '../api/forms'; +import { ACTIVITY_META } from '../api/forms'; /** Capture Lead — the INIT activity. Creates a new instance via /start. */ export function NewLeadScreen() { @@ -13,7 +13,7 @@ export function NewLeadScreen() {
{ refetch(); diff --git a/src/screens/PipelineScreen.tsx b/src/screens/PipelineScreen.tsx index df49947..44f0248 100644 --- a/src/screens/PipelineScreen.tsx +++ b/src/screens/PipelineScreen.tsx @@ -226,14 +226,14 @@ export function PipelineScreen() { )} {/* KPI row */} -
+
{kpis.map((k, i) => ( ))}
{/* charts */} -
+
Live}> @@ -264,25 +264,27 @@ export function PipelineScreen() { {loading ? (
Loading leads…
) : view === 'table' ? ( -
-
+
+
+ Lead + Score + Segment + Channel + Owner + Last action +
+ {leads.length ? ( + leads.map((l) => openLead(l)} />) + ) : ( +
No leads yet.
)} - > - Lead - Score - Segment - Channel - Owner - Last action
- {leads.length ? ( - leads.map((l) => openLead(l)} />) - ) : ( -
No leads yet.
- )}
) : (
diff --git a/src/screens/RecommendScreen.tsx b/src/screens/RecommendScreen.tsx index ed6e7f8..5e23258 100644 --- a/src/screens/RecommendScreen.tsx +++ b/src/screens/RecommendScreen.tsx @@ -12,10 +12,13 @@ import { RupeeAmount, Select, } from '../components'; +import { LookupField } from '../components/form'; +import type { LookupValue } from '../components/form'; import { useQuery, useZino } from '../api/provider'; import { useLeads } from '../api/leads'; -import { decisionToCard } from '../api/adapters'; -import { ACTIVITIES, FREQUENCY_OPTIONS, PRODUCT_OPTIONS, RIDER_OPTIONS } from '../api/config'; +import { decisionToCard, regionOf } from '../api/adapters'; +import { fieldFromSchema } from '../api/schema'; +import { ACTIVITIES } from '../api/config'; import type { LeadRecord } from '../api/types'; const F = ACTIVITIES.RECOMMEND_PRODUCT.fields; @@ -52,6 +55,13 @@ export function RecommendScreen() { const { records, refetch } = useLeads(); + // Field config (product lookup template, frequency + rider options) from the + // live form schema, not hardcoded. + const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid), []); + const productField = useMemo(() => fieldFromSchema(schemaQ.data, F.product), [schemaQ.data]); + const freqOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumFrequency)?.options ?? [], [schemaQ.data]); + const riderOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.riders)?.options ?? [], [schemaQ.data]); + // Leads where Recommend Product is valid (state = Meeting Scheduled). const eligible = useMemo( () => records.filter((r) => r.current_state_name === 'Meeting Scheduled'), @@ -70,7 +80,7 @@ export function RecommendScreen() { ); // Form state — seeded from the instance's existing values when present. - const [product, setProduct] = useState('term'); + const [product, setProduct] = useState(null); const [sum, setSum] = useState(2000000); const [freq, setFreq] = useState('annual'); const [premium, setPremium] = useState(0); @@ -80,7 +90,7 @@ export function RecommendScreen() { useEffect(() => { if (!record) return; - setProduct(record.recommended_product || 'term'); + setProduct((record.recommended_product as LookupValue) ?? null); setSum(record.sum_assured || 2000000); setFreq(record.premium_frequency || 'annual'); setRiders(toRiderValues(record.riders)); @@ -100,6 +110,10 @@ export function RecommendScreen() { async function submit() { if (!record) return; + if (!product) { + setResult({ ok: false, msg: 'Pick a product from the catalogue.' }); + return; + } setSubmitting(true); setResult(null); try { @@ -124,8 +138,8 @@ export function RecommendScreen() { const incomeMultiple = lead?.annual_income ? (sum / lead.annual_income).toFixed(1) : '—'; return ( -
-
+
+
{/* context strip + lead picker */}
{lead ? ( @@ -136,7 +150,7 @@ export function RecommendScreen() { {lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
- {lead.assigned_region ?? lead.city ?? '—'} · {lead.product_interest ?? 'No stated interest'} · income ₹ + {regionOf(lead.assigned_region) ?? lead.city ?? '—'} · {lead.product_interest ?? 'No stated interest'} · income ₹ {formatINR(lead.annual_income ?? 0)}
@@ -184,12 +198,20 @@ export function RecommendScreen() {
- ({ + 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]" + /> +
+ + {result && ( +
+ {result.ok && } + {result.msg} + {result.ok && lead && ( + + )} +
+ )} + + + {lead?.eligibility_notes && ( +
+ Eligibility notes: {lead.eligibility_notes} +
+ )} +
+