fix(security): harden auth, tenant scoping, and scrub secrets

Require session on APIs, scope KPIs/activos/simulate to the user tenant, strip real JWTs from .env.example, and add health/smoke/docs for 4Ventures ops.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-21 10:51:46 +02:00
parent e7b6ab71ca
commit 2df3fe6983
21 changed files with 621 additions and 276 deletions

View File

@@ -3,9 +3,26 @@ import { createClient } from '@/lib/supabase/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 || '';
// Prefer user JWT for tenant-scoped reads; service role only if explicitly set (server)
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
// 1. Extraer mensaje del usuario con compatibilidad para cualquier estructura de body
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
const companyName = user.user_metadata?.company || 'Cliente';
if (!tenantId) {
return NextResponse.json(
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
{ status: 403 }
);
}
// 1. Extraer mensaje del usuario
let userMessage = '';
try {
const body = await req.json();
@@ -23,7 +40,6 @@ export async function POST(req: NextRequest) {
console.error('Error parsing chat body:', e);
}
// Normalizar consulta eliminando tildes, signos de interrogación y puntuación
const normQuery = userMessage
.toLowerCase()
.normalize('NFD')
@@ -32,24 +48,11 @@ export async function POST(req: NextRequest) {
.replace(/\s+/g, ' ')
.trim();
// 2. Obtener tenant_id dinámico del usuario autenticado
let tenantId = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
let companyName = '4Ventures Central';
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (user) {
tenantId = user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || tenantId;
companyName = user.user_metadata?.company || companyName;
}
} catch (e) {
console.error('Error resolving user tenant in chat:', e);
}
// Use service role for RPC only after authn; still filter by tenant_id in every call
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
const headers = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
apikey: keyForRpc,
Authorization: `Bearer ${keyForRpc}`,
'Content-Type': 'application/json'
};

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
/** Liveness for Dokploy / uptime — no secrets. */
export async function GET() {
const hasAnon = Boolean(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim());
const hasUrl = Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL?.trim());
const hasService = Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY?.trim());
const ok = hasAnon && hasUrl;
return NextResponse.json(
{
ok,
service: '4ventures-client-portal',
supabaseConfigured: hasUrl && hasAnon,
serviceRoleConfigured: hasService,
ts: new Date().toISOString(),
},
{ status: ok ? 200 : 503 }
);
}

View File

