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 = { 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 }) { return (
{data.map((f) => (
{f.stage}
{f.count.toLocaleString('en-IN')}
))}
); } 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 (
{segments.reduce((a, b) => a + b.count, 0).toLocaleString('en-IN')} active
{segments.map((s) => (
{s.label} {s.count.toLocaleString('en-IN')}
))}
); } // 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 (
{lead.name}
{[lead.city, lead.channel].filter(Boolean).join(' · ')}
{lead.score}
{lead.lastAction}
); } function KanbanBoard({ leads, onOpenLead }: { leads: Lead[]; onOpenLead: (l: Lead) => void }) { const cols = STAGES.slice(0, 8); return (
{cols.map((stage, idx) => { const items = leads.filter((l) => l.stage === idx); return (
{stage} {items.length}
{/* Card list caps at viewport height and scrolls per-column so a busy stage doesn't blow up the whole board's height. */}
{items.length ? ( items.map((l) => onOpenLead(l)} />) ) : (
No leads
)}
); })}
); } function ViewToggle({ view, onChange }: { view: string; onChange: (v: string) => void }) { const opts: Array<[string, string, typeof Table]> = [ ['table', 'Table', Table], ['kanban', 'Kanban', Columns3], ]; return (
{opts.map(([id, label, Icon]) => ( ))}
); } 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(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 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 (
{error && (
Couldn’t load leads: {error}
)} {/* KPI row */}
{kpis.map((k, i) => ( ))}
{/* charts — cards stay equal height (aligned bottoms); the shorter segments content is vertically centered so there's no dead space. */}
Live}>
{/* leads — table or kanban */}
} > {loading ? (
Loading leads…
) : view === 'table' ? (
Lead Score Segment Channel Owner Last action
{pageLeads.length ? ( pageLeads.map((l) => openLead(l)} />) ) : (
No leads match these filters.
)}
) : (
)} {view === 'table' && !loading && ( )} ); }