feat: metrics dashboard with WoW/MoM/YoY and creatives pipeline

Rebuild KPIs as a real dashboard (period deltas, tenant zoom), clarify
objetivos/calendario/activos sources, and turn creatives into a status
pipeline with delete.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-22 13:45:41 +02:00
parent 791976a3d8
commit 8182402d96
11 changed files with 1002 additions and 263 deletions

View File

@@ -140,3 +140,94 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ creative: data }, { status: 201 });
}
const ALLOWED = new Set(['draft', 'in_review', 'approved', 'rejected']);
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json().catch(() => ({}));
const id = typeof body.id === 'string' ? body.id.trim() : '';
const status = typeof body.status === 'string' ? body.status.trim() : '';
if (!id || !ALLOWED.has(status)) {
return NextResponse.json({ error: 'id y status válidos requeridos' }, { status: 400 });
}
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
status,
updated_at: new Date().toISOString(),
}),
}
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ creative: data[0] || data });
}
const { data, error } = await supabase
.from('creatives')
.update({ status, updated_at: new Date().toISOString() })
.eq('id', id)
.select('id,title,status')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ creative: data });
}
export async function DELETE(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
{ method: 'DELETE', headers }
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
const { error } = await supabase.from('creatives').delete().eq('id', id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true });
}

View File

@@ -1,93 +1,116 @@
import React from 'react';
import { FileText, CheckCircle2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
export const revalidate = 0;
export const dynamic = 'force-dynamic';
type Doc = {
id: string;
type?: string;
title?: string;
content?: string;
version?: number;
created_at?: string;
status?: string;
};
export default async function ActivosPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
let documents: Array<{
id: string;
type?: string;
title?: string;
content?: string;
version?: number;
created_at?: string;
status?: string;
}> = [];
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
let documents: Doc[] = [];
if (user) {
// Relies on RLS: only documents for the caller's tenant
if (ctx.portfolioWide) {
documents = [];
} else if (agency && ctx.viaView && ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const r = await restGet<Doc[]>(
`/rest/v1/documents?tenant_id=eq.${tid}&status=eq.approved&select=id,type,title,content,version,created_at,status&order=created_at.desc`
);
documents = r.data || [];
} else {
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 ?? [];
}
if (error) console.error('documents', error.message);
else documents = data ?? [];
}
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Biblioteca de Activos & Entregables
Biblioteca de activos
</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. Filtrado por tenant (RLS).
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
Entregables aprobados del cliente ({ctx.label}): briefs, audits,
copys, calendarios exportados. Fuente:{' '}
<code>documents</code> con <code>status=approved</code> (agentes →
Slack → OK). Distinto del pipeline de creatividades ads.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{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-xs text-gray-400 mt-1">
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>
<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">
{doc.type}
</span>
<span className="flex items-center gap-1 text-[11px] text-emerald-400">
<CheckCircle2 className="w-3 h-3" /> Aprobado
{ctx.portfolioWide ? (
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
Selecciona un cliente en Contexto para ver su biblioteca.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{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-xs text-gray-400 mt-1">
Aparecerán aquí tras validación en Slack / ops.
</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>
<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">
{doc.type}
</span>
<span className="flex items-center gap-1 text-[11px] text-emerald-400">
<CheckCircle2 className="w-3 h-3" /> Aprobado
</span>
</div>
<h4 className="font-bold text-white text-base mb-2">
{doc.title}
</h4>
<p className="text-xs text-gray-400 line-clamp-4">
{doc.content}
</p>
</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>
{doc.created_at
? new Date(doc.created_at).toLocaleDateString('es-ES')
: '—'}
</span>
</div>
<h4 className="font-bold text-white text-base mb-2">{doc.title}</h4>
<p className="text-xs text-gray-400 line-clamp-4">{doc.content}</p>
</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>
{doc.created_at
? new Date(doc.created_at).toLocaleDateString('es-ES')
: '—'}
</span>
</div>
</div>
))
)}
</div>
))
)}
</div>
)}
</div>
);
}

View File

