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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user