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