feat(creatives): implement agency and client views (Tasks 6 & 7) with fatigue panel and Claude queue

This commit is contained in:
Marcos Fargas
2026-10-12 20:08:34 +02:00
parent 7b604c68ca
commit 34ce46774d
17 changed files with 2636 additions and 87 deletions

View File

@@ -23,15 +23,7 @@ export async function POST(
}
const { id } = await params;
const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant context required' },
{ status: 400 }
);
}
const role = userRole(user);
let body: any;
try {
@@ -40,8 +32,28 @@ export async function POST(
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const ctx = await resolveActiveTenant(user);
let tenantId = ctx.tenantId;
if (!tenantId && (role === 'agency_admin' || role === 'agency_member' || role === 'admin')) {
if (body.tenant_id) {
tenantId = body.tenant_id;
} else {
const { getAdminClient } = await import('@/lib/creatives-workflow');
const adminClient = getAdminClient();
const { data: c } = await adminClient.from('creatives').select('tenant_id').eq('id', id).maybeSingle();
if (c?.tenant_id) tenantId = c.tenant_id;
}
}
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant context required' },
{ status: 400 }
);
}
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire'
const role = userRole(user);
const actor = { role, userId: user.id };
try {

View File

@@ -0,0 +1,180 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { createClient as createAdminClient } from '@supabase/supabase-js';
function getAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createAdminClient(url, key);
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
const isAgency = isAgencyRole(role);
const ctx = await resolveActiveTenant(user);
const activeTenantId = ctx.tenantId;
const admin = getAdmin();
const { data: creative, error } = await admin
.from('creatives')
.select('*')
.eq('id', id)
.maybeSingle();
if (error || !creative) {
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
}
// Si no es agencia, validar pertenencia al tenant
if (!isAgency && creative.tenant_id !== activeTenantId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Obtener métricas diarias
const { data: metrics } = await admin
.from('creative_metrics_daily')
.select('*')
.eq('creative_id', id)
.order('date', { ascending: false })
.limit(30);
// Obtener eventos
const { data: events } = await admin
.from('creative_events')
.select('*')
.eq('creative_id', id)
.order('created_at', { ascending: false })
.limit(20);
// Obtener aprobaciones
const { data: approvals } = await admin
.from('approvals')
.select('*')
.eq('creative_id', id)
.order('created_at', { ascending: false });
const { sanitizeForRole } = await import('@/lib/creatives-visibility');
return NextResponse.json({
creative: sanitizeForRole(creative, role),
metrics: metrics || [],
events: events || [],
approvals: approvals || [],
});
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
const isAgency = isAgencyRole(role);
const ctx = await resolveActiveTenant(user);
const activeTenantId = ctx.tenantId;
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const admin = getAdmin();
const { data: existing, error: getErr } = await admin
.from('creatives')
.select('*')
.eq('id', id)
.maybeSingle();
if (getErr || !existing) {
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
}
if (!isAgency && existing.tenant_id !== activeTenantId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const prevStatus = existing.status;
const updates: Record<string, any> = {
updated_at: new Date().toISOString(),
};
if (body.asset_url !== undefined) updates.asset_url = body.asset_url;
if (body.thumbnail_url !== undefined) updates.thumbnail_url = body.thumbnail_url;
if (body.asset_final !== undefined) updates.asset_final = Boolean(body.asset_final);
if (body.headline !== undefined) updates.headline = body.headline;
if (body.primary_text !== undefined) updates.primary_text = body.primary_text;
if (body.cta !== undefined) updates.cta = body.cta;
if (body.angle !== undefined) updates.angle = body.angle;
if (body.hook !== undefined) updates.hook = body.hook;
if (body.campaign_name !== undefined) updates.campaign_name = body.campaign_name;
if (body.adset_id !== undefined) updates.adset_id = body.adset_id;
if (body.format !== undefined) updates.format = body.format;
if (body.visibility !== undefined) updates.visibility = body.visibility;
if (body.status !== undefined) updates.status = body.status;
const { data: updated, error: updateErr } = await admin
.from('creatives')
.update(updates)
.eq('id', id)
.select('*')
.single();
if (updateErr) {
return NextResponse.json({ error: updateErr.message }, { status: 500 });
}
// Si cambia el status o se sube imagen final, registrar en creative_events
if (updates.status && updates.status !== prevStatus) {
await admin.from('creative_events').insert({
tenant_id: existing.tenant_id,
creative_id: id,
event_type: 'status_change',
actor: `user:${user.id}`,
from_status: prevStatus,
to_status: updates.status,
payload: { changed_by: role, reason: body.status_reason || null },
visibility: updates.status === 'publicada' || updates.status === 'aprobada' ? 'client' : 'agency',
});
} else if (updates.asset_final && !existing.asset_final) {
await admin.from('creative_events').insert({
tenant_id: existing.tenant_id,
creative_id: id,
event_type: 'asset_final_uploaded',
actor: `user:${user.id}`,
from_status: prevStatus,
to_status: prevStatus,
payload: { asset_url: updates.asset_url },
visibility: 'agency',
});
}
return NextResponse.json({ ok: true, creative: updated });
}

View File

@@ -7,9 +7,11 @@ import {
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { sanitizeForRole } from '@/lib/creatives-visibility';
import { createClient as createAdminClient } from '@supabase/supabase-js';
const SELECT =
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,created_at,updated_at';
'id,tenant_id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,campaign_name,adset_id,meta_ad_id,angle,hook,asset_url,thumbnail_url,visibility,parent_id,generation_request_id,asset_final,created_by,created_at,updated_at';
const CHANNELS = new Set([
'meta_ads',
@@ -90,7 +92,7 @@ async function fetchCurrentStatus(
return null;
}
export async function GET() {
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
@@ -99,76 +101,112 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
const viaView = ctx.viaView;
const role = userRole(user);
const isAgency = isAgencyRole(role);
const { searchParams } = new URL(req.url);
const queryTenantId = searchParams.get('tenant_id');
if (viaView && tenantId) {
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
{ creatives: [], warning: 'Service role required for superadmin view' },
{ status: 200 }
);
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=${SELECT}&order=updated_at.desc`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
// Fallback if preview columns not migrated yet
const fallback = await fetch(
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=id,title,status,mime_type,bytes,created_at,updated_at&order=updated_at.desc`,
{ headers, cache: 'no-store' }
);
if (fallback.ok) {
return NextResponse.json({
creatives: await fallback.json(),
viewTenantId: tenantId,
warning:
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
});
}
return NextResponse.json({
creatives: [],
warning: await r.text(),
viewTenantId: tenantId,
});
}
return NextResponse.json({
creatives: await r.json(),
viewTenantId: tenantId,
isAgency: isAgencyRole(userRole(user)),
});
let tenantId: string | null = null;
if (isAgency && queryTenantId) {
tenantId = queryTenantId;
} else {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId;
}
const { data, error } = await supabase
if (!tenantId) {
return NextResponse.json({ creatives: [], isAgency });
}
const adminUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const adminKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const admin = createAdminClient(adminUrl, adminKey);
let query = admin
.from('creatives')
.select(SELECT)
.eq('tenant_id', tenantId)
.order('updated_at', { ascending: false });
if (error) {
const { data: basic, error: e2 } = await supabase
.from('creatives')
.select('id,title,status,mime_type,bytes,created_at,updated_at')
.order('updated_at', { ascending: false });
if (e2) {
return NextResponse.json(
{ creatives: [], warning: error.message },
{ status: 200 }
);
}
return NextResponse.json({
creatives: basic ?? [],
isAgency: isAgencyRole(userRole(user)),
warning:
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
});
// Si es cliente, aplicar visibilidad estricta
if (!isAgency) {
query = query
.eq('visibility', 'client')
.in('status', ['pendiente_aprobacion', 'aprobada', 'publicada', 'fatigada', 'retirada', 'rechazada']);
}
const { data: rows, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const creatives = rows || [];
// Obtener métricas agregadas de los últimos 30 días para estas creatividades
const creativeIds = creatives.map((c: any) => c.id);
const metricsMap: Record<string, any> = {};
if (creativeIds.length > 0) {
const { data: metrics } = await admin
.from('creative_metrics_daily')
.select('creative_id, impressions, clicks, spend, results, frequency, ctr, cpa')
.in('creative_id', creativeIds);
if (metrics && metrics.length > 0) {
for (const m of metrics) {
const cid = m.creative_id;
metricsMap[cid] = metricsMap[cid] || {
impressions: 0,
clicks: 0,
spend: 0,
results: 0,
count: 0,
freq_sum: 0,
ctr_sum: 0,
cpa_sum: 0,
cpa_count: 0,
};
metricsMap[cid].impressions += Number(m.impressions || 0);
metricsMap[cid].clicks += Number(m.clicks || 0);
metricsMap[cid].spend += Number(m.spend || 0);
metricsMap[cid].results += Number(m.results || 0);
metricsMap[cid].count += 1;
metricsMap[cid].freq_sum += Number(m.frequency || 0);
metricsMap[cid].ctr_sum += Number(m.ctr || 0);
if (m.cpa != null) {
metricsMap[cid].cpa_sum += Number(m.cpa);
metricsMap[cid].cpa_count += 1;
}
}
}
}
// Adjuntar métricas resumidas
const enriched = creatives.map((c: any) => {
const agg = metricsMap[c.id];
const metrics = agg
? {
impressions: agg.impressions,
clicks: agg.clicks,
spend: Number(agg.spend.toFixed(2)),
results: agg.results,
frequency: agg.count > 0 ? Number((agg.freq_sum / agg.count).toFixed(2)) : 0,
ctr: agg.impressions > 0 ? Number(((agg.clicks / agg.impressions) * 100).toFixed(2)) : 0,
cpa: agg.cpa_count > 0 ? Number((agg.cpa_sum / agg.cpa_count).toFixed(2)) : null,
}
: null;
return {
...c,
metrics,
};
});
return NextResponse.json({
creatives: data ?? [],
isAgency: isAgencyRole(userRole(user)),
creatives: sanitizeForRole(enriched, role),
isAgency,
tenantId,
});
}

View File

@@ -150,8 +150,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: up.error.message }, { status: 500 });
}
const mediaUrl = `/api/creatives/media?id=${encodeURIComponent(creativeId)}`;
const patch = {
const mediaUrl = `/api/creatives/media?path=${encodeURIComponent(storagePath)}`;
const isFinal =
form.get('is_final') === 'true' ||
form.get('asset_final') === 'true';
const patch: Record<string, any> = {
storage_path: storagePath,
mime_type: mime,
bytes: file.size,
@@ -159,6 +164,12 @@ export async function POST(req: NextRequest) {
updated_at: new Date().toISOString(),
};
if (isFinal) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.4ventures.es';
patch.asset_url = `${appUrl.replace(/\/$/, '')}${mediaUrl}`;
patch.asset_final = true;
}
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,

View File

@@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { createClient as createAdminClient } from '@supabase/supabase-js';
const DEFAULT_RULES = {
lookback_days: 7,
min_impressions: 1000,
max_frequency: 2.8,
ctr_drop_pct: 35,
cpa_rise_pct: 40,
signals_required: 2,
auto_request_relief: true,
};
function getAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createAdminClient(url, key);
}
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
const isAgency = isAgencyRole(role);
const { searchParams } = new URL(req.url);
const queryTenantId = searchParams.get('tenant_id');
let tenantId: string | null = null;
if (isAgency && queryTenantId) {
tenantId = queryTenantId;
} else {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId;
}
if (!tenantId) {
return NextResponse.json({ error: 'Tenant context required' }, { status: 400 });
}
const admin = getAdmin();
const { data: rules, error } = await admin
.from('fatigue_rules')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
if (!rules) {
return NextResponse.json({
rules: {
tenant_id: tenantId,
...DEFAULT_RULES,
},
isDefault: true,
});
}
return NextResponse.json({ rules, isDefault: false });
}
export async function PUT(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
if (!isAgencyRole(role)) {
return NextResponse.json({ error: 'Solo la agencia puede modificar reglas de fatiga' }, { status: 403 });
}
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const tenantId = body.tenant_id;
if (!tenantId) {
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
}
const payload = {
tenant_id: tenantId,
lookback_days: Math.max(1, Number(body.lookback_days || DEFAULT_RULES.lookback_days)),
min_impressions: Math.max(0, Number(body.min_impressions || DEFAULT_RULES.min_impressions)),
max_frequency: Math.max(1, Number(body.max_frequency || DEFAULT_RULES.max_frequency)),
ctr_drop_pct: Math.max(1, Number(body.ctr_drop_pct || DEFAULT_RULES.ctr_drop_pct)),
cpa_rise_pct: Math.max(1, Number(body.cpa_rise_pct || DEFAULT_RULES.cpa_rise_pct)),
signals_required: Math.min(3, Math.max(1, Number(body.signals_required || DEFAULT_RULES.signals_required))),
auto_request_relief: body.auto_request_relief !== undefined ? Boolean(body.auto_request_relief) : DEFAULT_RULES.auto_request_relief,
updated_at: new Date().toISOString(),
};
const admin = getAdmin();
const { data: updated, error } = await admin
.from('fatigue_rules')
.upsert(payload, { onConflict: 'tenant_id' })
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true, rules: updated });
}

