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:
2354
package-lock.json
generated
Normal file
2354
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
22
scripts/generate_calendar_template.ts
Normal file
22
scripts/generate_calendar_template.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Regenerate public/plantillas/calendario-rrss-oficial.xlsx
|
||||||
|
* npx tsx scripts/generate_calendar_template.ts
|
||||||
|
*/
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { buildOfficialXlsx } from '../src/lib/calendar-template';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { buffer, filename } = await buildOfficialXlsx('CLIENTE');
|
||||||
|
const out = resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'public/plantillas/calendario-rrss-oficial.xlsx'
|
||||||
|
);
|
||||||
|
writeFileSync(out, buffer);
|
||||||
|
console.log('Wrote', out, `(${buffer.length} bytes) · ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
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';
|
} from '@/lib/agency';
|
||||||
import {
|
import {
|
||||||
parseJsonCalendar,
|
parseJsonCalendar,
|
||||||
SAMPLE_CSV,
|
|
||||||
sheetsToPlans,
|
sheetsToPlans,
|
||||||
type ImportResult,
|
type ImportResult,
|
||||||
type SheetRows,
|
type SheetRows,
|
||||||
} from '@/lib/calendar-import';
|
} from '@/lib/calendar-import';
|
||||||
|
import { buildOfficialCsv } from '@/lib/calendar-template';
|
||||||
import type { RrssMonthPlan } from '@/components/RrssMonthView';
|
import type { RrssMonthPlan } from '@/components/RrssMonthView';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -21,12 +21,14 @@ export const runtime = 'nodejs';
|
|||||||
|
|
||||||
const MAX_BYTES = 8 * 1024 * 1024;
|
const MAX_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** @deprecated Prefer GET /api/calendar/template?format=csv */
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return new NextResponse(SAMPLE_CSV, {
|
return new NextResponse(buildOfficialCsv('CLIENTE'), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/csv; charset=utf-8',
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
'Content-Disposition':
|
'Content-Disposition':
|
||||||
'attachment; filename="plantilla-calendario-4ventures.csv"',
|
'attachment; filename="plantilla-calendario-4ventures.csv"',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,23 +56,26 @@ export default function CalendarUpload({
|
|||||||
Subir calendario (CSV, Excel o JSON)
|
Subir calendario (CSV, Excel o JSON)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Plantilla oficial: Excel por mes con filas SEMANA / Story / POST
|
Plantilla oficial multi-cliente: escribe{' '}
|
||||||
(como Brava Octubre). También CSV con columnas fecha + título. Si
|
<strong>fecha + nombre_publicacion</strong> y se rellenan
|
||||||
el mes ya existía, se reemplaza.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<a
|
<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"
|
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)
|
Plantilla Excel (oficial)
|
||||||
</a>
|
</a>
|
||||||
<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"
|
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>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/** Parse CSV / Excel / JSON editorial calendars into rrss_month plans. */
|
/** Parse CSV / Excel / JSON editorial calendars into rrss_month plans. */
|
||||||
|
|
||||||
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
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)[][];
|
export type SheetRows = (string | number | null)[][];
|
||||||
|
|
||||||
@@ -305,18 +310,37 @@ export function parseWeekGrid(
|
|||||||
? 'Carrusel'
|
? 'Carrusel'
|
||||||
: 'Post';
|
: 'Post';
|
||||||
const k = kind === 'STORIES' ? 'story' : kindFromFormat(format);
|
const k = kind === 'STORIES' ? 'story' : kindFromFormat(format);
|
||||||
pieces.push({
|
const titleLine = text.split('\n')[0].slice(0, 140);
|
||||||
id: `${date}-${k}-${slugify(text).slice(0, 28)}`,
|
const cleanBody = sanitizePublishCaption(text, { maxLen: 4000 }).caption;
|
||||||
date,
|
const enriched = enrichPieceFromCatalog({
|
||||||
|
title: titleLine,
|
||||||
platform: 'instagram',
|
platform: 'instagram',
|
||||||
format,
|
format,
|
||||||
pillar: k === 'story' ? 'stories' : 'feed',
|
pillar: k === 'story' ? 'stories' : 'feed',
|
||||||
title: text.split('\n')[0].slice(0, 140),
|
time: undefined,
|
||||||
hook: text.split('\n')[0].slice(0, 180),
|
cta: undefined,
|
||||||
body: text,
|
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',
|
status: 'planned',
|
||||||
kind: k,
|
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'],
|
date: ['fecha', 'date', 'dia', 'día', 'day'],
|
||||||
platform: ['plataforma', 'platform', 'red', 'canal', 'network'],
|
platform: ['plataforma', 'platform', 'red', 'canal', 'network'],
|
||||||
format: ['formato', 'format', 'tipo', 'type'],
|
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'],
|
hook: ['hook', 'gancho'],
|
||||||
body: ['copy', 'body', 'caption', 'texto', 'descripcion', 'descripción', 'contenido'],
|
body: ['copy', 'body', 'caption', 'texto', 'descripcion', 'descripción', 'contenido'],
|
||||||
cta: ['cta', 'llamada'],
|
cta: ['cta', 'llamada'],
|
||||||
@@ -344,6 +376,15 @@ function headerIndex(headers: string[]): Record<string, number> {
|
|||||||
time: ['hora', 'time'],
|
time: ['hora', 'time'],
|
||||||
objective: ['objetivo', 'objective', 'metric', 'kpi'],
|
objective: ['objetivo', 'objective', 'metric', 'kpi'],
|
||||||
status: ['estado', 'status'],
|
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) => {
|
headers.forEach((h, i) => {
|
||||||
const k = h
|
const k = h
|
||||||
@@ -361,8 +402,9 @@ function headerIndex(headers: string[]): Record<string, number> {
|
|||||||
export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] {
|
export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] {
|
||||||
let headerRow = -1;
|
let headerRow = -1;
|
||||||
let idx: Record<string, number> = {};
|
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);
|
const headers = (rows[i] || []).map(cell);
|
||||||
|
if (headers[0]?.startsWith('#')) continue;
|
||||||
const found = headerIndex(headers);
|
const found = headerIndex(headers);
|
||||||
if (found.date != null && (found.title != null || found.body != null || found.format != null)) {
|
if (found.date != null && (found.title != null || found.body != null || found.format != null)) {
|
||||||
headerRow = i;
|
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 get = (k: string) => (idx[k] != null ? cell(row[idx[k]]) : '');
|
||||||
const date = parseFlexibleDate(idx.date != null ? row[idx.date] : '', ymHint);
|
const date = parseFlexibleDate(idx.date != null ? row[idx.date] : '', ymHint);
|
||||||
if (!date) continue;
|
if (!date) continue;
|
||||||
const format = get('format') || 'Post';
|
// Skip comment / instruction rows in CSV
|
||||||
const title = get('title') || get('body') || get('hook') || format;
|
if (String(row[0] || '').trim().startsWith('#')) continue;
|
||||||
|
const title = get('title') || get('body') || get('hook') || get('format');
|
||||||
if (!title.trim()) continue;
|
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 kind = kindFromFormat(format);
|
||||||
const statusRaw = get('status').toLowerCase();
|
const statusRaw = get('status').toLowerCase();
|
||||||
const status = /publi|subido|posted/.test(statusRaw)
|
const status = /publi|subido|posted/.test(statusRaw)
|
||||||
@@ -390,17 +450,17 @@ export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] {
|
|||||||
? 'approved'
|
? 'approved'
|
||||||
: 'planned';
|
: 'planned';
|
||||||
pieces.push({
|
pieces.push({
|
||||||
id: `${date}-${kind}-${slugify(title).slice(0, 28)}`,
|
id: `${date}-${kind}-${slugify(enriched.title).slice(0, 28)}`,
|
||||||
date,
|
date,
|
||||||
time: get('time') || undefined,
|
time: enriched.time || undefined,
|
||||||
platform,
|
platform,
|
||||||
format,
|
format,
|
||||||
pillar: get('pillar') || undefined,
|
pillar: enriched.pillar || undefined,
|
||||||
title: title.split('\n')[0].slice(0, 160),
|
title: enriched.title,
|
||||||
hook: get('hook') || title.split('\n')[0],
|
hook: enriched.hook || enriched.title,
|
||||||
body: get('body') || title,
|
body: enriched.body || enriched.title,
|
||||||
cta: get('cta') || undefined,
|
cta: enriched.cta || undefined,
|
||||||
hashtags: get('hashtags') || undefined,
|
hashtags: enriched.hashtags || undefined,
|
||||||
objective_metric: get('objective') || undefined,
|
objective_metric: get('objective') || undefined,
|
||||||
status,
|
status,
|
||||||
kind,
|
kind,
|
||||||
@@ -567,12 +627,8 @@ export function sheetsToPlans(
|
|||||||
return { plans, warnings, sheets: report };
|
return { plans, warnings, sheets: report };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback tabular CSV. Preferred official format = Excel week grid like Brava Octubre. */
|
/** Fallback tabular CSV (official multi-client). Prefer Excel from /api/calendar/template. */
|
||||||
export const SAMPLE_CSV = `fecha,plataforma,formato,titulo,hook,copy,cta,hashtags,pilar,hora,objetivo
|
export const SAMPLE_CSV = buildOfficialCsv('CLIENTE');
|
||||||
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
|
|
||||||
`;
|
|
||||||
|
|
||||||
/** Minimal October-style grid as TSV (for docs / paste into Excel). */
|
/** 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
|
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