diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/api/creatives/[id]/action/route.ts b/src/app/api/creatives/[id]/action/route.ts index 45ba2c3..46c75f6 100644 --- a/src/app/api/creatives/[id]/action/route.ts +++ b/src/app/api/creatives/[id]/action/route.ts @@ -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 { diff --git a/src/app/api/creatives/[id]/route.ts b/src/app/api/creatives/[id]/route.ts new file mode 100644 index 0000000..966a4f9 --- /dev/null +++ b/src/app/api/creatives/[id]/route.ts @@ -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 = { + 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 }); +} diff --git a/src/app/api/creatives/route.ts b/src/app/api/creatives/route.ts index 7328aeb..b25ede0 100644 --- a/src/app/api/creatives/route.ts +++ b/src/app/api/creatives/route.ts @@ -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 = {}; + + 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, }); } diff --git a/src/app/api/creatives/upload/route.ts b/src/app/api/creatives/upload/route.ts index a01d2a4..075fcb4 100644 --- a/src/app/api/creatives/upload/route.ts +++ b/src/app/api/creatives/upload/route.ts @@ -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 = { 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)}`, diff --git a/src/app/api/fatigue/rules/route.ts b/src/app/api/fatigue/rules/route.ts new file mode 100644 index 0000000..3e502b9 --- /dev/null +++ b/src/app/api/fatigue/rules/route.ts @@ -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 }); +} diff --git a/src/app/api/generation-requests/route.ts b/src/app/api/generation-requests/route.ts new file mode 100644 index 0000000..72c4791 --- /dev/null +++ b/src/app/api/generation-requests/route.ts @@ -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 }); +} diff --git a/src/app/dashboard/agencia/cliente/[id]/creatividades/page.tsx b/src/app/dashboard/agencia/cliente/[id]/creatividades/page.tsx new file mode 100644 index 0000000..b1b29c6 --- /dev/null +++ b/src/app/dashboard/agencia/cliente/[id]/creatividades/page.tsx @@ -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 ; +} diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx index 69dd134..88ec3ec 100644 --- a/src/app/dashboard/creatives/page.tsx +++ b/src/app/dashboard/creatives/page.tsx @@ -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 ; + const [mode, setMode] = useState<'ads' | 'social'>('ads'); + + return ( +
+
+ + +
+ + {mode === 'ads' ? : } +
+ ); } diff --git a/src/app/dashboard/creatividades/page.tsx b/src/app/dashboard/creatividades/page.tsx new file mode 100644 index 0000000..70db5ab --- /dev/null +++ b/src/app/dashboard/creatividades/page.tsx @@ -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 ; +} diff --git a/src/components/AgencyCreativesView.tsx b/src/components/AgencyCreativesView.tsx new file mode 100644 index 0000000..e90854b --- /dev/null +++ b/src/components/AgencyCreativesView.tsx @@ -0,0 +1,1130 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import Link from 'next/link'; +import { + ArrowLeft, + Sparkles, + AlertTriangle, + Upload, + Send, + CheckCircle2, + XCircle, + Eye, + Clock, + History, + Settings, + RefreshCw, + Plus, + Play, + Layers, + ChevronRight, + ExternalLink, + Flame, + Archive, + BarChart2, + FileCheck2, + Filter, +} from 'lucide-react'; +import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui'; + +export type CreativeFormat = 'estatico' | 'carrusel' | 'video'; + +export type CreativeItem = { + id: string; + tenant_id: string; + campaign_name?: string | null; + adset_id?: string | null; + meta_ad_id?: string | null; + format: string; + angle?: string | null; + hook?: string | null; + primary_text?: string | null; + headline?: string | null; + cta?: string | null; + asset_url?: string | null; + thumbnail_url?: string | null; + status: string; + visibility: string; + parent_id?: string | null; + generation_request_id?: string | null; + asset_final: boolean; + created_by?: string; + created_at: string; + updated_at: string; + metrics?: { + impressions: number; + clicks: number; + spend: number; + results: number; + frequency: number; + ctr: number; + cpa: number | null; + } | null; +}; + +export type FatigueRules = { + tenant_id: string; + lookback_days: number; + min_impressions: number; + max_frequency: number; + ctr_drop_pct: number; + cpa_rise_pct: number; + signals_required: number; + auto_request_relief: boolean; +}; + +export type GenerationRequest = { + id: string; + tenant_id: string; + reason: string; + brief: { + format: string; + variants_requested: number; + notes?: string; + }; + status: string; + cost_estimate?: Record | null; + requested_at: string; + completed_at?: string | null; +}; + +const KANBAN_COLUMNS = [ + { id: 'borrador', label: 'Borrador', color: 'border-amber-400 bg-amber-50/30' }, + { id: 'pendiente_aprobacion', label: 'Pendiente Cliente', color: 'border-blue-400 bg-blue-50/30' }, + { id: 'aprobada', label: 'Aprobada', color: 'border-emerald-400 bg-emerald-50/30' }, + { id: 'publicada', label: 'Publicada Meta', color: 'border-indigo-400 bg-indigo-50/30' }, + { id: 'fatigada', label: 'Fatigada', color: 'border-rose-400 bg-rose-50/30' }, + { id: 'rechazada', label: 'Rechazada', color: 'border-red-400 bg-red-50/30' }, + { id: 'retirada', label: 'Retirada', color: 'border-gray-300 bg-gray-50/40' }, +] as const; + +export default function AgencyCreativesView({ + tenantId, + clientName, +}: { + tenantId: string; + clientName: string; +}) { + const [activeTab, setActiveTab] = useState<'kanban' | 'fatiga' | 'cola_claude'>('kanban'); + const [creatives, setCreatives] = useState([]); + const [rules, setRules] = useState(null); + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Modales + const [uploadModalCreative, setUploadModalCreative] = useState(null); + const [uploadFile, setUploadFile] = useState(null); + const [uploadUrl, setUploadUrl] = useState(''); + const [isUploading, setIsUploading] = useState(false); + + const [reliefModalCreative, setReliefModalCreative] = useState(null); + const [newRequestModal, setNewRequestModal] = useState(false); + const [variantsCount, setVariantsCount] = useState(3); + const [requestFormat, setRequestFormat] = useState<'estatico' | 'carrusel' | 'video'>('estatico'); + const [requestNotes, setRequestNotes] = useState(''); + const [isRequesting, setIsRequesting] = useState(false); + + const [savingRules, setSavingRules] = useState(false); + const [rulesSuccess, setRulesSuccess] = useState(false); + + const [detailCreative, setDetailCreative] = useState(null); + const [detailEvents, setDetailEvents] = useState([]); + + // 1. Cargar datos + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [resCreatives, resRules, resReqs] = await Promise.all([ + fetch(`/api/creatives?tenant_id=${encodeURIComponent(tenantId)}`), + fetch(`/api/fatigue/rules?tenant_id=${encodeURIComponent(tenantId)}`), + fetch(`/api/generation-requests?tenant_id=${encodeURIComponent(tenantId)}`), + ]); + + if (!resCreatives.ok) throw new Error('Error al cargar creatividades'); + const dataCreatives = await resCreatives.json(); + setCreatives(dataCreatives.creatives || []); + + if (resRules.ok) { + const dataRules = await resRules.json(); + setRules(dataRules.rules); + } + + if (resReqs.ok) { + const dataReqs = await resReqs.json(); + setRequests(dataReqs.requests || []); + } + } catch (err: any) { + setError(err.message || 'Error al cargar datos'); + } finally { + setLoading(false); + } + }, [tenantId]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + // 2. Acción: Enviar al cliente + const handleSendToClient = async (creative: CreativeItem) => { + if (!creative.asset_final) { + alert('Solo se puede enviar al cliente si cuenta con imagen final compuesta (asset_final=true). Por favor sube la imagen final primero.'); + return; + } + + try { + const res = await fetch(`/api/creatives/${creative.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'send_to_client' }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al enviar'); + await loadData(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + }; + + // 3. Acción: Cambiar estado manual + const handleChangeStatus = async (creativeId: string, nextStatus: string) => { + try { + const res = await fetch(`/api/creatives/${creativeId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: nextStatus }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'No se pudo actualizar estado'); + } + await loadData(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + }; + + // 4. Acción: Subir imagen final + const handleSaveFinalAsset = async () => { + if (!uploadModalCreative) return; + setIsUploading(true); + + try { + if (uploadFile) { + const formData = new FormData(); + formData.append('file', uploadFile); + formData.append('creativeId', uploadModalCreative.id); + formData.append('is_final', 'true'); + + const res = await fetch('/api/creatives/upload', { + method: 'POST', + body: formData, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al subir archivo'); + } else if (uploadUrl.trim()) { + const res = await fetch(`/api/creatives/${uploadModalCreative.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asset_url: uploadUrl.trim(), + asset_final: true, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al actualizar URL'); + } else { + throw new Error('Selecciona un archivo o introduce una URL'); + } + + setUploadModalCreative(null); + setUploadFile(null); + setUploadUrl(''); + await loadData(); + } catch (err: any) { + alert(err.message); + } finally { + setIsUploading(false); + } + }; + + // 5. Acción: Pedir relevo a Claude + const handleRequestGeneration = async (sourceCreativeId?: string) => { + setIsRequesting(true); + try { + const res = await fetch('/api/generation-requests', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tenant_id: tenantId, + reason: sourceCreativeId ? 'fatiga' : 'manual', + source_creative_id: sourceCreativeId || null, + format: requestFormat, + variants_requested: variantsCount, + notes: requestNotes, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al solicitar variantes'); + + setReliefModalCreative(null); + setNewRequestModal(false); + setRequestNotes(''); + await loadData(); + alert('¡Petición enviada! El Workflow 3 de Claude la procesará en su próxima ejecución.'); + } catch (err: any) { + alert(err.message); + } finally { + setIsRequesting(false); + } + }; + + // 6. Guardar reglas de fatiga + const handleSaveRules = async (e: React.FormEvent) => { + e.preventDefault(); + if (!rules) return; + setSavingRules(true); + setRulesSuccess(false); + + try { + const res = await fetch('/api/fatigue/rules', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rules), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al guardar reglas'); + setRulesSuccess(true); + setTimeout(() => setRulesSuccess(false), 4000); + } catch (err: any) { + alert(err.message); + } finally { + setSavingRules(false); + } + }; + + // 7. Ver detalle / eventos + const handleOpenDetail = async (c: CreativeItem) => { + setDetailCreative(c); + try { + const res = await fetch(`/api/creatives/${c.id}`); + if (res.ok) { + const data = await res.json(); + setDetailEvents(data.events || []); + } + } catch { + setDetailEvents([]); + } + }; + + // Columnas Kanban + const kanbanGroups = useMemo(() => { + const groups: Record = {}; + for (const col of KANBAN_COLUMNS) groups[col.id] = []; + + for (const c of creatives) { + let st = c.status; + if (st === 'draft') st = 'borrador'; + if (st === 'in_review') st = 'pendiente_aprobacion'; + if (st === 'approved') st = 'aprobada'; + if (st === 'rejected') st = 'rechazada'; + + if (groups[st]) { + groups[st].push(c); + } else { + groups['borrador'].push(c); + } + } + return groups; + }, [creatives]); + + return ( +
+ {/* Cabecera */} +
+
+ + + Volver a ficha del cliente + +
+

+ Creatividades · {clientName} +

+ + + IA & Meta Ads + +
+

+ Gestión integral de relevo con Claude (WF3), detector de fatiga (WF2) y publicación en Meta (WF4). +

+
+ +
+ + +
+
+ + {/* Selector de Pestañas Principales */} +
+ r.status === 'pendiente' || r.status === 'en_proceso').length} activas)` }, + ]} + onChange={(v) => setActiveTab(v as any)} + /> +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {/* 1. VISTA KANBAN */} + {activeTab === 'kanban' && ( +
+
+ {KANBAN_COLUMNS.map((col) => { + const items = kanbanGroups[col.id] || []; + return ( +
+ {/* Encabezado Columna */} +
+
+ +

+ {col.label} +

+
+ + {items.length} + +
+ + {/* Lista de Tarjetas */} +
+ {items.length === 0 ? ( +
+ Sin piezas +
+ ) : ( + items.map((c) => ( +
+ {/* Miniatura / Media */} +
+ {c.asset_url || c.thumbnail_url ? ( + {c.headline + ) : ( +
+ + Sin imagen asignada +
+ )} + + {/* Badge asset_final */} +
+ {c.asset_final ? ( + + + Final compuesta + + ) : ( + + + Requiere final + + )} +
+ + {/* Badge Formato */} +
+ + {c.format || 'estatico'} + +
+
+ + {/* Info Titular y Copy */} +
+

+ {c.headline || c.hook || 'Sin titular'} +

+ {c.primary_text ? ( +

+ {c.primary_text} +

+ ) : null} +
+ + {/* Meta: Ángulo & Campaña */} +
+ {c.angle ? ( + + {c.angle} + + ) : null} + {c.campaign_name ? ( + + {c.campaign_name} + + ) : null} +
+ + {/* Métricas si existen */} + {c.metrics ? ( +
+
+ CTR + {c.metrics.ctr}% +
+
+ Frec. + {c.metrics.frequency} +
+
+ Gasto + {c.metrics.spend}€ +
+
+ ) : null} + + {/* Botonera de Acciones de la Tarjeta */} +
+
+ {/* Botón Subir Imagen Final */} + + + {/* Botón Enviar al Cliente (solo si está en borrador o pendiente) */} + {col.id === 'borrador' ? ( + + ) : null} + + {/* Botón Pedir Relevo si está fatigada o rechazada */} + {col.id === 'fatigada' || col.id === 'rechazada' ? ( + + ) : null} +
+ + {/* Selector rápido para mover de columna */} +
+ + + +
+
+
+ )) + )} +
+
+ ); + })} +
+
+ )} + + {/* 2. PANEL DE FATIGA Y REGLAS */} + {activeTab === 'fatiga' && ( +
+ {/* Formulario de Reglas */} +
+ +
+

+ + Reglas de Fatiga del Cliente +

+

+ WF2 evalúa a las 07:15 cada creatividad activa contra estos umbrales para marcar fatiga automáticamente. +

+
+ + {rules ? ( +
+
+ + setRules({ ...rules, lookback_days: Number(e.target.value) })} + className="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-brand-500" + /> + Días recientes frente a la mejor semana de los 28 anteriores. +
+ +
+ + setRules({ ...rules, min_impressions: Number(e.target.value) })} + className="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-brand-500" + /> +
+ +
+ + setRules({ ...rules, max_frequency: Number(e.target.value) })} + className="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-brand-500" + /> +
+ +
+ + setRules({ ...rules, ctr_drop_pct: Number(e.target.value) })} + className="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-brand-500" + /> +
+ +
+ + setRules({ ...rules, cpa_rise_pct: Number(e.target.value) })} + className="w-full rounded-lg border border-gray-300 p-2 text-sm focus:border-brand-500" + /> +
+ +
+ + +
+ +
+ setRules({ ...rules, auto_request_relief: e.target.checked })} + className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500" + /> + +
+ + {rulesSuccess ? ( +
+ ✓ Reglas actualizadas con éxito +
+ ) : null} + + +
+ ) : ( +
Cargando reglas...
+ )} +
+
+ + {/* Listado de Creatividades Fatigadas */} +
+ +
+
+

