diff --git a/next.config.js b/next.config.js index 08041c0..9b5b876 100644 --- a/next.config.js +++ b/next.config.js @@ -3,6 +3,7 @@ const nextConfig = { output: 'standalone', reactStrictMode: true, poweredByHeader: false, + serverExternalPackages: ['xlsx'], async headers() { return [ { diff --git a/package.json b/package.json index ff14cf4..b10c481 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "next": "15.1.7", "react": "^19.0.0", "react-dom": "^19.0.0", - "tailwind-merge": "^3.0.2" + "tailwind-merge": "^3.0.2", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/node": "^22.13.4", diff --git a/src/app/api/calendar/upload/route.ts b/src/app/api/calendar/upload/route.ts new file mode 100644 index 0000000..ff2d3f6 --- /dev/null +++ b/src/app/api/calendar/upload/route.ts @@ -0,0 +1,209 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { + parseJsonCalendar, + SAMPLE_CSV, + sheetsToPlans, + type ImportResult, + type SheetRows, +} from '@/lib/calendar-import'; +import type { RrssMonthPlan } from '@/components/RrssMonthView'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const MAX_BYTES = 8 * 1024 * 1024; + +export async function GET() { + return new NextResponse(SAMPLE_CSV, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': + 'attachment; filename="plantilla-calendario-4ventures.csv"', + }, + }); +} + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!isAgencyRole(userRole(user))) { + return NextResponse.json( + { error: 'Solo el equipo de 4Ventures puede subir calendarios.' }, + { status: 403 } + ); + } + + const ctx = await resolveActiveTenant(user); + if (!ctx.tenantId || ctx.portfolioWide) { + return NextResponse.json( + { error: 'Selecciona un cliente en Contexto.' }, + { status: 400 } + ); + } + + const form = await req.formData().catch(() => null); + const file = form?.get('file'); + if (!(file instanceof File)) { + return NextResponse.json({ error: 'Adjunta un CSV, Excel o JSON.' }, { status: 400 }); + } + if (file.size > MAX_BYTES) { + return NextResponse.json({ error: 'El archivo supera 8 MB.' }, { status: 400 }); + } + + const name = file.name || 'calendario'; + const buf = Buffer.from(await file.arrayBuffer()); + const text = buf.toString('utf8').replace(/^\uFEFF/, ''); + const yearHint = new Date().getFullYear(); + + let parsed: ImportResult; + const lower = name.toLowerCase(); + if (lower.endsWith('.json') || text.trim().startsWith('{') || text.trim().startsWith('[')) { + const json = parseJsonCalendar(text, ctx.label); + if (!json || !json.plans.length) { + return NextResponse.json( + { error: json?.warnings[0] || 'JSON no válido.' }, + { status: 400 } + ); + } + parsed = json; + } else { + try { + const mod = await import('xlsx'); + const XLSX = (mod as { default?: typeof import('xlsx') }).default ?? mod; + const wb = XLSX.read(buf, { type: 'buffer', cellDates: true, raw: false }); + const sheets: { name: string; rows: SheetRows }[] = []; + for (const sheetName of wb.SheetNames) { + const ws = wb.Sheets[sheetName]; + if (!ws) continue; + const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(ws, { + header: 1, + defval: '', + raw: false, + blankrows: false, + }) as SheetRows; + sheets.push({ name: sheetName, rows }); + } + parsed = sheetsToPlans(sheets, ctx.label, yearHint); + } catch (e) { + return NextResponse.json( + { + error: + e instanceof Error + ? `No se pudo leer el Excel/CSV: ${e.message}` + : 'No se pudo leer el Excel/CSV', + }, + { status: 400 } + ); + } + } + + if (!parsed.plans.length) { + return NextResponse.json( + { + error: parsed.warnings[0] || 'No se leyeron piezas.', + sheets: parsed.sheets, + }, + { status: 400 } + ); + } + + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) { + return NextResponse.json({ error: 'Service role required' }, { status: 503 }); + } + + const saved: { month: string; pieces: number }[] = []; + try { + for (const plan of parsed.plans) { + await upsertPlan(base, headers, ctx.tenantId, plan); + saved.push({ month: plan.month, pieces: plan.pieces.length }); + } + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : 'Error al guardar' }, + { status: 500 } + ); + } + + return NextResponse.json({ + ok: true, + tenant: ctx.label, + months: saved, + warnings: parsed.warnings, + sheets: parsed.sheets, + }); +} + +async function upsertPlan( + base: string, + headers: { apikey: string; Authorization: string }, + tenantId: string, + plan: RrssMonthPlan +) { + const title = `RRSS ${plan.label}`; + const existing = await fetch( + `${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent( + tenantId + )}&type=eq.calendar&select=id,title,content`, + { headers, cache: 'no-store' } + ); + const docs = existing.ok + ? ((await existing.json()) as { + id: string; + title: string; + content: unknown; + }[]) + : []; + + for (const d of docs) { + let c = d.content as { month?: string } | string | null; + if (typeof c === 'string') { + try { + c = JSON.parse(c) as { month?: string }; + } catch { + c = null; + } + } + const month = c && typeof c === 'object' ? c.month : null; + if (month === plan.month || d.title === title) { + await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, { + method: 'DELETE', + headers: { ...headers, Prefer: 'return=minimal' }, + }); + } + } + + const row = { + tenant_id: tenantId, + type: 'calendar', + title, + content: plan, + status: 'approved', + }; + const ins = await fetch(`${base}/rest/v1/documents`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify(row), + }); + if (!ins.ok) { + throw new Error(`No se pudo guardar ${plan.month}: ${(await ins.text()).slice(0, 280)}`); + } +} diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 6bcb4b6..98011b9 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -10,6 +10,7 @@ import RrssArchiveYear from '@/components/RrssArchiveYear'; import octPlan from '@/data/rrss-octubre-2026.json'; import { Instagram, Linkedin } from 'lucide-react'; import { PageHeader } from '@/components/ui'; +import CalendarUpload from '@/components/CalendarUpload'; import { mergePipelineIntoPlans, type PipelineCalendarRow, @@ -164,6 +165,7 @@ export default async function CalendarioPage({ LinkedIn + {agency ? : null} ); } @@ -176,6 +178,7 @@ export default async function CalendarioPage({ year={year} plans={plans} tenantLabel={ctx.label} + canUpload={agency} /> ); } @@ -183,12 +186,15 @@ export default async function CalendarioPage({ // Month plan (like /rrss/ig-fb/octubre-2026/) if (active && active.kind === 'rrss_month') { return ( - +
+ {agency ? : null} + +
); } @@ -200,14 +206,15 @@ export default async function CalendarioPage({ if (!legacy.length) { return ( -
+

Sin calendario editorial

- {ctx.label} no tiene un plan RRSS en el portal. Cada cliente solo ve - el suyo; no se muestra el de otros tenants. + {ctx.label} no tiene un plan RRSS en el portal. Sube un CSV o Excel + para encajar las publicaciones por día.

+ {agency ? : null}
); } diff --git a/src/components/CalendarUpload.tsx b/src/components/CalendarUpload.tsx new file mode 100644 index 0000000..4c8ae0e --- /dev/null +++ b/src/components/CalendarUpload.tsx @@ -0,0 +1,109 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { FileSpreadsheet, Loader2, Upload } from 'lucide-react'; + +export default function CalendarUpload({ + tenantLabel, +}: { + tenantLabel?: string; +}) { + const router = useRouter(); + const inputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + + async function onFile(file: File) { + setBusy(true); + setErr(null); + setMsg(null); + try { + const fd = new FormData(); + fd.append('file', file); + const r = await fetch('/api/calendar/upload', { method: 'POST', body: fd }); + const j = await r.json().catch(() => ({})); + if (!r.ok) { + setErr(j.error || 'No se pudo leer el archivo'); + return; + } + const months = (j.months || []) as { month: string; pieces: number }[]; + const summary = months + .map((m) => `${m.month}: ${m.pieces} piezas`) + .join(' · '); + setMsg(`Listo para ${j.tenant || tenantLabel}. ${summary}`); + const first = months[0]?.month; + router.refresh(); + if (first) { + router.push(`/dashboard/calendario?view=month&month=${first}`); + } + } catch (e) { + setErr(e instanceof Error ? e.message : 'Error al subir'); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ +
+
+

+ Subir calendario (CSV, Excel o JSON) +

+

+ Encaja las publicaciones por día en el mes. Acepta rejilla SEMANA / + STORIES / POST o columnas fecha + título / copy. Si el mes ya + existía, se reemplaza. +

+
+
+ + Descargar plantilla CSV + + + { + const f = e.target.files?.[0]; + e.target.value = ''; + if (f) void onFile(f); + }} + /> +
+
+ {msg ? ( +

+ {msg} +

+ ) : null} + {err ? ( +

+ {err} +

+ ) : null} +
+ ); +} diff --git a/src/components/RrssArchiveYear.tsx b/src/components/RrssArchiveYear.tsx index f49c828..b42e040 100644 --- a/src/components/RrssArchiveYear.tsx +++ b/src/components/RrssArchiveYear.tsx @@ -1,6 +1,7 @@ import React from 'react'; import Link from 'next/link'; import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; +import CalendarUpload from '@/components/CalendarUpload'; const MONTH_NAMES = [ 'Enero', @@ -145,12 +146,19 @@ export default function RrssArchiveYear({ year = 2026, plans, tenantLabel, + canUpload = false, }: { year?: number; plans: RrssMonthPlan[]; tenantLabel: string; + canUpload?: boolean; }) { const byMonth = new Map(plans.map((p) => [p.month, p])); + const years = Array.from( + new Set(plans.map((p) => Number(p.month.slice(0, 4))).filter(Boolean)) + ).sort(); + if (!years.includes(year)) years.push(year); + years.sort(); const legend = plans[0]?.legend || [ { label: 'Educativo / serie', color: '#2071cf' }, { label: 'Familia / bienestar', color: '#E08E2E' }, @@ -182,9 +190,28 @@ export default function RrssArchiveYear({ Año {year} · {tenantLabel} × 4Ventures
+ {years.length > 1 ? ( +
+ {years.map((y) => ( + + {y} + + ))} +
+ ) : null} + {canUpload ? : null} +
{Array.from({ length: 12 }).map((_, i) => { const key = `${year}-${String(i + 1).padStart(2, '0')}`; diff --git a/src/lib/calendar-import.ts b/src/lib/calendar-import.ts new file mode 100644 index 0000000..a4b93ab --- /dev/null +++ b/src/lib/calendar-import.ts @@ -0,0 +1,519 @@ +/** Parse CSV / Excel / JSON editorial calendars into rrss_month plans. */ + +import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; + +export type SheetRows = (string | number | null)[][]; + +export type ImportResult = { + plans: RrssMonthPlan[]; + warnings: string[]; + sheets: { name: string; pieces: number; mode: string }[]; +}; + +const MONTHS: Record = { + enero: 1, + february: 2, + febrero: 2, + marzo: 3, + march: 3, + abril: 4, + april: 4, + mayo: 5, + may: 5, + junio: 6, + june: 6, + julio: 7, + july: 7, + agosto: 8, + august: 8, + septiembre: 9, + septimebre: 9, + september: 9, + octubre: 10, + october: 10, + noviembre: 11, + november: 11, + diciembre: 12, + december: 12, + ene: 1, + feb: 2, + mar: 3, + abr: 4, + jun: 6, + jul: 7, + ago: 8, + sep: 9, + oct: 10, + nov: 11, + dic: 12, +}; + +export function slugify(s: string) { + return s + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 48); +} + +export function monthLabel(ym: string) { + const [y, m] = ym.split('-').map(Number); + const names = [ + '', + 'Enero', + 'Febrero', + 'Marzo', + 'Abril', + 'Mayo', + 'Junio', + 'Julio', + 'Agosto', + 'Septiembre', + 'Octubre', + 'Noviembre', + 'Diciembre', + ]; + return `${names[m] || ym} ${y}`; +} + +export function inferYmFromSheetName(name: string, fallbackYear: number): string | null { + const raw = name.replace(/\s*\(prov\)\s*/i, '').trim(); + const iso = raw.match(/(20\d{2})[-_/ ](\d{1,2})/); + if (iso) { + const m = Number(iso[2]); + if (m >= 1 && m <= 12) return `${iso[1]}-${String(m).padStart(2, '0')}`; + } + const folded = raw + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + let month: number | null = null; + for (const [k, v] of Object.entries(MONTHS)) { + if (folded.includes(k)) { + month = v; + break; + } + } + if (!month) return null; + const yMatch = folded.match(/20\d{2}/); + let year = yMatch ? Number(yMatch[0]) : fallbackYear; + const two = folded.match(/\b(\d{2})\s*$/); + if (!yMatch && two) { + const n = Number(two[1]); + if (n >= 20 && n <= 40) year = 2000 + n; + } + return `${year}-${String(month).padStart(2, '0')}`; +} + +function cell(v: unknown): string { + if (v == null) return ''; + if (v instanceof Date && !Number.isNaN(v.getTime())) { + return v.toISOString().slice(0, 10); + } + return String(v).trim(); +} + +function excelSerialToIso(n: number): string | null { + if (!Number.isFinite(n) || n < 20000 || n > 80000) return null; + const utc = new Date(Date.UTC(1899, 11, 30) + Math.round(n) * 86400000); + if (Number.isNaN(utc.getTime())) return null; + return utc.toISOString().slice(0, 10); +} + +export function parseFlexibleDate(raw: unknown, ymHint?: string): string | null { + if (raw instanceof Date && !Number.isNaN(raw.getTime())) { + return raw.toISOString().slice(0, 10); + } + if (typeof raw === 'number') return excelSerialToIso(raw); + const s = cell(raw); + if (!s) return null; + const iso = s.match(/(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})/); + if (iso) { + return `${iso[1]}-${String(iso[2]).padStart(2, '0')}-${String(iso[3]).padStart(2, '0')}`; + } + const dmy = s.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](20\d{2})$/); + if (dmy) { + return `${dmy[3]}-${String(dmy[2]).padStart(2, '0')}-${String(dmy[1]).padStart(2, '0')}`; + } + const dayOnly = s.match(/^(\d{1,2})$/); + if (dayOnly && ymHint) { + return `${ymHint}-${String(dayOnly[1]).padStart(2, '0')}`; + } + return null; +} + +function parseDayHeader(h: string): number | null { + const raw = String(h || '').trim(); + if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null; + const m = raw.match(/(\d{1,2})\s*$/); + if (!m) return null; + const n = Number(m[1]); + return n >= 1 && n <= 31 ? n : null; +} + +function looksLikeGrid(rows: SheetRows): boolean { + let hits = 0; + for (const row of rows.slice(0, 40)) { + const line = (row || []).map(cell).join(' ').toUpperCase(); + if (line.includes('SEMANA') || /\bSTORIES\b/.test(line) || /^POST\b/.test(line)) { + hits += 1; + } + if (/LUNES|MARTES|MIÉRCOLES|MIERCOLES/.test(line) && /\d/.test(line)) hits += 1; + } + return hits >= 2; +} + +function colorFor(platform: string, format: string, kind?: string): string { + const f = `${format} ${kind}`.toLowerCase(); + if (f.includes('stor')) return '#374151'; + if ((platform || '').toLowerCase().includes('linkedin')) return '#7C66B8'; + if (f.includes('reel')) return '#7C66B8'; + return '#2071cf'; +} + +function normalizePlatform(raw: string, format: string): string { + const s = `${raw} ${format}`.toLowerCase(); + if (s.includes('linkedin')) return 'linkedin'; + if (s.includes('tiktok')) return 'tiktok'; + if (s.includes('facebook') || s.includes('fb')) return 'facebook'; + if (s.includes('blog')) return 'blog'; + return 'instagram'; +} + +function kindFromFormat(format: string): string { + const f = format.toLowerCase(); + if (f.includes('stor')) return 'story'; + if (f.includes('reel')) return 'reel'; + return 'post'; +} + +export function parseWeekGrid( + rows: SheetRows, + year: number, + month: number +): RrssPiece[] { + const pieces: RrssPiece[] = []; + let dayCols: { col: number; day: number }[] = []; + let kind: 'STORIES' | 'POST' | null = null; + let weekWraps = false; + + for (const row of rows) { + if (!row || !row.some((c) => cell(c))) continue; + const c0 = cell(row[0]).toUpperCase(); + + if ( + c0.startsWith('SEMANA') || + /^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(cell(row[1])) + ) { + dayCols = []; + for (let col = 1; col < Math.min(row.length, 10); col++) { + const day = parseDayHeader(cell(row[col])); + if (day) dayCols.push({ col, day }); + } + weekWraps = dayCols.some( + (d, i) => + i > 0 && + d.day < dayCols[i - 1].day && + dayCols[i - 1].day >= 28 && + d.day <= 7 + ); + kind = null; + continue; + } + + if (c0.startsWith('STORIES') || c0 === 'STORY' || c0 === 'STORIE') { + kind = 'STORIES'; + } else if (c0.startsWith('POST') || c0.startsWith('REEL') || c0.startsWith('FEED')) { + kind = 'POST'; + } else if (!kind) { + continue; + } + + for (const { col, day } of dayCols) { + const text = cell(row[col]); + if (!text || text === 'X' || /^columna/i.test(text)) continue; + + let y = year; + let m = month; + if (weekWraps && day >= 28) { + m = month - 1; + if (m < 1) { + m = 12; + y = year - 1; + } + } + const date = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const dt = new Date(`${date}T12:00:00Z`); + if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue; + + const format = kind === 'STORIES' ? 'Stories' : 'Post'; + const k = kind === 'STORIES' ? 'story' : 'post'; + pieces.push({ + id: `${date}-${k}-${slugify(text).slice(0, 28)}`, + date, + platform: 'instagram', + format, + pillar: k === 'story' ? 'stories' : 'feed', + title: text.split('\n')[0].slice(0, 140), + hook: text.split('\n')[0].slice(0, 180), + body: text, + status: 'planned', + kind: k, + color: colorFor('instagram', format, k), + }); + } + } + + const seen = new Set(); + return pieces.filter((p) => { + if (seen.has(p.id)) return false; + seen.add(p.id); + return true; + }); +} + +function headerIndex(headers: string[]): Record { + const map: Record = {}; + const aliases: Record = { + date: ['fecha', 'date', 'dia', 'día', 'day'], + platform: ['plataforma', 'platform', 'red', 'canal', 'network'], + format: ['formato', 'format', 'tipo', 'type'], + title: ['titulo', 'título', 'title', 'pieza', 'nombre'], + hook: ['hook', 'gancho'], + body: ['copy', 'body', 'caption', 'texto', 'descripcion', 'descripción', 'contenido'], + cta: ['cta', 'llamada'], + hashtags: ['hashtags', 'hashtag', 'tags'], + pillar: ['pilar', 'pillar', 'linea', 'línea', 'tema'], + time: ['hora', 'time'], + objective: ['objetivo', 'objective', 'metric', 'kpi'], + status: ['estado', 'status'], + }; + headers.forEach((h, i) => { + const k = h + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim(); + for (const [field, list] of Object.entries(aliases)) { + if (list.includes(k) && map[field] == null) map[field] = i; + } + }); + return map; +} + +export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] { + let headerRow = -1; + let idx: Record = {}; + for (let i = 0; i < Math.min(rows.length, 12); i++) { + const headers = (rows[i] || []).map(cell); + const found = headerIndex(headers); + if (found.date != null && (found.title != null || found.body != null || found.format != null)) { + headerRow = i; + idx = found; + break; + } + } + if (headerRow < 0) return []; + + const pieces: RrssPiece[] = []; + for (const row of rows.slice(headerRow + 1)) { + if (!row) continue; + const get = (k: string) => (idx[k] != null ? cell(row[idx[k]]) : ''); + const date = parseFlexibleDate(idx.date != null ? row[idx.date] : '', ymHint); + if (!date) continue; + const format = get('format') || 'Post'; + const title = get('title') || get('body') || get('hook') || format; + if (!title.trim()) continue; + const platform = normalizePlatform(get('platform'), format); + const kind = kindFromFormat(format); + const statusRaw = get('status').toLowerCase(); + const status = /publi|subido|posted/.test(statusRaw) + ? 'published' + : /aprob/.test(statusRaw) + ? 'approved' + : 'planned'; + pieces.push({ + id: `${date}-${kind}-${slugify(title).slice(0, 28)}`, + date, + time: get('time') || undefined, + platform, + format, + pillar: get('pillar') || undefined, + title: title.split('\n')[0].slice(0, 160), + hook: get('hook') || title.split('\n')[0], + body: get('body') || title, + cta: get('cta') || undefined, + hashtags: get('hashtags') || undefined, + objective_metric: get('objective') || undefined, + status, + kind, + color: colorFor(platform, format, kind), + }); + } + return pieces; +} + +export function buildPlan( + ym: string, + pieces: RrssPiece[], + tenantLabel: string +): RrssMonthPlan { + const sorted = [...pieces].sort((a, b) => a.date.localeCompare(b.date)); + const posts = sorted.filter((p) => p.kind !== 'story').length; + const stories = sorted.filter((p) => p.kind === 'story').length; + const ig = sorted.filter((p) => (p.platform || '').toLowerCase().includes('insta')).length; + const li = sorted.filter((p) => (p.platform || '').toLowerCase().includes('linkedin')).length; + return { + kind: 'rrss_month', + month: ym, + label: monthLabel(ym), + hero: { + eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`, + title: `Calendario editorial · ${monthLabel(ym)}`, + lede: 'Importado desde CSV / Excel. Cada pieza queda en su día del mes.', + chips: [ + `${sorted.length} piezas`, + posts ? `${posts} posts` : '', + stories ? `${stories} stories` : '', + ig ? `${ig} IG` : '', + li ? `${li} LinkedIn` : '', + ].filter(Boolean), + }, + pieces: sorted, + }; +} + +function asPlan(raw: unknown): RrssMonthPlan | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (o.kind === 'rrss_month' && Array.isArray(o.pieces) && typeof o.month === 'string') { + return o as unknown as RrssMonthPlan; + } + return null; +} + +export function parseJsonCalendar(text: string, tenantLabel: string): ImportResult | null { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return null; + } + const warnings: string[] = []; + const plans: RrssMonthPlan[] = []; + const single = asPlan(raw); + if (single) { + plans.push(single); + return { + plans, + warnings, + sheets: [{ name: 'json', pieces: single.pieces.length, mode: 'json' }], + }; + } + if (Array.isArray(raw)) { + if (raw.length && asPlan(raw[0])) { + for (const item of raw) { + const p = asPlan(item); + if (p) plans.push(p); + } + return { + plans, + warnings, + sheets: [ + { + name: 'json', + pieces: plans.reduce((a, p) => a + p.pieces.length, 0), + mode: 'json', + }, + ], + }; + } + const pieces = raw as RrssPiece[]; + if (pieces[0] && typeof pieces[0] === 'object' && 'date' in pieces[0]) { + const by = new Map(); + for (const p of pieces) { + const d = String(p.date || '').slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) continue; + const ym = d.slice(0, 7); + const list = by.get(ym) || []; + list.push(p); + by.set(ym, list); + } + for (const [ym, list] of by) plans.push(buildPlan(ym, list, tenantLabel)); + return { + plans, + warnings, + sheets: [{ name: 'json-pieces', pieces: pieces.length, mode: 'json' }], + }; + } + } + return { plans, warnings: ['JSON no reconocido como calendario RRSS'], sheets: [] }; +} + +export function sheetsToPlans( + sheets: { name: string; rows: SheetRows }[], + tenantLabel: string, + fallbackYear = new Date().getFullYear() +): ImportResult { + const warnings: string[] = []; + const byMonth = new Map(); + const report: ImportResult['sheets'] = []; + + for (const sh of sheets) { + if (!sh.rows?.length) continue; + const ym = inferYmFromSheetName(sh.name, fallbackYear); + let mode = 'empty'; + let pieces: RrssPiece[] = []; + if (looksLikeGrid(sh.rows) && ym) { + const [y, m] = ym.split('-').map(Number); + pieces = parseWeekGrid(sh.rows, y, m); + mode = 'grid'; + } + if (!pieces.length) { + const tabular = parseTabular(sh.rows, ym || undefined); + if (tabular.length) { + pieces = tabular; + mode = 'tabla'; + } + } + if (!pieces.length && looksLikeGrid(sh.rows) && !ym) { + warnings.push( + `Hoja «${sh.name}»: parece calendario semanal pero no se pudo leer el mes. Renómbrala (ej. Octubre 2026).` + ); + } + report.push({ name: sh.name, pieces: pieces.length, mode }); + for (const p of pieces) { + const key = p.date.slice(0, 7); + const list = byMonth.get(key) || []; + list.push(p); + byMonth.set(key, list); + } + } + + const plans = [...byMonth.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([ym, pieces]) => { + const seen = new Set(); + const uniq = pieces.filter((p) => { + if (seen.has(p.id)) return false; + seen.add(p.id); + return true; + }); + return buildPlan(ym, uniq, tenantLabel); + }); + + if (!plans.length) { + warnings.push( + 'No se encontraron piezas. Usa columnas fecha + título (o copy), o una rejilla SEMANA / STORIES / POST como Blue Parrot.' + ); + } + return { plans, warnings, sheets: report }; +} + +export const SAMPLE_CSV = `fecha,plataforma,formato,titulo,hook,copy,cta,hashtags,pilar,hora,objetivo +2026-10-01,instagram,Stories,Encuesta de arranque,¿De dónde bebes el agua en casa?,Sticker encuesta 3 opciones,Responde a la story,,Interactiva,09:00,Respuestas +2026-10-02,instagram,Carrusel,El plástico que dejas de traer a casa,Cada semana subes botellas a casa,Copy del carrusel (slides 1-5),Guarda este post,#Marca,Sostenibilidad,10:30,Guardados +2026-10-03,linkedin,Post,Por qué las empresas cambian el agua de oficina,Menos bidones en la oficina,Cuerpo del post LinkedIn,Comentar,,B2B,09:30,DMs +`;