fix: KPIs — ocultar WoW >7d, YoY con histórico completo, ventas en nº

Pagina ad_daily (~420d) para que YoY no quede vacío; Ventas muestra
conteo de deals (importe Clientify en hint/columna Importe).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-28 11:05:21 +02:00
parent dff9c46747
commit 4c8042aa23
3 changed files with 119 additions and 64 deletions

View File

@@ -20,6 +20,55 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc
export const dynamic = 'force-dynamic'; 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<AdDailyRow[]> {
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. */ /** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
async function fetchCrmLeads( async function fetchCrmLeads(
tenantId: string | null, tenantId: string | null,
@@ -148,11 +197,8 @@ export default async function KpisPage() {
const w = await restGet<KpiWeeklyRow[]>( const w = await restGet<KpiWeeklyRow[]>(
'/rest/v1/kpi_weekly?select=*&order=week.desc' '/rest/v1/kpi_weekly?select=*&order=week.desc'
); );
const d = await restGet<AdDailyRow[]>(
'/rest/v1/ad_daily?select=*&order=date.desc&limit=5000'
);
weekly = w.data || []; weekly = w.data || [];
daily = d.data || []; daily = await fetchAdDaily(null, true);
crmLeads = []; crmLeads = [];
wonDeals = await fetchWonDeals(null, true); wonDeals = await fetchWonDeals(null, true);
} else if (ctx.tenantId) { } else if (ctx.tenantId) {
@@ -160,11 +206,8 @@ export default async function KpisPage() {
const w = await restGet<KpiWeeklyRow[]>( const w = await restGet<KpiWeeklyRow[]>(
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc` `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc`
); );
const d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000`
);
weekly = w.data || []; weekly = w.data || [];
daily = d.data || []; daily = await fetchAdDaily(ctx.tenantId, true);
crmLeads = await fetchCrmLeads(ctx.tenantId, true); crmLeads = await fetchCrmLeads(ctx.tenantId, true);
wonDeals = await fetchWonDeals(ctx.tenantId, true); wonDeals = await fetchWonDeals(ctx.tenantId, true);
} }
@@ -173,13 +216,8 @@ export default async function KpisPage() {
.from('kpi_weekly') .from('kpi_weekly')
.select('*') .select('*')
.order('week', { ascending: false }); .order('week', { ascending: false });
const { data: d } = await supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(5000);
weekly = (w as KpiWeeklyRow[]) || []; weekly = (w as KpiWeeklyRow[]) || [];
daily = (d as AdDailyRow[]) || []; daily = await fetchAdDaily(ctx.tenantId, false);
crmLeads = await fetchCrmLeads(ctx.tenantId, false); crmLeads = await fetchCrmLeads(ctx.tenantId, false);
wonDeals = await fetchWonDeals(ctx.tenantId, false); wonDeals = await fetchWonDeals(ctx.tenantId, false);
} }

View File

@@ -29,6 +29,7 @@ import {
filterDealsForRange, filterDealsForRange,
filterLeadsByDayRange, filterLeadsByDayRange,
metricTotalsFromDaily, metricTotalsFromDaily,
rangeDaySpan,
sumWeekly, sumWeekly,
withCohortFromDeals, withCohortFromDeals,
KpiWeeklyRow, KpiWeeklyRow,
@@ -53,12 +54,15 @@ function DeltaTriple({
empty, empty,
format, format,
current, current,
showWow,
}: { }: {
delta: Delta; delta: Delta;
invert?: boolean; invert?: boolean;
empty?: boolean; empty?: boolean;
format: (v: number) => string; format: (v: number) => string;
current: number; current: number;
/** Hide WoW when selected range is longer than a week. */
showWow: boolean;
}) { }) {
if (empty) { if (empty) {
return ( return (
@@ -73,12 +77,18 @@ function DeltaTriple({
v: number | null; v: number | null;
base: 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: 'MoM', v: delta.mom, base: delta.momBase },
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase }, { label: 'YoY', v: delta.yoy, base: delta.yoyBase },
]; ];
return ( return (
<div className="mt-3 grid grid-cols-3 gap-1.5"> <div
className={`mt-3 grid gap-1.5 ${
showWow ? 'grid-cols-3' : 'grid-cols-2'
}`}
>
{cells.map((c) => { {cells.map((c) => {
const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0; const good = c.v === null ? null : invert ? c.v < 0 : c.v > 0;
const color = const color =
@@ -95,7 +105,8 @@ function DeltaTriple({
: good : good
? ArrowUpRight ? ArrowUpRight
: ArrowDownRight; : 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 = const absDelta =
hasBase && c.base !== null ? current - c.base : null; hasBase && c.base !== null ? current - c.base : null;
return ( return (
@@ -242,8 +253,8 @@ export default function MetricsDashboard({
gap: -v.leads, gap: -v.leads,
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0, cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
cplCrm: 0, cplCrm: 0,
cierres: 0,
ventas: 0, ventas: 0,
importe: 0,
roas: 0, roas: 0,
cac: 0, cac: 0,
})) }))
@@ -257,16 +268,16 @@ export default function MetricsDashboard({
platformLeads: a.platformLeads + r.platformLeads, platformLeads: a.platformLeads + r.platformLeads,
crmLeads: a.crmLeads + r.crmLeads, crmLeads: a.crmLeads + r.crmLeads,
gap: a.gap + r.gap, gap: a.gap + r.gap,
cierres: a.cierres + r.cierres,
ventas: a.ventas + r.ventas, ventas: a.ventas + r.ventas,
importe: a.importe + r.importe,
}), }),
{ {
spend: 0, spend: 0,
platformLeads: 0, platformLeads: 0,
crmLeads: 0, crmLeads: 0,
gap: 0, gap: 0,
cierres: 0,
ventas: 0, ventas: 0,
importe: 0,
} }
); );
}, [attributionRows]); }, [attributionRows]);
@@ -274,10 +285,11 @@ export default function MetricsDashboard({
const crmPeriodTotal = rangedLeads.length; const crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0; const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0; const hasCohort = rangedDeals.length > 0;
const cierres = periodTotals.deals; const ventasCount = periodTotals.deals;
const cohortFromLeads = cohortSource === 'cliente_leads'; const cohortFromLeads = cohortSource === 'cliente_leads';
const revenueMissing = const revenueMissing =
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads; hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
const showWow = rangeDaySpan(since, until) <= 7;
const cards: Card[] = [ const cards: Card[] = [
{ {
@@ -303,14 +315,14 @@ export default function MetricsDashboard({
invert: true, invert: true,
}, },
{ {
key: 'revenue', key: 'deals',
label: 'Ventas (CRM)', label: 'Ventas (CRM)',
hint: hasCohort hint: hasCohort
? cohortFromLeads ? cohortFromLeads
? `${formatNum(cierres)} clientes Clientify · importe no en webhook` ? 'Clientes Clientify en el periodo (sin importe en webhook)'
: `${formatNum(cierres)} deals ganados · importe Clientify` : `Deals ganados Clientify · importe ${formatEur(periodTotals.revenue)}`
: 'Deals won / status cliente Clientify en el periodo', : 'Deals won Clientify en el periodo',
format: formatEur, format: formatNum,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />, icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
cohort: true, cohort: true,
}, },
@@ -318,7 +330,7 @@ export default function MetricsDashboard({
key: 'cac', key: 'cac',
label: 'CAC', label: 'CAC',
hint: hasCohort hint: hasCohort
? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}` ? `Inversión ads ÷ ${formatNum(ventasCount)} ventas`
: 'Inversión ÷ deals ganados (no leads)', : 'Inversión ÷ deals ganados (no leads)',
format: (v) => `${formatNum(v)} €`, format: (v) => `${formatNum(v)} €`,
icon: <Target className="w-4 h-4 text-amber-600" />, icon: <Target className="w-4 h-4 text-amber-600" />,
@@ -330,7 +342,7 @@ export default function MetricsDashboard({
label: 'ROAS ventas', label: 'ROAS ventas',
hint: revenueMissing hint: revenueMissing
? 'Falta importe en CRM (API BPS) para ROAS' ? '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`, format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />, icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true, cohort: true,
@@ -440,13 +452,9 @@ export default function MetricsDashboard({
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{cards.map((c) => { {cards.map((c) => {
const emptyCohort = Boolean(c.cohort) && !hasCohort; const emptyCohort = Boolean(c.cohort) && !hasCohort;
const showClientesCount =
c.key === 'revenue' && revenueMissing && !emptyCohort;
const value = emptyCohort const value = emptyCohort
? '—' ? '—'
: showClientesCount : c.format(periodTotals[c.key]);
? `${formatNum(cierres)} clientes`
: c.format(periodTotals[c.key]);
return ( return (
<div <div
key={c.key} key={c.key}
@@ -468,6 +476,7 @@ export default function MetricsDashboard({
empty={emptyCohort} empty={emptyCohort}
format={c.format} format={c.format}
current={periodTotals[c.key]} current={periodTotals[c.key]}
showWow={showWow}
/> />
</div> </div>
); );
@@ -481,9 +490,9 @@ export default function MetricsDashboard({
Cruce por canal · ads vs CRM Cruce por canal · ads vs CRM
</h3> </h3>
<p className="text-xs text-gray-600 mt-0.5"> <p className="text-xs text-gray-600 mt-0.5">
Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las Leads plat = conversiones ads. Leads CRM = altas Clientify por
ventas de deals sin fuente de contacto aparecen en{' '} fuente. Ventas = nº deals ganados; Importe = amount Clientify.
<span className="font-medium">sin-atribuir</span>. Sin fuente de contacto → canal sin-atribuir.
</p> </p>
</div> </div>
<p className="text-xs text-gray-600 shrink-0 font-medium"> <p className="text-xs text-gray-600 shrink-0 font-medium">
@@ -519,8 +528,8 @@ export default function MetricsDashboard({
<th className="py-2 pr-3 font-semibold">Leads plat</th> <th className="py-2 pr-3 font-semibold">Leads plat</th>
<th className="py-2 pr-3 font-semibold">Leads CRM</th> <th className="py-2 pr-3 font-semibold">Leads CRM</th>
<th className="py-2 pr-3 font-semibold">Gap</th> <th className="py-2 pr-3 font-semibold">Gap</th>
<th className="py-2 pr-3 font-semibold">Cierres</th>
<th className="py-2 pr-3 font-semibold">Ventas</th> <th className="py-2 pr-3 font-semibold">Ventas</th>
<th className="py-2 pr-3 font-semibold">Importe</th>
<th className="py-2 pr-3 font-semibold">ROAS</th> <th className="py-2 pr-3 font-semibold">ROAS</th>
<th className="py-2 pr-3 font-semibold">CPL plat</th> <th className="py-2 pr-3 font-semibold">CPL plat</th>
<th className="py-2 font-semibold">CPL CRM</th> <th className="py-2 font-semibold">CPL CRM</th>
@@ -564,11 +573,11 @@ export default function MetricsDashboard({
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}` ? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'} : '—'}
</td> </td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{hasCrm ? formatNum(row.cierres) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums"> <td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
{row.ventas > 0 ? formatEur(row.ventas) : '—'} {row.ventas > 0 ? formatNum(row.ventas) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.importe > 0 ? formatEur(row.importe) : '—'}
</td> </td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums"> <td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'} {row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
@@ -612,21 +621,23 @@ export default function MetricsDashboard({
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}` ? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
: '—'} : '—'}
</td> </td>
<td className="py-2.5 pr-3 tabular-nums"> <td className="py-2.5 pr-3 tabular-nums text-gray-900">
{hasCrm ? formatNum(attributionTotals.cierres) : '—'} {formatNum(
attributionTotals.ventas || ventasCount
)}
</td> </td>
<td className="py-2.5 pr-3 tabular-nums text-gray-900"> <td className="py-2.5 pr-3 tabular-nums text-gray-900">
{attributionTotals.ventas > 0 || periodTotals.revenue > 0 {attributionTotals.importe > 0 || periodTotals.revenue > 0
? formatEur( ? formatEur(
attributionTotals.ventas || periodTotals.revenue attributionTotals.importe || periodTotals.revenue
) )
: '—'} : '—'}
</td> </td>
<td className="py-2.5 pr-3 tabular-nums"> <td className="py-2.5 pr-3 tabular-nums">
{attributionTotals.spend > 0 && {attributionTotals.spend > 0 &&
(attributionTotals.ventas || periodTotals.revenue) > 0 (attributionTotals.importe || periodTotals.revenue) > 0
? `${formatNum( ? `${formatNum(
(attributionTotals.ventas || (attributionTotals.importe ||
periodTotals.revenue) / attributionTotals.spend periodTotals.revenue) / attributionTotals.spend
)}x` )}x`
: '—'} : '—'}

View File

@@ -214,7 +214,7 @@ export function totalsForDayRange(
/** /**
* WoW / MoM / YoY relative to the selected date range (not “today”). * 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 * - MoM: previous calendar month if the range is a full month; else prior equal-length window
* - YoY: same calendar dates one year earlier * - YoY: same calendar dates one year earlier
*/ */
@@ -273,10 +273,11 @@ export function buildRangeDeltas(
const deltas = {} as Record<keyof MetricTotals, Delta>; const deltas = {} as Record<keyof MetricTotals, Delta>;
for (const k of keys) { for (const k of keys) {
deltas[k] = { 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]), mom: pctChange(cur[k], mom[k]),
yoy: pctChange(cur[k], yoy[k]), yoy: pctChange(cur[k], yoy[k]),
wowBase: wow[k], wowBase: span <= 7 ? wow[k] : null,
momBase: mom[k], momBase: mom[k],
yoyBase: yoy[k], yoyBase: yoy[k],
}; };
@@ -284,6 +285,11 @@ export function buildRangeDeltas(
return deltas; 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 { export function formatEur(v: number): string {
return `${v.toLocaleString('es-ES', { return `${v.toLocaleString('es-ES', {
minimumFractionDigits: 0, minimumFractionDigits: 0,
@@ -836,20 +842,20 @@ export type AttributionChannelRow = {
gap: number; gap: number;
cplPlatform: number; cplPlatform: number;
cplCrm: number; cplCrm: number;
/** Contacts marked cliente (by CRM source) in the period. */ /** Won deals count (Clientify stage=won) attributed to channel. */
cierres: number;
/** Won-deal revenue attributed to channel (or sin-atribuir). */
ventas: number; 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; roas: number;
/** Inversión ÷ cierres (0 if no cierres). */ /** Inversión ÷ ventas (0 if no ventas). */
cac: number; cac: number;
}; };
/** /**
* Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify). * Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify).
* Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI). * 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( export function attributionCross(
daily: AdDailyRow[], daily: AdDailyRow[],
@@ -862,8 +868,8 @@ export function attributionCross(
spend: number; spend: number;
platformLeads: number; platformLeads: number;
crmLeads: number; crmLeads: number;
cierres: number;
ventas: number; ventas: number;
importe: number;
} }
>(); >();
@@ -874,8 +880,8 @@ export function attributionCross(
spend: 0, spend: 0,
platformLeads: 0, platformLeads: 0,
crmLeads: 0, crmLeads: 0,
cierres: 0,
ventas: 0, ventas: 0,
importe: 0,
}; };
map.set(channel, cur); map.set(channel, cur);
} }
@@ -893,7 +899,6 @@ export function attributionCross(
const channel = normalizeLeadChannel(r.source); const channel = normalizeLeadChannel(r.source);
const cur = ensure(channel); const cur = ensure(channel);
cur.crmLeads += 1; cur.crmLeads += 1;
if (isClienteStatus(r.status)) cur.cierres += 1;
} }
for (const d of deals) { for (const d of deals) {
@@ -901,7 +906,8 @@ export function attributionCross(
const raw = (d.source || '').trim(); const raw = (d.source || '').trim();
const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir'; const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir';
const cur = ensure(channel); const cur = ensure(channel);
cur.ventas += n(d.amount); cur.ventas += 1;
cur.importe += n(d.amount);
} }
return [...map.entries()] return [...map.entries()]
@@ -913,15 +919,15 @@ export function attributionCross(
gap: v.crmLeads - v.platformLeads, gap: v.crmLeads - v.platformLeads,
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0, cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0, cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
cierres: v.cierres,
ventas: v.ventas, ventas: v.ventas,
roas: v.spend > 0 ? v.ventas / v.spend : 0, importe: v.importe,
cac: v.cierres > 0 ? v.spend / v.cierres : 0, roas: v.spend > 0 ? v.importe / v.spend : 0,
cac: v.ventas > 0 ? v.spend / v.ventas : 0,
})) }))
.sort( .sort(
(a, b) => (a, b) =>
b.spend - a.spend || b.spend - a.spend ||
b.ventas - a.ventas || b.importe - a.importe ||
b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads) b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads)
); );
} }