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>
1115 lines
37 KiB
TypeScript
1115 lines
37 KiB
TypeScript
'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,
|
||
};
|
||
}
|