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>