lead-to-policy/src/screens/Lead360Screen.tsx
Bhanu Prakash Sai Potteri 6d7b335147 refactor: Lead360 profile tiles + header LeadActions, collapse decision stream
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:42:56 +05:30

267 lines
12 KiB
TypeScript

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 (
<div className="min-w-0 rounded-md bg-slate-50 px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">{label}</div>
<div className={mono ? 'truncate text-sm font-semibold text-strong font-mono' : 'truncate text-sm font-semibold text-strong'}>
{value}
</div>
</div>
);
}
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 <div className="p-10 text-center text-sm text-faint">Loading lead</div>;
}
if (!record) {
return <div className="p-10 text-center text-sm text-faint">Lead not found.</div>;
}
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 (
<div className="mx-auto flex w-full max-w-[1320px] flex-col gap-[18px]">
{/* HERO — identity + lifecycle + headline figures unified into one band so
the page reads as a single record, not a scatter of tiles. */}
<Card bodyClassName="p-0">
<div className="flex flex-col gap-5 p-5">
<div className="flex flex-wrap items-center gap-4">
<Avatar name={lead.name} size={56} className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xl font-bold text-strong truncate">{lead.name}</span>
<SegmentBadge segment={lead.segment} />
<ChannelBadge channel={lead.channel} />
</div>
<div className="text-xs text-faint mt-1">
#{lead.id} · {lead.city} · owned by {lead.owner}
{meetingLabel ? ` · meeting ${meetingLabel}` : ''}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Badge tone={issued ? 'success' : 'warning'} dot>
{record.current_state_name ?? '—'}
</Badge>
{hasAction && <LeadActions record={record} onDone={onAdvanced} />}
</div>
</div>
{/* lifecycle stepper */}
<div className="overflow-x-auto pb-1">
<WorkflowStepper stages={STAGES} current={Math.max(0, lead.stage)} />
</div>
{/* headline figures */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Sum assured</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-strong nums truncate">{compactINR(cover)}</div>
</div>
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Premium</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-sunrise-600 nums truncate">{compactINR(premium)}</div>
</div>
<div className="min-w-0 bg-slate-50 rounded-md px-3.5 py-3 col-span-2 sm:col-span-1">
<div className="text-2xs text-faint mb-1.5">Annual income</div>
<div className="font-numeric text-2xl font-extrabold leading-none text-strong nums truncate">{compactINR(lead.income)}</div>
</div>
</div>
</div>
</Card>
{/* BODY — primary work on the left, live AI feed + reference on the right. */}
<div className="grid items-start gap-[18px] grid-cols-1 lg:grid-cols-[1fr_360px]">
{/* MAIN */}
<div className="flex flex-col gap-[18px] min-w-0">
<Card title="Profile">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<ProfileTile label="Owner" value={lead.owner} />
<ProfileTile label="Phone" value={lead.phone} mono />
<ProfileTile label="Email" value={lead.email} />
<ProfileTile label="Date of birth" value={lead.age ? `${lead.dob} · ${lead.age} yrs` : lead.dob} />
<ProfileTile label="Occupation" value={lead.occupation} />
<ProfileTile label="Annual income" value={compactINR(lead.income)} />
<ProfileTile label="Preferred language" value={lead.language} />
<ProfileTile label="Product interest" value={<Badge tone="accent">{lead.interest}</Badge>} />
</div>
</Card>
{onboarded && (
<Card
title="Policy & onboarding"
action={
<Badge tone={welcomeSent ? 'success' : welcomeFailed ? 'danger' : 'warning'} dot>
{welcomeSent ? 'Welcome sent' : welcomeFailed ? 'Welcome failed' : 'Welcome pending'}
</Badge>
}
>
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Policy number</div>
<div className="text-sm font-semibold text-strong font-mono">
{record.policy_number || '—'}
</div>
</div>
<div className="bg-slate-50 rounded-md px-3.5 py-3">
<div className="text-2xs text-faint mb-1.5">Issued</div>
<div className="text-sm font-semibold text-strong">{issueLabel || '—'}</div>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
{welcomeSent ? (
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
) : welcomeFailed ? (
<Ban size={16} className="text-ruby-600 shrink-0" />
) : (
<CircleDashed size={16} className="text-faint shrink-0" />
)}
<span className="text-sm text-strong">
Welcome email {welcomeSent ? 'sent' : welcomeFailed ? 'failed to send' : 'pending'}
</span>
</div>
{policyDocUrl && (
<div className="mt-3">
<a
href={policyDocUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-sunrise-600 hover:underline"
>
<Download size={15} className="shrink-0" />
Download policy schedule
</a>
</div>
)}
{onboardingNotes && (
<div className="mt-3 pt-3 border-t border-border-subtle">
<div className="text-2xs text-faint mb-1.5">Onboarding notes</div>
<div className="text-sm text-muted whitespace-pre-wrap">{onboardingNotes}</div>
</div>
)}
</Card>
)}
<Card title="Activity log">
{timeline.length ? (
timeline.map((t, i) => (
<TimelineEntry
key={i}
actor={t.actor}
ai={t.ai}
action={t.action}
time={t.time}
tone={t.tone}
last={i === timeline.length - 1}
/>
))
) : (
<div className="text-sm text-faint">No activity recorded yet.</div>
)}
</Card>
</div>
{/* RAIL — live AI decision feed (manual refresh, no polling). */}
<div className="flex flex-col gap-[18px] min-w-0">
<AIDecisionStream
decisions={decisions}
loading={decisionsQ.loading}
onRefresh={refreshFeed}
/>
</div>
</div>
</div>
);
}