diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx
index 583eb99..038b901 100644
--- a/src/components/MetricsDashboard.tsx
+++ b/src/components/MetricsDashboard.tsx
@@ -1,18 +1,22 @@
'use client';
-import React, { useMemo, useState } from 'react';
+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,
- MousePointerClick,
- Globe,
- Search,
} from 'lucide-react';
import {
AdDailyRow,
@@ -23,6 +27,7 @@ import {
Delta,
MetricTotals,
formatEur,
+ formatEurCents,
formatNum,
formatPct,
attributionCross,
@@ -36,7 +41,7 @@ import {
withCohortFromDeals,
KpiWeeklyRow,
} from '@/lib/metrics';
-import { PageHeader } from '@/components/ui';
+import { PageHeader, Surface } from '@/components/ui';
import SyncNowButton from '@/components/SyncNowButton';
import { useDateRange } from '@/components/DateRangeContext';
import { OrganicDailyRow } from '@/lib/organic';
@@ -48,11 +53,46 @@ type Card = {
format: (v: number) => string;
icon: React.ReactNode;
invert?: boolean;
- /** Business outcome from Clientify won deals. */
cohort?: boolean;
};
-function DeltaTriple({
+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 (
+
+
- Sin cierres en este rango — amplía el periodo o revisa oportunidades - ganadas en el CRM +
+ Sin cierres en este rango. Amplía el periodo o revisa oportunidades + ganadas en el CRM.
); } - const cells: { - label: string; - v: number | null; - base: number | null; - }[] = [ - ...(showWow - ? [{ label: 'WoW', v: delta.wow, base: delta.wowBase }] - : []), - { label: 'MoM', v: delta.mom, base: delta.momBase }, - { label: 'YoY', v: delta.yoy, base: delta.yoyBase }, + 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 ( -- {c.label} -
-
-
- prev {format(c.base ?? 0)} -
- {absDelta !== null && ( -- Δ{' '} - {absDelta > 0 - ? `+${format(absDelta)}` - : format(absDelta)} -
- )} - > - ) : ( -- sin histórico -
- )} -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 ( +{hint}
++ {label} +
+ {icon} ++ {value} +
+{hint}
++ {icon} + {label} +
++ {value} +
+{hint}
+- Histórico limitado: si faltan deltas o canales, el warehouse aún no - tiene semanas suficientes. -
+ {(sparse || !hasCohort || (cohortFromLeads && hasCohort)) && ( ++ 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. +
+ )} +- Ventas / CAC / ROAS vacíos en {rangeHint}: no hay - deals ganados ni contactos con status «cliente» en este rango - {wonDeals.length - ? ` (sí 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 del CRM con status «cliente» (no hay oportunidades - ganadas sincronizadas). El importe puede ir incompleto. -
- )} - -- Cómo leer este panel -
-- Publicidad: lo que Meta y Google dicen (gasto, - conversiones, CPL). No es el listado de clientes. -
-- {crmName}: personas y ventas reales en el CRM. Un - lead de ads y un alta CRM casi nunca coinciden 1 a 1 (el gap lo - explica). -
-- Web y SEO: visitas y clics orgánicos (Search Console - y Analytics). No se suman a los leads de anuncios. +
+ Resumen del periodo
+
- Inversión y conversiones de las plataformas de ads · {rangeHint}
+
+ 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.
- {value}
- {c.hint}
+ Inversión por día
+
+ Leads de plataforma por día
+
- Altas y ventas del CRM · no uses estos números como si fueran los de
- ads
-
- Leads CRM
-
- {hasCrm ? formatNum(crmPeriodTotal) : '—'}
-
- Altas en {crmName} en el periodo
-
- CPL CRM
-
- {hasCrm && crmPeriodTotal > 0
- ? `${formatNum(periodTotals.spend / crmPeriodTotal)} €`
- : '—'}
-
- Inversión ads ÷ leads CRM
-
- Gap ads vs CRM
-
- {hasCrm
- ? `${crmPeriodTotal - periodTotals.leads > 0 ? '+' : ''}${formatNum(crmPeriodTotal - periodTotals.leads)}`
- : '—'}
-
- CRM − conversiones ads · detalle por canal abajo
-
- {value}
- {c.hint}
- Tráfico del sitio · mismo periodo · no es publicidad
-
-
- {formatNum(organicPeriod.gscClicks)}
- Search Console
- Impresiones SEO
-
- {formatNum(organicPeriod.gscImpressions)}
- Apariciones en Google
-
- {formatNum(organicPeriod.ga4Sessions)}
- Analytics (GA4)
- Conv. web
-
- {formatNum(organicPeriod.ga4Conversions)}
-
- Eventos GA4 · cuota {formatPct(organicPeriod.organicShare)}
-
- Leads plat = conversiones que reporta el anuncio. Leads CRM =
- altas en {crmName} según la fuente del contacto. Ventas =
- oportunidades ganadas (importe {'>'} 0). Gap = CRM − plat.
+
+ Gap ads vs CRM
+
+ {hasCrm
+ ? `${gapAdsCrm > 0 ? '+' : ''}${formatNum(gapAdsCrm)}`
+ : '—'}
+
+ CRM − conversiones ads. Positivo = más altas en CRM que las que
+ reporta el anuncio.
- Gap = CRM − plat
-
- Resumen: plat {formatNum(channelInsight.platTotal)}{' '}
- vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '}
- {channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''}
- {formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De
- los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto
- ads y {formatNum(channelInsight.crmOther)} en otras fuentes
- (integrations, ecommerce, messenger…). Mayor desfase en{' '}
-
- {channelInsight.worstGap.channel}
-
- .
-
- Sin series de canal en el periodo.
- Sin filas diarias. Sin filas diarias.
- Barras por día · mismo periodo del header
-
+ Plat.
+
+ {formatNum(channelInsight.platTotal)}
+
+ CRM
+
+ {formatNum(channelInsight.crmTotal)}
+
+ Con gasto ads
+
+ {formatNum(channelInsight.crmPaid)}
+
+ Otras fuentes
+
+ {formatNum(channelInsight.crmOther)}
+
+ Mayor desfase en{' '}
+
+ {channelInsight.worstGap.channel}
+
+ . Ecommerce, messenger e integrations no son fallos de ads.
+
+ Sin series de canal en el periodo.
+
+ {label}
+
+ {value}
+
+
+
- 2. Negocio ({crmName})
-
- 3. Web y SEO
-
- 4. Cruce por canal · ads vs {crmName}
-
-
-
-
-
-
-
-
- {attributionRows.map((row) => (
- Canal
- Inversión
- Leads plat
- Leads CRM
- Gap
- Ventas
- Importe
- ROAS
- CPL plat
- CPL CRM
-
-
- ))}
-
- {attributionRows.length > 0 && (
-
-
- {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
- ? `${formatNum(row.cplPlatform)} €`
- : '—'}
-
-
- {hasCrm && row.cplCrm
- ? `${formatNum(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
- ? `${formatNum(
- attributionTotals.spend /
- attributionTotals.platformLeads
- )} €`
- : '—'}
-
-
- {hasCrm && attributionTotals.crmLeads > 0
- ? `${formatNum(
- attributionTotals.spend /
- attributionTotals.crmLeads
- )} €`
- : '—'}
-
-
- Ads · inversión / día
-
- {dailyChart.length === 0 ? (
-
- Ads · leads plat. / día
-
- {dailyChart.length === 0 ? (
-
+
+
+
+
+
+
+ {attributionRows.map((row) => (
+ Canal
+
+ Inversión
+
+ Plat.
+ CRM
+ Gap
+ Ventas
+ Importe
+ ROAS
+ CPL plat
+ CPL CRM
+ 0 ? 'bg-white' : 'bg-gray-50/40'
+ }`}
+ >
+
+ ))}
+
+ {attributionRows.length > 0 && (
+
+
+ {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)
+ : '—'}
+
+
+
+
+ )}
+ 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
+ )
+ : '—'}
+
+