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:
4Ventures
2026-10-06 18:01:11 +02:00
parent 12ecde1b3b
commit c2bbbfcd06
6 changed files with 884 additions and 56 deletions

View File

@@ -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 ? (