lead-to-policy/src/components/activities/RecommendBody.tsx
Bhanu Prakash Sai Potteri 1a622b1267 feat: worklist screens + modal-driven activity forms
- state-scoped lead worklists (list → row action → form modal) replace per-activity screens
- shared Modal / ActivityActions / registry; bespoke bodies + generic ActivityForm
- screen RBAC (canSeeScreen + RequireScreen), client-side pagination, lead filters
- fixes: document scroll, PickerShell z-index above modal, friendly RBAC message, Lead360 compact INR, segments-card alignment
2026-06-24 17:04:26 +05:30

234 lines
8.6 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 { CheckCircle2 } from 'lucide-react';
import {
AIDecisionCard,
Button,
Card,
formatINR,
Input,
MultiSelect,
RupeeAmount,
SelectField,
} from '../';
import { LookupField } from '../form';
import type { LookupValue } from '../form';
import { useQuery, useZino } from '../../api/provider';
import { decisionToCard } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config';
import type { ActivityBodyProps } from './types';
import type { LeadRecord } from '../../api/types';
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
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 RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) {
const { client } = useZino();
const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid, instanceId), [instanceId]);
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]);
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);
const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—';
async function submit() {
if (!product) {
setResult({ ok: false, msg: 'Pick a product from the catalogue.' });
return;
}
setSubmitting(true);
setResult(null);
try {
const res = await client.performActivity(instanceId, 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.' });
onSuccess?.(res);
} catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
} finally {
setSubmitting(false);
}
}
return (
<div className="grid gap-[18px] items-start grid-cols-1 lg:grid-cols-[1fr_360px]">
<div className="flex flex-col gap-[18px] min-w-0">
{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}
</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={instanceId}
value={product}
onChange={setProduct}
/>
)}
<Input
label="Sum assured"
prefix="₹"
type="number"
value={sum}
onChange={(e) => setSum(Number(e.target.value) || 0)}
/>
<SelectField
label="Premium frequency"
options={freqOptions}
value={freq}
onChange={setFreq}
/>
<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={submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Send recommendation'}
</Button>
</div>
</Card>
</div>
<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={instanceId} />
</div>
</div>
);
}
function RecommendAiSuggestion({ instanceId }: { instanceId: number | string }) {
const { client } = useZino();
const q = useQuery(() => client.aiDecisions(Number(instanceId)), [instanceId]);
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 />
</>
);
}