From be45df300bcb570031e9b35fa16552e153efe4d4 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 23 Sep 2026 17:33:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20pesta=C3=B1a=20CRM=20con=20%=20a=20clie?= =?UTF-8?q?nte=20por=20canal=20y=20cruce=20ads=20vs=20Clientify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- scripts/ingest_gads_json.py | 1 + src/app/dashboard/crm/page.tsx | 138 +++++++++++ src/app/dashboard/kpis/page.tsx | 54 ++++- src/app/dashboard/layout.tsx | 10 +- src/components/CrmDashboard.tsx | 350 +++++++++++++++++++++++++++ src/components/CrmPortfolioBoard.tsx | 254 +++++++++++++++++++ src/components/MetricsDashboard.tsx | 166 ++++++++----- src/lib/metrics.ts | 241 ++++++++++++++++++ 8 files changed, 1157 insertions(+), 57 deletions(-) create mode 100644 src/app/dashboard/crm/page.tsx create mode 100644 src/components/CrmDashboard.tsx create mode 100644 src/components/CrmPortfolioBoard.tsx diff --git a/scripts/ingest_gads_json.py b/scripts/ingest_gads_json.py index d45d819..c154a51 100755 --- a/scripts/ingest_gads_json.py +++ b/scripts/ingest_gads_json.py @@ -33,6 +33,7 @@ MAP={ '2508925045':'a5a250b4-e79a-4cb3-bfa1-4f9a6d908a03', '4418211066':'48919ad2-6af0-4ef0-96a3-fd9ca0a46cd2', '1323363601':'0b3c5fc0-6901-4caf-b26a-b6a0201d8541', + '1064441284':'8bec977a-6032-4759-aa7f-b5042081f450', # BPS / Blue Parrot School } def call(method, path, body=None): diff --git a/src/app/dashboard/crm/page.tsx b/src/app/dashboard/crm/page.tsx new file mode 100644 index 0000000..59be813 --- /dev/null +++ b/src/app/dashboard/crm/page.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { createClient } from '@/lib/supabase/server'; +import { redirect } from 'next/navigation'; +import CrmDashboard from '@/components/CrmDashboard'; +import CrmPortfolioBoard from '@/components/CrmPortfolioBoard'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { AdDailyRow, CrmLeadRow } from '@/lib/metrics'; +import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +async function fetchCrmLeads( + tenantId: string | null, + useServiceRole: boolean, + includeTenantId: boolean +): Promise { + const select = includeTenantId + ? 'tenant_id,source,created_at,status' + : 'source,created_at,status'; + const pageSize = 1000; + const out: CrmLeadRow[] = []; + + if (useServiceRole) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return []; + const tidFilter = tenantId + ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` + : ''; + for (let offset = 0; offset < 80000; offset += pageSize) { + const r = await fetch( + `${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`, + { headers, cache: 'no-store' } + ); + if (!r.ok) break; + const chunk = (await r.json()) as CrmLeadRow[]; + out.push(...chunk); + if (chunk.length < pageSize) break; + } + return out; + } + + const supabase = await createClient(); + for (let offset = 0; offset < 80000; offset += pageSize) { + const { data } = await supabase + .from('leads') + .select(select) + .eq('crm', 'clientify') + .order('created_at', { ascending: false }) + .range(offset, offset + pageSize - 1); + const chunk = (data as CrmLeadRow[]) || []; + out.push(...chunk); + if (chunk.length < pageSize) break; + } + return out; +} + +async function fetchAdDaily( + tenantId: string | null, + useServiceRole: boolean +): Promise { + if (useServiceRole) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return []; + const tidFilter = tenantId + ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` + : ''; + const r = await fetch( + `${base}/rest/v1/ad_daily?select=date,platform,spend,platform_leads${tidFilter}&order=date.desc&limit=5000`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return []; + return (await r.json()) as AdDailyRow[]; + } + const supabase = await createClient(); + const { data } = await supabase + .from('ad_daily') + .select('date,platform,spend,platform_leads') + .order('date', { ascending: false }) + .limit(5000); + return (data as AdDailyRow[]) || []; +} + +async function fetchTenantNames(): Promise> { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return {}; + const r = await fetch(`${base}/rest/v1/tenants?select=id,name,slug`, { + headers, + cache: 'no-store', + }); + if (!r.ok) return {}; + const rows = (await r.json()) as { id: string; name: string; slug: string }[]; + const map: Record = {}; + for (const t of rows) map[t.id] = t.name || t.slug; + return map; +} + +export default async function CrmPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect('/login'); + + const ctx = await resolveActiveTenant(user); + const agency = isAgencyRole(userRole(user)); + const useService = agency && (ctx.portfolioWide || ctx.viaView); + + if (agency && ctx.portfolioWide) { + const [leads, names] = await Promise.all([ + fetchCrmLeads(null, true, true), + fetchTenantNames(), + ]); + return ( + + ); + } + + const tenantId = ctx.tenantId; + const [crmLeads, dailyRows] = await Promise.all([ + fetchCrmLeads(tenantId, useService, false), + fetchAdDaily(tenantId, useService), + ]); + + return ( + + ); +} diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index 4a77f49..0752b6c 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -5,6 +5,7 @@ import MetricsDashboard from '@/components/MetricsDashboard'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { AdDailyRow, + CrmLeadRow, KpiWeeklyRow, buildChannelSeries, buildComparisons, @@ -12,10 +13,55 @@ import { sumDaily, sumWeekly, } from '@/lib/metrics'; -import { isAgencyRole, userRole } from '@/lib/agency'; +import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; export const dynamic = 'force-dynamic'; +/** Paginate CRM leads (source + created_at) for attribution cross. */ +async function fetchCrmLeads( + tenantId: string | null, + useServiceRole: boolean +): Promise { + const select = 'source,created_at'; + const pageSize = 1000; + const out: CrmLeadRow[] = []; + + if (useServiceRole) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return []; + const tidFilter = tenantId + ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` + : ''; + for (let offset = 0; offset < 50000; offset += pageSize) { + const r = await fetch( + `${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`, + { headers, cache: 'no-store' } + ); + if (!r.ok) break; + const chunk = (await r.json()) as CrmLeadRow[]; + out.push(...chunk); + if (chunk.length < pageSize) break; + } + return out; + } + + // Client path: RLS already scopes tenant + const supabase = await createClient(); + for (let offset = 0; offset < 50000; offset += pageSize) { + const { data } = await supabase + .from('leads') + .select(select) + .eq('crm', 'clientify') + .order('created_at', { ascending: false }) + .range(offset, offset + pageSize - 1); + const chunk = (data as CrmLeadRow[]) || []; + out.push(...chunk); + if (chunk.length < pageSize) break; + } + return out; +} + export default async function KpisPage() { const supabase = await createClient(); const { @@ -28,6 +74,7 @@ export default async function KpisPage() { let weekly: KpiWeeklyRow[] = []; let daily: AdDailyRow[] = []; + let crmLeads: CrmLeadRow[] = []; if (agency && (ctx.portfolioWide || ctx.viaView)) { // Service role for portfolio / zoom (bypass single-tenant RLS) @@ -40,6 +87,8 @@ export default async function KpisPage() { ); weekly = w.data || []; daily = d.data || []; + // Attribution cross is clearest per-tenant; skip portfolio dump. + crmLeads = []; } else if (ctx.tenantId) { const tid = encodeURIComponent(ctx.tenantId); const w = await restGet( @@ -50,6 +99,7 @@ export default async function KpisPage() { ); weekly = w.data || []; daily = d.data || []; + crmLeads = await fetchCrmLeads(ctx.tenantId, true); } } else { const { data: w } = await supabase @@ -63,6 +113,7 @@ export default async function KpisPage() { .limit(5000); weekly = (w as KpiWeeklyRow[]) || []; daily = (d as AdDailyRow[]) || []; + crmLeads = await fetchCrmLeads(ctx.tenantId, false); } weekly = resolveWeeklyRows(weekly, daily); @@ -88,6 +139,7 @@ export default async function KpisPage() { weeklyRows={weekly} channelPoints={channelSeries.points} dailyRows={daily} + crmLeads={crmLeads} /> ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index f7db414..20c2970 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -11,7 +11,8 @@ import { UserCheck, CalendarDays, Radio, - Megaphone + Megaphone, + Contact } from 'lucide-react'; import { createClient } from '@/lib/supabase/server'; import { logoutAction } from '@/app/auth/actions'; @@ -57,6 +58,13 @@ export default async function DashboardLayout({ Métricas + + + CRM + = 8) return 'text-emerald-400'; + if (rate >= 3) return 'text-amber-400'; + if (rate > 0) return 'text-orange-400'; + return 'text-gray-500'; +} + +export default function CrmDashboard({ + label, + portfolioWide, + crmLeads = [], + dailyRows = [], +}: { + label: string; + portfolioWide: boolean; + crmLeads?: CrmLeadRow[]; + dailyRows?: AdDailyRow[]; +}) { + const [range, setRange] = useState('all'); + + const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]); + + const rangedLeads = useMemo(() => { + if (!crmLeads.length) return []; + if (!dayWindow) return crmLeads; + return filterLeadsByDayRange(crmLeads, dayWindow.since, dayWindow.until); + }, [crmLeads, dayWindow]); + + const rangedDaily = useMemo(() => { + if (!dailyRows.length) return []; + if (!dayWindow) return dailyRows; + return filterDailyByDayRange(dailyRows, dayWindow.since, dayWindow.until); + }, [dailyRows, dayWindow]); + + 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 rangeHint = dayWindow + ? `${dayWindow.since} → ${dayWindow.until}` + : 'todo el histórico CRM'; + + const paidChannels = channelRows.filter((r) => + ['meta', 'google'].includes(r.channel) + ); + const otherChannels = channelRows.filter( + (r) => !['meta', 'google'].includes(r.channel) + ); + + return ( +
+
+
+

+ CRM · Clientify +

+

+ {label} + {portfolioWide ? ' · portafolio' : ' · zoom cliente'}. Funnel y % a + cliente (ganado) por canal · {rangeHint}. +

+
+
+ +
+ {( + [ + ['7d', '7d'], + ['30d', '30d'], + ['month', 'Mes'], + ['all', 'Todo'], + ] as [RangeKey, string][] + ).map(([k, lbl]) => ( + + ))} +
+
+
+ + {!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} + + {/* North star */} +
+ {[ + { + 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}

+
+ ))} +
+

