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:
@@ -3,6 +3,7 @@ const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: ['xlsx'],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
209
src/app/api/calendar/upload/route.ts
Normal file
209
src/app/api/calendar/upload/route.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
109
src/components/CalendarUpload.tsx
Normal file
109
src/components/CalendarUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
519
src/lib/calendar-import.ts
Normal 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
|
||||
`;
|
||||
Reference in New Issue
Block a user