Allow agency to fully delete an editorial calendar month.

Removes the calendar document and linked draft pipeline pieces, with confirm-by-typing the YYYY-MM code.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-13 18:07:24 +02:00
parent fd2bd9582d
commit a225f8c2a6
5 changed files with 227 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
/** /**
* Create / update editorial month plans and pieces. * Create / update editorial month plans and pieces.
* POST { action: 'create'|'upsert_piece'|'delete_piece'|'replace_plan', month, piece?, plan?, syncPipeline? } * POST { action: 'create'|'upsert_piece'|'delete_piece'|'replace_plan'|'delete_month', month, piece?, plan?, syncPipeline? }
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
@@ -364,6 +364,87 @@ export async function POST(req: NextRequest) {
}); });
} }
if (action === 'delete_month') {
if (!doc?.id) {
return NextResponse.json(
{ error: 'No hay calendario guardado para ese mes' },
{ status: 404 }
);
}
const pieceIds = (existing?.pieces || [])
.map((p) => p.id)
.filter((id): id is string => Boolean(id));
let pipelineDeleted = 0;
let pipelineUnlinked = 0;
for (const pieceId of pieceIds) {
const list = await fetch(
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&calendar_piece_id=eq.${encodeURIComponent(
pieceId
)}&select=id,status,publish_status`,
{ headers, cache: 'no-store' }
);
if (!list.ok) continue;
const rows = (await list.json()) as {
id: string;
status?: string;
publish_status?: string;
}[];
for (const row of rows) {
const published =
row.publish_status === 'published' ||
row.status === 'publicada' ||
row.status === 'publicado';
if (published) {
const patch = await fetch(
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(row.id)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
calendar_piece_id: null,
updated_at: new Date().toISOString(),
}),
}
);
if (patch.ok) pipelineUnlinked += 1;
} else {
const del = await fetch(
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(row.id)}`,
{ method: 'DELETE', headers }
);
if (del.ok) pipelineDeleted += 1;
}
}
}
const delDoc = await fetch(
`${base}/rest/v1/documents?id=eq.${encodeURIComponent(doc.id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
{ method: 'DELETE', headers }
);
if (!delDoc.ok) {
throw new Error(
(await delDoc.text()).slice(0, 300) || 'No se pudo borrar el documento'
);
}
return NextResponse.json({
ok: true,
deleted: true,
month,
document_id: doc.id,
pipeline_deleted: pipelineDeleted,
pipeline_unlinked: pipelineUnlinked,
});
}
return NextResponse.json({ error: 'action desconocida' }, { status: 400 }); return NextResponse.json({ error: 'action desconocida' }, { status: 400 });
} catch (e) { } catch (e) {
return NextResponse.json( return NextResponse.json(

View File

@@ -12,6 +12,7 @@ import { Instagram, Linkedin } from 'lucide-react';
import { PageHeader } from '@/components/ui'; import { PageHeader } from '@/components/ui';
import CalendarUpload from '@/components/CalendarUpload'; import CalendarUpload from '@/components/CalendarUpload';
import CalendarCreator from '@/components/CalendarCreator'; import CalendarCreator from '@/components/CalendarCreator';
import DeleteCalendarMonthButton from '@/components/DeleteCalendarMonthButton';
import { import {
mergePipelineIntoPlans, mergePipelineIntoPlans,
type PipelineCalendarRow, type PipelineCalendarRow,
@@ -229,6 +230,9 @@ export default async function CalendarioPage({
tenantLabel={ctx.label} tenantLabel={ctx.label}
existingMonths={plans.map((p) => p.month).filter(Boolean)} existingMonths={plans.map((p) => p.month).filter(Boolean)}
/> />
{active.month ? (
<DeleteCalendarMonthButton month={active.month} />
) : null}
</div> </div>
) : null} ) : null}
<RrssMonthView <RrssMonthView

View File

@@ -361,6 +361,46 @@ export default function CalendarCreator({
} }
} }
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 = dayZoom ? byDate.get(dayZoom) || [] : []; const dayPieces = dayZoom ? byDate.get(dayZoom) || [] : [];
return ( return (
@@ -490,6 +530,17 @@ export default function CalendarCreator({
> >
Guardar mes en portal Guardar mes en portal
</button> </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"> <span className="text-[11px] text-gray-500">
{plan?.pieces?.length || 0} piezas {plan?.pieces?.length || 0} piezas
{keepIds.size ? ` · ${keepIds.size} ✓` : ''} {keepIds.size ? ` · ${keepIds.size} ✓` : ''}

View File

@@ -0,0 +1,75 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, Trash2 } from 'lucide-react';
import { monthLabel } from '@/lib/calendar-import';
/** Agency-only: wipe an editorial calendar month document (+ draft pipeline links). */
export default function DeleteCalendarMonthButton({
month,
compact = false,
}: {
month: string;
compact?: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function onDelete() {
const label = monthLabel(month);
const ok = window.confirm(
`¿Borrar por completo el calendario de ${label}?\n\nSe elimina el mes del portal y las piezas de Pipeline RRSS en borrador vinculadas. Las ya publicadas en Meta no se borran de la red (solo se desvinculan).`
);
if (!ok) return;
const typed = window.prompt(
`Escribe ${month} para confirmar el borrado definitivo:`
);
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');
router.push('/dashboard/calendario?view=archive');
router.refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error al borrar');
} finally {
setBusy(false);
}
}
return (
<div className="inline-flex flex-col gap-1">
<button
type="button"
disabled={busy}
onClick={() => void onDelete()}
className={
compact
? 'inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1.5 text-[11px] font-semibold text-red-700 hover:bg-red-100 disabled:opacity-60'
: 'inline-flex items-center gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 hover:bg-red-100 disabled:opacity-60'
}
title={`Borrar calendario ${monthLabel(month)}`}
>
{busy ? (
<Loader2 className={compact ? 'h-3.5 w-3.5 animate-spin' : 'h-4 w-4 animate-spin'} />
) : (
<Trash2 className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
)}
{compact ? 'Borrar mes' : `Borrar calendario · ${monthLabel(month)}`}
</button>
{err ? <p className="text-[11px] text-red-600">{err}</p> : null}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import Link from 'next/link';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import CalendarCreator from '@/components/CalendarCreator'; import CalendarCreator from '@/components/CalendarCreator';
import CalendarUpload from '@/components/CalendarUpload'; import CalendarUpload from '@/components/CalendarUpload';
import DeleteCalendarMonthButton from '@/components/DeleteCalendarMonthButton';
const MONTH_NAMES = [ const MONTH_NAMES = [
'Enero', 'Enero',
@@ -35,10 +36,12 @@ function MiniMonth({
year, year,
monthIndex, monthIndex,
plan, plan,
canDelete = false,
}: { }: {
year: number; year: number;
monthIndex: number; // 0-11 monthIndex: number; // 0-11
plan?: RrssMonthPlan; plan?: RrssMonthPlan;
canDelete?: boolean;
}) { }) {
const monthKey = `${year}-${String(monthIndex + 1).padStart(2, '0')}`; const monthKey = `${year}-${String(monthIndex + 1).padStart(2, '0')}`;
const first = new Date(Date.UTC(year, monthIndex, 1)); const first = new Date(Date.UTC(year, monthIndex, 1));
@@ -126,12 +129,17 @@ function MiniMonth({
if (live) { if (live) {
return ( return (
<div className="space-y-2">
<Link <Link
href={`/dashboard/calendario?view=month&month=${monthKey}`} href={`/dashboard/calendario?view=month&month=${monthKey}`}
className={className} className={className}
> >
{body} {body}
</Link> </Link>
{canDelete ? (
<DeleteCalendarMonthButton month={monthKey} compact />
) : null}
</div>
); );
} }
@@ -237,6 +245,7 @@ export default function RrssArchiveYear({
year={year} year={year}
monthIndex={i} monthIndex={i}
plan={byMonth.get(key)} plan={byMonth.get(key)}
canDelete={canUpload}
/> />
); );
})} })}