Add CSV/Excel calendar upload that maps posts into the monthly RRSS view.

Agency can import editorial calendars per tenant; pieces land on each day with the same month grid and day zoom as existing plans.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 16:26:34 +02:00
parent ed275f475a
commit b395243ce7
7 changed files with 883 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ const nextConfig = {
output: 'standalone',
reactStrictMode: true,
poweredByHeader: false,
serverExternalPackages: ['xlsx'],
async headers() {
return [
{

View File

@@ -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",

View File

@@ -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)}`);
}
}

View File

@@ -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 className="w-4 h-4 mr-2" /> LinkedIn
</Link>
</div>
{agency ? <CalendarUpload tenantLabel={ctx.label} /> : null}
</div>
);
}
@@ -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 (
<div className="space-y-5">
{agency ? <CalendarUpload tenantLabel={ctx.label} /> : null}
<RrssMonthView
plan={active}
tenantLabel={ctx.label}
canCollaborate
initialPlatform={initialPlatform}
/>
</div>
);
}
@@ -200,14 +206,15 @@ export default async function CalendarioPage({
if (!legacy.length) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 space-y-2">
<div className="rounded-xl border border-gray-200 bg-white p-6 space-y-4">
<h2 className="text-lg font-semibold text-gray-900">
Sin calendario editorial
</h2>
<p className="text-sm text-gray-600">
{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.
</p>
{agency ? <CalendarUpload tenantLabel={ctx.label} /> : null}
</div>
);
}

View File

@@ -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<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(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 (
<section className="rounded-2xl border border-dashed border-brand-300 bg-brand-50/40 p-5">
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-white border border-brand-200 text-brand-700">
<FileSpreadsheet className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-gray-900">
Subir calendario (CSV, Excel o JSON)
</h3>
<p className="text-xs text-gray-500 mt-1">
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.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<a
href="/api/calendar/upload"
className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:border-brand-300"
>
Descargar plantilla CSV
</a>
<button
type="button"
disabled={busy}
onClick={() => 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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{busy ? 'Leyendo…' : 'Elegir archivo'}
</button>
<input
ref={inputRef}
type="file"
accept=".csv,.xlsx,.xls,.json,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void onFile(f);
}}
/>
</div>
</div>
{msg ? (
<p className="mt-3 text-xs text-emerald-800 bg-emerald-50 border border-emerald-200 rounded-lg px-3 py-2">
{msg}
</p>
) : null}
{err ? (
<p className="mt-3 text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{err}
</p>
) : null}
</section>
);
}

View File

@@ -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({
<span className="w-2 h-2 rounded-full bg-sky-400" />
Año {year} · {tenantLabel} × 4Ventures
</div>
{years.length > 1 ? (
<div className="flex gap-1">
{years.map((y) => (
<Link
key={y}
href={`/dashboard/calendario?view=archive&month=${y}-01`}
className={`rounded-full px-3 py-1.5 text-xs font-semibold ${
y === year
? 'bg-gray-900 text-white'
: 'bg-white border border-gray-200 text-gray-700'
}`}
>
{y}
</Link>
))}
</div>
) : null}
</div>
</div>
{canUpload ? <CalendarUpload tenantLabel={tenantLabel} /> : null}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 12 }).map((_, i) => {
const key = `${year}-${String(i + 1).padStart(2, '0')}`;

519
src/lib/calendar-import.ts Normal file
View File

@@ -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<string, number> = {
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<string>();
return pieces.filter((p) => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
}
function headerIndex(headers: string[]): Record<string, number> {
const map: Record<string, number> = {};
const aliases: Record<string, string[]> = {
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<string, number> = {};
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<string, unknown>;
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<string, RrssPiece[]>();
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<string, RrssPiece[]>();
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<string>();
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
`;