+ *Frío incluye templado / caliente / cold-lead. +

+ + {/* % cliente por canal — paid first */} +
+
+

+ % a cliente por canal +

+

+ Estado Clientify = cliente{' '} + (ganado). Paid arriba; resto de atribución abajo. +

+
+ + {channelRows.length === 0 ? ( +

+ Sin canales en el periodo. +

+ ) : ( +
+ + +
+ )} +
+ + {/* Funnel statuses */} +
+

Funnel por estado

+ {statusRows.length === 0 ? ( +

Sin datos.

+ ) : ( +
+ + + + + + + + + + {statusRows.map((row) => ( + + + + + + ))} + +
EstadoLeads% del total
+ {row.status} + + {formatNum(row.count)} + + {row.pct.toFixed(1)}% +
+
+ )} +
+ +

+ La inversión por canal (si hay ads) es solo referencia; el % a cliente es + 100% CRM (Clientify). +

+
+ ); +} + +function ChannelTable({ + title, + rows, + spendByChannel, +}: { + title: string; + rows: CrmChannelStats[]; + spendByChannel: Map; +}) { + if (!rows.length) { + return ( +
+

+ {title} +

+

Sin filas.

+
+ ); + } + return ( +
+

+ {title} +

+ + + + + + + + + + + + + + + {rows.map((row) => { + const spend = spendByChannel.get(row.channel) || 0; + return ( + + + + + + + + + + + ); + })} + +
CanalLeadsClientes% a clienteEn oportunidadFrío*PerdidoSpend ads
+ {row.channel} + + {formatNum(row.leads)} + + {formatNum(row.cliente)} + + {row.clienteRate.toFixed(1)}% + + {formatNum(row.enOportunidad)} + + {formatNum(row.leadFrio)} + + {formatNum(row.leadPerdido)} + + {spend ? formatEur(spend) : '—'} +
+
+ ); +} diff --git a/src/components/CrmPortfolioBoard.tsx b/src/components/CrmPortfolioBoard.tsx new file mode 100644 index 0000000..5b10a38 --- /dev/null +++ b/src/components/CrmPortfolioBoard.tsx @@ -0,0 +1,254 @@ +'use client'; + +import React, { useMemo, useState } from 'react'; +import { + CalendarRange, + Percent, + UserCheck, + Users, +} from 'lucide-react'; +import { + CrmLeadRow, + KpiRangeKey, + buildCrmChannelStats, + buildCrmFunnel, + filterLeadsByDayRange, + formatNum, + resolveKpiDayRange, +} from '@/lib/metrics'; + +type RangeKey = KpiRangeKey; + +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'; +} + +export default function CrmPortfolioBoard({ + leads, + tenantNames, +}: { + leads: CrmLeadRow[]; + tenantNames: Record; +}) { + const [range, setRange] = useState('all'); + const dayWindow = useMemo(() => resolveKpiDayRange(range), [range]); + + const ranged = useMemo(() => { + if (!leads.length) return []; + if (!dayWindow) return leads; + return filterLeadsByDayRange(leads, dayWindow.since, dayWindow.until); + }, [leads, dayWindow]); + + const byTenant = useMemo(() => { + const map = new Map(); + for (const r of ranged) { + const tid = r.tenant_id || 'unknown'; + const cur = map.get(tid) || []; + cur.push(r); + map.set(tid, cur); + } + return [...map.entries()] + .map(([tenantId, rows]) => { + const funnel = buildCrmFunnel(rows); + const channels = buildCrmChannelStats(rows); + const meta = channels.find((c) => c.channel === 'meta'); + const google = channels.find((c) => c.channel === 'google'); + return { + tenantId, + name: tenantNames[tenantId] || tenantId.slice(0, 8), + funnel, + channels, + meta, + google, + }; + }) + .filter((t) => t.funnel.total > 0) + .sort((a, b) => b.funnel.cliente - a.funnel.cliente); + }, [ranged, tenantNames]); + + const rangeHint = dayWindow + ? `${dayWindow.since} → ${dayWindow.until}` + : 'todo el histórico'; + + return ( +
+
+
+

