Files
4ventures-client-portal/src/components/MetricsDashboard.tsx
4Ventures 21051afda9 fix: cohort CAC/ROAS desde deals Clientify y más contraste en dashboards
Los totales de Métricas usaban solo ad_daily (cohort a 0). Ahora suman
deals won; selectores y tipografía ganan contraste sobre fondo blanco.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:40:12 +02:00

535 lines
17 KiB
TypeScript
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.
'use client';
import React, { useMemo, useState } from 'react';
import Link from 'next/link';
import {
ArrowDownRight,
ArrowUpRight,
DollarSign,
Minus,
Target,
TrendingUp,
Users,
MousePointerClick,
Eye,
CalendarRange,
} from 'lucide-react';
import {
AdDailyRow,
AttributionChannelRow,
ChannelWeekPoint,
CrmLeadRow,
DealWonRow,
Delta,
MetricTotals,
formatEur,
formatNum,
formatPct,
attributionCross,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
metricTotalsFromDaily,
resolveKpiDayRange,
sumWeekly,
withCohortFromDeals,
KpiWeeklyRow,
KpiRangeKey,
} from '@/lib/metrics';
import { PageHeader, SegmentedControl } from '@/components/ui';
type Card = {
key: keyof MetricTotals;
label: string;
hint: string;
format: (v: number) => string;
icon: React.ReactNode;
invert?: boolean;
/** When true and value is 0 with no deals, show em dash + empty state. */
cohort?: boolean;
};
type RangeKey = KpiRangeKey;
function DeltaTriple({
delta,
invert,
empty,
}: {
delta: Delta;
invert?: boolean;
empty?: boolean;
}) {
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
</p>
);
}
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-700'
: c.v === 0
? 'text-gray-600'
: 'text-red-600';
const Icon =
c.v === null || c.v === 0
? Minus
: good
? ArrowUpRight
: ArrowDownRight;
return (
<div
key={c.label}
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1.5 text-center"
>
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
{c.label}
</p>
<p
className={`text-xs font-bold inline-flex items-center justify-center gap-0.5 ${color}`}
>
<Icon className="w-3.5 h-3.5" />
{formatPct(c.v)}
</p>
</div>
);
})}
</div>
);
}
function gapClass(gap: number): string {
if (gap === 0) return 'text-gray-600';
const abs = Math.abs(gap);
if (abs <= 5) return 'text-emerald-700';
if (abs <= 25) return 'text-amber-700';
return 'text-red-600';
}
export default function MetricsDashboard({
label,
portfolioWide,
month,
week,
deltas,
daily,
sparse,
weeklyRows = [],
channelPoints = [],
dailyRows = [],
crmLeads = [],
wonDeals = [],
}: {
label: string;
portfolioWide: boolean;
month: MetricTotals;
week: MetricTotals;
deltas: Record<keyof MetricTotals, Delta>;
daily: {
spend: number;
leads: number;
impressions: number;
clicks: number;
};
sparse: boolean;
weeklyRows?: KpiWeeklyRow[];
channelPoints?: ChannelWeekPoint[];
dailyRows?: AdDailyRow[];
crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[];
}) {
const [range, setRange] = useState<RangeKey>('7d');
const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]);
const rangedDaily = useMemo(() => {
if (!dailyRows.length) return [];
if (!dayWindow) return dailyRows;
return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until);
}, [dailyRows, dayWindow]);
const rangedLeads = useMemo(() => {
if (!crmLeads.length) return [];
if (!dayWindow) return crmLeads;
return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until);
}, [crmLeads, dayWindow]);
const rangedDeals = useMemo(
() =>
filterDealsForRange(
wonDeals,
dayWindow?.since ?? null,
dayWindow?.until ?? null
),
[wonDeals, dayWindow]
);
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;
});
base = ranged.length ? sumWeekly(ranged) : month;
}
return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, dayWindow, 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) {
const cur = map.get(p.channel) || { spend: 0, leads: 0 };
cur.spend += p.spend;
cur.leads += p.leads;
map.set(p.channel, cur);
}
return [...map.entries()]
.map(([channel, v]) => ({
channel,
spend: v.spend,
platformLeads: v.leads,
crmLeads: 0,
gap: -v.leads,
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
cplCrm: 0,
}))
.sort((a, b) => b.spend - a.spend);
}, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]);
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 cards: Card[] = [
{
key: 'spend',
label: 'Inversión ads',
hint: 'Paid del periodo',
format: formatEur,
icon: <DollarSign className="w-4 h-4 text-brand-600" />,
},
{
key: 'leads',
label: 'Leads',
hint: 'Captación periodo',
format: formatNum,
icon: <Users className="w-4 h-4 text-blue-600" />,
},
{
key: 'cpl',
label: 'CPL',
hint: 'Coste por lead',
format: (v) => `${formatNum(v)} €`,
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
invert: true,
},
{
key: 'cac',
label: 'CAC cohorte',
hint: hasCohort
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres`
: 'Paid ÷ deals ganados Clientify',
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',
format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true,
},
];
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>
}
/>
{sparse && (
<p className="text-xs text-amber-800 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
Histórico limitado: si faltan deltas o canales, el warehouse aún no
tiene semanas suficientes.
</p>
)}
{!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.
</p>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: 'Inversión periodo', value: formatEur(periodTotals.spend) },
{
label: 'Leads plataforma',
value: formatNum(periodTotals.leads),
},
{
label: 'Leads CRM',
value: hasCrm ? formatNum(crmPeriodTotal) : '—',
},
{
label: 'CPL CRM (paid)',
value:
hasCrm && crmPeriodTotal > 0
? `${formatNum(periodTotals.spend / crmPeriodTotal)} €`
: periodTotals.cpl
? `${formatNum(periodTotals.cpl)} €`
: '—',
},
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-brand-200 bg-brand-50 px-4 py-3 shadow-sm"
>
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-700">
{s.label}
</p>
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
{s.value}
</p>
</div>
))}
</div>
<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]);
return (
<div
key={c.key}
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">
{c.label}
</span>
{c.icon}
</div>
<p className="text-2xl font-bold text-gray-900 tabular-nums">
{value}
</p>
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
<DeltaTriple
delta={deltas[c.key]}
invert={c.invert}
empty={emptyCohort}
/>
</div>
);
})}
</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">
<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'
: ''}
</p>
</div>
<p className="text-xs text-gray-600 shrink-0 font-medium">
Gap = CRM − plat
</p>
</div>
{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).
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200">
<tr>
<th className="py-2 pr-3 font-semibold">Canal</th>
<th className="py-2 pr-3 font-semibold">Inversión</th>
<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">CPL plat</th>
<th className="py-2 font-semibold">CPL CRM</th>
</tr>
</thead>
<tbody>
{attributionRows.map((row) => (
<tr
key={row.channel}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3">
{row.spend > 0 ? (
<Link
href={`/dashboard/kpis/canal/${encodeURIComponent(row.channel)}`}
className="text-brand-700 hover:text-brand-600 font-semibold capitalize"
>
{row.channel}
</Link>
) : (
<span className="text-gray-700 font-semibold capitalize">
{row.channel}
</span>
)}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.spend ? formatEur(row.spend) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{formatNum(row.platformLeads)}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{hasCrm ? formatNum(row.crmLeads) : '—'}
</td>
<td
className={`py-2.5 pr-3 font-semibold tabular-nums ${
hasCrm ? gapClass(row.gap) : 'text-gray-500'
}`}
>
{hasCrm
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.cplPlatform
? `${formatNum(row.cplPlatform)} €`
: '—'}
</td>
<td className="py-2.5 text-gray-800 tabular-nums">
{hasCrm && row.cplCrm
? `${formatNum(row.cplCrm)} €`
: '—'}
</td>
</tr>
))}
</tbody>
</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>
<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>
</div>
);
}