Add agency-only schedule fields and queue for pipeline creatives.

Approved pieces can be timed for IG/FB from the agency board; cron queues due jobs and calendar uses scheduled_at. Meta publish comes next.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 17:54:00 +02:00
parent 392af11060
commit 12ecde1b3b
9 changed files with 629 additions and 13 deletions

View File

@@ -16,6 +16,11 @@ import {
Filter,
} from 'lucide-react';
import { PageHeader, Surface } from '@/components/ui';
import {
scheduleLabel,
toDatetimeLocalValue,
type PublishChannel,
} from '@/lib/pipeline-schedule';
type PipelineItem = {
id: string;
@@ -32,6 +37,11 @@ type PipelineItem = {
approved_at: string | null;
created_at: string;
updated_at: string;
scheduled_at?: string | null;
channels?: string[] | null;
publish_status?: string | null;
publish_error?: string | null;
publish_attempts?: number | null;
};
const STATUS_LABEL: Record<string, string> = {
@@ -196,7 +206,37 @@ export default function CreativePipelineBoard() {
return;
}
if (previewItem && previewItem.id === id) {
setPreviewItem({ ...previewItem, status });
setPreviewItem({ ...previewItem, ...(data.item || {}), status });
}
await load();
} finally {
setBusyId(null);
}
}
async function saveSchedule(
id: string,
payload: {
scheduled_at: string | null;
channels: PublishChannel[];
}
) {
if (!isAgency) return;
setBusyId(id);
setErr(null);
try {
const res = await fetch('/api/creative-pipeline', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...payload }),
});
const data = await res.json();
if (!res.ok) {
setErr(data.error || 'No se pudo guardar la programación');
return;
}
if (previewItem && previewItem.id === id && data.item) {
setPreviewItem({ ...previewItem, ...data.item });
}
await load();
} finally {
@@ -246,7 +286,11 @@ export default function CreativePipelineBoard() {
<PageHeader
eyebrow="Producción editorial"
title="Panel de Creatividades"
description="Planificación, diseño y archivo histórico sincronizado con Instagram."
description={
isAgency
? 'Producción editorial. Programa IG/FB solo desde aquí (vista agencia).'
: 'Revisa y aprueba las creatividades propuestas por 4Ventures.'
}
actions={
isAgency ? (
<button
@@ -436,6 +480,7 @@ export default function CreativePipelineBoard() {
busy={busyId === previewItem.id}
onClose={() => setPreviewItem(null)}
onStatus={(s) => setStatus(previewItem.id, s)}
onSaveSchedule={(payload) => saveSchedule(previewItem.id, payload)}
/>
)}
</div>
@@ -603,6 +648,28 @@ function PieceCard({
{STATUS_LABEL[item.status] || item.status}
</span>
{scheduleLabel(item.scheduled_at) ? (
<span className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-semibold text-sky-800">
{scheduleLabel(item.scheduled_at)}
</span>
) : null}
{item.publish_status &&
item.publish_status !== 'idle' &&
isAgency ? (
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${
item.publish_status === 'published'
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
: item.publish_status === 'failed'
? 'border-red-200 bg-red-50 text-red-800'
: 'border-amber-200 bg-amber-50 text-amber-900'
}`}
>
{item.publish_status}
</span>
) : null}
{igUrl && (
<a
href={igUrl}
@@ -669,16 +736,37 @@ function DetailModal({
busy,
onClose,
onStatus,
onSaveSchedule,
}: {
item: PipelineItem;
isAgency: boolean;
busy: boolean;
onClose: () => void;
onStatus: (status: string) => void;
onSaveSchedule: (payload: {
scheduled_at: string | null;
channels: PublishChannel[];
}) => void;
}) {
const slides = item.slide_paths || [];
const allImages = [item.cover_path, ...slides].filter(Boolean) as string[];
const [activeIdx, setActiveIdx] = useState(0);
const [schedLocal, setSchedLocal] = useState(
toDatetimeLocalValue(item.scheduled_at)
);
const [chIg, setChIg] = useState(
(item.channels || ['instagram', 'facebook']).includes('instagram')
);
const [chFb, setChFb] = useState(
(item.channels || ['instagram', 'facebook']).includes('facebook')
);
useEffect(() => {
setSchedLocal(toDatetimeLocalValue(item.scheduled_at));
const ch = item.channels || ['instagram', 'facebook'];
setChIg(ch.includes('instagram'));
setChFb(ch.includes('facebook'));
}, [item.id, item.scheduled_at, item.channels]);
const activePath = allImages[activeIdx] || item.cover_path;
const currentUrl = mediaUrl(activePath);
@@ -690,6 +778,16 @@ function DetailModal({
const clientCanDecide = !isAgency && item.status === 'revision_interna';
function submitSchedule() {
const channels: PublishChannel[] = [];
if (chIg) channels.push('instagram');
if (chFb) channels.push('facebook');
onSaveSchedule({
scheduled_at: schedLocal ? new Date(schedLocal).toISOString() : null,
channels: channels.length ? channels : ['instagram', 'facebook'],
});
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
@@ -820,6 +918,85 @@ function DetailModal({
</div>
</div>
)}
{isAgency ? (
<div className="rounded-xl border border-sky-200 bg-sky-50/60 p-3 space-y-3">
<p className="text-xs font-bold uppercase tracking-wider text-sky-900">
Programación (solo agencia)
</p>
<label className="block text-xs text-gray-700">
Fecha y hora
<input
type="datetime-local"
value={schedLocal}
onChange={(e) => setSchedLocal(e.target.value)}
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-900"
/>
</label>
<div className="flex flex-wrap gap-3 text-xs text-gray-800">
<label className="inline-flex items-center gap-1.5">
<input
type="checkbox"
checked={chIg}
onChange={(e) => setChIg(e.target.checked)}
/>
Instagram
</label>
<label className="inline-flex items-center gap-1.5">
<input
type="checkbox"
checked={chFb}
onChange={(e) => setChFb(e.target.checked)}
/>
Facebook
</label>
</div>
{item.publish_status && item.publish_status !== 'idle' ? (
<p className="text-[11px] text-gray-600">
Worker: <strong>{item.publish_status}</strong>
{item.publish_attempts
? ` · intentos ${item.publish_attempts}`
: ''}
</p>
) : null}
{item.publish_error ? (
<p className="text-[11px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2 py-1.5">
{item.publish_error}
</p>
) : null}
<div className="flex gap-2">
<button
type="button"
disabled={busy}
onClick={submitSchedule}
className="flex-1 rounded-lg bg-sky-700 px-3 py-2 text-xs font-semibold text-white hover:bg-sky-800 disabled:opacity-60"
>
Guardar programación
</button>
{schedLocal ? (
<button
type="button"
disabled={busy}
onClick={() => {
setSchedLocal('');
onSaveSchedule({
scheduled_at: null,
channels: ['instagram', 'facebook'],
});
}}
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700"
>
Quitar
</button>
) : null}
</div>
<p className="text-[10px] text-sky-900/80 leading-relaxed">
Requiere estado <strong>aprobado</strong> para entrar en cola.
Publica IG+FB al unísono vía cron (Meta se conecta en el
siguiente paso).
</p>
</div>
) : null}
</div>
<div className="mt-6 border-t border-gray-100 pt-4">