+ CRM · Portafolio +

+

+ % a cliente (ganado) por canal y cliente · {rangeHint}. Zoom un + cliente en Contexto para el detalle completo. +

+
+
+ +
+ {( + [ + ['7d', '7d'], + ['30d', '30d'], + ['month', 'Mes'], + ['all', 'Todo'], + ] as [RangeKey, string][] + ).map(([k, lbl]) => ( + + ))} +
+
+
+ + {!byTenant.length ? ( +

+ Sin leads Clientify en el portafolio todavía. +

+ ) : null} + +
+ + + + + + + + + + + + + + + {byTenant.map((t) => ( + + + + + + + + + + + ))} + +
ClienteLeadsClientes% globalMeta leadsMeta %→cliGoogle leadsGoogle %→cli
+ {t.name} + + {formatNum(t.funnel.total)} + + {formatNum(t.funnel.cliente)} + + {t.funnel.clienteRate.toFixed(1)}% + + {t.meta ? formatNum(t.meta.leads) : '—'} + + {t.meta ? `${t.meta.clienteRate.toFixed(1)}%` : '—'} + + {t.google ? formatNum(t.google.leads) : '—'} + + {t.google ? `${t.google.clienteRate.toFixed(1)}%` : '—'} +
+
+ + {/* Expanded channel matrix per tenant */} +
+ {byTenant.map((t) => ( +
+
+

{t.name}

+
+ + + {formatNum(t.funnel.total)} + + + + {formatNum(t.funnel.cliente)} + + + + {t.funnel.clienteRate.toFixed(1)}% + +
+
+
+ + + + + + + + + + + + + + {t.channels.slice(0, 12).map((row) => ( + + + + + + + + + + ))} + +
CanalLeadsClientes% a clienteOportunidadFrío*Perdido
+ {row.channel} + + {formatNum(row.leads)} + + {formatNum(row.cliente)} + + {row.clienteRate.toFixed(1)}% + + {formatNum(row.enOportunidad)} + + {formatNum(row.leadFrio)} + + {formatNum(row.leadPerdido)} +
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index c32848a..0ec3321 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -16,14 +16,17 @@ import { } from 'lucide-react'; import { AdDailyRow, + AttributionChannelRow, ChannelWeekPoint, + CrmLeadRow, Delta, MetricTotals, formatEur, formatNum, formatPct, - channelTotalsFromDaily, + attributionCross, filterDailyByDayRange, + filterLeadsByDayRange, metricTotalsFromDaily, resolveKpiDayRange, sumWeekly, @@ -88,6 +91,14 @@ function DeltaTriple({ delta, invert }: { delta: Delta; invert?: boolean }) { ); } +function gapClass(gap: number): string { + if (gap === 0) return 'text-gray-400'; + const abs = Math.abs(gap); + if (abs <= 5) return 'text-emerald-400'; + if (abs <= 25) return 'text-amber-400'; + return 'text-red-400'; +} + export default function MetricsDashboard({ label, portfolioWide, @@ -99,6 +110,7 @@ export default function MetricsDashboard({ weeklyRows = [], channelPoints = [], dailyRows = [], + crmLeads = [], }: { label: string; portfolioWide: boolean; @@ -116,6 +128,8 @@ export default function MetricsDashboard({ channelPoints?: ChannelWeekPoint[]; /** Raw ad_daily — period totals/matrix use calendar days (same as Ad Tracker). */ dailyRows?: AdDailyRow[]; + /** Clientify leads for CRM vs platform attribution. */ + crmLeads?: CrmLeadRow[]; }) { const [range, setRange] = useState('7d'); @@ -127,6 +141,12 @@ export default function MetricsDashboard({ 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 periodTotals = useMemo(() => { if (rangedDaily.length) return metricTotalsFromDaily(rangedDaily); // Fallback when no ad_daily: approximate from weekly (legacy) @@ -141,12 +161,12 @@ export default function MetricsDashboard({ return ranged.length ? sumWeekly(ranged) : month; }, [rangedDaily, weeklyRows, dayWindow, month]); - const channelRows = useMemo(() => { - if (rangedDaily.length) return channelTotalsFromDaily(rangedDaily); - const map = new Map< - string, - { spend: number; leads: number; deals: number; revenue: number } - >(); + const attributionRows: AttributionChannelRow[] = useMemo(() => { + if (rangedDaily.length || rangedLeads.length) { + return attributionCross(rangedDaily, rangedLeads); + } + // Fallback: weekly series without CRM cross + const map = new Map(); const src = range === 'all' ? channelPoints @@ -156,27 +176,26 @@ export default function MetricsDashboard({ return d >= dayWindow.since && d <= dayWindow.until; }); for (const p of src) { - const cur = map.get(p.channel) || { - spend: 0, - leads: 0, - deals: 0, - revenue: 0, - }; + const cur = map.get(p.channel) || { spend: 0, leads: 0 }; cur.spend += p.spend; cur.leads += p.leads; - cur.deals += p.deals; - cur.revenue += p.revenue; map.set(p.channel, cur); } return [...map.entries()] .map(([channel, v]) => ({ channel, - ...v, - cpl: v.leads > 0 ? v.spend / v.leads : 0, - roas: v.spend > 0 ? v.revenue / v.spend : 0, + 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, channelPoints, range, dayWindow]); + }, [rangedDaily, rangedLeads, channelPoints, range, dayWindow]); + + const crmPeriodTotal = rangedLeads.length; + const hasCrm = crmLeads.length > 0; const rangeHint = dayWindow ? `${dayWindow.since} → ${dayWindow.until}` @@ -283,18 +302,22 @@ export default function MetricsDashboard({
{[ { label: 'Inversión periodo', value: formatEur(periodTotals.spend) }, - { label: 'Leads', value: formatNum(periodTotals.leads) }, { - label: 'CAC (paid)', - value: periodTotals.cac - ? `${formatNum(periodTotals.cac)} €` - : '—', + label: 'Leads plataforma', + value: formatNum(periodTotals.leads), }, { - label: 'ROAS cohorte', - value: periodTotals.roas - ? `${formatNum(periodTotals.roas)}x` - : '—', + 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) => (
- {/* Channel matrix */} + {/* Channel attribution cross */}
-

- Matriz por canal -

-

- Clic → dash secundario del canal +

+

+ 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

- {channelRows.length === 0 ? ( + {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 @@ -353,40 +385,60 @@ export default function MetricsDashboard({ Canal Inversión - Leads - CPL - Revenue - ROAS + Leads plat + Leads CRM + Gap + CPL plat + CPL CRM - {channelRows.map((row) => ( + {attributionRows.map((row) => ( - - {row.channel} - + {row.spend > 0 ? ( + + {row.channel} + + ) : ( + + {row.channel} + + )} - {formatEur(row.spend)} + {row.spend ? formatEur(row.spend) : '—'} - {formatNum(row.leads)} + {formatNum(row.platformLeads)} - {row.cpl ? `${formatNum(row.cpl)} €` : '—'} + {hasCrm ? formatNum(row.crmLeads) : '—'} + + + {hasCrm + ? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}` + : '—'} - {formatEur(row.revenue)} + {row.cplPlatform + ? `${formatNum(row.cplPlatform)} €` + : '—'} - {row.roas ? `${formatNum(row.roas)}x` : '—'} + {hasCrm && row.cplCrm + ? `${formatNum(row.cplCrm)} €` + : '—'} ))} @@ -430,13 +482,17 @@ export default function MetricsDashboard({

  • - El selector de fechas filtra inversión/leads del periodo; WoW/MoM/YoY - siguen siendo comparativas de calendario. + El selector filtra el mismo calendario que Ad Tracker; el cruce + compara leads de la plataforma ads vs atribución Clientify.
  • - CAC unificado con fee/tools y cierre CRM llega en v2. + Gap cercano a 0 en Meta/Google = buena atribución. Canales sin + spend (whatsapp, manual…) son orgánicos/CRM. +
  • +
  • + CPL CRM usa inversión paid ÷ leads CRM del canal (más realista + que el CPL de la plataforma cuando hay desfase).
  • -
  • GEO / LTV aparecen como stubs hasta tener pipeline de datos.
diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index e57d561..19df8b3 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -393,3 +393,244 @@ export function metricTotalsFromDaily(rows: AdDailyRow[]): MetricTotals { cpl: leads > 0 ? spend / leads : 0, }; } + +/** CRM lead row used for channel attribution cross. */ +export type CrmLeadRow = { + source?: string | null; + created_at?: string | null; + status?: string | null; + tenant_id?: string | null; +}; + +/** Map Clientify / UTM sources onto paid channels when possible. */ +export function normalizeLeadChannel(source?: string | null): string { + const s = (source || '').toLowerCase().trim(); + if (!s) return 'otros'; + if ( + s === 'meta' || + s === 'facebook' || + s === 'instagram' || + s === 'fb' || + s === 'facebook-leads' || + s.includes('facebook') || + s.includes('instagram') + ) { + return 'meta'; + } + if ( + s === 'google' || + s === 'google-ads' || + s === 'google_ads' || + s.startsWith('google') || + s.includes('google ads') || + s.includes('google-ads') + ) { + return 'google'; + } + if (s === 'inbox_messenger' || s === 'messenger') return 'messenger'; + if (s.startsWith('integrations')) return 'integrations-api'; + if (s === 'whatsapp') return 'whatsapp'; + return s; +} + +export function normalizeLeadStatus(status?: string | null): string { + const s = (status || '').toLowerCase().trim(); + if (!s) return 'sin-estado'; + if (s === 'cold-lead') return 'lead frío'; + return s; +} + +export function isClienteStatus(status?: string | null): boolean { + return normalizeLeadStatus(status) === 'cliente'; +} + +export type CrmFunnelTotals = { + total: number; + cliente: number; + enOportunidad: number; + leadFrio: number; + leadPerdido: number; + other: number; + clienteRate: number; +}; + +export type CrmChannelStats = { + channel: string; + leads: number; + cliente: number; + enOportunidad: number; + leadFrio: number; + leadPerdido: number; + other: number; + /** % leads del canal que son cliente (0–100). */ + clienteRate: number; +}; + +export type CrmStatusRow = { + status: string; + count: number; + pct: number; +}; + +function bucketStatus(status: string): keyof Omit | 'cliente' { + if (status === 'cliente') return 'cliente'; + if (status === 'en oportunidad') return 'enOportunidad'; + if ( + status === 'lead frío' || + status === 'lead caliente' || + status === 'lead templado' + ) { + return 'leadFrio'; + } + if (status === 'lead perdido' || status === 'cliente perdido') { + return 'leadPerdido'; + } + return 'other'; +} + +export function buildCrmFunnel(leads: CrmLeadRow[]): CrmFunnelTotals { + const t: CrmFunnelTotals = { + total: leads.length, + cliente: 0, + enOportunidad: 0, + leadFrio: 0, + leadPerdido: 0, + other: 0, + clienteRate: 0, + }; + for (const r of leads) { + const b = bucketStatus(normalizeLeadStatus(r.status)); + t[b] += 1; + } + t.clienteRate = t.total > 0 ? (100 * t.cliente) / t.total : 0; + return t; +} + +export function buildCrmStatusBreakdown(leads: CrmLeadRow[]): CrmStatusRow[] { + const map = new Map(); + for (const r of leads) { + const st = normalizeLeadStatus(r.status); + map.set(st, (map.get(st) || 0) + 1); + } + const total = leads.length || 1; + return [...map.entries()] + .map(([status, count]) => ({ + status, + count, + pct: (100 * count) / total, + })) + .sort((a, b) => b.count - a.count); +} + +/** % cliente (ganado) por canal de atribución CRM. */ +export function buildCrmChannelStats(leads: CrmLeadRow[]): CrmChannelStats[] { + const map = new Map< + string, + { + leads: number; + cliente: number; + enOportunidad: number; + leadFrio: number; + leadPerdido: number; + other: number; + } + >(); + + for (const r of leads) { + const channel = normalizeLeadChannel(r.source); + const cur = map.get(channel) || { + leads: 0, + cliente: 0, + enOportunidad: 0, + leadFrio: 0, + leadPerdido: 0, + other: 0, + }; + cur.leads += 1; + const b = bucketStatus(normalizeLeadStatus(r.status)); + cur[b] += 1; + map.set(channel, cur); + } + + return [...map.entries()] + .map(([channel, v]) => ({ + channel, + ...v, + clienteRate: v.leads > 0 ? (100 * v.cliente) / v.leads : 0, + })) + .sort((a, b) => b.cliente - a.cliente || b.leads - a.leads); +} + +export function filterLeadsByDayRange( + rows: CrmLeadRow[], + since: string, + until: string +): CrmLeadRow[] { + return rows.filter((r) => { + const d = (r.created_at || '').slice(0, 10); + return d >= since && d <= until; + }); +} + +export type AttributionChannelRow = { + channel: string; + spend: number; + platformLeads: number; + crmLeads: number; + gap: number; + cplPlatform: number; + cplCrm: number; +}; + +/** + * Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify). + * Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI). + */ +export function attributionCross( + daily: AdDailyRow[], + leads: CrmLeadRow[] +): AttributionChannelRow[] { + const map = new Map< + string, + { spend: number; platformLeads: number; crmLeads: number } + >(); + + for (const r of daily) { + const channel = (r.platform || 'otros').toLowerCase(); + const cur = map.get(channel) || { + spend: 0, + platformLeads: 0, + crmLeads: 0, + }; + cur.spend += n(r.spend); + cur.platformLeads += n(r.platform_leads ?? r.conversions); + map.set(channel, cur); + } + + for (const r of leads) { + const channel = normalizeLeadChannel(r.source); + const cur = map.get(channel) || { + spend: 0, + platformLeads: 0, + crmLeads: 0, + }; + cur.crmLeads += 1; + map.set(channel, cur); + } + + return [...map.entries()] + .map(([channel, v]) => ({ + channel, + spend: v.spend, + platformLeads: v.platformLeads, + crmLeads: v.crmLeads, + gap: v.crmLeads - v.platformLeads, + cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0, + cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0, + })) + .sort( + (a, b) => + b.spend - a.spend || + b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads) + ); +}