diff --git a/src/app/dashboard/crm/page.tsx b/src/app/dashboard/crm/page.tsx index c3ac50e..f0b98b0 100644 --- a/src/app/dashboard/crm/page.tsx +++ b/src/app/dashboard/crm/page.tsx @@ -5,6 +5,7 @@ import CrmDashboard from '@/components/CrmDashboard'; import CrmPortfolioBoard from '@/components/CrmPortfolioBoard'; import { resolveActiveTenant } from '@/lib/tenant-context'; import { AdDailyRow, CrmLeadRow } from '@/lib/metrics'; +import { fetchWonDeals } from '@/lib/fetch-won-deals'; import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; export const dynamic = 'force-dynamic'; @@ -145,9 +146,10 @@ export default async function CrmPage() { } const tenantId = ctx.tenantId; - const [crmLeads, dailyRows] = await Promise.all([ + const [crmLeads, dailyRows, wonDeals] = await Promise.all([ fetchCrmLeads(tenantId, useService, false), fetchAdDaily(tenantId, useService), + fetchWonDeals(tenantId, useService), ]); return ( @@ -156,6 +158,7 @@ export default async function CrmPage() { portfolioWide={false} crmLeads={crmLeads} dailyRows={dailyRows} + wonDeals={wonDeals} /> ); } diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index 683fef1..2ecf14c 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -22,6 +22,7 @@ import { adDailyLookbackSince, fetchAdDailyPaged, } from '@/lib/fetch-ad-daily'; +import { fetchWonDeals } from '@/lib/fetch-won-deals'; export const dynamic = 'force-dynamic'; @@ -89,128 +90,6 @@ async function fetchCrmLeads( return out; } -/** Won deals for cohort CAC / revenue / ROAS. */ -async function fetchWonDeals( - tenantId: string | null, - useServiceRole: boolean -): Promise { - const select = - 'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,source,name,lead_id'; - const selectFallback = - 'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,lead_id'; - const pageSize = 1000; - const out: DealWonRow[] = []; - - if (useServiceRole) { - const headers = serviceHeaders(); - const base = supabaseUrl(); - if (!headers || !base) return []; - const tidFilter = tenantId - ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` - : ''; - let useSelect = select; - for (let offset = 0; offset < 50000; offset += pageSize) { - const r = await fetch( - `${base}/rest/v1/deals?select=${useSelect}${tidFilter}&stage=eq.won&order=won_at.desc.nullslast&limit=${pageSize}&offset=${offset}`, - { headers, cache: 'no-store' } - ); - if (!r.ok) { - if (useSelect !== selectFallback && offset === 0) { - useSelect = selectFallback; - offset = -pageSize; - continue; - } - break; - } - const chunk = (await r.json()) as DealWonRow[]; - out.push(...chunk); - if (chunk.length < pageSize) break; - } - } else { - const supabase = await createClient(); - let useSelect = select; - for (let offset = 0; offset < 50000; offset += pageSize) { - const { data, error } = await supabase - .from('deals') - .select(useSelect) - .eq('stage', 'won') - .order('won_at', { ascending: false, nullsFirst: false }) - .range(offset, offset + pageSize - 1); - if (error && useSelect !== selectFallback && offset === 0) { - useSelect = selectFallback; - offset = -pageSize; - continue; - } - if (error) break; - const chunk = (data as DealWonRow[]) || []; - out.push(...chunk); - if (chunk.length < pageSize) break; - } - } - - return enrichDealSources(out, tenantId, useServiceRole); -} - -/** Fill deal.source from linked lead when missing. */ -async function enrichDealSources( - deals: DealWonRow[], - tenantId: string | null, - useServiceRole: boolean -): Promise { - const need = deals.filter((d) => !d.source && d.lead_id); - if (!need.length) { - return deals.map((d) => ({ - ...d, - source: - d.source || - (/^order\s+\d+/i.test(d.name || '') ? 'ecommerce' : d.source) || - null, - })); - } - const leadIds = [...new Set(need.map((d) => d.lead_id!).filter(Boolean))]; - const leadSource = new Map(); - - if (useServiceRole) { - const headers = serviceHeaders(); - const base = supabaseUrl(); - if (headers && base) { - for (let i = 0; i < leadIds.length; i += 200) { - const chunk = leadIds.slice(i, i + 200); - const tidFilter = tenantId - ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` - : ''; - const r = await fetch( - `${base}/rest/v1/leads?select=id,source&id=in.(${chunk.map(encodeURIComponent).join(',')})${tidFilter}`, - { headers, cache: 'no-store' } - ); - if (!r.ok) continue; - const rows = (await r.json()) as { id: string; source?: string }[]; - for (const row of rows) { - if (row.source) leadSource.set(row.id, row.source); - } - } - } - } else { - const supabase = await createClient(); - for (let i = 0; i < leadIds.length; i += 200) { - const chunk = leadIds.slice(i, i + 200); - const { data } = await supabase - .from('leads') - .select('id,source') - .in('id', chunk); - for (const row of (data as { id: string; source?: string }[]) || []) { - if (row.source) leadSource.set(row.id, row.source); - } - } - } - - return deals.map((d) => { - let source = d.source || (d.lead_id ? leadSource.get(d.lead_id) : null) || null; - if (!source && /^order\s+\d+/i.test(d.name || '')) source = 'ecommerce'; - return { ...d, source }; - }); -} - /** Paginate organic & web daily metrics (GSC + GA4) for active tenant / portfolio. */ async function fetchOrganicDaily( tenantId: string | null, diff --git a/src/components/CrmDashboard.tsx b/src/components/CrmDashboard.tsx index d71a614..83fbbb2 100644 --- a/src/components/CrmDashboard.tsx +++ b/src/components/CrmDashboard.tsx @@ -7,19 +7,23 @@ import { 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'; @@ -56,11 +60,13 @@ export default function CrmDashboard({ 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'); @@ -82,6 +88,11 @@ export default function CrmDashboard({ 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), @@ -92,13 +103,23 @@ export default function CrmDashboard({ [rangedLeads] ); const attribution = useMemo( - () => attributionCross(rangedDaily, rangedLeads), - [rangedDaily, rangedLeads] + () => attributionCross(rangedDaily, rangedLeads, rangedDeals), + [rangedDaily, rangedLeads, rangedDeals] ); - const spendByChannel = useMemo(() => { - const m = new Map(); - for (const r of attribution) m.set(r.channel, r.spend); + 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]); @@ -108,6 +129,12 @@ export default function CrmDashboard({ const otherChannels = channelRows.filter( (r) => !['meta', 'google'].includes(r.channel) ); + // Channels with ads spend / ventas but maybe no CRM lead row in this lens + const extraCacChannels = attribution.filter( + (r) => + (r.spend > 0 || r.ventas > 0) && + !channelRows.some((c) => c.channel === r.channel) + ); const leadMax = Math.max(1, ...channelRows.map((r) => r.leads)); const statusMax = Math.max(1, ...statusRows.map((r) => r.count)); @@ -139,8 +166,9 @@ export default function CrmDashboard({

{LENS_HELP[lens]}

- El % a cliente es del CRM, no de ads. La inversión por canal es solo - referencia del periodo. + 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.

@@ -160,7 +188,7 @@ export default function CrmDashboard({ ) : null} -
+
+ 0 ? formatEurCents(cacGlobal) : '—'} + chip={ + + {ventasTotal > 0 + ? `${formatNum(ventasTotal)} ventas · ${formatEur(spendTotal)} ads` + : 'Sin ventas won en el periodo'} + + } + /> } - title="% a cliente por canal" - hint="Estado ganado en el CRM. Ads spend del periodo es referencia." + icon={} + title="CAC por canal" + hint="Inversión ads ÷ ventas won atribuidas al canal (mismo criterio que Métricas)." /> - {!channelRows.length ? ( + {!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) => ( + + + + + + + + ))} + + + + + + + + + + +
CanalInversiónVentasCACImporte
+ {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." + /> + + {!channelRows.length && !extraCacChannels.length ? (

Sin leads en esta lente/periodo.

@@ -231,41 +346,50 @@ export default function CrmDashboard({ Clientes % a cliente Inversión ads + CAC - {[...paidChannels, ...otherChannels].map((row) => ( - - - {row.channel} - - - - - - {formatNum(row.cliente)} - - { + const cacRow = cacByChannel.get(row.channel); + return ( + - {row.clienteRate.toFixed(1)}% - - - {spendByChannel.get(row.channel) - ? formatEur(spendByChannel.get(row.channel)!) - : '—'} - - - ))} + + {row.channel} + + + + + + {formatNum(row.cliente)} + + + {row.clienteRate.toFixed(1)}% + + + {cacRow && cacRow.spend > 0 + ? formatEur(cacRow.spend) + : '—'} + + + {cacRow && cacRow.cac > 0 + ? formatEurCents(cacRow.cac) + : '—'} + + + ); + })}
diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index a951395..2ee671a 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -889,6 +889,7 @@ export default function MetricsDashboard({ CRM Gap Ventas + CAC Importe ROAS CPL plat @@ -962,6 +963,9 @@ export default function MetricsDashboard({ {row.ventas > 0 ? formatNum(row.ventas) : '—'} + + {row.cac > 0 ? formatEurCents(row.cac) : '—'} + {row.importe > 0 ? formatEur(row.importe) : '—'} @@ -1013,6 +1017,17 @@ export default function MetricsDashboard({ {formatNum(attributionTotals.ventas || ventasCount)} + + {(attributionTotals.ventas || ventasCount) > 0 && + attributionTotals.spend > 0 + ? formatEurCents( + attributionTotals.spend / + (attributionTotals.ventas || ventasCount) + ) + : periodTotals.cac > 0 + ? formatEurCents(periodTotals.cac) + : '—'} + {attributionTotals.importe > 0 || periodTotals.revenue > 0 diff --git a/src/lib/fetch-won-deals.ts b/src/lib/fetch-won-deals.ts new file mode 100644 index 0000000..0117007 --- /dev/null +++ b/src/lib/fetch-won-deals.ts @@ -0,0 +1,132 @@ +/** Shared won-deal fetch for Métricas / CRM (tenant-scoped CAC). */ + +import { createClient } from '@/lib/supabase/server'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import type { DealWonRow } from '@/lib/metrics'; + +/** Fill deal.source from linked lead when missing. */ +async function enrichDealSources( + deals: DealWonRow[], + tenantId: string | null, + useServiceRole: boolean +): Promise { + const need = deals.filter((d) => !d.source && d.lead_id); + if (!need.length) { + return deals.map((d) => ({ + ...d, + source: + d.source || + (/^order\s+\d+/i.test(d.name || '') ? 'ecommerce' : d.source) || + null, + })); + } + const leadIds = [...new Set(need.map((d) => d.lead_id!).filter(Boolean))]; + const leadSource = new Map(); + + if (useServiceRole) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (headers && base) { + for (let i = 0; i < leadIds.length; i += 200) { + const chunk = leadIds.slice(i, i + 200); + const tidFilter = tenantId + ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` + : ''; + const r = await fetch( + `${base}/rest/v1/leads?select=id,source&id=in.(${chunk + .map(encodeURIComponent) + .join(',')})${tidFilter}`, + { headers, cache: 'no-store' } + ); + if (!r.ok) continue; + const rows = (await r.json()) as { id: string; source?: string }[]; + for (const row of rows) { + if (row.source) leadSource.set(row.id, row.source); + } + } + } + } else { + const supabase = await createClient(); + for (let i = 0; i < leadIds.length; i += 200) { + const chunk = leadIds.slice(i, i + 200); + const { data } = await supabase + .from('leads') + .select('id,source') + .in('id', chunk); + for (const row of (data as { id: string; source?: string }[]) || []) { + if (row.source) leadSource.set(row.id, row.source); + } + } + } + + return deals.map((d) => { + let source = + d.source || (d.lead_id ? leadSource.get(d.lead_id) : null) || null; + if (!source && /^order\s+\d+/i.test(d.name || '')) source = 'ecommerce'; + return { ...d, source }; + }); +} + +/** Won deals for cohort CAC / revenue / ROAS. Always filter by tenant when provided. */ +export async function fetchWonDeals( + tenantId: string | null, + useServiceRole: boolean +): Promise { + const select = + 'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,source,name,lead_id'; + const selectFallback = + 'amount,won_at,stage,tenant_id,pipeline_id,pipeline_name,lead_id'; + const pageSize = 1000; + const out: DealWonRow[] = []; + + if (useServiceRole) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return []; + const tidFilter = tenantId + ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` + : ''; + let useSelect = select; + for (let offset = 0; offset < 50000; offset += pageSize) { + const r = await fetch( + `${base}/rest/v1/deals?select=${useSelect}${tidFilter}&stage=eq.won&order=won_at.desc.nullslast&limit=${pageSize}&offset=${offset}`, + { headers, cache: 'no-store' } + ); + if (!r.ok) { + if (useSelect !== selectFallback && offset === 0) { + useSelect = selectFallback; + offset = -pageSize; + continue; + } + break; + } + const chunk = (await r.json()) as DealWonRow[]; + out.push(...chunk); + if (chunk.length < pageSize) break; + } + } else { + const supabase = await createClient(); + let useSelect = select; + for (let offset = 0; offset < 50000; offset += pageSize) { + let q = supabase + .from('deals') + .select(useSelect) + .eq('stage', 'won') + .order('won_at', { ascending: false, nullsFirst: false }) + .range(offset, offset + pageSize - 1); + if (tenantId) q = q.eq('tenant_id', tenantId); + const { data, error } = await q; + if (error && useSelect !== selectFallback && offset === 0) { + useSelect = selectFallback; + offset = -pageSize; + continue; + } + if (error) break; + const chunk = (data as DealWonRow[]) || []; + out.push(...chunk); + if (chunk.length < pageSize) break; + } + } + + return enrichDealSources(out, tenantId, useServiceRole); +}