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 { 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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 [];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user