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