'use client'; import React, { useMemo, useState } from 'react'; import Link from 'next/link'; import { ArrowDownRight, ArrowUpRight, DollarSign, Minus, Target, TrendingUp, Users, MousePointerClick, } from 'lucide-react'; import { AdDailyRow, AttributionChannelRow, ChannelWeekPoint, CrmLeadRow, DealWonRow, Delta, MetricTotals, formatEur, formatNum, formatPct, attributionCross, buildRangeDeltas, filterDailyByDayRange, filterDealsForRange, filterLeadsByDayRange, metricTotalsFromDaily, rangeDaySpan, sumWeekly, withCohortFromDeals, KpiWeeklyRow, } from '@/lib/metrics'; import { PageHeader } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; import { useDateRange } from '@/components/DateRangeContext'; import WindsorAiSection from '@/components/WindsorAiSection'; import { OrganicDailyRow } from '@/lib/organic'; type Card = { key: keyof MetricTotals; label: string; hint: string; format: (v: number) => string; icon: React.ReactNode; invert?: boolean; /** Business outcome from Clientify won deals. */ cohort?: boolean; }; function DeltaTriple({ delta, invert, empty, format, current, showWow, }: { delta: Delta; invert?: boolean; empty?: boolean; format: (v: number) => string; current: number; /** Hide WoW when selected range is longer than a week. */ showWow: boolean; }) { if (empty) { return (

Sin cierres en este rango — amplía el periodo o revisa deals ganados en Clientify

); } 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 }, ]; 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; const hasBase = c.base !== null && Number.isFinite(c.base) && !(c.base === 0 && c.v === null); const absDelta = hasBase && c.base !== null ? current - c.base : null; return (

{c.label}

{formatPct(c.v)}

{hasBase ? ( <>

prev {format(c.base ?? 0)}

{absDelta !== null && (

Δ{' '} {absDelta > 0 ? `+${format(absDelta)}` : format(absDelta)}

)} ) : (

sin histórico

)}
); })}
); } 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 = 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'; }) { 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 (rangedDaily.length) { base = metricTotalsFromDaily(rangedDaily); } 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); }, [rangedDaily, rangedDeals, weeklyRows, since, until, month]); /** Comparatives relative to the selected range (not “today”). */ 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 cards: Card[] = [ { key: 'spend', label: 'Inversión ads', hint: 'Gasto Meta + Google del periodo', format: formatEur, icon: , }, { key: 'leads', label: 'Leads plataforma', hint: 'Conversiones reportadas por ads', format: formatNum, icon: , }, { key: 'cpl', label: 'CPL ads', hint: 'Inversión ÷ leads plataforma', format: (v) => `${formatNum(v)} €`, icon: , invert: true, }, { key: 'deals', label: 'Ventas (CRM)', hint: hasCohort ? cohortFromLeads ? 'Clientes Clientify en el periodo (sin importe en webhook)' : `Deals ganados Clientify · importe ${formatEur(periodTotals.revenue)}` : 'Deals won Clientify en el periodo', format: formatNum, icon: , cohort: true, }, { key: 'cac', label: 'CAC', hint: hasCohort ? `Inversión ads ÷ ${formatNum(ventasCount)} ventas` : 'Inversión ÷ deals ganados (no leads)', format: (v) => `${formatNum(v)} €`, icon: , invert: true, cohort: true, }, { key: 'roas', label: 'ROAS ventas', hint: revenueMissing ? 'Falta importe en CRM (API BPS) para ROAS' : `Importe Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`, 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 maxSpend = Math.max(1, ...dailyChart.map((d) => d.spend)); const channelInsight = useMemo(() => { if (!attributionRows.length) return null; const paid = attributionRows.filter((r) => r.spend > 0); const top = paid[0] || attributionRows[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 { top, worstGap, platTotal, crmTotal, crmPaid, crmOther }; }, [attributionRows]); return (
} /> {sparse && (

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

)} {!hasCohort && (

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 Clientify status «cliente» (no hay deals ganados sincronizados). Importe € incompleto hasta conectar la API Clientify de este tenant. Meta pixel reporta compras en el periodo; el CRM marca clientes sin amount en el webhook.

)}
{[ { 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

Leads plat = conversiones ads. Leads CRM = altas Clientify por fuente. Ventas = nº deals ganados; Importe = amount Clientify. Ventas = deals ganados Online (importe {'>'} 0), como el board Clientify. Fuente del contacto → canal (meta / google / ecommerce…). Sin fuente → sin-atribuir.

Gap = CRM − plat

{channelInsight && (

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

)} {attributionRows.length === 0 ? (

Sin series de canal en el periodo.

) : (
{attributionRows.map((row) => ( ))} {attributionRows.length > 0 && ( )}
Canal Inversión Leads plat Leads 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 ? `${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 ? (

Sin filas diarias.

) : (
{dailyChart.map((d) => (
))}
)}

Ads · leads plat. / día

{dailyChart.length === 0 ? (

Sin filas diarias.

) : (
{dailyChart.map((d) => (
x.leads))) * 80 )}px`, }} />
))}
)}

Barras compactas por día · periodo del header · detalle en Ads

{/* Windsor.ai Data for blueparrotschool.com (Directly below Cruce por canal) */}
); }