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>
1444 lines
59 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|