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:
4Ventures
2026-10-06 17:07:09 +02:00
parent 91380e2285
commit 392af11060
5 changed files with 355 additions and 80 deletions

View File

@@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import type { RrssPiece } from '@/components/RrssMonthView';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
type PlanDoc = {
id: string;
title: string;
content: unknown;
};
function asObject(content: unknown): Record<string, unknown> | null {
let c = content;
if (typeof c === 'string') {
try {
c = JSON.parse(c);
} catch {
return null;
}
}
if (!c || typeof c !== 'object') return null;
return c as Record<string, unknown>;
}
function piecesOf(content: Record<string, unknown>): RrssPiece[] {
return Array.isArray(content.pieces) ? (content.pieces as RrssPiece[]) : [];
}
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const body = (await req.json().catch(() => null)) as {
pieceId?: string;
date?: string;
snapshot?: RrssPiece;
} | null;
const pieceId = String(body?.pieceId || '').trim();
const date = String(body?.date || '').slice(0, 10);
if (!pieceId || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json(
{ error: 'Falta pieceId o una fecha válida.' },
{ status: 400 }
);
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const list = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
const docs = (list.ok ? await list.json() : []) as PlanDoc[];
let source: { doc: PlanDoc; plan: Record<string, unknown>; index: number } | null =
null;
for (const doc of docs) {
const plan = asObject(doc.content);
if (!plan) continue;
const pieces = piecesOf(plan);
const index = pieces.findIndex((p) => p.id === pieceId);
if (index >= 0) {
source = { doc, plan, index };
break;
}
}
const ym = date.slice(0, 7);
let targetDoc: PlanDoc | null = source?.doc || null;
let targetPlan: Record<string, unknown> | null = source?.plan || null;
if (!targetDoc || (source && String(source.plan.month || '') !== ym)) {
for (const doc of docs) {
const plan = asObject(doc.content);
if (!plan) continue;
if (String(plan.month || '') === ym) {
targetDoc = doc;
targetPlan = plan;
break;
}
}
}
if (!targetDoc || !targetPlan) {
return NextResponse.json(
{ error: 'No hay un calendario guardado para ese mes.' },
{ status: 404 }
);
}
const targetPieces = piecesOf(targetPlan);
if (source && source.doc.id === targetDoc.id) {
targetPieces[source.index] = { ...targetPieces[source.index], date };
} else if (source) {
const moved = { ...piecesOf(source.plan)[source.index], date };
piecesOf(source.plan).splice(source.index, 1);
source.plan.pieces = piecesOf(source.plan);
const srcPatch = await fetch(
`${base}/rest/v1/documents?id=eq.${source.doc.id}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ content: source.plan }),
}
);
if (!srcPatch.ok) {
return NextResponse.json(
{ error: 'No se pudo actualizar el mes de origen.' },
{ status: 500 }
);
}
targetPieces.push(moved);
} else if (body?.snapshot) {
targetPieces.push({ ...body.snapshot, date, id: pieceId });
} else {
return NextResponse.json(
{ error: 'No se encontró esa pieza en el calendario.' },
{ status: 404 }
);
}
targetPlan.pieces = targetPieces.sort((a, b) =>
String(a.date).localeCompare(String(b.date))
);
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${targetDoc.id}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ content: targetPlan }),
});
if (!patch.ok) {
return NextResponse.json(
{ error: (await patch.text()).slice(0, 240) || 'No se pudo guardar.' },
{ status: 500 }
);
}
return NextResponse.json({ ok: true, pieceId, date });
}

View File

@@ -146,7 +146,7 @@ export default async function CalendarioPage({
<h2 className="text-3xl font-extrabold text-gray-900 tracking-tight">
{ctx.label}
</h2>
<p className="text-sm text-gray-400 mt-2 max-w-xl">
<p className="text-sm text-gray-600 mt-2 max-w-xl">
Calendario editorial de {ctx.label}. No se publica en redes: solo el
cliente (y la agencia en su contexto) lo ve aquí.
</p>
@@ -154,13 +154,13 @@ export default async function CalendarioPage({
<div className="flex flex-wrap gap-3">
<Link
href="/dashboard/calendario?view=archive"
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#16204A] text-gray-900 text-sm font-bold hover:bg-[#1a2a63]"
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#16204A] text-white text-sm font-bold hover:bg-[#1a2a63]"
>
<Instagram className="w-4 h-4 mr-2" /> IG / FB
</Link>
<Link
href="/dashboard/calendario?view=month&month=2026-10&tab=linkedin"
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#2071CF] text-gray-900 text-sm font-bold hover:bg-[#1a5fb8]"
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#2071CF] text-white text-sm font-bold hover:bg-[#1a5fb8]"
>
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
</Link>

View File

@@ -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">

View File

@@ -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 && (

View File

@@ -121,8 +121,18 @@ export function mergePipelineIntoPlans(
for (const it of items) {
const day = pipelinePublishDay(it);
if (!day) continue;
const ym = day.slice(0, 7);
let plan = byMonth.get(ym);
const ig = instagramUrlFromNotes(it.notes);
const igKey = normPermalink(ig);
let existing: CalendarPieceLike | undefined;
let plan = out.find((p) => p.pieces.some((x) => x.pipeline_id === it.id));
if (plan) {
existing = plan.pieces.find((p) => p.pipeline_id === it.id);
}
const ym = (existing?.date || day).slice(0, 7);
if (!plan) {
plan = byMonth.get(ym);
}
if (!plan) {
const label = monthLabel(ym);
plan = {
@@ -140,15 +150,15 @@ export function mergePipelineIntoPlans(
out.push(plan);
byMonth.set(ym, plan);
}
const ig = instagramUrlFromNotes(it.notes);
const igKey = normPermalink(ig);
const existing = plan.pieces.find((p) => {
if ((p.date || '').slice(0, 10) !== day) return false;
if (igKey && normPermalink(p.meta_line) === igKey) return true;
const a = (p.title || '').toLowerCase().slice(0, 28);
const b = (it.title || '').toLowerCase().slice(0, 28);
return a && b && (a.includes(b) || b.includes(a));
});
if (!existing) {
existing = plan.pieces.find((p) => {
if ((p.date || '').slice(0, 10) !== day) return false;
if (igKey && normPermalink(p.meta_line) === igKey) return true;
const a = (p.title || '').toLowerCase().slice(0, 28);
const b = (it.title || '').toLowerCase().slice(0, 28);
return a && b && (a.includes(b) || b.includes(a));
});
}
const slides = slidesOf(it.slide_paths);
if (existing) {
existing.pipeline_id = it.id;