From ed18c9d3182d2b01aeac6393489e1e3fc74cba07 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:22:03 +0200 Subject: [PATCH] feat: panel de creatividades (pipeline editorial) en /dashboard/panel-cliente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nueva tabla creative_pipeline (separada de creatives, que es solo para anuncios aprobados Meta/Google): Propuesta -> En diseño -> Revisión interna -> Aprobado/Rechazado -> Publicado, con RLS por tenant. - sql/creative_pipeline.sql: esquema + políticas RLS - /api/creative-pipeline: CRUD con transiciones restringidas (el cliente solo puede aprobar/rechazar una pieza en revisión interna; el resto de movimientos y la creación son solo agencia) - /api/creative-pipeline/upload + /media: portada y slides de carrusel sobre el bucket privado tenant-assets ya existente - CreativePipelineBoard: tablero agrupado por mes, con subida de imagen inline y acciones según rol - Alta del módulo "Panel de Creatividades" en tenant-modules y sidebar Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqGTGPznxTp8mwMg6Yv4Ew --- sql/creative_pipeline.sql | 97 ++++ src/app/api/creative-pipeline/media/route.ts | 67 +++ src/app/api/creative-pipeline/route.ts | 300 ++++++++++++ src/app/api/creative-pipeline/upload/route.ts | 141 ++++++ src/app/dashboard/panel-cliente/page.tsx | 11 + src/components/CreativePipelineBoard.tsx | 429 ++++++++++++++++++ src/components/DashboardShell.tsx | 7 + src/lib/tenant-modules.ts | 8 + 8 files changed, 1060 insertions(+) create mode 100644 sql/creative_pipeline.sql create mode 100644 src/app/api/creative-pipeline/media/route.ts create mode 100644 src/app/api/creative-pipeline/route.ts create mode 100644 src/app/api/creative-pipeline/upload/route.ts create mode 100644 src/app/dashboard/panel-cliente/page.tsx create mode 100644 src/components/CreativePipelineBoard.tsx diff --git a/sql/creative_pipeline.sql b/sql/creative_pipeline.sql new file mode 100644 index 0000000..ca865ad --- /dev/null +++ b/sql/creative_pipeline.sql @@ -0,0 +1,97 @@ +-- Creative pipeline: producción interna de creatividades (Blue Parrot y resto +-- de clientes) separada de `creatives` (que es el repositorio de anuncios +-- Meta/Google ya aprobados). Aquí vive el flujo editorial completo: +-- Propuesta -> En diseño -> Revisión interna -> Aprobado / Rechazado -> Publicado +-- Visible también para el cliente en /dashboard/panel-cliente (RLS por tenant). +-- Idempotente. + +create table if not exists public.creative_pipeline ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + + -- referencia externa estable usada por los scripts de generación (ej. "oct-04") + external_ref text, + + title text not null, + format text not null default 'post' + check (format in ('post', 'story', 'reel', 'carrusel')), + + -- mes/calendario al que pertenece la pieza (agrupación en el board) + month text, -- 'YYYY-MM' + + status text not null default 'propuesta' + check (status in ( + 'propuesta', 'en_diseno', 'revision_interna', + 'aprobado', 'rechazado', 'publicado' + )), + + -- portada (todas las piezas tienen una) + cover_path text, + cover_mime text, + + -- piezas carrusel: array ordenado de storage paths de los slides interiores + CTA + -- ej. ["tenant/.../slide-2.jpg", "tenant/.../slide-3.jpg", ...] + slide_paths jsonb not null default '[]'::jsonb, + + caption text, + notes text, + + created_by uuid references auth.users(id), + approved_by uuid references auth.users(id), + approved_at timestamptz, + + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists creative_pipeline_tenant_ref_idx + on public.creative_pipeline (tenant_id, external_ref) + where external_ref is not null; + +create index if not exists creative_pipeline_tenant_month_idx + on public.creative_pipeline (tenant_id, month, status); + +alter table public.creative_pipeline enable row level security; + +-- Lectura: cliente ve las piezas de su propio tenant (incluye revisión interna, +-- ya que este panel es también para el cliente, no solo para el equipo). +create policy creative_pipeline_tenant_select on public.creative_pipeline + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +-- Alta/edición de contenido vía API del equipo (service role) — bloqueado para +-- clientes directos por RLS; el endpoint PATCH permite al cliente mover el +-- estado a 'aprobado'/'rechazado' únicamente mediante el mismo filtro de tenant. +create policy creative_pipeline_tenant_insert on public.creative_pipeline + for insert with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy creative_pipeline_tenant_update on public.creative_pipeline + for update using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy creative_pipeline_tenant_delete on public.creative_pipeline + for delete using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +comment on table public.creative_pipeline is + 'Pipeline interno de producción de creatividades (RRSS), separado de creatives (ads). Mismo bucket tenant-assets, prefijo pipeline/.'; + +-- Storage: reutiliza el bucket privado `tenant-assets` (ver src/lib/assets.ts), +-- path = {tenant_id}/pipeline/{id}/cover.jpg y {tenant_id}/pipeline/{id}/slide-N.jpg diff --git a/src/app/api/creative-pipeline/media/route.ts b/src/app/api/creative-pipeline/media/route.ts new file mode 100644 index 0000000..13c2eee --- /dev/null +++ b/src/app/api/creative-pipeline/media/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets'; + +export const dynamic = 'force-dynamic'; + +/** GET ?path= — redirect to a short-lived signed URL. */ +export async function GET(req: NextRequest) { + const path = req.nextUrl.searchParams.get('path')?.trim() || ''; + if (!path) { + return NextResponse.json({ error: 'path requerido' }, { status: 400 }); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId) { + return NextResponse.json({ error: 'Sin tenant' }, { status: 400 }); + } + + // El path siempre empieza por "{tenant_id}/pipeline/..." — evita servir + // ficheros de otro tenant aunque conozcan la ruta. + if (!path.startsWith(`${ctx.tenantId}/pipeline/`)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + // Doble check contra la fila (evita servir paths "adivinados"). + const headers = serviceHeaders(); + if (headers) { + const r = await fetch( + `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent( + ctx.tenantId + )}&or=(cover_path.eq.${encodeURIComponent(path)},slide_paths.cs.${encodeURIComponent( + JSON.stringify([path]) + )})&select=id&limit=1`, + { headers, cache: 'no-store' } + ); + if (r.ok) { + const rows = await r.json(); + if (!rows?.length) { + return NextResponse.json({ error: 'No encontrado' }, { status: 404 }); + } + } + } + + const svc = serviceSupabase(); + if (!svc) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + const signed = await svc.storage.from(ASSETS_BUCKET).createSignedUrl(path, 300); + if (signed.error || !signed.data?.signedUrl) { + return NextResponse.json( + { error: signed.error?.message || 'No signed URL' }, + { status: 500 } + ); + } + return NextResponse.redirect(signed.data.signedUrl); +} diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts new file mode 100644 index 0000000..da647d4 --- /dev/null +++ b/src/app/api/creative-pipeline/route.ts @@ -0,0 +1,300 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { resolveActiveTenant } from '@/lib/tenant-context'; + +const SELECT = + 'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at'; + +const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']); + +const ALL_STATUS = [ + 'propuesta', + 'en_diseno', + 'revision_interna', + 'aprobado', + 'rechazado', + 'publicado', +] as const; +type Status = (typeof ALL_STATUS)[number]; +const STATUS_SET = new Set(ALL_STATUS); + +// El cliente solo puede mover una pieza que esté en revisión interna, +// y únicamente hacia aprobar o rechazar. El resto de transiciones (incluida +// "publicado") son cosa del equipo de la agencia. +const CLIENT_ALLOWED_TRANSITIONS: Record = { + revision_interna: ['aprobado', 'rechazado'], +}; + +function str(v: unknown, max = 2000): string | null { + if (typeof v !== 'string') return null; + const t = v.trim(); + if (!t) return null; + return t.slice(0, max); +} + +function slidePaths(v: unknown): string[] { + if (!Array.isArray(v)) return []; + return v.filter((x): x is string => typeof x === 'string').slice(0, 10); +} + +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 ctx = await resolveActiveTenant(user); + const month = req.nextUrl.searchParams.get('month')?.trim() || null; + const isAgency = isAgencyRole(userRole(user)); + + if (ctx.portfolioWide) { + return NextResponse.json({ items: [], isAgency, warning: 'Selecciona un cliente en Contexto' }); + } + if (!ctx.tenantId) { + return NextResponse.json({ items: [], isAgency }); + } + + let q = `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent( + ctx.tenantId + )}&select=${SELECT}&order=month.asc,created_at.asc`; + if (month) q += `&month=eq.${encodeURIComponent(month)}`; + + if (ctx.viaView) { + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json( + { items: [], isAgency, warning: 'Service role required for superadmin view' }, + { status: 200 } + ); + } + const r = await fetch(q, { headers, cache: 'no-store' }); + if (!r.ok) { + return NextResponse.json({ items: [], isAgency, warning: await r.text() }); + } + return NextResponse.json({ items: await r.json(), isAgency }); + } + + let query = supabase + .from('creative_pipeline') + .select(SELECT) + .order('month', { ascending: true }) + .order('created_at', { ascending: true }); + if (month) query = query.eq('month', month); + + const { data, error } = await query; + if (error) { + return NextResponse.json({ items: [], isAgency, warning: error.message }); + } + return NextResponse.json({ items: data ?? [], isAgency }); +} + +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 agency = isAgencyRole(userRole(user)); + if (!agency) { + return NextResponse.json( + { error: 'Solo el equipo de la agencia puede crear piezas' }, + { status: 403 } + ); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto' }, + { status: 400 } + ); + } + + const body = (await req.json().catch(() => ({}))) as Record; + const title = str(body.title, 300); + if (!title) { + return NextResponse.json({ error: 'title requerido' }, { status: 400 }); + } + const format = str(body.format, 20); + + const row = { + tenant_id: ctx.tenantId, + title, + external_ref: str(body.external_ref, 80), + format: format && FORMATS.has(format) ? format : 'post', + month: str(body.month, 7), + caption: str(body.caption, 4000), + notes: str(body.notes, 2000), + status: 'propuesta', + created_by: user.id, + }; + + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + const r = await fetch(`${supabaseUrl()}/rest/v1/creative_pipeline`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const data = await r.json(); + return NextResponse.json({ item: Array.isArray(data) ? data[0] : data }, { status: 201 }); +} + +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 ctx = await resolveActiveTenant(user); + if (!ctx.tenantId) { + return NextResponse.json({ error: 'Sin tenant' }, { status: 400 }); + } + + const body = (await req.json().catch(() => ({}))) as Record; + const id = str(body.id, 80); + if (!id) { + return NextResponse.json({ error: 'id requerido' }, { status: 400 }); + } + + const agency = isAgencyRole(userRole(user)); + const headers = serviceHeaders(); + + // Resolve current row first — status transitions are checked against it. + const curRes = await fetch( + `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent( + id + )}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`, + { headers: headers || undefined, cache: 'no-store' } + ); + const curRows = curRes.ok ? await curRes.json() : []; + const current: string | undefined = curRows?.[0]?.status; + if (!current) { + return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 }); + } + + const clean: Record = { + updated_at: new Date().toISOString(), + }; + + if (typeof body.status === 'string' && STATUS_SET.has(body.status)) { + const next = body.status as Status; + if (agency) { + clean.status = next; + } else { + const allowed = CLIENT_ALLOWED_TRANSITIONS[current] || []; + if (!allowed.includes(next)) { + return NextResponse.json( + { error: `Como cliente solo puedes aprobar o rechazar una pieza en revisión interna` }, + { status: 403 } + ); + } + clean.status = next; + } + if (next === 'aprobado') { + clean.approved_by = user.id; + clean.approved_at = new Date().toISOString(); + } + } + + // Resto de campos: solo agencia los edita. + if (agency) { + const title = str(body.title, 300); + if (title) clean.title = title; + const month = str(body.month, 7); + if (month) clean.month = month; + const format = str(body.format, 20); + if (format && FORMATS.has(format)) clean.format = format; + if ('caption' in body) clean.caption = str(body.caption, 4000); + if ('notes' in body) clean.notes = str(body.notes, 2000); + if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500); + if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100); + if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths); + } + + if (!headers) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + const r = await fetch( + `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent( + id + )}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(clean), + } + ); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const data = await r.json(); + return NextResponse.json({ item: Array.isArray(data) ? data[0] : 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 agency = isAgencyRole(userRole(user)); + if (!agency) { + return NextResponse.json({ error: 'Solo la agencia puede borrar' }, { status: 403 }); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId) { + return NextResponse.json({ error: 'Sin tenant' }, { status: 400 }); + } + + const id = req.nextUrl.searchParams.get('id')?.trim() || ''; + if (!id) { + return NextResponse.json({ error: 'id requerido' }, { status: 400 }); + } + + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + const r = await fetch( + `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent( + id + )}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`, + { method: 'DELETE', headers } + ); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/creative-pipeline/upload/route.ts b/src/app/api/creative-pipeline/upload/route.ts new file mode 100644 index 0000000..6764801 --- /dev/null +++ b/src/app/api/creative-pipeline/upload/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + ASSETS_BUCKET, + MAX_ASSET_BYTES, + ensureAssetsBucket, + sanitizeFileName, + serviceSupabase, +} from '@/lib/assets'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']); + +/** + * POST multipart: sube portada o slide de una pieza del pipeline. + * fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based) + */ +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 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json( + { error: 'Solo el equipo de la agencia puede subir imágenes' }, + { status: 403 } + ); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json({ error: 'Selecciona un cliente en Contexto' }, { status: 400 }); + } + + const form = await req.formData(); + const file = form.get('file'); + const itemId = String(form.get('itemId') || '').trim(); + const slot = String(form.get('slot') || 'cover').trim(); + const index = Math.max(0, parseInt(String(form.get('index') || '0'), 10) || 0); + + if (!(file instanceof File)) { + return NextResponse.json({ error: 'file requerido' }, { status: 400 }); + } + if (!itemId) { + return NextResponse.json({ error: 'itemId requerido' }, { status: 400 }); + } + if (file.size <= 0 || file.size > MAX_ASSET_BYTES) { + return NextResponse.json( + { error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` }, + { status: 400 } + ); + } + const mime = file.type || 'application/octet-stream'; + if (!ALLOWED_MIME.has(mime)) { + return NextResponse.json({ error: 'Solo jpg/png/webp' }, { status: 400 }); + } + + const bucketOk = await ensureAssetsBucket(); + if (!bucketOk.ok) { + return NextResponse.json( + { error: bucketOk.error || 'No se pudo asegurar el bucket' }, + { status: 500 } + ); + } + + const svc = serviceSupabase(); + if (!svc) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + // Verifica que la pieza existe y pertenece al tenant activo. + const curRes = await fetch( + `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent( + itemId + )}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,slide_paths`, + { headers, cache: 'no-store' } + ); + const curRows = curRes.ok ? await curRes.json() : []; + if (!curRows?.[0]) { + return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 }); + } + + const ext = (sanitizeFileName(file.name || 'img.jpg').split('.').pop() || 'jpg').toLowerCase(); + const fileName = slot === 'cover' ? `cover.${ext}` : `slide-${index + 1}.${ext}`; + const storagePath = `${ctx.tenantId}/pipeline/${itemId}/${fileName}`; + const buf = Buffer.from(await file.arrayBuffer()); + + const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, { + contentType: mime, + upsert: true, + }); + if (up.error) { + return NextResponse.json({ error: up.error.message }, { status: 500 }); + } + + const patch: Record = { updated_at: new Date().toISOString() }; + if (slot === 'cover') { + patch.cover_path = storagePath; + patch.cover_mime = mime; + } else { + const slides: string[] = Array.isArray(curRows[0].slide_paths) + ? [...curRows[0].slide_paths] + : []; + slides[index] = storagePath; + patch.slide_paths = slides; + } + + const patchRes = await fetch( + `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(itemId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(patch), + } + ); + if (!patchRes.ok) { + await svc.storage.from(ASSETS_BUCKET).remove([storagePath]); + return NextResponse.json({ error: await patchRes.text() }, { status: 500 }); + } + const data = await patchRes.json(); + return NextResponse.json({ + item: Array.isArray(data) ? data[0] : data, + storage_path: storagePath, + }); +} diff --git a/src/app/dashboard/panel-cliente/page.tsx b/src/app/dashboard/panel-cliente/page.tsx new file mode 100644 index 0000000..9fd54b5 --- /dev/null +++ b/src/app/dashboard/panel-cliente/page.tsx @@ -0,0 +1,11 @@ +/** + * Panel de Creatividades — pipeline editorial interno (Propuesta → Diseño → + * Revisión interna → Aprobación), visible también para el cliente. + */ +'use client'; + +import CreativePipelineBoard from '@/components/CreativePipelineBoard'; + +export default function PanelClientePage() { + return ; +} diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx new file mode 100644 index 0000000..bd9fe68 --- /dev/null +++ b/src/components/CreativePipelineBoard.tsx @@ -0,0 +1,429 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { ImagePlus, Plus, Trash2, Upload } from 'lucide-react'; +import { PageHeader, Surface } from '@/components/ui'; + +type PipelineItem = { + id: string; + external_ref: string | null; + title: string; + format: 'post' | 'story' | 'reel' | 'carrusel'; + month: string | null; + status: string; + cover_path: string | null; + cover_mime: string | null; + slide_paths: string[]; + caption: string | null; + notes: string | null; + approved_at: string | null; + created_at: string; + updated_at: string; +}; + +const STATUS_LABEL: Record = { + propuesta: 'Propuesta', + en_diseno: 'En diseño', + revision_interna: 'Revisión interna', + aprobado: 'Aprobado', + rechazado: 'Rechazado', + publicado: 'Publicado', +}; + +const STATUS_COLOR: Record = { + propuesta: 'bg-gray-100 text-gray-700', + en_diseno: 'bg-blue-100 text-blue-700', + revision_interna: 'bg-amber-100 text-amber-800', + aprobado: 'bg-emerald-100 text-emerald-700', + rechazado: 'bg-red-100 text-red-700', + publicado: 'bg-purple-100 text-purple-700', +}; + +const AGENCY_STATUS_ORDER = [ + 'propuesta', + 'en_diseno', + 'revision_interna', + 'aprobado', + 'rechazado', + 'publicado', +]; + +function mediaUrl(path: string | null) { + if (!path) return null; + return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`; +} + +function monthLabel(m: string | null) { + if (!m) return 'Sin mes'; + const [y, mm] = m.split('-'); + const d = new Date(Number(y), Number(mm) - 1, 1); + return d.toLocaleDateString('es-ES', { month: 'long', year: 'numeric' }); +} + +export default function CreativePipelineBoard() { + const [items, setItems] = useState([]); + const [isAgency, setIsAgency] = useState(false); + const [loading, setLoading] = useState(true); + const [warning, setWarning] = useState(null); + const [err, setErr] = useState(null); + const [busyId, setBusyId] = useState(null); + const [showForm, setShowForm] = useState(false); + const [newTitle, setNewTitle] = useState(''); + const [newFormat, setNewFormat] = useState('post'); + const [newMonth, setNewMonth] = useState(''); + + async function load() { + setLoading(true); + try { + const res = await fetch('/api/creative-pipeline'); + const data = await res.json(); + setItems(data.items ?? []); + setIsAgency(Boolean(data.isAgency)); + setWarning(data.warning ?? null); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + }, []); + + const grouped = useMemo(() => { + const map = new Map(); + for (const it of items) { + const key = it.month || 'sin-mes'; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(it); + } + return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b)); + }, [items]); + + async function createItem(e: React.FormEvent) { + e.preventDefault(); + if (!newTitle.trim()) return; + setErr(null); + const res = await fetch('/api/creative-pipeline', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: newTitle, format: newFormat, month: newMonth || null }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'Error al crear'); + return; + } + setNewTitle(''); + setNewMonth(''); + setShowForm(false); + await load(); + } + + async function setStatus(id: string, status: string) { + setBusyId(id); + setErr(null); + try { + const res = await fetch('/api/creative-pipeline', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, status }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'Error al cambiar estado'); + return; + } + await load(); + } finally { + setBusyId(null); + } + } + + async function removeItem(id: string) { + setBusyId(id); + try { + await fetch(`/api/creative-pipeline?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); + await load(); + } finally { + setBusyId(null); + } + } + + async function uploadImage( + itemId: string, + file: File, + slot: 'cover' | 'slide', + index = 0 + ) { + setBusyId(itemId); + setErr(null); + try { + const fd = new FormData(); + fd.append('file', file); + fd.append('itemId', itemId); + fd.append('slot', slot); + fd.append('index', String(index)); + const res = await fetch('/api/creative-pipeline/upload', { method: 'POST', body: fd }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'Error al subir imagen'); + return; + } + await load(); + } finally { + setBusyId(null); + } + } + + return ( +
+ setShowForm((v) => !v)} + className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700" + > + Nueva pieza + + ) : null + } + /> + + {warning ? ( +
+ {warning} +
+ ) : null} + {err ? ( +
+ {err} +
+ ) : null} + + {showForm && isAgency ? ( + +
+
+ + setNewTitle(e.target.value)} + className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm" + placeholder="Certifica tu nivel" + /> +
+
+ + +
+
+ + setNewMonth(e.target.value)} + className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm" + /> +
+ +
+
+ ) : null} + + {loading ? ( +

Cargando…

+ ) : items.length === 0 ? ( + +

Todavía no hay piezas en el pipeline.

+
+ ) : ( +
+ {grouped.map(([month, monthItems]) => ( +
+

+ {monthLabel(month === 'sin-mes' ? null : month)} +

+
+ {monthItems.map((it) => ( + setStatus(it.id, s)} + onDelete={() => removeItem(it.id)} + onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)} + /> + ))} +
+
+ ))} +
+ )} +
+ ); +} + +function PieceCard({ + item, + isAgency, + busy, + onStatus, + onDelete, + onUpload, +}: { + item: PipelineItem; + isAgency: boolean; + busy: boolean; + onStatus: (status: string) => void; + onDelete: () => void; + onUpload: (file: File, slot: 'cover' | 'slide', index: number) => void; +}) { + const cover = mediaUrl(item.cover_path); + const slides = item.slide_paths || []; + const isCarrusel = item.format === 'carrusel'; + + const clientCanDecide = !isAgency && item.status === 'revision_interna'; + + return ( + +
+ {cover ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.title} + ) : ( +
+ +
+ )} + {isAgency ? ( + + ) : null} +
+ + {isCarrusel ? ( +
+ {Array.from({ length: 4 }).map((_, i) => { + const url = mediaUrl(slides[i] || null); + return ( +
+ {url ? ( + // eslint-disable-next-line @next/next/no-img-element + {`slide + ) : null} + {isAgency ? ( + + ) : null} +
+ ); + })} +
+ ) : null} + +
+
+

{item.title}

+ {isAgency ? ( + + ) : null} +
+ + {STATUS_LABEL[item.status] || item.status} + + +
+ {isAgency ? ( + + ) : clientCanDecide ? ( +
+ + +
+ ) : ( +

+ {item.status === 'aprobado' + ? 'Aprobada por ti' + : 'Pendiente del equipo de 4Ventures'} +

+ )} +
+
+
+ ); +} diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 486abcf..064b391 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -100,6 +100,13 @@ const NAV_ITEMS: NavItem[] = [ iconClass: 'text-orange-500', moduleId: 'creatives', }, + { + href: '/dashboard/panel-cliente', + label: 'Panel de Creatividades', + icon: Megaphone, + iconClass: 'text-pink-500', + moduleId: 'panel_cliente', + }, { href: '/dashboard/asistente', label: 'Asistente IA Financiero', diff --git a/src/lib/tenant-modules.ts b/src/lib/tenant-modules.ts index ceb4d7a..3025c03 100644 --- a/src/lib/tenant-modules.ts +++ b/src/lib/tenant-modules.ts @@ -11,6 +11,7 @@ export type DashboardModuleId = | 'organico' | 'activos' | 'creatives' + | 'panel_cliente' | 'asistente'; export type DashboardModuleDef = { @@ -93,6 +94,13 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [ providers: ['google_drive', 'meta_ads', 'google_ads'], hint: 'Flujo creativo', }, + { + id: 'panel_cliente', + href: '/dashboard/panel-cliente', + label: 'Panel de Creatividades', + providers: [], + hint: 'Producción editorial: propuesta, diseño, revisión y aprobación', + }, { id: 'asistente', href: '/dashboard/asistente',