@@ -2,65 +2,81 @@ import React from 'react';
import { createClient } from '@/lib/supabase/server';
import CalendarClient from './CalendarClient';
import { CalendarDays } from 'lucide-react';
import { redirect } from 'next/navigation';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
export const revalidate = 0;
export const dynamic = 'force-dynamic';
export default async function CalendarioPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
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 companyName = user?.user_metadata?.company || '4Ventures';
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
let calendarDocs: any[] = [];
if (tenantId) {
try {
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('type', 'calendar')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false });
let calendarDocs: Array<{ title?: string; content?: unknown }> = [];
if (!error && data) {
calendarDocs = data;
}
} catch (e) {
console.error('Error fetching calendar documents:', e);
}
if (ctx.portfolioWide) {
// no-op — prompt to select client
} else if (agency && ctx.viaView && ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const r = await restGet<typeof calendarDocs>(
`/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&select=*&order=created_at.desc`
);
calendarDocs = r.data || [];
} else if (ctx.tenantId) {
const { data } = await supabase
.from('documents')
.select('*')
.eq('type', 'calendar')
.eq('tenant_id', ctx.tenantId)
.order('created_at', { ascending: false });
calendarDocs = data || [];
}
// Parse posts from calendar document
let posts: any[] = [];
let posts: unknown[] = [];
if (calendarDocs.length > 0) {
try {
const raw = calendarDocs[0].content;
if (typeof raw === 'string') {
posts = JSON.parse(raw);
} else if (Array.isArray(raw)) {
posts = raw;
}
} catch (err) {
console.error('Error parsing calendar JSON:', err);
if (typeof raw === 'string') posts = JSON.parse(raw);
else if (Array.isArray(raw)) posts = raw;
} catch {
posts = [];
}
}
return (
<div className="space-y-8">
{/* Header */}
<div>
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-brand-950/60 border border-brand-800/40 text-brand-400 text-xs font-semibold uppercase tracking-wider mb-2">
<CalendarDays className="w-3.5 h-3.5" /> Planificación de Redes Sociales · Agente #07
<CalendarDays className="w-3.5 h-3.5" /> RRSS · plan editorial
</div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Calendario Editorial Mensual — {companyName}
Calendario editorial — {ctx.label}
</h2>
<p className="text-sm text-gray-400 mt-1">
Estrategia omnicanal de publicaciones para Instagram, Facebook y LinkedIn producida por los agentes de IA de 4Ventures.
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
Plan de publicaciones (IG / FB / LinkedIn) guardado en{' '}
<code>documents</code> con <code>type=calendar</code>, generado por
agentes / ops y ligado al tenant. No es el calendario de Google Ads.
</p>
</div>
<CalendarClient initialPosts={posts} calendarTitle={calendarDocs[0]?.title || 'Calendario Septiembre 2026'} />
{ctx.portfolioWide ? (
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
Selecciona un cliente en Contexto para ver su calendario editorial.
</p>
) : (
<CalendarClient
initialPosts={posts as never[]}
calendarTitle={
calendarDocs[0]?.title || 'Sin calendario cargado aún'
}
/>
)}
</div>
);
}

View File

