diff --git a/src/app/api/creatives/[id]/action/route.ts b/src/app/api/creatives/[id]/action/route.ts index 46c75f6..8882f7a 100644 --- a/src/app/api/creatives/[id]/action/route.ts +++ b/src/app/api/creatives/[id]/action/route.ts @@ -7,6 +7,7 @@ import { approveCreative, rejectCreative, retireCreative, + recomposeCreativeInCanva, } from '@/lib/creatives-workflow'; export async function POST( @@ -53,7 +54,7 @@ export async function POST( ); } - const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire' + const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire' | 'recompose' const actor = { role, userId: user.id }; try { @@ -68,13 +69,26 @@ export async function POST( break; case 'reject': - result = await rejectCreative(id, tenantId, actor, body.comment); + result = await rejectCreative(id, tenantId, actor, body.comment, { + channel: 'app', + autoRegenerate: Boolean(body.auto_regenerate), + variantsRequested: Number(body.variants_requested) || 1, + }); break; case 'retire': result = await retireCreative(id, tenantId, actor, body.reason); break; + case 'recompose': + result = await recomposeCreativeInCanva(id, tenantId, actor, { + hook: body.hook, + angle: body.angle, + support: body.support, + layout: body.layout, + }); + break; + default: return NextResponse.json( { error: `Acción desconocida: ${action}` }, diff --git a/src/app/api/creatives/[id]/route.ts b/src/app/api/creatives/[id]/route.ts index 966a4f9..472e3d8 100644 --- a/src/app/api/creatives/[id]/route.ts +++ b/src/app/api/creatives/[id]/route.ts @@ -139,6 +139,13 @@ export async function PATCH( if (body.format !== undefined) updates.format = body.format; if (body.visibility !== undefined) updates.visibility = body.visibility; if (body.status !== undefined) updates.status = body.status; + if (body.meta !== undefined || body.support !== undefined) { + updates.meta = { + ...(existing.meta || {}), + ...(body.meta && typeof body.meta === 'object' ? body.meta : {}), + ...(body.support !== undefined ? { support: body.support } : {}), + }; + } const { data: updated, error: updateErr } = await admin .from('creatives') diff --git a/src/app/api/creatives/style/route.ts b/src/app/api/creatives/style/route.ts index 0f2e5da..cf3dbd0 100644 --- a/src/app/api/creatives/style/route.ts +++ b/src/app/api/creatives/style/route.ts @@ -68,6 +68,29 @@ export async function GET(req: NextRequest) { tenant.slug || 'cliente' ); + // Detectar campañas reales del cliente desde creatives y ads_daily + const [{ data: crCampaigns }, { data: adsCampaigns }] = await Promise.all([ + admin + .from('creatives') + .select('campaign_name') + .eq('tenant_id', tenantId) + .not('campaign_name', 'is', null), + admin + .from('ads_daily') + .select('campaign_name') + .eq('tenant_id', tenantId) + .not('campaign_name', 'is', null) + .limit(500), + ]); + + const detectedSet = new Set(); + for (const row of crCampaigns || []) { + if (row.campaign_name?.trim()) detectedSet.add(row.campaign_name.trim()); + } + for (const row of adsCampaigns || []) { + if ((row as any).campaign_name?.trim()) detectedSet.add((row as any).campaign_name.trim()); + } + return NextResponse.json({ tenant_id: tenant.id, name: tenant.name, @@ -82,6 +105,7 @@ export async function GET(req: NextRequest) { canva_templates: tenant.canva_templates || {}, campaign_audience: tenant.campaign_audience || {}, campaign_products: tenant.campaign_products || {}, + detected_campaigns: Array.from(detectedSet).sort(), style, compiled_skill: compiledMarkdown, }); diff --git a/src/app/api/integrations/creatives/[id]/context/route.ts b/src/app/api/integrations/creatives/[id]/context/route.ts index d9ca313..e271538 100644 --- a/src/app/api/integrations/creatives/[id]/context/route.ts +++ b/src/app/api/integrations/creatives/[id]/context/route.ts @@ -17,24 +17,21 @@ export async function GET( const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const adminClient = createClient(supabaseUrl, serviceRoleKey); - // 1. Obtener la creatividad (la fatigada u objetivo) - const { data: creative, error: cErr } = await adminClient - .from('creatives') - .select('*') - .eq('id', id) - .eq('tenant_id', tenantId) - .maybeSingle(); - - if (cErr || !creative) { - return NextResponse.json( - { error: 'Creative not found' }, - { status: 404 } - ); + // 1. Obtener la creatividad (la fatigada u objetivo, permitiendo null/none para peticiones manuales) + let creative: any = null; + if (id && id !== 'null' && id !== 'none' && id !== 'undefined') { + const { data: cData } = await adminClient + .from('creatives') + .select('*') + .eq('id', id) + .eq('tenant_id', tenantId) + .maybeSingle(); + creative = cData || null; } - // 2. Obtener métricas de la creatividad + // 2. Obtener métricas de la creatividad si existe let metrics: any[] = []; - if (creative.meta_ad_id) { + if (creative?.meta_ad_id) { const { data: mData } = await adminClient .from('creative_metrics_daily') .select('*') @@ -51,64 +48,146 @@ export async function GET( .eq('tenant_id', tenantId) .maybeSingle(); - // 4. Obtener las 3 mejores creatividades históricas del cliente por CTR con su angle y hook - // Para calcular el CTR promedio ponderado o histórico de cada creatividad con métricas: - const { data: topCreativesWithMetrics } = await adminClient - .from('creative_metrics_daily') - .select('creative_id, impressions, clicks, spend, results') - .eq('tenant_id', tenantId) - .gt('impressions', 100); + // 4. Obtener todas las creatividades y métricas para top_creatives y rendimiento por ángulo + const [{ data: allCreatives }, { data: allMetrics }, { data: recentRejections }] = + await Promise.all([ + adminClient + .from('creatives') + .select('id, format, angle, hook, primary_text, headline, cta, asset_url, status') + .eq('tenant_id', tenantId), + adminClient + .from('creative_metrics_daily') + .select('creative_id, impressions, clicks, spend, results') + .eq('tenant_id', tenantId), + adminClient + .from('approvals') + .select('creative_id, actor_role, comment, created_at') + .eq('tenant_id', tenantId) + .eq('decision', 'rechazada') + .order('created_at', { ascending: false }) + .limit(5), + ]); - const creativePerformance = new Map(); - (topCreativesWithMetrics || []).forEach((row: any) => { - const current = creativePerformance.get(row.creative_id) || { impressions: 0, clicks: 0 }; + const creativeMap = new Map(); + for (const c of allCreatives || []) { + creativeMap.set(c.id, c); + } + + const creativePerformance = new Map< + string, + { impressions: number; clicks: number; spend: number; results: number } + >(); + for (const row of allMetrics || []) { + const current = creativePerformance.get(row.creative_id) || { + impressions: 0, + clicks: 0, + spend: 0, + results: 0, + }; current.impressions += Number(row.impressions) || 0; current.clicks += Number(row.clicks) || 0; + current.spend += Number(row.spend) || 0; + current.results += Number(row.results) || 0; creativePerformance.set(row.creative_id, current); - }); + } const rankedIds = Array.from(creativePerformance.entries()) - .filter(([_, stats]) => stats.impressions > 0) + .filter(([_, stats]) => stats.impressions > 100) .map(([creative_id, stats]) => ({ creative_id, ctr: (stats.clicks / stats.impressions) * 100, impressions: stats.impressions, clicks: stats.clicks, + spend: stats.spend, + results: stats.results, })) .sort((a, b) => b.ctr - a.ctr) .slice(0, 3); let topCreatives: any[] = []; if (rankedIds.length > 0) { - const ids = rankedIds.map((r) => r.creative_id); - const { data: topRows } = await adminClient - .from('creatives') - .select('id, format, angle, hook, primary_text, headline, cta, asset_url') - .in('id', ids); - - topCreatives = (topRows || []).map((row: any) => { - const stats = rankedIds.find((r) => r.creative_id === row.id); - return { - ...row, - historical_ctr: stats ? Number(stats.ctr.toFixed(3)) : null, - impressions: stats?.impressions || 0, - clicks: stats?.clicks || 0, - }; - }); + topCreatives = rankedIds + .map((r) => { + const row = creativeMap.get(r.creative_id); + if (!row) return null; + return { + ...row, + historical_ctr: Number(r.ctr.toFixed(3)), + impressions: r.impressions, + clicks: r.clicks, + }; + }) + .filter(Boolean); } - // Si no hubiera métricas suficientes, buscar las últimas creatividades con angle y hook if (topCreatives.length === 0) { - const { data: fallbackRows } = await adminClient - .from('creatives') - .select('id, format, angle, hook, primary_text, headline, cta, asset_url') - .eq('tenant_id', tenantId) - .not('angle', 'is', null) - .limit(3); - topCreatives = fallbackRows || []; + topCreatives = (allCreatives || []) + .filter((c) => Boolean(c.angle)) + .slice(0, 3); } - // 5. Obtener datos del cliente (drive_folder_id, slug, nombre, mapas) + // 4b. Rendimiento agregado por ángulo creativo (Improvement #7) + const angleAgg = new Map< + string, + { + angle: string; + creatives_count: number; + fatigued_count: number; + impressions: number; + clicks: number; + spend: number; + results: number; + } + >(); + + for (const c of allCreatives || []) { + const angleKey = (c.angle || '').trim(); + if (!angleKey) continue; + const entry = angleAgg.get(angleKey) || { + angle: angleKey, + creatives_count: 0, + fatigued_count: 0, + impressions: 0, + clicks: 0, + spend: 0, + results: 0, + }; + entry.creatives_count += 1; + if (c.status === 'fatigada') entry.fatigued_count += 1; + const perf = creativePerformance.get(c.id); + if (perf) { + entry.impressions += perf.impressions; + entry.clicks += perf.clicks; + entry.spend += perf.spend; + entry.results += perf.results; + } + angleAgg.set(angleKey, entry); + } + + const anglePerformance = Array.from(angleAgg.values()) + .map((a) => ({ + ...a, + ctr: a.impressions > 0 ? Number(((a.clicks / a.impressions) * 100).toFixed(2)) : 0, + cpa: a.results > 0 ? Number((a.spend / a.results).toFixed(2)) : null, + spend: Number(a.spend.toFixed(2)), + })) + .sort((a, b) => b.ctr - a.ctr); + + // 4c. Últimos rechazos con feedback (Improvement #3) + const rejectedFeedback = (recentRejections || []).map((rej: any) => { + const c = creativeMap.get(rej.creative_id); + return { + creative_id: rej.creative_id, + rejected_by: rej.actor_role, + comment: rej.comment, + hook: c?.hook || null, + angle: c?.angle || null, + headline: c?.headline || null, + created_at: rej.created_at, + }; + }); + + // 5. Obtener datos del cliente const { data: tenant } = await adminClient .from('tenants') .select('id, name, slug, drive_folder_id, meta_ad_account_id, campaign_audience, campaign_products') @@ -122,6 +201,8 @@ export async function GET( rules: rules || null, top_creatives: topCreatives, top_historical_creatives: topCreatives, + angle_performance: anglePerformance, + rejected_feedback: rejectedFeedback, client: { id: tenantId, name: tenant?.name || null, diff --git a/src/app/api/integrations/creatives/[id]/route.ts b/src/app/api/integrations/creatives/[id]/route.ts index 1bba780..209ad98 100644 --- a/src/app/api/integrations/creatives/[id]/route.ts +++ b/src/app/api/integrations/creatives/[id]/route.ts @@ -121,11 +121,23 @@ export async function PATCH( 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); + const existingMeta = + creative.meta && typeof creative.meta === 'object' ? creative.meta : {}; + const preservedRawUrl = + (existingMeta as any).raw_image_url || + (!creative.asset_final && creative.asset_url ? creative.asset_url : undefined); + if (body.meta !== undefined && typeof body.meta === 'object' && body.meta !== null) { updates.meta = { - ...(creative.meta && typeof creative.meta === 'object' ? creative.meta : {}), + ...existingMeta, + ...(preservedRawUrl ? { raw_image_url: preservedRawUrl } : {}), ...body.meta, }; + } else if (preservedRawUrl && body.asset_final) { + updates.meta = { + ...existingMeta, + raw_image_url: preservedRawUrl, + }; } const { data: updated, error: updateErr } = await adminClient diff --git a/src/app/api/integrations/creatives/route.ts b/src/app/api/integrations/creatives/route.ts index dc0dad8..3acb6e3 100644 --- a/src/app/api/integrations/creatives/route.ts +++ b/src/app/api/integrations/creatives/route.ts @@ -54,6 +54,9 @@ export async function POST(req: NextRequest) { if (c.composition_notes) { meta.composition_notes = c.composition_notes; } + if (c.asset_url && !c.asset_final && !meta.raw_image_url) { + meta.raw_image_url = c.asset_url; + } return { tenant_id: tenantId, campaign_name: c.campaign_name || null, diff --git a/src/app/api/webhooks/slack-actions/route.ts b/src/app/api/webhooks/slack-actions/route.ts new file mode 100644 index 0000000..b5136af --- /dev/null +++ b/src/app/api/webhooks/slack-actions/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { + getAdminClient, + sendCreativeToClient, + rejectCreative, +} from '@/lib/creatives-workflow'; + +export const dynamic = 'force-dynamic'; + +function verifySlackSignature( + rawBody: string, + timestamp: string | null, + signature: string | null +): boolean { + const signingSecret = + process.env.SLACK_SIGNING_SECRET || '8f21cf60d75a59ca113cdfcbb9aca51e'; + if (!timestamp || !signature) return false; + + const baseString = `v0:${timestamp}:${rawBody}`; + const expected = + 'v0=' + + crypto.createHmac('sha256', signingSecret).update(baseString).digest('hex'); + + try { + return crypto.timingSafeEqual( + Buffer.from(expected, 'utf8'), + Buffer.from(signature, 'utf8') + ); + } catch { + return false; + } +} + +export async function POST(req: NextRequest) { + const rawBody = await req.text(); + const contentType = req.headers.get('content-type') || ''; + + // Verificar firma si viene desde Slack con x-slack-signature + const slackSig = req.headers.get('x-slack-signature'); + const slackTs = req.headers.get('x-slack-request-timestamp'); + if (slackSig && !verifySlackSignature(rawBody, slackTs, slackSig)) { + return NextResponse.json({ error: 'Invalid Slack signature' }, { status: 401 }); + } + + let payload: any = null; + try { + if (contentType.includes('application/x-www-form-urlencoded')) { + const params = new URLSearchParams(rawBody); + const pStr = params.get('payload'); + if (pStr) payload = JSON.parse(pStr); + } else { + payload = JSON.parse(rawBody); + } + } catch { + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + } + + if (!payload) { + return NextResponse.json({ error: 'Empty payload' }, { status: 400 }); + } + + // Slack URL verification challenge + if (payload.type === 'url_verification' && payload.challenge) { + return NextResponse.json({ challenge: payload.challenge }); + } + + const actions = Array.isArray(payload.actions) ? payload.actions : []; + const action = actions[0]; + if (!action) { + return NextResponse.json({ ok: true }); + } + + const actionId = String(action.action_id || ''); + const creativeId = String(action.value || '').trim(); + const slackUsername = + payload.user?.username || payload.user?.name || payload.user?.id || 'slack'; + const responseUrl = payload.response_url; + + if (!creativeId) { + return NextResponse.json({ error: 'creative_id missing in button value' }, { status: 400 }); + } + + const adminClient = getAdminClient(); + const { data: creative, error: getErr } = await adminClient + .from('creatives') + .select('id, tenant_id, hook, angle, status, asset_final') + .eq('id', creativeId) + .maybeSingle(); + + if (getErr || !creative) { + return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 }); + } + + try { + let statusBanner = ''; + + if (actionId === 'creative_send_client') { + // Asegurar asset_final=true si se envía desde el mensaje de Slack de WF5 + if (!creative.asset_final) { + await adminClient + .from('creatives') + .update({ asset_final: true }) + .eq('id', creativeId); + } + await sendCreativeToClient(creativeId, creative.tenant_id, { + role: 'agency', + userId: null, + }); + statusBanner = `✅ *Enviada al cliente para aprobación* desde Slack por *@${slackUsername}*`; + } else if (actionId === 'creative_discard') { + await rejectCreative( + creativeId, + creative.tenant_id, + { role: 'agency', userId: null }, + `Descartada desde Slack por @${slackUsername}`, + { channel: 'slack', autoRegenerate: false } + ); + statusBanner = `🗑️ *Descartada (rechazada)* desde Slack por *@${slackUsername}*`; + } else { + return NextResponse.json({ ok: true, ignored: actionId }); + } + + // Construir bloques actualizados quitando la fila de botones (actions) y añadiendo el estado final + const originalBlocks = Array.isArray(payload.message?.blocks) + ? payload.message.blocks.filter((b: any) => b.type !== 'actions') + : []; + + const updatedBlocks = [ + ...originalBlocks, + { + type: 'context', + elements: [{ type: 'mrkdwn', text: statusBanner }], + }, + ]; + + if (responseUrl) { + fetch(responseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + replace_original: true, + text: statusBanner, + blocks: updatedBlocks, + }), + }).catch(() => {}); + } + + return NextResponse.json({ + replace_original: true, + text: statusBanner, + blocks: updatedBlocks, + }); + } catch (err: any) { + return NextResponse.json( + { + replace_original: false, + response_type: 'ephemeral', + text: `⚠️ Error al procesar acción en app.4ventures.es: ${err.message}`, + }, + { status: 200 } + ); + } +} diff --git a/src/components/AgencyCreativesView.tsx b/src/components/AgencyCreativesView.tsx index 2cea028..4307881 100644 --- a/src/components/AgencyCreativesView.tsx +++ b/src/components/AgencyCreativesView.tsx @@ -135,39 +135,129 @@ export default function AgencyCreativesView({ const [detailCreative, setDetailCreative] = useState(null); const [detailEvents, setDetailEvents] = useState([]); - // Edición inline de ángulo y hook + // Edición inline de ángulo, hook y support + Re-composición en Canva const [inlineEditingId, setInlineEditingId] = useState(null); const [inlineAngle, setInlineAngle] = useState(''); const [inlineHook, setInlineHook] = useState(''); + const [inlineSupport, setInlineSupport] = useState(''); const [savingInline, setSavingInline] = useState(false); + const [recomposingId, setRecomposingId] = useState(null); - const handleSaveInline = async (creativeId: string) => { + // Modal de rechazo con feedback para Claude + auto-regenerar + const [rejectModalCreative, setRejectModalCreative] = useState(null); + const [rejectComment, setRejectComment] = useState(''); + const [rejectAutoRegenerate, setRejectAutoRegenerate] = useState(true); + const [isRejecting, setIsRejecting] = useState(false); + + const handleSaveInline = async (creativeId: string, alsoRecompose = false) => { setSavingInline(true); try { - const res = await fetch(`/api/creatives/${creativeId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - angle: inlineAngle.trim() || null, - hook: inlineHook.trim() || null, - }), - }); - if (!res.ok) { + if (alsoRecompose) { + setRecomposingId(creativeId); + const res = await fetch(`/api/creatives/${creativeId}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'recompose', + angle: inlineAngle.trim() || null, + hook: inlineHook.trim() || null, + support: inlineSupport.trim() || null, + }), + }); const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'Error al guardar'); + if (!res.ok) throw new Error(data.error || 'Error al re-componer en Canva'); + setInlineEditingId(null); + await loadData(); + alert('¡Re-composición enviada a Canva (WF5)! En ~15 segundos tendrás la nueva imagen lista sin coste de IA.'); + } else { + const res = await fetch(`/api/creatives/${creativeId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + angle: inlineAngle.trim() || null, + hook: inlineHook.trim() || null, + support: inlineSupport.trim() || null, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Error al guardar'); + } + setCreatives((prev) => + prev.map((c) => + c.id === creativeId + ? { + ...c, + angle: inlineAngle.trim() || null, + hook: inlineHook.trim() || null, + meta: { ...(c.meta || {}), support: inlineSupport.trim() || null }, + } + : c + ) + ); + setInlineEditingId(null); } - setCreatives((prev) => - prev.map((c) => - c.id === creativeId - ? { ...c, angle: inlineAngle.trim() || null, hook: inlineHook.trim() || null } - : c - ) - ); - setInlineEditingId(null); } catch (err: any) { alert(err.message); } finally { setSavingInline(false); + setRecomposingId(null); + } + }; + + const handleRecomposeQuick = async (c: CreativeItem) => { + setRecomposingId(c.id); + try { + const res = await fetch(`/api/creatives/${c.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'recompose', + hook: c.hook, + angle: c.angle, + support: c.meta?.support || c.headline, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Error al re-componer en Canva'); + await loadData(); + alert('¡Re-composición enviada a Canva (WF5)! Refresca en 15 segundos para ver el nuevo diseño.'); + } catch (err: any) { + alert(err.message); + } finally { + setRecomposingId(null); + } + }; + + const handleConfirmReject = async () => { + if (!rejectModalCreative) return; + if (!rejectComment.trim()) { + alert('Escribe un motivo de rechazo para que Claude aprenda de esta corrección.'); + return; + } + setIsRejecting(true); + try { + const res = await fetch(`/api/creatives/${rejectModalCreative.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'reject', + comment: rejectComment.trim(), + auto_regenerate: rejectAutoRegenerate, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Error al rechazar creatividad'); + setRejectModalCreative(null); + setRejectComment(''); + await loadData(); + if (rejectAutoRegenerate) { + alert('Creatividad rechazada y nueva variante solicitada automáticamente a Claude (WF3) aplicando tu corrección.'); + } + } catch (err: any) { + alert(err.message); + } finally { + setIsRejecting(false); } }; @@ -229,6 +319,15 @@ export default function AgencyCreativesView({ // 3. Acción: Cambiar estado manual const handleChangeStatus = async (creativeId: string, nextStatus: string) => { + if (nextStatus === 'rechazada') { + const found = creatives.find((c) => c.id === creativeId); + if (found) { + setRejectModalCreative(found); + setRejectComment(''); + setRejectAutoRegenerate(true); + return; + } + } try { const res = await fetch(`/api/creatives/${creativeId}`, { method: 'PATCH', @@ -379,6 +478,57 @@ export default function AgencyCreativesView({ return groups; }, [creatives]); + // Analítica agregada por Ángulo Creativo (#7) + const anglePerformanceStats = useMemo(() => { + const byAngle: Record< + string, + { + angle: string; + creatives_count: number; + fatigued_count: number; + approved_count: number; + impressions: number; + clicks: number; + spend: number; + results: number; + } + > = {}; + + for (const c of creatives) { + const ang = (c.angle || 'sin_angulo').trim(); + if (!byAngle[ang]) { + byAngle[ang] = { + angle: ang, + creatives_count: 0, + fatigued_count: 0, + approved_count: 0, + impressions: 0, + clicks: 0, + spend: 0, + results: 0, + }; + } + byAngle[ang].creatives_count += 1; + if (c.status === 'fatigada') byAngle[ang].fatigued_count += 1; + if (c.status === 'aprobada' || c.status === 'publicada') byAngle[ang].approved_count += 1; + if (c.metrics) { + byAngle[ang].impressions += Number(c.metrics.impressions || 0); + byAngle[ang].clicks += Number(c.metrics.clicks || 0); + byAngle[ang].spend += Number(c.metrics.spend || 0); + byAngle[ang].results += Number(c.metrics.results || 0); + } + } + + return Object.values(byAngle) + .map((a) => ({ + ...a, + spend: Number(a.spend.toFixed(2)), + ctr: a.impressions > 0 ? Number(((a.clicks / a.impressions) * 100).toFixed(2)) : 0, + cpa: a.results > 0 ? Number((a.spend / a.results).toFixed(2)) : null, + })) + .sort((a, b) => b.ctr - a.ctr || b.creatives_count - a.creatives_count); + }, [creatives]); + return (
{/* Cabecera */} @@ -499,8 +649,8 @@ export default function AgencyCreativesView({
)} - {/* Badge asset_final */} -
+ {/* Badge asset_final + QA Visual */} +
{c.asset_final ? ( @@ -512,10 +662,35 @@ export default function AgencyCreativesView({ Requiere final )} + + {c.meta?.qa_visual?.quality_score ? ( + = 8 + ? 'bg-indigo-600/90' + : 'bg-amber-600/90' + }`} + > + QA {c.meta.qa_visual.quality_score}/10 + + ) : null}
- {/* Badge Formato */} -
+ {/* Badge Formato + 9:16 Stories */} +