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:
243
src/components/MetricsDashboard.tsx
Normal file
243
src/components/MetricsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user