fix: unify creatives into Pipeline and hide Panel nav
Absorb client approval, month filters and editorial pieces into Pipeline; redirect /panel-cliente and remove duplicate menu entry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string, string[]> = {
|
||||
in_review: ['approved', 'rejected'],
|
||||
};
|
||||
|
||||
async function resolveTenantId(user: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
@@ -78,6 +88,23 @@ function previewFields(body: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCurrentStatus(
|
||||
id: string,
|
||||
tenantId: string | null,
|
||||
headers: Record<string, string> | null
|
||||
): Promise<string | null> {
|
||||
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<string, unknown>;
|
||||
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) {
|
||||
|
||||
@@ -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 <CreativePipelineBoard />;
|
||||
/** Panel absorbido por Pipeline (`/dashboard/creatives`). */
|
||||
export default function PanelClienteRedirect() {
|
||||
redirect('/dashboard/creatives');
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string | null>(null);
|
||||
const [isAgency, setIsAgency] = useState(true);
|
||||
const [monthFilter, setMonthFilter] = useState<string>('all');
|
||||
|
||||
function metaOf(c: Creative): {
|
||||
month?: string | null;
|
||||
notes?: string | null;
|
||||
instagram_url?: string | null;
|
||||
} {
|
||||
const m = (c as Creative & { meta?: Record<string, unknown> }).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() {
|
||||
<PageHeader
|
||||
eyebrow="Contenido"
|
||||
title="Pipeline"
|
||||
description="Preview de piezas en Meta/Google Ads o RRSS antes de aprobar."
|
||||
description="El cliente aprueba aquí. La agencia sube piezas (ads y RRSS) con preview de placement."
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Nueva pieza
|
||||
</button>
|
||||
isAgency ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Nueva pieza
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -275,8 +442,39 @@ export default function CreativesPipeline() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMonthFilter('all')}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
monthFilter === 'all'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Todos los meses
|
||||
</button>
|
||||
{Array.from(
|
||||
new Set(creatives.map((c) => c.month).filter((m): m is string => Boolean(m)))
|
||||
)
|
||||
.sort()
|
||||
.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMonthFilter(m)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
monthFilter === m
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
{showForm && isAgency && (
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-4 md:p-5 grid lg:grid-cols-[1fr_300px] gap-5">
|
||||
<form onSubmit={create} className="space-y-3">
|
||||
<p className="text-sm font-bold text-gray-900">Nueva creatividad</p>
|
||||
@@ -302,6 +500,16 @@ export default function CreativesPipeline() {
|
||||
placeholder="Marca del cliente"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Mes (YYYY-MM)">
|
||||
<input
|
||||
type="month"
|
||||
value={draft.month}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, month: e.target.value }))
|
||||
}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Canal">
|
||||
<select
|
||||
value={draft.channel}
|
||||
@@ -398,17 +606,33 @@ export default function CreativesPipeline() {
|
||||
placeholder="https://… o /api/creatives/media?id=…"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Landing">
|
||||
<Field label="Landing / Instagram">
|
||||
<input
|
||||
value={draft.landing_url}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Notas internas">
|
||||
<textarea
|
||||
className={`${inputCls} min-h-[72px]`}
|
||||
value={draft.notes}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, notes: e.target.value }))
|
||||
}
|
||||
placeholder="Contexto para el cliente o el equipo"
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-xl border border-dashed border-violet-500/40 bg-violet-500/5 p-4">
|
||||
<p className="text-[11px] uppercase tracking-wider text-violet-300 font-semibold mb-2 flex items-center gap-1.5">
|
||||
<Upload className="w-3.5 h-3.5" /> Subir creativo (sin URL)
|
||||
@@ -511,7 +735,8 @@ export default function CreativesPipeline() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="border border-gray-200 px-2 py-1 font-mono text-[10px] tracking-wide uppercase text-gray-600">
|
||||
{STATUSES.find((s) => s.id === c.status)?.label || c.status}
|
||||
{STATUSES.find((s) => s.id === (EDITORIAL_STATUS[c.status] || c.status))?.label ||
|
||||
statusLabel(c.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -539,27 +764,58 @@ export default function CreativesPipeline() {
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<select
|
||||
className="flex-1 text-[11px] rounded-lg bg-gray-900 border border-gray-200 text-gray-600 px-2 py-1.5"
|
||||
value={c.status}
|
||||
disabled={busyId === c.id}
|
||||
onChange={(e) => setStatus(c.id, e.target.value)}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
title="Borrar"
|
||||
disabled={busyId === c.id}
|
||||
onClick={() => remove(c.id)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{isAgency ? (
|
||||
<>
|
||||
<select
|
||||
className="flex-1 text-[11px] rounded-lg bg-gray-900 border border-gray-200 text-gray-600 px-2 py-1.5"
|
||||
value={EDITORIAL_STATUS[c.status] || c.status}
|
||||
disabled={busyId === c.id}
|
||||
onChange={(e) => setStatus(c.id, e.target.value)}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
title="Borrar"
|
||||
disabled={busyId === c.id}
|
||||
onClick={() => remove(c.id)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (EDITORIAL_STATUS[c.status] || c.status) === 'in_review' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === c.id}
|
||||
onClick={() => setStatus(c.id, 'approved')}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-2 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Aprobar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === c.id}
|
||||
onClick={() => setStatus(c.id, 'rejected')}
|
||||
className="flex-1 rounded-lg bg-red-600 px-2 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Rechazar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
{(EDITORIAL_STATUS[c.status] || c.status) === 'approved'
|
||||
? 'Aprobada por ti'
|
||||
: (EDITORIAL_STATUS[c.status] || c.status) === 'published'
|
||||
? 'Publicado'
|
||||
: 'Pendiente del equipo de 4Ventures'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -731,22 +987,48 @@ export default function CreativesPipeline() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200 bg-white flex flex-wrap justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === selected.id}
|
||||
onClick={() => remove(selected.id)}
|
||||
className="px-3 py-2 rounded-xl text-xs text-red-300 border border-red-500/30"
|
||||
>
|
||||
Borrar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === selected.id}
|
||||
onClick={saveSelected}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
|
||||
>
|
||||
Guardar cambios
|
||||
</button>
|
||||
{isAgency ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === selected.id}
|
||||
onClick={() => remove(selected.id)}
|
||||
className="px-3 py-2 rounded-xl text-xs text-red-300 border border-red-500/30"
|
||||
>
|
||||
Borrar
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{isAgency ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === selected.id}
|
||||
onClick={saveSelected}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40"
|
||||
>
|
||||
Guardar cambios
|
||||
</button>
|
||||
) : (EDITORIAL_STATUS[selected.status] || selected.status) ===
|
||||
'in_review' ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === selected.id}
|
||||
onClick={() => setStatus(selected.id, 'approved')}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-600 text-white"
|
||||
>
|
||||
Aprobar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === selected.id}
|
||||
onClick={() => setStatus(selected.id, 'rejected')}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold bg-red-600 text-white"
|
||||
>
|
||||
Rechazar
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,13 +100,6 @@ 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',
|
||||
|
||||
@@ -11,7 +11,6 @@ export type DashboardModuleId =
|
||||
| 'organico'
|
||||
| 'activos'
|
||||
| 'creatives'
|
||||
| 'panel_cliente'
|
||||
| 'asistente';
|
||||
|
||||
export type DashboardModuleDef = {
|
||||
@@ -92,14 +91,7 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [
|
||||
href: '/dashboard/creatives',
|
||||
label: 'Pipeline creatividades',
|
||||
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',
|
||||
hint: 'Aprobación de piezas ads y RRSS',
|
||||
},
|
||||
{
|
||||
id: 'asistente',
|
||||
|
||||
Reference in New Issue
Block a user