499 lines
20 KiB
TypeScript
499 lines
20 KiB
TypeScript
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(`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 (
|
||
<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 plan’s 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 won’t 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">Aria’s 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>
|
||
);
|
||
}
|