diff --git a/src/app/api/creatives/route.ts b/src/app/api/creatives/route.ts index f610633..4d38652 100644 --- a/src/app/api/creatives/route.ts +++ b/src/app/api/creatives/route.ts @@ -11,7 +11,7 @@ import { } from '@/lib/agency'; const SELECT = - 'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,created_at,updated_at'; + 'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,created_at,updated_at'; const CHANNELS = new Set([ 'meta_ads', @@ -39,7 +39,17 @@ const FORMATS = new Set([ 'youtube', 'other', ]); -const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']); +const ALLOWED_STATUS = new Set([ + 'draft', + 'in_review', + 'approved', + 'rejected', + 'published', +]); + +const CLIENT_ALLOWED_TRANSITIONS: Record = { + in_review: ['approved', 'rejected'], +}; async function resolveTenantId(user: { app_metadata?: Record; @@ -78,6 +88,23 @@ function previewFields(body: Record) { }; } +async function fetchCurrentStatus( + id: string, + tenantId: string | null, + headers: Record | null +): Promise { + if (headers && tenantId) { + const r = await fetch( + `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(tenantId)}&select=status,meta`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return null; + const rows = (await r.json()) as { status?: string }[]; + return rows[0]?.status || null; + } + return null; +} + export async function GET() { const supabase = await createClient(); const { @@ -124,6 +151,7 @@ export async function GET() { return NextResponse.json({ creatives: await r.json(), viewTenantId: tenantId, + isAgency: isAgencyRole(userRole(user)), }); } @@ -145,12 +173,16 @@ export async function GET() { } return NextResponse.json({ creatives: basic ?? [], + isAgency: isAgencyRole(userRole(user)), warning: 'Aplica sql/creatives_preview.sql para canales, copy y media preview', }); } - return NextResponse.json({ creatives: data ?? [] }); + return NextResponse.json({ + creatives: data ?? [], + isAgency: isAgencyRole(userRole(user)), + }); } export async function POST(req: NextRequest) { @@ -166,6 +198,12 @@ export async function POST(req: NextRequest) { if (!tenantId) { return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 }); } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json( + { error: 'Solo el equipo de 4Ventures puede crear piezas' }, + { status: 403 } + ); + } const body = (await req.json().catch(() => ({}))) as Record; const title = str(body.title, 300); @@ -179,6 +217,11 @@ export async function POST(req: NextRequest) { status: 'draft', created_by: user.id, ...previewFields(body), + meta: { + month: str(body.month, 7), + notes: str(body.notes, 2000), + instagram_url: str(body.instagram_url, 500), + }, }; if (viaView) { @@ -268,9 +311,68 @@ export async function PATCH(req: NextRequest) { for (const [k, v] of Object.entries(previewFields(body))) { if (k in body) clean[k] = v; } + if ('month' in body || 'notes' in body || 'instagram_url' in body) { + clean.meta = { + month: str(body.month, 7), + notes: str(body.notes, 2000), + instagram_url: str(body.instagram_url, 500), + }; + } const agency = isAgencyRole(userRole(user)); const headers = serviceHeaders(); + const { tenantId } = await resolveTenantId(user); + + if (!agency) { + const current = await fetchCurrentStatus(id, tenantId, headers); + const next = + typeof body.status === 'string' && ALLOWED_STATUS.has(body.status) + ? body.status + : ''; + 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 } + ); + } + const clientPatch = { + status: next, + updated_at: new Date().toISOString(), + }; + if (headers && tenantId) { + const r = await fetch( + `${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(tenantId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(clientPatch), + } + ); + if (!r.ok) { + return NextResponse.json({ error: await r.text() }, { status: 500 }); + } + const data = await r.json(); + return NextResponse.json({ creative: data[0] || data }); + } + const { data, error } = await supabase + .from('creatives') + .update(clientPatch) + .eq('id', id) + .select(SELECT) + .single(); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + return NextResponse.json({ creative: data }); + } if (agency && headers) { const r = await fetch( @@ -319,6 +421,12 @@ export async function DELETE(req: NextRequest) { } const agency = isAgencyRole(userRole(user)); + if (!agency) { + return NextResponse.json( + { error: 'Solo el equipo de 4Ventures puede borrar piezas' }, + { status: 403 } + ); + } const headers = serviceHeaders(); if (agency && headers) { diff --git a/src/app/dashboard/panel-cliente/page.tsx b/src/app/dashboard/panel-cliente/page.tsx index 9fd54b5..69b4e39 100644 --- a/src/app/dashboard/panel-cliente/page.tsx +++ b/src/app/dashboard/panel-cliente/page.tsx @@ -1,11 +1,6 @@ -/** - * Panel de Creatividades — pipeline editorial interno (Propuesta → Diseño → - * Revisión interna → Aprobación), visible también para el cliente. - */ -'use client'; +import { redirect } from 'next/navigation'; -import CreativePipelineBoard from '@/components/CreativePipelineBoard'; - -export default function PanelClientePage() { - return ; +/** Panel absorbido por Pipeline (`/dashboard/creatives`). */ +export default function PanelClienteRedirect() { + redirect('/dashboard/creatives'); } diff --git a/src/components/CreativesPipeline.tsx b/src/components/CreativesPipeline.tsx index d1dbff8..94b9a99 100644 --- a/src/components/CreativesPipeline.tsx +++ b/src/components/CreativesPipeline.tsx @@ -22,15 +22,40 @@ export type Creative = CreativePreviewData & { status: string; created_at?: string; updated_at?: string; + source?: 'ads' | 'editorial'; + month?: string | null; + notes?: string | null; + instagram_url?: string | null; + caption?: string | null; }; const STATUSES = [ - { id: 'draft', label: 'Borrador' }, - { id: 'in_review', label: 'En review' }, + { id: 'draft', label: 'Propuesta' }, + { id: 'in_review', label: 'Revisión interna' }, { id: 'approved', label: 'Aprobado' }, { id: 'rejected', label: 'Rechazado' }, + { id: 'published', label: 'Publicado' }, ] as const; +const EDITORIAL_STATUS: Record = { + propuesta: 'draft', + en_diseno: 'draft', + revision_interna: 'in_review', + aprobado: 'approved', + rechazado: 'rejected', + publicado: 'published', + draft: 'draft', + in_review: 'in_review', + approved: 'approved', + rejected: 'rejected', + published: 'published', +}; + +function statusLabel(status: string): string { + const mapped = EDITORIAL_STATUS[status] || status; + return STATUSES.find((s) => s.id === mapped)?.label || status; +} + const inputCls = 'mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-white'; @@ -46,6 +71,9 @@ type Draft = { media_url: string; brand_name: string; landing_url: string; + month: string; + notes: string; + instagram_url: string; }; const emptyDraft = (): Draft => ({ @@ -60,6 +88,9 @@ const emptyDraft = (): Draft => ({ media_url: '', brand_name: '', landing_url: '', + month: '', + notes: '', + instagram_url: '', }); function channelLabel(id?: string | null) { @@ -78,13 +109,97 @@ export default function CreativesPipeline() { const [filter, setFilter] = useState<'all' | string>('all'); const [uploading, setUploading] = useState(false); const [uploadMsg, setUploadMsg] = useState(null); + const [isAgency, setIsAgency] = useState(true); + const [monthFilter, setMonthFilter] = useState('all'); + + function metaOf(c: Creative): { + month?: string | null; + notes?: string | null; + instagram_url?: string | null; + } { + const m = (c as Creative & { meta?: Record }).meta; + if (m && typeof m === 'object') { + return { + month: typeof m.month === 'string' ? m.month : null, + notes: typeof m.notes === 'string' ? m.notes : null, + instagram_url: + typeof m.instagram_url === 'string' ? m.instagram_url : null, + }; + } + return {}; + } async function load() { setLoading(true); - const res = await fetch('/api/creatives'); - const data = await res.json(); - setCreatives(data.creatives ?? []); - setWarning(data.warning ?? null); + const [adsRes, edRes] = await Promise.all([ + fetch('/api/creatives'), + fetch('/api/creative-pipeline'), + ]); + const adsData = await adsRes.json(); + const edData = await edRes.json().catch(() => ({})); + setIsAgency(Boolean(adsData.isAgency ?? edData.isAgency ?? true)); + const ads: Creative[] = (adsData.creatives ?? []).map((c: Creative) => { + const extra = metaOf(c); + return { + ...c, + source: 'ads' as const, + month: extra.month, + notes: extra.notes, + instagram_url: extra.instagram_url || c.landing_url, + }; + }); + const editorial: Creative[] = (edData.items ?? []).map( + (it: { + id: string; + title: string; + status: string; + format?: string; + month?: string | null; + caption?: string | null; + notes?: string | null; + cover_path?: string | null; + created_at?: string; + updated_at?: string; + }) => { + const ig = + (it.notes || '').match(/https?:\/\/(?:www\.)?instagram\.com\S+/i)?.[0] || + null; + const fmt = + it.format === 'carrusel' + ? 'carousel' + : it.format === 'post' + ? 'image' + : it.format || 'image'; + return { + id: it.id, + title: it.title, + status: EDITORIAL_STATUS[it.status] || it.status, + source: 'editorial' as const, + channel: 'instagram', + placement: it.format === 'story' ? 'stories' : 'feed', + format: fmt, + headline: it.title, + primary_text: it.caption || '', + description: '', + cta: '', + media_url: it.cover_path + ? `/api/creative-pipeline/media?path=${encodeURIComponent(it.cover_path)}` + : '', + brand_name: '', + landing_url: ig || '', + month: it.month, + notes: it.notes, + instagram_url: ig, + caption: it.caption, + created_at: it.created_at, + updated_at: it.updated_at, + } as Creative; + } + ); + setCreatives([...editorial, ...ads]); + setWarning( + [adsData.warning, edData.warning].filter(Boolean).join(' · ') || null + ); setLoading(false); } @@ -93,21 +208,29 @@ export default function CreativesPipeline() { }, []); const filtered = useMemo(() => { - if (filter === 'all') return creatives; - if (filter === 'ads') { - return creatives.filter((c) => - ['meta_ads', 'google_ads'].includes(c.channel || '') - ); - } - if (filter === 'rrss') { - return creatives.filter((c) => - ['instagram', 'facebook', 'linkedin', 'stories', 'tiktok'].includes( - c.channel || '' - ) - ); - } - return creatives.filter((c) => c.status === filter); - }, [creatives, filter]); + return creatives.filter((c) => { + if (monthFilter !== 'all' && (c.month || 'sin-mes') !== monthFilter) { + return false; + } + if (filter === 'all') return true; + if (filter === 'ads') { + return ( + c.source === 'ads' || + ['meta_ads', 'google_ads'].includes(c.channel || '') + ); + } + if (filter === 'rrss') { + return ( + c.source === 'editorial' || + ['instagram', 'facebook', 'linkedin', 'stories', 'tiktok'].includes( + c.channel || '' + ) + ); + } + const mapped = EDITORIAL_STATUS[c.status] || c.status; + return mapped === filter || c.status === filter; + }); + }, [creatives, filter, monthFilter]); async function uploadFile( file: File, @@ -171,10 +294,29 @@ export default function CreativesPipeline() { async function setStatus(id: string, status: string) { setBusyId(id); setErr(null); - const res = await fetch('/api/creatives', { + const item = creatives.find((c) => c.id === id); + const endpoint = + item?.source === 'editorial' ? '/api/creative-pipeline' : '/api/creatives'; + const editorialStatus = + item?.source === 'editorial' + ? status === 'draft' + ? 'propuesta' + : status === 'in_review' + ? 'revision_interna' + : status === 'approved' + ? 'aprobado' + : status === 'rejected' + ? 'rechazado' + : status === 'published' + ? 'publicado' + : status + : status === 'published' + ? 'approved' + : status; + const res = await fetch(endpoint, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, status }), + body: JSON.stringify({ id, status: editorialStatus }), }); const data = await res.json(); if (!res.ok) setErr(data.error ?? 'No se pudo actualizar'); @@ -187,8 +329,26 @@ export default function CreativesPipeline() { async function saveSelected() { if (!selected) return; + if (!isAgency) return; setBusyId(selected.id); setErr(null); + if (selected.source === 'editorial') { + const res = await fetch('/api/creative-pipeline', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: selected.id, + title: selected.title, + caption: selected.primary_text, + notes: selected.notes || selected.landing_url, + }), + }); + const data = await res.json(); + if (!res.ok) setErr(data.error ?? 'No se pudo guardar'); + await load(); + setBusyId(null); + return; + } const res = await fetch('/api/creatives', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -218,7 +378,12 @@ export default function CreativesPipeline() { if (!confirm('¿Borrar esta creatividad?')) return; setBusyId(id); setErr(null); - const res = await fetch(`/api/creatives?id=${encodeURIComponent(id)}`, { + const item = creatives.find((c) => c.id === id); + const url = + item?.source === 'editorial' + ? `/api/creative-pipeline?id=${encodeURIComponent(id)}` + : `/api/creatives?id=${encodeURIComponent(id)}`; + const res = await fetch(url, { method: 'DELETE', }); const data = await res.json().catch(() => ({})); @@ -235,15 +400,17 @@ export default function CreativesPipeline() { setShowForm((v) => !v)} - className="inline-flex items-center gap-2 rounded-xl bg-brand-500/90 hover:bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white" - > - Nueva pieza - + isAgency ? ( + + ) : null } /> @@ -275,8 +442,39 @@ export default function CreativesPipeline() { ))} +
+ + {Array.from( + new Set(creatives.map((c) => c.month).filter((m): m is string => Boolean(m))) + ) + .sort() + .map((m) => ( + + ))} +
- {showForm && ( + {showForm && isAgency && (

Nueva creatividad

@@ -302,6 +500,16 @@ export default function CreativesPipeline() { placeholder="Marca del cliente" /> + + + setDraft((d) => ({ ...d, month: e.target.value })) + } + className={inputCls} + /> + - setDraft((d) => ({ ...d, landing_url: e.target.value })) + setDraft((d) => ({ + ...d, + landing_url: e.target.value, + instagram_url: /instagram\.com/i.test(e.target.value) + ? e.target.value + : d.instagram_url, + })) } className={inputCls} - placeholder="https://…" + placeholder="https://… o enlace de Instagram" /> + +