import { useMemo } from 'react'; import type { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; import { Ban, CheckCircle2, CircleDashed, Download } from 'lucide-react'; import { AIDecisionStream, Avatar, Badge, Card, ChannelBadge, formatINR, SegmentBadge, TimelineEntry, WorkflowStepper, } from '../components'; import { LeadActions, STEP_BY_STATE } from '../components/activities'; import { useQuery, useZino } from '../api/provider'; import { useLeads } from '../api/leads'; import { recordToLead, decisionToCard, auditToTimeline } from '../api/adapters'; import { STAGES, ONBOARD_ACTIVITY_UID } from '../api/config'; import { DISQUALIFY_STATES } from '../api/forms'; // Compact Indian currency for the small stat tiles, so large cover figures // (₹2,00,00,000) never overflow + get clipped by the card. Crore/lakh short // form above ₹1L; full grouping below. function compactINR(n: number): string { if (!n) return '₹0'; if (n >= 1e7) return `₹${+(n / 1e7).toFixed(2)} Cr`; if (n >= 1e5) return `₹${+(n / 1e5).toFixed(2)} L`; return `₹${formatINR(n)}`; } /** A compact label/value tile for the Profile grid — fills the wide card width * far better than full-width key→value rows. */ function ProfileTile({ label, value, mono }: { label: string; value: ReactNode; mono?: boolean }) { return (
{label}
{value}
); } export function Lead360Screen() { const { id } = useParams(); const { client } = useZino(); // Shared single fetch of all leads; pick the one we're viewing. const { records, loading: leadsLoading, refetch } = useLeads(); const record = useMemo( () => records.find((r) => String(r.instance_id) === id) ?? records[0], [records, id], ); const instanceId = record?.instance_id; // No polling — async AI decisions are pulled on demand via the stream's // refresh button (and after any activity advance), so the view never flickers. const decisionsQ = useQuery(() => client.aiDecisions(instanceId!), [instanceId], instanceId != null); const auditQ = useQuery(() => client.audit(instanceId!), [instanceId], instanceId != null); const onAdvanced = () => { refetch(); decisionsQ.refetch(); auditQ.refetch(); }; // Manual refresh of the live AI feed + audit (no reload of the whole lead set). const refreshFeed = () => { decisionsQ.refetch(); auditQ.refetch(); }; if (leadsLoading) { return
Loading lead…
; } if (!record) { return
Lead not found.
; } const lead = recordToLead(record); const decisions = (decisionsQ.data ?? []).map(decisionToCard); const audit = auditQ.data ?? []; const timeline = auditToTimeline(audit); // 5d onboarding outcome: welcome_email_status + policy_doc are written by the // Onboard trigger onto the lead record. onboarding_notes is still a `local` // activity field, so it lives in the audit feed, not the recordview. const onboard = audit.find((e) => e.activity_id === ONBOARD_ACTIVITY_UID)?.data; const welcomeStatus = typeof record.welcome_email_status === 'string' ? record.welcome_email_status : null; const policyDocUrl = record.policy_doc?.[0]?.url ?? null; const onboarded = !!onboard || !!welcomeStatus || !!policyDocUrl; const welcomeSent = welcomeStatus === 'sent'; const welcomeFailed = welcomeStatus === 'failed'; const onboardingNotes = typeof onboard?.onboarding_notes === 'string' ? onboard.onboarding_notes : ''; const issueDate = record.policy_issue_date ? new Date(record.policy_issue_date) : null; const issueLabel = issueDate && !Number.isNaN(issueDate.getTime()) ? issueDate.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : null; const cover = record.sum_assured ?? 0; const premium = record.premium_amount ?? 0; const meeting = record.meeting_datetime ? new Date(record.meeting_datetime) : null; const meetingLabel = meeting && !Number.isNaN(meeting.getTime()) ? meeting.toLocaleString('en-IN', { weekday: 'short', day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true }) : null; const issued = lead.stage >= STAGES.indexOf('Policy Issued'); const state = record.current_state_name ?? ''; const hasAction = !!STEP_BY_STATE[state] || DISQUALIFY_STATES.has(state); return (
{/* HERO — identity + lifecycle + headline figures unified into one band so the page reads as a single record, not a scatter of tiles. */}
{lead.name}
#{lead.id} · {lead.city} · owned by {lead.owner} {meetingLabel ? ` · meeting ${meetingLabel}` : ''}
{record.current_state_name ?? '—'} {hasAction && }
{/* lifecycle stepper */}
{/* headline figures */}
Sum assured
{compactINR(cover)}
Premium
{compactINR(premium)}
Annual income
{compactINR(lead.income)}
{/* BODY — primary work on the left, live AI feed + reference on the right. */}
{/* MAIN */}
{lead.interest}} />
{onboarded && ( {welcomeSent ? 'Welcome sent' : welcomeFailed ? 'Welcome failed' : 'Welcome pending'} } >
Policy number
{record.policy_number || '—'}
Issued
{issueLabel || '—'}
{welcomeSent ? ( ) : welcomeFailed ? ( ) : ( )} Welcome email {welcomeSent ? 'sent' : welcomeFailed ? 'failed to send' : 'pending'}
{policyDocUrl && (
Download policy schedule
)} {onboardingNotes && (
Onboarding notes
{onboardingNotes}
)}
)} {timeline.length ? ( timeline.map((t, i) => ( )) ) : (
No activity recorded yet.
)}
{/* RAIL — live AI decision feed (manual refresh, no polling). */}
); }