'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 (
{label} {formatPct(v)}
);
}
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 (
Sin cierres en este rango. Amplía el periodo o revisa oportunidades
ganadas en el CRM.
);
}
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 (
{items.map((c) => {
const hasBase =
c.base !== null && Number.isFinite(c.base) && !(c.base === 0 && c.v === null);
return (
);
})}
);
}
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 Sin filas diarias.
;
}
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 (
{formatValue(max)}
máx.
{fmtDay(first)}
{fmtDay(last)}
);
}
function SectionHead({
icon,
title,
hint,
}: {
icon: React.ReactNode;
title: string;
hint: string;
}) {
return (
);
}
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 (
);
}
function StatMini({
label,
value,
hint,
icon,
}: {
label: string;
value: string;
hint: string;
icon?: React.ReactNode;
}) {
return (
{icon}
{label}
{value}
{hint}
);
}
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;
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();
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: ,
},
{
key: 'leads',
label: 'Leads plataforma',
hint: 'Conversiones que reportan los anuncios',
format: formatNum,
icon: ,
},
{
key: 'cpl',
label: 'CPL ads',
hint: 'Inversión ÷ leads de plataforma',
format: (v) => formatEurCents(v),
icon: ,
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: ,
cohort: true,
},
{
key: 'cac',
label: 'CAC',
hint: hasCohort
? `Inversión ÷ ${formatNum(ventasCount)} ventas`
: 'Inversión ÷ deals ganados',
format: (v) => formatEurCents(v),
icon: ,
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: ,
cohort: true,
},
];
const dailyChart = useMemo(() => {
const byDay = new Map();
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 (
}
/>
{(sparse || !hasCohort || (cohortFromLeads && hasCohort)) && (
{sparse && (
Histórico limitado: el warehouse aún no tiene semanas suficientes
para algunos deltas.
)}
{!hasCohort && (
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.
)}
{cohortFromLeads && hasCohort && (
Cohorte desde contactos «cliente» (no hay oportunidades ganadas
sincronizadas). El importe puede ir incompleto.
)}
)}
Resumen del periodo
}
/>
}
/>
{formatNum(periodTotals.leads)} plat.
}
/>
) : (
sin cierres
)
}
/>
pendiente CRM
) : (
)
}
/>
Cómo leer ads, CRM y web
mostrar
ocultar
Publicidad: lo que Meta y
Google reportan. No es el listado de clientes.
{crmName}: altas y ventas
reales. Un lead de ads y un alta CRM casi nunca coinciden 1 a 1.
Web y SEO: Search Console
y Analytics. No se suman a los leads de anuncios.
}
title="Publicidad"
hint={`Meta + Google Ads · ${rangeHint}`}
/>
{adsCards.map((c) => (
))}
Inversión por día
({ date: d.date, value: d.spend }))}
color="bg-brand-500 hover:bg-brand-600"
formatValue={formatEur}
/>
Leads de plataforma por día
({ date: d.date, value: d.leads }))}
color="bg-sky-500 hover:bg-sky-600"
formatValue={formatNum}
/>
}
title={`Negocio · ${crmName}`}
hint="Altas y ventas reales · no uses estos números como si fueran los de ads"
/>
Gap ads vs CRM
{hasCrm
? `${gapAdsCrm > 0 ? '+' : ''}${formatNum(gapAdsCrm)}`
: '—'}
CRM − conversiones ads. Positivo = más altas en CRM que las que
reporta el anuncio.
{outcomeCards.map((c) => {
const emptyCohort = Boolean(c.cohort) && !hasCohort;
return (
);
})}
}
title="Web y SEO"
hint="Tráfico orgánico · mismo periodo · no es publicidad"
/>
}
/>
}
/>
}
title={`Cruce por canal · ads vs ${crmName}`}
hint="Leads plat = el anuncio. Leads CRM = fuente del contacto. Gap = CRM − plat."
/>
{channelInsight && (
Plat.
{formatNum(channelInsight.platTotal)}
CRM
{formatNum(channelInsight.crmTotal)}
Con gasto ads
{formatNum(channelInsight.crmPaid)}
Otras fuentes
{formatNum(channelInsight.crmOther)}
)}
{channelInsight && (
Mayor desfase en{' '}
{channelInsight.worstGap.channel}
. Ecommerce, messenger e integrations no son fallos de ads.
)}
{attributionRows.length === 0 ? (
Sin series de canal en el periodo.
) : (
Canal
Inversión
Plat.
CRM
Gap
Ventas
Importe
ROAS
CPL plat
CPL CRM
{attributionRows.map((row) => (
0 ? 'bg-white' : 'bg-gray-50/40'
}`}
>
{row.spend > 0 ? (
{row.channel}
) : (
{row.channel}
)}
{row.spend ? (
) : (
—
)}
{formatNum(row.platformLeads)}
{hasCrm ? formatNum(row.crmLeads) : '—'}
{hasCrm ? (
{row.gap > 0 ? '+' : ''}
{formatNum(row.gap)}
) : (
'—'
)}
{row.ventas > 0 ? formatNum(row.ventas) : '—'}
{row.importe > 0 ? formatEur(row.importe) : '—'}
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
{row.cplPlatform
? formatEurCents(row.cplPlatform)
: '—'}
{hasCrm && row.cplCrm
? formatEurCents(row.cplCrm)
: '—'}
))}
{attributionRows.length > 0 && (
Total
{attributionTotals.spend
? formatEur(attributionTotals.spend)
: '—'}
{formatNum(attributionTotals.platformLeads)}
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
{hasCrm ? (
{attributionTotals.gap > 0 ? '+' : ''}
{formatNum(attributionTotals.gap)}
) : (
'—'
)}
{formatNum(attributionTotals.ventas || ventasCount)}
{attributionTotals.importe > 0 ||
periodTotals.revenue > 0
? formatEur(
attributionTotals.importe || periodTotals.revenue
)
: '—'}
{attributionTotals.spend > 0 &&
(attributionTotals.importe || periodTotals.revenue) > 0
? `${formatNum(
(attributionTotals.importe ||
periodTotals.revenue) / attributionTotals.spend
)}x`
: '—'}
{attributionTotals.platformLeads > 0
? formatEurCents(
attributionTotals.spend /
attributionTotals.platformLeads
)
: '—'}
{hasCrm && attributionTotals.crmLeads > 0
? formatEurCents(
attributionTotals.spend /
attributionTotals.crmLeads
)
: '—'}
)}
)}
);
}
function HeroStat({
label,
value,
chip,
}: {
label: string;
value: string;
chip: React.ReactNode;
}) {
return (
);
}
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,
};
}