sud-life-insurance/src/screens/PipelineScreen.tsx
2026-07-03 14:52:45 +05:30

303 lines
12 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 { useNavigate } from 'react-router-dom';
import { Columns3, Table } from 'lucide-react';
import { cn } from '../lib/cn';
import { Badge, Card, KpiCard, LeadRow, LEAD_GRID, Pagination } from '../components';
import type { Kpi, Lead, SegmentDatum } from '../types';
import { useLeads } from '../api/leads';
import { STAGES } from '../api/config';
import { AddLeadButton } from '../components/activities';
import { LeadFilters } from '../components/LeadFilters';
import { recordToLead } from '../api/adapters';
import { applyFilters, EMPTY_FILTER, type FilterState } from '../api/filters';
const PAGE_SIZE = 10;
// Funnel milestones (subset of STAGES) — count = leads that have *reached* it.
const FUNNEL_STEPS: Array<{ label: string; stage: string }> = [
{ label: 'New Lead', stage: 'New Lead' },
{ label: 'Qualified', stage: 'Qualified' },
{ label: 'Assigned', stage: 'Assigned' },
{ label: 'Contacted', stage: 'Contacted' },
{ label: 'Meeting', stage: 'Meeting Scheduled' },
{ label: 'Recommended', stage: 'Product Recommended' },
{ label: 'Underwriting', stage: 'Underwriting' },
{ label: 'Issued', stage: 'Policy Issued' },
];
const SEGMENT_COLORS: Record<string, string> = {
Hot: 'var(--sunrise-500)',
Warm: 'var(--amber-500)',
Cold: 'var(--slate-400)',
};
function buildKpis(leads: Lead[]): Kpi[] {
const total = leads.length || 1;
const hot = leads.filter((l) => l.segment === 'Hot').length;
const issuedIdx = STAGES.indexOf('Policy Issued');
const issued = leads.filter((l) => l.stage >= issuedIdx && l.stage >= 0).length;
const aiHandled = leads.filter((l) => l.ownerAi).length;
return [
{ label: 'Total leads', value: String(leads.length) },
{ label: 'Hot leads', value: String(Math.round((hot / total) * 100)), unit: '%' },
{ label: 'Conversion rate', value: String(Math.round((issued / total) * 100)), unit: '%' },
{ label: 'AI-handled', value: String(Math.round((aiHandled / total) * 100)), unit: '%', accent: true },
];
}
function buildFunnel(leads: Lead[]) {
const total = leads.length || 1;
return FUNNEL_STEPS.map((step) => {
const idx = STAGES.indexOf(step.stage as (typeof STAGES)[number]);
const count = leads.filter((l) => l.stage >= idx).length;
return { stage: step.label, count, pct: Math.round((count / total) * 100) };
});
}
function buildSegments(leads: Lead[]): SegmentDatum[] {
return (['Hot', 'Warm', 'Cold'] as const).map((label) => ({
label,
count: leads.filter((l) => l.segment === label).length,
color: SEGMENT_COLORS[label],
}));
}
function FunnelChart({ data }: { data: ReturnType<typeof buildFunnel> }) {
return (
<div className="flex flex-col gap-2.5">
{data.map((f) => (
<div key={f.stage} className="flex items-center gap-3">
<span className="w-24 shrink-0 text-xs font-medium text-muted text-right">{f.stage}</span>
<div className="flex-1 h-[26px] bg-slate-100 rounded-sm overflow-hidden">
<div
className="h-full bg-sunrise rounded-sm flex items-center justify-end pr-2.5 transition-[width] duration-700"
style={{ width: `${Math.max(f.pct, 6)}%` }}
>
<span className="text-2xs font-bold text-white nums">{f.count.toLocaleString('en-IN')}</span>
</div>
</div>
</div>
))}
</div>
);
}
function SegmentDonut({ segments }: { segments: SegmentDatum[] }) {
const total = segments.reduce((a, b) => a + b.count, 0) || 1;
let acc = 0;
const stops = segments
.map((s) => {
const start = (acc / total) * 360;
acc += s.count;
const end = (acc / total) * 360;
return `${s.color} ${start}deg ${end}deg`;
})
.join(',');
return (
<div className="flex items-center gap-5">
<div className="w-[120px] h-[120px] rounded-full relative shrink-0" style={{ background: `conic-gradient(${stops})` }}>
<div className="absolute inset-3.5 rounded-full bg-card flex flex-col items-center justify-center">
<span className="font-numeric font-extrabold text-2xl text-strong leading-none">
{segments.reduce((a, b) => a + b.count, 0).toLocaleString('en-IN')}
</span>
<span className="text-[10px] text-faint font-semibold">active</span>
</div>
</div>
<div className="flex flex-col gap-2.5 flex-1">
{segments.map((s) => (
<div key={s.label} className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-[3px] shrink-0" style={{ background: s.color }} />
<span className="text-sm font-semibold text-body flex-1">{s.label}</span>
<span className="font-numeric font-bold text-md text-strong nums">{s.count.toLocaleString('en-IN')}</span>
</div>
))}
</div>
</div>
);
}
// Minimal card — monochrome text with a single segment dot as the only accent
// (segment already encodes hot/warm/cold, so the score stays neutral).
function KanbanCard({ lead, onClick }: { lead: Lead; onClick: () => void }) {
return (
<div
onClick={onClick}
className="bg-card border border-border-subtle rounded-md shadow-xs p-3 cursor-pointer flex flex-col gap-1.5 transition-all duration-150 hover:shadow-md hover:-translate-y-px"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-semibold text-strong truncate">{lead.name}</div>
<div className="text-2xs text-faint truncate">
{[lead.city, lead.channel].filter(Boolean).join(' · ')}
</div>
</div>
<span className="font-numeric font-bold text-sm text-muted nums shrink-0">{lead.score}</span>
</div>
<div className="flex items-center gap-1.5 text-2xs text-faint min-w-0">
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: SEGMENT_COLORS[lead.segment] ?? 'var(--slate-400)' }}
title={lead.segment}
/>
<span className="truncate">{lead.lastAction}</span>
</div>
</div>
);
}
function KanbanBoard({ leads, onOpenLead }: { leads: Lead[]; onOpenLead: (l: Lead) => void }) {
const cols = STAGES.slice(0, 8);
return (
<div className="flex gap-3.5 overflow-x-auto pb-1.5 scrollbar-slim">
{cols.map((stage, idx) => {
const items = leads.filter((l) => l.stage === idx);
return (
<div key={stage} className="shrink-0 w-[236px] flex flex-col gap-2.5">
<div className="flex items-center justify-between px-1 py-0.5">
<span className="text-xs font-bold text-strong">{stage}</span>
<span className="text-2xs font-bold text-faint bg-slate-100 rounded-pill min-w-5 text-center px-[7px] py-0.5 nums">
{items.length}
</span>
</div>
{/* Card list caps at viewport height and scrolls per-column so a
busy stage doesn't blow up the whole board's height. */}
<div className="flex flex-col gap-2.5 bg-slate-100 rounded-md p-2.5 min-h-[120px] max-h-[70vh] overflow-y-auto scrollbar-slim">
{items.length ? (
items.map((l) => <KanbanCard key={l.id} lead={l} onClick={() => onOpenLead(l)} />)
) : (
<div className="text-2xs text-faint text-center py-4">No leads</div>
)}
</div>
</div>
);
})}
</div>
);
}
function ViewToggle({ view, onChange }: { view: string; onChange: (v: string) => void }) {
const opts: Array<[string, string, typeof Table]> = [
['table', 'Table', Table],
['kanban', 'Kanban', Columns3],
];
return (
<div className="flex gap-0.5 bg-slate-100 rounded-md p-[3px]">
{opts.map(([id, label, Icon]) => (
<button
key={id}
onClick={() => onChange(id)}
className={cn(
'inline-flex items-center gap-1.5 border-none cursor-pointer font-sans text-xs font-semibold px-3 py-1.5 rounded-sm transition-all duration-150',
view === id ? 'bg-card text-strong shadow-xs' : 'bg-transparent text-muted',
)}
>
<Icon size={14} />
{label}
</button>
))}
</div>
);
}
export function PipelineScreen() {
const [view, setView] = useState('table');
const navigate = useNavigate();
const openLead = (l: Lead) => navigate(`/leads/${l.id}`);
const { leads, records, fields, loading, error, refetch } = useLeads();
const [filters, setFilters] = useState<FilterState>(EMPTY_FILTER);
// KPIs / funnel / segments summarize the whole pipeline; the list below is
// what the filter narrows.
const kpis = useMemo(() => buildKpis(leads), [leads]);
const funnel = useMemo(() => buildFunnel(leads), [leads]);
const segments = useMemo(() => buildSegments(leads), [leads]);
const visibleLeads = useMemo(
() => applyFilters(records, filters, fields).map(recordToLead),
[records, filters, fields],
);
// Table-view pagination (the kanban board shows every stage at once). Page is
// clamped in <Pagination> and reset when the filter set changes.
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [filters]);
const start = (Math.min(page, Math.max(1, Math.ceil(visibleLeads.length / PAGE_SIZE))) - 1) * PAGE_SIZE;
const pageLeads = visibleLeads.slice(start, start + PAGE_SIZE);
return (
<div className="flex flex-col gap-5 max-w-[1240px] mx-auto">
{error && (
<div className="text-sm text-amber-700 bg-escalated-soft border border-amber-300 rounded-md px-4 py-3">
Couldnt load leads: {error}
</div>
)}
{/* KPI row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{kpis.map((k, i) => (
<KpiCard key={i} {...k} />
))}
</div>
{/* charts — cards stay equal height (aligned bottoms); the shorter
segments content is vertically centered so there's no dead space. */}
<div className="grid grid-cols-1 lg:grid-cols-[1.7fr_1fr] gap-4">
<Card title="Pipeline funnel" action={<Badge tone="neutral">Live</Badge>}>
<FunnelChart data={funnel} />
</Card>
<Card title="Lead segments" className="flex flex-col" bodyClassName="flex-1 flex items-center">
<SegmentDonut segments={segments} />
</Card>
</div>
{/* leads — table or kanban */}
<Card
title="Leads"
pad={false}
action={
<div className="flex gap-2.5 items-center">
<ViewToggle view={view} onChange={setView} />
<AddLeadButton onDone={refetch} />
</div>
}
>
<LeadFilters records={records} fields={fields} value={filters} onChange={setFilters} />
{loading ? (
<div className="p-10 text-center text-sm text-faint">Loading leads</div>
) : view === 'table' ? (
<div className="overflow-x-auto">
<div className="min-w-[760px]">
<div
className={cn(
'grid gap-3 px-[18px] py-[11px] bg-slate-50 border-b border-border-subtle text-[10px] font-bold uppercase tracking-[0.06em] text-faint',
LEAD_GRID,
)}
>
<span>Lead</span>
<span>Score</span>
<span>Segment</span>
<span>Channel</span>
<span>Owner</span>
<span>Last action</span>
</div>
{pageLeads.length ? (
pageLeads.map((l) => <LeadRow key={l.id} lead={l} onClick={() => openLead(l)} />)
) : (
<div className="p-10 text-center text-sm text-faint">No leads match these filters.</div>
)}
</div>
</div>
) : (
<div className="p-5">
<KanbanBoard leads={visibleLeads} onOpenLead={openLead} />
</div>
)}
{view === 'table' && !loading && (
<Pagination page={page} pageSize={PAGE_SIZE} total={visibleLeads.length} onPage={setPage} />
)}
</Card>
</div>
);
}