'use client'; import React, { useMemo, useState } from 'react'; import { Flame, UserX, Briefcase, GitCompare, ListTree, Target, } from 'lucide-react'; import { AdDailyRow, CrmChannelStats, CrmDateLens, CrmLeadRow, DealWonRow, attributionCross, buildCrmChannelStats, buildCrmFunnel, buildCrmStatusBreakdown, filterDailyByDayRange, filterDealsForRange, filterLeadsByDayRange, formatEur, formatEurCents, formatNum, } from '@/lib/metrics'; import { PageHeader, SegmentedControl, Surface } from '@/components/ui'; import { useDateRange } from '@/components/DateRangeContext'; import SyncNowButton from '@/components/SyncNowButton'; import { HelpDetails, HeroBand, HeroStat, SectionHead, ShareBar, StatMini, tableHeadClass, } from '@/components/dash-ui'; function rateClass(rate: number): string { if (rate >= 8) return 'text-emerald-800'; if (rate >= 3) return 'text-amber-800'; if (rate > 0) return 'text-orange-800'; return 'text-gray-500'; } const LENS_HELP: Record = { created: 'Altas: leads cuya fecha de creación en el CRM cae en el periodo del header.', status: 'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at.', snapshot: 'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora.', }; export default function CrmDashboard({ label, portfolioWide, crmLeads = [], dailyRows = [], wonDeals = [], }: { label: string; portfolioWide: boolean; crmLeads?: CrmLeadRow[]; dailyRows?: AdDailyRow[]; wonDeals?: DealWonRow[]; }) { const { since, until, label: rangeHint } = useDateRange(); const [lens, setLens] = useState('created'); const hasStatusTiming = useMemo( () => crmLeads.some((l) => Boolean(l.status_changed_at || l.updated_at)), [crmLeads] ); const rangedLeads = useMemo(() => { if (!crmLeads.length) return []; const effective: CrmDateLens = lens === 'status' && !hasStatusTiming ? 'created' : lens; return filterLeadsByDayRange(crmLeads, since, until, effective); }, [crmLeads, since, until, lens, hasStatusTiming]); const rangedDaily = useMemo(() => { if (!dailyRows.length) return []; return filterDailyByDayRange(dailyRows, since, until); }, [dailyRows, since, until]); const rangedDeals = useMemo( () => filterDealsForRange(wonDeals, since, until), [wonDeals, since, until] ); const funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]); const statusRows = useMemo( () => buildCrmStatusBreakdown(rangedLeads), [rangedLeads] ); const channelRows: CrmChannelStats[] = useMemo( () => buildCrmChannelStats(rangedLeads), [rangedLeads] ); const attribution = useMemo( () => attributionCross(rangedDaily, rangedLeads, rangedDeals), [rangedDaily, rangedLeads, rangedDeals] ); const spendTotal = useMemo( () => attribution.reduce((a, r) => a + r.spend, 0), [attribution] ); const ventasTotal = rangedDeals.length; const cacGlobal = ventasTotal > 0 && spendTotal > 0 ? spendTotal / ventasTotal : 0; const cacByChannel = useMemo(() => { const m = new Map(); for (const r of attribution) { m.set(r.channel, { cac: r.cac, spend: r.spend, ventas: r.ventas }); } return m; }, [attribution]); // Include paid channels that have ad spend/ventas even with 0 CRM leads // (e.g. Google Ads spend but no leads sourced as "google" in Clientify). const channelRowsWithAds: CrmChannelStats[] = useMemo(() => { const byCh = new Map(channelRows.map((r) => [r.channel, r])); for (const r of attribution) { if (r.spend <= 0 && r.ventas <= 0) continue; if (byCh.has(r.channel)) continue; byCh.set(r.channel, { channel: r.channel, leads: 0, cliente: 0, enOportunidad: 0, leadFrio: 0, leadPerdido: 0, other: 0, clienteRate: 0, }); } const all = [...byCh.values()]; const paid = all .filter((r) => ['meta', 'google'].includes(r.channel)) .sort((a, b) => { const sa = cacByChannel.get(a.channel)?.spend || 0; const sb = cacByChannel.get(b.channel)?.spend || 0; return sb - sa || b.leads - a.leads; }); const other = all .filter((r) => !['meta', 'google'].includes(r.channel)) .sort((a, b) => b.cliente - a.cliente || b.leads - a.leads); return [...paid, ...other]; }, [channelRows, attribution, cacByChannel]); const leadMax = Math.max( 1, ...channelRowsWithAds.map((r) => r.leads) ); const statusMax = Math.max(1, ...statusRows.map((r) => r.count)); return (
} />

Lente

{LENS_HELP[lens]}

El % a cliente es del CRM, no de ads. El{' '} CAC = inversión ads del periodo ÷ ventas ganadas (deals won) del periodo; por canal usa la atribución del deal.

{lens === 'status' && !hasStatusTiming ? (

Aún no hay fecha de cambio de status en warehouse — filtrando por alta. Ejecuta sql/leads_status_timing.sql{' '} y deja que los webhooks actualicen contactos.

) : null} {!crmLeads.length ? (

Sin leads CRM en este contexto. Importa el histórico o activa el webhook. En portafolio, elige un cliente en Contexto.

) : null}
{funnel.enOportunidad} en oportunidad } /> 0 ? formatEurCents(cacGlobal) : '—'} chip={ {ventasTotal > 0 ? `${formatNum(ventasTotal)} ventas · ${formatEur(spendTotal)} ads` : 'Sin ventas won en el periodo'} } /> {formatNum(funnel.leadFrio)} fríos } />
} /> } /> } />
} title="CAC por canal" hint="Inversión ads ÷ ventas won atribuidas al canal (mismo criterio que Métricas)." /> {!attribution.some((r) => r.spend > 0 || r.ventas > 0) ? (

Sin inversión ads ni ventas won en el periodo.

) : (
{attribution .filter((r) => r.spend > 0 || r.ventas > 0) .map((row) => ( ))}
Canal Inversión Ventas CAC Importe
{row.channel} {row.spend > 0 ? formatEur(row.spend) : '—'} {row.ventas > 0 ? formatNum(row.ventas) : '—'} {row.cac > 0 ? formatEurCents(row.cac) : '—'} {row.importe > 0 ? formatEur(row.importe) : '—'}
Total {spendTotal > 0 ? formatEur(spendTotal) : '—'} {ventasTotal > 0 ? formatNum(ventasTotal) : '—'} {cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'} {attribution.reduce((a, r) => a + r.importe, 0) > 0 ? formatEur( attribution.reduce((a, r) => a + r.importe, 0) ) : '—'}
)}
} title="% a cliente por canal" hint="Estado ganado en el CRM. Ads spend y CAC del periodo son referencia." /> {!channelRowsWithAds.length ? (

Sin leads ni gasto ads en esta lente/periodo.

) : (
{channelRowsWithAds.map((row) => { const cacRow = cacByChannel.get(row.channel); return ( ); })}
Canal Leads Clientes % a cliente Inversión ads CAC
{row.channel} {formatNum(row.cliente)} {row.leads > 0 ? `${row.clienteRate.toFixed(1)}%` : '—'} {cacRow && cacRow.spend > 0 ? formatEur(cacRow.spend) : '—'} {cacRow && cacRow.cac > 0 ? formatEurCents(cacRow.cac) : '—'}
)}
} title="Desglose de status" hint="Distribución de contactos en el warehouse con la lente activa" /> {!statusRows.length ? (

Sin datos.

) : (
{statusRows.map((row) => ( ))}
Status N %
{row.status} {row.pct.toFixed(1)}%
)}
); }