lead-to-policy/src/components/activities/RecommendBody.tsx
Bhanu Prakash Sai Potteri 66d4c91f32 Initial import of lead-to-policy (dev env)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:28:12 +05:30

499 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from 'react';
import { CheckCircle2, ChevronRight, Info, ShieldCheck, Sparkles, Wand2 } from 'lucide-react';
import {
AiSuggestionPanel,
Avatar,
Button,
Card,
formatINR,
Input,
MultiSelect,
RupeeAmount,
SelectField,
} from '../';
import { LookupField, SchemaGuard } from '../form';
import type { LookupValue } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { decisionToCard, recommendationFromDecisions } from '../../api/adapters';
import type { AiRecommendation } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import { allowedRidersFor, normalizeRiderValues, recommendCalc, saValidIssues } from '../../lib/recommend';
import type { ActivityBodyProps } from './types';
import type { LeadRecord } from '../../api/types';
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
// Riders arrive as a JSON array, a CSV, or a Postgres array-literal `{a,b}` —
// normalizeRiderValues handles all three.
function toRiderValues(raw: LeadRecord['riders']): string[] {
return normalizeRiderValues(raw);
}
export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid, instanceId), [instanceId]);
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]);
// Premium Amount is designer-disabled (field_defaults.premium_amount_input.disabled):
// the custom_js computes it from the rate-card, so it's read-only, never typed.
const premiumDisabled = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumAmount)?.disabled ?? false, [schemaQ.data]);
const [product, setProduct] = useState<LookupValue | null>(null);
const [sum, setSum] = useState(2000000);
const [freq, setFreq] = useState('annual');
const [premium, setPremium] = useState(0);
const [premiumTouched, setPremiumTouched] = useState(false);
// No seed: riders depend on the picked product. Before a product is chosen the
// field is disabled; the record-load effect fills in any saved riders.
const [riders, setRiders] = useState<string[]>([]);
const [notes, setNotes] = useState('');
// Labels of riders auto-removed on the last product switch (for the inline note).
const [prunedRiders, setPrunedRiders] = useState<string[]>([]);
// Suppress the prune-note on the initial record-driven product set, so reopening
// a lead doesn't flash "removed riders". Re-armed on every record load.
const skipPruneNote = useRef(true);
useEffect(() => {
if (!record) return;
skipPruneNote.current = true;
setPrunedRiders([]);
setProduct((record.recommended_product as LookupValue) ?? null);
setSum(record.sum_assured || 2000000);
setFreq(record.premium_frequency || 'annual');
setRiders(toRiderValues(record.riders));
setNotes(record.recommendation_notes || '');
setPremiumTouched(false);
if (record.premium_amount) {
setPremium(record.premium_amount);
setPremiumTouched(true);
}
}, [record]);
// Riders the picked product permits. null = legacy product value (no
// allowed_riders key) → show ALL options. Empty array = plan allows none.
const allowed = useMemo(() => allowedRidersFor(product as Record<string, unknown> | null), [product]);
const visibleRiderOptions = useMemo(
() => (allowed == null ? riderOptions : riderOptions.filter((o) => allowed.includes(o.value))),
[riderOptions, allowed],
);
const riderLabel = useMemo(() => {
const m = new Map<string, string>();
riderOptions.forEach((o) => m.set(o.value, o.label));
return m;
}, [riderOptions]);
const ridersDisabled = !product || (allowed != null && allowed.length === 0);
const ridersHint = !product
? 'Pick a product first'
: allowed != null && allowed.length === 0
? 'No riders available for this plan'
: undefined;
// On a product switch, drop selected riders the new plan doesn't offer and note
// which were removed. Legacy values (allowed == null) keep every saved rider.
// Keyed on `product` only; setRiders fires solely when the set actually shrinks,
// so there's no render loop.
useEffect(() => {
if (allowed == null) {
setPrunedRiders([]);
return;
}
const removed = riders.filter((r) => !allowed.includes(r));
if (removed.length) setRiders(riders.filter((r) => allowed.includes(r)));
if (skipPruneNote.current) {
skipPruneNote.current = false;
setPrunedRiders([]);
return;
}
setPrunedRiders(removed.map((r) => riderLabel.get(r) ?? r));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [product]);
// Mirror the designer custom_js: rate-card premium + sa_valid (the stage-gate
// field). Without sa_valid the lead can't advance past Meeting Scheduled.
const calc = recommendCalc(product, record?.date_of_birth, sum, record?.annual_income);
// When the field is designer-disabled, the premium is always the computed
// rate-card value — a manual override (premiumTouched) can't apply.
const effectivePremium = !premiumDisabled && premiumTouched ? premium : calc.premium;
const issues = saValidIssues(calc);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
// One-click apply of Aria Advisor's pre-activity recommendation: sets the sum
// assured straight off, and resolves her product *name* against the live
// catalogue into the lookup value the field needs. The agent can still edit.
const [applying, setApplying] = useState(false);
const [applyNote, setApplyNote] = useState<string | null>(null);
async function applyRecommendation(rec: AiRecommendation) {
setApplyNote(null);
if (rec.sumAssured && rec.sumAssured > 0) {
setSum(rec.sumAssured);
setPremiumTouched(false); // let the rate-card premium recompute
}
if (!productField?.lookupTemplate) return;
setApplying(true);
try {
const { records } = await client.lookupRecords(productField.lookupTemplate, {
activityId: ACTIVITIES.RECOMMEND_PRODUCT.uid,
fieldId: F.product,
instanceId,
search: '',
limit: 50,
});
const match = matchProduct(rec.product, records, productField.lookupDisplay ?? []);
if (match) {
const stored: LookupValue = {};
(productField.lookupStorage ?? []).forEach((c) => (stored[c] = match[c] ?? null));
(productField.lookupDisplay ?? []).forEach((c) => {
if (!(c in stored)) stored[c] = match[c] ?? null;
});
setProduct(stored);
} else {
setApplyNote(`Couldnt match “${rec.product}” to the catalogue — pick the product manually.`);
}
} catch {
setApplyNote('Couldnt load the product catalogue — pick the product manually.');
} finally {
setApplying(false);
}
}
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
async function submit() {
if (!product) {
setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
return;
}
setSubmitting(true);
setResult(null);
try {
const res = await client.performActivity(instanceId, ACTIVITIES.RECOMMEND_PRODUCT.uid, {
[F.product]: product,
[F.sumAssured]: sum,
[F.premiumAmount]: effectivePremium,
[F.premiumFrequency]: freq,
[F.riders]: riders,
[F.notes]: notes,
[F.saValid]: calc.saValid,
});
setResult({
ok: true,
msg: calc.saValid
? 'Recommendation submitted — lead advanced to Product Recommended.'
: 'Recommendation saved, but the sum assured is outside the plans eligible range — the lead stays at Meeting Scheduled until corrected.',
});
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
setSubmitting(false);
}
}
// Product / frequency / riders render from the live schema. Without a guard a
// failed schema fetch (most often an RBAC denial — only some roles can
// recommend) silently drops those fields, leaving a broken half-form with no
// explanation. SchemaGuard surfaces loading + permission/error instead.
return (
<SchemaGuard
loading={schemaQ.loading}
error={schemaQ.error}
empty={!productField}
action="recommend a product 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>
<div className="grid grid-cols-2 gap-4">
{productField && (
<LookupField
label={productField.label}
required={productField.required}
templateUid={productField.lookupTemplate!}
displayCols={productField.lookupDisplay ?? []}
storageCols={productField.lookupStorage ?? []}
activityId={ACTIVITIES.RECOMMEND_PRODUCT.uid}
fieldId={F.product}
instanceId={instanceId}
value={product}
onChange={setProduct}
/>
)}
<Input
label="Sum assured"
prefix="₹"
type="number"
value={sum}
onChange={(e) => setSum(Number(e.target.value) || 0)}
/>
<SelectField
label="Premium frequency"
options={freqOptions}
value={freq}
onChange={setFreq}
/>
<Input
label="Premium amount"
prefix="₹"
type="number"
value={effectivePremium}
disabled={premiumDisabled}
hint={
premiumDisabled
? calc.evaluated
? 'Auto-calculated from the plans rate-card'
: 'Pick a product to compute the premium'
: premiumTouched
? undefined
: calc.evaluated
? `Rate-card — ₹${formatINR(calc.premium)}`
: undefined
}
onChange={(e) => {
if (premiumDisabled) return;
setPremium(Number(e.target.value) || 0);
setPremiumTouched(true);
}}
/>
<div className="col-span-full">
<MultiSelect
label="Riders"
options={visibleRiderOptions}
value={riders}
onChange={setRiders}
disabled={ridersDisabled}
hint={ridersHint}
/>
{prunedRiders.length > 0 && (
<div className="mt-2 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
<Info size={13} className="mt-0.5 shrink-0" />
<span>Removed {prunedRiders.join(', ')} not offered on this plan.</span>
</div>
)}
</div>
<div className="col-span-full">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-muted">Notes for the customer</span>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Why this product fits the lead…"
className="font-sans text-base text-strong border border-border-default rounded-md px-3 py-2.5 resize-y outline-none focus:border-sunrise-500"
/>
</label>
</div>
</div>
{issues.length > 0 && (
<div className="mt-4 flex items-start gap-2.5 rounded-md border border-amber-300 bg-escalated-soft px-4 py-3 text-sm text-amber-800">
<CheckCircle2 size={16} className="mt-0.5 shrink-0 rotate-45 text-amber-600" />
<div>
<div className="font-semibold">Sum assured not yet eligible lead wont advance</div>
<div className="mt-0.5 text-xs text-amber-700">{issues.join('; ')}.</div>
</div>
</div>
)}
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Send recommendation'}
</Button>
</div>
</Card>
</div>
<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">
{premiumTouched ? 'Premium' : 'Indicative premium'}
</div>
<RupeeAmount value={effectivePremium} size="hero" tone="onNavy" />
<div className="text-sm text-on-navy-muted mt-1.5">
{(freqOptions.find((f) => f.value === freq)?.label ?? freq).toLowerCase()} · excl. 18% GST
</div>
<div className="h-px bg-[rgba(255,255,255,0.12)] my-4" />
<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(sum)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-on-navy-muted">Cover multiple</span>
<span className="text-sm font-semibold text-on-navy">{incomeMultiple}× income</span>
</div>
</div>
<RecommendAiSuggestion
instanceId={instanceId}
onApply={applyRecommendation}
applying={applying}
applyNote={applyNote}
/>
</div>
</div>
</SchemaGuard>
);
}
/** Match Aria's free-text product name to a catalogue row by case/punctuation-
* insensitive containment (longest matching label wins). */
function matchProduct(
aiName: string,
rows: Array<Record<string, unknown>>,
displayCols: string[],
): Record<string, unknown> | null {
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const ai = norm(aiName);
if (!ai) return null;
let best: Record<string, unknown> | null = null;
let bestLen = 0;
for (const row of rows) {
// Try each display column on its own *and* the joined label — a product
// name like "DigiShield" must still match a multi-column "DigiShield · term".
const candidates = [...displayCols.map((c) => row[c]), displayCols.map((c) => row[c]).join(' ')]
.map((v) => (v == null ? '' : norm(String(v))))
.filter(Boolean);
for (const n of candidates) {
if ((ai.includes(n) || n.includes(ai)) && n.length > bestLen) {
best = row;
bestLen = n.length;
}
}
}
return best;
}
interface RecommendAiSuggestionProps {
instanceId: number | string;
onApply: (rec: AiRecommendation) => void;
applying: boolean;
applyNote: string | null;
}
function RecommendAiSuggestion({ instanceId, onApply, applying, applyNote }: RecommendAiSuggestionProps) {
const { client } = useZino();
const q = useQuery(() => client.aiDecisions(Number(instanceId)), [instanceId]);
const decisions = q.data ?? [];
const rec = recommendationFromDecisions(decisions);
// No structured recommendation yet → fall back to the generic latest-decision
// card so the agent still sees whatever Aria last did.
if (!rec) {
const latest = decisions[0];
if (!latest) return null;
return <AiSuggestionPanel {...decisionToCard(latest)} />;
}
return (
<>
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">Arias recommendation</div>
<AriaRecommendationCard rec={rec} onApply={() => onApply(rec)} applying={applying} applyNote={applyNote} />
</>
);
}
function AriaRecommendationCard({
rec,
onApply,
applying,
applyNote,
}: {
rec: AiRecommendation;
onApply: () => void;
applying: boolean;
applyNote: string | null;
}) {
const [open, setOpen] = useState(false);
const autonomous = rec.confidence >= 0.7;
const accent = autonomous ? 'var(--status-autonomous)' : 'var(--status-escalated)';
const pct = Math.round(Math.max(0, Math.min(1, rec.confidence)) * 100);
return (
<article className="bg-card rounded-2xl border border-border-subtle shadow-sm overflow-hidden font-sans">
{/* header — identity + confidence */}
<header className="flex items-center gap-3 px-5 pt-[18px] pb-3.5">
<Avatar name={rec.employee} ai size={36} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-bold text-strong leading-tight truncate">{rec.employee}</div>
<div className="text-xs text-faint mt-0.5 truncate">
Pre-meeting suggestion{rec.time && <span> · {rec.time}</span>}
</div>
</div>
<span
className="shrink-0 inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-2xs font-bold tracking-[0.02em] nums"
style={{ background: 'transparent', color: accent }}
title={autonomous ? 'Autonomous' : 'Needs review'}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: accent }} />
{pct}%
</span>
</header>
<div className="px-5 pb-5">
{/* recommended product — navy hero tile, echoes the premium card */}
<div className="rounded-xl bg-navy-grad p-4">
<div className="flex items-start gap-3">
<span className="shrink-0 w-11 h-11 rounded-xl bg-sunrise text-white flex items-center justify-center shadow-sunrise">
<ShieldCheck size={22} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 text-2xs font-bold uppercase tracking-[0.06em] text-sunrise-300">
<Sparkles size={11} /> Recommends
</div>
<div className="mt-1 text-lg font-bold text-on-navy leading-snug break-words">{rec.product}</div>
</div>
</div>
{rec.sumAssured != null && (
<div className="mt-3.5 flex items-baseline justify-between gap-2 border-t border-[rgba(255,255,255,0.12)] pt-3.5">
<span className="text-2xs font-bold uppercase tracking-[0.06em] text-on-navy-muted shrink-0">Suggested cover</span>
<span className="font-numeric text-xl font-extrabold text-on-navy nums truncate">{formatINR(rec.sumAssured)}</span>
</div>
)}
</div>
{/* why it recommended this — expands inline, no inner scrollbar */}
{rec.rationale && (
<div className="mt-3.5">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center gap-1.5 bg-none border-none p-0 cursor-pointer font-sans text-xs font-semibold text-link"
>
<ChevronRight size={14} className={open ? 'rotate-90 transition-transform duration-200' : 'transition-transform duration-200'} />
{open ? 'Hide reasoning' : 'Why this product'}
</button>
{open && (
<p className="mt-2.5 px-4 py-3.5 bg-sunk rounded-xl text-sm leading-relaxed text-body whitespace-pre-line">
{rec.rationale}
</p>
)}
</div>
)}
<div className="mt-3.5">
<Button variant="primary" size="sm" full disabled={applying} onClick={onApply} iconLeft={<Wand2 size={14} />}>
{applying ? 'Applying…' : 'Use this suggestion'}
</Button>
{applyNote && <div className="mt-2 text-xs text-amber-700">{applyNote}</div>}
</div>
</div>
</article>
);
}