Files
4ventures-client-portal/src/components/CalendarCreator.tsx
4Ventures 4a1abd3318 feat: LinkedIn editorial, accesos agencia estables y deshacer en historial
Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 04:15:51 +02:00

1444 lines
59 KiB
TypeScript

'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CalendarPlus,
ExternalLink,
Instagram,
Linkedin,
Loader2,
Plus,
Sparkles,
Trash2,
X,
Pencil,
} from 'lucide-react';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { monthLabel } from '@/lib/calendar-import';
import {
keepOnlyPieces,
mergeKeepAndRegenerate,
} from '@/lib/calendar-generate';
import {
DEFAULT_GEN_OPTIONS,
WEEKDAY_LABELS,
normalizeGenOptions,
type CalendarGenOptions,
} from '@/lib/calendar-gen-options';
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);
/** Piezas marcadas para regenerar (✓) */
const [regenIds, setRegenIds] = useState<Set<string>>(new Set());
const [genOptions, setGenOptions] = useState<CalendarGenOptions>({
...DEFAULT_GEN_OPTIONS,
});
const [showGenPanel, setShowGenPanel] = useState(true);
/** Pestaña del creador: IG/FB vs LinkedIn */
const [creatorTab, setCreatorTab] = useState<'ig-fb' | 'linkedin'>('ig-fb');
const [liStrategyMeta, setLiStrategyMeta] = useState<{
url: string | null;
posts_count: number;
themes: string[];
} | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const r = await fetch('/api/calendar/linkedin-strategy');
const j = await r.json();
if (!r.ok || cancelled) return;
setLiStrategyMeta({
url: j.url || j.brandbook_url || null,
posts_count: j.posts_count || 0,
themes: j.themes || [],
});
if (j.url || j.brandbook_url) {
setGenOptions((o) =>
o.linkedinStrategyUrl
? o
: {
...o,
linkedinStrategyUrl: String(j.url || j.brandbook_url || ''),
}
);
}
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, [open]);
const monthChoices = useMemo(() => {
const base = ymNow();
return Array.from({ length: 6 }, (_, i) => addMonths(base, i));
}, []);
const filteredPieces = useMemo(() => {
const all = plan?.pieces || [];
if (creatorTab === 'linkedin') {
return all.filter((p) => /linkedin/i.test(p.platform || ''));
}
return all.filter((p) => !/linkedin/i.test(p.platform || ''));
}, [plan, creatorTab]);
const byDate = useMemo(() => {
const m = new Map<string, RrssPiece[]>();
for (const p of filteredPieces) {
const k = p.date.slice(0, 10);
(m.get(k) || m.set(k, []).get(k)!).push(p);
}
return m;
}, [filteredPieces]);
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('');
}
setRegenIds(new Set());
setMonth(ym);
setStep('edit');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setBusy(false);
}
}
function toggleRegen(id: string) {
setRegenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleRegenDay(date: string) {
const ids = (byDate.get(date) || []).map((p) => p.id);
if (!ids.length) return;
setRegenIds((prev) => {
const next = new Set(prev);
const allOn = ids.every((id) => next.has(id));
if (allOn) ids.forEach((id) => next.delete(id));
else ids.forEach((id) => next.add(id));
return next;
});
}
async function runPreview(mode: 'replace' | 'regen_selected' = 'replace') {
setBusy(true);
setErr(null);
try {
let opts = normalizeGenOptions(genOptions);
// Pestaña LinkedIn: prioriza LI (default 8) y no regenera IG/FB en este preview
if (creatorTab === 'linkedin') {
if (opts.linkedinPosts <= 0) {
opts = { ...opts, linkedinPosts: 8 };
setGenOptions((o) => ({ ...o, linkedinPosts: 8 }));
}
opts = {
...opts,
posts: 0,
stories: 0,
reels: 0,
carruseles: 0,
};
}
const keepIds =
mode === 'regen_selected' && plan?.pieces?.length
? new Set(
(plan.pieces || [])
.filter((p) => !regenIds.has(p.id))
.map((p) => p.id)
)
: new Set<string>();
const keptPieces = (plan?.pieces || []).filter((p) => keepIds.has(p.id));
const r = await fetch('/api/calendar/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
month,
avoid_titles: keptPieces
.map((p) => p.title || p.hook || '')
.filter(Boolean),
gen_options: {
...opts,
regenInstructions:
mode === 'regen_selected'
? opts.regenInstructions || opts.instructions
: opts.instructions,
},
}),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo generar preview');
if (mode === 'regen_selected' && plan?.pieces?.length) {
if (regenIds.size === 0) {
throw new Error('Marca al menos una pieza (o un día) para regenerar.');
}
if (regenIds.size >= plan.pieces.length) {
setPlan(j.plan);
setRationale(j.rationale || '');
setSource(j.source || 'heuristic');
setRegenIds(new Set());
} else {
const merged = mergeKeepAndRegenerate(plan, j.plan, keepIds);
setPlan(merged);
setRationale(
`${j.rationale || ''}
Regeneradas: ${regenIds.size} · Conservadas: ${
keepIds.size
}.`
);
setSource(`${j.source || 'heuristic'}+regen`);
setRegenIds(new Set());
}
} else if (creatorTab === 'linkedin' && plan?.pieces?.length) {
// Conserva IG/FB existentes; sustituye solo LinkedIn
const keepIg = (plan.pieces || []).filter(
(p) => !/linkedin/i.test(p.platform || '')
);
const newLi = (j.plan?.pieces || []).filter((p: RrssPiece) =>
/linkedin/i.test(p.platform || '')
);
const merged: RrssMonthPlan = {
...(j.plan || plan),
pieces: [...keepIg, ...newLi].sort((a, b) =>
a.date.localeCompare(b.date)
),
};
setPlan(merged);
setRationale(
`${j.rationale || ''}\n\nLinkedIn regenerado (${newLi.length}); IG/FB conservados (${keepIg.length}).`
);
setSource(`${j.source || 'heuristic'}+li`);
setRegenIds(new Set());
} else {
setPlan(j.plan);
setRationale(j.rationale || '');
setSource(j.source || 'heuristic');
setRegenIds(new Set());
}
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error preview');
} finally {
setBusy(false);
}
}
function dropSelected() {
if (!plan?.pieces?.length) return;
if (regenIds.size === 0) {
setErr('Marca las piezas que quieres eliminar del preview.');
return;
}
const keep = new Set(
(plan.pieces || []).filter((p) => !regenIds.has(p.id)).map((p) => p.id)
);
setPlan(keepOnlyPieces(plan, keep));
setRationale(
`Eliminadas ${regenIds.size} pieza(s) del preview. Guarda el mes cuando quieras.`
);
setSource('seleccion');
setRegenIds(new Set());
setErr(null);
}
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);
}
}
async function deleteEntireMonth() {
if (!existingMonths.includes(month) && source !== 'existing') {
setErr('Este mes aún no está guardado en el portal.');
return;
}
const label = monthLabel(month);
if (
!confirm(
`¿Borrar por completo el calendario de ${label}?\n\nSe elimina el mes del portal y borradores de Pipeline RRSS vinculados.`
)
) {
return;
}
const typed = prompt(`Escribe ${month} para confirmar:`);
if (typed?.trim() !== month) {
setErr('Confirmación cancelada o incorrecta.');
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_month', month }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo borrar el mes');
setPlan(null);
setStep('pick');
setOpen(false);
router.push('/dashboard/calendario?view=archive');
router.refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error al borrar');
} finally {
setBusy(false);
}
}
const dayPieces = useMemo(() => {
if (!dayZoom) return [] as RrssPiece[];
// En el drawer del día mostramos todas las redes; el tab solo filtra el grid
return (plan?.pieces || []).filter((p) => p.date.slice(0, 10) === dayZoom);
}, [dayZoom, plan]);
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
setStep('pick');
setPlan(null);
setErr(null);
setDayZoom(null);
setForm(null);
}}
className="ml-auto 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">
<div className="inline-flex rounded-xl border border-gray-200 bg-gray-50 p-0.5">
<button
type="button"
onClick={() => setCreatorTab('ig-fb')}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold ${
creatorTab === 'ig-fb'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500'
}`}
>
<Instagram className="h-3.5 w-3.5" />
IG / FB
<span className="text-[10px] text-gray-400">
{
(plan?.pieces || []).filter(
(p) => !/linkedin/i.test(p.platform || '')
).length
}
</span>
</button>
<button
type="button"
onClick={() => setCreatorTab('linkedin')}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold ${
creatorTab === 'linkedin'
? 'bg-[#7C66B8] text-white shadow-sm'
: 'text-gray-500'
}`}
>
<Linkedin className="h-3.5 w-3.5" />
LinkedIn
<span
className={`text-[10px] ${
creatorTab === 'linkedin'
? 'text-white/70'
: 'text-gray-400'
}`}
>
{
(plan?.pieces || []).filter((p) =>
/linkedin/i.test(p.platform || '')
).length
}
</span>
</button>
</div>
<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('replace')}
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 estrategia del mes
</button>
<button
type="button"
disabled={busy || !plan?.pieces?.length || regenIds.size === 0}
onClick={() => void runPreview('regen_selected')}
title="Regenera solo las piezas marcadas (con instrucciones opcionales)"
className="inline-flex items-center gap-1.5 rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-700 disabled:opacity-60"
>
Regenerar marcadas
</button>
<button
type="button"
disabled={busy || regenIds.size === 0}
onClick={() => dropSelected()}
title="Quita del preview las piezas marcadas"
className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-900 disabled:opacity-60"
>
Eliminar marcadas
</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>
{existingMonths.includes(month) || source === 'existing' ? (
<button
type="button"
disabled={busy}
onClick={() => void deleteEntireMonth()}
className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 hover:bg-red-100 disabled:opacity-60"
>
<Trash2 className="h-3.5 w-3.5" />
Borrar mes completo
</button>
) : null}
<span className="text-[11px] text-gray-500">
{plan?.pieces?.length || 0} piezas
{regenIds.size ? ` · ${regenIds.size} ✓` : ''}
{source ? ` · ${source}` : ''}
</span>
</div>
{showGenPanel ? (
<div className="rounded-xl border border-violet-200 bg-violet-50/50 p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-bold uppercase tracking-wider text-violet-800">
Opciones de generación
</p>
<button
type="button"
className="text-[11px] text-violet-700 underline"
onClick={() => setShowGenPanel(false)}
>
Ocultar
</button>
</div>
{creatorTab === 'ig-fb' ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{(
[
['posts', 'Posts'],
['stories', 'Stories'],
['reels', 'Reels'],
['carruseles', 'Carruseles'],
] as const
).map(([key, label]) => (
<label key={key} className="text-[11px] text-gray-700">
{label}
<input
type="number"
min={0}
max={40}
value={genOptions[key]}
onChange={(e) =>
setGenOptions((o) => ({
...o,
[key]: Number(e.target.value) || 0,
}))
}
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm"
/>
</label>
))}
</div>
) : (
<div className="space-y-3 rounded-xl border border-[#7C66B8]/30 bg-white/80 p-3">
<p className="text-[11px] font-bold uppercase tracking-wider text-[#7C66B8]">
Generación LinkedIn B2B
</p>
<label className="text-[11px] text-gray-700 block">
Nº de posts LinkedIn
<input
type="number"
min={0}
max={40}
value={genOptions.linkedinPosts}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinPosts: Number(e.target.value) || 0,
}))
}
className="mt-0.5 w-full max-w-[120px] rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm"
/>
</label>
<div>
<p className="text-[11px] font-semibold text-gray-700 mb-1">
Fuente del copy
</p>
<div className="flex flex-wrap gap-1.5">
{(
[
['from_strategy', 'Estrategia (copie listo)'],
['ideas', 'Ideas nuevas'],
['mixed', 'Mixto'],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() =>
setGenOptions((o) => ({
...o,
linkedinMode: mode,
}))
}
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold border ${
genOptions.linkedinMode === mode
? 'bg-[#7C66B8] text-white border-[#7C66B8]'
: 'bg-white text-gray-600 border-gray-200'
}`}
>
{label}
</button>
))}
</div>
</div>
<label className="block text-[11px] text-gray-700">
URL estrategia LinkedIn
<input
type="url"
value={genOptions.linkedinStrategyUrl || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinStrategyUrl: e.target.value,
}))
}
placeholder="https://4ventures.es/bps/estrategia-linkedin-bps.html"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs font-mono"
/>
</label>
{liStrategyMeta?.url ? (
<p className="text-[11px] text-gray-600">
Detectada:{' '}
<a
href={liStrategyMeta.url}
target="_blank"
rel="noopener noreferrer"
className="text-[#7C66B8] underline inline-flex items-center gap-0.5"
>
{liStrategyMeta.url.replace(/^https?:\/\//, '').slice(0, 56)}
<ExternalLink className="h-3 w-3" />
</a>
{liStrategyMeta.posts_count
? ` · ${liStrategyMeta.posts_count} posts en estrategia`
: ''}
</p>
) : (
<p className="text-[11px] text-amber-800">
Sin estrategia en brandbook: pega la URL o guárdala en Zona de marca → LinkedIn B2B.
</p>
)}
{liStrategyMeta?.themes?.length ? (
<p className="text-[11px] text-gray-500">
Temas: {liStrategyMeta.themes.slice(0, 5).join(' · ')}
</p>
) : null}
<label className="block text-[11px] text-gray-700">
Instrucciones LinkedIn
<textarea
value={genOptions.linkedinInstructions || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
linkedinInstructions: e.target.value,
}))
}
rows={2}
placeholder="Ej: 8 posts desde la estrategia; tono C-level; CTA a demo…"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs"
/>
</label>
<p className="text-[10px] text-gray-500 leading-relaxed">
Con URL: revisa qué posts de la estrategia ya están publicados o
agendados y programa los pendientes (copie listo). Sin URL o
backlog agotado: crea ideas nuevas siguiendo temas/CTAs del
brandbook y el histórico LinkedIn. Publicación API cuando configures{' '}
<code className="bg-gray-100 px-1 rounded">LINKEDIN_ACCESS_TOKEN</code>.
</p>
</div>
)}
<div>
<p className="text-[11px] font-semibold text-gray-700 mb-1">
Días de la semana
</p>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ key, label }) => {
const on = genOptions.weekdays.includes(key);
return (
<button
key={key}
type="button"
onClick={() =>
setGenOptions((o) => ({
...o,
weekdays: on
? o.weekdays.filter((d) => d !== key)
: [...o.weekdays, key].sort((a, b) => a - b),
}))
}
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold border ${
on
? 'bg-violet-600 text-white border-violet-600'
: 'bg-white text-gray-600 border-gray-200'
}`}
>
{label}
</button>
);
})}
</div>
</div>
<label className="flex items-start gap-2 text-[11px] text-gray-800">
<input
type="checkbox"
className="mt-0.5"
checked={genOptions.allowSameDayMix}
onChange={(e) =>
setGenOptions((o) => ({
...o,
allowSameDayMix: e.target.checked,
}))
}
/>
<span>
Permitir <strong>story + post</strong> o{' '}
<strong>reel + story</strong> el mismo día
</span>
</label>
<label className="block text-[11px] text-gray-700">
Instrucciones para generar el mes
<textarea
value={genOptions.instructions || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
instructions: e.target.value,
}))
}
rows={2}
placeholder="Ej: enfocar Black Friday la 3ª semana; tono cercano; 2 stories encuesta…"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs"
/>
</label>
<label className="block text-[11px] text-gray-700">
Instrucciones solo al regenerar marcadas
<textarea
value={genOptions.regenInstructions || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
regenInstructions: e.target.value,
}))
}
rows={2}
placeholder="Ej: más CTA WhatsApp; menos educativo; urgencia…"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs"
/>
</label>
</div>
) : (
<button
type="button"
className="text-[11px] text-violet-700 underline"
onClick={() => setShowGenPanel(true)}
>
Mostrar opciones de generación
</button>
)}
{rationale ? (
<div className="rounded-xl border border-violet-200 bg-violet-50/70 px-4 py-3 text-xs text-violet-950 leading-relaxed whitespace-pre-wrap">
<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">
Marca ✓ las piezas/días a <strong>regenerar</strong>. Luego{' '}
<strong>Regenerar marcadas</strong> (con instrucciones) o{' '}
<strong>Eliminar marcadas</strong>. El resto se conserva.
No se guardan versiones intermedias hasta «Guardar mes».
</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) || [];
const dayKept =
hits.length > 0 && hits.every((p) => regenIds.has(p.id));
return (
<div
key={key}
className={`bg-white min-h-[88px] p-1.5 text-left flex flex-col gap-1 ${
dayKept ? 'ring-1 ring-inset ring-emerald-400' : ''
}`}
>
<div className="flex items-center justify-between gap-1">
<button
type="button"
onClick={() => {
setDayZoom(key);
setForm(null);
}}
className="text-[11px] font-bold text-gray-700 hover:text-sky-700"
>
{day}
</button>
{hits.length > 0 ? (
<label
className="flex items-center gap-0.5 text-[9px] text-emerald-700 cursor-pointer"
title="Marcar/desmarcar el día para regenerar"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={dayKept}
onChange={() => toggleRegenDay(key)}
className="h-3 w-3"
/>
día
</label>
) : null}
</div>
<button
type="button"
onClick={() => {
setDayZoom(key);
setForm(null);
}}
className="flex flex-col gap-0.5 flex-1 text-left hover:bg-sky-50/80 rounded"
>
{hits.slice(0, 3).map((p) => (
<span
key={p.id}
className={`truncate rounded px-1 py-0.5 text-[9px] font-semibold text-white ${
regenIds.has(p.id)
? 'outline outline-1 outline-offset-[-1px] outline-white'
: ''
}`}
style={{ background: colorFor(p) }}
title={p.title}
>
{regenIds.has(p.id) ? '✓ ' : ''}
{p.title}
</span>
))}
{hits.length > 3 ? (
<span className="text-[9px] text-gray-400">
+{hits.length - 3}
</span>
) : null}
</button>
</div>
);
})}
</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={() => {
const f = emptyForm(dayZoom);
if (creatorTab === 'linkedin') {
f.platform = 'linkedin';
f.format = 'Post';
f.pillar = 'b2b';
f.cta = 'Comenta tu caso · DM abierto';
}
setForm(f);
}}
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" />
{creatorTab === 'linkedin'
? 'Añadir post LinkedIn'
: '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 p-3 space-y-2 ${
regenIds.has(p.id)
? 'border-emerald-300 bg-emerald-50/40'
: 'border-gray-200'
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 cursor-pointer">
<input
type="checkbox"
checked={regenIds.has(p.id)}
onChange={() => toggleRegen(p.id)}
/>
{p.title}
</label>
<p className="text-[11px] text-gray-500 pl-6">
{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-6 whitespace-pre-wrap">
{p.body || p.hook}
</p>
{/linkedin/i.test(p.platform || '') ? (
<div className="rounded-lg border border-[#7C66B8]/25 bg-[#7C66B8]/5 p-2 space-y-1.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#7C66B8] inline-flex items-center gap-1">
<Linkedin className="h-3 w-3" /> Estrategia LinkedIn
</p>
{p.strategy_angle ? (
<p className="text-[11px] text-gray-700">
<span className="font-semibold">Ángulo:</span>{' '}
{p.strategy_angle}
</p>
) : null}
{(p.strategy_url || p.meta_line) && (
<a
href={
(p.meta_line?.includes('http')
? p.meta_line
: p.strategy_url) || '#'
}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-[#7C66B8] underline inline-flex items-center gap-1"
>
Ver en página de estrategia
{p.strategy_post_id
? ` (#${p.strategy_post_id})`
: ''}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
) : null}
{p.interactive_story &&
p.interactive_story !== 'none' ? (
<p className="text-[10px] font-semibold text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
Story interactiva: {p.interactive_story} · Meta API
no publica stickers — briefing de producción
</p>
) : null}
{p.slide_copy && p.slide_copy.length > 0 ? (
<div className="rounded-lg border border-gray-100 bg-gray-50 p-2 space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">
Texto por slide
</p>
{p.slide_copy.map((s) => (
<p key={s.n} className="text-[11px] text-gray-700">
<span className="font-semibold">
Slide {s.n}
{s.label ? ` · ${s.label}` : ''}:
</span>{' '}
{s.text}
</p>
))}
</div>
) : null}
{p.reel_script ? (
<details className="rounded-lg border border-violet-100 bg-violet-50/50 p-2">
<summary className="text-[10px] font-bold uppercase tracking-wider text-violet-800 cursor-pointer">
Guión / idea Reel
</summary>
<pre className="mt-1 text-[11px] text-violet-950 whitespace-pre-wrap font-sans">
{p.reel_script}
</pre>
</details>
) : null}
<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}
</>
);
}