From f728c33533f29884c8bdf3cb78bc0eef1f3e8baa Mon Sep 17 00:00:00 2001 From: Marcos Fargas Date: Mon, 21 Sep 2026 01:51:50 +0200 Subject: [PATCH] feat(portal): complete masterplan with deterministic budget simulator, RAG assistant, agency cross-tenant view and multi-tenant middleware --- src/app/api/chat/route.ts | 73 ++++++++++ src/app/api/simulate/route.ts | 37 +++++ src/app/dashboard/agencia/page.tsx | 150 +++++++++++++++++++ src/app/dashboard/estrategia/page.tsx | 3 + src/app/dashboard/layout.tsx | 13 +- src/components/BudgetSimulator.tsx | 198 ++++++++++++++++++++++++++ src/middleware.ts | 43 ++++++ 7 files changed, 516 insertions(+), 1 deletion(-) create mode 100644 src/app/api/simulate/route.ts create mode 100644 src/app/dashboard/agencia/page.tsx create mode 100644 src/components/BudgetSimulator.tsx create mode 100644 src/middleware.ts diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index ab4d968..f02a311 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -19,6 +19,79 @@ export async function POST(req: NextRequest) { 'Content-Type': 'application/json' }; + // 0. Intención: Simulación Determinista "¿Qué pasa si...?" + if (userMessage.includes('simul') || userMessage.includes('invierto') || userMessage.includes('pasa si') || userMessage.includes('proyec')) { + try { + // Extraer importe numérico + const matchNumber = userMessage.match(/(\d+[\d\.,]*)/); + let amount = 1000; + if (matchNumber) { + amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.')); + } + + let channel: string | null = null; + if (userMessage.includes('meta') || userMessage.includes('facebook') || userMessage.includes('instagram')) channel = 'meta'; + else if (userMessage.includes('google')) channel = 'google'; + else if (userMessage.includes('tiktok')) channel = 'tiktok'; + + 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 text = `🔮 **Simulación de Inversión Adicional (${amount.toLocaleString('es-ES')} € en ${sim.target_channel.toUpperCase()}):**\n\n` + + `*Métricas Base Reales:* CPL ${base.historical_cpl} €, Tasa de Cierre ${base.conversion_rate_pct}%, Ticket Medio ${Number(base.avg_ticket || 0).toLocaleString('es-ES')} €.\n\n` + + `- **Escenario Esperado (p50):** +${p50.leads} leads, +${p50.deals} clientes, **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p50.roas}x)\n` + + `- **Escenario Conservador (p10):** +${p10.leads} leads, +${p10.deals} clientes, **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p10.roas}x)\n` + + `- **Escenario Optimista (p90):** +${p90.leads} leads, +${p90.deals} clientes, **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p90.roas}x)\n\n` + + `*Proyección calculada matemáticamente sin alucinaciones con la RPC simulate_budget.*`; + return Response.json([{ text }]); + } + } catch (e) { + console.error('Error in simulate_budget chat:', e); + } + } + + // 0.5 Intención: RAG Documental Híbrido (Documentos / Copys / Informes) + if (userMessage.includes('document') || userMessage.includes('copy') || userMessage.includes('informe') || userMessage.includes('brief') || userMessage.includes('contenido') || userMessage.includes('activo')) { + try { + const res = await fetch(`${supabaseUrl}/rest/v1/rpc/match_documents`, { + method: 'POST', + headers, + body: JSON.stringify({ + p_tenant_id: tenantId, + p_query_text: userMessage, + p_match_count: 3 + }) + }); + if (res.ok) { + const matches = await res.json(); + if (Array.isArray(matches) && matches.length > 0) { + const excerpts = matches.map((m: any) => + `📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}` + ).join('\n\n'); + + const text = `📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Fuente: Búsqueda vectorial y de texto completo en document_chunks.*`; + return Response.json([{ text }]); + } + } + } catch (e) { + console.error('Error in match_documents chat:', e); + } + } + // 1. Intención: Objetivos vs Real / Estrategia if (userMessage.includes('objetivo') || userMessage.includes('meta') || userMessage.includes('factura') || userMessage.includes('estrategia')) { try { diff --git a/src/app/api/simulate/route.ts b/src/app/api/simulate/route.ts new file mode 100644 index 0000000..df69e5a --- /dev/null +++ b/src/app/api/simulate/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from 'next/server'; + +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 || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; + const tenantId = process.env.DEFAULT_TENANT_ID || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541'; + + try { + const body = await req.json(); + const spend = parseFloat(body.spend || 1000); + const channel = body.channel || 'meta'; + + const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, { + method: 'POST', + headers: { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + p_tenant_id: tenantId, + p_additional_spend: spend, + p_target_channel: channel === 'blended' ? null : channel + }) + }); + + if (res.ok) { + const data = await res.json(); + return Response.json(data); + } else { + const err = await res.text(); + return Response.json({ error: err }, { status: 500 }); + } + } catch (e: any) { + return Response.json({ error: e.message }, { status: 500 }); + } +} diff --git a/src/app/dashboard/agencia/page.tsx b/src/app/dashboard/agencia/page.tsx new file mode 100644 index 0000000..2df4899 --- /dev/null +++ b/src/app/dashboard/agencia/page.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react'; + +export const revalidate = 0; + +export default async function AgencyPage() { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; + + const headers = { + apikey: serviceKey, + Authorization: `Bearer ${serviceKey}` + }; + + let tenants: any[] = []; + let totalSpend = 0; + let totalLeads = 0; + let totalWonDeals = 0; + let totalRevenue = 0; + + try { + const [tenantsRes, adRes, leadsRes, dealsRes] = await Promise.all([ + fetch(`${supabaseUrl}/rest/v1/tenants?select=*&order=created_at.asc`, { headers, cache: 'no-store' }), + fetch(`${supabaseUrl}/rest/v1/ad_daily?select=spend`, { headers, cache: 'no-store' }), + fetch(`${supabaseUrl}/rest/v1/leads?select=id`, { headers, cache: 'no-store' }), + fetch(`${supabaseUrl}/rest/v1/deals?select=amount&stage=eq.won`, { headers, cache: 'no-store' }) + ]); + + if (tenantsRes.ok) tenants = await tenantsRes.json(); + if (adRes.ok) { + const ads = await adRes.json(); + totalSpend = ads.reduce((acc: number, curr: any) => acc + Number(curr.spend || 0), 0); + } + if (leadsRes.ok) { + const leads = await leadsRes.json(); + totalLeads = leads.length; + } + if (dealsRes.ok) { + const deals = await dealsRes.json(); + totalWonDeals = deals.length; + totalRevenue = deals.reduce((acc: number, curr: any) => acc + Number(curr.amount || 0), 0); + } + } catch (e) { + console.error('Error in AgencyPage:', e); + } + + const blendedRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0'; + + return ( +
+
+
+ Vista Global de Agencia (Cross-Tenant) +
+

