Link pipeline creatives to RRSS calendar pieces with pick-or-create UI.
Agency can choose a scheduled calendar post to prefill title, copy, pillar and date, or create/edit one in the calendar while adding it to the pipeline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,6 +21,30 @@ import {
|
||||
toDatetimeLocalValue,
|
||||
type PublishChannel,
|
||||
} from '@/lib/pipeline-schedule';
|
||||
import {
|
||||
buildCaption,
|
||||
mapFormatToPipeline,
|
||||
mapPipelineFormatToCalendar,
|
||||
scheduledAtFromPiece,
|
||||
} from '@/lib/calendar-documents';
|
||||
|
||||
type CalendarOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
month: string;
|
||||
format?: string;
|
||||
kind?: string;
|
||||
pillar?: string;
|
||||
hook?: string;
|
||||
body?: string;
|
||||
cta?: string;
|
||||
hashtags?: string;
|
||||
platform?: string;
|
||||
linked_pipeline?: boolean;
|
||||
pipeline_id?: string | null;
|
||||
};
|
||||
|
||||
type PipelineItem = {
|
||||
id: string;
|
||||
@@ -42,6 +66,7 @@ type PipelineItem = {
|
||||
publish_status?: string | null;
|
||||
publish_error?: string | null;
|
||||
publish_attempts?: number | null;
|
||||
calendar_piece_id?: string | null;
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
@@ -112,9 +137,17 @@ export default function CreativePipelineBoard() {
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [calPieces, setCalPieces] = useState<CalendarOption[]>([]);
|
||||
const [calPick, setCalPick] = useState<string>('__new__');
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newFormat, setNewFormat] = useState<PipelineItem['format']>('post');
|
||||
const [newMonth, setNewMonth] = useState('');
|
||||
const [newCaption, setNewCaption] = useState('');
|
||||
const [newHook, setNewHook] = useState('');
|
||||
const [newPillar, setNewPillar] = useState('');
|
||||
const [newDate, setNewDate] = useState('');
|
||||
const [newTime, setNewTime] = useState('10:00');
|
||||
const [syncCalendar, setSyncCalendar] = useState(true);
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>('all');
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>('all');
|
||||
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
|
||||
@@ -132,10 +165,65 @@ export default function CreativePipelineBoard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCalendarPieces() {
|
||||
try {
|
||||
const r = await fetch('/api/calendar/pieces');
|
||||
const j = await r.json();
|
||||
if (r.ok) setCalPieces((j.pieces || []) as CalendarOption[]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showForm && isAgency) void loadCalendarPieces();
|
||||
}, [showForm, isAgency]);
|
||||
|
||||
function applyCalendarPiece(id: string) {
|
||||
setCalPick(id);
|
||||
if (id === '__new__') {
|
||||
setNewTitle('');
|
||||
setNewCaption('');
|
||||
setNewHook('');
|
||||
setNewPillar('');
|
||||
setNewFormat('post');
|
||||
setNewMonth('');
|
||||
setNewDate('');
|
||||
setNewTime('10:00');
|
||||
return;
|
||||
}
|
||||
const p = calPieces.find((x) => x.id === id);
|
||||
if (!p) return;
|
||||
setNewTitle(p.title || '');
|
||||
setNewHook(p.hook || '');
|
||||
setNewCaption(buildCaption(p) || p.body || '');
|
||||
setNewPillar(p.pillar || '');
|
||||
setNewFormat(
|
||||
mapFormatToPipeline(p.format, p.kind) as PipelineItem['format']
|
||||
);
|
||||
setNewMonth(p.month || p.date.slice(0, 7));
|
||||
setNewDate(p.date.slice(0, 10));
|
||||
setNewTime((p.time || '10:00').slice(0, 5));
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setCalPick('__new__');
|
||||
setNewTitle('');
|
||||
setNewCaption('');
|
||||
setNewHook('');
|
||||
setNewPillar('');
|
||||
setNewFormat('post');
|
||||
setNewMonth('');
|
||||
setNewDate('');
|
||||
setNewTime('10:00');
|
||||
setSyncCalendar(true);
|
||||
setShowForm(false);
|
||||
}
|
||||
|
||||
const monthCounts = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const it of items) {
|
||||
@@ -155,7 +243,8 @@ export default function CreativePipelineBoard() {
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((it) => {
|
||||
if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) return false;
|
||||
if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth)
|
||||
return false;
|
||||
if (selectedFormat !== 'all' && it.format !== selectedFormat) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -175,19 +264,64 @@ export default function CreativePipelineBoard() {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
setErr(null);
|
||||
|
||||
let calendarPieceId: string | null =
|
||||
calPick !== '__new__' ? calPick : null;
|
||||
|
||||
if (syncCalendar) {
|
||||
const date =
|
||||
newDate ||
|
||||
(newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10));
|
||||
const calFmt = mapPipelineFormatToCalendar(newFormat);
|
||||
const calRes = await fetch('/api/calendar/pieces', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pieceId: calendarPieceId,
|
||||
title: newTitle.trim(),
|
||||
date,
|
||||
time: newTime || '10:00',
|
||||
format: calFmt.format,
|
||||
kind: calFmt.kind,
|
||||
hook: newHook || undefined,
|
||||
body: newCaption || undefined,
|
||||
pillar: newPillar || undefined,
|
||||
platform: 'instagram',
|
||||
}),
|
||||
});
|
||||
const calData = await calRes.json();
|
||||
if (!calRes.ok) {
|
||||
setErr(calData.error || 'No se pudo guardar en el calendario RRSS');
|
||||
return;
|
||||
}
|
||||
calendarPieceId = calData.piece?.id || calendarPieceId;
|
||||
void loadCalendarPieces();
|
||||
}
|
||||
|
||||
const sched = newDate
|
||||
? scheduledAtFromPiece({ date: newDate, time: newTime })
|
||||
: 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 }),
|
||||
body: JSON.stringify({
|
||||
title: newTitle.trim(),
|
||||
format: newFormat,
|
||||
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
|
||||
caption: newCaption || null,
|
||||
notes: newPillar ? `Pilar: ${newPillar}` : null,
|
||||
calendar_piece_id: calendarPieceId,
|
||||
scheduled_at: sched,
|
||||
channels: ['instagram', 'facebook'],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Error al crear');
|
||||
return;
|
||||
}
|
||||
setNewTitle('');
|
||||
setNewMonth('');
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -387,44 +521,164 @@ export default function CreativePipelineBoard() {
|
||||
|
||||
{showForm && isAgency ? (
|
||||
<Surface className="mb-6">
|
||||
<form onSubmit={createItem} className="flex flex-wrap items-end gap-3">
|
||||
<form onSubmit={createItem} className="space-y-4">
|
||||
<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>
|
||||
<label className="block text-xs font-semibold text-gray-700">
|
||||
Publicación del calendario RRSS
|
||||
</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"
|
||||
value={calPick}
|
||||
onChange={(e) => applyCalendarPiece(e.target.value)}
|
||||
className="mt-1 w-full 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>
|
||||
<option value="__new__">+ Nueva publicación (no está en el calendario)</option>
|
||||
{calPieces.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.date}
|
||||
{p.time ? ` ${p.time.slice(0, 5)}` : ''} · {p.title}
|
||||
{p.linked_pipeline ? ' · ya en pipeline' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-gray-500">
|
||||
Elige una pieza del plan editorial del cliente (Contexto) para
|
||||
heredar título, copy, pilar y fecha. O crea una nueva.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600">Mes</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="sm:col-span-2">
|
||||
<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 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
placeholder="Nombre de la pieza"
|
||||
required
|
||||
/>
|
||||
</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 w-full 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">
|
||||
Pilar / temática
|
||||
</label>
|
||||
<input
|
||||
value={newPillar}
|
||||
onChange={(e) => setNewPillar(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
placeholder="Educativo, reseña…"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
Fecha
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newDate}
|
||||
onChange={(e) => {
|
||||
setNewDate(e.target.value);
|
||||
if (e.target.value) setNewMonth(e.target.value.slice(0, 7));
|
||||
}}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
Hora
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newTime}
|
||||
onChange={(e) => setNewTime(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
Mes (agrupación)
|
||||
</label>
|
||||
<input
|
||||
type="month"
|
||||
value={newMonth}
|
||||
onChange={(e) => setNewMonth(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
Hook
|
||||
</label>
|
||||
<input
|
||||
value={newHook}
|
||||
onChange={(e) => setNewHook(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
placeholder="Gancho / primera línea"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
Copy / caption
|
||||
</label>
|
||||
<textarea
|
||||
value={newCaption}
|
||||
onChange={(e) => setNewCaption(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
placeholder="Texto completo del anuncio"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-xs text-gray-700">
|
||||
<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"
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={syncCalendar}
|
||||
onChange={(e) => setSyncCalendar(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{calPick === '__new__'
|
||||
? 'Crear también en el calendario RRSS del cliente'
|
||||
: 'Actualizar la pieza del calendario RRSS con estos datos (editar antigua)'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800"
|
||||
>
|
||||
{calPick === '__new__'
|
||||
? 'Crear en pipeline'
|
||||
: 'Añadir al pipeline (desde calendario)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800"
|
||||
>
|
||||
Crear
|
||||
</button>
|
||||
</form>
|
||||
</Surface>
|
||||
) : null}
|
||||
@@ -654,6 +908,12 @@ function PieceCard({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{item.calendar_piece_id && isAgency ? (
|
||||
<span className="inline-flex rounded-full border border-violet-200 bg-violet-50 px-2 py-0.5 text-[10px] font-semibold text-violet-800">
|
||||
Calendario
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{item.publish_status &&
|
||||
item.publish_status !== 'idle' &&
|
||||
isAgency ? (
|
||||
|
||||
Reference in New Issue
Block a user