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>
535 lines
17 KiB
TypeScript
535 lines
17 KiB
TypeScript
'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>
|
||
);
|
||
}
|