Make uploaded calendars live with drag-and-drop days and readable chips.
Posts persist to the tenant calendar document when moved; contrast on the month grid no longer uses dark text on dark fills. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -181,8 +181,8 @@ export default function RrssArchiveYear({
|
||||
Calendario IG / FB {year}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-2 max-w-xl leading-relaxed">
|
||||
Archivo mensual de contenidos. Los días redondeados y coloreados
|
||||
marcan el tipo de publicación (misma paleta que en cada mes).
|
||||
Archivo mensual de contenidos. Los días coloreados marcan el tipo
|
||||
de publicación.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
GripVertical,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
MessageSquare,
|
||||
@@ -16,7 +17,6 @@ import CalendarDayZoom, {
|
||||
type SocialLite,
|
||||
} from '@/components/CalendarDayZoom';
|
||||
import {
|
||||
pipelineMediaUrl,
|
||||
pipelinePublishDay,
|
||||
type PipelineCalendarRow,
|
||||
} from '@/lib/pipeline-calendar';
|
||||
@@ -112,25 +112,30 @@ function colorFor(piece: RrssPiece): string {
|
||||
function DualBadge({ dual }: { dual?: Dual }) {
|
||||
if (!dual) {
|
||||
return (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-white/10 text-gray-900/50">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-gray-300 text-gray-700 bg-white">
|
||||
Pendiente
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (dual.both) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-800 border border-emerald-200">
|
||||
<ShieldCheck className="w-3 h-3" /> Doble OK
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-200 border border-amber-500/25">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-50 text-amber-900 border border-amber-200">
|
||||
{dual.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function chipTone(piece: RrssPiece): { accent: string; bg: string } {
|
||||
const accent = colorFor(piece);
|
||||
return { accent, bg: `${accent}22` };
|
||||
}
|
||||
|
||||
function pieceToRow(p: RrssPiece): PipelineCalendarRow {
|
||||
return {
|
||||
id: p.pipeline_id || p.id,
|
||||
@@ -157,7 +162,7 @@ export default function RrssMonthView({
|
||||
canCollaborate?: boolean;
|
||||
initialPlatform?: 'all' | string;
|
||||
}) {
|
||||
const pieces = plan.pieces || [];
|
||||
const [livePieces, setLivePieces] = useState<RrssPiece[]>(plan.pieces || []);
|
||||
const [platform, setPlatform] = useState<'all' | string>(initialPlatform);
|
||||
const [selected, setSelected] = useState<RrssPiece | null>(null);
|
||||
const [dayZoom, setDayZoom] = useState<string | null>(null);
|
||||
@@ -169,6 +174,16 @@ export default function RrssMonthView({
|
||||
const [party, setParty] = useState<'agency' | 'client'>('client');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dragOverDay, setDragOverDay] = useState<string | null>(null);
|
||||
const [moveMsg, setMoveMsg] = useState<string | null>(null);
|
||||
const draggingId = React.useRef<string | null>(null);
|
||||
const suppressClick = React.useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLivePieces(plan.pieces || []);
|
||||
}, [plan]);
|
||||
|
||||
const pieces = livePieces;
|
||||
|
||||
const platforms = useMemo(() => {
|
||||
const set = new Set(pieces.map((p) => p.platform));
|
||||
@@ -309,7 +324,13 @@ export default function RrssMonthView({
|
||||
: false;
|
||||
|
||||
function rowsForDay(key: string): PipelineCalendarRow[] {
|
||||
const fromPipe = pipeItems.filter((it) => pipelinePublishDay(it) === key);
|
||||
const fromPipe = pipeItems.filter((it) => {
|
||||
const linked = livePieces.find(
|
||||
(p) => p.pipeline_id === it.id || p.id === `pipe-${it.id}`
|
||||
);
|
||||
if (linked) return linked.date.slice(0, 10) === key;
|
||||
return pipelinePublishDay(it) === key;
|
||||
});
|
||||
const fromPlan = (byDate.get(key) || [])
|
||||
.map((p) => pieceToRow(p))
|
||||
.filter((r) => r.cover_path || r.caption || r.notes);
|
||||
@@ -325,6 +346,10 @@ export default function RrssMonthView({
|
||||
}
|
||||
|
||||
function openDay(key: string, piece?: RrssPiece) {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
const rows = rowsForDay(key);
|
||||
if (rows.length) {
|
||||
setDayZoom(key);
|
||||
@@ -333,6 +358,33 @@ export default function RrssMonthView({
|
||||
if (piece) setSelected(piece);
|
||||
}
|
||||
|
||||
async function movePiece(pieceId: string, toDate: string) {
|
||||
const piece = livePieces.find((p) => p.id === pieceId);
|
||||
if (!piece || piece.date.slice(0, 10) === toDate) return;
|
||||
const prev = livePieces;
|
||||
setLivePieces((list) =>
|
||||
list.map((p) => (p.id === pieceId ? { ...p, date: toDate } : p))
|
||||
);
|
||||
setMoveMsg(null);
|
||||
try {
|
||||
const r = await fetch('/api/calendar/move', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pieceId, date: toDate, snapshot: piece }),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setLivePieces(prev);
|
||||
setMoveMsg(j.error || 'No se pudo mover la pieza');
|
||||
return;
|
||||
}
|
||||
setMoveMsg(`Movida al ${toDate.slice(8, 10)}`);
|
||||
} catch {
|
||||
setLivePieces(prev);
|
||||
setMoveMsg('No se pudo mover la pieza');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hub crumbs + network filters */}
|
||||
@@ -370,8 +422,8 @@ export default function RrssMonthView({
|
||||
onClick={() => setPlatform('instagram')}
|
||||
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
|
||||
platform === 'instagram' || platform === 'facebook'
|
||||
? 'bg-[#16204A] text-gray-900 ring-2 ring-sky-400/40'
|
||||
: 'bg-[#16204A]/80 text-gray-900/90 hover:bg-[#1a2a63]'
|
||||
? 'bg-[#16204A] text-white ring-2 ring-sky-400/40'
|
||||
: 'bg-[#16204A] text-white/90 hover:bg-[#1a2a63]'
|
||||
}`}
|
||||
>
|
||||
<Instagram className="w-4 h-4 mr-2" /> IG / FB
|
||||
@@ -381,8 +433,8 @@ export default function RrssMonthView({
|
||||
onClick={() => setPlatform('linkedin')}
|
||||
className={`inline-flex items-center justify-center min-h-[44px] px-5 rounded-xl text-sm font-bold ${
|
||||
platform === 'linkedin'
|
||||
? 'bg-[#2071CF] text-gray-900 ring-2 ring-sky-300/50'
|
||||
: 'bg-[#2071CF]/80 text-gray-900/90 hover:bg-[#1a5fb8]'
|
||||
? 'bg-[#2071CF] text-white ring-2 ring-sky-300/50'
|
||||
: 'bg-[#2071CF] text-white hover:bg-[#1a5fb8]'
|
||||
}`}
|
||||
>
|
||||
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
|
||||
@@ -394,10 +446,10 @@ export default function RrssMonthView({
|
||||
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-300/90">
|
||||
{plan.hero.eyebrow || '4VENTURES · PLAN OPERATIVO'}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl md:text-3xl font-extrabold text-gray-900 tracking-tight">
|
||||
<h2 className="mt-2 text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||
{plan.hero.title}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-slate-300 max-w-2xl leading-relaxed">
|
||||
<p className="mt-3 text-sm text-slate-200 max-w-2xl leading-relaxed">
|
||||
{plan.hero.lede}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
@@ -457,7 +509,7 @@ export default function RrssMonthView({
|
||||
<p className="text-sm font-semibold text-gray-900">{r.label}</p>
|
||||
<p className="text-[11px] text-gray-500">{r.detail}</p>
|
||||
</div>
|
||||
<div className="hidden md:block h-3 rounded-full bg-gray-900 overflow-hidden">
|
||||
<div className="hidden md:block h-3 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${r.pct}%`, background: r.color }}
|
||||
@@ -493,7 +545,7 @@ export default function RrssMonthView({
|
||||
<p className="text-sm font-semibold text-gray-900">{r.label}</p>
|
||||
<p className="text-[11px] text-gray-500">{r.detail}</p>
|
||||
</div>
|
||||
<div className="hidden md:block h-3 rounded-full bg-gray-900 overflow-hidden">
|
||||
<div className="hidden md:block h-3 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${r.pct}%`, background: r.color }}
|
||||
@@ -536,15 +588,20 @@ export default function RrssMonthView({
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-1">
|
||||
El mes de un vistazo
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Clic en el día publicado para ver la creatividad real (preview +
|
||||
métricas). El pipeline es solo para revisión.
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Arrastra un post o story a otro día para reprogramarlo. Clic en el
|
||||
día abre el zoom.
|
||||
</p>
|
||||
{moveMsg ? (
|
||||
<p className="mb-3 text-xs font-medium text-gray-800 bg-gray-50 border border-gray-200 rounded-lg px-3 py-1.5">
|
||||
{moveMsg}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-7 gap-1.5 mb-1.5">
|
||||
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => (
|
||||
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="text-center text-[11px] font-bold text-gray-900 bg-[#16204A] rounded-lg py-2"
|
||||
className="text-center text-[12px] font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg py-2"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
@@ -554,58 +611,96 @@ export default function RrssMonthView({
|
||||
{Array.from({ length: startPad }).map((_, i) => (
|
||||
<div
|
||||
key={`pad-${i}`}
|
||||
className="min-h-[88px] rounded-lg border border-dashed border-gray-200 bg-transparent"
|
||||
className="min-h-[112px] rounded-lg border border-dashed border-gray-200 bg-gray-50/60"
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const key = `${plan.month}-${String(day).padStart(2, '0')}`;
|
||||
const dayPieces = byDate.get(key) || [];
|
||||
const pipeDay = pipeItems.filter(
|
||||
(it) => pipelinePublishDay(it) === key
|
||||
);
|
||||
const cover = pipeDay[0]
|
||||
? pipelineMediaUrl(pipeDay[0].cover_path)
|
||||
: dayPieces.find((p) => p.cover_path)
|
||||
? pipelineMediaUrl(
|
||||
dayPieces.find((p) => p.cover_path)!.cover_path!
|
||||
)
|
||||
: null;
|
||||
const over = dragOverDay === key;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={key}
|
||||
type="button"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragOverDay(key);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setDragOverDay((cur) => (cur === key ? null : cur));
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOverDay(null);
|
||||
const id =
|
||||
e.dataTransfer.getData('text/plain') || draggingId.current;
|
||||
if (id) {
|
||||
suppressClick.current = true;
|
||||
void movePiece(id, key);
|
||||
}
|
||||
}}
|
||||
onClick={() => openDay(key, dayPieces[0])}
|
||||
className="min-h-[88px] rounded-lg border border-gray-200 bg-white p-1.5 text-left hover:border-brand-400 hover:shadow-sm overflow-hidden"
|
||||
className={`min-h-[112px] rounded-lg border bg-white p-1.5 text-left cursor-pointer overflow-hidden ${
|
||||
over
|
||||
? 'border-brand-500 ring-2 ring-brand-200 bg-brand-50/40'
|
||||
: 'border-gray-200 hover:border-brand-400 hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<p className="text-[11px] font-bold text-gray-600 mb-1">{day}</p>
|
||||
{cover ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className="mb-1 h-10 w-full rounded object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-[13px] font-bold text-gray-900 mb-1 tabular-nums">
|
||||
{day}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{dayPieces.slice(0, cover ? 1 : 3).map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="block w-full rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-gray-900"
|
||||
style={{ background: colorFor(p) }}
|
||||
title={p.title}
|
||||
>
|
||||
{p.platform === 'linkedin' ? 'LI' : 'IG'} ·{' '}
|
||||
{p.title.replace(/^[^·]+·\s*/, '').slice(0, 42)}
|
||||
</span>
|
||||
))}
|
||||
{dayPieces.length > (cover ? 1 : 3) && (
|
||||
<p className="text-[9px] text-gray-500">
|
||||
+{dayPieces.length - (cover ? 1 : 3)}
|
||||
</p>
|
||||
)}
|
||||
{dayPieces.map((p) => {
|
||||
const tone = chipTone(p);
|
||||
const kind =
|
||||
(p.kind || p.format || 'post').toLowerCase().includes('stor')
|
||||
? 'Story'
|
||||
: (p.format || 'Post').replace(/^./, (c) =>
|
||||
c.toUpperCase()
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
draggingId.current = p.id;
|
||||
e.dataTransfer.setData('text/plain', p.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
draggingId.current = null;
|
||||
setDragOverDay(null);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
openDay(key, p);
|
||||
}}
|
||||
className="flex items-start gap-0.5 w-full rounded-md px-1 py-1 text-left cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
background: tone.bg,
|
||||
borderLeft: `3px solid ${tone.accent}`,
|
||||
}}
|
||||
title={`${p.title} — arrastra para cambiar de día`}
|
||||
>
|
||||
<GripVertical className="w-3 h-3 mt-0.5 shrink-0 text-gray-500" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-wide text-gray-700">
|
||||
{p.platform === 'linkedin' ? 'LI' : 'IG'} · {kind}
|
||||
</span>
|
||||
<span className="block text-[11px] font-semibold leading-snug text-gray-900 line-clamp-2">
|
||||
{p.title.replace(/^[^·]+·\s*/, '')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -627,7 +722,7 @@ export default function RrssMonthView({
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1">
|
||||
<span
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded text-gray-900"
|
||||
className="text-[10px] font-bold px-2 py-0.5 rounded text-white"
|
||||
style={{ background: colorFor(p) }}
|
||||
>
|
||||
{p.platform === 'linkedin' ? (
|
||||
@@ -658,11 +753,11 @@ export default function RrssMonthView({
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-900">{p.title}</p>
|
||||
{p.objective_metric && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Objetivo: {p.objective_metric}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-2 line-clamp-2">
|
||||
<p className="text-sm text-gray-700 mt-2 line-clamp-2 leading-relaxed">
|
||||
{p.hook || p.body}
|
||||
</p>
|
||||
</div>
|
||||
@@ -710,10 +805,10 @@ export default function RrssMonthView({
|
||||
<p className="text-xs text-gray-400">{selected.meta_line}</p>
|
||||
)
|
||||
)}
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-amber-200 text-sm">
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-amber-950 text-sm">
|
||||
{selected.hook}
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-900/40 p-3 text-gray-800 whitespace-pre-wrap text-xs leading-relaxed">
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-gray-800 whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{selected.body}
|
||||
</div>
|
||||
{selected.cta && (
|
||||
|
||||
Reference in New Issue
Block a user