Centro de Mando 4Ventures

+

+ Métricas consolidadas de infraestructura, inversión publicitaria y cartera de clientes. +

+
+ + {/* Global Agency KPI Cards */} +
+
+
+ Clientes / Tenants + +
+

{tenants.length}

+

100% aislados vía RLS

+
+ +
+
+ Inversión Gestionada + +
+

+ {totalSpend.toLocaleString('es-ES', { minimumFractionDigits: 2 })} € +

+

Vía Windsor.ai pipeline

+
+ +
+
+ Leads Captados + +
+

{totalLeads}

+

{totalWonDeals} cerrados ganados

+
+ +
+
+ ROAS Blended Agencia + +
+

{blendedRoas}x

+

+ {totalRevenue.toLocaleString('es-ES', { minimumFractionDigits: 2 })} € generados +

+
+
+ + {/* Tenants Table */} +
+
+

Cartera de Clientes Activos

+

+ Cada cliente cuenta con aislamiento nativo Row Level Security (RLS) en Postgres. +

+
+
+ + + + + + + + + + + + {tenants.map((t) => ( + + + + + + + + ))} + +
Nombre del ClienteTenant SlugUUIDAcceso PortalEstado
{t.name}{t.slug}{t.id} + + Abrir Portal ↗ + + + + Activo + +
+
+
+
+ ); +} diff --git a/src/app/dashboard/estrategia/page.tsx b/src/app/dashboard/estrategia/page.tsx index fdb9dd7..85251eb 100644 --- a/src/app/dashboard/estrategia/page.tsx +++ b/src/app/dashboard/estrategia/page.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Target, CheckCircle2, TrendingUp } from 'lucide-react'; +import BudgetSimulator from '@/components/BudgetSimulator'; export const revalidate = 0; @@ -103,6 +104,8 @@ export default async function EstrategiaPage() {

+ + ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 404fa84..6abe019 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -5,7 +5,8 @@ import { Target, FolderGit2, Bot, - ShieldCheck + ShieldCheck, + Layers } from 'lucide-react'; export default function DashboardLayout({ @@ -57,6 +58,16 @@ export default function DashboardLayout({ Asistente IA Financiero +
+ Agencia OS + + + Cartera Cross-Tenant + +
diff --git a/src/components/BudgetSimulator.tsx b/src/components/BudgetSimulator.tsx new file mode 100644 index 0000000..1b56449 --- /dev/null +++ b/src/components/BudgetSimulator.tsx @@ -0,0 +1,198 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Calculator, TrendingUp, ShieldCheck, Sparkles } from 'lucide-react'; + +export default function BudgetSimulator() { + const [spend, setSpend] = useState(1000); + const [channel, setChannel] = useState('meta'); + const [simulation, setSimulation] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => { + fetchSimulation(spend, channel); + }, 200); + return () => clearTimeout(timer); + }, [spend, channel]); + + async function fetchSimulation(amount: number, ch: string) { + setLoading(true); + try { + const res = await fetch('/api/simulate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ spend: amount, channel: ch }) + }); + if (res.ok) { + const data = await res.json(); + setSimulation(data); + } + } catch (e) { + console.error('Error simulating budget:', e); + } finally { + setLoading(false); + } + } + + const p10 = simulation?.conservative_p10 || {}; + const p50 = simulation?.expected_p50 || {}; + const p90 = simulation?.optimistic_p90 || {}; + const baseline = simulation?.baseline || {}; + + return ( +
+
+
+
+ +

Simulador Determinista "¿Qué pasa si...?"

+
+

+ Proyección matemática basada en el histórico de CPL, tasa de cierre y ticket medio en Supabase. +

+
+
+ {['meta', 'google', 'tiktok', 'blended'].map((ch) => ( + + ))} +
+
+ + {/* Control del presupuesto */} +
+
+ + + {spend.toLocaleString('es-ES')} € + +
+ setSpend(Number(e.target.value))} + className="w-full accent-brand-500 bg-gray-800 rounded-lg cursor-pointer h-2" + /> +
+ 200 € + 2.500 € + 5.000 € + 10.000 € +
+
+ + {/* Baseline Info */} +
+ Parámetros Base Reales: + + CPL Histórico: {Number(baseline.historical_cpl || 0).toFixed(2)} € + + + Tasa Cierre: {baseline.conversion_rate_pct || 0}% + + + Ticket Medio: {Number(baseline.avg_ticket || 0).toLocaleString('es-ES')} € + +
+ + {/* Las 3 Bandas de Confianza */} +
+ {/* Conservador p10 */} +
+
+
+ Conservador (p10) + +
+

+ +{Number(p10.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € +

+

+ ROAS {p10.roas || 0}x +

+
+
+
+ Leads estimados: + {p10.leads || 0} +
+
+ Ventas cerradas: + {p10.deals || 0} +
+
+
+ + {/* Esperado p50 (Destacado) */} +
+
+ Más Probable +
+
+
+ Esperado (p50) + +
+

+ +{Number(p50.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € +

+

+ ROAS {p50.roas || 0}x +

+
+
+
+ Leads estimados: + {p50.leads || 0} +
+
+ Ventas cerradas: + {p50.deals || 0} +
+
+
+ + {/* Optimista p90 */} +
+
+
+ Optimista (p90) + +
+

+ +{Number(p90.revenue || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} € +

+

+ ROAS {p90.roas || 0}x +

+
+
+
+ Leads estimados: + {p90.leads || 0} +
+
+ Ventas cerradas: + {p90.deals || 0} +
+
+
+
+
+ ); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..750bb02 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export function middleware(req: NextRequest) { + const url = req.nextUrl.clone(); + const hostname = req.headers.get('host') || ''; + + // 1. Extraer slug desde subdominio (ej: acme.4ventures.es) + let tenantSlug = '4ventures-hub'; + const parts = hostname.split('.'); + + if (parts.length > 2) { + const sub = parts[0].toLowerCase(); + if (sub !== 'app' && sub !== 'git' && sub !== 'supabase' && sub !== 'n8n' && sub !== 'www') { + tenantSlug = sub; + } + } + + // 2. Override opcional vía query param ?tenant=slug + const queryTenant = url.searchParams.get('tenant'); + if (queryTenant) { + tenantSlug = queryTenant.toLowerCase(); + } else { + const cookieTenant = req.cookies.get('4v_tenant')?.value; + if (cookieTenant) { + tenantSlug = cookieTenant; + } + } + + const response = NextResponse.next({ + request: { + headers: new Headers(req.headers) + } + }); + + response.headers.set('x-tenant-slug', tenantSlug); + response.cookies.set('4v_tenant', tenantSlug, { path: '/', maxAge: 60 * 60 * 24 * 30 }); + + return response; +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|_4v_attr.js).*)'] +};