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(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([]); const [notes, setNotes] = useState(''); // Labels of riders auto-removed on the last product switch (for the inline note). const [prunedRiders, setPrunedRiders] = useState([]); // 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 | null), [product]); const visibleRiderOptions = useMemo( () => (allowed == null ? riderOptions : riderOptions.filter((o) => allowed.includes(o.value))), [riderOptions, allowed], ); const riderLabel = useMemo(() => { const m = new Map(); 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(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(`Couldn’t match “${rec.product}” to the catalogue — pick the product manually.`); } } catch { setApplyNote('Couldn’t 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 plan’s 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 (
{result && (
{result.ok && } {result.msg}
)}
{productField && ( )} setSum(Number(e.target.value) || 0)} /> { if (premiumDisabled) return; setPremium(Number(e.target.value) || 0); setPremiumTouched(true); }} />
{prunedRiders.length > 0 && (
Removed {prunedRiders.join(', ')} — not offered on this plan.
)}