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:
767
src/components/CalendarCreator.tsx
Normal file
767
src/components/CalendarCreator.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user