diff --git a/src/components/activities/RecommendBody.tsx b/src/components/activities/RecommendBody.tsx index 7713a4d..bbaaad9 100644 --- a/src/components/activities/RecommendBody.tsx +++ b/src/components/activities/RecommendBody.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { CheckCircle2, ChevronRight, ShieldCheck, Sparkles, Wand2 } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { CheckCircle2, ChevronRight, Info, ShieldCheck, Sparkles, Wand2 } from 'lucide-react'; import { AiSuggestionPanel, Avatar, @@ -18,19 +18,16 @@ import { decisionToCard, recommendationFromDecisions } from '../../api/adapters' import type { AiRecommendation } from '../../api/adapters'; import { fieldFromSchema } from '../../api/schema'; import { ACTIVITIES } from '../../api/config'; -import { recommendCalc, saValidIssues } from '../../lib/recommend'; +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[] { - if (!raw) return []; - if (Array.isArray(raw)) return raw as string[]; - return String(raw) - .split(',') - .map((s) => s.trim()) - .filter(Boolean); + return normalizeRiderValues(raw); } export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) { @@ -49,11 +46,20 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro const [freq, setFreq] = useState('annual'); const [premium, setPremium] = useState(0); const [premiumTouched, setPremiumTouched] = useState(false); - const [riders, setRiders] = useState(['critical_illness', 'accidental_death']); + // 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'); @@ -66,6 +72,45 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro } }, [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); @@ -231,10 +276,18 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
+ {prunedRiders.length > 0 && ( +
+ + Removed {prunedRiders.join(', ')} — not offered on this plan. +
+ )}