'use client'; import React, { useMemo, useState } from 'react'; import Link from 'next/link'; import { ArrowDownRight, ArrowUpRight, DollarSign, Minus, Target, TrendingUp, Users, MousePointerClick, Eye, CalendarRange, } from 'lucide-react'; import { AdDailyRow, AttributionChannelRow, ChannelWeekPoint, CrmLeadRow, DealWonRow, Delta, MetricTotals, formatEur, formatNum, formatPct, attributionCross, filterDailyByDayRange, filterDealsForRange, filterLeadsByDayRange, metricTotalsFromDaily, resolveKpiDayRange, sumWeekly, withCohortFromDeals, KpiWeeklyRow, KpiRangeKey, } from '@/lib/metrics'; import { PageHeader, SegmentedControl } from '@/components/ui'; type Card = { key: keyof MetricTotals; label: string; hint: string; format: (v: number) => string; icon: React.ReactNode; invert?: boolean; /** When true and value is 0 with no deals, show em dash + empty state. */ cohort?: boolean; }; type RangeKey = KpiRangeKey; function DeltaTriple({ delta, invert, empty, }: { delta: Delta; invert?: boolean; empty?: boolean; }) { if (empty) { return (

Sin cierres en el periodo — sincroniza deals Clientify

); } const cells: { label: string; v: number | null }[] = [ { label: 'WoW', v: delta.wow }, { label: 'MoM', v: delta.mom }, { label: 'YoY', v: delta.yoy }, ]; return (
{cells.map((c) => { const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0; const color = c.v === null ? 'text-gray-500' : good ? 'text-emerald-700' : c.v === 0 ? 'text-gray-600' : 'text-red-600'; const Icon = c.v === null || c.v === 0 ? Minus : good ? ArrowUpRight : ArrowDownRight; return (

{c.label}

{formatPct(c.v)}

); })}
); } function gapClass(gap: number): string { if (gap === 0) return 'text-gray-600'; const abs = Math.abs(gap); if (abs <= 5) return 'text-emerald-700'; if (abs <= 25) return 'text-amber-700'; return 'text-red-600'; } export default function MetricsDashboard({ label, portfolioWide, month, week, deltas, daily, sparse, weeklyRows = [], channelPoints = [], dailyRows = [], crmLeads = [], wonDeals = [], }: { label: string; portfolioWide: boolean; month: MetricTotals; week: MetricTotals; deltas: Record; daily: { spend: number; leads: number; impressions: number; clicks: number; }; sparse: boolean; weeklyRows?: KpiWeeklyRow[]; channelPoints?: ChannelWeekPoint[]; dailyRows?: AdDailyRow[]; crmLeads?: CrmLeadRow[]; wonDeals?: DealWonRow[]; }) { const [range, setRange] = useState('7d'); const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]); const rangedDaily = useMemo(() => { if (!dailyRows.length) return []; if (!dayWindow) return dailyRows; return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until); }, [dailyRows, dayWindow]); const rangedLeads = useMemo(() => { if (!crmLeads.length) return []; if (!dayWindow) return crmLeads; return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until); }, [crmLeads, dayWindow]); const rangedDeals = useMemo( () => filterDealsForRange( wonDeals, dayWindow?.since ?? null, dayWindow?.until ?? null ), [wonDeals, dayWindow] ); const periodTotals = useMemo(() => { let base: MetricTotals; if (rangedDaily.length) { base = metricTotalsFromDaily(rangedDaily); } else if (!dayWindow) { base = sumWeekly(weeklyRows); } else { const from = dayWindow.since; const until = dayWindow.until; const ranged = weeklyRows.filter((r) => { if (!r.week) return false; const w = r.week.slice(0, 10); return w <= until && w >= from; }); base = ranged.length ? sumWeekly(ranged) : month; } return withCohortFromDeals(base, rangedDeals); }, [rangedDaily, rangedDeals, weeklyRows, dayWindow, month]); const attributionRows: AttributionChannelRow[] = useMemo(() => { if (rangedDaily.length || rangedLeads.length) { return attributionCross(rangedDaily, rangedLeads); } const map = new Map(); const src = range === 'all' ? channelPoints : channelPoints.filter((p) => { const d = p.week; if (!d || !dayWindow) return true; return d >= dayWindow.since && d <= dayWindow.until; }); for (const p of src) { 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, })) .sort((a, b) => b.spend - a.spend); }, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]); const crmPeriodTotal = rangedLeads.length; const hasCrm = crmLeads.length > 0; const hasCohort = rangedDeals.length > 0; const rangeHint = dayWindow ? `${dayWindow.since} → ${dayWindow.until}` : 'todo el histórico'; const cards: Card[] = [ { key: 'spend', label: 'Inversión ads', hint: 'Paid del periodo', format: formatEur, icon: , }, { key: 'leads', label: 'Leads', hint: 'Captación periodo', format: formatNum, icon: , }, { key: 'cpl', label: 'CPL', hint: 'Coste por lead', format: (v) => `${formatNum(v)} €`, icon: , invert: true, }, { key: 'cac', label: 'CAC cohorte', hint: hasCohort ? `Paid ÷ ${formatNum(periodTotals.deals)} cierres` : 'Paid ÷ deals ganados Clientify', format: (v) => `${formatNum(v)} €`, icon: , invert: true, cohort: true, }, { key: 'revenue', label: 'Revenue cohorte', hint: hasCohort ? `${formatNum(periodTotals.deals)} deals won` : 'Suma amount deals won', format: formatEur, icon: , cohort: true, }, { key: 'roas', label: 'ROAS cohorte', hint: 'Revenue ÷ spend', format: (v) => `${formatNum(v)}x`, icon: , cohort: true, }, ]; return (
} /> {sparse && (

Histórico limitado: si faltan deltas o canales, el warehouse aún no tiene semanas suficientes.

)} {!hasCohort && (

CAC / Revenue / ROAS a cero: no hay deals ganados con fecha de cierre en este periodo. Se calculan con{' '} deals Clientify (stage=won), no solo con leads de ads.

)}
{[ { label: 'Inversión periodo', value: formatEur(periodTotals.spend) }, { label: 'Leads plataforma', value: formatNum(periodTotals.leads), }, { label: 'Leads CRM', value: hasCrm ? formatNum(crmPeriodTotal) : '—', }, { label: 'CPL CRM (paid)', value: hasCrm && crmPeriodTotal > 0 ? `${formatNum(periodTotals.spend / crmPeriodTotal)} €` : periodTotals.cpl ? `${formatNum(periodTotals.cpl)} €` : '—', }, ].map((s) => (

{s.label}

{s.value}

))}
{cards.map((c) => { const emptyCohort = Boolean(c.cohort) && !hasCohort; const value = emptyCohort ? '—' : c.format(periodTotals[c.key]); return (
{c.label} {c.icon}

{value}

{c.hint}

); })}

