Replace BPS Excel stub with multi-client calendar template.

Official download now generates a blank workbook with catalog autofill, notas_internas ignored on import, and the same template for every tenant.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-08 17:38:38 +02:00
parent 025fc85175
commit 501f8203e8
8 changed files with 2999 additions and 34 deletions

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import {
buildOfficialCsv,
buildOfficialXlsx,
} from '@/lib/calendar-template';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
/**
* GET /api/calendar/template?format=xlsx|csv
* Official blank multi-client template (NOT a filled BPS calendar).
*/
export async function GET(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 agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
const label =
ctx.tenantId && !ctx.portfolioWide ? ctx.label || 'CLIENTE' : 'CLIENTE';
const format = (req.nextUrl.searchParams.get('format') || 'xlsx').toLowerCase();
if (format === 'csv') {
const csv = buildOfficialCsv(label);
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition':
'attachment; filename="plantilla-calendario-4ventures.csv"',
'Cache-Control': 'no-store',
},
});
}
const { buffer, filename } = await buildOfficialXlsx(label);
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
}

View File

@@ -9,11 +9,11 @@ import {
} from '@/lib/agency';
import {
parseJsonCalendar,
SAMPLE_CSV,
sheetsToPlans,
type ImportResult,
type SheetRows,
} from '@/lib/calendar-import';
import { buildOfficialCsv } from '@/lib/calendar-template';
import type { RrssMonthPlan } from '@/components/RrssMonthView';
export const dynamic = 'force-dynamic';
@@ -21,12 +21,14 @@ export const runtime = 'nodejs';
const MAX_BYTES = 8 * 1024 * 1024;
/** @deprecated Prefer GET /api/calendar/template?format=csv */
export async function GET() {
return new NextResponse(SAMPLE_CSV, {
return new NextResponse(buildOfficialCsv('CLIENTE'), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition':
'attachment; filename="plantilla-calendario-4ventures.csv"',
'Cache-Control': 'no-store',
},
});
}

View File

