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

View File

@@ -1,109 +1,85 @@
import React from 'react';
import {
TrendingUp,
Users,
DollarSign,
Target,
} from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import MetricsDashboard from '@/components/MetricsDashboard';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import {
AdDailyRow,
KpiWeeklyRow,
buildComparisons,
sumDaily,
sumWeekly,
} from '@/lib/metrics';
import { isAgencyRole, userRole } from '@/lib/agency';
export const revalidate = 0;
type KpiRow = {
total_spend?: number;
total_leads?: number;
cohort_revenue_won?: number;
cohort_deals_won?: number;
cash_cac?: number;
cohort_cac?: number;
channel?: string;
week?: string;
};
export const dynamic = 'force-dynamic';
export default async function KpisPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
let kpis: KpiRow[] = [];
if (user) {
// RLS must scope by tenant; never use service role here
const { data, error } = await supabase.from('kpi_weekly').select('*');
if (error) console.error('kpi_weekly', error.message);
else kpis = (data as KpiRow[]) ?? [];
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
let weekly: KpiWeeklyRow[] = [];
let daily: AdDailyRow[] = [];
if (agency && (ctx.portfolioWide || ctx.viaView)) {
// Service role for portfolio / zoom (bypass single-tenant RLS)
if (ctx.portfolioWide) {
const w = await restGet<KpiWeeklyRow[]>(
'/rest/v1/kpi_weekly?select=*&order=week.desc'
);
const d = await restGet<AdDailyRow[]>(
'/rest/v1/ad_daily?select=*&order=date.desc&limit=500'
);
weekly = w.data || [];
daily = d.data || [];
} else if (ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const w = await restGet<KpiWeeklyRow[]>(
`/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=500`
);
weekly = w.data || [];
daily = d.data || [];
}
} else {
const { data: w } = await supabase
.from('kpi_weekly')
.select('*')
.order('week', { ascending: false });
const { data: d } = await supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(500);
weekly = (w as KpiWeeklyRow[]) || [];
daily = (d as AdDailyRow[]) || [];
}
const totalSpend = kpis.reduce((acc, row) => acc + Number(row.total_spend || 0), 0);
const totalLeads = kpis.reduce((acc, row) => acc + Number(row.total_leads || 0), 0);
const totalRevenue = kpis.reduce(
(acc, row) => acc + Number(row.cohort_revenue_won || 0),
0
);
const dealsWon = kpis.reduce(
(acc, row) => acc + Number(row.cohort_deals_won || 0),
0
);
const avgCac = dealsWon > 0 ? (totalSpend / dealsWon).toFixed(2) : '0.00';
const overallRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0.00';
const comparisons = buildComparisons(weekly);
const month =
comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0
? comparisons.currentMonth
: sumWeekly(weekly); // fallback: all available weeks if current month empty
const dailyTotals = sumDaily(daily);
const sparse = weekly.length < 4;
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Rendimiento & Métricas de Adquisición
</h2>
<p className="text-sm text-gray-400 mt-1">
Modelo: <strong className="text-gray-200">Cohorte</strong> vs caja · datos
filtrados por tenant (RLS).
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-5">
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">
Inversión en Ads
</span>
<DollarSign className="w-4 h-4 text-brand-500" />
</div>
<p className="text-2xl font-bold text-white">
{totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
</p>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">Leads</span>
<Users className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-white">{totalLeads}</p>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">
CAC Cohorte
</span>
<Target className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-bold text-white">{avgCac} €</p>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5">
<div className="flex items-center justify-between text-gray-400 mb-2">
<span className="text-xs font-medium uppercase tracking-wider">
ROAS Cohorte
</span>
<TrendingUp className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold text-white">{overallRoas}x</p>
</div>
</div>
{kpis.length === 0 && (
<p className="text-sm text-gray-500">
Sin filas en <code>kpi_weekly</code> para este tenant. Revisar pipeline
n8n/Windsor y políticas RLS.
</p>
)}
</div>
<MetricsDashboard
label={ctx.label}
portfolioWide={ctx.portfolioWide}
month={month}
week={comparisons.currentWeek}
deltas={comparisons.deltas}
daily={dailyTotals}
sparse={sparse}
/>
);
}