feat: sync Meta metrics, objetivos ppto/consecución y simulador por tenant

Rellena ad_daily desde Meta, KPIs con fallback a daily, objetivos de presupuesto vs consecución, y simulador sin TikTok anclado a métricas del cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 10:59:15 +02:00
parent 0d286dc66c
commit a1a00840d5
13 changed files with 1392 additions and 154 deletions

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { syncAdsForTenants } from '@/lib/ads-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/** POST { tenantId?, days? } — sync Meta/Google insights → ad_daily + kpi_weekly */
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const tenantId =
typeof body.tenantId === 'string' ? body.tenantId : undefined;
const days =
typeof body.days === 'number' && body.days > 0 && body.days <= 90
? body.days
: 30;
try {
const { results, days: d } = await syncAdsForTenants({ tenantId, days });
const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length;
const errs = results.filter((r) => r.errors.length).length;
return NextResponse.json({
ok: true,
days: d,
synced: ok,
withErrors: errs,
results,
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'sync error' },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics';
import {
SimChannel,
simulateFromTenantMetrics,
} from '@/lib/simulate';
export async function POST(req: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
@@ -12,10 +19,12 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
const tenantId = ctx.tenantId ||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
const companyName = user.user_metadata?.company || 'Cliente';
if (!tenantId) {
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
if (!tenantId && !ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
{ status: 403 }
@@ -121,52 +130,85 @@ export async function POST(req: NextRequest) {
normQuery.includes('presupuesto')
) {
try {
if (ctx.portfolioWide || !tenantId) {
return reply(
'Selecciona un **cliente en Contexto** para simular con sus métricas reales (CPL, cierre y ticket).'
);
}
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
let amount = 1000;
if (matchNumber) {
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
}
let channel: string | null = null;
let channel: SimChannel = 'blended';
if (normQuery.includes('meta') || normQuery.includes('facebook') || normQuery.includes('instagram')) channel = 'meta';
else if (normQuery.includes('google')) channel = 'google';
else if (normQuery.includes('tiktok')) channel = 'tiktok';
else if (normQuery.includes('linkedin')) channel = 'linkedin';
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
method: 'POST',
headers,
body: JSON.stringify({
p_tenant_id: tenantId,
p_additional_spend: amount,
p_target_channel: channel
})
});
if (res.ok) {
const sim = await res.json();
const p10 = sim.conservative_p10 || {};
const p50 = sim.expected_p50 || {};
const p90 = sim.optimistic_p90 || {};
const base = sim.baseline || {};
const channelLabel = (sim.target_channel || 'General').toUpperCase();
const text =
`🔮 **Simulación de Inversión Adicional (+${amount.toLocaleString('es-ES')} € en ${channelLabel}):**\n\n` +
`📈 **Métricas Base Observadas:**\n` +
`- **CPL Histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
`- **Tasa de Cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
`- **Ticket Medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n\n` +
`📊 **Proyección por Escenarios Matemáticos:**\n` +
`- **Escenario Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p50.roas}x**)\n` +
`- **Escenario Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p10.roas}x**)\n` +
`- **Escenario Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p90.roas}x**)\n\n` +
`💡 *Cálculo determinista con la función RPC simulate_budget basada en la atribución de cohorte a 90 días.*`;
return reply(text);
else if (normQuery.includes('tiktok')) {
return reply(
'TikTok aún no tiene campañas activas en este portafolio. Puedo simular con **Meta**, **Google** o presupuesto **blended** según el histórico del cliente.'
);
}
let daily: AdDailyRow[] = [];
let weekly: KpiWeeklyRow[] = [];
if (agency && ctx.viaView) {
const tid = encodeURIComponent(tenantId);
const [d, w] = await Promise.all([
restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
),
restGet<KpiWeeklyRow[]>(
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
),
]);
daily = d.data || [];
weekly = w.data || [];
} else {
const [{ data: d }, { data: w }] = await Promise.all([
supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(400),
supabase
.from('kpi_weekly')
.select('*')
.order('week', { ascending: false })
.limit(52),
]);
daily = (d as AdDailyRow[]) || [];
weekly = (w as KpiWeeklyRow[]) || [];
}
const sim = simulateFromTenantMetrics({
spend: Number.isFinite(amount) && amount > 0 ? amount : 1000,
channel,
daily,
weekly,
});
const p10 = sim.conservative_p10;
const p50 = sim.expected_p50;
const p90 = sim.optimistic_p90;
const base = sim.baseline;
const channelLabel = (sim.target_channel || 'blended').toUpperCase();
const text =
`🔮 **Simulación (+${amount.toLocaleString('es-ES')} € · ${channelLabel}) — ${companyName}:**\n\n` +
`📈 **Baseline del cliente (ad_daily + kpi_weekly):**\n` +
`- **CPL histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
`- **Tasa de cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
`- **Ticket medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` +
`- **Muestra:** ${Number(base.sample_spend || 0).toLocaleString('es-ES')} € · ${base.sample_leads || 0} leads\n\n` +
`📊 **Proyección:**\n` +
`- **Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p50.roas}x**)\n` +
`- **Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p10.roas}x**)\n` +
`- **Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p90.roas}x**)\n\n` +
`💡 *Estimación con métricas reales del cliente en contexto. Sin TikTok (sin campañas activas).*`;
return reply(text);
} catch (e) {
console.error('Error in simulate_budget chat:', e);
console.error('Error in tenant simulate chat:', e);
}
}

View File

@@ -1,10 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics';
import {
SimChannel,
simulateFromTenantMetrics,
} from '@/lib/simulate';
export const dynamic = 'force-dynamic';
async function loadTenantMetrics(tenantId: string, agencyView: boolean) {
let daily: AdDailyRow[] = [];
let weekly: KpiWeeklyRow[] = [];
if (agencyView) {
const tid = encodeURIComponent(tenantId);
const [d, w] = await Promise.all([
restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
),
restGet<KpiWeeklyRow[]>(
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
),
]);
daily = d.data || [];
weekly = w.data || [];
} else {
const supabase = await createClient();
const [{ data: d }, { data: w }] = await Promise.all([
supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(400),
supabase
.from('kpi_weekly')
.select('*')
.order('week', { ascending: false })
.limit(52),
]);
daily = (d as AdDailyRow[]) || [];
weekly = (w as KpiWeeklyRow[]) || [];
}
return { daily, weekly };
}
export async function POST(req: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
const supabase = await createClient();
const {
data: { user },
@@ -13,42 +55,50 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
if (!tenantId) {
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Usuario sin tenant_id' },
{ status: 403 }
{ error: 'Selecciona un cliente en Contexto para simular.' },
{ status: 400 }
);
}
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
try {
const body = await req.json();
const spend = parseFloat(body.spend || 1000);
const channel = body.channel || 'meta';
let channel = String(body.channel || 'blended').toLowerCase();
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
method: 'POST',
headers: {
apikey: keyForRpc,
Authorization: `Bearer ${keyForRpc}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
p_tenant_id: tenantId,
p_additional_spend: spend,
p_target_channel: channel === 'blended' ? null : channel,
}),
if (channel === 'tiktok') {
return NextResponse.json(
{
error:
'TikTok no está activo aún en este portafolio. Usa Meta, Google o blended.',
},
{ status: 400 }
);
}
if (!['meta', 'google', 'blended'].includes(channel)) {
channel = 'blended';
}
const agency = isAgencyRole(userRole(user));
const { daily, weekly } = await loadTenantMetrics(
ctx.tenantId,
agency && ctx.viaView
);
const sim = simulateFromTenantMetrics({
spend: Number.isFinite(spend) && spend > 0 ? spend : 1000,
channel: channel as SimChannel,
daily,
weekly,
});
if (res.ok) {
const data = await res.json();
return NextResponse.json(data);
}
const err = await res.text();
return NextResponse.json({ error: err }, { status: 500 });
return NextResponse.json({
...sim,
tenant_label: ctx.label,
hint: 'Estimación con CPL, cierre y ticket del cliente en contexto (ad_daily + kpi_weekly).',
});
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Error';
return NextResponse.json({ error: message }, { status: 500 });

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { Target } from 'lucide-react';
import { Target, Wallet, Trophy } from 'lucide-react';
import BudgetSimulator from '@/components/BudgetSimulator';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow } from '@/lib/metrics';
export const dynamic = 'force-dynamic';
@@ -16,8 +17,57 @@ type TargetRow = {
leads_actual?: number;
clients_target?: number;
clients_actual?: number;
spend_target?: number;
spend_actual?: number;
};
function pct(actual: number, target: number): number {
if (!target || target <= 0) return 0;
return Math.min(100, Math.round((actual / target) * 100));
}
function ProgressBar({
label,
actual,
target,
unit = '',
tone = 'brand',
}: {
label: string;
actual: number;
target: number;
unit?: string;
tone?: 'brand' | 'sky' | 'emerald' | 'amber';
}) {
const p = pct(actual, target);
const bar =
tone === 'sky'
? 'bg-sky-500'
: tone === 'emerald'
? 'bg-emerald-500'
: tone === 'amber'
? 'bg-amber-500'
: 'bg-brand-500';
return (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-300 font-medium">{label}</span>
<span className="text-white font-semibold tabular-nums">
{p}%
</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div className={`h-full ${bar}`} style={{ width: `${p}%` }} />
</div>
<p className="text-xs text-gray-500 tabular-nums">
{actual.toLocaleString('es-ES')}
{unit} / {target.toLocaleString('es-ES')}
{unit}
</p>
</div>
);
}
export default async function EstrategiaPage() {
const supabase = await createClient();
const {
@@ -28,7 +78,10 @@ export default async function EstrategiaPage() {
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const currentMonth = `${y}-${m}-01`;
const monthPrefix = `${y}-${m}`;
let targetData: TargetRow = {
revenue_target: 0,
@@ -38,28 +91,22 @@ export default async function EstrategiaPage() {
leads_actual: 0,
clients_target: 0,
clients_actual: 0,
spend_target: 0,
spend_actual: 0,
};
let sourceNote =
'Origen: tabla targets + RPC targets_vs_actual (metas cargadas en Postgres por tenant/mes).';
'Objetivos de presupuesto (ppto ads) y de consecución (leads / clientes / facturación) por tenant y mes.';
const tenantForRpc = ctx.portfolioWide ? null : ctx.tenantId;
if (tenantForRpc) {
if (agency && ctx.viaView) {
// Prefer raw targets row via service role; actuals may still need RPC with user JWT
const tid = encodeURIComponent(tenantForRpc);
const t = await restGet<
{ revenue_target: number; clients_target: number; leads_target: number }[]
>(
`/rest/v1/targets?tenant_id=eq.${tid}&month=eq.${currentMonth}&select=revenue_target,clients_target,leads_target`
const t = await restGet<TargetRow[]>(
`/rest/v1/targets?tenant_id=eq.${tid}&month=eq.${currentMonth}&select=revenue_target,clients_target,leads_target,spend_target,spend_actual`
);
if (t.data?.[0]) {
targetData = {
...targetData,
revenue_target: t.data[0].revenue_target,
clients_target: t.data[0].clients_target,
leads_target: t.data[0].leads_target,
};
targetData = { ...targetData, ...t.data[0] };
}
const { data, error } = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
@@ -68,6 +115,17 @@ export default async function EstrategiaPage() {
if (!error && Array.isArray(data) && data[0]) {
targetData = { ...targetData, ...data[0] };
}
// Actual spend from ad_daily this month
const d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${monthPrefix}-01&date=lte.${monthPrefix}-31&select=spend,date`
);
const spendSum = (d.data || []).reduce(
(a, r) => a + Number(r.spend || 0),
0
);
if (spendSum > 0 || !targetData.spend_actual) {
targetData.spend_actual = spendSum;
}
} else {
const { data, error } = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
@@ -83,16 +141,42 @@ export default async function EstrategiaPage() {
} else if (Array.isArray(data) && data[0]) {
targetData = { ...targetData, ...data[0] };
}
const { data: trow } = await supabase
.from('targets')
.select(
'revenue_target,clients_target,leads_target,spend_target,spend_actual'
)
.eq('month', currentMonth)
.maybeSingle();
if (trow) targetData = { ...targetData, ...trow };
const { data: daily } = await supabase
.from('ad_daily')
.select('spend')
.gte('date', `${monthPrefix}-01`)
.lte('date', `${monthPrefix}-31`);
const spendSum = (daily || []).reduce(
(a, r) => a + Number((r as { spend?: number }).spend || 0),
0
);
if (spendSum > 0 || !targetData.spend_actual) {
targetData.spend_actual = spendSum;
}
}
} else if (ctx.portfolioWide) {
sourceNote =
'Vista global: selecciona un cliente en el selector de contexto para ver sus objetivos del mes.';
'Vista global: selecciona un cliente en Contexto para ver ppto y consecución del mes.';
}
const revenuePct = Math.min(
100,
Math.round(Number(targetData.revenue_achievement_pct) || 0)
);
const spendTarget = Number(targetData.spend_target || 0);
const spendActual = Number(targetData.spend_actual || 0);
const leadsTarget = Number(targetData.leads_target || 0);
const leadsActual = Number(targetData.leads_actual || 0);
const clientsTarget = Number(targetData.clients_target || 0);
const clientsActual = Number(targetData.clients_actual || 0);
const revenueTarget = Number(targetData.revenue_target || 0);
const revenueActual = Number(targetData.revenue_actual || 0);
return (
<div className="space-y-8">
@@ -106,34 +190,97 @@ export default async function EstrategiaPage() {
{ctx.portfolioWide ? (
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
Elige un cliente arriba (Contexto) para ver facturación / leads /
clientes objetivo vs real de ese tenant.
Elige un cliente arriba (Contexto) para ver presupuesto y consecución
de objetivos de ese tenant.
</p>
) : (
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
<div className="flex items-center gap-2 text-brand-400 mb-4">
<Target className="w-4 h-4" />
<span className="text-sm font-semibold">
Facturación {revenuePct}%
</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-brand-500"
style={{ width: `${revenuePct}%` }}
<div className="grid gap-4 lg:grid-cols-2">
{/* Presupuesto */}
<section className="bg-[#111827] border border-gray-800 rounded-xl p-6 space-y-5">
<div className="flex items-center gap-2 text-amber-300">
<Wallet className="w-4 h-4" />
<h3 className="text-sm font-bold uppercase tracking-wider">
Objetivos de presupuesto (ppto)
</h3>
</div>
<p className="text-xs text-gray-500">
Inversión ads planificada vs gasto real del mes (ad_daily).
</p>
{spendTarget > 0 ? (
<ProgressBar
label="Presupuesto ads"
actual={spendActual}
target={spendTarget}
unit=" €"
tone="amber"
/>
) : (
<div className="rounded-lg border border-dashed border-gray-700 px-3 py-4 text-xs text-gray-500">
Sin <code className="text-gray-400">spend_target</code> para este
mes en <code className="text-gray-400">targets</code>. Gasto
actual: {spendActual.toLocaleString('es-ES')} €.
</div>
)}
<div className="grid grid-cols-2 gap-3 pt-1">
<div className="rounded-lg bg-black/25 border border-gray-800 px-3 py-2">
<p className="text-[10px] uppercase text-gray-500">Ppto</p>
<p className="text-lg font-bold text-white tabular-nums">
{spendTarget.toLocaleString('es-ES')} €
</p>
</div>
<div className="rounded-lg bg-black/25 border border-gray-800 px-3 py-2">
<p className="text-[10px] uppercase text-gray-500">Gastado</p>
<p className="text-lg font-bold text-white tabular-nums">
{spendActual.toLocaleString('es-ES')} €
</p>
</div>
</div>
</section>
{/* Consecución */}
<section className="bg-[#111827] border border-gray-800 rounded-xl p-6 space-y-5">
<div className="flex items-center gap-2 text-emerald-300">
<Trophy className="w-4 h-4" />
<h3 className="text-sm font-bold uppercase tracking-wider">
Objetivos de consecución
</h3>
</div>
<p className="text-xs text-gray-500">
Metas de negocio del mes vs resultado real (RPC targets_vs_actual).
</p>
<ProgressBar
label="Leads"
actual={leadsActual}
target={leadsTarget}
tone="sky"
/>
</div>
<p className="text-xs text-gray-500 mt-3">
{Number(targetData.revenue_actual || 0).toLocaleString('es-ES')} € /{' '}
{Number(targetData.revenue_target || 0).toLocaleString('es-ES')} € ·
leads {targetData.leads_actual || 0}/{targetData.leads_target || 0} ·
clientes {targetData.clients_actual || 0}/
{targetData.clients_target || 0}
</p>
<ProgressBar
label="Clientes"
actual={clientsActual}
target={clientsTarget}
tone="emerald"
/>
<ProgressBar
label="Facturación"
actual={revenueActual}
target={revenueTarget}
unit=" €"
tone="brand"
/>
<div className="flex items-center gap-2 text-[11px] text-gray-500 pt-1">
<Target className="w-3.5 h-3.5 text-brand-400" />
Cumplimiento facturación:{' '}
{pct(revenueActual, revenueTarget) ||
Math.round(Number(targetData.revenue_achievement_pct) || 0)}
%
</div>
</section>
</div>
)}
<BudgetSimulator />
{!ctx.portfolioWide ? (
<BudgetSimulator tenantLabel={ctx.label} />
) : null}
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
buildComparisons,
sumDaily,
sumWeekly,
weeklyFromDaily,
} from '@/lib/metrics';
import { isAgencyRole, userRole } from '@/lib/agency';
@@ -35,7 +36,7 @@ export default async function KpisPage() {
'/rest/v1/kpi_weekly?select=*&order=week.desc'
);
const d = await restGet<AdDailyRow[]>(
'/rest/v1/ad_daily?select=*&order=date.desc&limit=500'
'/rest/v1/ad_daily?select=*&order=date.desc&limit=5000'
);
weekly = w.data || [];
daily = d.data || [];
@@ -45,7 +46,7 @@ export default async function KpisPage() {
`/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`
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=5000`
);
weekly = w.data || [];
daily = d.data || [];
@@ -59,11 +60,19 @@ export default async function KpisPage() {
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(500);
.limit(5000);
weekly = (w as KpiWeeklyRow[]) || [];
daily = (d as AdDailyRow[]) || [];
}
// kpi_weekly is often a stale MV — prefer ad_daily when it has more signal
const fromDaily = weeklyFromDaily(daily);
const weeklySpend = weekly.reduce((a, r) => a + Number(r.total_spend || 0), 0);
const dailySpend = daily.reduce((a, r) => a + Number(r.spend || 0), 0);
if (fromDaily.length && dailySpend >= weeklySpend) {
weekly = fromDaily;
}
const comparisons = buildComparisons(weekly);
const month =
comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0

View File

@@ -169,7 +169,7 @@ export default async function DashboardLayout({
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2.5 py-1 rounded-full">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
Pipeline Windsor + n8n Activo
Sync Meta Ads · warehouse
</div>
</div>
</header>