View File

@@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { createClient as createAdminClient } from '@supabase/supabase-js';
function getAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createAdminClient(url, key);
}
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
const isAgency = isAgencyRole(role);
const { searchParams } = new URL(req.url);
const queryTenantId = searchParams.get('tenant_id');
let tenantId: string | null = null;
if (isAgency && queryTenantId) {
tenantId = queryTenantId;
} else {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId;
}
if (!tenantId) {
return NextResponse.json({ error: 'Tenant context required' }, { status: 400 });
}
const admin = getAdmin();
const { data: requests, error } = await admin
.from('generation_requests')
.select('*')
.eq('tenant_id', tenantId)
.order('requested_at', { ascending: false })
.limit(50);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ requests });
}
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
if (!isAgencyRole(role)) {
return NextResponse.json({ error: 'Solo la agencia puede solicitar relevos a Claude' }, { status: 403 });
}
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const tenantId = body.tenant_id;
if (!tenantId) {
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
}
const admin = getAdmin();
// Get tenant slug
const { data: tenant } = await admin
.from('tenants')
.select('slug, name')
.eq('id', tenantId)
.maybeSingle();
const brief = {
tenant_id: tenantId,
client_id: tenantId,
client_slug: tenant?.slug || 'cliente',
reason: body.reason || 'manual',
source_creative_id: body.source_creative_id || null,
format: body.format || 'estatico',
variants_requested: Number(body.variants_requested || 3),
campaign_name: body.campaign_name || null,
adset_id: body.adset_id || null,
notes: body.notes || null,
};
const { data: created, error } = await admin
.from('generation_requests')
.insert({
tenant_id: tenantId,
reason: brief.reason,
source_creative_id: brief.source_creative_id,
brief,
status: 'pendiente',
})
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Registrar en creative_events
await admin.from('creative_events').insert({
tenant_id: tenantId,
creative_id: brief.source_creative_id,
event_type: 'generation_requested',
actor: `user:${user.id}`,
from_status: null,
to_status: null,
payload: {
generation_request_id: created.id,
reason: brief.reason,
variants_requested: brief.variants_requested,
},
visibility: 'agency',
});
return NextResponse.json({ ok: true, request: created });
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import AgencyCreativesView from '@/components/AgencyCreativesView';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
export const dynamic = 'force-dynamic';
export default async function AgencyClienteCreatividadesPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
if (!isAgencyRole(userRole(user))) redirect('/dashboard/kpis');
let name = 'Cliente';
const headers = serviceHeaders();
if (headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(id)}&select=name,slug`,
{ headers, cache: 'no-store' }
);
if (r.ok) {
const rows = await r.json();
if (rows[0]?.name) name = rows[0].name;
}
}
return <AgencyCreativesView tenantId={id} clientName={name} />;
}

View File

@@ -1,11 +1,46 @@
/**
* Pipeline de creatividades — producción editorial (agencia programa IG/FB).
* El cliente solo ve y aprueba/rechaza en revisión interna.
* Pipeline de creatividades — selector entre Aprobación Meta Ads y Pipeline RRSS.
*/
'use client';
import React, { useState } from 'react';
import CreativePipelineBoard from '@/components/CreativePipelineBoard';
import ClientCreativesView from '@/components/ClientCreativesView';
import { Sparkles, CalendarDays } from 'lucide-react';
export default function CreativesPage() {
return <CreativePipelineBoard />;
const [mode, setMode] = useState<'ads' | 'social'>('ads');
return (
<div className="space-y-4">
<div className="flex items-center gap-2 px-4 pt-2 border-b border-gray-200">
<button
type="button"
onClick={() => setMode('ads')}
className={`flex items-center gap-1.5 pb-2.5 px-3 text-xs font-semibold border-b-2 transition-colors ${
mode === 'ads'
? 'border-brand-500 text-brand-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Sparkles className="w-3.5 h-3.5" />
Aprobación & Anuncios Meta Ads
</button>
<button
type="button"
onClick={() => setMode('social')}
className={`flex items-center gap-1.5 pb-2.5 px-3 text-xs font-semibold border-b-2 transition-colors ${
mode === 'social'
? 'border-brand-500 text-brand-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<CalendarDays className="w-3.5 h-3.5" />
Pipeline Editorial RRSS
</button>
</div>
{mode === 'ads' ? <ClientCreativesView /> : <CreativePipelineBoard />}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { resolveActiveTenant } from '@/lib/tenant-context';
import ClientCreativesView from '@/components/ClientCreativesView';
export const dynamic = 'force-dynamic';
export default async function CreatividadesPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const ctx = await resolveActiveTenant(user);
return <ClientCreativesView tenantName={ctx.label || 'Cliente'} />;
}