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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user