// Leads — filterable table wired to GET /api/v2/leads and GET /pipeline-counts. function LeadCard({ c, onClick }) { return (
{initialsOf(c.name)}
{c.name} {c.hot && HOT}
{c.phone || c.email || '—'}
{(c.stage_raw || c.stage || '').toLowerCase()} {c.tommy_status === 'pending_approval' && pending} {c.tommy_status === 'unanswered' && unread} {c.scheduled_label && {c.scheduled_label}} {c.source && {c.source}}
{fmtAgo(c.lastActivity)}
); } function LeadsView({ onNavigate, initialFilter, onOpenLead }) { const isMobile = useMediaQuery('(max-width: 480px)'); const [filter, setFilter] = React.useState(initialFilter || 'all'); const [q, setQ] = React.useState(''); const [debouncedQ, setDebouncedQ] = React.useState(''); const [loading, setLoading] = React.useState(true); const [leads, setLeads] = React.useState([]); const [total, setTotal] = React.useState(0); // Bumped when a new lead is created from the modal; the main fetch // useEffect depends on it and re-runs. const [refreshKey, setRefreshKey] = React.useState(0); const pipeline = useAsync(() => api.get('/api/v2/pipeline-counts'), []); React.useEffect(() => { function onLead() { setRefreshKey(k => k + 1); pipeline.reload(); } window.addEventListener('raw:lead-created', onLead); return () => window.removeEventListener('raw:lead-created', onLead); }, [pipeline.reload]); React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q.trim()), 250); return () => clearTimeout(t); }, [q]); React.useEffect(() => { let live = true; setLoading(true); const qs = new URLSearchParams({ limit: '200' }); if (debouncedQ) qs.set('search', debouncedQ); // Each filter sends the same stages that contribute to its chip // count below (sumStages() calls). Keeping these in sync means // clicking "Booked 104" actually returns 104 rows — previously // it sent only DAY PASS SCHEDULED and returned 100, dropping // the 4 SHOWED + 0 FDP BOOKED rows. Backend accepts a // comma-separated list (see leads.py /api/v2/leads). const stageByFilter = { new: 'NEW LEAD,FIRST TOUCH', contacted: 'INTERESTED,BOT ENGAGED,FOLLOW UP', booked: 'DAY PASS SCHEDULED,SHOWED,FDP BOOKED', reschedule: 'NEEDS_RESCHEDULE', member: 'MEMBER', churned: 'DEAD,NO SHOW,LOST', }; if (filter === 'hot') qs.set('hot', 'true'); else if (stageByFilter[filter]) qs.set('stage', stageByFilter[filter]); api.get('/api/v2/leads?' + qs.toString()) .then(r => { if (!live) return; const rows = (r?.leads || []).map(adaptLead); cacheContacts(rows); setLeads(rows); setTotal(r?.total || rows.length); }) .catch(() => { if (live) { setLeads([]); setTotal(0); } }) .finally(() => { if (live) setLoading(false); }); return () => { live = false; }; }, [filter, debouncedQ, refreshKey]); const pipelineCounts = pipeline.data?.counts || {}; const totalAll = pipeline.data?.total || 0; const hotCount = pipeline.data?.hot_leads || 0; // UI filter chips; counts come from pipeline when available function sumStages(...stages) { return stages.reduce((s, k) => s + (pipelineCounts[k] || 0), 0); } const filters = [ { id: 'all', label: 'All', count: totalAll }, { id: 'hot', label: 'Hot', count: hotCount }, { id: 'new', label: 'New', count: sumStages('NEW LEAD', 'FIRST TOUCH') }, { id: 'contacted', label: 'Contacted', count: sumStages('INTERESTED', 'BOT ENGAGED', 'FOLLOW UP') }, { id: 'booked', label: 'Booked', count: sumStages('DAY PASS SCHEDULED', 'SHOWED', 'FDP BOOKED') }, { id: 'reschedule', label: 'Reschedule', count: sumStages('NEEDS_RESCHEDULE') }, { id: 'member', label: 'Members', count: pipeline.data?.member_count || 0 }, { id: 'churned', label: 'Churned', count: sumStages('DEAD', 'NO SHOW', 'LOST') }, ]; // Client-side filter for free-text search on the loaded page const rows = React.useMemo(() => { if (!debouncedQ) return leads; const n = debouncedQ.toLowerCase(); return leads.filter(c => (c.name || '').toLowerCase().includes(n) || (c.phone || '').toLowerCase().includes(n) || (c.email || '').toLowerCase().includes(n) ); }, [leads, debouncedQ]); return (
— Leads & contacts

All contacts

{pipeline.data?.source === 'db' ? 'Synced from GoHighLevel' : 'Live from GHL'} · {totalAll} total · {hotCount} hot
{filters.map(f => ( ))}
setQ(e.target.value)} placeholder="Filter by name, phone, email…" style={{ height:26, padding:'0 10px', fontSize:12, border:'1px solid var(--line)', background:'var(--bone)', borderRadius:14, outline:'none', minWidth:240, fontFamily:'var(--sans)', }} /> {rows.length} rows{loading ? ' · loading…' : ''}
{isMobile ? (
{!loading && rows.length === 0 &&
No contacts match this filter.
} {rows.map(c => ( onNavigate({view:'lead', id:c.id})} /> ))}
) : (
{!loading && rows.length === 0 && ( )} {rows.map(c => ( onNavigate({view:'lead', id:c.id})}> ))}
Name Stage Source Tags Tommy Scheduled Last activity
No contacts match this filter.
{initialsOf(c.name)}
{c.name} {c.hot && · HOT}
{c.phone || c.email || '—'}
{c.stage_raw || c.stage} {c.source || '—'}
{(c.tags || []).slice(0, 3).map(t => ( {String(t)} ))}
{c.tommy_status === 'pending_approval' && pending} {c.tommy_status === 'unanswered' && unread} {c.tommy_status === 'idle' && (c.tommy_contacted ? contacted : )} {c.scheduled_label || '—'} {fmtAgo(c.lastActivity)}
)}
); } Object.assign(window, { LeadsView });