309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
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 />
|
||
</>
|
||
);
|
||
}
|