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:
parent
e55a26c963
commit
0bccb9ad96
@ -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<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('');
|
||||
// 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');
|
||||
@ -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<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);
|
||||
@ -231,10 +276,18 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
||||
<div className="col-span-full">
|
||||
<MultiSelect
|
||||
label="Riders"
|
||||
options={riderOptions}
|
||||
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">
|
||||
|
||||
@ -15,6 +15,10 @@ export interface MultiSelectProps {
|
||||
defaultValue?: string[];
|
||||
onChange?: (next: string[]) => void;
|
||||
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. */
|
||||
@ -25,6 +29,8 @@ export function MultiSelect({
|
||||
defaultValue = [],
|
||||
onChange,
|
||||
className,
|
||||
disabled = false,
|
||||
hint,
|
||||
}: MultiSelectProps) {
|
||||
const controlled = value !== undefined;
|
||||
const [internal, setInternal] = useState<string[]>(defaultValue);
|
||||
@ -40,31 +46,64 @@ export function MultiSelect({
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1.5 font-sans', className)}>
|
||||
{label && <span className="text-sm font-medium text-muted">{label}</span>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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 (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => toggle(val)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-[7px] rounded-pill font-sans text-sm font-semibold cursor-pointer border transition-all duration-150',
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{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">
|
||||
{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 (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => toggle(val)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-[7px] rounded-pill font-sans text-sm font-semibold cursor-pointer border transition-all duration-150',
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -74,6 +74,36 @@ export function recommendCalc(
|
||||
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). */
|
||||
export function saValidIssues(c: RecommendCalc): string[] {
|
||||
if (!c.evaluated || c.saValid) return [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user