feat: metrics dashboard with WoW/MoM/YoY and creatives pipeline

Rebuild KPIs as a real dashboard (period deltas, tenant zoom), clarify
objetivos/calendario/activos sources, and turn creatives into a status
pipeline with delete.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-22 13:45:41 +02:00
parent 791976a3d8
commit 8182402d96
11 changed files with 1002 additions and 263 deletions

189
src/lib/metrics.ts Normal file
View File

@@ -0,0 +1,189 @@
/** KPI aggregation + WoW / MoM / YoY helpers. */
export type KpiWeeklyRow = {
tenant_id?: string;
week?: string;
channel?: string;
total_leads?: number;
cohort_deals_won?: number;
cohort_revenue_won?: number;
total_spend?: number;
cac_cohort?: number;
roas_cohort?: number;
};
export type AdDailyRow = {
date?: string;
spend?: number;
platform_leads?: number;
impressions?: number;
clicks?: number;
};
export type MetricTotals = {
spend: number;
leads: number;
deals: number;
revenue: number;
cac: number;
roas: number;
cpl: number;
};
export type Delta = {
wow: number | null;
mom: number | null;
yoy: number | null;
};
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
export function sumWeekly(rows: KpiWeeklyRow[]): MetricTotals {
const spend = rows.reduce((a, r) => a + n(r.total_spend), 0);
const leads = rows.reduce((a, r) => a + n(r.total_leads), 0);
const deals = rows.reduce((a, r) => a + n(r.cohort_deals_won), 0);
const revenue = rows.reduce((a, r) => a + n(r.cohort_revenue_won), 0);
return {
spend,
leads,
deals,
revenue,
cac: deals > 0 ? spend / deals : 0,
roas: spend > 0 ? revenue / spend : 0,
cpl: leads > 0 ? spend / leads : 0,
};
}
export function sumDaily(rows: AdDailyRow[]): {
spend: number;
leads: number;
impressions: number;
clicks: number;
} {
return {
spend: rows.reduce((a, r) => a + n(r.spend), 0),
leads: rows.reduce((a, r) => a + n(r.platform_leads), 0),
impressions: rows.reduce((a, r) => a + n(r.impressions), 0),
clicks: rows.reduce((a, r) => a + n(r.clicks), 0),
};
}
function weekStart(d: Date): Date {
const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const day = x.getUTCDay(); // 0 Sun
const diff = day === 0 ? -6 : 1 - day; // Monday
x.setUTCDate(x.getUTCDate() + diff);
x.setUTCHours(0, 0, 0, 0);
return x;
}
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function parseWeek(w?: string): Date | null {
if (!w) return null;
const d = new Date(w);
return Number.isNaN(d.getTime()) ? null : d;
}
function pctChange(current: number, previous: number): number | null {
if (previous === 0) {
if (current === 0) return 0;
return null; // can't compute meaningfully
}
return ((current - previous) / Math.abs(previous)) * 100;
}
function filterWeeks(
rows: KpiWeeklyRow[],
from: Date,
toExclusive: Date
): KpiWeeklyRow[] {
return rows.filter((r) => {
const d = parseWeek(r.week);
if (!d) return false;
return d >= from && d < toExclusive;
});
}
/** Build period totals from weekly rows for WoW / MoM / YoY. */
export function buildComparisons(rows: KpiWeeklyRow[]): {
currentWeek: MetricTotals;
prevWeek: MetricTotals;
currentMonth: MetricTotals;
prevMonth: MetricTotals;
currentMonthLastYear: MetricTotals;
deltas: Record<keyof MetricTotals, Delta>;
} {
const now = new Date();
const thisWeek = weekStart(now);
const prevWeekStart = new Date(thisWeek);
prevWeekStart.setUTCDate(prevWeekStart.getUTCDate() - 7);
const nextWeek = new Date(thisWeek);
nextWeek.setUTCDate(nextWeek.getUTCDate() + 7);
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
const yoyMonthStart = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), 1));
const yoyMonthEnd = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1));
const currentWeek = sumWeekly(filterWeeks(rows, thisWeek, nextWeek));
const prevWeek = sumWeekly(filterWeeks(rows, prevWeekStart, thisWeek));
const currentMonth = sumWeekly(filterWeeks(rows, monthStart, nextMonth));
const prevMonth = sumWeekly(filterWeeks(rows, prevMonthStart, monthStart));
const currentMonthLastYear = sumWeekly(
filterWeeks(rows, yoyMonthStart, yoyMonthEnd)
);
const keys: (keyof MetricTotals)[] = [
'spend',
'leads',
'deals',
'revenue',
'cac',
'roas',
'cpl',
];
const deltas = {} as Record<keyof MetricTotals, Delta>;
for (const k of keys) {
// For CAC/CPL lower is better — still show raw % change; UI can note it
deltas[k] = {
wow: pctChange(currentWeek[k], prevWeek[k]),
mom: pctChange(currentMonth[k], prevMonth[k]),
yoy: pctChange(currentMonth[k], currentMonthLastYear[k]),
};
}
return {
currentWeek,
prevWeek,
currentMonth,
prevMonth,
currentMonthLastYear,
deltas,
};
}
export function formatEur(v: number): string {
return `${v.toLocaleString('es-ES', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})} €`;
}
export function formatPct(v: number | null): string {
if (v === null || Number.isNaN(v)) return '—';
const sign = v > 0 ? '+' : '';
return `${sign}${v.toFixed(1)}%`;
}
export function formatNum(v: number): string {
return v.toLocaleString('es-ES', { maximumFractionDigits: 1 });
}
export { isoDate, weekStart };

70
src/lib/tenant-context.ts Normal file
View File

@@ -0,0 +1,70 @@
import { cookies } from 'next/headers';
import {
VIEW_TENANT_COOKIE,
VIEW_TENANT_NAME_COOKIE,
homeTenantId,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
export type ResolvedTenant = {
tenantId: string | null;
/** true = agency zoomed into one client */
viaView: boolean;
/** true = agency with no client selected → all tenants */
portfolioWide: boolean;
label: string;
};
export async function resolveActiveTenant(user: {
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
} | null): Promise<ResolvedTenant> {
const role = userRole(user);
const cookieStore = await cookies();
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value || null;
if (isAgencyRole(role) && viewId) {
return {
tenantId: viewId,
viaView: true,
portfolioWide: false,
label: viewName || 'Cliente',
};
}
if (isAgencyRole(role) && !viewId) {
return {
tenantId: null,
viaView: false,
portfolioWide: true,
label: 'Portafolio (todos los clientes)',
};
}
return {
tenantId: homeTenantId(user),
viaView: false,
portfolioWide: false,
label:
(user?.user_metadata?.company as string) ||
(user?.app_metadata?.company as string) ||
'Cliente',
};
}
export async function restGet<T = unknown>(
path: string
): Promise<{ ok: boolean; data: T | null; error?: string }> {
const headers = serviceHeaders();
if (!headers) return { ok: false, data: null, error: 'no service role' };
const r = await fetch(`${supabaseUrl()}${path}`, {
headers,
cache: 'no-store',
});
if (!r.ok) {
return { ok: false, data: null, error: await r.text() };
}
return { ok: true, data: (await r.json()) as T };
}