303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
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">
|
||
Couldn’t 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>
|
||
);
|
||
}
|