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:
170
src/app/api/calendar/move/route.ts
Normal file
170
src/app/api/calendar/move/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user