Files
4ventures-client-portal/src/components/MetricsDashboard.tsx
4Ventures a5bfec698b Align Ads and Métricas on the same paginated ad_daily warehouse.
Ad Tracker only loaded 2000 newest rows, so Brava showed 0 € while Métricas summed the full month. Both now page the same table for the selected range.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:33:06 +02:00

1115 lines
37 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 } from 'react';
import Link from 'next/link';
import {
ArrowDownRight,
ArrowUpRight,
DollarSign,
GitCompare,
Globe,
Handshake,
Info,
Megaphone,
Minus,
MousePointerClick,
Search,
Target,
TrendingUp,
Users,
} from 'lucide-react';
import {
AdDailyRow,
AttributionChannelRow,
ChannelWeekPoint,
CrmLeadRow,
DealWonRow,
Delta,
MetricTotals,
formatEur,
formatEurCents,
formatNum,
formatPct,
attributionCross,
buildRangeDeltas,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
metricTotalsFromDaily,
rangeDaySpan,
sumWeekly,
withCohortFromDeals,
KpiWeeklyRow,
} from '@/lib/metrics';
import { PageHeader, Surface } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext';
import { OrganicDailyRow } from '@/lib/organic';
type Card = {
key: keyof MetricTotals;
label: string;
hint: string;
format: (v: number) => string;
icon: React.ReactNode;
invert?: boolean;
cohort?: boolean;
};
function toneForDelta(v: number | null, invert?: boolean): string {
if (v === null) return 'text-gray-500 bg-gray-50 border-gray-200';
if (v === 0) return 'text-gray-600 bg-gray-50 border-gray-200';
const good = invert ? v < 0 : v > 0;
return good
? 'text-emerald-800 bg-emerald-50 border-emerald-200'
: 'text-red-700 bg-red-50 border-red-200';
}
function DeltaChip({
label,
v,
invert,
title,
}: {
label: string;
v: number | null;
invert?: boolean;
title?: string;
}) {
const Icon =
v === null || v === 0 ? Minus : (invert ? v < 0 : v > 0) ? ArrowUpRight : ArrowDownRight;
return (
<span
title={title}
className={`inline-flex items-center gap-0.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold tabular-nums ${toneForDelta(
v,
invert
)}`}
>
<Icon className="h-3 w-3" />
{label} {formatPct(v)}
</span>
);
}
function DeltaRow({
delta,
invert,
empty,
format,
current,
showWow,
}: {
delta: Delta;
invert?: boolean;
empty?: boolean;
format: (v: number) => string;
current: number;
showWow: boolean;
}) {
if (empty) {
return (
<p className="mt-3 text-[11px] leading-snug text-gray-500">
Sin cierres en este rango. Amplía el periodo o revisa oportunidades
ganadas en el CRM.
</p>
);
}
const items: { label: string; v: number | null; base: number | null }[] = [
...(showWow ? [{ label: 'sem.', v: delta.wow, base: delta.wowBase }] : []),
{ label: 'mes ant.', v: delta.mom, base: delta.momBase },
{ label: 'año ant.', v: delta.yoy, base: delta.yoyBase },
];
return (
<div className="mt-3 flex flex-wrap gap-1.5">
{items.map((c) => {
const hasBase =
c.base !== null && Number.isFinite(c.base) && !(c.base === 0 && c.v === null);
return (
<DeltaChip
key={c.label}
label={c.label}
v={c.v}
invert={invert}
title={
hasBase
? `Anterior: ${format(c.base ?? 0)} · ahora ${format(current)}`
: 'Sin histórico suficiente'
}
/>
);
})}
</div>
);
}
function gapTone(gap: number): string {
if (gap === 0) return 'text-gray-700 bg-gray-100';
if (gap > 0) return 'text-amber-900 bg-amber-50 border border-amber-200';
const abs = Math.abs(gap);
if (abs <= 5) return 'text-emerald-800 bg-emerald-50 border border-emerald-200';
return 'text-red-800 bg-red-50 border border-red-200';
}
function SparkBars({
points,
color,
formatValue,
}: {
points: { date: string; value: number }[];
color: string;
formatValue: (v: number) => string;
}) {
if (points.length === 0) {
return <p className="text-xs text-gray-500 py-8 text-center">Sin filas diarias.</p>;
}
const max = Math.max(1, ...points.map((p) => p.value));
const first = points[0]?.date;
const last = points[points.length - 1]?.date;
const fmtDay = (d?: string) =>
d
? new Date(`${d}T00:00:00`).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
})
: '';
return (
<div>
<div className="flex items-end justify-between text-[10px] text-gray-500 mb-1 tabular-nums">
<span>{formatValue(max)}</span>
<span>máx.</span>
</div>
<div className="flex items-end gap-px h-28 w-full rounded-lg bg-gray-50 px-1 pt-2 pb-0 border border-gray-100">
{points.map((d) => (
<div
key={d.date}
className="flex-1 min-w-0 h-full flex items-end"
title={`${d.date}: ${formatValue(d.value)}`}
>
<div
className={`w-full rounded-t ${color}`}
style={{ height: `${Math.max(6, (d.value / max) * 100)}%` }}
/>
</div>
))}
</div>
<div className="flex justify-between text-[10px] text-gray-500 mt-1.5 tabular-nums">
<span>{fmtDay(first)}</span>
<span>{fmtDay(last)}</span>
</div>
</div>
);
}
function SectionHead({
icon,
title,
hint,
}: {
icon: React.ReactNode;
title: string;
hint: string;
}) {
return (
<div className="flex items-start gap-3 mb-4">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-700 border border-brand-200/60">
{icon}
</span>
<div>
<h2 className="text-base font-semibold text-gray-900 tracking-tight">
{title}
</h2>
<p className="text-xs text-gray-500 mt-0.5">{hint}</p>
</div>
</div>
);
}
function MetricTile({
label,
hint,
value,
icon,
delta,
invert,
empty,
format,
current,
showWow,
}: {
label: string;
hint: string;
value: string;
icon: React.ReactNode;
delta: Delta;
invert?: boolean;
empty?: boolean;
format: (v: number) => string;
current: number;
showWow: boolean;
}) {
return (
<div className="rounded-2xl border border-gray-200 bg-white p-4 md:p-5 shadow-sm h-full">
<div className="flex items-start justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
{label}
</p>
<span className="text-gray-400">{icon}</span>
</div>
<p className="mt-2 text-3xl font-semibold tracking-tight text-gray-900 tabular-nums leading-none">
{value}
</p>
<p className="text-xs text-gray-500 mt-2 leading-snug">{hint}</p>
<DeltaRow
delta={delta}
invert={invert}
empty={empty}
format={format}
current={current}
showWow={showWow}
/>
</div>
);
}
function StatMini({
label,
value,
hint,
icon,
}: {
label: string;
value: string;
hint: string;
icon?: React.ReactNode;
}) {
return (
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3.5">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 inline-flex items-center gap-1.5">
{icon}
{label}
</p>
<p className="text-2xl font-semibold text-gray-900 mt-1.5 tabular-nums tracking-tight">
{value}
</p>
<p className="text-xs text-gray-500 mt-1">{hint}</p>
</div>
);
}
export default function MetricsDashboard({
label,
portfolioWide = false,
tenantId,
month,
week,
deltas,
daily,
sparse,
weeklyRows = [],
channelPoints = [],
dailyRows = [],
organicRows = [],
crmLeads = [],
wonDeals = [],
integrations = [],
cohortSource = 'deals',
}: {
label: string;
portfolioWide?: boolean;
tenantId?: string | null;
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[];
organicRows?: OrganicDailyRow[];
crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[];
integrations?: any[];
cohortSource?: 'deals' | 'cliente_leads' | 'none';
}) {
void tenantId;
void week;
void daily;
const { since, until, label: rangeHint } = useDateRange();
const rangedDaily = useMemo(() => {
if (!dailyRows.length) return [];
return filterDailyByDayRange(dailyRows, since, until);
}, [dailyRows, since, until]);
const rangedLeads = useMemo(() => {
if (!crmLeads.length) return [];
return filterLeadsByDayRange(crmLeads, since, until);
}, [crmLeads, since, until]);
const rangedDeals = useMemo(
() => filterDealsForRange(wonDeals, since, until),
[wonDeals, since, until]
);
const periodTotals = useMemo(() => {
let base: MetricTotals;
if (dailyRows.length) {
base = rangedDaily.length
? metricTotalsFromDaily(rangedDaily)
: metricTotalsFromDaily([]);
} else {
const ranged = weeklyRows.filter((r) => {
if (!r.week) return false;
const w = r.week.slice(0, 10);
return w <= until && w >= since;
});
base = ranged.length ? sumWeekly(ranged) : month;
}
return withCohortFromDeals(base, rangedDeals);
}, [dailyRows.length, rangedDaily, rangedDeals, weeklyRows, since, until, month]);
const rangeDeltas = useMemo(
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
[dailyRows, wonDeals, since, until]
);
const attributionRows: AttributionChannelRow[] = useMemo(() => {
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) {
const d = p.week;
if (!d || d < since || d > until) continue;
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,
ventas: 0,
importe: 0,
roas: 0,
cac: 0,
}))
.sort((a, b) => b.spend - a.spend);
}, [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,
ventas: a.ventas + r.ventas,
importe: a.importe + r.importe,
}),
{
spend: 0,
platformLeads: 0,
crmLeads: 0,
gap: 0,
ventas: 0,
importe: 0,
}
);
}, [attributionRows]);
const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0;
const ventasCount = periodTotals.deals;
const cohortFromLeads = cohortSource === 'cliente_leads';
const revenueMissing =
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
const showWow = rangeDaySpan(since, until) <= 7;
const cplCrm =
hasCrm && crmPeriodTotal > 0 ? periodTotals.spend / crmPeriodTotal : 0;
const gapAdsCrm = hasCrm ? crmPeriodTotal - periodTotals.leads : 0;
const organicPeriod = useMemo(() => {
return organicPeriodSafe(organicRows, since, until, periodTotals.leads);
}, [organicRows, since, until, periodTotals.leads]);
const seoCtr =
organicPeriod.gscImpressions > 0
? (organicPeriod.gscClicks / organicPeriod.gscImpressions) * 100
: 0;
const cards: Card[] = [
{
key: 'spend',
label: 'Inversión ads',
hint: 'Gasto Meta + Google del periodo',
format: formatEur,
icon: <DollarSign className="w-4 h-4" />,
},
{
key: 'leads',
label: 'Leads plataforma',
hint: 'Conversiones que reportan los anuncios',
format: formatNum,
icon: <Users className="w-4 h-4" />,
},
{
key: 'cpl',
label: 'CPL ads',
hint: 'Inversión ÷ leads de plataforma',
format: (v) => formatEurCents(v),
icon: <MousePointerClick className="w-4 h-4" />,
invert: true,
},
{
key: 'deals',
label: 'Ventas CRM',
hint: hasCohort
? cohortFromLeads
? 'Contactos marcados cliente (sin importe)'
: `Oportunidades ganadas · ${formatEur(periodTotals.revenue)}`
: 'Oportunidades ganadas en el periodo',
format: formatNum,
icon: <TrendingUp className="w-4 h-4" />,
cohort: true,
},
{
key: 'cac',
label: 'CAC',
hint: hasCohort
? `Inversión ÷ ${formatNum(ventasCount)} ventas`
: 'Inversión ÷ deals ganados',
format: (v) => formatEurCents(v),
icon: <Target className="w-4 h-4" />,
invert: true,
cohort: true,
},
{
key: 'roas',
label: 'ROAS',
hint: revenueMissing
? 'Falta importe en el CRM'
: `Importe CRM ÷ inversión ads`,
format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4" />,
cohort: true,
},
];
const dailyChart = useMemo(() => {
const byDay = new Map<string, { spend: number; leads: number }>();
for (const r of rangedDaily) {
const d = (r.date || '').slice(0, 10);
if (!d) continue;
const cur = byDay.get(d) || { spend: 0, leads: 0 };
cur.spend += Number(r.spend) || 0;
cur.leads += Number(r.platform_leads ?? r.conversions) || 0;
byDay.set(d, cur);
}
return [...byDay.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([date, v]) => ({ date, ...v }));
}, [rangedDaily]);
const channelInsight = useMemo(() => {
if (!attributionRows.length) return null;
const paid = attributionRows.filter((r) => r.spend > 0);
const worstGap = [...attributionRows].sort(
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
)[0];
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 { worstGap, platTotal, crmTotal, crmPaid, crmOther };
}, [attributionRows]);
const crmName = useMemo(() => {
const rows = (integrations || []) as { provider?: string; status?: string }[];
const ghl = rows.find(
(i) => i.provider === 'gohighlevel' && i.status === 'connected'
);
const cfy = rows.find(
(i) => i.provider === 'clientify' && i.status === 'connected'
);
if (ghl) return 'GoHighLevel';
if (cfy) return 'Clientify';
return 'CRM';
}, [integrations]);
const adsCards = cards.filter((c) =>
['spend', 'leads', 'cpl'].includes(c.key)
);
const outcomeCards = cards.filter((c) =>
['deals', 'cac', 'roas'].includes(c.key)
);
const spendMax = Math.max(1, ...attributionRows.map((r) => r.spend));
const heroRoasEmpty = !hasCohort;
const heroDeltas = rangeDeltas;
return (
<div className="space-y-8">
<PageHeader
eyebrow="Rendimiento"
title="Métricas"
description={`${label}${portfolioWide ? ' · portafolio' : ''} · ${rangeHint}`}
actions={<SyncNowButton forceModule="metrics" />}
/>
{(sparse || !hasCohort || (cohortFromLeads && hasCohort)) && (
<div className="space-y-2">
{sparse && (
<p className="text-xs text-amber-900 border border-amber-200 bg-amber-50 rounded-xl px-3 py-2">
Histórico limitado: el warehouse aún no tiene semanas suficientes
para algunos deltas.
</p>
)}
{!hasCohort && (
<p className="text-xs text-gray-700 border border-gray-200 bg-white rounded-xl px-3 py-2">
Sin ventas en {rangeHint}
{wonDeals.length
? ` (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-200 bg-amber-50 rounded-xl px-3 py-2">
Cohorte desde contactos «cliente» (no hay oportunidades ganadas
sincronizadas). El importe puede ir incompleto.
</p>
)}
</div>
)}
<div className="rounded-2xl border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50/70 p-4 md:p-6 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-brand-700 mb-4">
Resumen del periodo
</p>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
<HeroStat
label="Inversión"
value={formatEur(periodTotals.spend)}
chip={
<DeltaChip
label="mes"
v={heroDeltas.spend.mom}
title="Vs mes anterior"
/>
}
/>
<HeroStat
label="CPL ads"
value={formatEurCents(periodTotals.cpl)}
chip={
<DeltaChip
label="mes"
v={heroDeltas.cpl.mom}
invert
title="Vs mes anterior · más bajo es mejor"
/>
}
/>
<HeroStat
label={`Leads ${crmName}`}
value={hasCrm ? formatNum(crmPeriodTotal) : '—'}
chip={
<span className="text-[11px] text-gray-500">
{formatNum(periodTotals.leads)} plat.
</span>
}
/>
<HeroStat
label="Ventas"
value={hasCohort ? formatNum(periodTotals.deals) : '—'}
chip={
hasCohort ? (
<DeltaChip label="mes" v={heroDeltas.deals.mom} />
) : (
<span className="text-[11px] text-gray-500">sin cierres</span>
)
}
/>
<HeroStat
label="ROAS"
value={heroRoasEmpty ? '—' : `${formatNum(periodTotals.roas)}x`}
chip={
heroRoasEmpty ? (
<span className="text-[11px] text-gray-500">pendiente CRM</span>
) : (
<DeltaChip label="mes" v={heroDeltas.roas.mom} />
)
}
/>
</div>
</div>
<details className="group rounded-xl border border-gray-200 bg-white">
<summary className="cursor-pointer list-none flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-600 hover:text-gray-900">
<Info className="w-3.5 h-3.5 text-brand-600" />
Cómo leer ads, CRM y web
<span className="ml-auto text-gray-400">
<span className="group-open:hidden">mostrar</span>
<span className="hidden group-open:inline">ocultar</span>
</span>
</summary>
<div className="px-4 pb-3 text-xs text-gray-600 space-y-1.5 border-t border-gray-100 pt-2">
<p>
<strong className="text-gray-800">Publicidad:</strong> lo que Meta y
Google reportan. No es el listado de clientes.
</p>
<p>
<strong className="text-gray-800">{crmName}:</strong> altas y ventas
reales. Un lead de ads y un alta CRM casi nunca coinciden 1 a 1.
</p>
<p>
<strong className="text-gray-800">Web y SEO:</strong> Search Console
y Analytics. No se suman a los leads de anuncios.
</p>
</div>
</details>
<section>
<SectionHead
icon={<Megaphone className="w-4 h-4" />}
title="Publicidad"
hint={`Meta + Google Ads · ${rangeHint}`}
/>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{adsCards.map((c) => (
<MetricTile
key={c.key}
label={c.label}
hint={c.hint}
value={c.format(periodTotals[c.key])}
icon={c.icon}
delta={rangeDeltas[c.key] ?? deltas[c.key]}
invert={c.invert}
format={c.format}
current={periodTotals[c.key]}
showWow={showWow}
/>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Surface>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
Inversión por día
</p>
<SparkBars
points={dailyChart.map((d) => ({ date: d.date, value: d.spend }))}
color="bg-brand-500 hover:bg-brand-600"
formatValue={formatEur}
/>
</Surface>
<Surface>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500 mb-3">
Leads de plataforma por día
</p>
<SparkBars
points={dailyChart.map((d) => ({ date: d.date, value: d.leads }))}
color="bg-sky-500 hover:bg-sky-600"
formatValue={formatNum}
/>
</Surface>
</div>
</section>
<section>
<SectionHead
icon={<Handshake className="w-4 h-4" />}
title={`Negocio · ${crmName}`}
hint="Altas y ventas reales · no uses estos números como si fueran los de ads"
/>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 mb-3">
<StatMini
label="Leads CRM"
value={hasCrm ? formatNum(crmPeriodTotal) : '—'}
hint={`Altas en ${crmName}`}
/>
<StatMini
label="CPL CRM"
value={cplCrm ? formatEurCents(cplCrm) : '—'}
hint="Inversión ads ÷ leads CRM"
/>
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3.5 col-span-2 lg:col-span-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
Gap ads vs CRM
</p>
<p
className={`mt-1.5 inline-flex items-center rounded-full px-2.5 py-0.5 text-xl font-semibold tabular-nums ${
hasCrm ? gapTone(gapAdsCrm) : 'text-gray-400'
}`}
>
{hasCrm
? `${gapAdsCrm > 0 ? '+' : ''}${formatNum(gapAdsCrm)}`
: '—'}
</p>
<p className="text-xs text-gray-500 mt-1.5">
CRM − conversiones ads. Positivo = más altas en CRM que las que
reporta el anuncio.
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{outcomeCards.map((c) => {
const emptyCohort = Boolean(c.cohort) && !hasCohort;
return (
<MetricTile
key={c.key}
label={c.label}
hint={c.hint}
value={emptyCohort ? '—' : c.format(periodTotals[c.key])}
icon={c.icon}
delta={rangeDeltas[c.key] ?? deltas[c.key]}
invert={c.invert}
empty={emptyCohort}
format={c.format}
current={periodTotals[c.key]}
showWow={showWow}
/>
);
})}
</div>
</section>
<section>
<SectionHead
icon={<Globe className="w-4 h-4" />}
title="Web y SEO"
hint="Tráfico orgánico · mismo periodo · no es publicidad"
/>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatMini
label="Clics SEO"
value={formatNum(organicPeriod.gscClicks)}
hint={`CTR ${seoCtr.toFixed(1)}% · Search Console`}
icon={<Search className="w-3.5 h-3.5" />}
/>
<StatMini
label="Impresiones"
value={formatNum(organicPeriod.gscImpressions)}
hint="Apariciones en Google"
/>
<StatMini
label="Sesiones web"
value={formatNum(organicPeriod.ga4Sessions)}
hint="Analytics (GA4)"
icon={<Globe className="w-3.5 h-3.5" />}
/>
<StatMini
label="Conv. web"
value={formatNum(organicPeriod.ga4Conversions)}
hint={`Cuota vs ads ${organicPeriod.organicShare.toFixed(0)}%`}
/>
</div>
</section>
<section>
<SectionHead
icon={<GitCompare className="w-4 h-4" />}
title={`Cruce por canal · ads vs ${crmName}`}
hint="Leads plat = el anuncio. Leads CRM = fuente del contacto. Gap = CRM − plat."
/>
{channelInsight && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
<div className="rounded-xl bg-gray-50 border border-gray-200 px-3 py-2.5">
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
Plat.
</p>
<p className="text-lg font-semibold tabular-nums text-gray-900">
{formatNum(channelInsight.platTotal)}
</p>
</div>
<div className="rounded-xl bg-gray-50 border border-gray-200 px-3 py-2.5">
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
CRM
</p>
<p className="text-lg font-semibold tabular-nums text-gray-900">
{formatNum(channelInsight.crmTotal)}
</p>
</div>
<div className="rounded-xl bg-gray-50 border border-gray-200 px-3 py-2.5">
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
Con gasto ads
</p>
<p className="text-lg font-semibold tabular-nums text-gray-900">
{formatNum(channelInsight.crmPaid)}
</p>
</div>
<div className="rounded-xl bg-gray-50 border border-gray-200 px-3 py-2.5">
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-semibold">
Otras fuentes
</p>
<p className="text-lg font-semibold tabular-nums text-gray-900">
{formatNum(channelInsight.crmOther)}
</p>
</div>
</div>
)}
{channelInsight && (
<p className="text-xs text-gray-500 mb-3">
Mayor desfase en{' '}
<span className="capitalize font-semibold text-gray-800">
{channelInsight.worstGap.channel}
</span>
. Ecommerce, messenger e integrations no son fallos de ads.
</p>
)}
<Surface padded={false}>
{attributionRows.length === 0 ? (
<p className="text-sm text-gray-600 py-10 text-center">
Sin series de canal en el periodo.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 text-[11px] uppercase tracking-wider text-gray-500">
<th className="py-3 px-4 font-semibold">Canal</th>
<th className="py-3 pr-4 font-semibold min-w-[160px]">
Inversión
</th>
<th className="py-3 pr-4 font-semibold">Plat.</th>
<th className="py-3 pr-4 font-semibold">CRM</th>
<th className="py-3 pr-4 font-semibold">Gap</th>
<th className="py-3 pr-4 font-semibold">Ventas</th>
<th className="py-3 pr-4 font-semibold">Importe</th>
<th className="py-3 pr-4 font-semibold">ROAS</th>
<th className="py-3 pr-4 font-semibold">CPL plat</th>
<th className="py-3 pr-4 font-semibold">CPL CRM</th>
</tr>
</thead>
<tbody>
{attributionRows.map((row) => (
<tr
key={row.channel}
className={`border-b border-gray-100 hover:bg-brand-50/40 ${
row.spend > 0 ? 'bg-white' : 'bg-gray-50/40'
}`}
>
<td className="py-3 px-4">
{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-600 font-medium capitalize">
{row.channel}
</span>
)}
</td>
<td className="py-3 pr-4">
{row.spend ? (
<div className="flex items-center gap-2 min-w-[140px]">
<span className="tabular-nums text-gray-900 w-[4.5rem]">
{formatEur(row.spend)}
</span>
<div className="h-1.5 flex-1 rounded-full bg-gray-100">
<div
className="h-1.5 rounded-full bg-brand-500"
style={{
width: `${Math.max(
4,
(row.spend / spendMax) * 100
)}%`,
}}
/>
</div>
</div>
) : (
<span className="text-gray-400">—</span>
)}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{formatNum(row.platformLeads)}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{hasCrm ? formatNum(row.crmLeads) : '—'}
</td>
<td className="py-3 pr-4">
{hasCrm ? (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ${gapTone(
row.gap
)}`}
>
{row.gap > 0 ? '+' : ''}
{formatNum(row.gap)}
</span>
) : (
'—'
)}
</td>
<td className="py-3 pr-4 tabular-nums font-semibold text-gray-900">
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{row.importe > 0 ? formatEur(row.importe) : '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{row.cplPlatform
? formatEurCents(row.cplPlatform)
: '—'}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-800">
{hasCrm && row.cplCrm
? formatEurCents(row.cplCrm)
: '—'}
</td>
</tr>
))}
</tbody>
{attributionRows.length > 0 && (
<tfoot>
<tr className="bg-gray-50 font-semibold border-t border-gray-200">
<td className="py-3 px-4 text-gray-900">Total</td>
<td className="py-3 pr-4 tabular-nums text-gray-900">
{attributionTotals.spend
? formatEur(attributionTotals.spend)
: '—'}
</td>
<td className="py-3 pr-4 tabular-nums">
{formatNum(attributionTotals.platformLeads)}
</td>
<td className="py-3 pr-4 tabular-nums">
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
</td>
<td className="py-3 pr-4">
{hasCrm ? (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums ${gapTone(
attributionTotals.gap
)}`}
>
{attributionTotals.gap > 0 ? '+' : ''}
{formatNum(attributionTotals.gap)}
</span>
) : (
'—'
)}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-900">
{formatNum(attributionTotals.ventas || ventasCount)}
</td>
<td className="py-3 pr-4 tabular-nums text-gray-900">
{attributionTotals.importe > 0 ||
periodTotals.revenue > 0
? formatEur(
attributionTotals.importe || periodTotals.revenue
)
: '—'}
</td>
<td className="py-3 pr-4 tabular-nums">
{attributionTotals.spend > 0 &&
(attributionTotals.importe || periodTotals.revenue) > 0
? `${formatNum(
(attributionTotals.importe ||
periodTotals.revenue) / attributionTotals.spend
)}x`
: '—'}
</td>
<td className="py-3 pr-4 tabular-nums">
{attributionTotals.platformLeads > 0
? formatEurCents(
attributionTotals.spend /
attributionTotals.platformLeads
)
: '—'}
</td>
<td className="py-3 pr-4 tabular-nums">
{hasCrm && attributionTotals.crmLeads > 0
? formatEurCents(
attributionTotals.spend /
attributionTotals.crmLeads
)
: '—'}
</td>
</tr>
</tfoot>
)}
</table>
</div>
)}
</Surface>
</section>
</div>
);
}
function HeroStat({
label,
value,
chip,
}: {
label: string;
value: string;
chip: React.ReactNode;
}) {
return (
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-gray-500">
{label}
</p>
<p className="mt-1.5 text-2xl md:text-3xl font-semibold tracking-tight text-gray-900 tabular-nums leading-none">
{value}
</p>
<div className="mt-2">{chip}</div>
</div>
);
}
function organicPeriodSafe(
organicRows: OrganicDailyRow[],
since: string,
until: string,
adsLeads = 0
) {
const rows = organicRows.filter((r) => {
const d = (r.day || r.date || '').slice(0, 10);
return d >= since && d <= until;
});
const gscClicks = rows.reduce((a, r) => a + (Number(r.gsc_clicks) || 0), 0);
const gscImpressions = rows.reduce(
(a, r) => a + (Number(r.gsc_impressions) || 0),
0
);
const ga4Sessions = rows.reduce(
(a, r) => a + (Number(r.ga4_sessions) || 0),
0
);
const ga4Conversions = rows.reduce(
(a, r) => a + (Number(r.ga4_conversions) || 0),
0
);
const denom = adsLeads + ga4Conversions;
return {
gscClicks,
gscImpressions,
ga4Sessions,
ga4Conversions,
organicShare: denom > 0 ? (ga4Conversions / denom) * 100 : 0,
};
}