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
+
+
inputRef.current?.click()}
+ className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ {busy ? 'Leyendo…' : 'Elegir archivo'}
+
+
{
+ 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
+`;