diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index 7054a96..5120e05 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -20,6 +20,55 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc export const dynamic = 'force-dynamic'; +/** Enough history for YoY on ad-level Meta rows (can be dense). */ +const AD_DAILY_LOOKBACK_DAYS = 420; +const AD_DAILY_PAGE = 1000; + +async function fetchAdDaily( + tenantId: string | null, + useServiceRole: boolean +): Promise { + const since = new Date(); + since.setUTCDate(since.getUTCDate() - AD_DAILY_LOOKBACK_DAYS); + const sinceStr = since.toISOString().slice(0, 10); + const out: AdDailyRow[] = []; + + 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 < 200000; offset += AD_DAILY_PAGE) { + const r = await fetch( + `${base}/rest/v1/ad_daily?select=*&date=gte.${sinceStr}${tidFilter}&order=date.desc&limit=${AD_DAILY_PAGE}&offset=${offset}`, + { headers, cache: 'no-store' } + ); + if (!r.ok) break; + const chunk = (await r.json()) as AdDailyRow[]; + out.push(...chunk); + if (chunk.length < AD_DAILY_PAGE) break; + } + return out; + } + + const supabase = await createClient(); + for (let offset = 0; offset < 200000; offset += AD_DAILY_PAGE) { + const { data, error } = await supabase + .from('ad_daily') + .select('*') + .gte('date', sinceStr) + .order('date', { ascending: false }) + .range(offset, offset + AD_DAILY_PAGE - 1); + if (error) break; + const chunk = (data as AdDailyRow[]) || []; + out.push(...chunk); + if (chunk.length < AD_DAILY_PAGE) break; + } + return out; +} + /** Paginate CRM leads (source + created_at + status) for attribution + cohort. */ async function fetchCrmLeads( tenantId: string | null, @@ -148,11 +197,8 @@ export default async function KpisPage() { const w = await restGet( '/rest/v1/kpi_weekly?select=*&order=week.desc' ); - const d = await restGet( - '/rest/v1/ad_daily?select=*&order=date.desc&limit=5000' - ); weekly = w.data || []; - daily = d.data || []; + daily = await fetchAdDaily(null, true); crmLeads = []; wonDeals = await fetchWonDeals(null, true); } else if (ctx.tenantId) { @@ -160,11 +206,8 @@ export default async function KpisPage() { const w = await restGet( `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc` ); - const d = await restGet( - `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000` - ); weekly = w.data || []; - daily = d.data || []; + daily = await fetchAdDaily(ctx.tenantId, true); crmLeads = await fetchCrmLeads(ctx.tenantId, true); wonDeals = await fetchWonDeals(ctx.tenantId, true); } @@ -173,13 +216,8 @@ export default async function KpisPage() { .from('kpi_weekly') .select('*') .order('week', { ascending: false }); - const { data: d } = await supabase - .from('ad_daily') - .select('*') - .order('date', { ascending: false }) - .limit(5000); weekly = (w as KpiWeeklyRow[]) || []; - daily = (d as AdDailyRow[]) || []; + daily = await fetchAdDaily(ctx.tenantId, false); crmLeads = await fetchCrmLeads(ctx.tenantId, false); wonDeals = await fetchWonDeals(ctx.tenantId, false); } diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index b3e4ee1..1caaaa2 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -29,6 +29,7 @@ import { filterDealsForRange, filterLeadsByDayRange, metricTotalsFromDaily, + rangeDaySpan, sumWeekly, withCohortFromDeals, KpiWeeklyRow, @@ -53,12 +54,15 @@ function DeltaTriple({ 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 ( @@ -73,12 +77,18 @@ function DeltaTriple({ v: number | null; base: number | null; }[] = [ - { label: 'WoW', v: delta.wow, base: delta.wowBase }, + ...(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 = @@ -95,7 +105,8 @@ function DeltaTriple({ : good ? ArrowUpRight : ArrowDownRight; - const hasBase = c.base !== null && !(c.base === 0 && c.v === null); + 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 ( @@ -242,8 +253,8 @@ export default function MetricsDashboard({ gap: -v.leads, cplPlatform: v.leads > 0 ? v.spend / v.leads : 0, cplCrm: 0, - cierres: 0, ventas: 0, + importe: 0, roas: 0, cac: 0, })) @@ -257,16 +268,16 @@ export default function MetricsDashboard({ platformLeads: a.platformLeads + r.platformLeads, crmLeads: a.crmLeads + r.crmLeads, gap: a.gap + r.gap, - cierres: a.cierres + r.cierres, ventas: a.ventas + r.ventas, + importe: a.importe + r.importe, }), { spend: 0, platformLeads: 0, crmLeads: 0, gap: 0, - cierres: 0, ventas: 0, + importe: 0, } ); }, [attributionRows]); @@ -274,10 +285,11 @@ export default function MetricsDashboard({ const crmPeriodTotal = rangedLeads.length; const hasCrm = crmLeads.length > 0; const hasCohort = rangedDeals.length > 0; - const cierres = periodTotals.deals; + 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[] = [ { @@ -303,14 +315,14 @@ export default function MetricsDashboard({ invert: true, }, { - key: 'revenue', + key: 'deals', label: 'Ventas (CRM)', hint: hasCohort ? cohortFromLeads - ? `${formatNum(cierres)} clientes Clientify · importe no en webhook` - : `${formatNum(cierres)} deals ganados · importe Clientify` - : 'Deals won / status cliente Clientify en el periodo', - format: formatEur, + ? '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, }, @@ -318,7 +330,7 @@ export default function MetricsDashboard({ key: 'cac', label: 'CAC', hint: hasCohort - ? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}` + ? `Inversión ads ÷ ${formatNum(ventasCount)} ventas` : 'Inversión ÷ deals ganados (no leads)', format: (v) => `${formatNum(v)} €`, icon: , @@ -330,7 +342,7 @@ export default function MetricsDashboard({ label: 'ROAS ventas', hint: revenueMissing ? 'Falta importe en CRM (API BPS) para ROAS' - : `Importe deals ganados Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`, + : `Importe Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`, format: (v) => `${formatNum(v)}x`, icon: , cohort: true, @@ -440,13 +452,9 @@ export default function MetricsDashboard({
{cards.map((c) => { const emptyCohort = Boolean(c.cohort) && !hasCohort; - const showClientesCount = - c.key === 'revenue' && revenueMissing && !emptyCohort; const value = emptyCohort ? '—' - : showClientesCount - ? `${formatNum(cierres)} clientes` - : c.format(periodTotals[c.key]); + : c.format(periodTotals[c.key]); return (
); @@ -481,9 +490,9 @@ export default function MetricsDashboard({ Cruce por canal · ads vs CRM

- Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las - ventas de deals sin fuente de contacto aparecen en{' '} - sin-atribuir. + Leads plat = conversiones ads. Leads CRM = altas Clientify por + fuente. Ventas = nº deals ganados; Importe = amount Clientify. + Sin fuente de contacto → canal sin-atribuir.

@@ -519,8 +528,8 @@ export default function MetricsDashboard({ Leads plat Leads CRM Gap - Cierres Ventas + Importe ROAS CPL plat CPL CRM @@ -564,11 +573,11 @@ export default function MetricsDashboard({ ? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}` : '—'} - - {hasCrm ? formatNum(row.cierres) : '—'} - - {row.ventas > 0 ? formatEur(row.ventas) : '—'} + {row.ventas > 0 ? formatNum(row.ventas) : '—'} + + + {row.importe > 0 ? formatEur(row.importe) : '—'} {row.roas > 0 ? `${formatNum(row.roas)}x` : '—'} @@ -612,21 +621,23 @@ export default function MetricsDashboard({ ? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}` : '—'} - - {hasCrm ? formatNum(attributionTotals.cierres) : '—'} + + {formatNum( + attributionTotals.ventas || ventasCount + )} - {attributionTotals.ventas > 0 || periodTotals.revenue > 0 + {attributionTotals.importe > 0 || periodTotals.revenue > 0 ? formatEur( - attributionTotals.ventas || periodTotals.revenue + attributionTotals.importe || periodTotals.revenue ) : '—'} {attributionTotals.spend > 0 && - (attributionTotals.ventas || periodTotals.revenue) > 0 + (attributionTotals.importe || periodTotals.revenue) > 0 ? `${formatNum( - (attributionTotals.ventas || + (attributionTotals.importe || periodTotals.revenue) / attributionTotals.spend )}x` : '—'} diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 218788e..ce145ef 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -214,7 +214,7 @@ export function totalsForDayRange( /** * WoW / MoM / YoY relative to the selected date range (not “today”). - * - WoW: previous window of the same length + * - WoW: previous window of the same length (only meaningful if span ≤ 7d) * - MoM: previous calendar month if the range is a full month; else prior equal-length window * - YoY: same calendar dates one year earlier */ @@ -273,10 +273,11 @@ export function buildRangeDeltas( const deltas = {} as Record; for (const k of keys) { deltas[k] = { - wow: pctChange(cur[k], wow[k]), + // WoW only when the selected window is ≤ 7 days + wow: span <= 7 ? pctChange(cur[k], wow[k]) : null, mom: pctChange(cur[k], mom[k]), yoy: pctChange(cur[k], yoy[k]), - wowBase: wow[k], + wowBase: span <= 7 ? wow[k] : null, momBase: mom[k], yoyBase: yoy[k], }; @@ -284,6 +285,11 @@ export function buildRangeDeltas( return deltas; } +/** Day count of an inclusive ISO range (for UI comparative rules). */ +export function rangeDaySpan(since: string, until: string): number { + return inclusiveDaySpan(since, until); +} + export function formatEur(v: number): string { return `${v.toLocaleString('es-ES', { minimumFractionDigits: 0, @@ -836,20 +842,20 @@ export type AttributionChannelRow = { gap: number; cplPlatform: number; cplCrm: number; - /** Contacts marked cliente (by CRM source) in the period. */ - cierres: number; - /** Won-deal revenue attributed to channel (or sin-atribuir). */ + /** Won deals count (Clientify stage=won) attributed to channel. */ ventas: number; - /** Ventas ÷ inversión (0 if no spend). */ + /** Won-deal revenue (€) attributed to channel (or sin-atribuir). */ + importe: number; + /** Importe ÷ inversión (0 if no spend). */ roas: number; - /** Inversión ÷ cierres (0 if no cierres). */ + /** Inversión ÷ ventas (0 if no ventas). */ cac: number; }; /** * Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify). * Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI). - * Ventas come from won deals; without deal.source they land in `sin-atribuir`. + * Ventas/importe come from won deals; without deal.source they land in `sin-atribuir`. */ export function attributionCross( daily: AdDailyRow[], @@ -862,8 +868,8 @@ export function attributionCross( spend: number; platformLeads: number; crmLeads: number; - cierres: number; ventas: number; + importe: number; } >(); @@ -874,8 +880,8 @@ export function attributionCross( spend: 0, platformLeads: 0, crmLeads: 0, - cierres: 0, ventas: 0, + importe: 0, }; map.set(channel, cur); } @@ -893,7 +899,6 @@ export function attributionCross( const channel = normalizeLeadChannel(r.source); const cur = ensure(channel); cur.crmLeads += 1; - if (isClienteStatus(r.status)) cur.cierres += 1; } for (const d of deals) { @@ -901,7 +906,8 @@ export function attributionCross( const raw = (d.source || '').trim(); const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir'; const cur = ensure(channel); - cur.ventas += n(d.amount); + cur.ventas += 1; + cur.importe += n(d.amount); } return [...map.entries()] @@ -913,15 +919,15 @@ export function attributionCross( gap: v.crmLeads - v.platformLeads, cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0, cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0, - cierres: v.cierres, ventas: v.ventas, - roas: v.spend > 0 ? v.ventas / v.spend : 0, - cac: v.cierres > 0 ? v.spend / v.cierres : 0, + importe: v.importe, + roas: v.spend > 0 ? v.importe / v.spend : 0, + cac: v.ventas > 0 ? v.spend / v.ventas : 0, })) .sort( (a, b) => b.spend - a.spend || - b.ventas - a.ventas || + b.importe - a.importe || b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads) ); }