feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal

Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite
histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-28 10:23:36 +02:00
parent dd50251de2
commit cf27eba74d
30 changed files with 2367 additions and 514 deletions

View File

@@ -24,6 +24,7 @@ import {
formatNum,
formatPct,
attributionCross,
buildRangeDeltas,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
@@ -50,22 +51,31 @@ function DeltaTriple({
delta,
invert,
empty,
format,
current,
}: {
delta: Delta;
invert?: boolean;
empty?: boolean;
format: (v: number) => string;
current: number;
}) {
if (empty) {
return (
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center">
Sin cierres en el periodo — sincroniza deals Clientify
Sin cierres en este rango — amplía el periodo o revisa deals ganados en
Clientify
</p>
);
}
const cells: { label: string; v: number | null }[] = [
{ label: 'WoW', v: delta.wow },
{ label: 'MoM', v: delta.mom },
{ label: 'YoY', v: delta.yoy },
const cells: {
label: string;
v: number | null;
base: number | null;
}[] = [
{ label: 'WoW', v: delta.wow, base: delta.wowBase },
{ label: 'MoM', v: delta.mom, base: delta.momBase },
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase },
];
return (
<div className="mt-3 grid grid-cols-3 gap-1.5">
@@ -85,6 +95,9 @@ function DeltaTriple({
: good
? ArrowUpRight
: ArrowDownRight;
const hasBase = c.base !== null && !(c.base === 0 && c.v === null);
const absDelta =
hasBase && c.base !== null ? current - c.base : null;
return (
<div
key={c.label}
@@ -99,6 +112,27 @@ function DeltaTriple({
<Icon className="w-3.5 h-3.5" />
{formatPct(c.v)}
</p>
{hasBase ? (
<>
<p className="text-[11px] font-semibold text-gray-800 mt-1 tabular-nums leading-tight">
prev {format(c.base ?? 0)}
</p>
{absDelta !== null && (
<p
className={`text-[10px] font-medium tabular-nums leading-tight ${color}`}
>
Δ{' '}
{absDelta > 0
? `+${format(absDelta)}`
: format(absDelta)}
</p>
)}
</>
) : (
<p className="text-[10px] text-gray-500 mt-1 leading-tight">
sin histórico
</p>
)}
</div>
);
})}
@@ -127,6 +161,7 @@ export default function MetricsDashboard({
dailyRows = [],
crmLeads = [],
wonDeals = [],
cohortSource = 'deals',
}: {
label: string;
portfolioWide: boolean;
@@ -145,6 +180,7 @@ export default function MetricsDashboard({
dailyRows?: AdDailyRow[];
crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[];
cohortSource?: 'deals' | 'cliente_leads' | 'none';
}) {
const { since, until, label: rangeHint } = useDateRange();
@@ -178,9 +214,15 @@ export default function MetricsDashboard({
return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
/** Comparatives relative to the selected range (not “today”). */
const rangeDeltas = useMemo(
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
[dailyRows, wonDeals, since, until]
);
const attributionRows: AttributionChannelRow[] = useMemo(() => {
if (rangedDaily.length || rangedLeads.length) {
return attributionCross(rangedDaily, rangedLeads);
if (rangedDaily.length || rangedLeads.length || rangedDeals.length) {
return attributionCross(rangedDaily, rangedLeads, rangedDeals);
}
const map = new Map<string, { spend: number; leads: number }>();
for (const p of channelPoints) {
@@ -200,14 +242,42 @@ export default function MetricsDashboard({
gap: -v.leads,
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
cplCrm: 0,
cierres: 0,
ventas: 0,
roas: 0,
cac: 0,
}))
.sort((a, b) => b.spend - a.spend);
}, [rangedDaily, rangedLeads, channelPoints, since, until]);
}, [rangedDaily, rangedLeads, rangedDeals, channelPoints, since, until]);
const attributionTotals = useMemo(() => {
return attributionRows.reduce(
(a, r) => ({
spend: a.spend + r.spend,
platformLeads: a.platformLeads + r.platformLeads,
crmLeads: a.crmLeads + r.crmLeads,
gap: a.gap + r.gap,
cierres: a.cierres + r.cierres,
ventas: a.ventas + r.ventas,
}),
{
spend: 0,
platformLeads: 0,
crmLeads: 0,
gap: 0,
cierres: 0,
ventas: 0,
}
);
}, [attributionRows]);
const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0;
const cierres = periodTotals.deals;
const cohortFromLeads = cohortSource === 'cliente_leads';
const revenueMissing =
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
const cards: Card[] = [
{
@@ -236,8 +306,10 @@ export default function MetricsDashboard({
key: 'revenue',
label: 'Ventas (CRM)',
hint: hasCohort
? `${formatNum(cierres)} deals ganados · importe Clientify`
: 'Deals won Clientify en el periodo',
? cohortFromLeads
? `${formatNum(cierres)} clientes Clientify · importe no en webhook`
: `${formatNum(cierres)} deals ganados · importe Clientify`
: 'Deals won / status cliente Clientify en el periodo',
format: formatEur,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
cohort: true,
@@ -246,7 +318,7 @@ export default function MetricsDashboard({
key: 'cac',
label: 'CAC',
hint: hasCohort
? `Inversión ads ÷ ${formatNum(cierres)} ventas`
? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}`
: 'Inversión ÷ deals ganados (no leads)',
format: (v) => `${formatNum(v)} €`,
icon: <Target className="w-4 h-4 text-amber-600" />,
@@ -256,7 +328,9 @@ export default function MetricsDashboard({
{
key: 'roas',
label: 'ROAS ventas',
hint: 'Ventas CRM ÷ inversión ads',
hint: revenueMissing
? 'Falta importe en CRM (API BPS) para ROAS'
: `Importe deals ganados Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`,
format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true,
@@ -287,7 +361,11 @@ export default function MetricsDashboard({
const worstGap = [...attributionRows].sort(
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
)[0];
return { top, worstGap };
const platTotal = attributionRows.reduce((a, r) => a + r.platformLeads, 0);
const crmTotal = attributionRows.reduce((a, r) => a + r.crmLeads, 0);
const crmPaid = paid.reduce((a, r) => a + r.crmLeads, 0);
const crmOther = crmTotal - crmPaid;
return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther };
}, [attributionRows]);
return (
@@ -307,8 +385,20 @@ 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">
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.
Ventas / CAC / ROAS vacíos en <strong>{rangeHint}</strong>: no hay
deals ganados ni contactos con status «cliente» en este rango
{wonDeals.length
? ` (sí hay ${formatNum(wonDeals.length)} en el histórico — prueba «30 días» o «Mes»).`
: '.'}{' '}
El gasto de ads no cuenta como venta.
</p>
)}
{cohortFromLeads && hasCohort && (
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
Cohorte desde contactos Clientify status «cliente» (no hay deals
ganados sincronizados). Importe € incompleto hasta conectar la API
Clientify de este tenant. Meta pixel reporta compras en el periodo;
el CRM marca clientes sin amount en el webhook.
</p>
)}
@@ -350,7 +440,13 @@ export default function MetricsDashboard({
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{cards.map((c) => {
const emptyCohort = Boolean(c.cohort) && !hasCohort;
const value = emptyCohort ? '—' : c.format(periodTotals[c.key]);
const showClientesCount =
c.key === 'revenue' && revenueMissing && !emptyCohort;
const value = emptyCohort
? '—'
: showClientesCount
? `${formatNum(cierres)} clientes`
: c.format(periodTotals[c.key]);
return (
<div
key={c.key}
@@ -367,9 +463,11 @@ export default function MetricsDashboard({
</p>
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
<DeltaTriple
delta={deltas[c.key]}
delta={rangeDeltas[c.key] ?? deltas[c.key]}
invert={c.invert}
empty={emptyCohort}
format={c.format}
current={periodTotals[c.key]}
/>
</div>
);
@@ -383,16 +481,9 @@ export default function MetricsDashboard({
Cruce por canal · ads vs CRM
</h3>
<p className="text-xs text-gray-600 mt-0.5">
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.
Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las
ventas de deals sin fuente de contacto aparecen en{' '}
<span className="font-medium">sin-atribuir</span>.
</p>
</div>
<p className="text-xs text-gray-600 shrink-0 font-medium">
@@ -401,16 +492,17 @@ export default function MetricsDashboard({
</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{' '}
<strong>Resumen:</strong> plat {formatNum(channelInsight.platTotal)}{' '}
vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '}
{channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''}
{formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De
los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto
ads y {formatNum(channelInsight.crmOther)} en otras fuentes
(integrations, ecommerce, messenger…). Mayor desfase en{' '}
<span className="capitalize font-semibold">
{channelInsight.worstGap.channel}
</span>{' '}
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
{formatNum(channelInsight.worstGap.gap)}).
</span>
.
</p>
)}
{attributionRows.length === 0 ? (
@@ -427,6 +519,9 @@ export default function MetricsDashboard({
<th className="py-2 pr-3 font-semibold">Leads plat</th>
<th className="py-2 pr-3 font-semibold">Leads CRM</th>
<th className="py-2 pr-3 font-semibold">Gap</th>
<th className="py-2 pr-3 font-semibold">Cierres</th>
<th className="py-2 pr-3 font-semibold">Ventas</th>
<th className="py-2 pr-3 font-semibold">ROAS</th>
<th className="py-2 pr-3 font-semibold">CPL plat</th>
<th className="py-2 font-semibold">CPL CRM</th>
</tr>
@@ -469,6 +564,15 @@ export default function MetricsDashboard({
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{hasCrm ? formatNum(row.cierres) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
{row.ventas > 0 ? formatEur(row.ventas) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.cplPlatform
? `${formatNum(row.cplPlatform)} €`
@@ -482,6 +586,70 @@ export default function MetricsDashboard({
</tr>
))}
</tbody>
{attributionRows.length > 0 && (
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="py-2.5 pr-3 text-gray-900">Total</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
{attributionTotals.spend
? formatEur(attributionTotals.spend)
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{formatNum(attributionTotals.platformLeads)}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
</td>
<td
className={`py-2.5 pr-3 tabular-nums ${
hasCrm
? gapClass(attributionTotals.gap)
: 'text-gray-500'
}`}
>
{hasCrm
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{hasCrm ? formatNum(attributionTotals.cierres) : '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
{attributionTotals.ventas > 0 || periodTotals.revenue > 0
? formatEur(
attributionTotals.ventas || periodTotals.revenue
)
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{attributionTotals.spend > 0 &&
(attributionTotals.ventas || periodTotals.revenue) > 0
? `${formatNum(
(attributionTotals.ventas ||
periodTotals.revenue) / attributionTotals.spend
)}x`
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{attributionTotals.platformLeads > 0
? `${formatNum(
attributionTotals.spend /
attributionTotals.platformLeads
)} €`
: '—'}
</td>
<td className="py-2.5 tabular-nums">
{hasCrm && attributionTotals.crmLeads > 0
? `${formatNum(
attributionTotals.spend /
attributionTotals.crmLeads
)} €`
: '—'}
</td>
</tr>
</tfoot>
)}
</table>
</div>
)}
@@ -498,13 +666,13 @@ export default function MetricsDashboard({
{dailyChart.map((d) => (
<div
key={`s-${d.date}`}
className="flex-1 min-w-0"
className="flex-1 min-w-0 h-full flex items-end"
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)}%`,
height: `${Math.max(4, (d.spend / maxSpend) * 80)}px`,
}}
/>
</div>
@@ -523,13 +691,18 @@ export default function MetricsDashboard({
{dailyChart.map((d) => (
<div
key={`l-${d.date}`}
className="flex-1 min-w-0"
className="flex-1 min-w-0 h-full flex items-end"
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)}%`,
height: `${Math.max(
4,
(d.leads /
Math.max(1, ...dailyChart.map((x) => x.leads))) *
80
)}px`,
}}
/>
</div>