lead-to-policy/src/screens/RecommendScreen.tsx
2026-06-23 11:20:31 +05:30

309 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { CheckCircle2 } from 'lucide-react';
import {
AIDecisionCard,
Avatar,
Button,
Card,
formatINR,
Input,
MultiSelect,
RupeeAmount,
Select,
} from '../components';
import { LookupField } from '../components/form';
import type { LookupValue } from '../components/form';
import { useQuery, useZino } from '../api/provider';
import { useLeads } from '../api/leads';
import { decisionToCard, regionOf } from '../api/adapters';
import { fieldFromSchema } from '../api/schema';
import { ACTIVITIES } from '../api/config';
import type { LeadRecord } from '../api/types';
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
// Crude indicative premium — rate per sum assured, adjusted by frequency.
function indicativePremium(sum: number, freq: string): number {
const base = Math.round(sum * 0.00142);
switch (freq) {
case 'monthly':
return Math.round((base / 12) * 1.04);
case 'half_yearly':
return Math.round((base / 2) * 1.02);
case 'quarterly':
return Math.round((base / 4) * 1.03);
default:
return base;
}
}
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);
}
export function RecommendScreen() {
const { client } = useZino();
const navigate = useNavigate();
const [params] = useSearchParams();
const instanceParam = params.get('instance');
const { records, refetch } = useLeads();
// Field config (product lookup template, frequency + rider options) from the
// live form schema, not hardcoded.
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid), []);
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]);
// Leads where Recommend Product is valid (state = Meeting Scheduled).
const eligible = useMemo(
() => records.filter((r) => r.current_state_name === 'Meeting Scheduled'),
[records],
);
const [selectedId, setSelectedId] = useState<string>('');
useEffect(() => {
if (instanceParam) setSelectedId(instanceParam);
else if (!selectedId && eligible[0]) setSelectedId(String(eligible[0].instance_id));
}, [instanceParam, eligible, selectedId]);
const record = useMemo(
() => records.find((r) => String(r.instance_id) === selectedId),
[records, selectedId],
);
// Form state — seeded from the instance's existing values when present.
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);
const [riders, setRiders] = useState<string[]>(['critical_illness', 'accidental_death']);
const [notes, setNotes] = useState('');
useEffect(() => {
if (!record) return;
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]);
const computed = indicativePremium(sum, freq);
const effectivePremium = premiumTouched ? premium : computed;
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function submit() {
if (!record) return;
if (!product) {
setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
return;
}
setSubmitting(true);
setResult(null);
try {
await client.performActivity(record.instance_id, ACTIVITIES.RECOMMEND_PRODUCT.uid, {
[F.product]: product,
[F.sumAssured]: sum,
[F.premiumAmount]: effectivePremium,
[F.premiumFrequency]: freq,
[F.riders]: riders,
[F.notes]: notes,
});
setResult({ ok: true, msg: 'Recommendation submitted — lead advanced to Product Recommended.' });
refetch();
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
setSubmitting(false);
}
}
const lead = record;
const incomeMultiple = lead?.annual_income ? (sum / lead.annual_income).toFixed(1) : '—';
return (
<div className="grid gap-[18px] max-w-[1140px] mx-auto items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{/* context strip + lead picker */}
<div className="flex items-center gap-3 bg-card border border-border-subtle rounded-lg px-[18px] py-3.5 shadow-sm">
{lead ? (
<>
<Avatar name={lead.lead_name ?? `Lead ${lead.instance_id}`} size={40} />
<div className="flex-1">
<div className="text-base font-bold text-strong">
{lead.lead_name ?? `Lead ${lead.instance_id}`} · #{lead.instance_id}
</div>
<div className="text-xs text-faint">
{regionOf(lead.assigned_region) ?? lead.city ?? '—'} · {lead.product_interest ?? 'No stated interest'} · income
{formatINR(lead.annual_income ?? 0)}
</div>
</div>
</>
) : (
<div className="flex-1 text-sm text-faint">Select a lead at the Meeting Scheduled stage.</div>
)}
<Select
options={[
{ value: '', label: eligible.length ? 'Choose lead…' : 'No eligible leads' },
...records.map((r) => ({
value: String(r.instance_id),
label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`,
})),
]}
value={selectedId}
onChange={(e) => {
setSelectedId(e.target.value);
setResult(null);
}}
className="w-[260px]"
/>
</div>
{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}
{result.ok && lead && (
<button
className="ml-auto text-sm font-semibold text-link underline"
onClick={() => navigate(`/leads/${lead.instance_id}`)}
>
View lead
</button>
)}
</div>
)}
<Card title="Recommend product">
<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={record?.instance_id}
value={product}
onChange={setProduct}
/>
)}
<Input
label="Sum assured"
prefix="₹"
type="number"
value={sum}
onChange={(e) => setSum(Number(e.target.value) || 0)}
/>
<Select
label="Premium frequency"
options={freqOptions}
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
<Input
label="Premium amount"
prefix="₹"
type="number"
value={effectivePremium}
hint={premiumTouched ? undefined : `Indicative — ₹${formatINR(computed)}`}
onChange={(e) => {
setPremium(Number(e.target.value) || 0);
setPremiumTouched(true);
}}
/>
<div className="col-span-full">
<MultiSelect
label="Riders"
options={riderOptions}
value={riders}
onChange={setRiders}
/>
</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>
<div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={!record || submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Send recommendation'}
</Button>
</div>
</Card>
</div>
{/* RIGHT — indicative premium helper + AI suggestion */}
<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={record?.instance_id} />
</div>
</div>
);
}
// Pull the latest AI decision for this lead, if any, as the suggestion card.
function RecommendAiSuggestion({ instanceId }: { instanceId?: number }) {
const { client } = useZino();
const q = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null);
const latest = (q.data ?? [])[0];
if (!latest) return null;
const card = decisionToCard(latest);
return (
<>
<div className="text-2xs font-bold uppercase tracking-[0.06em] text-faint">AI suggestion</div>
<AIDecisionCard {...card} defaultExpanded />
</>
);
}