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>
This commit is contained in:
4Ventures
2026-09-24 10:40:12 +02:00
parent 8fda2996b6
commit 21051afda9
20 changed files with 383 additions and 184 deletions

View File

@@ -19,6 +19,7 @@ import {
AttributionChannelRow,
ChannelWeekPoint,
CrmLeadRow,
DealWonRow,
Delta,
MetricTotals,
formatEur,
@@ -26,14 +27,16 @@ import {
formatPct,
attributionCross,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
metricTotalsFromDaily,
resolveKpiDayRange,
sumWeekly,
withCohortFromDeals,
KpiWeeklyRow,
KpiRangeKey,
} from '@/lib/metrics';
import { PageHeader } from '@/components/ui';
import { PageHeader, SegmentedControl } from '@/components/ui';
type Card = {
key: keyof MetricTotals;
@@ -42,11 +45,28 @@ 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. */
cohort?: boolean;
};
type RangeKey = KpiRangeKey;
function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
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 },
@@ -55,16 +75,15 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
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 good = c.v === null ? null : invert ? c.v < 0 : c.v > 0;
const color =
c.v === null
? 'text-gray-500'
: good
? 'text-emerald-400'
? 'text-emerald-700'
: c.v === 0
? 'text-gray-400'
: 'text-red-400';
? 'text-gray-600'
: 'text-red-600';
const Icon =
c.v === null || c.v === 0
? Minus
@@ -74,15 +93,15 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
return (
<div
key={c.label}
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1 text-center"
className="rounded-md bg-gray-50 border border-gray-200 px-1.5 py-1.5 text-center"
>
<p className="text-[9px] uppercase tracking-wider text-gray-500">
<p className="text-[10px] font-semibold uppercase tracking-wider text-gray-600">
{c.label}
</p>
<p
className={`text-[11px] font-semibold inline-flex items-center justify-center gap-0.5 ${color}`}
className={`text-xs font-bold inline-flex items-center justify-center gap-0.5 ${color}`}
>
<Icon className="w-3 h-3" />
<Icon className="w-3.5 h-3.5" />
{formatPct(c.v)}
</p>
</div>
@@ -93,11 +112,11 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) {
}
function gapClass(gap: number): string {
if (gap === 0) return 'text-gray-400';
if (gap === 0) return 'text-gray-600';
const abs = Math.abs(gap);
if (abs <= 5) return 'text-emerald-400';
if (abs <= 25) return 'text-amber-400';
return 'text-red-400';
if (abs <= 5) return 'text-emerald-700';
if (abs <= 25) return 'text-amber-700';
return 'text-red-600';
}
export default function MetricsDashboard({
@@ -112,6 +131,7 @@ export default function MetricsDashboard({
channelPoints = [],
dailyRows = [],
crmLeads = [],
wonDeals = [],
}: {
label: string;
portfolioWide: boolean;
@@ -127,10 +147,9 @@ export default function MetricsDashboard({
sparse: boolean;
weeklyRows?: KpiWeeklyRow[];
channelPoints?: ChannelWeekPoint[];
/** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */
dailyRows?: AdDailyRow[];
/** Clientify leads for CRM vs platform attribution. */
crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[];
}) {
const [range, setRange] = useState<RangeKey>('7d');
@@ -148,25 +167,39 @@ export default function MetricsDashboard({
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(() => {
if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily);
// Fallback when no ad_daily: approximate from weekly (legacy)
if (!dayWindow) return sumWeekly(weeklyRows);
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 ranged.length ? sumWeekly(ranged) : month;
}, [rangedDaily, weeklyRows, dayWindow, month]);
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);
}
// Fallback: weekly series without CRM cross
const map = new Map<string, { spend: number; leads: number }>();
const src =
range === 'all'
@@ -197,6 +230,7 @@ export default function MetricsDashboard({
const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0;
const rangeHint = dayWindow
? `${dayWindow.since} → ${dayWindow.until}`
@@ -208,44 +242,51 @@ export default function MetricsDashboard({
label: 'Inversión ads',
hint: 'Paid del periodo',
format: formatEur,
icon: <DollarSign className="w-4 h-4 text-brand-500" />,
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-400" />,
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-400" />,
icon: <MousePointerClick className="w-4 h-4 text-violet-600" />,
invert: true,
},
{
key: 'cac',
label: 'CAC cohorte',
hint: 'Paid / cierres (parcial v1)',
hint: hasCohort
? `Paid ÷ ${formatNum(periodTotals.deals)} cierres`
: 'Paid ÷ deals ganados Clientify',
format: (v) => `${formatNum(v)} €`,
icon: <Target className="w-4 h-4 text-amber-400" />,
icon: <Target className="w-4 h-4 text-amber-600" />,
invert: true,
cohort: true,
},
{
key: 'revenue',
label: 'Revenue cohorte',
hint: 'Deals won atribuidos',
hint: hasCohort
? `${formatNum(periodTotals.deals)} deals won`
: 'Suma amount deals won',
format: formatEur,
icon: <TrendingUp className="w-4 h-4 text-emerald-400" />,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
cohort: true,
},
{
key: 'roas',
label: 'ROAS cohorte',
hint: 'Revenue / spend',
hint: 'Revenue ÷ spend',
format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-300" />,
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true,
},
];
@@ -257,42 +298,38 @@ export default function MetricsDashboard({
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-500" />
<div className="flex gap-1 rounded-lg border border-white/[0.08] p-0.5 bg-gray-50">
{(
[
['7d', '7d'],
['30d', '30d'],
['month', 'Mes'],
['all', 'Todo'],
] as [RangeKey, string][]
).map(([k, lbl]) => (
<button
key={k}
type="button"
onClick={() => setRange(k)}
className={`px-2.5 py-1 text-xs rounded-md ${
range === k
? 'bg-brand-500/20 text-brand-300'
: 'text-gray-400 hover:text-white'
}`}
>
{lbl}
</button>
))}
</div>
<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-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
<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>
)}
{/* North Star strip */}
{!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) },
@@ -316,45 +353,54 @@ export default function MetricsDashboard({
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-brand-500/20 bg-brand-500/5 px-4 py-3"
className="rounded-xl border border-brand-200 bg-brand-50 px-4 py-3 shadow-sm"
>
<p className="text-[10px] uppercase tracking-wider text-brand-400/80">
<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">{s.value}</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) => (
<div
key={c.key}
className="bg-white border border-gray-200 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}
{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>
<p className="text-2xl font-bold text-gray-900">
{c.format(periodTotals[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>
{/* Channel attribution cross */}
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
<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-[11px] text-gray-500 mt-0.5">
<p className="text-xs text-gray-600 mt-0.5">
Plataforma = leads reportados por Meta/Google · CRM = atribución
Clientify (origen/UTM)
{!hasCrm && portfolioWide
@@ -362,61 +408,61 @@ export default function MetricsDashboard({
: ''}
</p>
</div>
<p className="text-[11px] text-gray-500 shrink-0">
<p className="text-xs text-gray-600 shrink-0 font-medium">
Gap = CRM − plat
</p>
</div>
{attributionRows.length === 0 ? (
<p className="text-xs text-gray-500 py-6 text-center">
<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-xs">
<thead className="text-gray-500 border-b border-gray-200">
<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-medium">Canal</th>
<th className="py-2 pr-3 font-medium">Inversión</th>
<th className="py-2 pr-3 font-medium">Leads plat</th>
<th className="py-2 pr-3 font-medium">Leads CRM</th>
<th className="py-2 pr-3 font-medium">Gap</th>
<th className="py-2 pr-3 font-medium">CPL plat</th>
<th className="py-2 font-medium">CPL CRM</th>
<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-900 hover:bg-white/[0.02]"
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-400 hover:text-brand-300 font-semibold capitalize"
className="text-brand-700 hover:text-brand-600 font-semibold capitalize"
>
{row.channel}
</Link>
) : (
<span className="text-gray-600 font-semibold capitalize">
<span className="text-gray-700 font-semibold capitalize">
{row.channel}
</span>
)}
</td>
<td className="py-2.5 pr-3 text-gray-800">
<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">
<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">
<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-medium ${
className={`py-2.5 pr-3 font-semibold tabular-nums ${
hasCrm ? gapClass(row.gap) : 'text-gray-500'
}`}
>
@@ -424,12 +470,12 @@ export default function MetricsDashboard({
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800">
<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">
<td className="py-2.5 text-gray-800 tabular-nums">
{hasCrm && row.cplCrm
? `${formatNum(row.cplCrm)} €`
: '—'}
@@ -440,52 +486,45 @@ export default function MetricsDashboard({
</table>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 pt-2">
{['GEO', 'CRM close %', 'LTV'].map((soon) => (
<div
key={soon}
className="rounded-lg border border-dashed border-gray-200 px-3 py-2 text-[11px] text-gray-500"
>
{soon} · fase 2 (stub)
</div>
))}
</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">
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs uppercase tracking-wider">
<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-400" />
<Eye className="w-4 h-4 text-sky-600" />
</div>
<p className="text-lg font-bold text-gray-900">{formatEur(daily.spend)}</p>
<p className="text-xs text-gray-500 mt-1">
<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-[11px] text-gray-600 mt-2">
<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">
<p className="text-xs uppercase tracking-wider text-gray-500 mb-2">
<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-xs text-gray-400 space-y-1 list-disc list-inside">
<ul className="text-sm text-gray-700 space-y-1.5 list-disc list-inside">
<li>
El selector filtra el mismo calendario que Ad Tracker; el cruce
compara leads de la plataforma ads vs atribución Clientify.
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 (whatsapp, manual…) son orgánicos/CRM.
spend son orgánicos/CRM.
</li>
<li>
CPL CRM usa inversión paid ÷ leads CRM del canal (más realista
que el CPL de la plataforma cuando hay desfase).
CPL CRM = inversión paid ÷ leads CRM del canal (más realista
cuando hay desfase con la plataforma).
</li>
</ul>
</div>