From 63c4fbac996185970cb8dd6a1f37441d2cdb3419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Sun, 17 May 2026 07:49:00 -0300 Subject: [PATCH] "chore(claude-code): configurar permisos compartidos del proyecto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settings.json: deny patterns críticos (rm -rf root, force push, etc.) - settings.local.json en gitignore — cada dev configura sus allows Refs: methodology/PATTERNS.md B.4 (reglas no negociables en repo) --- .claude/settings.json | 14 + .gitignore | 1 + CLAUDE.md | 8 + sprints/sprint-1-5/ETAPA_4_PROMPT.md | 311 +++++++++++++++++++ src/lib/export/pdf-export.ts | 34 +- src/lib/export/pdf-sections-roi.ts | 3 +- src/lib/export/pdf-sections-shared.ts | 191 ++++++++++++ src/lib/export/pdf-sections.ts | 28 +- tests/lib/export/pdf-export-roi.test.ts | 8 +- tests/lib/export/pdf-export.test.ts | 8 +- tests/lib/export/pdf-sections-shared.test.ts | 148 +++++++++ 11 files changed, 718 insertions(+), 36 deletions(-) create mode 100644 .claude/settings.json create mode 100644 sprints/sprint-1-5/ETAPA_4_PROMPT.md create mode 100644 src/lib/export/pdf-sections-shared.ts create mode 100644 tests/lib/export/pdf-sections-shared.test.ts diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..65fcf96 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "deny": [ + "Bash(rm -rf /)", + "Bash(rm -rf /*)", + "Bash(rm -rf ~)", + "Bash(rm -rf ~/*)", + "Bash(git push --force *)", + "Bash(git reset --hard *)", + "Bash(git clean -fdx *)", + "Bash(npm publish *)" + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index f83cc70..3ce60ec 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ test-results/ # Wrangler .wrangler/ +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index 9295781..7a2258a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,5 +307,13 @@ Cuando detectés una oportunidad de mejora fuera del scope del prompt actual: - Cambios de marca, arquitectura o datos (paleta, dependencias, estructura, features nuevas): CONSULTAR ANTES DE PROCEDER +Estructura de propuesta cuando hay un cambio Nivel 3: + + PROPUESTA DE CAMBIO NIVEL 3 — requiere aprobación antes de implementar + Cambio sugerido: [descripción] + Justificación: [por qué lo considero valioso] + Impacto: [qué afecta] + Alternativa si se rechaza: [qué hacer en lugar] + Ver methodology/PRINCIPLES.md sección "Política de iniciativa agéntica" para detalle completo. \ No newline at end of file diff --git a/sprints/sprint-1-5/ETAPA_4_PROMPT.md b/sprints/sprint-1-5/ETAPA_4_PROMPT.md new file mode 100644 index 0000000..224fc38 --- /dev/null +++ b/sprints/sprint-1-5/ETAPA_4_PROMPT.md @@ -0,0 +1,311 @@ +# Sprint 1.5 — Etapa 4: PDF — Header y footer compartidos + +> **Objetivo:** refactor estructural de los headers y footers de PDFs. Crear módulo compartido que aplique identidad InQ consistente en los 3 PDFs (Actual, Automatizado, ROI). Aplicar paleta naranja InQ a elementos de marca. NO rediseñar layout de contenido — eso es Etapa 5+. +> +> **Pre-requisitos:** Etapas 1, 2 y 3 cerradas. Sistema de diseño con tokens InQ disponible. Web app con identidad InQ aplicada. +> +> **Etapa siguiente:** Etapa 5 — PDF: layout híbrido portrait + landscape (la página BPMN pasa a horizontal). + +--- + +## 1. Alcance estricto de Etapa 4 + +### Sí entra +- ✅ Crear módulo compartido `pdf-sections-shared.ts` con `drawHeader` y `drawFooter` reutilizables +- ✅ Refactorizar los 3 PDFs existentes (Actual, Automatizado, ROI) para usar el módulo compartido +- ✅ Aplicar paleta naranja InQ en: + - Border-bottom del header (color naranja `#F59845`, grosor variable según página) + - Logo "InQ ROI" en color naranja en cada header + - "Powered by InQuality" en color slate-500 en cada footer +- ✅ Reemplazar `PDF.blue` por `PDF.inqOrange` en headers de tablas autotable (de pdf-sections.ts, pdf-export.ts, pdf-sections-roi.ts según audit Etapa 1) +- ✅ Quitar versión "v0.1.0" del footer del PDF (decisión de Etapa 3 — la versión se queda solo en web app) +- ✅ Tests E2E confirman que los 3 PDFs siguen generándose correctamente con la nueva estructura + +### NO entra +- ❌ Cambio de orientación de páginas (Etapa 5) +- ❌ Rediseño de página 1 ROI con KPI Hero gradiente (Etapa 6) +- ❌ Tabla comparativa rediseñada con paleta (Etapa 7) +- ❌ Top 3 ahorros en página 1 ROI (Etapa 6) +- ❌ Sección Transparencia rediseñada (Etapa 7) +- ❌ Modificar el contenido de cualquier página más allá del header/footer + +**Si encontrás algo que parece "natural" agregar pero no está en "Sí entra", PARÁ y consultá. Aplicá la política de iniciativa proactiva de CLAUDE.md.** + +--- + +## 2. Decisiones ya tomadas (no consultar de nuevo) + +| Decisión | Justificación | +|---|---| +| Logo "InQ ROI" en header de cada página | Brief sección 2.6, ya aplicado en Etapa 2 | +| "Powered by InQuality" en footer de cada página | Regla de marca CLAUDE.md, ya aplicado en Etapa 2 | +| Border-bottom 3px naranja en página 1, 2px en páginas siguientes | Brief sección 2.6 | +| Quitar versión del PDF (mantener solo en web) | Decisión de Etapa 3 | +| Color naranja InQ `#F59845` para elementos de marca | Manual de marca Etapa 1 | +| Mantener fuente actual (Roboto o equivalente) | No cambiar de fuente en esta etapa | +| Caracteres especiales (✓, ⚡, ⚠) reemplazados por equivalentes ASCII si la fuente no soporta | Decisión Etapa 2 — se mantiene | + +--- + +## 3. Estructura del módulo compartido + +### 3.1 Archivo nuevo: `src/lib/export/pdf-sections-shared.ts` + +Debe exportar al menos estas dos funciones: + +```typescript +import type { jsPDF } from 'jspdf'; + +export interface PdfHeaderOptions { + pageNumber: number; // 1, 2, 3... + totalPages: number; // necesario para "Página X de Y" + productName: string; // "InQ ROI" + sectionTitle?: string; // ej. "Análisis de retorno de inversión" + clientName?: string; // ej. "Banco Atlántico S.A." + processName?: string; // ej. "medium-with-gateways" + simulatedAt: Date; // fecha de simulación +} + +export interface PdfFooterOptions { + pageNumber: number; + totalPages: number; + productName: string; // "InQ ROI" + clientName?: string; + simulatedAt: Date; +} + +/** + * Dibuja el header en la posición actual del documento. + * - Página 1: border-bottom 3px naranja + * - Otras páginas: border-bottom 2px naranja + * - Logo "InQ ROI" en naranja InQ (esquina superior izquierda o derecha según diseño) + * - Metadata del producto/proceso a la derecha (ver brief sección 2.6) + */ +export function drawHeader(doc: jsPDF, options: PdfHeaderOptions): void; + +/** + * Dibuja el footer en la posición fija inferior (16-20px del bottom). + * - Border-top 0.5px slate-200 + * - Izquierda: "InQ ROI · Powered by InQuality · [cliente] · [fecha]" + * - Derecha: "Página X de Y" + * - NO incluir versión del producto + */ +export function drawFooter(doc: jsPDF, options: PdfFooterOptions): void; + +/** + * Constantes de paleta para PDFs. + */ +export const PDF_COLORS = { + inqOrange: [245, 152, 69], // #F59845 + inqOrangeDark: [180, 83, 9], // #B45309 + inqMagenta: [237, 62, 143], // #ED3E8F + textPrimary: [15, 23, 42], // #0F172A + textSecondary: [71, 85, 105], // #475569 + textTertiary: [148, 163, 184], // #94A3B8 + borderLight: [226, 232, 240], // #E2E8F0 + // ... cualquier otro que necesites +} as const; +``` + +### 3.2 Eliminar `PDF.blue` del código + +Buscar todas las ocurrencias y reemplazar por `PDF_COLORS.inqOrange`: + +```bash +grep -rn "PDF.blue\|PDF\.blue" src/ +``` + +Archivos afectados según audit Etapa 1: +- `pdf-sections.ts:9` (definición de la constante — eliminar) +- `pdf-sections.ts:112` (uso en KPI card accent) +- `pdf-export.ts:120` (header de tabla autotable) +- `pdf-sections-roi.ts:203` (header de tabla comparativa ROI) + +### 3.3 Refactorizar los 3 PDFs + +Los archivos que generan los PDFs deben: +1. Importar `drawHeader`, `drawFooter`, `PDF_COLORS` del módulo compartido +2. Reemplazar el código actual de header/footer por las llamadas al módulo +3. Mantener todo el resto del contenido sin cambios (esto NO es Etapa 5-7) + +Archivos a refactorizar: +- `src/lib/export/pdf-export.ts` (PDFs Actual y Automatizado) +- `src/lib/export/pdf-sections-roi.ts` (PDF de Comparación + ROI) +- Cualquier helper que genere los PDFs anteriores + +--- + +## 4. Validación visual mandatoria + +### 4.1 Antes de declarar etapa completada + +Generar los 3 PDFs con el setup realista de pruebas y validarlos visualmente: + +**Setup de referencia:** +- Proceso: `medium-with-gateways` +- 3 actividades automatizables (`task_recv`, `task_score`, `task_analisis`) +- Costos: directos $200, automatizados $20 +- Volumetría: 5000/año, 3 años, USD 80.000 inversión + +**Archivos a generar en `tests/e2e/__output__/`:** +- `etapa-4-actual.pdf` +- `etapa-4-automatizado.pdf` +- `etapa-4-roi.pdf` + +### 4.2 Validaciones automatizadas mandatorias + +Antes de `present_files`, ejecutá y confirmá: + +``` +Para cada PDF generado: + +1. pdf-parse confirma palabras clave en TODAS las páginas: + - "InQ ROI" presente + - "Powered by InQuality" presente en footer + +2. pdf-parse rechaza términos prohibidos: + - "Process Cost Platform" — NO debe aparecer + - "v0.1.0" — NO debe aparecer en el PDF (solo en web) + - "INQ", "Inq", "inq" — NO debe aparecer (regla de marca I-n-Q) + +3. Cantidad de páginas: + - actual.pdf: 3 páginas + - automatizado.pdf: 3 páginas + - roi.pdf: 4 páginas + +4. Tamaño de cada página: entre 15-80 KB + Si alguna queda <15KB: anomalía, investigar antes de presentar + +5. Análisis de píxeles del border-bottom del header: + - Confirmar presencia de naranja #F59845 (al menos N píxeles) + - Ausencia de azul `#1E40AF` (color anterior que migramos) + +6. Tests previos verdes: >= 455 +``` + +Si CUALQUIER validación falla, NO uses `present_files`. Reportá qué falló y esperá instrucciones. + +### 4.3 Validación visual del humano + +Después de las validaciones automatizadas, presentar los 3 PDFs vía `present_files`. El humano va a verificar visualmente: + +1. ¿El header tiene logo "InQ ROI" en naranja en las 3 (o 4) páginas? +2. ¿Hay un border-bottom naranja consistente en cada header? +3. ¿El footer dice exactamente "InQ ROI · Powered by InQuality · [cliente] · [fecha]" sin versión? +4. ¿La paginación "Página X de Y" es correcta en cada PDF? +5. ¿Las tablas internas usan headers naranjas en lugar de azules? +6. ¿El contenido de las páginas más allá del header/footer NO cambió respecto a la versión anterior? + +--- + +## 5. Entregables (Definition of Done) + +``` +[ ] src/lib/export/pdf-sections-shared.ts creado con drawHeader, + drawFooter y PDF_COLORS exportados +[ ] pdf-export.ts refactorizado para usar el módulo compartido +[ ] pdf-sections.ts refactorizado para usar el módulo compartido +[ ] pdf-sections-roi.ts refactorizado para usar el módulo compartido +[ ] Cero ocurrencias de PDF.blue en grep +[ ] Versión "v0.1.0" eliminada del footer de PDFs (mantener en web) +[ ] 3 PDFs generados en tests/e2e/__output__/: + - etapa-4-actual.pdf (3 páginas) + - etapa-4-automatizado.pdf (3 páginas) + - etapa-4-roi.pdf (4 páginas) +[ ] 6 validaciones automatizadas confirmadas (palabras clave, prohibidos, + páginas, tamaño, píxeles naranja, tests previos) +[ ] PDFs presentados con present_files +[ ] 455 tests previos + nuevos tests del módulo compartido = ~460+ verdes +[ ] Tests nuevos para drawHeader y drawFooter (3-5 casos mínimo) +[ ] npm run build limpio (sin warnings TS nuevos) +[ ] Decisiones tomadas por iniciativa documentadas en reporte (Patrón C.6) +``` + +--- + +## 6. Tests obligatorios + +### 6.1 Tests unitarios del módulo compartido + +Crear `tests/lib/export/pdf-sections-shared.test.ts`: + +```typescript +describe('PDF shared sections', () => { + describe('PDF_COLORS', () => { + it('expone inqOrange como array RGB válido', () => { + expect(PDF_COLORS.inqOrange).toEqual([245, 152, 69]); + }); + + it('NO incluye colores de CONCILIA', () => { + const allColors = JSON.stringify(PDF_COLORS); + expect(allColors).not.toContain('87, 47, 162'); // #572FA2 + expect(allColors).not.toContain('203, 24, 137'); // #CB1889 + }); + }); + + describe('drawHeader', () => { + it('dibuja border-bottom 3px en página 1', () => { + // mock jsPDF, capturar llamadas, assertions + }); + + it('dibuja border-bottom 2px en página 2+', () => { + // ... + }); + }); + + describe('drawFooter', () => { + it('incluye "InQ ROI" y "Powered by InQuality"', () => { + // ... + }); + + it('NO incluye versión del producto', () => { + // ... + }); + }); +}); +``` + +### 6.2 Tests E2E + +Adaptar tests E2E existentes para verificar que los 3 PDFs siguen generándose correctamente después del refactor. + +--- + +## 7. Reporte final a entregar + +Cuando termines, devolveme: + +1. **Tabla de archivos modificados/creados** con líneas +2. **Resultado de las validaciones automatizadas** (los 6 puntos del 4.2) +3. **Cantidad final de tests verdes** (esperado 460+) +4. **3 PDFs presentados con `present_files`** para validación visual mía +5. **Hallazgos no esperados** — cualquier cosa que merezca decisión arquitectónica +6. **Decisiones tomadas por iniciativa** — si aplicaste algún cambio Nivel 2 según política de CLAUDE.md, listalo acá. Si encontrás algo Nivel 3, **NO LO HAGAS** y reportalo como propuesta + +NO arranques Etapa 5 sin mi OK explícito. + +--- + +## 8. Reglas operativas (recordatorios) + +1. **No tocar contenido de las páginas más allá del header/footer**. El rediseño de páginas viene en Etapas 5-7. +2. **No cambiar la orientación de ninguna página**. La página BPMN sigue vertical en esta etapa (cambia en Etapa 5). +3. **No agregar features nuevas al PDF**. Esta etapa es refactor + identidad, no nueva funcionalidad. +4. **No racionalizar problemas**. Si una validación automatizada falla, investigar causa raíz. +5. **Política de iniciativa proactiva (CLAUDE.md):** + - Cambios Nivel 1 (refactor interno, mejor naming): ejecutar + - Cambios Nivel 2 (visible pero reversible): ejecutar y reportar + - Cambios Nivel 3 (marca, arquitectura, datos): CONSULTAR antes +6. **Si encontrás contradicción** entre este prompt y la realidad del código (un archivo no está donde decía, un import no resuelve, etc.): **PARÁ y consultá**, no asumas. + +--- + +## 9. Notas sobre dependencia con Etapa 5 + +Etapa 5 va a cambiar la orientación de la página BPMN a landscape. El módulo `pdf-sections-shared.ts` que crees en esta etapa debe estar preparado para esto: + +- `drawHeader` y `drawFooter` deben recibir el ancho/alto disponible como parámetros implícitos del `doc` (jsPDF lo provee con `doc.internal.pageSize.getWidth()` y `getHeight()`) +- NO hardcodear `pageWidth = 595` (A4 portrait). Si el header se llama desde una página landscape, debe adaptarse al ancho real +- Probá esto con un test que llame `drawHeader` en una página landscape mock + +Esto preserva el contrato del módulo para que Etapa 5 no requiera refactor adicional. \ No newline at end of file diff --git a/src/lib/export/pdf-export.ts b/src/lib/export/pdf-export.ts index da1d467..4f7a7e2 100644 --- a/src/lib/export/pdf-export.ts +++ b/src/lib/export/pdf-export.ts @@ -1,13 +1,14 @@ import { buildFileName } from './slug' import { calculateRoi } from '@/domain/roi' import { - drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend, - drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF, + drawKpiCards, drawHeatmapImage, drawHeatmapLegend, + drawAnalysisSection, drawMethodologySection, PDF, } from './pdf-sections' import { drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig, drawRoiAnalysisPage, drawRoiMethodologySection, } from './pdf-sections-roi' +import { drawSharedHeader, applyFootersToAllPages, PDF_COLORS } from './pdf-sections-shared' import type { Process, Simulation, Resource, Activity } from '@/domain/types' export type ReportTab = 'actual' | 'automatizado' | 'roi' @@ -69,7 +70,10 @@ async function exportScenarioPdf( keywords: 'bpmn, costos, simulación', }) - let y = drawHeader(docAny, process, simulation.executedAt) + const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt } + + // Página 1 + let y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 1 }) y = drawKpiCards(docAny, result, currency, y) y += 4 @@ -83,8 +87,9 @@ async function exportScenarioPdf( y = drawHeatmapLegend(docAny, y) y += 4 + // Página 2 — análisis + tabla de costos docAny.addPage() - y = PDF.margin + y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 }) y = drawAnalysisSection(docAny, result, currency, y) y += 6 @@ -117,14 +122,16 @@ async function exportScenarioPdf( startY: y, margin: { left: PDF.margin, right: PDF.margin }, styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' }, - headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 }, + headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 }, alternateRowStyles: { fillColor: PDF.slate50 }, columnStyles: { 0: { cellWidth: 48 } }, }) + // Página 3 — nota metodológica docAny.addPage() - drawMethodologySection(docAny, process, PDF.margin) - addPageNumbers(docAny, 'InQ ROI · Powered by InQuality', simulation.executedAt) + y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 }) + drawMethodologySection(docAny, process, y) + applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }) } // ─── ROI — 4 páginas ────────────────────────────────────────────────────────── @@ -157,14 +164,16 @@ async function exportRoiPdf( keywords: 'bpmn, roi, automatización, payback', }) + const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt } + // ── Página 1: Header + resumen ejecutivo + 5 KPI cards ───────────────────── - let y = drawHeader(doc, process, simulation.executedAt) + let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 1 }) y = drawRoiExecutiveSummary(doc, process, roi, currency, y) y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y) // ── Página 2: Tabla comparativa ──────────────────────────────────────────── doc.addPage() - y = PDF.margin + y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 }) doc.setFont('helvetica', 'bold') doc.setFontSize(12) @@ -189,14 +198,15 @@ async function exportRoiPdf( // ── Página 3: Análisis textual + transparencia ───────────────────────────── doc.addPage() - y = PDF.margin + y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 3 }) drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y) // ── Página 4: Metodología ────────────────────────────────────────────────── doc.addPage() - drawRoiMethodologySection(doc, process, PDF.margin) + y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 4 }) + drawRoiMethodologySection(doc, process, y) - addPageNumbers(doc, 'InQ ROI · Powered by InQuality', simulation.executedAt) + applyFootersToAllPages(doc, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }) } function formatNum(n: number): string { diff --git a/src/lib/export/pdf-sections-roi.ts b/src/lib/export/pdf-sections-roi.ts index 42fe254..7eb116c 100644 --- a/src/lib/export/pdf-sections-roi.ts +++ b/src/lib/export/pdf-sections-roi.ts @@ -2,6 +2,7 @@ import { formatCurrency, formatPayback, formatPercent } from '@/lib/format' import type { ActivitySimResult, Process, Activity } from '@/domain/types' import type { RoiResult } from '@/domain/roi' import { PDF, type DocLike } from './pdf-sections' +import { PDF_COLORS } from './pdf-sections-shared' const ROI_HIGH_THRESHOLD = 1000 @@ -194,7 +195,7 @@ export function buildComparisonTableConfig( startY, margin: { left: PDF.margin, right: PDF.margin }, styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' }, - headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 }, + headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 }, columnStyles: { 0: { cellWidth: 42 } }, } } diff --git a/src/lib/export/pdf-sections-shared.ts b/src/lib/export/pdf-sections-shared.ts new file mode 100644 index 0000000..d6d3b2e --- /dev/null +++ b/src/lib/export/pdf-sections-shared.ts @@ -0,0 +1,191 @@ +/** + * Módulo compartido de header/footer PDF — Sprint 1.5 Etapa 4. + * + * Provee drawSharedHeader, drawSharedFooter, applyFootersToAllPages + * y PDF_COLORS para uso en los 3 PDFs (Actual, Automatizado, ROI). + * + * Diseñado para ser compatible con páginas landscape (Etapa 5): + * usa doc.internal.pageSize.getWidth/Height() en lugar de constantes A4. + */ +import { formatSimulationTimestamp } from '@/lib/format' + +// ─── Tipo DocLike ───────────────────────────────────────────────────────────── +// Superset del DocLike anterior: agrega pageSize para compatibilidad landscape. +// pdf-sections.ts re-exporta este tipo para mantener compatibilidad de imports. + +export type DocLike = { + setFont: (name: string, style: string) => void + setFontSize: (size: number) => void + setTextColor: (...args: number[]) => void + setFillColor: (...args: number[]) => void + setDrawColor: (...args: number[]) => void + setLineWidth: (w: number) => void + text: (text: string | string[], x: number, y: number, opts?: Record) => void + rect: (x: number, y: number, w: number, h: number, style?: string) => void + roundedRect: (x: number, y: number, w: number, h: number, rx: number, ry: number, style?: string) => void + line: (x1: number, y1: number, x2: number, y2: number) => void + addImage: (img: string, format: string, x: number, y: number, w: number, h: number) => void + addPage: () => void + setPage: (page: number) => void + internal: { + getNumberOfPages: () => number + pageSize: { + getWidth: () => number + getHeight: () => number + } + } + splitTextToSize: (text: string, maxWidth: number) => string[] +} + +// ─── Paleta PDF ─────────────────────────────────────────────────────────────── + +export const PDF_COLORS = { + inqOrange: [245, 152, 69] as [number, number, number], // #F59845 + inqOrangeDark: [180, 83, 9] as [number, number, number], // #B45309 + inqMagenta: [237, 62, 143] as [number, number, number], // #ED3E8F + textPrimary: [15, 23, 42] as [number, number, number], // #0F172A slate-900 + textSecondary: [71, 85, 105] as [number, number, number], // #475569 slate-600 + textTertiary: [100, 116, 139] as [number, number, number], // #64748B slate-500 + textDisabled: [148, 163, 184] as [number, number, number], // #94A3B8 slate-400 + borderLight: [226, 232, 240] as [number, number, number], // #E2E8F0 slate-200 + slate50: [248, 250, 252] as [number, number, number], // #F8FAFC + successGreen: [22, 163, 74] as [number, number, number], // #16A34A + dangerRed: [239, 68, 68] as [number, number, number], // #EF4444 + warningAmber: [245, 158, 11] as [number, number, number], // #F59E0B +} as const + +const MARGIN = 20 // mm + +// ─── Header compartido ──────────────────────────────────────────────────────── + +export interface PdfHeaderOptions { + pageNumber: number // 1-based — determina grosor del border (3px pág 1, 2px resto) + processName: string + sectionTitle?: string // subtítulo de sección, ej. "Análisis de retorno de inversión" + clientName?: string + simulatedAt: number // Unix timestamp ms +} + +/** + * Dibuja el header InQ-branded. + * - Logo "InQ ROI" naranja a la izquierda, fecha a la derecha + * - Nombre del proceso + cliente + * - Border-bottom naranja (más grueso en página 1) + * Retorna la coordenada y donde empieza el contenido. + */ +export function drawSharedHeader(doc: DocLike, options: PdfHeaderOptions): number { + const pageW = doc.internal.pageSize.getWidth() + const contentRight = pageW - MARGIN + let y = MARGIN + + // Logo "InQ ROI" — naranja bold + doc.setFont('helvetica', 'bold') + doc.setFontSize(13) + doc.setTextColor(...PDF_COLORS.inqOrange) + doc.text('InQ ROI', MARGIN, y) + + // Fecha — derecha, slate-400 + const { date } = formatSimulationTimestamp(options.simulatedAt) + doc.setFont('helvetica', 'normal') + doc.setFontSize(7.5) + doc.setTextColor(...PDF_COLORS.textDisabled) + doc.text(date, contentRight, y, { align: 'right' } as Record) + y += 7 + + // Nombre del proceso — bold slate-900 + doc.setFont('helvetica', 'bold') + doc.setFontSize(11) + doc.setTextColor(...PDF_COLORS.textPrimary) + doc.text(options.processName, MARGIN, y) + + // Cliente — derecha, slate-500 + if (options.clientName) { + doc.setFont('helvetica', 'normal') + doc.setFontSize(7.5) + doc.setTextColor(...PDF_COLORS.textTertiary) + doc.text(options.clientName, contentRight, y, { align: 'right' } as Record) + } + y += 5 + + // Subtítulo de sección (opcional) + if (options.sectionTitle) { + doc.setFont('helvetica', 'normal') + doc.setFontSize(8.5) + doc.setTextColor(...PDF_COLORS.textSecondary) + doc.text(options.sectionTitle, MARGIN, y) + y += 4.5 + } + + // Border-bottom naranja InQ: 0.8mm (≈3pt) en página 1, 0.5mm (≈2pt) en el resto + const borderWidth = options.pageNumber === 1 ? 0.8 : 0.5 + doc.setDrawColor(...PDF_COLORS.inqOrange) + doc.setLineWidth(borderWidth) + doc.line(MARGIN, y, contentRight, y) + y += 5 + + // Reset lineWidth para no afectar el contenido siguiente + doc.setLineWidth(0.2) + + return y +} + +// ─── Footer compartido ──────────────────────────────────────────────────────── + +export interface PdfFooterOptions { + clientName?: string + simulatedAt: number +} + +/** + * Dibuja el footer en la posición inferior fija de la página actual. + * - Border-top slate-200 + * - Izquierda: "InQ ROI · Powered by InQuality · [cliente] · [fecha]" + * - Derecha: "Página X de Y" + * No incluye versión del producto. + */ +export function drawSharedFooter( + doc: DocLike, + options: PdfFooterOptions & { pageNumber: number; totalPages: number } +): void { + const pageW = doc.internal.pageSize.getWidth() + const pageH = doc.internal.pageSize.getHeight() + const contentRight = pageW - MARGIN + const footerLineY = pageH - 14 + const footerTextY = pageH - 9 + + // Border-top + doc.setDrawColor(...PDF_COLORS.borderLight) + doc.setLineWidth(0.2) + doc.line(MARGIN, footerLineY, contentRight, footerLineY) + + // Texto izquierda + const { date } = formatSimulationTimestamp(options.simulatedAt) + const parts: string[] = ['InQ ROI', 'Powered by InQuality'] + if (options.clientName) parts.push(options.clientName) + parts.push(date) + + doc.setFont('helvetica', 'normal') + doc.setFontSize(7) + doc.setTextColor(...PDF_COLORS.textDisabled) + doc.text(parts.join(' · '), MARGIN, footerTextY) + + // Paginación derecha + doc.text( + `Página ${options.pageNumber} de ${options.totalPages}`, + contentRight, + footerTextY, + { align: 'right' } as Record + ) +} + +/** + * Aplica footer a TODAS las páginas del documento. + * Llamar después de agregar todo el contenido. + */ +export function applyFootersToAllPages(doc: DocLike, options: PdfFooterOptions): void { + const total = doc.internal.getNumberOfPages() + for (let i = 1; i <= total; i++) { + doc.setPage(i) + drawSharedFooter(doc, { ...options, pageNumber: i, totalPages: total }) + } +} diff --git a/src/lib/export/pdf-sections.ts b/src/lib/export/pdf-sections.ts index 73945d7..9feac4a 100644 --- a/src/lib/export/pdf-sections.ts +++ b/src/lib/export/pdf-sections.ts @@ -1,12 +1,17 @@ import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format' import type { SimulationResult, Process } from '@/domain/types' +import { PDF_COLORS, type DocLike } from './pdf-sections-shared' + +// Re-exportar DocLike para compatibilidad de imports existentes (pdf-sections-roi, tests) +export type { DocLike } from './pdf-sections-shared' // Unidades: mm. A4 = 210 × 297mm, márgenes 20mm, área útil 170 × 257mm +// PDF.blue eliminado en Etapa 4 — usar PDF_COLORS.inqOrange del módulo compartido export const PDF = { pageW: 210, pageH: 297, margin: 20, contentW: 170, - blue: [30, 64, 175] as [number, number, number], + inqOrange: PDF_COLORS.inqOrange, slate900: [15, 23, 42] as [number, number, number], slate700: [51, 65, 85] as [number, number, number], slate500: [100, 116, 139] as [number, number, number], @@ -17,25 +22,6 @@ export const PDF = { heatHigh: [239, 68, 68] as [number, number, number], } -// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo) -export type DocLike = { - setFont: (name: string, style: string) => void - setFontSize: (size: number) => void - setTextColor: (...args: number[]) => void - setFillColor: (...args: number[]) => void - setDrawColor: (...args: number[]) => void - setLineWidth: (w: number) => void - text: (text: string | string[], x: number, y: number, opts?: Record) => void - rect: (x: number, y: number, w: number, h: number, style?: string) => void - roundedRect: (x: number, y: number, w: number, h: number, rx: number, ry: number, style?: string) => void - line: (x1: number, y1: number, x2: number, y2: number) => void - addImage: (img: string, format: string, x: number, y: number, w: number, h: number) => void - addPage: () => void - setPage: (page: number) => void - internal: { getNumberOfPages: () => number } - splitTextToSize: (text: string, maxWidth: number) => string[] -} - export function drawHeader(doc: DocLike, process: Process, simulatedAt: number): number { let y = PDF.margin @@ -109,7 +95,7 @@ export function drawKpiCards( // Valor grande doc.setFont('helvetica', 'bold') doc.setFontSize(card.value.length > 16 ? 9 : 12) - doc.setTextColor(card.accent ? PDF.blue[0] : PDF.slate900[0], card.accent ? PDF.blue[1] : PDF.slate900[1], card.accent ? PDF.blue[2] : PDF.slate900[2]) + doc.setTextColor(...(card.accent ? PDF.inqOrange : PDF.slate900)) doc.text(card.value, x + 4, y + 16) maxY = Math.max(maxY, y + cardH) diff --git a/tests/lib/export/pdf-export-roi.test.ts b/tests/lib/export/pdf-export-roi.test.ts index 2cd358e..167d5c7 100644 --- a/tests/lib/export/pdf-export-roi.test.ts +++ b/tests/lib/export/pdf-export-roi.test.ts @@ -24,7 +24,13 @@ const mockDoc = { setPage: vi.fn(), save: vi.fn(), splitTextToSize: vi.fn().mockImplementation((text: string) => [text]), - internal: { getNumberOfPages: vi.fn().mockReturnValue(4) }, + internal: { + getNumberOfPages: vi.fn().mockReturnValue(4), + pageSize: { + getWidth: vi.fn().mockReturnValue(210), + getHeight: vi.fn().mockReturnValue(297), + }, + }, } vi.mock('jspdf', () => { diff --git a/tests/lib/export/pdf-export.test.ts b/tests/lib/export/pdf-export.test.ts index f9e4115..7693980 100644 --- a/tests/lib/export/pdf-export.test.ts +++ b/tests/lib/export/pdf-export.test.ts @@ -23,7 +23,13 @@ const mockDoc = { setPage: vi.fn(), save: vi.fn(), splitTextToSize: vi.fn().mockImplementation((text: string) => [text]), - internal: { getNumberOfPages: vi.fn().mockReturnValue(3) }, + internal: { + getNumberOfPages: vi.fn().mockReturnValue(3), + pageSize: { + getWidth: vi.fn().mockReturnValue(210), + getHeight: vi.fn().mockReturnValue(297), + }, + }, } // Arrow functions no pueden ser constructoras — usar function() regular diff --git a/tests/lib/export/pdf-sections-shared.test.ts b/tests/lib/export/pdf-sections-shared.test.ts new file mode 100644 index 0000000..b27934c --- /dev/null +++ b/tests/lib/export/pdf-sections-shared.test.ts @@ -0,0 +1,148 @@ +/** + * Tests del módulo compartido de header/footer PDF — Etapa 4 Sprint 1.5. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { PDF_COLORS, drawSharedHeader, drawSharedFooter, applyFootersToAllPages } from '@/lib/export/pdf-sections-shared' + +// ─── Mock DocLike ───────────────────────────────────────────────────────────── + +function makeMockDoc(pageW = 210, pageH = 297, totalPages = 2) { + return { + setFont: vi.fn(), + setFontSize: vi.fn(), + setTextColor: vi.fn(), + setFillColor: vi.fn(), + setDrawColor: vi.fn(), + setLineWidth: vi.fn(), + text: vi.fn(), + rect: vi.fn(), + roundedRect: vi.fn(), + line: vi.fn(), + addImage: vi.fn(), + addPage: vi.fn(), + setPage: vi.fn(), + splitTextToSize: vi.fn().mockImplementation((t: string) => [t]), + internal: { + getNumberOfPages: vi.fn().mockReturnValue(totalPages), + pageSize: { + getWidth: vi.fn().mockReturnValue(pageW), + getHeight: vi.fn().mockReturnValue(pageH), + }, + }, + } +} + +const BASE_OPTS = { processName: 'Proceso de prueba', simulatedAt: new Date('2026-05-17T10:00:00Z').getTime() } + +// ─── PDF_COLORS ─────────────────────────────────────────────────────────────── + +describe('PDF_COLORS', () => { + it('expone inqOrange como array RGB válido [245, 152, 69]', () => { + expect(PDF_COLORS.inqOrange).toEqual([245, 152, 69]) + }) + + it('NO incluye colores de CONCILIA (#572FA2 → [87, 47, 162])', () => { + const all = JSON.stringify(PDF_COLORS) + expect(all).not.toContain('87, 47, 162') // #572FA2 morado CONCILIA + expect(all).not.toContain('203, 24, 137') // #CB1889 magenta CONCILIA + }) + + it('tiene al menos 8 colores definidos', () => { + expect(Object.keys(PDF_COLORS).length).toBeGreaterThanOrEqual(8) + }) +}) + +// ─── drawSharedHeader ───────────────────────────────────────────────────────── + +describe('drawSharedHeader', () => { + let doc: ReturnType + + beforeEach(() => { doc = makeMockDoc() }) + + it('retorna un número mayor que el margen (20mm) — espacio del header', () => { + const y = drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 1 }) + expect(y).toBeGreaterThan(20) + }) + + it('dibuja border-bottom más grueso en página 1 que en páginas siguientes', () => { + drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 1 }) + const page1LineWidths = doc.setLineWidth.mock.calls.map((c: number[]) => c[0]) + + doc.setLineWidth.mockClear() + drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 2 }) + const page2LineWidths = doc.setLineWidth.mock.calls.map((c: number[]) => c[0]) + + // El grosor máximo de página 1 debe ser mayor que el de página 2 + expect(Math.max(...page1LineWidths)).toBeGreaterThan(Math.max(...page2LineWidths)) + }) + + it('dibuja el texto "InQ ROI" en la primera llamada a text()', () => { + drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 1 }) + const firstTextCall = doc.text.mock.calls[0] + expect(firstTextCall[0]).toBe('InQ ROI') + }) + + it('usa inqOrange para el texto del logo', () => { + drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 1 }) + // La primera llamada a setTextColor debe ser el naranja InQ + const firstColor = doc.setTextColor.mock.calls[0] + expect(firstColor).toEqual(PDF_COLORS.inqOrange) + }) + + it('usa doc.internal.pageSize.getWidth() — compatible con landscape', () => { + const landscapeDoc = makeMockDoc(297, 210) + const y = drawSharedHeader(landscapeDoc, { ...BASE_OPTS, pageNumber: 1 }) + expect(landscapeDoc.internal.pageSize.getWidth).toHaveBeenCalled() + expect(y).toBeGreaterThan(20) + }) +}) + +// ─── drawSharedFooter ───────────────────────────────────────────────────────── + +describe('drawSharedFooter', () => { + let doc: ReturnType + + beforeEach(() => { doc = makeMockDoc() }) + + it('incluye "InQ ROI" en el texto del footer', () => { + drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 1, totalPages: 3 }) + const textCalls = doc.text.mock.calls.map((c: unknown[]) => c[0]) + expect(textCalls.some((t: unknown) => typeof t === 'string' && t.includes('InQ ROI'))).toBe(true) + }) + + it('incluye "Powered by InQuality" en el texto del footer', () => { + drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 1, totalPages: 3 }) + const textCalls = doc.text.mock.calls.map((c: unknown[]) => c[0]) + expect(textCalls.some((t: unknown) => typeof t === 'string' && t.includes('Powered by InQuality'))).toBe(true) + }) + + it('muestra paginación correcta "Página X de Y"', () => { + drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 2, totalPages: 4 }) + const textCalls = doc.text.mock.calls.map((c: unknown[]) => c[0]) + expect(textCalls.some((t: unknown) => typeof t === 'string' && t === 'Página 2 de 4')).toBe(true) + }) + + it('NO incluye versión del producto (v0.1.0)', () => { + drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 1, totalPages: 3 }) + const allText = doc.text.mock.calls.map((c: unknown[]) => c[0]).join(' ') + expect(allText).not.toContain('v0.') + expect(allText).not.toContain('v0.1') + }) + + it('usa doc.internal.pageSize.getHeight() para posición inferior', () => { + drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 1, totalPages: 3 }) + expect(doc.internal.pageSize.getHeight).toHaveBeenCalled() + }) +}) + +// ─── applyFootersToAllPages ─────────────────────────────────────────────────── + +describe('applyFootersToAllPages', () => { + it('llama setPage para cada página del documento', () => { + const doc = makeMockDoc(210, 297, 4) + applyFootersToAllPages(doc, BASE_OPTS) + expect(doc.setPage).toHaveBeenCalledTimes(4) + expect(doc.setPage).toHaveBeenNthCalledWith(1, 1) + expect(doc.setPage).toHaveBeenNthCalledWith(4, 4) + }) +})