@@ -1,10 +1,11 @@
/**
* Creatividades multi-tenant — lista vía /api/creatives (RLS).
* Pipeline de creatividades publicitarias del cliente en contexto.
* No es la librería de assets (documentos) ni el sync live de Meta — es la cola de aprobación.
*/
'use client';
import React, { FormEvent, useEffect, useState } from 'react';
import { Image as ImageIcon, Plus } from 'lucide-react';
import { Image as ImageIcon, Plus, Trash2 } from 'lucide-react';
type Creative = {
id: string;
@@ -13,12 +14,20 @@ type Creative = {
created_at?: string;
};
const STATUSES = [
{ id: 'draft', label: 'Borrador' },
{ id: 'in_review', label: 'En review' },
{ id: 'approved', label: 'Aprobado' },
{ id: 'rejected', label: 'Rechazado' },
] as const;
export default function CreativesPage() {
const [creatives, setCreatives] = useState<Creative[]>([]);
const [title, setTitle] = useState('');
const [warning, setWarning] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
async function load() {
setLoading(true);
@@ -50,15 +59,43 @@ export default function CreativesPage() {
await load();
}
async function setStatus(id: string, status: string) {
setBusyId(id);
setErr(null);
const res = await fetch('/api/creatives', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
});
const data = await res.json();
if (!res.ok) setErr(data.error ?? 'No se pudo actualizar');
await load();
setBusyId(null);
}
async function remove(id: string) {
if (!confirm('¿Borrar esta creatividad?')) return;
setBusyId(id);
setErr(null);
const res = await fetch(`/api/creatives?id=${encodeURIComponent(id)}`, {
method: 'DELETE',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) setErr(data.error ?? 'No se pudo borrar');
await load();
setBusyId(null);
}
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Creatividades
Pipeline de creatividades
</h2>
<p className="text-sm text-gray-400 mt-1">
Borradores del cliente en contexto (selector arriba). Con superadmin
activo, ves/creas en ese tenant.
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
Cola de aprobación de piezas ads del <strong className="text-gray-300">cliente en contexto</strong> (selector arriba):
borrador → review → aprobado. No sustituye Meta Ads Manager; el sync
automático de anuncios live llegará con la integración Meta.
</p>
</div>
@@ -71,12 +108,14 @@ export default function CreativesPage() {
<form onSubmit={create} className="flex gap-3 items-end">
<div className="flex-1">
<label className="text-[11px] uppercase text-gray-500">Título</label>
<label className="text-[11px] uppercase text-gray-500">
Nueva pieza
</label>
<input
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-sm text-white"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Video Meta · testimonial"
placeholder="Video Meta · testimonial ES"
required
/>
</div>
@@ -84,7 +123,7 @@ export default function CreativesPage() {
type="submit"
className="inline-flex items-center gap-2 rounded-lg bg-brand-500/90 hover:bg-brand-500 px-4 py-2 text-sm font-medium text-white"
>
<Plus className="w-4 h-4" /> Crear borrador
<Plus className="w-4 h-4" /> Añadir
</button>
</form>
@@ -93,22 +132,59 @@ export default function CreativesPage() {
) : creatives.length === 0 ? (
<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">Sin creatividades aún</p>
<p className="text-gray-300 font-medium">Pipeline vacío</p>
<p className="text-xs text-gray-500 mt-2 max-w-md mx-auto">
Úsalo para piezas en producción creativa por cliente. La biblioteca
de activos es para documentos/briefs aprobados; esto es el flow de
ads.
</p>
</div>
) : (
<ul className="space-y-2">
{creatives.map((c) => (
<li
key={c.id}
className="flex justify-between items-center bg-[#111827] border border-gray-800 rounded-lg px-4 py-3"
>
<span className="text-sm text-white">{c.title}</span>
<span className="text-[10px] uppercase tracking-wider text-gray-400">
{c.status}
</span>
</li>
))}
</ul>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{STATUSES.map((col) => {
const items = creatives.filter((c) => c.status === col.id);
return (
<div key={col.id} className="space-y-2">
<p className="text-[11px] uppercase tracking-wider text-gray-500 font-semibold px-1">
{col.label} ({items.length})
</p>
<ul className="space-y-2 min-h-[80px]">
{items.map((c) => (
<li
key={c.id}
className="rounded-lg border border-gray-800 bg-[#111827] p-3 space-y-2"
>
<p className="text-sm text-white font-medium">{c.title}</p>
<div className="flex items-center gap-2">
<select
className="flex-1 text-[11px] rounded bg-gray-900 border border-gray-700 text-gray-300 px-2 py-1"
value={c.status}
disabled={busyId === c.id}
onChange={(e) => setStatus(c.id, e.target.value)}
>
{STATUSES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
<button
type="button"
title="Borrar"
disabled={busyId === c.id}
onClick={() => remove(c.id)}
className="p-1.5 rounded text-gray-500 hover:text-red-400 hover:bg-red-500/10"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
</div>
);
})}
</div>
)}
</div>
);

View File

@@ -3,8 +3,20 @@ import { Target } 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';
export const revalidate = 0;
export const dynamic = 'force-dynamic';
type TargetRow = {
revenue_target?: number;
revenue_actual?: number;
revenue_achievement_pct?: number;
leads_target?: number;
leads_actual?: number;
clients_target?: number;
clients_actual?: number;
};
export default async function EstrategiaPage() {
const supabase = await createClient();
@@ -13,21 +25,12 @@ export default async function EstrategiaPage() {
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
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`;
let targetData: {
revenue_target: number;
revenue_actual: number;
revenue_achievement_pct: number;
leads_target: number;
leads_actual: number;
clients_target: number;
clients_actual: number;
} = {
let targetData: TargetRow = {
revenue_target: 0,
revenue_actual: 0,
revenue_achievement_pct: 0,
@@ -36,58 +39,99 @@ export default async function EstrategiaPage() {
clients_target: 0,
clients_actual: 0,
};
let sourceNote =
'Origen: tabla targets + RPC targets_vs_actual (metas cargadas en Postgres por tenant/mes).';
if (tenantId) {
const { data, error } = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
p_tenant_id: tenantId,
});
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);
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`
);
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,
};
}
const { data, error } = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
p_tenant_id: tenantForRpc,
});
if (!error && Array.isArray(data) && data[0]) {
targetData = { ...targetData, ...data[0] };
}
} else {
const { data, error } = await supabase.rpc('targets_vs_actual', {
p_month: currentMonth,
p_tenant_id: tenantForRpc,
});
if (error) {
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 if (Array.isArray(data) && data[0]) {
targetData = { ...targetData, ...data[0] };
}
} else if (Array.isArray(data) && data[0]) {
targetData = { ...targetData, ...data[0] };
}
} else if (ctx.portfolioWide) {
sourceNote =
'Vista global: selecciona un cliente en el selector de contexto para ver sus objetivos del mes.';
}
const revenuePct = Math.min(100, Math.round(targetData.revenue_achievement_pct || 0));
const revenuePct = Math.min(
100,
Math.round(Number(targetData.revenue_achievement_pct) || 0)
);
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight text-white">
Estrategia de Crecimiento & Objetivos
Objetivos del mes
</h2>
<p className="text-sm text-gray-400 mt-1">
Metas del mes vs real · scoped al tenant del usuario.
</p>
<p className="text-sm text-gray-400 mt-1">{ctx.label}</p>
<p className="text-xs text-gray-500 mt-2 max-w-2xl">{sourceNote}</p>
</div>
<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>
<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}
{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.
</p>
</div>
) : (
<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>
<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>
</div>
)}
<BudgetSimulator />
</div>

View File

@@ -1,109 +1,85 @@
import React from 'react';
import {
TrendingUp,
Users,
DollarSign,
Target,
} from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import MetricsDashboard from '@/components/MetricsDashboard';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import {
AdDailyRow,
KpiWeeklyRow,
buildComparisons,
sumDaily,
sumWeekly,
} from '@/lib/metrics';
import { isAgencyRole, userRole } from '@/lib/agency';
export const revalidate = 0;
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;
};
export const dynamic = 'force-dynamic';
export default async function KpisPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
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 ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
let weekly: KpiWeeklyRow[] = [];
let daily: AdDailyRow[] = [];
if (agency && (ctx.portfolioWide || ctx.viaView)) {
// Service role for portfolio / zoom (bypass single-tenant RLS)
if (ctx.portfolioWide) {
const w = await restGet<KpiWeeklyRow[]>(
'/rest/v1/kpi_weekly?select=*&order=week.desc'
);
const d = await restGet<AdDailyRow[]>(
'/rest/v1/ad_daily?select=*&order=date.desc&limit=500'
);
weekly = w.data || [];
daily = d.data || [];
} else if (ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const w = await restGet<KpiWeeklyRow[]>(
`/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`
);
weekly = w.data || [];
daily = d.data || [];
}
} else {
const { data: w } = await supabase
.from('kpi_weekly')
.select('*')
.order('week', { ascending: false });
const { data: d } = await supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(500);
weekly = (w as KpiWeeklyRow[]) || [];
daily = (d as AdDailyRow[]) || [];
}
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 avgCac = dealsWon > 0 ? (totalSpend / dealsWon).toFixed(2) : '0.00';
const overallRoas = totalSpend > 0 ? (totalRevenue / totalSpend).toFixed(2) : '0.00';
const comparisons = buildComparisons(weekly);
const month =
comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0
? comparisons.currentMonth
: sumWeekly(weekly); // fallback: all available weeks if current month empty
const dailyTotals = sumDaily(daily);
const sparse = weekly.length < 4;
return (
<div className="space-y-8">
<div>
<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: <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">
<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>
<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>
</div>
<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</span>
<Users className="w-4 h-4 text-blue-400" />
</div>
<p className="text-2xl font-bold text-white">{totalLeads}</p>
</div>
<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>
<Target className="w-4 h-4 text-amber-400" />
</div>
<p className="text-2xl font-bold text-white">{avgCac} €</p>
</div>
<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>
<TrendingUp className="w-4 h-4 text-emerald-400" />
</div>
<p className="text-2xl font-bold text-white">{overallRoas}x</p>
</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>
<MetricsDashboard
label={ctx.label}
portfolioWide={ctx.portfolioWide}
month={month}
week={comparisons.currentWeek}
deltas={comparisons.deltas}
daily={dailyTotals}
sparse={sparse}
/>
);
}

View File

@@ -53,7 +53,7 @@ export default async function DashboardLayout({
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-200 hover:text-white hover:bg-gray-800/60 transition-all"
>
<BarChart3 className="w-4 h-4 text-brand-500" />
Métricas & CAC / ROAS
Métricas
</Link>
<Link
href="/dashboard/estrategia"
@@ -81,7 +81,7 @@ export default async function DashboardLayout({
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
Pipeline creatividades
</Link>
<Link
href="/dashboard/asistente"