Files
LynkedUpPro_CRM/src/modules/socialAdsEngine/screens/AttributionDashboard.jsx
T
2026-06-12 16:43:12 +05:30

442 lines
32 KiB
React
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.
/**
* AttributionDashboard — spend, CPL, cost-per-appointment, show rate, close rate,
* revenue, and ROAS by source/campaign (spec §8, §10). Analytics are built from CRM
* events (the analyticsDaily series), not impression-level assumptions.
*/
import React, { useMemo, useState, useCallback } from 'react';
import {
BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy,
Zap, FileText, Crown, Activity, Filter, X, Star, Gift, Repeat, Route,
ThumbsDown, Copy, CalendarX, Pause, Check, ShieldAlert,
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
LineChart, Line, CartesianGrid, Legend,
} from 'recharts';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { totals, metrics, metricsBy, metricsByFn, timeSeries, liveMetrics, reviewTotals, reviewsByFn, attributionByModel, budgetPacing } from '../engine/attribution.js';
import { getPlatform } from '../data/platforms.js';
import { Card, KpiTile, SectionHeader, PlatformBadge } from '../components/ui.jsx';
const money = (n) => `$${Math.round(n).toLocaleString('en-US')}`;
const pct = (n) => `${Math.round(n * 100)}%`;
const mmss = (s) => s == null ? '—' : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
const MODEL_NOTE = {
first: 'First-touch credits 100% to the source that first introduced the customer.',
last: 'Last-touch credits 100% to the final source before the sale (the default single-touch view).',
multi: 'Multi-touch splits credit evenly (linear) across every source in the journey.',
};
const ANOMALY_META = {
cpl_spike: { label: 'CPL spike', icon: TrendingUp, tone: 'red' },
quality_drop: { label: 'Lead quality drop', icon: ThumbsDown, tone: 'amber' },
duplicate_rate: { label: 'Abnormal duplicate rate', icon: Copy, tone: 'amber' },
appt_decline: { label: 'Source-to-appointment decline', icon: CalendarX, tone: 'red' },
};
const PACE_CHIP = {
over: 'text-red-600 dark:text-red-400 bg-red-500/10',
on: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10',
under: 'text-amber-600 dark:text-amber-400 bg-amber-500/10',
};
const PACE_LABEL = { over: 'Pacing hot', on: 'On track', under: 'Under-pacing' };
export default function AttributionDashboard() {
const { analyticsDaily, campaigns, creatives, leads, reviews, journeys, anomalies, resolveAnomaly } = useSocialAds();
const [view, setView] = useState('platform'); // platform | campaign | creative | playbook
const [selected, setSelected] = useState(null); // drill-down: a key within the current view
const [model, setModel] = useState('last'); // attribution model: first | last | multi
// Lookups for the creative + playbook breakdowns (spec §10 dimensions).
const campById = useMemo(() => Object.fromEntries(campaigns.map(c => [c.id, c])), [campaigns]);
const creativeById = useMemo(() => Object.fromEntries(creatives.map(c => [c.id, c])), [creatives]);
const primaryCreativeByCampaign = useMemo(() => {
const m = {};
for (const cr of creatives) if (!m[cr.campaignId]) m[cr.campaignId] = cr;
return m;
}, [creatives]);
// The dimension key for any object carrying { platform, campaignId } — works for
// both analyticsDaily rows and leads, so a drill-down filters both consistently.
const keyOf = useCallback((v, o) => {
if (v === 'platform') return o.platform;
if (v === 'campaign') return o.campaignId;
if (v === 'creative') return primaryCreativeByCampaign[o.campaignId]?.id || `nocre:${o.campaignId}`;
return campById[o.campaignId]?.playbook || 'Unmapped';
}, [primaryCreativeByCampaign, campById]);
// Scope spend + leads to the drilled-down entity (top KPIs + trend), if any.
const switchView = (v) => { setView(v); setSelected(null); };
const filteredDaily = useMemo(() => selected ? analyticsDaily.filter(r => keyOf(view, r) === selected) : analyticsDaily, [analyticsDaily, selected, view, keyOf]);
const filteredLeads = useMemo(() => selected ? leads.filter(l => keyOf(view, l) === selected) : leads, [leads, selected, view, keyOf]);
const filteredReviews = useMemo(() => selected ? reviews.filter(r => keyOf(view, r) === selected) : reviews, [reviews, selected, view, keyOf]);
// Review & referral lift (spec §10) — totals scope to the drill-down; the breakdown
// groups all reviews by the current dimension.
const reviewAgg = useMemo(() => reviewTotals(filteredReviews), [filteredReviews]);
const reviewsBreakdown = useMemo(() => reviewsByFn(reviews, r => keyOf(view, r)).sort((a, b) => b.count - a.count), [reviews, view, keyOf]);
// Multi-touch attribution by model (spec §11), grouped by the current dimension.
const modelRows = useMemo(() => attributionByModel(journeys, t => keyOf(view, t)).sort((a, b) => b[model] - a[model]), [journeys, view, keyOf, model]);
const modelTotal = useMemo(() => modelRows.reduce((s, r) => s + r[model], 0), [modelRows, model]);
// Budget pacing + open anomaly alerts (spec §11).
const pacing = useMemo(() => budgetPacing(campaigns, analyticsDaily), [campaigns, analyticsDaily]);
const openAlerts = useMemo(() => anomalies.filter(a => a.status === 'open'), [anomalies]);
// Spend/ROAS = platform-reported; lead→won + rates = live CRM events (spec §6, §10).
// Both honor the active drill-down filter so the KPI cards reconcile with it.
const reported = useMemo(() => metrics(totals(filteredDaily)), [filteredDaily]);
const live = useMemo(() => liveMetrics(filteredLeads), [filteredLeads]);
const m = useMemo(() => ({
...live,
spend: reported.spend,
cpl: live.leads ? reported.spend / live.leads : 0,
costPerAppt: live.appointments ? reported.spend / live.appointments : 0,
roas: reported.spend ? live.revenue / reported.spend : 0,
}), [reported, live]);
const bySource = useMemo(() => {
if (view === 'platform') return metricsBy(analyticsDaily, 'platform');
if (view === 'campaign') return metricsBy(analyticsDaily, 'campaignId');
if (view === 'creative') return metricsByFn(analyticsDaily, r => primaryCreativeByCampaign[r.campaignId]?.id || `nocre:${r.campaignId}`);
if (view === 'playbook') return metricsByFn(analyticsDaily, r => campById[r.campaignId]?.playbook || 'Unmapped');
return [];
}, [analyticsDaily, view, primaryCreativeByCampaign, campById]);
const revenueSeries = useMemo(() => {
// Merge revenue + spend per date for a dual-line chart (respects drill-down).
const rev = timeSeries(filteredDaily, 'revenue');
const spend = timeSeries(filteredDaily, 'spend');
return rev.map((r, i) => ({ date: r.date, revenue: r.value, spend: spend[i]?.value || 0 }));
}, [filteredDaily]);
const campName = (id) => campById[id]?.name || id;
// Full + short labels for whichever dimension is selected.
const labelFor = (k) => {
if (view === 'platform') return getPlatform(k)?.name || k;
if (view === 'campaign') return campName(k);
if (view === 'creative') return k.startsWith('nocre:') ? `${campName(k.slice(6))} — no creative on file` : (creativeById[k]?.headline || k);
return k; // playbook key is the name itself
};
const shortLabel = (k) => {
if (view === 'platform') return getPlatform(k)?.short || k;
if (view === 'campaign') return campName(k).slice(0, 10);
if (view === 'creative') return (k.startsWith('nocre:') ? campName(k.slice(6)) : (creativeById[k]?.headline || k)).slice(0, 12);
return k.split(' ').slice(0, 2).join(' ');
};
const barColor = (k) => {
if (view === 'platform') return getPlatform(k)?.color || '#3b82f6';
if (view === 'creative' && !k.startsWith('nocre:')) return getPlatform(creativeById[k]?.platform)?.color || '#3b82f6';
return '#3b82f6';
};
// ROAS leaderboard
const ranked = [...bySource].sort((a, b) => b.roas - a.roas);
return (
<div className="space-y-6">
<SectionHeader icon={BarChart3} title="Attribution Dashboard" description="Source-to-close economics built from CRM events — leads alone are not enough.">
<div className="flex flex-wrap rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{['platform', 'campaign', 'creative', 'playbook'].map(v => (
<button key={v} onClick={() => switchView(v)}
className={`px-3 py-1.5 text-xs font-semibold capitalize ${view === v ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
By {v}
</button>
))}
</div>
</SectionHeader>
{/* Live banner + drill-down filter chip */}
<div className="flex flex-wrap items-center justify-between gap-2 -mb-2">
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400">
<Activity size={13} className="animate-pulse" />
<span className="font-semibold">Live</span>
<span className="text-zinc-400"> lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported.</span>
</div>
{selected && (
<button onClick={() => setSelected(null)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-amber-500/15 text-amber-700 dark:text-amber-400 hover:bg-amber-500/25 transition-colors">
<Filter size={12} /> {labelFor(selected)} <span className="text-amber-500/60">· by {view}</span> <X size={13} />
</button>
)}
</div>
{/* Budget pacing & anomaly alerts (spec §11) */}
<Card title="Budget pacing & anomaly alerts" subtitle="AI flags spend/quality anomalies; you pause or review" icon={ShieldAlert}>
{/* Alerts */}
{openAlerts.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 mb-4">
<Check size={16} /> No open anomalies all campaigns within guardrails.
</div>
) : (
<div className="space-y-2 mb-5">
{openAlerts.map(a => {
const meta = ANOMALY_META[a.type] || ANOMALY_META.cpl_spike;
const AIcon = meta.icon;
const toneCls = meta.tone === 'red' ? 'text-red-500 bg-red-500/10' : 'text-amber-500 bg-amber-500/10';
return (
<div key={a.id} className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]">
<span className={`p-2 rounded-lg shrink-0 self-start ${toneCls}`}>{AIcon && <AIcon size={16} />}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm text-zinc-900 dark:text-white">{campName(a.campaignId)}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${a.severity === 'high' ? 'text-red-600 dark:text-red-400 bg-red-500/10' : 'text-amber-600 dark:text-amber-400 bg-amber-500/10'}`}>{a.severity}</span>
<span className="text-[11px] text-zinc-400">{meta.label}</span>
</div>
<p className="text-xs font-mono text-zinc-700 dark:text-zinc-200 mt-0.5">{a.metric}</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">{a.detail}</p>
<p className="text-[11px] text-purple-500 mt-0.5 flex items-center gap-1"><Activity size={11} /> AI recommends: <b>{a.recommendation === 'pause' ? 'pause the campaign' : 'review & optimize'}</b></p>
</div>
<div className="flex items-center gap-1.5 shrink-0 self-start sm:self-center">
<button onClick={() => resolveAnomaly(a.id, 'paused')} title="Pause campaign"
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${a.recommendation === 'pause' ? 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
<Pause size={13} /> Pause
</button>
<button onClick={() => resolveAnomaly(a.id, 'reviewed')} title="Mark reviewed"
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10">
<Check size={13} /> Review
</button>
<button onClick={() => resolveAnomaly(a.id, 'dismissed')} title="Dismiss" className="p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"><X size={15} /></button>
</div>
</div>
);
})}
</div>
)}
{/* Budget pacing per active campaign */}
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-2">Budget pacing active campaigns</p>
<div className="space-y-2">
{pacing.map(p => (
<div key={p.id} className="flex items-center gap-3">
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 w-40 sm:w-52 truncate shrink-0" title={p.name}>{p.name}</span>
<div className="flex-1 min-w-0">
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div className={`h-full ${p.status === 'over' ? 'bg-red-500' : p.status === 'under' ? 'bg-amber-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(100, p.pacePct * 100)}%` }} />
</div>
</div>
<span className="text-[11px] font-mono text-zinc-400 w-28 text-right shrink-0 hidden sm:block">{money(p.projected)} / {money(p.monthlyCap)}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0 ${PACE_CHIP[p.status]}`}>{PACE_LABEL[p.status]}</span>
</div>
))}
</div>
</Card>
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile label="Spend (reported)" value={money(m.spend)} icon={DollarSign} color="amber" />
<KpiTile label="CPL" value={money(m.cpl)} icon={Users} color="blue" sub={`${m.leads} leads`} />
<KpiTile label="Cost / appt" value={money(m.costPerAppt)} icon={CalendarCheck} color="purple" sub={`${m.appointments} appts`} />
<KpiTile label="ROAS" value={`${m.roas.toFixed(1)}×`} icon={TrendingUp} color="emerald" sub={money(m.revenue)} />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile label="Appt rate" value={pct(m.apptRate)} icon={CalendarCheck} color="blue" />
<KpiTile label="Show rate" value={pct(m.showRate)} icon={Eye} color="cyan" />
<KpiTile label="Estimate rate" value={pct(m.estimateRate)} icon={FileText} color="blue" sub={`${m.estimates} sent`} />
<KpiTile label="Close rate" value={pct(m.closeRate)} icon={Percent} color="emerald" sub={`${m.wins} won`} />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile label="Speed-to-lead" value={mmss(m.avgSpeedToLead)} icon={Zap} color="amber" sub="avg to first contact" />
<KpiTile label="Premium mix" value={pct(m.premiumMix)} icon={Crown} color="purple" sub={`${m.premiumWins} premium`} />
<KpiTile label="Qualified leads" value={m.qualified} icon={Users} color="emerald" sub={`spam ${pct(m.spamRate)}`} />
<KpiTile label="Revenue (won)" value={money(m.revenue)} icon={Trophy} color="amber" />
</div>
{/* Revenue vs spend */}
<Card title="Revenue vs. spend" subtitle="Daily, all sources" icon={TrendingUp}>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={revenueSeries} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
<XAxis dataKey="date" tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }} labelStyle={{ color: '#fff' }} formatter={(v, n) => [money(v), n === 'revenue' ? 'Revenue' : 'Spend']} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2.5} dot={false} name="Revenue" />
<Line type="monotone" dataKey="spend" stroke="#f59e0b" strokeWidth={2} dot={false} name="Spend" />
</LineChart>
</ResponsiveContainer>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* CPL by source */}
<Card title={`Cost per lead by ${view}`} icon={Users}>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={bySource} margin={{ top: 5, right: 20, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
<XAxis dataKey="key" tick={{ fill: '#71717a', fontSize: 10 }} axisLine={false} tickLine={false}
interval={0} tickFormatter={shortLabel} />
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }}
formatter={(v) => [money(v), 'CPL']} labelFormatter={labelFor} />
<Bar dataKey="cpl" radius={[6, 6, 0, 0]} barSize={36}>
{bySource.map((s, i) => <Cell key={i} fill={barColor(s.key)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* ROAS leaderboard */}
<Card title="ROAS leaderboard" subtitle="Ranked by return — click a row to filter" icon={Trophy}>
<div className="space-y-2">
{ranked.map((s, i) => (
<div key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
title="Click to filter the dashboard to this row"
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10 ring-1 ring-amber-400/60' : 'bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10'}`}>
<span className="text-xs font-bold text-zinc-400 w-4">{i + 1}</span>
{view === 'platform'
? <PlatformBadge platformId={s.key} />
: <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 truncate flex-1" title={labelFor(s.key)}>{labelFor(s.key)}</span>}
<div className="flex items-center gap-3 ml-auto text-xs">
<span className="text-zinc-400">{money(s.revenue)}</span>
<span className={`font-mono font-bold w-12 text-right ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</span>
</div>
</div>
))}
</div>
</Card>
</div>
{/* Review & referral lift (spec §8 "reviews"; §10 review/referral lift) */}
<Card title="Review & referral lift" subtitle={`Post-job reviews attributed back to source${selected ? ` · filtered to ${labelFor(selected)}` : ''}`} icon={Star}>
<div className="grid grid-cols-3 gap-2 mb-4">
<LiftStat icon={Star} color="text-amber-500" value={reviewAgg.count} label="Reviews" sub={reviewAgg.count ? `${reviewAgg.avgRating.toFixed(1)}★ avg` : 'no reviews'} />
<LiftStat icon={Gift} color="text-emerald-500" value={reviewAgg.referralLeads} label="Referral leads" sub="generated" />
<LiftStat icon={Repeat} color="text-purple-500" value={money(reviewAgg.reactivationRevenue)} label="Reactivation $" sub="repeat revenue" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className="py-2 px-2 font-bold text-right">Reviews</th>
<th className="py-2 px-2 font-bold text-right">Avg rating</th>
<th className="py-2 px-2 font-bold text-right">Referral leads</th>
<th className="py-2 pl-2 font-bold text-right">Reactivation $</th>
</tr>
</thead>
<tbody>
{reviewsBreakdown.length === 0 && <tr><td colSpan={5} className="py-4 text-center text-zinc-400">No reviews for this dimension yet.</td></tr>}
{reviewsBreakdown.map(b => (
<tr key={b.key} onClick={() => setSelected(sel => sel === b.key ? null : b.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === b.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={b.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(b.key)}</span>}
</td>
<td className="py-2 px-2 text-right font-mono">{b.count}</td>
<td className="py-2 px-2 text-right font-mono text-amber-600 dark:text-amber-400">{b.avgRating.toFixed(1)}</td>
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{b.referralLeads}</td>
<td className="py-2 pl-2 text-right font-mono">{money(b.reactivationRevenue)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Multi-touch attribution models (spec §11) */}
<Card title="Multi-touch attribution" subtitle={`How closed-won revenue is credited across sources, by ${view}`} icon={Route}
action={
<div className="flex rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{[['first', 'First-touch'], ['last', 'Last-touch'], ['multi', 'Multi-touch']].map(([m, label]) => (
<button key={m} onClick={() => setModel(m)}
className={`px-2.5 py-1.5 text-[11px] font-semibold ${model === m ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
{label}
</button>
))}
</div>
}>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-3 flex items-start gap-1.5">
<Route size={13} className="text-amber-500 mt-0.5 shrink-0" />
{MODEL_NOTE[model]} Highlighted column is the active model; compare to see how credit shifts.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className={`py-2 px-2 font-bold text-right ${model === 'first' ? 'text-amber-600 dark:text-amber-400' : ''}`}>First-touch</th>
<th className={`py-2 px-2 font-bold text-right ${model === 'last' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Last-touch</th>
<th className={`py-2 pl-2 font-bold text-right ${model === 'multi' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Multi-touch</th>
</tr>
</thead>
<tbody>
{modelRows.length === 0 && <tr><td colSpan={4} className="py-4 text-center text-zinc-400">No closed-won journeys for this dimension yet.</td></tr>}
{modelRows.map(r => (
<tr key={r.key} onClick={() => setSelected(sel => sel === r.key ? null : r.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === r.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={r.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(r.key)}</span>}
</td>
<td className={`py-2 px-2 text-right font-mono ${model === 'first' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.first)}</td>
<td className={`py-2 px-2 text-right font-mono ${model === 'last' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.last)}</td>
<td className={`py-2 pl-2 text-right font-mono ${model === 'multi' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.multi)}</td>
</tr>
))}
</tbody>
{modelRows.length > 0 && (
<tfoot>
<tr className="text-[11px] font-bold text-zinc-600 dark:text-zinc-300">
<td className="py-2 pr-3">Total ({model}-touch)</td>
<td colSpan={3} className="py-2 pl-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(modelTotal)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</Card>
{/* Detail table */}
<Card title={`Performance by ${view}`} subtitle="Click a row to filter the KPIs above" icon={BarChart3}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className="py-2 px-2 font-bold text-right">Spend</th>
<th className="py-2 px-2 font-bold text-right">Leads</th>
<th className="py-2 px-2 font-bold text-right">CPL</th>
<th className="py-2 px-2 font-bold text-right">Appts</th>
<th className="py-2 px-2 font-bold text-right">Show</th>
<th className="py-2 px-2 font-bold text-right">Close</th>
<th className="py-2 px-2 font-bold text-right">Revenue</th>
<th className="py-2 pl-2 font-bold text-right">ROAS</th>
</tr>
</thead>
<tbody>
{bySource.map(s => (
<tr key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={s.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(s.key)}</span>}
</td>
<td className="py-2 px-2 text-right font-mono">{money(s.spend)}</td>
<td className="py-2 px-2 text-right font-mono">{s.leads}</td>
<td className="py-2 px-2 text-right font-mono">{money(s.cpl)}</td>
<td className="py-2 px-2 text-right font-mono">{s.appointments}</td>
<td className="py-2 px-2 text-right font-mono">{pct(s.showRate)}</td>
<td className="py-2 px-2 text-right font-mono">{pct(s.closeRate)}</td>
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(s.revenue)}</td>
<td className={`py-2 pl-2 text-right font-mono font-bold ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}
function LiftStat({ icon: Icon, color, value, label, sub }) {
return (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 text-center">
{Icon && <Icon size={16} className={`${color} mx-auto mb-1`} />}
<p className="text-lg font-mono font-bold text-zinc-800 dark:text-zinc-100">{value}</p>
<p className="text-[10px] uppercase tracking-wider text-zinc-400">{label}</p>
{sub && <p className="text-[10px] text-zinc-400 mt-0.5">{sub}</p>}
</div>
);
}