'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.
{points.map((d) => (
))}
{fmtDay(first)} {fmtDay(last)}
); } function SectionHead({ icon, title, hint, }: { icon: React.ReactNode; title: string; hint: string; }) { return (
{icon}

{title}

{hint}

); } 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 (

{label}

{icon}

{value}

{hint}

); } 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.

) : (
{attributionRows.map((row) => ( 0 ? 'bg-white' : 'bg-gray-50/40' }`} > ))} {attributionRows.length > 0 && ( )}
Canal Inversión Plat. CRM Gap Ventas Importe ROAS CPL plat CPL CRM
{row.spend > 0 ? ( {row.channel} ) : ( {row.channel} )} {row.spend ? (
{formatEur(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) : '—'}
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 (

{label}

{value}

{chip}
); } 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, }; }