@@ -56,23 +56,26 @@ export default function CalendarUpload({
Subir calendario (CSV, Excel o JSON)
</h3>
<p className="text-xs text-gray-500 mt-1">
Plantilla oficial: Excel por mes con filas SEMANA / Story / POST
(como Brava Octubre). También CSV con columnas fecha + título. Si
el mes ya existía, se reemplaza.
Plantilla oficial multi-cliente: escribe{' '}
<strong>fecha + nombre_publicacion</strong> y se rellenan
plataforma, formato, pilar, hora y CTA. Usa{' '}
<strong>notas_internas</strong> para WhatsApp / PENDIENTE (nunca van
al caption). Misma plantilla para todos los clientes
{tenantLabel ? ` · ahora: ${tenantLabel}` : ''}.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<a
href="/plantillas/calendario-rrss-oficial.xlsx"
href="/api/calendar/template?format=xlsx"
className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:border-brand-300"
>
Plantilla Excel (oficial)
</a>
<a
href="/api/calendar/upload"
href="/api/calendar/template?format=csv"
className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-500 hover:border-brand-300"
>
CSV alternativo
CSV / Google Sheets
</a>
<button
type="button"

View File

@@ -1,6 +1,11 @@
/** Parse CSV / Excel / JSON editorial calendars into rrss_month plans. */
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import {
buildOfficialCsv,
enrichPieceFromCatalog,
} from '@/lib/calendar-template';
import { sanitizePublishCaption } from '@/lib/publish-caption';
export type SheetRows = (string | number | null)[][];
@@ -305,18 +310,37 @@ export function parseWeekGrid(
? 'Carrusel'
: 'Post';
const k = kind === 'STORIES' ? 'story' : kindFromFormat(format);
pieces.push({
id: `${date}-${k}-${slugify(text).slice(0, 28)}`,
date,
const titleLine = text.split('\n')[0].slice(0, 140);
const cleanBody = sanitizePublishCaption(text, { maxLen: 4000 }).caption;
const enriched = enrichPieceFromCatalog({
title: titleLine,
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,
time: undefined,
cta: undefined,
hashtags: undefined,
body: cleanBody || titleLine,
});
pieces.push({
id: `${date}-${k}-${slugify(titleLine).slice(0, 28)}`,
date,
platform: enriched.platform || 'instagram',
format: enriched.format || format,
pillar: enriched.pillar || (k === 'story' ? 'stories' : 'feed'),
title: enriched.title,
hook: titleLine.slice(0, 180),
body: enriched.body || titleLine,
time: enriched.time,
cta: enriched.cta,
hashtags: enriched.hashtags,
status: 'planned',
kind: k,
color: colorFor('instagram', format, k),
color: colorFor(
enriched.platform || 'instagram',
enriched.format || format,
k
),
});
}
}
@@ -335,7 +359,15 @@ function headerIndex(headers: string[]): Record<string, number> {
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'],
title: [
'nombre_publicacion',
'nombre publicacion',
'nombre',
'titulo',
'título',
'title',
'pieza',
],
hook: ['hook', 'gancho'],
body: ['copy', 'body', 'caption', 'texto', 'descripcion', 'descripción', 'contenido'],
cta: ['cta', 'llamada'],
@@ -344,6 +376,15 @@ function headerIndex(headers: string[]): Record<string, number> {
time: ['hora', 'time'],
objective: ['objetivo', 'objective', 'metric', 'kpi'],
status: ['estado', 'status'],
// Parsed but never used as caption (production notes)
notes: [
'notas_internas',
'notas internas',
'notas',
'interno',
'produccion',
'producción',
],
};
headers.forEach((h, i) => {
const k = h
@@ -361,8 +402,9 @@ function headerIndex(headers: string[]): Record<string, number> {
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++) {
for (let i = 0; i < Math.min(rows.length, 20); i++) {
const headers = (rows[i] || []).map(cell);
if (headers[0]?.startsWith('#')) continue;
const found = headerIndex(headers);
if (found.date != null && (found.title != null || found.body != null || found.format != null)) {
headerRow = i;
@@ -378,10 +420,28 @@ export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] {
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;
// Skip comment / instruction rows in CSV
if (String(row[0] || '').trim().startsWith('#')) continue;
const title = get('title') || get('body') || get('hook') || get('format');
if (!title.trim()) continue;
const platform = normalizePlatform(get('platform'), format);
// Body = public copy only. notas_internas is intentionally ignored.
const rawBody = get('body');
const cleanBody = sanitizePublishCaption(rawBody || title, {
maxLen: 4000,
}).caption;
const enriched = enrichPieceFromCatalog({
title: title.split('\n')[0].slice(0, 160),
platform: get('platform'),
format: get('format') || 'Post',
pillar: get('pillar'),
time: get('time'),
cta: get('cta'),
hashtags: get('hashtags'),
hook: get('hook'),
body: cleanBody,
});
const format = enriched.format || 'Post';
const platform = normalizePlatform(enriched.platform || '', format);
const kind = kindFromFormat(format);
const statusRaw = get('status').toLowerCase();
const status = /publi|subido|posted/.test(statusRaw)
@@ -390,17 +450,17 @@ export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] {
? 'approved'
: 'planned';
pieces.push({
id: `${date}-${kind}-${slugify(title).slice(0, 28)}`,
id: `${date}-${kind}-${slugify(enriched.title).slice(0, 28)}`,
date,
time: get('time') || undefined,
time: enriched.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,
pillar: enriched.pillar || undefined,
title: enriched.title,
hook: enriched.hook || enriched.title,
body: enriched.body || enriched.title,
cta: enriched.cta || undefined,
hashtags: enriched.hashtags || undefined,
objective_metric: get('objective') || undefined,
status,
kind,
@@ -567,12 +627,8 @@ export function sheetsToPlans(
return { plans, warnings, sheets: report };
}
/** Fallback tabular CSV. Preferred official format = Excel week grid like Brava Octubre. */
export const SAMPLE_CSV = `fecha,plataforma,formato,titulo,hook,copy,cta,hashtags,pilar,hora,objetivo
2026-10-01,instagram,Stories,H1 · ENCUESTA Otoño: ¿qué le pides a tu piel?,Encuesta otoño,Sticker encuesta,Responde a la story,,stories,09:00,Respuestas
2026-10-03,instagram,Reel,15_REEL TREND · Cosas que nunca escucharás en Brava,Trend propio,Copy del reel,Guarda este post,#Brava,feed,10:30,Guardados
2026-10-05,instagram,Carrusel,POST 1 · ¿Lista para un peeling?,Señales sí / aún no,Copy del carrusel,Pedir cita,,feed,10:30,Citas
`;
/** Fallback tabular CSV (official multi-client). Prefer Excel from /api/calendar/template. */
export const SAMPLE_CSV = buildOfficialCsv('CLIENTE');
/** Minimal October-style grid as TSV (for docs / paste into Excel). */
export const SAMPLE_GRID_TSV = `SEMANA 1\tx\tx\tx\tJUEVES 1\tVIERNES 2\tSÁBADO 3\tDOMINGO 4

View File

@@ -0,0 +1,473 @@
/**
* Official multi-tenant RRSS calendar template (CSV / Excel / Google Sheets).
* Same file for every client — only the client name in INSTRUCCIONES changes.
*
* Workflow:
* 1. Fill fecha + nombre_publicacion (and copy if ready).
* 2. Other fields auto-fill from CATALOGO (Excel formulas + server enrich on upload).
* 3. Put WhatsApp / PENDIENTE / assignees ONLY in notas_internas (never imported as caption).
*/
export type CatalogRule = {
/** Substring match against nombre_publicacion (case-insensitive). Longer keys win. */
clave: string;
plataforma: string;
formato: string;
pilar: string;
hora: string;
cta: string;
hashtags: string;
};
/** Generic rules for all 4Ventures clients (order: longest / most specific first). */
export const CALENDAR_CATALOG: CatalogRule[] = [
{
clave: 'NUEVO ALUMNO',
plataforma: 'instagram',
formato: 'Post',
pilar: 'nuevo-alumno',
hora: '13:24',
cta: '',
hashtags: '',
},
{
clave: 'ALUMNO NUEVO',
plataforma: 'instagram',
formato: 'Post',
pilar: 'nuevo-alumno',
hora: '13:24',
cta: '',
hashtags: '',
},
{
clave: 'CARRUSEL',
plataforma: 'instagram',
formato: 'Carrusel',
pilar: 'feed',
hora: '10:30',
cta: 'Enlace en la bio',
hashtags: '',
},
{
clave: 'LINKEDIN',
plataforma: 'linkedin',
formato: 'Post',
pilar: 'linkedin',
hora: '09:00',
cta: '',
hashtags: '',
},
{
clave: 'STORIES',
plataforma: 'instagram',
formato: 'Stories',
pilar: 'stories',
hora: '09:00',
cta: 'Responde a la story',
hashtags: '',
},
{
clave: 'STORY',
plataforma: 'instagram',
formato: 'Stories',
pilar: 'stories',
hora: '09:00',
cta: 'Responde a la story',
hashtags: '',
},
{
clave: 'REEL',
plataforma: 'instagram',
formato: 'Reel',
pilar: 'feed',
hora: '18:00',
cta: 'Guarda este post',
hashtags: '',
},
{
clave: 'FEED',
plataforma: 'instagram',
formato: 'Post',
pilar: 'feed',
hora: '10:30',
cta: 'Enlace en la bio',
hashtags: '',
},
{
clave: 'POST',
plataforma: 'instagram',
formato: 'Post',
pilar: 'feed',
hora: '10:30',
cta: 'Enlace en la bio',
hashtags: '',
},
{
clave: 'BLOG',
plataforma: 'blog',
formato: 'Post',
pilar: 'blog',
hora: '10:00',
cta: '',
hashtags: '',
},
{
clave: 'FACEBOOK',
plataforma: 'facebook',
formato: 'Post',
pilar: 'feed',
hora: '11:00',
cta: '',
hashtags: '',
},
{
clave: 'TIKTOK',
plataforma: 'tiktok',
formato: 'Reel',
pilar: 'feed',
hora: '18:00',
cta: '',
hashtags: '',
},
];
export function matchCatalog(title: string): CatalogRule | null {
const t = title
.toUpperCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
if (!t.trim()) return null;
let best: CatalogRule | null = null;
for (const rule of CALENDAR_CATALOG) {
const key = rule.clave
.toUpperCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
if (t.includes(key)) {
if (!best || rule.clave.length > best.clave.length) best = rule;
}
}
return best;
}
export type EnrichablePiece = {
title: string;
platform?: string;
format?: string;
pillar?: string;
time?: string;
cta?: string;
hashtags?: string;
hook?: string;
body?: string;
kind?: string;
};
/** Fill empty fields from CATALOGO based on publication name. Never overwrites non-empty values. */
export function enrichPieceFromCatalog<T extends EnrichablePiece>(piece: T): T {
const rule = matchCatalog(piece.title || '');
if (!rule) return piece;
const format = (piece.format || '').trim();
const platform = (piece.platform || '').trim();
const defaultishFormat = !format || /^post$/i.test(format);
return {
...piece,
platform: platform || rule.plataforma,
format: defaultishFormat ? rule.formato : format,
pillar: (piece.pillar || '').trim() || rule.pilar,
time: (piece.time || '').trim() || rule.hora,
cta: (piece.cta || '').trim() || rule.cta || undefined,
hashtags: (piece.hashtags || '').trim() || rule.hashtags || undefined,
};
}
export const TEMPLATE_HEADERS = [
'fecha',
'nombre_publicacion',
'plataforma',
'formato',
'pilar',
'hora',
'hook',
'copy',
'cta',
'hashtags',
'objetivo',
'estado',
'notas_internas',
] as const;
/** Official CSV — blank-ish with 3 example rows (generic, not a client calendar). */
export function buildOfficialCsv(tenantLabel = 'CLIENTE'): string {
const examples = [
[
'2026-11-03',
'POST 1 · Título del carrusel',
'instagram',
'Post',
'feed',
'10:30',
'Primera línea / gancho',
'Copy público completo. Sin notas internas aquí.',
'Enlace en la bio',
'',
'Citas / leads',
'planned',
'',
],
[
'2026-11-04',
'REEL · Trend del mes',
'instagram',
'Reel',
'feed',
'18:00',
'Hook del reel',
'Copy del reel',
'Guarda este post',
'',
'Guardados',
'planned',
'',
],
[
'2026-11-05',
'STORIES · Encuesta',
'instagram',
'Stories',
'stories',
'09:00',
'Pregunta de la encuesta',
'',
'Responde a la story',
'',
'Respuestas',
'planned',
'PENDIENTE PUBLICAR · fotos en Drive (NO va al caption)',
],
];
const header = TEMPLATE_HEADERS.join(',');
const lines = examples.map((row) =>
row.map((c) => csvEscape(String(c))).join(',')
);
return [
`# Plantilla oficial 4Ventures · válida para TODOS los clientes`,
`# Cliente sugerido: ${tenantLabel}`,
`# Rellena fecha + nombre_publicacion. El portal completa plataforma/formato/pilar/hora/cta si van vacíos.`,
`# notas_internas NUNCA se publica (WhatsApp, PENDIENTE, assignees…).`,
header,
...lines,
].join('\n');
}
function csvEscape(s: string): string {
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
export type BuiltWorkbook = {
buffer: Buffer;
filename: string;
};
/** Build .xlsx with INSTRUCCIONES + CATALOGO + CALENDARIO (formulas for Sheets/Excel). */
export async function buildOfficialXlsx(
tenantLabel = 'CLIENTE'
): Promise<BuiltWorkbook> {
const XLSX = await import('xlsx');
const wb = XLSX.utils.book_new();
const instrucciones = [
['Plantilla oficial calendarios RRSS · 4Ventures'],
['Válida para TODOS los clientes (no es un calendario de Blue Parrot ni de nadie).'],
[`Cliente al que vas a subir: ${tenantLabel}`],
[''],
['CÓMO USARLA'],
['1. Abre la hoja CALENDARIO (o impórtala en Google Sheets → Archivo → Importar).'],
['2. En cada fila escribe solo: fecha (AAAA-MM-DD) + nombre_publicacion.'],
['3. plataforma / formato / pilar / hora / cta se rellenan solos (fórmulas + catálogo).'],
['4. Pega el copy público en la columna «copy». NUNCA pegues ahí WhatsApp / PENDIENTE / nombres.'],
['5. Esas notas van SOLO en «notas_internas» (el portal las ignora al publicar).'],
['6. Renombra la hoja o el archivo con el mes (ej. Octubre 2026) si usas varias pestañas.'],
['7. Sube el archivo en Centro de Mando → Calendario → Elegir archivo (con el cliente activo).'],
[''],
['CLAVES DEL CATÁLOGO (si el nombre las contiene, se aplican defaults)'],
...CALENDAR_CATALOG.map((r) => [
r.clave,
`→ ${r.plataforma} · ${r.formato} · ${r.pilar} · ${r.hora}`,
]),
[''],
['COLUMNAS'],
['fecha', 'Obligatoria · 2026-11-03 o 03/11/2026'],
['nombre_publicacion', 'Obligatoria · ej. POST 1 · Título, REEL · …, STORIES · …'],
['plataforma', 'Auto o: instagram / facebook / linkedin / tiktok / blog'],
['formato', 'Auto o: Post / Reel / Stories / Carrusel'],
['pilar', 'Auto (feed, stories, nuevo-alumno…)'],
['hora', 'Auto · HH:MM'],
['hook', 'Primera línea; si vacío = nombre_publicacion'],
['copy', 'Texto público (caption). Sin notas de producción.'],
['cta', 'Auto desde catálogo si vacío'],
['hashtags', 'Opcional'],
['objetivo', 'KPI interno (no se publica)'],
['estado', 'planned / approved / published'],
['notas_internas', 'WhatsApp, PENDIENTE, Miriam… — NUNCA al caption'],
];
const wsInst = XLSX.utils.aoa_to_sheet(instrucciones);
wsInst['!cols'] = [{ wch: 28 }, { wch: 72 }];
XLSX.utils.book_append_sheet(wb, wsInst, 'INSTRUCCIONES');
const catRows: (string | number)[][] = [
['clave', 'plataforma', 'formato', 'pilar', 'hora', 'cta', 'hashtags'],
...CALENDAR_CATALOG.map((r) => [
r.clave,
r.plataforma,
r.formato,
r.pilar,
r.hora,
r.cta,
r.hashtags,
]),
];
const wsCat = XLSX.utils.aoa_to_sheet(catRows);
wsCat['!cols'] = [
{ wch: 16 },
{ wch: 12 },
{ wch: 10 },
{ wch: 14 },
{ wch: 8 },
{ wch: 22 },
{ wch: 20 },
];
XLSX.utils.book_append_sheet(wb, wsCat, 'CATALOGO');
// CALENDARIO: header + 40 formula rows. User types fecha + nombre; rest auto.
const header = [...TEMPLATE_HEADERS];
const cal: (string | number | null)[][] = [header];
// Seed 3 examples (values, not formulas) so import works even without Excel recalc
cal.push([
'2026-11-03',
'POST 1 · Título del carrusel',
'instagram',
'Post',
'feed',
'10:30',
'Primera línea / gancho',
'Copy público completo. Sin notas internas aquí.',
'Enlace en la bio',
'',
'Citas / leads',
'planned',
'',
]);
cal.push([
'2026-11-04',
'REEL · Trend del mes',
'instagram',
'Reel',
'feed',
'18:00',
'Hook del reel',
'Copy del reel',
'Guarda este post',
'',
'Guardados',
'planned',
'',
]);
cal.push([
'2026-11-05',
'STORIES · Encuesta',
'instagram',
'Stories',
'stories',
'09:00',
'Pregunta de la encuesta',
'',
'Responde a la story',
'',
'Respuestas',
'planned',
'PENDIENTE PUBLICAR · fotos en Drive (NO va al caption)',
]);
// SheetJS: header + 3 examples as values, then formula rows (nested IF = Excel + Google Sheets)
const wsCal = XLSX.utils.aoa_to_sheet(cal.slice(0, 4));
for (let i = 0; i < 37; i++) {
const excelRow = 5 + i;
const r0 = excelRow - 1;
const set = (col: number, cell: { t: string; v?: string; f?: string }) => {
const addr = XLSX.utils.encode_cell({ r: r0, c: col });
wsCal[addr] = cell;
};
const b = `$B${excelRow}`;
set(0, { t: 's', v: '' });
set(1, { t: 's', v: '' });
set(2, {
t: 'str',
f: `IF(${b}="";"";IF(ISNUMBER(SEARCH("LINKEDIN";${b}));"linkedin";IF(ISNUMBER(SEARCH("FACEBOOK";${b}));"facebook";IF(ISNUMBER(SEARCH("TIKTOK";${b}));"tiktok";IF(ISNUMBER(SEARCH("BLOG";${b}));"blog";"instagram")))))`,
});
set(3, {
t: 'str',
f: `IF(${b}="";"";IF(ISNUMBER(SEARCH("CARRUSEL";${b}));"Carrusel";IF(OR(ISNUMBER(SEARCH("STORIES";${b}));ISNUMBER(SEARCH("STORY";${b})));"Stories";IF(ISNUMBER(SEARCH("REEL";${b}));"Reel";"Post"))))`,
});
set(4, {
t: 'str',
f: `IF(${b}="";"";IF(OR(ISNUMBER(SEARCH("NUEVO ALUMNO";${b}));ISNUMBER(SEARCH("ALUMNO NUEVO";${b})));"nuevo-alumno";IF(OR(ISNUMBER(SEARCH("STORIES";${b}));ISNUMBER(SEARCH("STORY";${b})));"stories";IF(ISNUMBER(SEARCH("LINKEDIN";${b}));"linkedin";"feed"))))`,
});
set(5, {
t: 'str',
f: `IF(${b}="";"";IF(OR(ISNUMBER(SEARCH("STORIES";${b}));ISNUMBER(SEARCH("STORY";${b})));"09:00";IF(ISNUMBER(SEARCH("REEL";${b}));"18:00";IF(ISNUMBER(SEARCH("LINKEDIN";${b}));"09:00";IF(OR(ISNUMBER(SEARCH("NUEVO ALUMNO";${b}));ISNUMBER(SEARCH("ALUMNO NUEVO";${b})));"13:24";"10:30")))))`,
});
set(6, { t: 's', v: '' });
set(7, { t: 's', v: '' });
set(8, {
t: 'str',
f: `IF(${b}="";"";IF(OR(ISNUMBER(SEARCH("STORIES";${b}));ISNUMBER(SEARCH("STORY";${b})));"Responde a la story";IF(ISNUMBER(SEARCH("REEL";${b}));"Guarda este post";"Enlace en la bio")))`,
});
set(9, { t: 's', v: '' });
set(10, { t: 's', v: '' });
set(11, { t: 's', v: 'planned' });
set(12, { t: 's', v: '' });
}
wsCal['!ref'] = XLSX.utils.encode_range({
s: { r: 0, c: 0 },
e: { r: 40, c: 12 },
});
wsCal['!cols'] = header.map((h) => ({
wch: Math.max(12, Math.min(28, h.length + 4)),
}));
XLSX.utils.book_append_sheet(wb, wsCal, 'CALENDARIO');
// Also a month-named sheet for people who prefer week grid (empty scaffold)
const gridHint = [
['SEMANA 1', '', '', '', 'JUEVES 1', 'VIERNES 2', 'SÁBADO 3', 'DOMINGO 4'],
['STORIES', '', '', '', 'STORIES · Ejemplo encuesta', '', '', ''],
['POST', '', '', '', '', 'REEL · Ejemplo', '', ''],
['SEMANA 2', 'LUNES 5', 'MARTES 6', 'MIÉRCOLES 7', 'JUEVES 8', 'VIERNES 9', 'SÁBADO 10', 'DOMINGO 11'],
['STORIES', '', '', '', '', '', '', ''],
['POST', 'POST 1 · Título', '', '', '', '', '', ''],
[''],
['Nota: renombra esta hoja a «Noviembre 2026» (o el mes) antes de subir.'],
['O usa solo la hoja CALENDARIO (recomendada).'],
];
XLSX.utils.book_append_sheet(
wb,
XLSX.utils.aoa_to_sheet(gridHint),
'REJILLA_OPCIONAL'
);
const buffer = Buffer.from(
XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as ArrayBuffer
);
const slug = tenantLabel
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 32);
return {
buffer,
filename: `plantilla-calendario-4ventures-${slug || 'cliente'}.xlsx`,
};
}