From f316497650d3a1cf6624d33563b46fcd5c1f9754 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 8 Oct 2026 18:12:10 +0200 Subject: [PATCH] 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 --- src/app/api/calendar/month/route.ts | 404 ++++++++++++++ src/app/api/calendar/preview/route.ts | 73 +++ src/app/dashboard/calendario/page.tsx | 40 +- src/components/CalendarCreator.tsx | 767 ++++++++++++++++++++++++++ src/components/RrssArchiveYear.tsx | 18 +- src/lib/calendar-documents.ts | 15 +- src/lib/calendar-generate.ts | 391 +++++++++++++ 7 files changed, 1700 insertions(+), 8 deletions(-) create mode 100644 src/app/api/calendar/month/route.ts create mode 100644 src/app/api/calendar/preview/route.ts create mode 100644 src/components/CalendarCreator.tsx create mode 100644 src/lib/calendar-generate.ts diff --git a/src/app/api/calendar/month/route.ts b/src/app/api/calendar/month/route.ts new file mode 100644 index 0000000..2a09d07 --- /dev/null +++ b/src/app/api/calendar/month/route.ts @@ -0,0 +1,404 @@ +/** + * Create / update editorial month plans and pieces. + * POST { action: 'create'|'upsert_piece'|'delete_piece'|'replace_plan', month, piece?, plan?, syncPipeline? } + */ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { + asPlanObject, + emptyMonthPlan, + newPieceId, + buildCaption, + mapFormatToPipeline, + scheduledAtFromPiece, + type CalendarDoc, +} from '@/lib/calendar-documents'; +import { monthLabel } from '@/lib/calendar-import'; +import { sanitizePublishCaption } from '@/lib/publish-caption'; +import { enrichPieceFromCatalog } from '@/lib/calendar-template'; +import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; + +export const dynamic = 'force-dynamic'; + +function str(v: unknown, max = 4000): string { + return typeof v === 'string' ? v.trim().slice(0, max) : ''; +} + +async function loadMonthDoc( + base: string, + headers: Record, + tenantId: string, + month: string +): Promise<{ doc: CalendarDoc | null; plan: RrssMonthPlan | null }> { + const r = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + tenantId + )}&type=eq.calendar&select=id,title,content`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return { doc: null, plan: null }; + const docs = (await r.json()) as CalendarDoc[]; + for (const d of docs) { + const plan = asPlanObject(d.content) as RrssMonthPlan | null; + if (plan && plan.month === month) { + return { doc: d, plan: plan as unknown as RrssMonthPlan }; + } + } + return { doc: null, plan: null }; +} + +async function savePlan( + base: string, + headers: Record, + tenantId: string, + plan: RrssMonthPlan, + existingId?: string | null +) { + const title = `RRSS ${plan.label || monthLabel(plan.month)}`; + if (existingId) { + const patch = await fetch( + `${base}/rest/v1/documents?id=eq.${encodeURIComponent(existingId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + title, + content: plan, + status: 'approved', + updated_at: new Date().toISOString(), + }), + } + ); + if (!patch.ok) throw new Error(await patch.text()); + const rows = await patch.json(); + return Array.isArray(rows) ? rows[0] : rows; + } + const ins = await fetch(`${base}/rest/v1/documents`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + tenant_id: tenantId, + type: 'calendar', + title, + content: plan, + status: 'approved', + }), + }); + if (!ins.ok) throw new Error(await ins.text()); + const rows = await ins.json(); + return Array.isArray(rows) ? rows[0] : rows; +} + +function normalizePiece( + raw: Record, + fallbackDate: string +): RrssPiece { + const date = str(raw.date, 10) || fallbackDate; + const title = str(raw.title, 160) || 'Sin título'; + const format = str(raw.format, 40) || 'Post'; + const kindRaw = str(raw.kind, 20); + const kind = + kindRaw || + (/stor/i.test(format) ? 'story' : /reel/i.test(format) ? 'reel' : 'post'); + const cleanBody = sanitizePublishCaption( + str(raw.body, 4000) || title, + { maxLen: 4000 } + ).caption; + const enriched = enrichPieceFromCatalog({ + title, + platform: str(raw.platform, 40) || 'instagram', + format, + pillar: str(raw.pillar, 80), + time: str(raw.time, 5), + cta: str(raw.cta, 200), + hashtags: str(raw.hashtags, 500), + hook: str(raw.hook, 300), + body: cleanBody, + }); + return { + id: str(raw.id, 120) || newPieceId(date, title), + date, + time: enriched.time || '10:30', + platform: (enriched.platform || 'instagram').toLowerCase(), + format: enriched.format || format, + pillar: enriched.pillar || undefined, + title: enriched.title, + hook: enriched.hook || enriched.title, + body: enriched.body || enriched.title, + cta: enriched.cta || undefined, + hashtags: enriched.hashtags || undefined, + objective_metric: str(raw.objective_metric || raw.objetivo, 200) || undefined, + status: str(raw.status, 40) || 'planned', + kind, + color: + kind === 'story' + ? '#374151' + : /linkedin/i.test(enriched.platform || '') + ? '#7C66B8' + : '#2071cf', + }; +} + +async function syncPieceToPipeline( + base: string, + headers: Record, + tenantId: string, + userId: string, + piece: RrssPiece, + month: string +) { + const caption = sanitizePublishCaption( + buildCaption(piece) || piece.title, + { maxLen: 4000 } + ).caption; + // Find existing by calendar_piece_id + const find = await fetch( + `${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent( + tenantId + )}&calendar_piece_id=eq.${encodeURIComponent(piece.id)}&select=id&limit=1`, + { headers, cache: 'no-store' } + ); + const existing = find.ok ? ((await find.json()) as { id: string }[]) : []; + const row: Record = { + title: piece.title, + format: mapFormatToPipeline(piece.format, piece.kind), + month, + caption: caption || null, + notes: piece.pillar ? `Pilar: ${piece.pillar}` : null, + calendar_piece_id: piece.id, + scheduled_at: scheduledAtFromPiece(piece), + channels: + (piece.platform || '').toLowerCase() === 'facebook' + ? ['facebook', 'instagram'] + : ['instagram', 'facebook'], + updated_at: new Date().toISOString(), + }; + if (existing[0]?.id) { + await fetch( + `${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(existing[0].id)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(row), + } + ); + return existing[0].id; + } + const ins = await fetch(`${base}/rest/v1/creative_pipeline`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ + ...row, + tenant_id: tenantId, + status: 'propuesta', + publish_status: 'idle', + created_by: userId, + }), + }); + if (!ins.ok) return null; + const rows = (await ins.json()) as { id: string }[]; + return rows[0]?.id || null; +} + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + const body = (await req.json().catch(() => ({}))) as Record; + const action = str(body.action, 40) || 'create'; + const month = str(body.month, 7); + if (!/^\d{4}-\d{2}$/.test(month)) { + return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 }); + } + + const { doc, plan: existing } = await loadMonthDoc( + base, + headers, + ctx.tenantId, + month + ); + + try { + if (action === 'create') { + if (existing && !(body.force === true)) { + return NextResponse.json({ + ok: true, + existed: true, + plan: existing, + document_id: doc?.id, + }); + } + const seed = body.plan as RrssMonthPlan | undefined; + const plan = + seed && seed.month === month + ? seed + : emptyMonthPlan(month, ctx.label); + const saved = await savePlan( + base, + headers, + ctx.tenantId, + plan, + body.force === true ? doc?.id : null + ); + return NextResponse.json({ + ok: true, + created: true, + plan, + document_id: saved?.id, + }); + } + + if (action === 'replace_plan') { + const seed = body.plan as RrssMonthPlan | undefined; + if (!seed || seed.month !== month) { + return NextResponse.json({ error: 'plan inválido' }, { status: 400 }); + } + const saved = await savePlan(base, headers, ctx.tenantId, seed, doc?.id); + return NextResponse.json({ + ok: true, + plan: seed, + document_id: saved?.id, + }); + } + + if (action === 'upsert_piece') { + const raw = (body.piece || {}) as Record; + const piece = normalizePiece(raw, `${month}-01`); + if (!piece.date.startsWith(month)) { + return NextResponse.json( + { error: 'La fecha debe pertenecer al mes' }, + { status: 400 } + ); + } + let plan = existing || emptyMonthPlan(month, ctx.label); + const idx = plan.pieces.findIndex((p) => p.id === piece.id); + if (idx >= 0) plan.pieces[idx] = { ...plan.pieces[idx], ...piece }; + else plan.pieces.push(piece); + plan.pieces.sort((a, b) => a.date.localeCompare(b.date)); + plan.hero = { + ...plan.hero, + chips: [`${plan.pieces.length} piezas`], + }; + const saved = await savePlan(base, headers, ctx.tenantId, plan, doc?.id); + let pipelineId: string | null = null; + if (body.syncPipeline !== false) { + pipelineId = await syncPieceToPipeline( + base, + headers, + ctx.tenantId, + user.id, + piece, + month + ); + } + return NextResponse.json({ + ok: true, + piece, + plan, + document_id: saved?.id, + pipeline_id: pipelineId, + }); + } + + if (action === 'delete_piece') { + const pieceId = str(body.pieceId || (body.piece as { id?: string })?.id, 120); + if (!pieceId || !existing) { + return NextResponse.json({ error: 'pieza no encontrada' }, { status: 404 }); + } + const plan = { + ...existing, + pieces: existing.pieces.filter((p) => p.id !== pieceId), + }; + plan.hero = { + ...plan.hero, + chips: [`${plan.pieces.length} piezas`], + }; + const saved = await savePlan(base, headers, ctx.tenantId, plan, doc?.id); + return NextResponse.json({ + ok: true, + plan, + document_id: saved?.id, + }); + } + + return NextResponse.json({ error: 'action desconocida' }, { status: 400 }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message.slice(0, 400) : 'Error' }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 }); + } + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + const month = req.nextUrl.searchParams.get('month') || ''; + if (!/^\d{4}-\d{2}$/.test(month)) { + return NextResponse.json({ error: 'month requerido' }, { status: 400 }); + } + const { doc, plan } = await loadMonthDoc(base, headers, ctx.tenantId, month); + return NextResponse.json({ + ok: true, + plan, + document_id: doc?.id || null, + exists: Boolean(plan), + }); +} diff --git a/src/app/api/calendar/preview/route.ts b/src/app/api/calendar/preview/route.ts new file mode 100644 index 0000000..3cab31b --- /dev/null +++ b/src/app/api/calendar/preview/route.ts @@ -0,0 +1,73 @@ +/** + * POST { month: 'YYYY-MM' } → editorial preview for active tenant. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { asPlanObject } from '@/lib/calendar-documents'; +import { generateMonthPreview } from '@/lib/calendar-generate'; +import type { RrssMonthPlan } from '@/components/RrssMonthView'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Solo agencia' }, { status: 403 }); + } + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + + const body = (await req.json().catch(() => ({}))) as { month?: string }; + const month = String(body.month || '').slice(0, 7); + if (!/^\d{4}-\d{2}$/.test(month)) { + return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 }); + } + + let slug = 'cliente'; + const t = await restGet<{ slug?: string }[]>( + `/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug` + ); + slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-'); + + const docs = await restGet<{ content?: unknown }[]>( + `/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + ctx.tenantId + )}&type=eq.calendar&select=content&order=created_at.desc&limit=24` + ); + const history: RrssMonthPlan[] = []; + for (const d of docs.data || []) { + const plan = asPlanObject(d.content) as RrssMonthPlan | null; + if (plan?.month && plan.month !== month && Array.isArray(plan.pieces)) { + history.push(plan as unknown as RrssMonthPlan); + } + } + + const result = await generateMonthPreview({ + ym: month, + slug, + label: ctx.label, + history, + }); + + return NextResponse.json({ + ok: true, + plan: result.plan, + rationale: result.rationale, + source: result.source, + brand: slug, + }); +} diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 48fb102..922bb41 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -11,6 +11,7 @@ import octPlan from '@/data/rrss-octubre-2026.json'; import { Instagram, Linkedin } from 'lucide-react'; import { PageHeader } from '@/components/ui'; import CalendarUpload from '@/components/CalendarUpload'; +import CalendarCreator from '@/components/CalendarCreator'; import { mergePipelineIntoPlans, type PipelineCalendarRow, @@ -179,7 +180,14 @@ export default async function CalendarioPage({ LinkedIn - {agency ? : null} + {agency ? ( +
+ p.month).filter(Boolean)} + /> +
+ ) : null} ); } @@ -215,7 +223,14 @@ export default async function CalendarioPage({ if (active && active.kind === 'rrss_month') { return (
- {agency ? : null} + {agency ? ( +
+ p.month).filter(Boolean)} + /> +
+ ) : null}

- {ctx.label} no tiene un plan RRSS en el portal. Sube un CSV o Excel - para encajar las publicaciones por día. + {ctx.label} no tiene un plan RRSS en el portal. Crea un mes nuevo con + preview editorial o añade publicaciones día a día.

- {agency ? : null} + {agency ? ( + p.month).filter(Boolean)} + /> + ) : null} + {agency ? ( +
+ + Importar Excel / CSV (opcional) + +
+ +
+
+ ) : null}
); } diff --git a/src/components/CalendarCreator.tsx b/src/components/CalendarCreator.tsx new file mode 100644 index 0000000..b087b03 --- /dev/null +++ b/src/components/CalendarCreator.tsx @@ -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(null); + const [rationale, setRationale] = useState(''); + const [source, setSource] = useState(''); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [dayZoom, setDayZoom] = useState(null); + const [form, setForm] = useState(null); + + const monthChoices = useMemo(() => { + const base = ymNow(); + return Array.from({ length: 6 }, (_, i) => addMonths(base, i)); + }, []); + + const byDate = useMemo(() => { + const m = new Map(); + 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 ( + <> + + + {open ? ( +
+
+
+
+

+ {tenantLabel} · nuevo mes editorial +

+

+ {step === 'pick' + ? 'Elige el mes' + : `Zoom · ${monthLabel(month)}`} +

+
+ +
+ +
+ {err ? ( +

+ {err} +

+ ) : null} + + {step === 'pick' ? ( +
+ {monthChoices.map((ym) => { + const exists = existingMonths.includes(ym); + const [y, m] = ym.split('-').map(Number); + return ( + + ); + })} +
+ ) : ( + <> +
+ + + + + {plan?.pieces?.length || 0} piezas + {source ? ` · ${source}` : ''} + +
+ + {rationale ? ( +
+

+ Criterio del preview +

+ {rationale} +
+ ) : ( +

+ 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). +

+ )} + + {/* Large month zoom */} +
+
+ + {monthLabel(month)} + + + Zoom editorial + +
+
+ {['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map( + (d) => ( +
+ {d} +
+ ) + )} + {Array.from({ length: grid.pad }).map((_, i) => ( +
+ ))} + {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 ( + + ); + })} +
+
+ + )} +
+
+
+ ) : null} + + {/* Day drawer */} + {dayZoom ? ( +
+
+
+
+

+ Día +

+

{dayZoom}

+
+ +
+ +
+ {!form ? ( + <> + + {dayPieces.length === 0 ? ( +

+ Sin piezas este día. Añade una o genera preview del mes. +

+ ) : ( + dayPieces.map((p) => ( +
+
+
+

+ {p.title} +

+

+ {p.platform} · {p.format} · {p.time || '—'} + {p.pillar ? ` · ${p.pillar}` : ''} +

+
+ +
+

+ {p.body || p.hook} +

+
+ + +
+
+ )) + )} + + ) : ( +
+

+ {form.id ? 'Editar publicación' : 'Nueva publicación'} + + (mismos campos que Pipeline creatividades) + +

+ +
+ + + + +
+ + +