Add in-app editorial calendar creator with month zoom and preview.

Agency can pick a month, generate a brand/seasonality preview, and add/edit/delete pieces with the same fields as the creative pipeline, optionally syncing drafts there.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-08 18:12:10 +02:00
parent 501f8203e8
commit f316497650
7 changed files with 1700 additions and 8 deletions

View File

@@ -0,0 +1,767 @@
'use client';
import React, { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CalendarPlus,
Loader2,
Plus,
Sparkles,
Trash2,
X,
Pencil,
} from 'lucide-react';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { monthLabel } from '@/lib/calendar-import';
const MONTH_NAMES = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
function ymNow(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function addMonths(ym: string, n: number): string {
const [y, m] = ym.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1 + n, 1));
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}`;
}
function colorFor(p: RrssPiece): string {
if (p.color) return p.color;
if ((p.kind || '').includes('stor') || /stor/i.test(p.format || '')) {
return '#374151';
}
if ((p.platform || '').includes('linkedin')) return '#7C66B8';
if (/reel/i.test(p.format || '')) return '#7C66B8';
return '#2071cf';
}
type PieceForm = {
id?: string;
date: string;
time: string;
title: string;
platform: string;
format: string;
pillar: string;
hook: string;
body: string;
cta: string;
hashtags: string;
objective: string;
notes_internas: string;
syncPipeline: boolean;
};
function emptyForm(date: string): PieceForm {
return {
date,
time: '10:30',
title: '',
platform: 'instagram',
format: 'Post',
pillar: 'feed',
hook: '',
body: '',
cta: 'Enlace en la bio',
hashtags: '',
objective: '',
notes_internas: '',
syncPipeline: true,
};
}
function fromPiece(p: RrssPiece): PieceForm {
return {
id: p.id,
date: p.date.slice(0, 10),
time: (p.time || '10:30').slice(0, 5),
title: p.title || '',
platform: (p.platform || 'instagram').toLowerCase(),
format: p.format || 'Post',
pillar: p.pillar || 'feed',
hook: p.hook || '',
body: p.body || '',
cta: p.cta || '',
hashtags: p.hashtags || '',
objective: p.objective_metric || '',
notes_internas: '',
syncPipeline: true,
};
}
export default function CalendarCreator({
tenantLabel,
existingMonths = [],
}: {
tenantLabel: string;
existingMonths?: string[];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'pick' | 'edit'>('pick');
const [month, setMonth] = useState(ymNow());
const [plan, setPlan] = useState<RrssMonthPlan | null>(null);
const [rationale, setRationale] = useState('');
const [source, setSource] = useState<string>('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [dayZoom, setDayZoom] = useState<string | null>(null);
const [form, setForm] = useState<PieceForm | null>(null);
const monthChoices = useMemo(() => {
const base = ymNow();
return Array.from({ length: 6 }, (_, i) => addMonths(base, i));
}, []);
const byDate = useMemo(() => {
const m = new Map<string, RrssPiece[]>();
for (const p of plan?.pieces || []) {
const k = p.date.slice(0, 10);
(m.get(k) || m.set(k, []).get(k)!).push(p);
}
return m;
}, [plan]);
const grid = useMemo(() => {
if (!month) return { pad: 0, days: 0 };
const [y, m] = month.split('-').map(Number);
const first = new Date(Date.UTC(y, m - 1, 1));
const days = new Date(Date.UTC(y, m, 0)).getUTCDate();
const pad = (first.getUTCDay() + 6) % 7;
return { pad, days, y, m };
}, [month]);
async function loadOrCreate(ym: string) {
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/calendar/month?month=${ym}`);
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Error');
if (j.plan) {
setPlan(j.plan);
setRationale('Mes ya existente — edita o regenera preview.');
setSource('existing');
} else {
setPlan(null);
setRationale('');
setSource('');
}
setMonth(ym);
setStep('edit');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setBusy(false);
}
}
async function runPreview() {
setBusy(true);
setErr(null);
try {
const r = await fetch('/api/calendar/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ month }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo generar preview');
setPlan(j.plan);
setRationale(j.rationale || '');
setSource(j.source || 'heuristic');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error preview');
} finally {
setBusy(false);
}
}
async function savePlan(force = false) {
if (!plan) return;
setBusy(true);
setErr(null);
try {
const r = await fetch('/api/calendar/month', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: force || source === 'existing' ? 'replace_plan' : 'create',
month,
plan,
force: true,
}),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo guardar');
setPlan(j.plan || plan);
router.refresh();
router.push(`/dashboard/calendario?view=month&month=${month}`);
setOpen(false);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error al guardar');
} finally {
setBusy(false);
}
}
async function savePiece() {
if (!form || !form.title.trim()) return;
setBusy(true);
setErr(null);
try {
// Ensure month doc exists first
if (!plan) {
await fetch('/api/calendar/month', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create', month }),
});
}
const r = await fetch('/api/calendar/month', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'upsert_piece',
month,
syncPipeline: form.syncPipeline,
piece: {
id: form.id,
date: form.date,
time: form.time,
title: form.title,
platform: form.platform,
format: form.format,
pillar: form.pillar,
hook: form.hook || form.title,
body: form.body || form.title,
cta: form.cta,
hashtags: form.hashtags,
objective_metric: form.objective,
kind: /stor/i.test(form.format)
? 'story'
: /reel/i.test(form.format)
? 'reel'
: 'post',
},
}),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo guardar la pieza');
setPlan(j.plan);
setForm(null);
setDayZoom(null);
router.refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error pieza');
} finally {
setBusy(false);
}
}
async function deletePiece(id: string) {
if (!confirm('¿Eliminar esta publicación del calendario?')) return;
setBusy(true);
setErr(null);
try {
const r = await fetch('/api/calendar/month', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_piece', month, pieceId: id }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo eliminar');
setPlan(j.plan);
setForm(null);
router.refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setBusy(false);
}
}
const dayPieces = dayZoom ? byDate.get(dayZoom) || [] : [];
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
setStep('pick');
setPlan(null);
setErr(null);
setDayZoom(null);
setForm(null);
}}
className="inline-flex items-center gap-2 rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-gray-800"
>
<CalendarPlus className="h-4 w-4" />
Crear nuevo calendario
</button>
{open ? (
<div className="fixed inset-0 z-[80] flex items-stretch justify-center bg-black/50 p-2 sm:p-4">
<div className="flex w-full max-w-6xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl">
<header className="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-sky-600">
{tenantLabel} · nuevo mes editorial
</p>
<h2 className="text-lg font-extrabold text-gray-900">
{step === 'pick'
? 'Elige el mes'
: `Zoom · ${monthLabel(month)}`}
</h2>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
</header>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{err ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{err}
</p>
) : null}
{step === 'pick' ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{monthChoices.map((ym) => {
const exists = existingMonths.includes(ym);
const [y, m] = ym.split('-').map(Number);
return (
<button
key={ym}
type="button"
disabled={busy}
onClick={() => void loadOrCreate(ym)}
className={`rounded-2xl border px-4 py-5 text-left transition hover:-translate-y-0.5 ${
exists
? 'border-sky-300 bg-sky-50'
: 'border-gray-200 bg-white hover:border-brand-400'
}`}
>
<p className="text-xs font-bold uppercase tracking-wider text-gray-500">
{y}
</p>
<p className="text-xl font-extrabold text-gray-900">
{MONTH_NAMES[m - 1]}
</p>
<p className="mt-2 text-[11px] text-gray-500">
{exists ? 'Ya existe · abrir zoom' : 'Mes vacío · crear'}
</p>
</button>
);
})}
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setStep('pick')}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-700"
>
← Cambiar mes
</button>
<button
type="button"
disabled={busy}
onClick={() => void runPreview()}
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-60"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Generar preview editorial
</button>
<button
type="button"
disabled={busy || !plan?.pieces?.length}
onClick={() => void savePlan(true)}
className="rounded-lg bg-gray-900 px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-60"
>
Guardar mes en portal
</button>
<span className="text-[11px] text-gray-500">
{plan?.pieces?.length || 0} piezas
{source ? ` · ${source}` : ''}
</span>
</div>
{rationale ? (
<div className="rounded-xl border border-violet-200 bg-violet-50/70 px-4 py-3 text-xs text-violet-950 leading-relaxed">
<p className="font-bold uppercase tracking-wider text-violet-700 mb-1">
Criterio del preview
</p>
{rationale}
</div>
) : (
<p className="text-xs text-gray-500">
Genera un preview según la marca, estacionalidad e histórico
RRSS del cliente, o haz click en un día para añadir
publicaciones a mano (mismos campos que Pipeline
creatividades).
</p>
)}
{/* Large month zoom */}
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden">
<div className="bg-gradient-to-r from-[#1a2a63] to-[#2071CF] px-4 py-3 text-white flex items-center justify-between">
<span className="text-sm font-extrabold tracking-wider uppercase">
{monthLabel(month)}
</span>
<span className="text-[10px] font-bold uppercase tracking-wider bg-white/20 rounded-full px-2 py-1">
Zoom editorial
</span>
</div>
<div className="grid grid-cols-7 gap-px bg-gray-100 p-px">
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map(
(d) => (
<div
key={d}
className="bg-gray-50 py-2 text-center text-[10px] font-bold uppercase text-gray-500"
>
{d}
</div>
)
)}
{Array.from({ length: grid.pad }).map((_, i) => (
<div key={`pad-${i}`} className="bg-gray-50 min-h-[88px]" />
))}
{Array.from({ length: grid.days }).map((_, i) => {
const day = i + 1;
const key = `${month}-${String(day).padStart(2, '0')}`;
const hits = byDate.get(key) || [];
return (
<button
key={key}
type="button"
onClick={() => {
setDayZoom(key);
setForm(null);
}}
className="bg-white min-h-[88px] p-1.5 text-left hover:bg-sky-50/80 transition flex flex-col gap-1"
>
<span className="text-[11px] font-bold text-gray-700">
{day}
</span>
<div className="flex flex-col gap-0.5 flex-1">
{hits.slice(0, 3).map((p) => (
<span
key={p.id}
className="truncate rounded px-1 py-0.5 text-[9px] font-semibold text-white"
style={{ background: colorFor(p) }}
title={p.title}
>
{p.title}
</span>
))}
{hits.length > 3 ? (
<span className="text-[9px] text-gray-400">
+{hits.length - 3}
</span>
) : null}
</div>
</button>
);
})}
</div>
</div>
</>
)}
</div>
</div>
</div>
) : null}
{/* Day drawer */}
{dayZoom ? (
<div className="fixed inset-0 z-[90] flex justify-end bg-black/40">
<div className="h-full w-full max-w-md overflow-y-auto bg-white shadow-2xl">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500">
Día
</p>
<p className="text-base font-extrabold text-gray-900">{dayZoom}</p>
</div>
<button
type="button"
onClick={() => {
setDayZoom(null);
setForm(null);
}}
className="rounded-lg p-2 hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-3">
{!form ? (
<>
<button
type="button"
onClick={() => setForm(emptyForm(dayZoom))}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 px-3 py-2.5 text-sm font-semibold text-white hover:bg-brand-700"
>
<Plus className="h-4 w-4" />
Añadir publicación
</button>
{dayPieces.length === 0 ? (
<p className="text-xs text-gray-500">
Sin piezas este día. Añade una o genera preview del mes.
</p>
) : (
dayPieces.map((p) => (
<div
key={p.id}
className="rounded-xl border border-gray-200 p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-bold text-gray-900">
{p.title}
</p>
<p className="text-[11px] text-gray-500">
{p.platform} · {p.format} · {p.time || '—'}
{p.pillar ? ` · ${p.pillar}` : ''}
</p>
</div>
<span
className="mt-1 h-2.5 w-2.5 rounded-full shrink-0"
style={{ background: colorFor(p) }}
/>
</div>
<p className="text-xs text-gray-600 line-clamp-3 whitespace-pre-wrap">
{p.body || p.hook}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setForm(fromPiece(p))}
className="inline-flex flex-1 items-center justify-center gap-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-semibold"
>
<Pencil className="h-3 w-3" /> Editar
</button>
<button
type="button"
onClick={() => void deletePiece(p.id)}
className="inline-flex items-center justify-center gap-1 rounded-lg border border-red-200 px-2 py-1.5 text-xs font-semibold text-red-700"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))
)}
</>
) : (
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-wider text-gray-500">
{form.id ? 'Editar publicación' : 'Nueva publicación'}
<span className="ml-1 font-normal normal-case text-gray-400">
(mismos campos que Pipeline creatividades)
</span>
</p>
<label className="block text-xs text-gray-600">
Título / nombre
<input
value={form.title}
onChange={(e) =>
setForm({ ...form, title: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="POST 1 · … / REEL · … / STORIES · …"
/>
</label>
<div className="grid grid-cols-2 gap-2">
<label className="block text-xs text-gray-600">
Fecha
<input
type="date"
value={form.date}
onChange={(e) =>
setForm({ ...form, date: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Hora
<input
type="time"
value={form.time}
onChange={(e) =>
setForm({ ...form, time: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Plataforma
<select
value={form.platform}
onChange={(e) =>
setForm({ ...form, platform: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-2 text-sm"
>
<option value="instagram">Instagram</option>
<option value="facebook">Facebook</option>
<option value="linkedin">LinkedIn</option>
<option value="tiktok">TikTok</option>
</select>
</label>
<label className="block text-xs text-gray-600">
Formato
<select
value={form.format}
onChange={(e) =>
setForm({ ...form, format: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-2 text-sm"
>
<option value="Post">Post</option>
<option value="Carrusel">Carrusel</option>
<option value="Reel">Reel</option>
<option value="Stories">Stories</option>
</select>
</label>
</div>
<label className="block text-xs text-gray-600">
Pilar
<input
value={form.pillar}
onChange={(e) =>
setForm({ ...form, pillar: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="nuevo-alumno, educativo…"
/>
</label>
<label className="block text-xs text-gray-600">
Hook
<input
value={form.hook}
onChange={(e) =>
setForm({ ...form, hook: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Copy público
<textarea
value={form.body}
onChange={(e) =>
setForm({ ...form, body: e.target.value })
}
rows={5}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="Solo texto que irá a redes. Sin WhatsApp / PENDIENTE."
/>
</label>
<label className="block text-xs text-gray-600">
CTA
<input
value={form.cta}
onChange={(e) =>
setForm({ ...form, cta: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Hashtags
<input
value={form.hashtags}
onChange={(e) =>
setForm({ ...form, hashtags: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Objetivo / KPI (interno)
<input
value={form.objective}
onChange={(e) =>
setForm({ ...form, objective: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Notas internas (nunca al caption)
<textarea
value={form.notes_internas}
onChange={(e) =>
setForm({ ...form, notes_internas: e.target.value })
}
rows={2}
className="mt-1 w-full rounded-lg border border-amber-200 bg-amber-50/50 px-3 py-2 text-sm"
placeholder="WhatsApp, PENDIENTE, assignee…"
/>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700">
<input
type="checkbox"
checked={form.syncPipeline}
onChange={(e) =>
setForm({ ...form, syncPipeline: e.target.checked })
}
/>
Crear / actualizar también en Pipeline creatividades
</label>
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={busy || !form.title.trim()}
onClick={() => void savePiece()}
className="flex-1 rounded-xl bg-gray-900 px-3 py-2.5 text-sm font-semibold text-white disabled:opacity-60"
>
{busy ? 'Guardando…' : 'Guardar publicación'}
</button>
<button
type="button"
onClick={() => setForm(null)}
className="rounded-xl border border-gray-200 px-3 py-2.5 text-sm font-semibold"
>
Cancelar
</button>
</div>
</div>
)}
</div>
</div>
</div>
) : null}
</>
);
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import Link from 'next/link';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import CalendarCreator from '@/components/CalendarCreator';
import CalendarUpload from '@/components/CalendarUpload';
const MONTH_NAMES = [
@@ -210,7 +211,22 @@ export default function RrssArchiveYear({
</div>
</div>
{canUpload ? <CalendarUpload tenantLabel={tenantLabel} /> : null}
{canUpload ? (
<div className="flex flex-wrap items-center gap-3">
<CalendarCreator
tenantLabel={tenantLabel}
existingMonths={plans.map((p) => p.month).filter(Boolean)}
/>
<details className="text-xs text-gray-500">
<summary className="cursor-pointer font-semibold text-gray-600 hover:text-gray-900">
Importar Excel / CSV (opcional)
</summary>
<div className="mt-2">
<CalendarUpload tenantLabel={tenantLabel} />
</div>
</details>
</div>
) : null}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 12 }).map((_, i) => {