feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico
Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,8 +11,6 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
MousePointerClick,
|
||||
Eye,
|
||||
CalendarRange,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AdDailyRow,
|
||||
@@ -30,13 +28,12 @@ import {
|
||||
filterDealsForRange,
|
||||
filterLeadsByDayRange,
|
||||
metricTotalsFromDaily,
|
||||
resolveKpiDayRange,
|
||||
sumWeekly,
|
||||
withCohortFromDeals,
|
||||
KpiWeeklyRow,
|
||||
KpiRangeKey,
|
||||
} from '@/lib/metrics';
|
||||
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
type Card = {
|
||||
key: keyof MetricTotals;
|
||||
@@ -45,12 +42,10 @@ type Card = {
|
||||
format: (v: number) => string;
|
||||
icon: React.ReactNode;
|
||||
invert?: boolean;
|
||||
/** When true and value is 0 with no deals, show em dash + empty state. */
|
||||
/** Business outcome from Clientify won deals. */
|
||||
cohort?: boolean;
|
||||
};
|
||||
|
||||
type RangeKey = KpiRangeKey;
|
||||
|
||||
function DeltaTriple({
|
||||
delta,
|
||||
invert,
|
||||
@@ -151,65 +146,46 @@ export default function MetricsDashboard({
|
||||
crmLeads?: CrmLeadRow[];
|
||||
wonDeals?: DealWonRow[];
|
||||
}) {
|
||||
const [range, setRange] = useState<RangeKey>('7d');
|
||||
|
||||
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
|
||||
const rangedDaily = useMemo(() => {
|
||||
if (!dailyRows.length) return [];
|
||||
if (!dayWindow) return dailyRows;
|
||||
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
|
||||
}, [dailyRows, dayWindow]);
|
||||
return filterDailyByDayRange(dailyRows, since, until);
|
||||
}, [dailyRows, since, until]);
|
||||
|
||||
const rangedLeads = useMemo(() => {
|
||||
if (!crmLeads.length) return [];
|
||||
if (!dayWindow) return crmLeads;
|
||||
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
|
||||
}, [crmLeads, dayWindow]);
|
||||
return filterLeadsByDayRange(crmLeads, since, until);
|
||||
}, [crmLeads, since, until]);
|
||||
|
||||
const rangedDeals = useMemo(
|
||||
() =>
|
||||
filterDealsForRange(
|
||||
wonDeals,
|
||||
dayWindow?.since ?? null,
|
||||
dayWindow?.until ?? null
|
||||
),
|
||||
[wonDeals, dayWindow]
|
||||
() => filterDealsForRange(wonDeals, since, until),
|
||||
[wonDeals, since, until]
|
||||
);
|
||||
|
||||
const periodTotals = useMemo(() => {
|
||||
let base: MetricTotals;
|
||||
if (rangedDaily.length) {
|
||||
base = metricTotalsFromDaily(rangedDaily);
|
||||
} else if (!dayWindow) {
|
||||
base = sumWeekly(weeklyRows);
|
||||
} else {
|
||||
const from = dayWindow.since;
|
||||
const until = dayWindow.until;
|
||||
const ranged = weeklyRows.filter((r) => {
|
||||
if (!r.week) return false;
|
||||
const w = r.week.slice(0, 10);
|
||||
return w <= until && w >= from;
|
||||
return w <= until && w >= since;
|
||||
});
|
||||
base = ranged.length ? sumWeekly(ranged) : month;
|
||||
}
|
||||
return withCohortFromDeals(base, rangedDeals);
|
||||
}, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]);
|
||||
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
|
||||
|
||||
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||||
if (rangedDaily.length || rangedLeads.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads);
|
||||
}
|
||||
const map = new Map<string, { spend: number; leads: number }>();
|
||||
const src =
|
||||
range === 'all'
|
||||
? channelPoints
|
||||
: channelPoints.filter((p) => {
|
||||
const d = p.week;
|
||||
if (!d || !dayWindow) return true;
|
||||
return d >= dayWindow.since && d <= dayWindow.until;
|
||||
});
|
||||
for (const p of src) {
|
||||
for (const p of channelPoints) {
|
||||
const d = p.week;
|
||||
if (!d || d < since || d > until) continue;
|
||||
const cur = map.get(p.channel) || { spend: 0, leads: 0 };
|
||||
cur.spend += p.spend;
|
||||
cur.leads += p.leads;
|
||||
@@ -226,92 +202,100 @@ export default function MetricsDashboard({
|
||||
cplCrm: 0,
|
||||
}))
|
||||
.sort((a, b) => b.spend - a.spend);
|
||||
}, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]);
|
||||
}, [rangedDaily, rangedLeads, channelPoints, since, until]);
|
||||
|
||||
const crmPeriodTotal = rangedLeads.length;
|
||||
const hasCrm = crmLeads.length > 0;
|
||||
const hasCohort = rangedDeals.length > 0;
|
||||
|
||||
const rangeHint = dayWindow
|
||||
? `${dayWindow.since} → ${dayWindow.until}`
|
||||
: 'todo el histórico';
|
||||
const cierres = periodTotals.deals;
|
||||
|
||||
const cards: Card[] = [
|
||||
{
|
||||
key: 'spend',
|
||||
label: 'Inversión ads',
|
||||
hint: 'Paid del periodo',
|
||||
hint: 'Gasto Meta + Google del periodo',
|
||||
format: formatEur,
|
||||
icon: <DollarSign className="w-4 h-4 text-brand-600" />,
|
||||
},
|
||||
{
|
||||
key: 'leads',
|
||||
label: 'Leads',
|
||||
hint: 'Captación periodo',
|
||||
label: 'Leads plataforma',
|
||||
hint: 'Conversiones reportadas por ads',
|
||||
format: formatNum,
|
||||
icon: <Users className="w-4 h-4 text-blue-600" />,
|
||||
},
|
||||
{
|
||||
key: 'cpl',
|
||||
label: 'CPL',
|
||||
hint: 'Coste por lead',
|
||||
label: 'CPL ads',
|
||||
hint: 'Inversión ÷ leads plataforma',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
|
||||
invert: true,
|
||||
},
|
||||
{
|
||||
key: 'cac',
|
||||
label: 'CAC cohorte',
|
||||
key: 'revenue',
|
||||
label: 'Ventas (CRM)',
|
||||
hint: hasCohort
|
||||
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres`
|
||||
: 'Paid ÷ deals ganados Clientify',
|
||||
? `${formatNum(cierres)} deals ganados · importe Clientify`
|
||||
: 'Deals won Clientify en el periodo',
|
||||
format: formatEur,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||
cohort: true,
|
||||
},
|
||||
{
|
||||
key: 'cac',
|
||||
label: 'CAC',
|
||||
hint: hasCohort
|
||||
? `Inversión ads ÷ ${formatNum(cierres)} ventas`
|
||||
: 'Inversión ÷ deals ganados (no leads)',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <Target className="w-4 h-4 text-amber-600" />,
|
||||
invert: true,
|
||||
cohort: true,
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
label: 'Revenue cohorte',
|
||||
hint: hasCohort
|
||||
? `${formatNum(periodTotals.deals)} deals won`
|
||||
: 'Suma amount deals won',
|
||||
format: formatEur,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||
cohort: true,
|
||||
},
|
||||
{
|
||||
key: 'roas',
|
||||
label: 'ROAS cohorte',
|
||||
hint: 'Revenue ÷ spend',
|
||||
label: 'ROAS ventas',
|
||||
hint: 'Ventas CRM ÷ inversión ads',
|
||||
format: (v) => `${formatNum(v)}x`,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
||||
cohort: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dailyChart = useMemo(() => {
|
||||
const byDay = new Map<string, { spend: number; leads: number }>();
|
||||
for (const r of rangedDaily) {
|
||||
const d = (r.date || '').slice(0, 10);
|
||||
if (!d) continue;
|
||||
const cur = byDay.get(d) || { spend: 0, leads: 0 };
|
||||
cur.spend += Number(r.spend) || 0;
|
||||
cur.leads += Number(r.platform_leads ?? r.conversions) || 0;
|
||||
byDay.set(d, cur);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([date, v]) => ({ date, ...v }));
|
||||
}, [rangedDaily]);
|
||||
|
||||
const maxSpend = Math.max(1, ...dailyChart.map((d) => d.spend));
|
||||
|
||||
const channelInsight = useMemo(() => {
|
||||
if (!attributionRows.length) return null;
|
||||
const paid = attributionRows.filter((r) => r.spend > 0);
|
||||
const top = paid[0] || attributionRows[0];
|
||||
const worstGap = [...attributionRows].sort(
|
||||
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
|
||||
)[0];
|
||||
return { top, worstGap };
|
||||
}, [attributionRows]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
eyebrow="Rendimiento"
|
||||
title="Métricas"
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (mismos días calendario que Ad Tracker).`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarRange className="w-4 h-4 text-gray-600" />
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
size="sm"
|
||||
options={[
|
||||
{ id: '7d', label: '7d' },
|
||||
{ id: '30d', label: '30d' },
|
||||
{ id: 'month', label: 'Mes' },
|
||||
{ id: 'all', label: 'Todo' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
|
||||
/>
|
||||
|
||||
{sparse && (
|
||||
@@ -323,10 +307,8 @@ export default function MetricsDashboard({
|
||||
|
||||
{!hasCohort && (
|
||||
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
|
||||
CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre
|
||||
en este periodo. Se calculan con{' '}
|
||||
<span className="font-semibold">deals Clientify (stage=won)</span>, no
|
||||
solo con leads de ads.
|
||||
Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con
|
||||
fecha de cierre en este periodo. El gasto de ads no cuenta como venta.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -394,29 +376,46 @@ export default function MetricsDashboard({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
Cruce por canal · ads vs CRM
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Plataforma = leads reportados por Meta/Google · CRM = atribución
|
||||
Clientify (origen/UTM)
|
||||
{!hasCrm && portfolioWide
|
||||
? ' · elige un cliente en Contexto para ver CRM'
|
||||
: ''}
|
||||
Gasto y leads de plataforma vs leads CRM (como en{' '}
|
||||
<a
|
||||
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand-600 underline"
|
||||
>
|
||||
combinación de datos
|
||||
</a>
|
||||
): el coste vive en ads; el resultado de negocio en CRM/GA.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
||||
Gap = CRM − plat
|
||||
</p>
|
||||
</div>
|
||||
{channelInsight && (
|
||||
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
|
||||
<strong>Resumen:</strong> más inversión en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.top.channel}
|
||||
</span>{' '}
|
||||
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.worstGap.channel}
|
||||
</span>{' '}
|
||||
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
|
||||
{formatNum(channelInsight.worstGap.gap)}).
|
||||
</p>
|
||||
)}
|
||||
{attributionRows.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 py-6 text-center">
|
||||
Sin series de canal en el periodo. Los canales Meta/Google aparecen
|
||||
cuando hay filas en ad_daily para este cliente (elige uno en
|
||||
Contexto).
|
||||
Sin series de canal en el periodo.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -486,49 +485,63 @@ export default function MetricsDashboard({
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between text-gray-700 mb-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
Ads · ventana diaria
|
||||
</span>
|
||||
<Eye className="w-4 h-4 text-sky-600" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
|
||||
Ads · inversión / día
|
||||
</h4>
|
||||
{dailyChart.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">Sin filas diarias.</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-0.5 h-20 w-full">
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`s-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
title={`${d.date}: ${formatEur(d.spend)}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-600 mb-2">
|
||||
Ads · leads plat. / día
|
||||
</h4>
|
||||
{dailyChart.length === 0 ? (
|
||||
<p className="text-xs text-gray-500">Sin filas diarias.</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-0.5 h-20 w-full">
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`l-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
title={`${d.date}: ${formatNum(d.leads)} leads`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.leads / Math.max(1, ...dailyChart.map((x) => x.leads))) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||
{formatEur(daily.spend)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr.
|
||||
· {formatNum(daily.clicks)} clicks
|
||||
</p>
|
||||
<p className="text-xs text-gray-700 mt-2 font-medium">
|
||||
Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '}
|
||||
leads
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 md:col-span-2 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-700 mb-2">
|
||||
Cómo leer
|
||||
</p>
|
||||
<ul className="text-sm text-gray-700 space-y-1.5 list-disc list-inside">
|
||||
<li>
|
||||
CAC / Revenue / ROAS usan deals ganados de Clientify (
|
||||
<span className="font-semibold">won_at</span> en el periodo), no
|
||||
leads de Meta/Google.
|
||||
</li>
|
||||
<li>
|
||||
Gap cercano a 0 en Meta/Google = buena atribución. Canales sin
|
||||
spend son orgánicos/CRM.
|
||||
</li>
|
||||
<li>
|
||||
CPL CRM = inversión paid ÷ leads CRM del canal (más realista
|
||||
cuando hay desfase con la plataforma).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
Barras compactas por día · periodo del header · detalle en Ads
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user