feat: dynamic riders in Recommend form, driven by product allowed_riders

Riders multiselect filters to the picked product's allowed_riders (newly
exposed on the product lookup). Disabled w/ hint until a product is chosen;
auto-prunes now-invalid selections on switch w/ a note. Legacy product values
(no allowed_riders key) fall back to all options. Brace-safe rider normalizer.
MultiSelect gains disabled + hint props.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-29 19:20:07 +05:30
parent e55a26c963
commit 0bccb9ad96
3 changed files with 158 additions and 36 deletions

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { CheckCircle2, ChevronRight, ShieldCheck, Sparkles, Wand2 } from 'lucide-react'; import { CheckCircle2, ChevronRight, Info, ShieldCheck, Sparkles, Wand2 } from 'lucide-react';
import { import {
AiSuggestionPanel, AiSuggestionPanel,
Avatar, Avatar,
@ -18,19 +18,16 @@ import { decisionToCard, recommendationFromDecisions } from '../../api/adapters'
import type { AiRecommendation } from '../../api/adapters'; import type { AiRecommendation } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema'; import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config'; 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 { ActivityBodyProps } from './types';
import type { LeadRecord } from '../../api/types'; import type { LeadRecord } from '../../api/types';
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields; 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[] { function toRiderValues(raw: LeadRecord['riders']): string[] {
if (!raw) return []; return normalizeRiderValues(raw);
if (Array.isArray(raw)) return raw as string[];
return String(raw)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
} }
export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) { export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
@ -49,11 +46,20 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
const [freq, setFreq] = useState('annual'); const [freq, setFreq] = useState('annual');
const [premium, setPremium] = useState(0); const [premium, setPremium] = useState(0);
const [premiumTouched, setPremiumTouched] = useState(false); const [premiumTouched, setPremiumTouched] = useState(false);
const [riders, setRiders] = useState<string[]>(['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<string[]>([]);
const [notes, setNotes] = useState(''); 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(() => { useEffect(() => {
if (!record) return; if (!record) return;
skipPruneNote.current = true;
setPrunedRiders([]);
setProduct((record.recommended_product as LookupValue) ?? null); setProduct((record.recommended_product as LookupValue) ?? null);
setSum(record.sum_assured || 2000000); setSum(record.sum_assured || 2000000);
setFreq(record.premium_frequency || 'annual'); setFreq(record.premium_frequency || 'annual');
@ -66,6 +72,45 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
} }
}, [record]); }, [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 // 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. // field). Without sa_valid the lead can't advance past Meeting Scheduled.
const calc = recommendCalc(product, record?.date_of_birth, sum, record?.annual_income); const calc = recommendCalc(product, record?.date_of_birth, sum, record?.annual_income);
@ -231,10 +276,18 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
<div className="col-span-full"> <div className="col-span-full">
<MultiSelect <MultiSelect
label="Riders" label="Riders"
options={riderOptions} options={visibleRiderOptions}
value={riders} value={riders}
onChange={setRiders} 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>
<div className="col-span-full"> <div className="col-span-full">
<label className="flex flex-col gap-1.5"> <label className="flex flex-col gap-1.5">

View File

@ -15,6 +15,10 @@ export interface MultiSelectProps {
defaultValue?: string[]; defaultValue?: string[];
onChange?: (next: string[]) => void; onChange?: (next: string[]) => void;
className?: string; className?: string;
/** Render chips as read-only (no toggling) and show `hint` instead of being editable. */
disabled?: boolean;
/** Small muted helper line — used for the disabled-state explanation. */
hint?: string;
} }
/** Chip-based multiselect. Click options to toggle; selected render as filled chips. */ /** Chip-based multiselect. Click options to toggle; selected render as filled chips. */
@ -25,6 +29,8 @@ export function MultiSelect({
defaultValue = [], defaultValue = [],
onChange, onChange,
className, className,
disabled = false,
hint,
}: MultiSelectProps) { }: MultiSelectProps) {
const controlled = value !== undefined; const controlled = value !== undefined;
const [internal, setInternal] = useState<string[]>(defaultValue); const [internal, setInternal] = useState<string[]>(defaultValue);
@ -40,6 +46,38 @@ export function MultiSelect({
return ( return (
<div className={cn('flex flex-col gap-1.5 font-sans', className)}> <div className={cn('flex flex-col gap-1.5 font-sans', className)}>
{label && <span className="text-sm font-medium text-muted">{label}</span>} {label && <span className="text-sm font-medium text-muted">{label}</span>}
{disabled ? (
// Read-only: chips don't toggle and a hint explains why (no product yet,
// or the plan offers no riders). Muted so it reads as inactive.
<div className="flex flex-col gap-1.5">
{options.length > 0 && (
<div className="flex flex-wrap gap-2 opacity-60">
{options.map((o) => {
const val = typeof o === 'string' ? o : o.value;
const lab = typeof o === 'string' ? o : o.label;
const on = selected.includes(val);
return (
<span
key={val}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-[7px] rounded-pill font-sans text-sm font-semibold cursor-default border',
on
? 'bg-sunrise-100 text-sunrise-600 border-transparent'
: 'bg-card text-body border-border-default',
)}
>
<span className={cn('flex', on ? 'opacity-100' : 'opacity-40')}>
{on ? <Check size={13} /> : <Plus size={13} />}
</span>
{lab}
</span>
);
})}
</div>
)}
{hint && <span className="text-xs text-faint">{hint}</span>}
</div>
) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{options.map((o) => { {options.map((o) => {
const val = typeof o === 'string' ? o : o.value; const val = typeof o === 'string' ? o : o.value;
@ -65,6 +103,7 @@ export function MultiSelect({
); );
})} })}
</div> </div>
)}
</div> </div>
); );
} }

View File

@ -74,6 +74,36 @@ export function recommendCalc(
return { premium, saValid: okAge && okBounds && okMult, age, okAge, okBounds, okMult, evaluated: true }; return { premium, saValid: okAge && okBounds && okMult, age, okAge, okBounds, okMult, evaluated: true };
} }
/**
* Normalize a rider list into clean string tokens. A Postgres text[] can reach
* the client in three shapes: a real JSON array `["critical_illness", …]`, a
* plain CSV string, or an array-literal string `"{critical_illness,accidental_death}"`.
* Strip surrounding braces + per-token quotes, trim, drop empties.
*/
export function normalizeRiderValues(raw: unknown): string[] {
if (raw == null) return [];
const parts = Array.isArray(raw)
? raw.map((v) => String(v))
: String(raw)
.replace(/^\s*\{|\}\s*$/g, '') // strip the surrounding `{ }` of an array literal
.split(',');
return parts
.map((s) => s.trim().replace(/^["']|["']$/g, '').trim()) // drop quotes around tokens
.filter(Boolean);
}
/**
* Derive the rider values a product permits.
* - key ABSENT null: a legacy value saved before allowed_riders existed; the
* caller should fall back to showing ALL rider options (don't hide saved riders).
* - key PRESENT the normalized list (possibly empty = the plan truly allows none).
*/
export function allowedRidersFor(product: Record<string, unknown> | null | undefined): string[] | null {
if (!product || typeof product !== 'object') return null;
if (!('allowed_riders' in product)) return null; // legacy value — no key at all
return normalizeRiderValues(product.allowed_riders);
}
/** Human reasons a recommendation fails validation (for an inline hint). */ /** Human reasons a recommendation fails validation (for an inline hint). */
export function saValidIssues(c: RecommendCalc): string[] { export function saValidIssues(c: RecommendCalc): string[] {
if (!c.evaluated || c.saValid) return []; if (!c.evaluated || c.saValid) return [];