feat: panel de creatividades (pipeline editorial) en /dashboard/panel-cliente

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqGTGPznxTp8mwMg6Yv4Ew
This commit is contained in:
Claude
2026-10-04 00:22:03 +02:00
parent e3180ceb51
commit ed18c9d318
8 changed files with 1060 additions and 0 deletions

97
sql/creative_pipeline.sql Normal file
View File

@@ -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

View File

@@ -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=<storage_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);
}

View File

@@ -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<string>(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<string, Status[]> = {
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<string, unknown>;
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<string, unknown>;
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<string, unknown> = {
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 });
}

View File

@@ -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<string, unknown> = { 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,
});
}

View File

@@ -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 <CreativePipelineBoard />;
}

View File

@@ -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<string, string> = {
propuesta: 'Propuesta',
en_diseno: 'En diseño',
revision_interna: 'Revisión interna',
aprobado: 'Aprobado',
rechazado: 'Rechazado',
publicado: 'Publicado',
};
const STATUS_COLOR: Record<string, string> = {
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<PipelineItem[]>([]);
const [isAgency, setIsAgency] = useState(false);
const [loading, setLoading] = useState(true);
const [warning, setWarning] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newFormat, setNewFormat] = useState<PipelineItem['format']>('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<string, PipelineItem[]>();
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 (
<div>
<PageHeader
eyebrow="Producción editorial"
title="Panel de Creatividades"
description="Propuesta → Diseño → Revisión interna → Aprobación del cliente."
actions={
isAgency ? (
<button
onClick={() => 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"
>
<Plus className="h-4 w-4" /> Nueva pieza
</button>
) : null
}
/>
{warning ? (
<div className="mb-4 rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-sm text-amber-800">
{warning}
</div>
) : null}
{err ? (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-800">
{err}
</div>
) : null}
{showForm && isAgency ? (
<Surface className="mb-6">
<form onSubmit={createItem} className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs font-medium text-gray-600">Título</label>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="Certifica tu nivel"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600">Formato</label>
<select
value={newFormat}
onChange={(e) => setNewFormat(e.target.value as PipelineItem['format'])}
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
>
<option value="post">Post</option>
<option value="story">Story</option>
<option value="reel">Reel</option>
<option value="carrusel">Carrusel</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600">Mes</label>
<input
type="month"
value={newMonth}
onChange={(e) => setNewMonth(e.target.value)}
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white"
>
Crear
</button>
</form>
</Surface>
) : null}
{loading ? (
<p className="text-sm text-gray-500">Cargando…</p>
) : items.length === 0 ? (
<Surface>
<p className="text-sm text-gray-500">Todavía no hay piezas en el pipeline.</p>
</Surface>
) : (
<div className="space-y-8">
{grouped.map(([month, monthItems]) => (
<div key={month}>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">
{monthLabel(month === 'sin-mes' ? null : month)}
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{monthItems.map((it) => (
<PieceCard
key={it.id}
item={it}
isAgency={isAgency}
busy={busyId === it.id}
onStatus={(s) => setStatus(it.id, s)}
onDelete={() => removeItem(it.id)}
onUpload={(file, slot, index) => uploadImage(it.id, file, slot, index)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
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 (
<Surface padded={false} className="overflow-hidden flex flex-col">
<div className="relative aspect-[4/5] bg-gray-100">
{cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={cover} alt={item.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-gray-300">
<ImagePlus className="h-10 w-10" />
</div>
)}
{isAgency ? (
<label className="absolute bottom-2 right-2 cursor-pointer rounded-full bg-black/60 p-2 text-white hover:bg-black/80">
<Upload className="h-3.5 w-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onUpload(f, 'cover', 0);
}}
/>
</label>
) : null}
</div>
{isCarrusel ? (
<div className="flex gap-1 overflow-x-auto p-2 bg-gray-50">
{Array.from({ length: 4 }).map((_, i) => {
const url = mediaUrl(slides[i] || null);
return (
<div
key={i}
className="relative h-14 w-11 shrink-0 overflow-hidden rounded bg-gray-200"
>
{url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={url} alt={`slide ${i + 1}`} className="h-full w-full object-cover" />
) : null}
{isAgency ? (
<label className="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/0 hover:bg-black/40 text-transparent hover:text-white text-[10px]">
+
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onUpload(f, 'slide', i);
}}
/>
</label>
) : null}
</div>
);
})}
</div>
) : null}
<div className="flex flex-1 flex-col gap-2 p-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-gray-900 line-clamp-2">{item.title}</p>
{isAgency ? (
<button
onClick={onDelete}
disabled={busy}
className="shrink-0 text-gray-400 hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
<span
className={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${
STATUS_COLOR[item.status] || 'bg-gray-100 text-gray-700'
}`}
>
{STATUS_LABEL[item.status] || item.status}
</span>
<div className="mt-auto pt-2">
{isAgency ? (
<select
value={item.status}
disabled={busy}
onChange={(e) => onStatus(e.target.value)}
className="w-full rounded-lg border border-gray-200 px-2 py-1.5 text-xs"
>
{AGENCY_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{STATUS_LABEL[s]}
</option>
))}
</select>
) : clientCanDecide ? (
<div className="flex gap-2">
<button
disabled={busy}
onClick={() => onStatus('aprobado')}
className="flex-1 rounded-lg bg-emerald-600 px-2 py-1.5 text-xs font-medium text-white hover:bg-emerald-700"
>
Aprobar
</button>
<button
disabled={busy}
onClick={() => onStatus('rechazado')}
className="flex-1 rounded-lg bg-red-600 px-2 py-1.5 text-xs font-medium text-white hover:bg-red-700"
>
Rechazar
</button>
</div>
) : (
<p className="text-xs text-gray-400">
{item.status === 'aprobado'
? 'Aprobada por ti'
: 'Pendiente del equipo de 4Ventures'}
</p>
)}
</div>
</div>
</Surface>
);
}

View File

@@ -100,6 +100,13 @@ const NAV_ITEMS: NavItem[] = [
iconClass: 'text-orange-500', iconClass: 'text-orange-500',
moduleId: 'creatives', moduleId: 'creatives',
}, },
{
href: '/dashboard/panel-cliente',
label: 'Panel de Creatividades',
icon: Megaphone,
iconClass: 'text-pink-500',
moduleId: 'panel_cliente',
},
{ {
href: '/dashboard/asistente', href: '/dashboard/asistente',
label: 'Asistente IA Financiero', label: 'Asistente IA Financiero',

View File

@@ -11,6 +11,7 @@ export type DashboardModuleId =
| 'organico' | 'organico'
| 'activos' | 'activos'
| 'creatives' | 'creatives'
| 'panel_cliente'
| 'asistente'; | 'asistente';
export type DashboardModuleDef = { export type DashboardModuleDef = {
@@ -93,6 +94,13 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
providers: ['google_drive', 'meta_ads', 'google_ads'], providers: ['google_drive', 'meta_ads', 'google_ads'],
hint: 'Flujo creativo', 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', id: 'asistente',
href: '/dashboard/asistente', href: '/dashboard/asistente',