'use client'; import React, { useMemo, useState } from 'react'; import { Users, UserCheck, Percent, Flame, UserX, Briefcase, } from 'lucide-react'; import { AdDailyRow, CrmChannelStats, CrmDateLens, CrmLeadRow, attributionCross, buildCrmChannelStats, buildCrmFunnel, buildCrmStatusBreakdown, filterDailyByDayRange, filterLeadsByDayRange, formatEur, formatNum, } from '@/lib/metrics'; import { PageHeader } from '@/components/ui'; import { useDateRange } from '@/components/DateRangeContext'; import SyncNowButton from '@/components/SyncNowButton'; function rateClass(rate: number): string { if (rate >= 8) return 'text-emerald-400'; if (rate >= 3) return 'text-amber-400'; if (rate > 0) return 'text-orange-400'; return 'text-gray-500'; } const LENS_HELP: Record = { created: 'Altas: leads cuya fecha de creación en Clientify cae en el periodo del header.', status: 'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at (sql/leads_status_timing.sql).', snapshot: 'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora en el warehouse.', }; export default function CrmDashboard({ label, portfolioWide, crmLeads = [], dailyRows = [], }: { label: string; portfolioWide: boolean; crmLeads?: CrmLeadRow[]; dailyRows?: AdDailyRow[]; }) { 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 funnel = useMemo(() => buildCrmFunnel(rangedLeads), [rangedLeads]); const statusRows = useMemo( () => buildCrmStatusBreakdown(rangedLeads), [rangedLeads] ); const channelRows: CrmChannelStats[] = useMemo( () => buildCrmChannelStats(rangedLeads), [rangedLeads] ); const attribution = useMemo( () => attributionCross(rangedDaily, rangedLeads), [rangedDaily, rangedLeads] ); const spendByChannel = useMemo(() => { const m = new Map(); for (const r of attribution) m.set(r.channel, r.spend); return m; }, [attribution]); const paidChannels = channelRows.filter((r) => ['meta', 'google'].includes(r.channel) ); const otherChannels = channelRows.filter( (r) => !['meta', 'google'].includes(r.channel) ); return (
} />

Lente CRM

{( [ ['created', 'Altas en periodo'], ['status', 'Conversiones en periodo'], ['snapshot', 'Estado actual'], ] as const ).map(([id, lab]) => ( ))}

{LENS_HELP[lens]}

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

Aún no hay status_changed_at en warehouse — aplicando filtro por alta. Ejecuta{' '} sql/leads_status_timing.sql en Supabase y deja que los webhooks actualicen contactos.

) : null} {!crmLeads.length ? (

Sin leads Clientify en este contexto. Importa el histórico o activa el webhook (`?tenant=…`). En portafolio, elige un cliente en Contexto.

) : null}
{[ { label: 'Leads CRM', value: formatNum(funnel.total), icon: , }, { label: 'Clientes (ganado)', value: formatNum(funnel.cliente), icon: , }, { label: '% a cliente', value: `${funnel.clienteRate.toFixed(1)}%`, icon: , }, { label: 'En oportunidad', value: formatNum(funnel.enOportunidad), icon: , }, { label: 'Lead frío', value: formatNum(funnel.leadFrio), icon: , }, { label: 'Lead perdido', value: formatNum(funnel.leadPerdido), icon: , }, ].map((s) => (
{s.label} {s.icon}

{s.value}

))}

% a cliente por canal

Estado Clientify = cliente{' '} (ganado). Ads spend del periodo es referencia.

{!channelRows.length ? (

Sin leads en esta lente/periodo.

) : (
{[...paidChannels, ...otherChannels].map((row) => ( ))}
Canal Leads Clientes % a cliente Inversión ads
{row.channel} {formatNum(row.leads)} {formatNum(row.cliente)} {row.clienteRate.toFixed(1)}% {spendByChannel.get(row.channel) ? formatEur(spendByChannel.get(row.channel)!) : '—'}
)}

La inversión por canal (si hay ads) es solo referencia; el % a cliente es CRM. Lente activa: {lens}.

Desglose de status

{!statusRows.length ? (

Sin datos.

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