schema-driven forms + tier-1 fixes
This commit is contained in:
parent
714f8ab8ff
commit
f35e0ce5d2
2
.gitignore
vendored
2
.gitignore
vendored
@ -17,3 +17,5 @@ yarn-error.log*
|
|||||||
|
|
||||||
# Local env (base URL etc.)
|
# Local env (base URL etc.)
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
173
scripts/build_policy_docx.py
Normal file
173
scripts/build_policy_docx.py
Normal 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))
|
||||||
64
scripts/kyc-match.check.ts
Normal file
64
scripts/kyc-match.check.ts
Normal 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)`);
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
190
src/api/forms.ts
190
src/api/forms.ts
@ -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: '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: {
|
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, ActivityMeta>;
|
||||||
],
|
|
||||||
},
|
|
||||||
} satisfies Record<string, ActivityDef>;
|
|
||||||
|
|
||||||
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' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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
79
src/api/schema.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,8 +168,14 @@ 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 &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 50 }}
|
||||||
|
className="bg-card border border-border-default rounded-md shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
<div className="p-2 border-b border-border-subtle">
|
<div className="p-2 border-b border-border-subtle">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -158,7 +203,8 @@ export function LookupField({
|
|||||||
<div className="px-3 py-3 text-sm text-faint">No matches.</div>
|
<div className="px-3 py-3 text-sm text-faint">No matches.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
171
src/components/insurance/AIDecisionStream.tsx
Normal file
171
src/components/insurance/AIDecisionStream.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/insurance/ReasoningBody.tsx
Normal file
98
src/components/insurance/ReasoningBody.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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 customer’s stated preference.',
|
|
||||||
reasoning:
|
|
||||||
'WhatsApp reply indicated availability after 6 PM on weekdays. Cross-checked the assigned agent’s 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 },
|
|
||||||
];
|
|
||||||
@ -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
179
src/lib/kyc-match.ts
Normal 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
117
src/lib/underwriting.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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,16 +132,23 @@ 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"
|
|
||||||
required
|
|
||||||
prefix={<MapPin size={15} />}
|
|
||||||
value={region}
|
|
||||||
onChange={(e) => setRegion(e.target.value)}
|
|
||||||
placeholder="e.g. West / Maharashtra"
|
|
||||||
/>
|
|
||||||
<LookupField
|
<LookupField
|
||||||
label="Owner agent"
|
label={REGION.label}
|
||||||
|
required={REGION.required}
|
||||||
|
templateUid={REGION.lookupTemplate!}
|
||||||
|
displayCols={REGION.lookupDisplay ?? []}
|
||||||
|
storageCols={REGION.lookupStorage ?? []}
|
||||||
|
activityId={DEF.uid}
|
||||||
|
fieldId={REGION.id}
|
||||||
|
instanceId={record?.instance_id}
|
||||||
|
value={region}
|
||||||
|
onChange={setRegion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{OWNER && (
|
||||||
|
<LookupField
|
||||||
|
label={OWNER.label}
|
||||||
templateUid={OWNER.lookupTemplate!}
|
templateUid={OWNER.lookupTemplate!}
|
||||||
displayCols={OWNER.lookupDisplay ?? []}
|
displayCols={OWNER.lookupDisplay ?? []}
|
||||||
storageCols={OWNER.lookupStorage ?? []}
|
storageCols={OWNER.lookupStorage ?? []}
|
||||||
@ -145,6 +158,8 @@ export function AssignScreen() {
|
|||||||
value={agent}
|
value={agent}
|
||||||
onChange={setAgent}
|
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>
|
||||||
|
|||||||
@ -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 you’ve confirmed the documents belong to this lead.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : kyc.anyChecked ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3">
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
OCR identity matches the captured lead (name + DOB).
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
|
||||||
|
|||||||
222
src/screens/IssuePolicyScreen.tsx
Normal file
222
src/screens/IssuePolicyScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,7 +264,8 @@ 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 className="min-w-[760px]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'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',
|
'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',
|
||||||
@ -284,6 +285,7 @@ export function PipelineScreen() {
|
|||||||
<div className="p-10 text-center text-sm text-faint">No leads yet.</div>
|
<div className="p-10 text-center text-sm text-faint">No leads yet.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<KanbanBoard leads={leads} onOpenLead={openLead} />
|
<KanbanBoard leads={leads} onOpenLead={openLead} />
|
||||||
|
|||||||
@ -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}
|
||||||
|
required={productField.required}
|
||||||
|
templateUid={productField.lookupTemplate!}
|
||||||
|
displayCols={productField.lookupDisplay ?? []}
|
||||||
|
storageCols={productField.lookupStorage ?? []}
|
||||||
|
activityId={ACTIVITIES.RECOMMEND_PRODUCT.uid}
|
||||||
|
fieldId={F.product}
|
||||||
|
instanceId={record?.instance_id}
|
||||||
value={product}
|
value={product}
|
||||||
onChange={(e) => setProduct(e.target.value)}
|
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">
|
||||||
|
|||||||
226
src/screens/UnderwritingScreen.tsx
Normal file
226
src/screens/UnderwritingScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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"}
|
|
||||||
Loading…
Reference in New Issue
Block a user