Cruce por canal · ads vs CRM

Plataforma = leads reportados por Meta/Google · CRM = atribución Clientify (origen/UTM) {!hasCrm && portfolioWide ? ' · elige un cliente en Contexto para ver CRM' : ''}

Gap = CRM − plat

{attributionRows.length === 0 ? (

Sin series de canal en el periodo. Los canales Meta/Google aparecen cuando hay filas en ad_daily para este cliente (elige uno en Contexto).

) : (
{attributionRows.map((row) => ( ))}
Canal Inversión Leads plat Leads CRM Gap 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.cplPlatform ? `${formatNum(row.cplPlatform)} €` : '—'} {hasCrm && row.cplCrm ? `${formatNum(row.cplCrm)} €` : '—'}
)}
Ads · ventana diaria

{formatEur(daily.spend)}

{formatNum(daily.leads)} leads · {formatNum(daily.impressions)} impr. · {formatNum(daily.clicks)} clicks

Semana actual: {formatEur(week.spend)} · {formatNum(week.leads)}{' '} leads

Cómo leer

  • CAC / Revenue / ROAS usan deals ganados de Clientify ( won_at en el periodo), no leads de Meta/Google.
  • Gap cercano a 0 en Meta/Google = buena atribución. Canales sin spend son orgánicos/CRM.
  • CPL CRM = inversión paid ÷ leads CRM del canal (más realista cuando hay desfase con la plataforma).
); }