diff --git a/src/app/api/calendar/move/route.ts b/src/app/api/calendar/move/route.ts new file mode 100644 index 0000000..170e2f4 --- /dev/null +++ b/src/app/api/calendar/move/route.ts @@ -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 | 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; +} + +function piecesOf(content: Record): 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; 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 | 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 }); +} diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 98011b9..c4ab981 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -146,7 +146,7 @@ export default async function CalendarioPage({

{ctx.label}

-

+

Calendario editorial de {ctx.label}. No se publica en redes: solo el cliente (y la agencia en su contexto) lo ve aquí.

@@ -154,13 +154,13 @@ export default async function CalendarioPage({
IG / FB LinkedIn diff --git a/src/components/RrssArchiveYear.tsx b/src/components/RrssArchiveYear.tsx index b42e040..757ce92 100644 --- a/src/components/RrssArchiveYear.tsx +++ b/src/components/RrssArchiveYear.tsx @@ -181,8 +181,8 @@ export default function RrssArchiveYear({ Calendario IG / FB {year}

- 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.

diff --git a/src/components/RrssMonthView.tsx b/src/components/RrssMonthView.tsx index 9ca11b8..905606a 100644 --- a/src/components/RrssMonthView.tsx +++ b/src/components/RrssMonthView.tsx @@ -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 ( - + Pendiente ); } if (dual.both) { return ( - + Doble OK ); } return ( - + {dual.label} ); } +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(plan.pieces || []); const [platform, setPlatform] = useState<'all' | string>(initialPlatform); const [selected, setSelected] = useState(null); const [dayZoom, setDayZoom] = useState(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(null); + const [moveMsg, setMoveMsg] = useState(null); + const draggingId = React.useRef(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 (
{/* 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]' }`} > 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 @@ -394,10 +446,10 @@ export default function RrssMonthView({

{plan.hero.eyebrow || '4VENTURES · PLAN OPERATIVO'}

-

+

{plan.hero.title}

-

+

{plan.hero.lede}

@@ -457,7 +509,7 @@ export default function RrssMonthView({

{r.label}

{r.detail}

-
+
{r.label}

{r.detail}

-
+
El mes de un vistazo -

- Clic en el día publicado para ver la creatividad real (preview + - métricas). El pipeline es solo para revisión. +

+ Arrastra un post o story a otro día para reprogramarlo. Clic en el + día abre el zoom.

+ {moveMsg ? ( +

+ {moveMsg} +

+ ) : null}
- {['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => ( + {['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map((d) => (
{d}
@@ -554,58 +611,96 @@ export default function RrssMonthView({ {Array.from({ length: startPad }).map((_, i) => (
))} {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 ( - +
); })}
@@ -627,7 +722,7 @@ export default function RrssMonthView({
{p.platform === 'linkedin' ? ( @@ -658,11 +753,11 @@ export default function RrssMonthView({

{p.title}

{p.objective_metric && ( -

+

Objetivo: {p.objective_metric}

)} -

+

{p.hook || p.body}

@@ -710,10 +805,10 @@ export default function RrssMonthView({

{selected.meta_line}

) )} -
+
{selected.hook}
-
+
{selected.body}
{selected.cta && ( diff --git a/src/lib/pipeline-calendar.ts b/src/lib/pipeline-calendar.ts index 5d33bc5..51eb813 100644 --- a/src/lib/pipeline-calendar.ts +++ b/src/lib/pipeline-calendar.ts @@ -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;