feat: metrics dashboard with WoW/MoM/YoY and creatives pipeline

Rebuild KPIs as a real dashboard (period deltas, tenant zoom), clarify
objetivos/calendario/activos sources, and turn creatives into a status
pipeline with delete.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-22 13:45:41 +02:00
parent 791976a3d8
commit 8182402d96
11 changed files with 1002 additions and 263 deletions

View File

@@ -0,0 +1,243 @@
import React from 'react';
import {
ArrowDownRight,
ArrowUpRight,
DollarSign,
Minus,
Target,
TrendingUp,
Users,
MousePointerClick,
Eye,
} from 'lucide-react';
import {
Delta,
MetricTotals,
formatEur,
formatNum,
formatPct,
} from '@/lib/metrics';
type Card = {
key: keyof MetricTotals;
label: string;
hint: string;
format: (v: number) => string;
icon: React.ReactNode;
invert?: boolean; // lower is better
};
function DeltaTriple({
delta,
invert,
}: {
delta: Delta;
invert?: boolean;
}) {
const cells: { label: string; v: number | null }[] = [
{ label: 'WoW', v: delta.wow },
{ label: 'MoM', v: delta.mom },
{ label: 'YoY', v: delta.yoy },
];
return (
<div className="mt-3 grid grid-cols-3 gap-1.5">
{cells.map((c) => {
const good =
c.v === null
? null
: invert
? c.v < 0
: c.v > 0;
const color =
c.v === null
? 'text-gray-500'
: good
? 'text-emerald-400'
: c.v === 0
? 'text-gray-400'
: 'text-red-400';
const Icon =
c.v === null || c.v === 0
? Minus
: good
? ArrowUpRight
: ArrowDownRight;
return (
<div
key={c.label}
className="rounded-md bg-black/25 border border-gray-800/80 px-1.5 py-1 text-center"
>
<p className="text-[9px] uppercase tracking-wider text-gray-500">
{c.label}
</p>
<p
className={`text-[11px] font-semibold inline-flex items-center justify-center gap-0.5 ${color}`}
>
<Icon className="w-3 h-3" />
{formatPct(c.v)}
</p>
</div>
);
})}
</div>
);
}
export default function MetricsDashboard({
label,
portfolioWide,
month,
week,
deltas,
daily,
sparse,
}: {
label: string;
portfolioWide: boolean;
month: MetricTotals;
week: MetricTotals;
deltas: Record<keyof MetricTotals, Delta>;
daily: {
spend: number;
leads: number;
impressions: number;
clicks: number;
};
sparse: boolean;
}) {
const cards: Card[] = [
{
key: 'spend',
label: 'Inversión ads',
hint: 'Mes en curso (kpi_weekly)',
format: formatEur,
icon: <DollarSign className="w-4 h-4 text-brand-500" />,
},
{
key: 'leads',
label: 'Leads',
hint: 'Cohorte · mes',
format: formatNum,
icon: <Users className="w-4 h-4 text-blue-400" />,
},
{
key: 'cpl',
label: 'CPL',
hint: 'Coste por lead · mes',
format: (v) => `${formatNum(v)} €`,
icon: <MousePointerClick className="w-4 h-4 text-violet-400" />,
invert: true,
},
{
key: 'cac',
label: 'CAC cohorte',
hint: 'Spend / clientes ganados',
format: (v) => `${formatNum(v)} €`,
icon: <Target className="w-4 h-4 text-amber-400" />,
invert: true,
},
{
key: 'revenue',
label: 'Revenue cohorte',
hint: 'Deals won atribuidos',
format: formatEur,
icon: <TrendingUp className="w-4 h-4 text-emerald-400" />,
},
{
key: 'roas',
label: 'ROAS cohorte',
hint: 'Revenue / spend',
format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-300" />,
},
];
return (
<div className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Dashboard de métricas
</h2>
<p className="text-sm text-gray-400 mt-1">
{label}
{portfolioWide
? ' · agrega todos los tenants (vista agencia)'
: ' · zoom al cliente del selector'}
. Comparativas WoW / MoM / YoY sobre <code>kpi_weekly</code>.
</p>
</div>
<div className="text-[11px] text-gray-500 bg-gray-900/50 border border-gray-800 rounded-lg px-3 py-2">
Semana actual spend {formatEur(week.spend)} · leads {formatNum(week.leads)}
</div>
</div>
{sparse && (
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
Histórico limitado en warehouse: si WoW/MoM/YoY salen «—», falta
pipeline Windsor/n8n o semanas previas / año anterior.
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{cards.map((c) => (
<div
key={c.key}
className="bg-[#111827] border border-gray-800 rounded-xl p-5"
>
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">
{c.label}
</span>
{c.icon}
</div>
<p className="text-2xl font-bold text-white">
{c.format(month[c.key])}
</p>
<p className="text-[11px] text-gray-500 mt-1">{c.hint}</p>
<DeltaTriple delta={deltas[c.key]} invert={c.invert} />
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs uppercase tracking-wider">Ads · 30d (ad_daily)</span>
<Eye className="w-4 h-4 text-sky-400" />
</div>
<p className="text-lg font-bold text-white">
{formatEur(daily.spend)}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatNum(daily.leads)} leads plataforma ·{' '}
{formatNum(daily.impressions)} impr. · {formatNum(daily.clicks)}{' '}
clicks
</p>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 md:col-span-2">
<p className="text-xs uppercase tracking-wider text-gray-500 mb-2">
Cómo leer las deltas
</p>
<ul className="text-xs text-gray-400 space-y-1 list-disc list-inside">
<li>
<strong className="text-gray-300">WoW</strong>: semana actual vs
anterior
</li>
<li>
<strong className="text-gray-300">MoM</strong>: mes actual vs mes
previo
</li>
<li>
<strong className="text-gray-300">YoY</strong>: mismo mes vs hace
un año
</li>
<li>
En CPL/CAC, bajar es bueno (flecha verde si el % es negativo)
</li>
</ul>
</div>
</div>
</div>
);
}