@@ -1,9 +1,28 @@
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/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';
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
if (!tenantId) {
return NextResponse.json(
{ error: 'Usuario sin tenant_id' },
{ status: 403 }
);
}
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
try {
const body = await req.json();
@@ -13,25 +32,25 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
method: 'POST',
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
'Content-Type': 'application/json'
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
})
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 });
return NextResponse.json(data);
}
} catch (e: any) {
return Response.json({ error: e.message }, { status: 500 });
const err = await res.text();
return NextResponse.json({ error: err }, { status: 500 });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Error';
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -1,34 +1,48 @@
import React from 'react';
import { FileText, CheckCircle2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
export const revalidate = 0;
export default async function ActivosPage() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
let documents: any[] = [];
try {
const res = await fetch(`${supabaseUrl}/rest/v1/documents?select=*&order=created_at.desc`, {
headers: {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`
},
cache: 'no-store'
});
if (res.ok) {
documents = await res.json();
let documents: Array<{
id: string;
type?: string;
title?: string;
content?: string;
version?: number;
created_at?: string;
status?: string;
}> = [];
if (user) {
// Relies on RLS: only documents for the caller's tenant
const { data, error } = await supabase
.from('documents')
.select('id,type,title,content,version,created_at,status')
.eq('status', 'approved')
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching documents:', error.message);
} else {
documents = data ?? [];
}
} catch (e) {
console.error('Error fetching documents:', e);
}
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">Biblioteca de Activos & Entregables</h2>
<h2 className="text-2xl font-bold tracking-tight text-white">
Biblioteca de Activos & Entregables
</h2>
<p className="text-sm text-gray-400 mt-1">
Documentos, copys, calendarios y briefs producidos por los agentes de IA y aprobados por el equipo en Slack.
Documentos, copys, calendarios y briefs producidos por los agentes de IA
y aprobados por el equipo en Slack. Filtrado por tenant (RLS).
</p>
</div>
@@ -36,14 +50,20 @@ export default async function ActivosPage() {
{documents.length === 0 ? (
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-base font-medium text-gray-300">No hay activos aprobados todavía.</p>
<p className="text-base font-medium text-gray-300">
No hay activos aprobados todavía.
</p>
<p className="text-xs text-gray-400 mt-1">
Los activos generados por los agentes aparecerán aquí tras ser validados en Slack.
Los activos generados por los agentes aparecerán aquí tras ser
validados en Slack.
</p>
</div>
) : (
documents.map((doc) => (
<div key={doc.id} className="bg-[#111827] border border-gray-800 rounded-xl p-5 flex flex-col justify-between">
<div
key={doc.id}
className="bg-[#111827] border border-gray-800 rounded-xl p-5 flex flex-col justify-between"
>
<div>
<div className="flex items-center justify-between mb-3">
<span className="uppercase text-[10px] font-bold px-2 py-0.5 rounded bg-gray-800 text-gray-300">
@@ -58,7 +78,11 @@ export default async function ActivosPage() {
</div>
<div className="pt-4 border-t border-gray-800/80 mt-4 text-[11px] text-gray-400 flex justify-between">
<span>Versión v{doc.version}</span>
<span>{new Date(doc.created_at).toLocaleDateString('es-ES')}</span>
<span>
{doc.created_at
? new Date(doc.created_at).toLocaleDateString('es-ES')
: '—'}
</span>
</div>
</div>
))

View File

@@ -1,16 +1,37 @@
import React from 'react';
import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react';
import ClientManagement from '@/components/ClientManagement';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export const revalidate = 0;
export default async function AgencyPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const role = user.app_metadata?.role || user.user_metadata?.role || 'client';
if (role !== 'admin' && role !== 'team') {
redirect('/dashboard/kpis');
}
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 || '';
// Cross-tenant agency view requires service role — only after role gate above
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!serviceKey) {
return (
<div className="text-amber-400 text-sm">
Falta SUPABASE_SERVICE_ROLE_KEY en el servidor para la vista agencia.
</div>
);
}
const headers = {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`
Authorization: `Bearer ${serviceKey}`,
};
let tenants: any[] = [];

View File

@@ -9,23 +9,25 @@ export default async function CalendarioPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const tenantId = user?.app_metadata?.tenant_id || user?.user_metadata?.tenant_id || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
const tenantId = user?.app_metadata?.tenant_id || user?.user_metadata?.tenant_id || null;
const companyName = user?.user_metadata?.company || '4Ventures';
let calendarDocs: any[] = [];
try {
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('type', 'calendar')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false });
if (tenantId) {
try {
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('type', 'calendar')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false });
if (!error && data) {
calendarDocs = data;
if (!error && data) {
calendarDocs = data;
}
} catch (e) {
console.error('Error fetching calendar documents:', e);
}
} catch (e) {
console.error('Error fetching calendar documents:', e);
}
// Parse posts from calendar document

View File

@@ -0,0 +1,33 @@
/**
* Stub: workflow de creatividades multi-tenant (upload, review, storage).
*/
import React from 'react';
import { Image as ImageIcon, Upload } from 'lucide-react';
export const revalidate = 0;
export default function CreativesPage() {
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Creatividades
</h2>
<p className="text-sm text-gray-400 mt-1">
Revisión cliente/agencia · roadmap 4Ventures Centro de Mando.
</p>
</div>
<div className="bg-[#111827] border border-dashed border-gray-700 rounded-xl p-12 text-center">
<ImageIcon className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-300 font-medium">Próximo sprint</p>
<p className="text-xs text-gray-500 mt-2 max-w-md mx-auto">
Upload (JPEG/PNG/WebP/MP4/PDF · máx 25MB), estados draft → in_review →
approved/rejected, comentarios y storage por tenant (Supabase Storage).
</p>
<div className="mt-6 inline-flex items-center gap-2 text-amber-400/80 text-xs">
<Upload className="w-3.5 h-3.5" /> docs/CONVERGENCE.md
</div>
</div>
</div>
);
}

View File

@@ -1,108 +1,92 @@
import React from 'react';
import { Target, CheckCircle2, TrendingUp } from 'lucide-react';
import { Target } from 'lucide-react';
import BudgetSimulator from '@/components/BudgetSimulator';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export const revalidate = 0;
export default async function EstrategiaPage() {
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 supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
const now = new Date();
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
let targetData: any = {
revenue_target: 10000,
let targetData: {
revenue_target: number;
revenue_actual: number;
revenue_achievement_pct: number;
leads_target: number;
leads_actual: number;
clients_target: number;
clients_actual: number;
} = {
revenue_target: 0,
revenue_actual: 0,
revenue_achievement_pct: 0,
leads_target: 20,
leads_target: 0,
leads_actual: 0,
clients_target: 4,
clients_actual: 0
clients_target: 0,
clients_actual: 0,
};
try {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
method: 'POST',
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ p_month: currentMonth }),
cache: 'no-store'
if (tenantId) {
const { data, error } = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
p_tenant_id: tenantId,
});
if (res.ok) {
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
targetData = data[0];
if (error) {
// Some deployments only take p_month (tenant from JWT)
const fallback = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
});
if (!fallback.error && Array.isArray(fallback.data) && fallback.data[0]) {
targetData = { ...targetData, ...fallback.data[0] };
} else {
console.error('targets_vs_actual', error.message);
}
} else if (Array.isArray(data) && data[0]) {
targetData = { ...targetData, ...data[0] };
}
} catch (e) {
console.error('Error fetching targets_vs_actual:', e);
}
const revenuePct = Math.min(100, Math.round(targetData.revenue_achievement_pct || 0));
const leadsPct = targetData.leads_target > 0
? Math.min(100, Math.round((Number(targetData.leads_actual) / Number(targetData.leads_target)) * 100))
: 0;
const clientsPct = targetData.clients_target > 0
? Math.min(100, Math.round((Number(targetData.clients_actual) / Number(targetData.clients_target)) * 100))
: 0;
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">Estrategia de Crecimiento & Objetivos</h2>
<h2 className="text-2xl font-bold tracking-tight text-white">
Estrategia de Crecimiento & Objetivos
</h2>
<p className="text-sm text-gray-400 mt-1">
Monitoreo mensual en tiempo real de metas fijadas frente al rendimiento real consolidado en Supabase.
Metas del mes vs real · scoped al tenant del usuario.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-white text-base">Objetivo de Facturación</h3>
<Target className="w-5 h-5 text-brand-500" />
</div>
<p className="text-3xl font-extrabold text-white mb-2">
{Number(targetData.revenue_actual || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
</p>
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
<div className="bg-brand-500 h-2 rounded-full" style={{ width: `${revenuePct}%` }}></div>
</div>
<p className="text-xs text-gray-400">
Meta: {Number(targetData.revenue_target || 0).toLocaleString('es-ES')} € ({revenuePct}% completado)
</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="bg-[#111827] border border-gray-800 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-white text-base">Meta de Leads Cualificados</h3>
<TrendingUp className="w-5 h-5 text-blue-400" />
</div>
<p className="text-3xl font-extrabold text-white mb-2">{targetData.leads_actual || 0}</p>
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
<div className="bg-blue-400 h-2 rounded-full" style={{ width: `${leadsPct}%` }}></div>
</div>
<p className="text-xs text-gray-400">
{targetData.leads_actual || 0} de {targetData.leads_target || 0} leads captados ({leadsPct}%)
</p>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-white text-base">Nuevos Clientes (Won)</h3>
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
</div>
<p className="text-3xl font-extrabold text-white mb-2">{targetData.clients_actual || 0}</p>
<div className="w-full bg-gray-800 rounded-full h-2 mb-2">
<div className="bg-emerald-400 h-2 rounded-full" style={{ width: `${clientsPct}%` }}></div>
</div>
<p className="text-xs text-gray-400">
{targetData.clients_actual || 0} de {targetData.clients_target || 0} clientes ganados ({clientsPct}%)
</p>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-brand-500"
style={{ width: `${revenuePct}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-3">
{Number(targetData.revenue_actual).toLocaleString('es-ES')} € /{' '}
{Number(targetData.revenue_target).toLocaleString('es-ES')} € · leads{' '}
{targetData.leads_actual}/{targetData.leads_target} · clientes{' '}
{targetData.clients_actual}/{targetData.clients_target}
</p>
</div>
<BudgetSimulator />

View File

@@ -1,148 +1,109 @@
import React from 'react';
import {
TrendingUp,
Users,
DollarSign,
Target,
Layers
import {
TrendingUp,
Users,
DollarSign,
Target,
} from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
export const revalidate = 0;
export default async function KpisPage() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
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;
};
let kpis: any[] = [];
try {
const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?select=*`, {
headers: {
apikey: anonKey,
Authorization: `Bearer ${anonKey}`
},
cache: 'no-store'
});
if (res.ok) {
kpis = await res.json();
}
} catch (e) {
console.error('Error fetching kpis:', e);
export default async function KpisPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
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 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 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';
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">Rendimiento & Métricas de Adquisición</h2>
<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 de Atribución Oficial: <strong className="text-gray-200">Cohorte a 60 días</strong> con desglose comparativo de Caja Semanal.
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 shadow-sm">
<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>
<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>
<span className="text-[11px] text-gray-400 mt-1 inline-block">Windsor.ai (Meta + Google)</span>
<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 shadow-sm">
<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 Captados</span>
<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>
<span className="text-[11px] text-emerald-400 mt-1 inline-block">Atribución UTM 100% verificada</span>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
<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>
<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>
<span className="text-[11px] text-gray-400 mt-1 inline-block">Coste por cliente cerrado</span>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 shadow-sm">
<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>
<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-emerald-400">{overallRoas}x</p>
<span className="text-[11px] text-gray-400 mt-1 inline-block">Retorno sobre inversión de ads</span>
<p className="text-2xl font-bold text-white">{overallRoas}x</p>
</div>
</div>
<div className="bg-[#111827] border border-gray-800 rounded-xl overflow-hidden shadow-sm">
<div className="p-5 border-b border-gray-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-brand-500" />
<h3 className="font-semibold text-white text-sm">Desglose por Canal y Semana (kpi_weekly)</h3>
</div>
<span className="text-xs text-gray-400">Refresco automático: cada hora (pg_cron)</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-[#0D121F] text-gray-400 text-xs uppercase tracking-wider border-b border-gray-800">
<tr>
<th className="py-3.5 px-5 font-semibold">Semana</th>
<th className="py-3.5 px-5 font-semibold">Canal</th>
<th className="py-3.5 px-5 font-semibold text-right">Leads</th>
<th className="py-3.5 px-5 font-semibold text-right">Inversión</th>
<th className="py-3.5 px-5 font-semibold text-right">Ventas Cohorte</th>
<th className="py-3.5 px-5 font-semibold text-right">CAC Cohorte</th>
<th className="py-3.5 px-5 font-semibold text-right">ROAS Cohorte</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/80 text-gray-200">
{kpis.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-gray-400">
No hay datos registrados aún. Los leads ingresados por el Webhook de n8n o Windsor.ai se consolidarán aquí.
</td>
</tr>
) : (
kpis.map((row, idx) => (
<tr key={idx} className="hover:bg-gray-800/30 transition-colors">
<td className="py-3.5 px-5 font-mono text-xs text-gray-400">
{row.week ? new Date(row.week).toLocaleDateString('es-ES') : '-'}
</td>
<td className="py-3.5 px-5">
<span className="capitalize font-medium text-white px-2 py-0.5 rounded bg-gray-800/80 text-xs border border-gray-700/60">
{row.channel}
</span>
</td>
<td className="py-3.5 px-5 text-right font-medium">{row.total_leads}</td>
<td className="py-3.5 px-5 text-right font-mono">
{Number(row.total_spend || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
</td>
<td className="py-3.5 px-5 text-right font-mono text-emerald-400">
{Number(row.cohort_revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €
</td>
<td className="py-3.5 px-5 text-right font-mono font-medium text-amber-400">
{row.cac_cohort} €
</td>
<td className="py-3.5 px-5 text-right font-mono font-bold text-emerald-400">
{row.roas_cohort}x
</td>
</tr>
))
)}
</tbody>
</table>
</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>
);
}

View File

@@ -71,6 +71,13 @@ export default async function DashboardLayout({
<FolderGit2 className="w-4 h-4 text-blue-400" />
Biblioteca de Activos
</Link>
<Link
href="/dashboard/creatives"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
>
<FolderGit2 className="w-4 h-4 text-orange-400" />
Creatividades
</Link>
<Link
href="/dashboard/asistente"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"