+ + Creatividades Marcadas por Fatiga +

+

+ Creatividades que alcanzaron los umbrales de saturación y requieren reemplazo. +

+
+ + {kanbanGroups['fatigada']?.length || 0} fatigadas + +
+ +
+ {(!kanbanGroups['fatigada'] || kanbanGroups['fatigada'].length === 0) ? ( + + ) : ( + kanbanGroups['fatigada'].map((c) => ( +
+
+
+ {c.asset_url || c.thumbnail_url ? ( + + ) : ( + + )} +
+
+

{c.headline || c.hook || c.id}

+

{c.campaign_name || 'Campaña Meta'} · {c.format}

+ {c.metrics ? ( +
+ Frec: {c.metrics.frequency} + • + CTR: {c.metrics.ctr}% + • + Gasto: {c.metrics.spend}€ +
+ ) : null} +
+
+ + +
+ )) + )} +
+
+
+
+ )} + + {/* 3. COLA DE CLAUDE (GENERATION REQUESTS) */} + {activeTab === 'cola_claude' && ( + +
+
+

+ + Historial y Cola de Relevos de Claude (WF3) +

+

+ Peticiones procesadas por Claude Opus 5-5 y Freepik Mystic a 4:5. +

+
+ +
+ +
+ + + + + + + + + + + + + {requests.length === 0 ? ( + + + + ) : ( + requests.map((r) => ( + + + + + + + + + )) + )} + +
FechaMotivoFormato / VariantesEstadoCoste estimadoDetalle
+ Sin peticiones registradas +
+ {new Date(r.requested_at).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })} + + + {r.reason} + + + {r.brief?.format || 'estatico'} · {r.brief?.variants_requested || 3} variantes + + {r.status === 'completada' ? ( + + Completada + + ) : r.status === 'en_proceso' ? ( + + En proceso + + ) : r.status === 'fallida' ? ( + + Fallida + + ) : ( + + Pendiente + + )} + + {r.cost_estimate ? ( + + {r.cost_estimate.tokens || 0} tokens · {r.cost_estimate.image_generations || 0} imgs + + ) : ( + '—' + )} + + {r.brief?.notes || r.id} +
+
+
+ )} + + {/* MODAL: SUBIR IMAGEN FINAL */} + {uploadModalCreative ? ( +
+
+
+

Subir Imagen Final Compuesta

+ +
+ +

+ Esta imagen debe incluir el diseño final terminado (con texto y logotipo según la plantilla). Al guardarla, se marcará asset_final = true y se habilitará el botón para enviar al cliente. +

+ +
+
+ + setUploadFile(e.target.files?.[0] || null)} + className="w-full text-xs text-gray-500 file:mr-2 file:py-2 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-brand-50 file:text-brand-700 hover:file:bg-brand-100" + /> +
+ +
+
+ o bien +
+
+ +
+ + setUploadUrl(e.target.value)} + className="w-full rounded-lg border border-gray-300 p-2 text-xs focus:border-brand-500" + /> +
+
+ +
+ + +
+
+
+ ) : null} + + {/* MODAL: PEDIR RELEVO / NUEVA PETICIÓN */} + {(newRequestModal || reliefModalCreative) ? ( +
+
+
+

+ + {reliefModalCreative ? 'Solicitar Relevo Creativo' : 'Nueva Petición a Claude'} +

+ +
+ + {reliefModalCreative ? ( +
+ Creatividad base a relevar: + {reliefModalCreative.headline || reliefModalCreative.id} +
+ ) : null} + +
+
+ + +
+ +
+ + setVariantsCount(Number(e.target.value))} + className="w-full rounded-lg border border-gray-300 p-2 focus:border-brand-500" + /> +
+ +
+ +