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:
429
src/components/CreativePipelineBoard.tsx
Normal file
429
src/components/CreativePipelineBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user