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:
11
sql/creatives_delete_policy.sql
Normal file
11
sql/creatives_delete_policy.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Allow tenant-scoped delete on creatives (clients); agency uses service role.
|
||||||
|
alter table public.creatives enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists creatives_tenant_delete on public.creatives;
|
||||||
|
create policy creatives_tenant_delete on public.creatives
|
||||||
|
for delete using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -140,3 +140,94 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ creative: data }, { status: 201 });
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, CheckCircle2 } from 'lucide-react';
|
import { FileText, CheckCircle2 } from 'lucide-react';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
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';
|
||||||
|
|
||||||
export default async function ActivosPage() {
|
type Doc = {
|
||||||
const supabase = await createClient();
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
let documents: Array<{
|
|
||||||
id: string;
|
id: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -18,34 +15,56 @@ export default async function ActivosPage() {
|
|||||||
version?: number;
|
version?: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}> = [];
|
};
|
||||||
|
|
||||||
if (user) {
|
export default async function ActivosPage() {
|
||||||
// Relies on RLS: only documents for the caller's tenant
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
let documents: Doc[] = [];
|
||||||
|
|
||||||
|
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
|
const { data, error } = await supabase
|
||||||
.from('documents')
|
.from('documents')
|
||||||
.select('id,type,title,content,version,created_at,status')
|
.select('id,type,title,content,version,created_at,status')
|
||||||
.eq('status', 'approved')
|
.eq('status', 'approved')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
if (error) {
|
if (error) console.error('documents', error.message);
|
||||||
console.error('Error fetching documents:', error.message);
|
else documents = data ?? [];
|
||||||
} else {
|
|
||||||
documents = data ?? [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
Biblioteca de Activos & Entregables
|
Biblioteca de activos
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
|
||||||
Documentos, copys, calendarios y briefs producidos por los agentes de IA
|
Entregables aprobados del cliente ({ctx.label}): briefs, audits,
|
||||||
y aprobados por el equipo en Slack. Filtrado por tenant (RLS).
|
copys, calendarios exportados. Fuente:{' '}
|
||||||
|
<code>documents</code> con <code>status=approved</code> (agentes →
|
||||||
|
Slack → OK). Distinto del pipeline de creatividades ads.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
{documents.length === 0 ? (
|
{documents.length === 0 ? (
|
||||||
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
||||||
@@ -54,8 +73,7 @@ export default async function ActivosPage() {
|
|||||||
No hay activos aprobados todavía.
|
No hay activos aprobados todavía.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
Los activos generados por los agentes aparecerán aquí tras ser
|
Aparecerán aquí tras validación en Slack / ops.
|
||||||
validados en Slack.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -73,8 +91,12 @@ export default async function ActivosPage() {
|
|||||||
<CheckCircle2 className="w-3 h-3" /> Aprobado
|
<CheckCircle2 className="w-3 h-3" /> Aprobado
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-bold text-white text-base mb-2">{doc.title}</h4>
|
<h4 className="font-bold text-white text-base mb-2">
|
||||||
<p className="text-xs text-gray-400 line-clamp-4">{doc.content}</p>
|
{doc.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-400 line-clamp-4">
|
||||||
|
{doc.content}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 border-t border-gray-800/80 mt-4 text-[11px] text-gray-400 flex justify-between">
|
<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>Versión v{doc.version}</span>
|
||||||
@@ -88,6 +110,7 @@ export default async function ActivosPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,65 +2,81 @@ import React from 'react';
|
|||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import CalendarClient from './CalendarClient';
|
import CalendarClient from './CalendarClient';
|
||||||
import { CalendarDays } from 'lucide-react';
|
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() {
|
export default async function CalendarioPage() {
|
||||||
const supabase = await createClient();
|
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 ctx = await resolveActiveTenant(user);
|
||||||
const companyName = user?.user_metadata?.company || '4Ventures';
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
|
||||||
let calendarDocs: any[] = [];
|
let calendarDocs: Array<{ title?: string; content?: unknown }> = [];
|
||||||
if (tenantId) {
|
|
||||||
try {
|
if (ctx.portfolioWide) {
|
||||||
const { data, error } = await supabase
|
// 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')
|
.from('documents')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('type', 'calendar')
|
.eq('type', 'calendar')
|
||||||
.eq('tenant_id', tenantId)
|
.eq('tenant_id', ctx.tenantId)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
calendarDocs = data || [];
|
||||||
if (!error && data) {
|
|
||||||
calendarDocs = data;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error fetching calendar documents:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse posts from calendar document
|
let posts: unknown[] = [];
|
||||||
let posts: any[] = [];
|
|
||||||
if (calendarDocs.length > 0) {
|
if (calendarDocs.length > 0) {
|
||||||
try {
|
try {
|
||||||
const raw = calendarDocs[0].content;
|
const raw = calendarDocs[0].content;
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') posts = JSON.parse(raw);
|
||||||
posts = JSON.parse(raw);
|
else if (Array.isArray(raw)) posts = raw;
|
||||||
} else if (Array.isArray(raw)) {
|
} catch {
|
||||||
posts = raw;
|
posts = [];
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error parsing calendar JSON:', err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header */}
|
|
||||||
<div>
|
<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">
|
<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>
|
</div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
Calendario Editorial Mensual — {companyName}
|
Calendario editorial — {ctx.label}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
|
||||||
Estrategia omnicanal de publicaciones para Instagram, Facebook y LinkedIn producida por los agentes de IA de 4Ventures.
|
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>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
'use client';
|
||||||
|
|
||||||
import React, { FormEvent, useEffect, useState } from 'react';
|
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 = {
|
type Creative = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,12 +14,20 @@ type Creative = {
|
|||||||
created_at?: string;
|
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() {
|
export default function CreativesPage() {
|
||||||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [warning, setWarning] = useState<string | null>(null);
|
const [warning, setWarning] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -50,15 +59,43 @@ export default function CreativesPage() {
|
|||||||
await load();
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
Creatividades
|
Pipeline de creatividades
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
|
||||||
Borradores del cliente en contexto (selector arriba). Con superadmin
|
Cola de aprobación de piezas ads del <strong className="text-gray-300">cliente en contexto</strong> (selector arriba):
|
||||||
activo, ves/creas en ese tenant.
|
borrador → review → aprobado. No sustituye Meta Ads Manager; el sync
|
||||||
|
automático de anuncios live llegará con la integración Meta.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,12 +108,14 @@ export default function CreativesPage() {
|
|||||||
|
|
||||||
<form onSubmit={create} className="flex gap-3 items-end">
|
<form onSubmit={create} className="flex gap-3 items-end">
|
||||||
<div className="flex-1">
|
<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
|
<input
|
||||||
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-sm text-white"
|
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-sm text-white"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Video Meta · testimonial"
|
placeholder="Video Meta · testimonial ES"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +123,7 @@ export default function CreativesPage() {
|
|||||||
type="submit"
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -93,22 +132,59 @@ export default function CreativesPage() {
|
|||||||
) : creatives.length === 0 ? (
|
) : creatives.length === 0 ? (
|
||||||
<div className="bg-[#111827] border border-dashed border-gray-700 rounded-xl p-12 text-center">
|
<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" />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
{creatives.map((c) => (
|
{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
|
<li
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className="flex justify-between items-center bg-[#111827] border border-gray-800 rounded-lg px-4 py-3"
|
className="rounded-lg border border-gray-800 bg-[#111827] p-3 space-y-2"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-white">{c.title}</span>
|
<p className="text-sm text-white font-medium">{c.title}</p>
|
||||||
<span className="text-[10px] uppercase tracking-wider text-gray-400">
|
<div className="flex items-center gap-2">
|
||||||
{c.status}
|
<select
|
||||||
</span>
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,20 @@ import { Target } from 'lucide-react';
|
|||||||
import BudgetSimulator from '@/components/BudgetSimulator';
|
import BudgetSimulator from '@/components/BudgetSimulator';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { redirect } from 'next/navigation';
|
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() {
|
export default async function EstrategiaPage() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
@@ -13,21 +25,12 @@ export default async function EstrategiaPage() {
|
|||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
if (!user) redirect('/login');
|
if (!user) redirect('/login');
|
||||||
|
|
||||||
const tenantId =
|
const ctx = await resolveActiveTenant(user);
|
||||||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
|
||||||
let targetData: {
|
let targetData: TargetRow = {
|
||||||
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_target: 0,
|
||||||
revenue_actual: 0,
|
revenue_actual: 0,
|
||||||
revenue_achievement_pct: 0,
|
revenue_achievement_pct: 0,
|
||||||
@@ -36,44 +39,83 @@ export default async function EstrategiaPage() {
|
|||||||
clients_target: 0,
|
clients_target: 0,
|
||||||
clients_actual: 0,
|
clients_actual: 0,
|
||||||
};
|
};
|
||||||
|
let sourceNote =
|
||||||
|
'Origen: tabla targets + RPC targets_vs_actual (metas cargadas en Postgres por tenant/mes).';
|
||||||
|
|
||||||
if (tenantId) {
|
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', {
|
const { data, error } = await supabase.rpc('targets_vs_actual', {
|
||||||
p_month: currentMonth,
|
p_month: currentMonth,
|
||||||
p_tenant_id: tenantId,
|
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) {
|
if (error) {
|
||||||
// Some deployments only take p_month (tenant from JWT)
|
|
||||||
const fallback = await supabase.rpc('targets_vs_actual', {
|
const fallback = await supabase.rpc('targets_vs_actual', {
|
||||||
p_month: currentMonth,
|
p_month: currentMonth,
|
||||||
});
|
});
|
||||||
if (!fallback.error && Array.isArray(fallback.data) && fallback.data[0]) {
|
if (!fallback.error && Array.isArray(fallback.data) && fallback.data[0]) {
|
||||||
targetData = { ...targetData, ...fallback.data[0] };
|
targetData = { ...targetData, ...fallback.data[0] };
|
||||||
} else {
|
|
||||||
console.error('targets_vs_actual', error.message);
|
|
||||||
}
|
}
|
||||||
} else if (Array.isArray(data) && data[0]) {
|
} else if (Array.isArray(data) && data[0]) {
|
||||||
targetData = { ...targetData, ...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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
Estrategia de Crecimiento & Objetivos
|
Objetivos del mes
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
<p className="text-sm text-gray-400 mt-1">{ctx.label}</p>
|
||||||
Metas del mes vs real · scoped al tenant del usuario.
|
<p className="text-xs text-gray-500 mt-2 max-w-2xl">{sourceNote}</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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 className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-6">
|
||||||
<div className="flex items-center gap-2 text-brand-400 mb-4">
|
<div className="flex items-center gap-2 text-brand-400 mb-4">
|
||||||
<Target className="w-4 h-4" />
|
<Target className="w-4 h-4" />
|
||||||
<span className="text-sm font-semibold">Facturación {revenuePct}%</span>
|
<span className="text-sm font-semibold">
|
||||||
|
Facturación {revenuePct}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -82,12 +124,14 @@ export default async function EstrategiaPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-3">
|
<p className="text-xs text-gray-500 mt-3">
|
||||||
{Number(targetData.revenue_actual).toLocaleString('es-ES')} € /{' '}
|
{Number(targetData.revenue_actual || 0).toLocaleString('es-ES')} € /{' '}
|
||||||
{Number(targetData.revenue_target).toLocaleString('es-ES')} € · leads{' '}
|
{Number(targetData.revenue_target || 0).toLocaleString('es-ES')} € ·
|
||||||
{targetData.leads_actual}/{targetData.leads_target} · clientes{' '}
|
leads {targetData.leads_actual || 0}/{targetData.leads_target || 0} ·
|
||||||
{targetData.clients_actual}/{targetData.clients_target}
|
clientes {targetData.clients_actual || 0}/
|
||||||
|
{targetData.clients_target || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<BudgetSimulator />
|
<BudgetSimulator />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,109 +1,85 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
|
||||||
TrendingUp,
|
|
||||||
Users,
|
|
||||||
DollarSign,
|
|
||||||
Target,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { createClient } from '@/lib/supabase/server';
|
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;
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
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 default async function KpisPage() {
|
export default async function KpisPage() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
data: { user },
|
data: { user },
|
||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
|
||||||
let kpis: KpiRow[] = [];
|
const ctx = await resolveActiveTenant(user);
|
||||||
if (user) {
|
const agency = isAgencyRole(userRole(user));
|
||||||
// RLS must scope by tenant; never use service role here
|
|
||||||
const { data, error } = await supabase.from('kpi_weekly').select('*');
|
let weekly: KpiWeeklyRow[] = [];
|
||||||
if (error) console.error('kpi_weekly', error.message);
|
let daily: AdDailyRow[] = [];
|
||||||
else kpis = (data as KpiRow[]) ?? [];
|
|
||||||
|
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 comparisons = buildComparisons(weekly);
|
||||||
const totalLeads = kpis.reduce((acc, row) => acc + Number(row.total_leads || 0), 0);
|
const month =
|
||||||
const totalRevenue = kpis.reduce(
|
comparisons.currentMonth.spend > 0 || comparisons.currentMonth.leads > 0
|
||||||
(acc, row) => acc + Number(row.cohort_revenue_won || 0),
|
? comparisons.currentMonth
|
||||||
0
|
: sumWeekly(weekly); // fallback: all available weeks if current month empty
|
||||||
);
|
const dailyTotals = sumDaily(daily);
|
||||||
const dealsWon = kpis.reduce(
|
const sparse = weekly.length < 4;
|
||||||
(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 (
|
return (
|
||||||
<div className="space-y-8">
|
<MetricsDashboard
|
||||||
<div>
|
label={ctx.label}
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
portfolioWide={ctx.portfolioWide}
|
||||||
Rendimiento & Métricas de Adquisición
|
month={month}
|
||||||
</h2>
|
week={comparisons.currentWeek}
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
deltas={comparisons.deltas}
|
||||||
Modelo: <strong className="text-gray-200">Cohorte</strong> vs caja · datos
|
daily={dailyTotals}
|
||||||
filtrados por tenant (RLS).
|
sparse={sparse}
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
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" />
|
<BarChart3 className="w-4 h-4 text-brand-500" />
|
||||||
Métricas & CAC / ROAS
|
Métricas
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/estrategia"
|
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"
|
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" />
|
<FolderGit2 className="w-4 h-4 text-orange-400" />
|
||||||
Creatividades
|
Pipeline creatividades
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/asistente"
|
href="/dashboard/asistente"
|
||||||
|
|||||||
243
src/components/MetricsDashboard.tsx
Normal file
243
src/components/MetricsDashboard.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
ArrowDownRight,
|
||||||
|
ArrowUpRight,
|
||||||
|
DollarSign,
|
||||||
|
Minus,
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
MousePointerClick,
|
||||||
|
Eye,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Delta,
|
||||||
|
MetricTotals,
|
||||||
|
formatEur,
|
||||||
|
formatNum,
|
||||||
|
formatPct,
|
||||||
|
} from '@/lib/metrics';
|
||||||
|
|
||||||
|
type Card = {
|
||||||
|
key: keyof MetricTotals;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
format: (v: number) => string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
invert?: boolean; // lower is better
|
||||||
|
};
|
||||||
|
|
||||||
|
function DeltaTriple({
|
||||||
|
delta,
|
||||||
|
invert,
|
||||||
|
}: {
|
||||||
|
delta: Delta;
|
||||||
|
invert?: boolean;
|
||||||
|
}) {
|
||||||
|
const cells: { label: string; v: number | null }[] = [
|
||||||
|
{ label: 'WoW', v: delta.wow },
|
||||||
|
{ label: 'MoM', v: delta.mom },
|
||||||
|
{ label: 'YoY', v: delta.yoy },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||||
|
{cells.map((c) => {
|
||||||
|
const good =
|
||||||
|
c.v === null
|
||||||
|
? null
|
||||||
|
: invert
|
||||||
|
? c.v < 0
|
||||||
|
: c.v > 0;
|
||||||
|
const color =
|
||||||
|
c.v === null
|
||||||
|
? 'text-gray-500'
|
||||||
|
: good
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: c.v === 0
|
||||||
|
? 'text-gray-400'
|
||||||
|
: 'text-red-400';
|
||||||
|
const Icon =
|
||||||
|
c.v === null || c.v === 0
|
||||||
|
? Minus
|
||||||
|
: good
|
||||||
|
? ArrowUpRight
|
||||||
|
: ArrowDownRight;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.label}
|
||||||
|
className="rounded-md bg-black/25 border border-gray-800/80 px-1.5 py-1 text-center"
|
||||||
|
>
|
||||||
|
<p className="text-[9px] uppercase tracking-wider text-gray-500">
|
||||||
|
{c.label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-[11px] font-semibold inline-flex items-center justify-center gap-0.5 ${color}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{formatPct(c.v)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricsDashboard({
|
||||||
|
label,
|
||||||
|
portfolioWide,
|
||||||
|
month,
|
||||||
|
week,
|
||||||
|
deltas,
|
||||||
|
daily,
|
||||||
|
sparse,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
portfolioWide: boolean;
|
||||||
|
month: MetricTotals;
|
||||||
|
week: MetricTotals;
|
||||||
|
deltas: Record<keyof MetricTotals, Delta>;
|
||||||
|
daily: {
|
||||||
|
spend: number;
|
||||||
|
leads: number;
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
};
|
||||||
|
sparse: boolean;
|
||||||
|
}) {
|
||||||
|
const cards: Card[] = [
|
||||||
|
{
|
||||||
|
key: 'spend',
|
||||||
|
label: 'Inversión ads',
|
||||||
|
hint: 'Mes en curso (kpi_weekly)',
|
||||||
|
format: formatEur,
|
||||||
|
icon: <DollarSign className="w-4 h-4 text-brand-500" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'leads',
|
||||||
|
label: 'Leads',
|
||||||
|
hint: 'Cohorte · mes',
|
||||||
|
format: formatNum,
|
||||||
|
icon: <Users className="w-4 h-4 text-blue-400" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cpl',
|
||||||
|
label: 'CPL',
|
||||||
|
hint: 'Coste por lead · mes',
|
||||||
|
format: (v) => `${formatNum(v)} €`,
|
||||||
|
icon: <MousePointerClick className="w-4 h-4 text-violet-400" />,
|
||||||
|
invert: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cac',
|
||||||
|
label: 'CAC cohorte',
|
||||||
|
hint: 'Spend / clientes ganados',
|
||||||
|
format: (v) => `${formatNum(v)} €`,
|
||||||
|
icon: <Target className="w-4 h-4 text-amber-400" />,
|
||||||
|
invert: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'revenue',
|
||||||
|
label: 'Revenue cohorte',
|
||||||
|
hint: 'Deals won atribuidos',
|
||||||
|
format: formatEur,
|
||||||
|
icon: <TrendingUp className="w-4 h-4 text-emerald-400" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roas',
|
||||||
|
label: 'ROAS cohorte',
|
||||||
|
hint: 'Revenue / spend',
|
||||||
|
format: (v) => `${formatNum(v)}x`,
|
||||||
|
icon: <TrendingUp className="w-4 h-4 text-emerald-300" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||||
|
Dashboard de métricas
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{label}
|
||||||
|
{portfolioWide
|
||||||
|
? ' · agrega todos los tenants (vista agencia)'
|
||||||
|
: ' · zoom al cliente del selector'}
|
||||||
|
. Comparativas WoW / MoM / YoY sobre <code>kpi_weekly</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500 bg-gray-900/50 border border-gray-800 rounded-lg px-3 py-2">
|
||||||
|
Semana actual spend {formatEur(week.spend)} · leads {formatNum(week.leads)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sparse && (
|
||||||
|
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||||
|
Histórico limitado en warehouse: si WoW/MoM/YoY salen «—», falta
|
||||||
|
pipeline Windsor/n8n o semanas previas / año anterior.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.key}
|
||||||
|
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">
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
{c.icon}
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-white">
|
||||||
|
{c.format(month[c.key])}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">{c.hint}</p>
|
||||||
|
<DeltaTriple delta={deltas[c.key]} invert={c.invert} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<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 uppercase tracking-wider">Ads · 30d (ad_daily)</span>
|
||||||
|
<Eye className="w-4 h-4 text-sky-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{formatEur(daily.spend)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{formatNum(daily.leads)} leads plataforma ·{' '}
|
||||||
|
{formatNum(daily.impressions)} impr. · {formatNum(daily.clicks)}{' '}
|
||||||
|
clicks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#111827] border border-gray-800 rounded-xl p-5 md:col-span-2">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-gray-500 mb-2">
|
||||||
|
Cómo leer las deltas
|
||||||
|
</p>
|
||||||
|
<ul className="text-xs text-gray-400 space-y-1 list-disc list-inside">
|
||||||
|
<li>
|
||||||
|
<strong className="text-gray-300">WoW</strong>: semana actual vs
|
||||||
|
anterior
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-gray-300">MoM</strong>: mes actual vs mes
|
||||||
|
previo
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-gray-300">YoY</strong>: mismo mes vs hace
|
||||||
|
un año
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
En CPL/CAC, bajar es bueno (flecha verde si el % es negativo)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/lib/metrics.ts
Normal file
189
src/lib/metrics.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/** KPI aggregation + WoW / MoM / YoY helpers. */
|
||||||
|
|
||||||
|
export type KpiWeeklyRow = {
|
||||||
|
tenant_id?: string;
|
||||||
|
week?: string;
|
||||||
|
channel?: string;
|
||||||
|
total_leads?: number;
|
||||||
|
cohort_deals_won?: number;
|
||||||
|
cohort_revenue_won?: number;
|
||||||
|
total_spend?: number;
|
||||||
|
cac_cohort?: number;
|
||||||
|
roas_cohort?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdDailyRow = {
|
||||||
|
date?: string;
|
||||||
|
spend?: number;
|
||||||
|
platform_leads?: number;
|
||||||
|
impressions?: number;
|
||||||
|
clicks?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetricTotals = {
|
||||||
|
spend: number;
|
||||||
|
leads: number;
|
||||||
|
deals: number;
|
||||||
|
revenue: number;
|
||||||
|
cac: number;
|
||||||
|
roas: number;
|
||||||
|
cpl: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Delta = {
|
||||||
|
wow: number | null;
|
||||||
|
mom: number | null;
|
||||||
|
yoy: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function n(v: unknown): number {
|
||||||
|
const x = Number(v);
|
||||||
|
return Number.isFinite(x) ? x : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumWeekly(rows: KpiWeeklyRow[]): MetricTotals {
|
||||||
|
const spend = rows.reduce((a, r) => a + n(r.total_spend), 0);
|
||||||
|
const leads = rows.reduce((a, r) => a + n(r.total_leads), 0);
|
||||||
|
const deals = rows.reduce((a, r) => a + n(r.cohort_deals_won), 0);
|
||||||
|
const revenue = rows.reduce((a, r) => a + n(r.cohort_revenue_won), 0);
|
||||||
|
return {
|
||||||
|
spend,
|
||||||
|
leads,
|
||||||
|
deals,
|
||||||
|
revenue,
|
||||||
|
cac: deals > 0 ? spend / deals : 0,
|
||||||
|
roas: spend > 0 ? revenue / spend : 0,
|
||||||
|
cpl: leads > 0 ? spend / leads : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumDaily(rows: AdDailyRow[]): {
|
||||||
|
spend: number;
|
||||||
|
leads: number;
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
spend: rows.reduce((a, r) => a + n(r.spend), 0),
|
||||||
|
leads: rows.reduce((a, r) => a + n(r.platform_leads), 0),
|
||||||
|
impressions: rows.reduce((a, r) => a + n(r.impressions), 0),
|
||||||
|
clicks: rows.reduce((a, r) => a + n(r.clicks), 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekStart(d: Date): Date {
|
||||||
|
const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
const day = x.getUTCDay(); // 0 Sun
|
||||||
|
const diff = day === 0 ? -6 : 1 - day; // Monday
|
||||||
|
x.setUTCDate(x.getUTCDate() + diff);
|
||||||
|
x.setUTCHours(0, 0, 0, 0);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWeek(w?: string): Date | null {
|
||||||
|
if (!w) return null;
|
||||||
|
const d = new Date(w);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctChange(current: number, previous: number): number | null {
|
||||||
|
if (previous === 0) {
|
||||||
|
if (current === 0) return 0;
|
||||||
|
return null; // can't compute meaningfully
|
||||||
|
}
|
||||||
|
return ((current - previous) / Math.abs(previous)) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterWeeks(
|
||||||
|
rows: KpiWeeklyRow[],
|
||||||
|
from: Date,
|
||||||
|
toExclusive: Date
|
||||||
|
): KpiWeeklyRow[] {
|
||||||
|
return rows.filter((r) => {
|
||||||
|
const d = parseWeek(r.week);
|
||||||
|
if (!d) return false;
|
||||||
|
return d >= from && d < toExclusive;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build period totals from weekly rows for WoW / MoM / YoY. */
|
||||||
|
export function buildComparisons(rows: KpiWeeklyRow[]): {
|
||||||
|
currentWeek: MetricTotals;
|
||||||
|
prevWeek: MetricTotals;
|
||||||
|
currentMonth: MetricTotals;
|
||||||
|
prevMonth: MetricTotals;
|
||||||
|
currentMonthLastYear: MetricTotals;
|
||||||
|
deltas: Record<keyof MetricTotals, Delta>;
|
||||||
|
} {
|
||||||
|
const now = new Date();
|
||||||
|
const thisWeek = weekStart(now);
|
||||||
|
const prevWeekStart = new Date(thisWeek);
|
||||||
|
prevWeekStart.setUTCDate(prevWeekStart.getUTCDate() - 7);
|
||||||
|
const nextWeek = new Date(thisWeek);
|
||||||
|
nextWeek.setUTCDate(nextWeek.getUTCDate() + 7);
|
||||||
|
|
||||||
|
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
const nextMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
||||||
|
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
||||||
|
const yoyMonthStart = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), 1));
|
||||||
|
const yoyMonthEnd = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth() + 1, 1));
|
||||||
|
|
||||||
|
const currentWeek = sumWeekly(filterWeeks(rows, thisWeek, nextWeek));
|
||||||
|
const prevWeek = sumWeekly(filterWeeks(rows, prevWeekStart, thisWeek));
|
||||||
|
const currentMonth = sumWeekly(filterWeeks(rows, monthStart, nextMonth));
|
||||||
|
const prevMonth = sumWeekly(filterWeeks(rows, prevMonthStart, monthStart));
|
||||||
|
const currentMonthLastYear = sumWeekly(
|
||||||
|
filterWeeks(rows, yoyMonthStart, yoyMonthEnd)
|
||||||
|
);
|
||||||
|
|
||||||
|
const keys: (keyof MetricTotals)[] = [
|
||||||
|
'spend',
|
||||||
|
'leads',
|
||||||
|
'deals',
|
||||||
|
'revenue',
|
||||||
|
'cac',
|
||||||
|
'roas',
|
||||||
|
'cpl',
|
||||||
|
];
|
||||||
|
const deltas = {} as Record<keyof MetricTotals, Delta>;
|
||||||
|
for (const k of keys) {
|
||||||
|
// For CAC/CPL lower is better — still show raw % change; UI can note it
|
||||||
|
deltas[k] = {
|
||||||
|
wow: pctChange(currentWeek[k], prevWeek[k]),
|
||||||
|
mom: pctChange(currentMonth[k], prevMonth[k]),
|
||||||
|
yoy: pctChange(currentMonth[k], currentMonthLastYear[k]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentWeek,
|
||||||
|
prevWeek,
|
||||||
|
currentMonth,
|
||||||
|
prevMonth,
|
||||||
|
currentMonthLastYear,
|
||||||
|
deltas,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEur(v: number): string {
|
||||||
|
return `${v.toLocaleString('es-ES', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})} €`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPct(v: number | null): string {
|
||||||
|
if (v === null || Number.isNaN(v)) return '—';
|
||||||
|
const sign = v > 0 ? '+' : '';
|
||||||
|
return `${sign}${v.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNum(v: number): string {
|
||||||
|
return v.toLocaleString('es-ES', { maximumFractionDigits: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isoDate, weekStart };
|
||||||
70
src/lib/tenant-context.ts
Normal file
70
src/lib/tenant-context.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import {
|
||||||
|
VIEW_TENANT_COOKIE,
|
||||||
|
VIEW_TENANT_NAME_COOKIE,
|
||||||
|
homeTenantId,
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
|
||||||
|
export type ResolvedTenant = {
|
||||||
|
tenantId: string | null;
|
||||||
|
/** true = agency zoomed into one client */
|
||||||
|
viaView: boolean;
|
||||||
|
/** true = agency with no client selected → all tenants */
|
||||||
|
portfolioWide: boolean;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveActiveTenant(user: {
|
||||||
|
app_metadata?: Record<string, unknown>;
|
||||||
|
user_metadata?: Record<string, unknown>;
|
||||||
|
} | null): Promise<ResolvedTenant> {
|
||||||
|
const role = userRole(user);
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||||
|
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value || null;
|
||||||
|
|
||||||
|
if (isAgencyRole(role) && viewId) {
|
||||||
|
return {
|
||||||
|
tenantId: viewId,
|
||||||
|
viaView: true,
|
||||||
|
portfolioWide: false,
|
||||||
|
label: viewName || 'Cliente',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isAgencyRole(role) && !viewId) {
|
||||||
|
return {
|
||||||
|
tenantId: null,
|
||||||
|
viaView: false,
|
||||||
|
portfolioWide: true,
|
||||||
|
label: 'Portafolio (todos los clientes)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: homeTenantId(user),
|
||||||
|
viaView: false,
|
||||||
|
portfolioWide: false,
|
||||||
|
label:
|
||||||
|
(user?.user_metadata?.company as string) ||
|
||||||
|
(user?.app_metadata?.company as string) ||
|
||||||
|
'Cliente',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restGet<T = unknown>(
|
||||||
|
path: string
|
||||||
|
): Promise<{ ok: boolean; data: T | null; error?: string }> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) return { ok: false, data: null, error: 'no service role' };
|
||||||
|
const r = await fetch(`${supabaseUrl()}${path}`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
return { ok: false, data: null, error: await r.text() };
|
||||||
|
}
|
||||||
|
return { ok: true, data: (await r.json()) as T };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user