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:
55
src/app/api/calendar/template/route.ts
Normal file
55
src/app/api/calendar/template/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
473
src/lib/calendar-template.ts
Normal file
473
src/lib/calendar-template.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user