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:
@@ -27,6 +27,7 @@ export default function AdsDiscoverPanel() {
|
||||
const [gadsOk, setGadsOk] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
const [metaToken, setMetaToken] = useState('');
|
||||
const [meta, setMeta] = useState<ProviderResult | null>(null);
|
||||
@@ -100,6 +101,41 @@ export default function AdsDiscoverPanel() {
|
||||
setRunning(false);
|
||||
}
|
||||
|
||||
async function syncMetrics() {
|
||||
setSyncing(true);
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
const res = await fetch('/api/admin/ads/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ days: 30 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Sync falló');
|
||||
} else {
|
||||
const parts = (data.results || [])
|
||||
.filter(
|
||||
(r: { metaRows?: number; googleRows?: number }) =>
|
||||
(r.metaRows || 0) + (r.googleRows || 0) > 0
|
||||
)
|
||||
.map(
|
||||
(r: {
|
||||
tenantName: string;
|
||||
metaRows: number;
|
||||
googleRows: number;
|
||||
}) =>
|
||||
`${r.tenantName}: M${r.metaRows}/G${r.googleRows}`
|
||||
);
|
||||
setMsg(
|
||||
`Métricas sincronizadas (${data.synced || 0} clientes, ${data.days || 30}d)` +
|
||||
(parts.length ? ` · ${parts.slice(0, 6).join(' · ')}` : '') +
|
||||
(data.withErrors ? ` · ${data.withErrors} con avisos` : '')
|
||||
);
|
||||
}
|
||||
setSyncing(false);
|
||||
}
|
||||
|
||||
async function assign(
|
||||
provider: 'meta_ads' | 'google_ads',
|
||||
tenantId: string,
|
||||
@@ -150,6 +186,19 @@ export default function AdsDiscoverPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void syncMetrics()}
|
||||
disabled={syncing || (!metaOk && !gadsOk)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-emerald-700/60 text-emerald-200 hover:border-emerald-500 disabled:opacity-40"
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Sincronizar métricas
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void discover(['google_ads'])}
|
||||
|
||||
@@ -3,33 +3,82 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react';
|
||||
|
||||
export default function BudgetSimulator() {
|
||||
type SimPayload = {
|
||||
tenant_label?: string;
|
||||
hint?: string;
|
||||
target_channel?: string;
|
||||
baseline?: {
|
||||
historical_cpl?: number;
|
||||
conversion_rate_pct?: number;
|
||||
avg_ticket?: number;
|
||||
sample_spend?: number;
|
||||
sample_leads?: number;
|
||||
sample_deals?: number;
|
||||
source?: string;
|
||||
};
|
||||
conservative_p10?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
expected_p50?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
optimistic_p90?: {
|
||||
revenue?: number;
|
||||
roas?: number;
|
||||
leads?: number;
|
||||
deals?: number;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const CHANNELS = [
|
||||
{ id: 'meta', label: 'Meta' },
|
||||
{ id: 'google', label: 'Google' },
|
||||
{ id: 'blended', label: 'Blended' },
|
||||
] as const;
|
||||
|
||||
export default function BudgetSimulator({
|
||||
tenantLabel,
|
||||
}: {
|
||||
tenantLabel?: string;
|
||||
}) {
|
||||
const [spend, setSpend] = useState<number>(1000);
|
||||
const [channel, setChannel] = useState<string>('meta');
|
||||
const [simulation, setSimulation] = useState<any>(null);
|
||||
const [channel, setChannel] = useState<string>('blended');
|
||||
const [simulation, setSimulation] = useState<SimPayload | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchSimulation(spend, channel);
|
||||
void fetchSimulation(spend, channel);
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [spend, channel]);
|
||||
|
||||
async function fetchSimulation(amount: number, ch: string) {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/simulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ spend: amount, channel: ch })
|
||||
body: JSON.stringify({ spend: amount, channel: ch }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSimulation(data);
|
||||
const data = (await res.json()) as SimPayload;
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Error al simular');
|
||||
setSimulation(null);
|
||||
return;
|
||||
}
|
||||
setSimulation(data);
|
||||
} catch (e) {
|
||||
console.error('Error simulating budget:', e);
|
||||
setErr(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -46,37 +95,48 @@ export default function BudgetSimulator() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="w-5 h-5 text-brand-500" />
|
||||
<h3 className="text-lg font-bold text-white">Simulador Determinista "¿Qué pasa si...?"</h3>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
Simulador «¿Qué pasa si…?»
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Proyección matemática basada en el histórico de CPL, tasa de cierre y ticket medio en Supabase.
|
||||
<p className="text-xs text-gray-400 mt-1 max-w-xl">
|
||||
Proyección con CPL, tasa de cierre y ticket medio de{' '}
|
||||
<strong className="text-gray-300">
|
||||
{simulation?.tenant_label || tenantLabel || 'este cliente'}
|
||||
</strong>
|
||||
. Sin TikTok (aún sin campañas activas).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{['meta', 'google', 'tiktok', 'blended'].map((ch) => (
|
||||
{CHANNELS.map((ch) => (
|
||||
<button
|
||||
key={ch}
|
||||
onClick={() => setChannel(ch)}
|
||||
key={ch.id}
|
||||
type="button"
|
||||
onClick={() => setChannel(ch.id)}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-semibold uppercase transition-all ${
|
||||
channel === ch
|
||||
channel === ch.id
|
||||
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/20'
|
||||
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{ch}
|
||||
{ch.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control del presupuesto */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs font-medium text-gray-300">
|
||||
Inversión Adicional Proyectada
|
||||
Inversión adicional proyectada
|
||||
</label>
|
||||
<span className="text-lg font-extrabold text-brand-400">
|
||||
{spend.toLocaleString('es-ES')} €
|
||||
{loading ? (
|
||||
<span className="text-[10px] text-gray-500 ml-2 font-normal">
|
||||
…
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
@@ -96,31 +156,51 @@ export default function BudgetSimulator() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baseline Info */}
|
||||
{err ? (
|
||||
<p className="text-xs text-red-400 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{err}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 bg-gray-900/60 p-3 rounded-lg border border-gray-800/80 text-xs text-gray-300">
|
||||
<span className="text-gray-400">Parámetros Base Reales:</span>
|
||||
<span className="text-gray-400">Baseline del cliente:</span>
|
||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
||||
CPL Histórico: <strong>{Number(baseline.historical_cpl || 0).toFixed(2)} €</strong>
|
||||
CPL:{' '}
|
||||
<strong>{Number(baseline.historical_cpl || 0).toFixed(2)} €</strong>
|
||||
</span>
|
||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
||||
Tasa Cierre: <strong>{baseline.conversion_rate_pct || 0}%</strong>
|
||||
Cierre:{' '}
|
||||
<strong>{baseline.conversion_rate_pct || 0}%</strong>
|
||||
</span>
|
||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-200">
|
||||
Ticket Medio: <strong>{Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €</strong>
|
||||
Ticket:{' '}
|
||||
<strong>
|
||||
{Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} €
|
||||
</strong>
|
||||
</span>
|
||||
{baseline.sample_spend != null ? (
|
||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-400">
|
||||
Muestra: {Number(baseline.sample_spend).toLocaleString('es-ES')} € ·{' '}
|
||||
{baseline.sample_leads || 0} leads
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Las 3 Bandas de Confianza */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
{/* Conservador p10 */}
|
||||
<div className="bg-[#0B0F19] border border-gray-800/80 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-400">Conservador (p10)</span>
|
||||
<span className="text-xs font-semibold text-gray-400">
|
||||
Conservador (p10)
|
||||
</span>
|
||||
<ShieldCheck className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-white">
|
||||
+{Number(p10.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
||||
+
|
||||
{Number(p10.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
||||
ROAS {p10.roas || 0}x
|
||||
@@ -128,28 +208,33 @@ export default function BudgetSimulator() {
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-800/60 text-xs text-gray-400 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads estimados:</span>
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-200">{p10.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas cerradas:</span>
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-200">{p10.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Esperado p50 (Destacado) */}
|
||||
<div className="bg-gradient-to-b from-brand-950/40 to-[#0B0F19] border-2 border-brand-500/60 rounded-xl p-4 flex flex-col justify-between relative shadow-lg shadow-brand-900/20">
|
||||
<div className="absolute -top-2.5 right-3 bg-brand-600 text-white text-[9px] uppercase tracking-wider font-bold px-2 py-0.5 rounded-full">
|
||||
Más Probable
|
||||
Más probable
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-brand-300">Esperado (p50)</span>
|
||||
<span className="text-xs font-semibold text-brand-300">
|
||||
Esperado (p50)
|
||||
</span>
|
||||
<Sparkles className="w-4 h-4 text-brand-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-white">
|
||||
+{Number(p50.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
||||
+
|
||||
{Number(p50.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-brand-400 font-semibold mt-1">
|
||||
ROAS {p50.roas || 0}x
|
||||
@@ -157,25 +242,30 @@ export default function BudgetSimulator() {
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-brand-800/40 text-xs text-gray-300 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads estimados:</span>
|
||||
<span>Leads:</span>
|
||||
<strong className="text-white">{p50.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas cerradas:</span>
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-white">{p50.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optimista p90 */}
|
||||
<div className="bg-[#0B0F19] border border-gray-800/80 rounded-xl p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-gray-400">Optimista (p90)</span>
|
||||
<span className="text-xs font-semibold text-gray-400">
|
||||
Optimista (p90)
|
||||
</span>
|
||||
<TrendingUp className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-extrabold text-white">
|
||||
+{Number(p90.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
|
||||
+
|
||||
{Number(p90.revenue || 0).toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 2,
|
||||
})}{' '}
|
||||
€
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400 font-semibold mt-1">
|
||||
ROAS {p90.roas || 0}x
|
||||
@@ -183,11 +273,11 @@ export default function BudgetSimulator() {
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-800/60 text-xs text-gray-400 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Leads estimados:</span>
|
||||
<span>Leads:</span>
|
||||
<strong className="text-gray-200">{p90.leads || 0}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Ventas cerradas:</span>
|
||||
<span>Ventas:</span>
|
||||
<strong className="text-gray-200">{p90.deals || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user