sud-life-insurance/src/components/activities/UnderwritingBody.tsx
2026-07-03 14:52:45 +05:30

222 lines
8.3 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Sparkles } from 'lucide-react';
import { Button, Card, SelectField } from '../';
import { SchemaGuard } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import type { ActivityBodyProps } from './types';
const F = ACTIVITIES.SUBMIT_UNDERWRITING.fields;
export function UnderwritingBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.SUBMIT_UNDERWRITING.uid, instanceId), [instanceId]);
const statusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.status)?.options ?? [], [schemaQ.data]);
const [status, setStatus] = useState('approved');
useEffect(() => {
if (record?.underwriting_status) setStatus(String(record.underwriting_status));
}, [record]);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
setSubmitting(true);
setResult(null);
try {
const res = await client.performActivity(instanceId, ACTIVITIES.SUBMIT_UNDERWRITING.uid, {
[F.status]: status,
});
setResult({ ok: true, msg: 'Underwriting verdict submitted.' });
onSuccess?.(res);
} catch (e) {
setResult({
ok: false,
msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed'),
});
} finally {
setSubmitting(false);
}
}
return (
<SchemaGuard
loading={schemaQ.loading}
error={schemaQ.error}
action="submit underwriting for this lead"
>
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{result && (
<div
className={
result.ok
? 'flex items-center gap-2 text-sm text-emerald-700 bg-emerald-100 border border-emerald-300 rounded-md px-4 py-3'
: 'text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3'
}
>
{result.ok && <CheckCircle2 size={16} />}
{result.msg}
</div>
)}
<Card title="Underwriting decision">
<div className="grid grid-cols-2 gap-4">
<SelectField
label="Underwriting status"
options={statusOptions}
value={status}
onChange={setStatus}
/>
</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={submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Record verdict'}
</Button>
</div>
</Card>
</div>
<div className="flex flex-col gap-4">
{record?.eligibility_notes ? (
<EligibilityNotes notes={String(record.eligibility_notes)} status={record.eligibility_status} />
) : (
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md text-sm text-on-navy-muted">
No eligibility assessment on file yet Aria Eligibility writes it when documents are collected.
</div>
)}
</div>
</div>
</SchemaGuard>
);
}
// --- Eligibility notes (AI-generated, free-form) -------------------------------
// Aria Eligibility writes eligibility_notes as semi-structured prose:
// "DOCUMENTS: …", "ELIGIBILITY CHECK (…):", "✓ Age …", "REFERRAL REASON: …".
// We parse it leniently into headings / check-items / bullets / paragraphs.
// Nothing is guaranteed — if no structure is detected we fall back to the raw
// text exactly as the model emitted it. Rendered as React children (escaped),
// never dangerouslySetInnerHTML.
type NoteBlock =
| { kind: 'heading'; text: string; inline?: string }
| { kind: 'check'; ok: boolean; text: string }
| { kind: 'bullet'; text: string }
| { kind: 'para'; text: string };
function parseNotes(raw: string): NoteBlock[] {
const blocks: NoteBlock[] = [];
for (const line of raw.split(/\r?\n/)) {
const t = line.trim();
if (!t) continue;
let m: RegExpMatchArray | null;
if ((m = t.match(/^[✓✔☑]\s*(.+)$/))) {
blocks.push({ kind: 'check', ok: true, text: m[1] });
continue;
}
if ((m = t.match(/^[✗✘×✕☒]\s*(.+)$/))) {
blocks.push({ kind: 'check', ok: false, text: m[1] });
continue;
}
if ((m = t.match(/^[-•*]\s+(.+)$/))) {
blocks.push({ kind: 'bullet', text: m[1] });
continue;
}
// Heading: short prefix before a colon whose first word is ALL-CAPS
// (DOCUMENTS, ELIGIBILITY, REFERRAL). Keeps mixed-case tails like
// "(ABSLI DigiShield Plan)" inside the heading.
const colon = t.indexOf(':');
if (colon > 1 && colon <= 60) {
const head = t.slice(0, colon).trim();
const firstWord = (head.split(/\s+/)[0] || '').replace(/[^A-Za-z]/g, '');
if (firstWord.length >= 2 && firstWord === firstWord.toUpperCase()) {
const inline = t.slice(colon + 1).trim();
blocks.push({ kind: 'heading', text: head, inline: inline || undefined });
continue;
}
}
blocks.push({ kind: 'para', text: t });
}
return blocks;
}
function EligibilityNotes({ notes, status }: { notes: string; status?: string | null }) {
const blocks = useMemo(() => parseNotes(notes), [notes]);
const structured = blocks.some((b) => b.kind !== 'para');
const s = (status ?? '').toLowerCase();
// On the navy card, status reads as a tinted text on a translucent chip.
const chipText =
s === 'eligible' ? 'text-emerald-300' : s === 'ineligible' ? 'text-ruby-500' : 'text-amber-300'; // refer / unknown
let first = true;
return (
<div className="bg-navy-grad rounded-lg px-5 py-[22px] shadow-md">
<div className="flex items-center justify-between gap-2 mb-3.5">
<div className="flex items-center gap-1.5 text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted">
<Sparkles size={12} className="text-sunrise-300" />
Eligibility assessment · Aria Eligibility
</div>
{status && (
<span className={`shrink-0 rounded-pill bg-white/10 px-2 py-0.5 text-2xs font-bold ${chipText}`}>
{status}
</span>
)}
</div>
{structured ? (
<div className="flex flex-col gap-1.5">
{blocks.map((b, i) => {
if (b.kind === 'heading') {
const el = (
<div key={i} className={first ? '' : 'mt-3 pt-3 border-t border-white/10'}>
<div className="text-2xs font-bold uppercase tracking-[0.04em] text-on-navy-muted">{b.text}</div>
{b.inline && <div className="mt-1 text-sm text-on-navy leading-relaxed">{b.inline}</div>}
</div>
);
first = false;
return el;
}
if (b.kind === 'check') {
return (
<div key={i} className="flex items-start gap-2 text-sm text-on-navy">
{b.ok ? (
<CheckCircle2 size={15} className="mt-0.5 shrink-0 text-emerald-300" />
) : (
<AlertTriangle size={15} className="mt-0.5 shrink-0 text-amber-300" />
)}
<span>{b.text}</span>
</div>
);
}
if (b.kind === 'bullet') {
return (
<div key={i} className="flex items-start gap-2 text-sm text-on-navy">
<span className="mt-1.5 shrink-0 w-1 h-1 rounded-full bg-on-navy-muted" />
<span>{b.text}</span>
</div>
);
}
return (
<p key={i} className="text-sm text-on-navy leading-relaxed">
{b.text}
</p>
);
})}
</div>
) : (
// Fallback — render the model's output verbatim, line breaks preserved.
<p className="text-sm text-on-navy leading-relaxed whitespace-pre-line">{notes}</p>
)}
</div>
);
}