// Home — wires to /api/v2/dashboard/home, /dashboard/trend, /tommy/pending-approvals, // /events, /pipeline-counts. All KPIs, activity, and pipeline bars reflect real data. function KPICard({ label, value, delta, deltaLabel, sub, sparkData, sparkColor, breakdown }) { const numeric = typeof delta === 'number'; const up = numeric && delta >= 0; return (
{label}
{sparkData && sparkData.length > 1 && }
{value ?? '—'}
{numeric && ( {up ? '▲' : '▼'} {up ? '+' : ''}{delta}{deltaLabel && deltaLabel.includes('%') ? '' : ''} )} {sub && {sub}}
{breakdown && breakdown.length > 0 && (
{breakdown.map(b => ( {b.label.toUpperCase()} {b.n} ))}
)}
); } function FollowUpRow({ c, onClick }) { const d = parseDate(c.scheduled_at); const short = d ? d.toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' }) : ''; const clock = d ? d.toLocaleTimeString(undefined, { hour:'numeric', minute:'2-digit' }) : '—'; return (
{clock} {short}
{c.name}
{c.interest || (c.stage_raw || c.stage)} · {c.source || 'direct'}
{c.stage_raw || c.stage}
{c.hot ? hot : }
{fmtAgo(c.lastActivity)}
); } function TommyApprovalCard({ a, onApprove, onReject, onDismiss, onOpen }) { const minutes = minutesAgo(a.created_at); const initials = initialsOf(a.contact_name); return (
{initials}
{a.contact_name || 'Unknown'}
{a.hold_reason || 'Needs review'} · {fmtAgo(minutes)}
needs approval
{a.inbound_message && (
Inbound {a.inbound_message}
)}
{a.tommy_draft}
{a.status === 'context_requested' ? 'redrafting…' : 'draft #' + String(a.id).slice(-3)}
); } // Card listing pending appointment_status_checks rows (5-min post-appointment // "did they show?" nudges that haven't been resolved). Parallel surface to // the Telegram channel ping — staff who missed the Telegram message or wants // a persistent view of unresolved appointments uses this card. Resolving // from here POSTs through the same helper as the Telegram inline buttons // (see app/post_appointment.py:resolve_status_check), so the audit trail and // stage transition path are identical across surfaces. function ShowCheckCard({ checks, onResolve, onOpenLead }) { if (!checks || checks.length === 0) { // Hide the card entirely when nothing pending — staff doesn't need to // see an empty inbox box on a typical day. Telegram is the primary // surface; this is the catch-net. return null; } // fmtClock from data.jsx renders the scheduled appointment time in gym-local ET. // Relative "sent ... ago" uses the shared fmtAgoFrom so it rolls into days // past 24h (1d, 2d, ...) like the rest of the app, instead of unbounded hours. return (
Did they show?
{checks.length} pending
{checks.slice(0, 6).map(c => (
c.contact_id && onOpenLead?.(c.contact_id)}> {c.contact_name || c.contact_id}
{fmtClock(c.scheduled_at)} ET · sent {fmtAgoFrom(c.sent_at)}
))} {checks.length > 6 && (
+{checks.length - 6} more pending — resolve via Telegram or open lead.
)}
); } function ActRow({ a }) { const KIND = { contact_created: 'new', day_pass_booked: 'booked', appointment_cancelled: 'cancel', appointment_updated: 'upd', tommy_escalation: 'tommy', tommy_sent: 'sms', sms_inbound: 'sms', }; const label = KIND[a.event_type] || String(a.event_type || 'event').slice(0, 8); const name = a.contact_name || 'System'; const title = a.title || a.event_type || ''; // Backend writes titles like "{contact_name} booked a Day Pass" — when the // title already starts with the name, drop the redundant prefix instead of // rendering "Dominique · Dominique booked a Day Pass". const titleStartsWithName = name !== 'System' && title.toLowerCase().startsWith(name.toLowerCase()); return (
{label}
{titleStartsWithName ? {name}{title.slice(name.length)} : {name} · {title}}
{fmtAgoFrom(a.created_at)}
); } const PERIOD_OPTS = [ { key: '1d', label: '1D' }, { key: 'l7', label: 'L7' }, { key: 'l14', label: 'L14' }, { key: 'l30', label: 'L30' }, { key: 'mtd', label: 'MTD' }, { key: 'ytd', label: 'YTD' }, ]; const PERIOD_SUB = { '1d': 'today', 'l7': 'last 7 days', 'l14': 'last 14 days', 'l30': 'last 30 days', 'mtd': 'month to date', 'ytd': 'year to date', }; function fmtMonthLabel(ym) { const [y, m] = ym.split('-').map(Number); if (!y || !m) return ym; const d = new Date(y, m - 1, 1); return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); } function lastNMonths(n) { const out = []; const now = new Date(); for (let i = 0; i < n; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; out.push({ ym, label: d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) }); } return out; } function MonthPicker({ active, monthValue, onPick }) { // The parent `.period-pill-row` uses `overflow: hidden` to clip its // rounded corners over the active pill's dark background. That same // overflow clip hides any absolutely-positioned child, so the popover // is rendered with `position: fixed` and coords computed from the // button's rect. This also means the popover stays put if the user // scrolls while it's open — acceptable for a 24-row menu. const [open, setOpen] = React.useState(false); const [pos, setPos] = React.useState({ top: 0, left: 0 }); const btnRef = React.useRef(null); const popRef = React.useRef(null); const options = React.useMemo(() => lastNMonths(24), []); const toggle = () => { if (!open && btnRef.current) { const r = btnRef.current.getBoundingClientRect(); // Right-align the 160px-wide popover to the button's right edge setPos({ top: r.bottom + 6, left: r.right - 160 }); } setOpen(v => !v); }; React.useEffect(() => { if (!open) return; function onClick(e) { if (btnRef.current?.contains(e.target)) return; if (popRef.current?.contains(e.target)) return; setOpen(false); } function onKey(e) { if (e.key === 'Escape') setOpen(false); } document.addEventListener('mousedown', onClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey); }; }, [open]); const label = active ? fmtMonthLabel(monthValue) : 'Month'; return ( <> {open && (
{options.map(o => ( ))}
)} ); } function HomeView({ onNavigate, onOpenCmd, onOpenLead, user }) { const [period, setPeriod] = React.useState('1d'); const [monthValue, setMonthValue] = React.useState(() => { // Default to the previous full month so the "Month" button picks // something sensible the first time it's selected. const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; }); const queryString = period === 'month' ? `period=month&month=${encodeURIComponent(monthValue)}` : `period=${period}`; const home = useAsync(() => api.get('/api/v2/dashboard/home?' + queryString), [queryString]); const trend = useAsync(() => api.get('/api/v2/dashboard/trend?' + queryString), [queryString]); const approvals = useAsync(() => api.get('/api/v2/tommy/pending-approvals'), []); const events = useAsync(() => api.get('/api/v2/events?limit=10'), []); const pipeline = useAsync(() => api.get('/api/v2/pipeline-counts'), []); // Post-appointment "did they show?" checks. Persistent queue parallel to // the Telegram nudges — staff who missed the Telegram ping or wants an // audit view sees them here. Endpoint is GET-pending only; the sweeper // in app/post_appointment.py is what populates the table. const showChecks = useAsync(() => api.get('/api/v2/appointment-status-checks?status=pending'), []); // Realtime: when pg_notify fires, reload activity + tommy + show checks React.useEffect(() => { function onEv() { events.reload(); approvals.reload(); home.reload(); showChecks.reload(); } function onLead() { home.reload(); events.reload(); } window.addEventListener('raw:event', onEv); window.addEventListener('raw:lead-created', onLead); return () => { window.removeEventListener('raw:event', onEv); window.removeEventListener('raw:lead-created', onLead); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const greeting = home.data?.greeting_name || (user?.name || 'there').split(' ')[0]; const kpis = home.data?.kpis || {}; // Build sparklines from trend buckets for each KPI const sparkNew = (trend.data?.buckets || []).map(b => b.count || 0); const followUps = React.useMemo(() => { // Fetch follow-ups list from /leads?stage=DAY PASS SCHEDULED, sorted by scheduled_at. return null; }, []); const [fupList, setFupList] = React.useState([]); React.useEffect(() => { api.get('/api/v2/leads?stage=DAY%20PASS%20SCHEDULED&limit=20') .then(r => { const rows = (r?.leads || []).map(adaptLead) .filter(l => l.scheduled_at) .sort((a, b) => (parseDate(a.scheduled_at) || 0) - (parseDate(b.scheduled_at) || 0)); cacheContacts(rows); setFupList(rows); }) .catch(() => setFupList([])); }, []); const approvalList = approvals.data?.approvals || []; const eventList = events.data?.events || []; const pipelineCounts = pipeline.data?.counts || {}; const stageOrder = ['NEW LEAD', 'INTERESTED', 'FOLLOW UP', 'DAY PASS SCHEDULED', 'NEEDS_RESCHEDULE', 'SHOWED']; async function approveOne(a) { try { await api.post('/api/v2/tommy/approve', { approval_id: a.id, message: a.tommy_draft }); approvals.reload(); } catch (e) { if (isQuietHoursError(e) && confirm(quietHoursPrompt(e, 'approve and send'))) { try { await api.post('/api/v2/tommy/approve', { approval_id: a.id, message: a.tommy_draft, override_quiet_hours: true, }); approvals.reload(); return; } catch (e2) { alert(friendlyApiError(e2, 'Approve failed')); return; } } alert(friendlyApiError(e, 'Approve failed')); } } async function rejectOne(a) { try { await api.post('/api/v2/tommy/reject', { approval_id: a.id }); approvals.reload(); } catch (e) { alert(friendlyApiError(e, 'Reject failed')); } } async function dismissOne(a) { try { await api.post('/api/v2/tommy/dismiss', { approval_id: a.id }); approvals.reload(); } catch (e) { alert(friendlyApiError(e, 'Dismiss failed')); } } async function resolveShowCheck(checkId, resolution) { try { const r = await api.post(`/api/v2/appointment-status-checks/${checkId}/resolve`, { resolution }); showChecks.reload(); // Two server-side failure signals can land on a successful POST. // Both are silent in the response body unless we surface them — // staff would otherwise see the row disappear and assume the // customer-facing side effect happened. // - ghl_sync_failed_detail: stage was updated locally, GHL tag // push failed. BOOKED_STAGES preserve keeps local stable. // - rebook_sms_sent=false on a reschedule: the customer never // got the FDP rebook link. const warnings = []; if (resolution === 'reschedule' && r?.rebook_sms_sent === false) { warnings.push('Rebook SMS failed — please send the FDP link to the customer manually.'); } if (r?.ghl_sync_failed_detail) { warnings.push(`GHL sync failed (${r.ghl_sync_failed_detail}). Verify the contact's tags in GHL.`); } if (warnings.length) alert(`Resolved locally.\n\n- ${warnings.join('\n- ')}`); } catch (e) { alert(friendlyApiError(e, 'Resolve failed')); } } const now = new Date(); const dateLine = now.toLocaleDateString(undefined, { weekday:'long', month:'short', day:'numeric', year:'numeric' }); const kpiNew = kpis.day_passes?.value != null ? kpis.day_passes : { value: 0 }; const kpiFollowUps = kpis.follow_ups || { value: 0 }; const kpiJoined = kpis.joined || { value: 0 }; const kpiTommy = kpis.tommy_replies || { value: 0 }; return (
— {dateLine}

{now.getHours() < 12 ? 'Morning' : now.getHours() < 18 ? 'Afternoon' : 'Evening'}, {greeting}.

{home.data ? ( <> {(kpiNew.breakdown || []).find(b => b.label === 'today')?.n ?? 0} day passes today · {approvalList.length} Tommy drafts waiting on you · {eventList.filter(e => !e.is_read).length} unread events ) : home.loading ? 'Loading…' : home.error?.message}
{PERIOD_OPTS.map(p => ( ))} { setMonthValue(v); setPeriod('month'); }} />
Follow-ups · scheduled
{fupList.length} scheduled
{fupList.length === 0 &&
No scheduled follow-ups in range.
} {fupList.slice(0, 8).map(c => ( onNavigate({view:'lead', id:c.id})} /> ))}
Recent activity
live · pg_notify
{eventList.length === 0 && !events.loading &&
No events yet.
} {eventList.map(a => )}
Tommy · waiting on you
{approvalList.length > 0 && {approvalList.length} pending}
{approvalList.length === 0 &&
All caught up. Tommy is auto-sending.
} {approvalList.slice(0, 4).map(a => ( approveOne(a)} onReject={() => rejectOne(a)} onDismiss={() => dismissOne(a)} onOpen={() => onNavigate({view:'lead', id:a.contact_id})} /> ))} {approvalList.length > 4 && (
)}
onNavigate({view:'lead', id})} />
Pipeline
{pipeline.data?.total || 0} contacts · {pipeline.data?.hot_leads || 0} hot
{stageOrder.map(stageRaw => { const count = pipelineCounts[stageRaw] || 0; const group = stageGroupOf(stageRaw); const maxCount = Math.max(1, ...Object.values(pipelineCounts)); const pct = Math.max(4, (count / maxCount) * 100); return (
{stageRaw.toLowerCase()}
{count}
); })}
members · {pipeline.data?.member_count || 0} cold · {pipeline.data?.cold_leads || 0}
); } Object.assign(window, { HomeView });