222 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|