"chore(claude-code): configurar permisos compartidos del proyecto

- 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)
This commit is contained in:
2026-05-17 07:49:00 -03:00
parent 5ef385c4a3
commit 63c4fbac99
11 changed files with 718 additions and 36 deletions

14
.claude/settings.json Normal file
View File

@@ -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 *)"
]
}
}

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ test-results/
# Wrangler # Wrangler
.wrangler/ .wrangler/
.claude/settings.local.json

View File

@@ -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, - Cambios de marca, arquitectura o datos (paleta, dependencias,
estructura, features nuevas): CONSULTAR ANTES DE PROCEDER 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" Ver methodology/PRINCIPLES.md sección "Política de iniciativa agéntica"
para detalle completo. para detalle completo.

View File

@@ -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.

View File

@@ -1,13 +1,14 @@
import { buildFileName } from './slug' import { buildFileName } from './slug'
import { calculateRoi } from '@/domain/roi' import { calculateRoi } from '@/domain/roi'
import { import {
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF, drawAnalysisSection, drawMethodologySection, PDF,
} from './pdf-sections' } from './pdf-sections'
import { import {
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig, drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
drawRoiAnalysisPage, drawRoiMethodologySection, drawRoiAnalysisPage, drawRoiMethodologySection,
} from './pdf-sections-roi' } from './pdf-sections-roi'
import { drawSharedHeader, applyFootersToAllPages, PDF_COLORS } from './pdf-sections-shared'
import type { Process, Simulation, Resource, Activity } from '@/domain/types' import type { Process, Simulation, Resource, Activity } from '@/domain/types'
export type ReportTab = 'actual' | 'automatizado' | 'roi' export type ReportTab = 'actual' | 'automatizado' | 'roi'
@@ -69,7 +70,10 @@ async function exportScenarioPdf(
keywords: 'bpmn, costos, simulación', 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 = drawKpiCards(docAny, result, currency, y)
y += 4 y += 4
@@ -83,8 +87,9 @@ async function exportScenarioPdf(
y = drawHeatmapLegend(docAny, y) y = drawHeatmapLegend(docAny, y)
y += 4 y += 4
// Página 2 — análisis + tabla de costos
docAny.addPage() docAny.addPage()
y = PDF.margin y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
y = drawAnalysisSection(docAny, result, currency, y) y = drawAnalysisSection(docAny, result, currency, y)
y += 6 y += 6
@@ -117,14 +122,16 @@ async function exportScenarioPdf(
startY: y, startY: y,
margin: { left: PDF.margin, right: PDF.margin }, margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' }, 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 }, alternateRowStyles: { fillColor: PDF.slate50 },
columnStyles: { 0: { cellWidth: 48 } }, columnStyles: { 0: { cellWidth: 48 } },
}) })
// Página 3 — nota metodológica
docAny.addPage() docAny.addPage()
drawMethodologySection(docAny, process, PDF.margin) y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
addPageNumbers(docAny, 'InQ ROI · Powered by InQuality', simulation.executedAt) drawMethodologySection(docAny, process, y)
applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
} }
// ─── ROI — 4 páginas ────────────────────────────────────────────────────────── // ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
@@ -157,14 +164,16 @@ async function exportRoiPdf(
keywords: 'bpmn, roi, automatización, payback', 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 ───────────────────── // ── 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 = drawRoiExecutiveSummary(doc, process, roi, currency, y)
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y) y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
// ── Página 2: Tabla comparativa ──────────────────────────────────────────── // ── Página 2: Tabla comparativa ────────────────────────────────────────────
doc.addPage() doc.addPage()
y = PDF.margin y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
doc.setFont('helvetica', 'bold') doc.setFont('helvetica', 'bold')
doc.setFontSize(12) doc.setFontSize(12)
@@ -189,14 +198,15 @@ async function exportRoiPdf(
// ── Página 3: Análisis textual + transparencia ───────────────────────────── // ── Página 3: Análisis textual + transparencia ─────────────────────────────
doc.addPage() doc.addPage()
y = PDF.margin y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 3 })
drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y) drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y)
// ── Página 4: Metodología ────────────────────────────────────────────────── // ── Página 4: Metodología ──────────────────────────────────────────────────
doc.addPage() 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 { function formatNum(n: number): string {

View File

@@ -2,6 +2,7 @@ import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
import type { ActivitySimResult, Process, Activity } from '@/domain/types' import type { ActivitySimResult, Process, Activity } from '@/domain/types'
import type { RoiResult } from '@/domain/roi' import type { RoiResult } from '@/domain/roi'
import { PDF, type DocLike } from './pdf-sections' import { PDF, type DocLike } from './pdf-sections'
import { PDF_COLORS } from './pdf-sections-shared'
const ROI_HIGH_THRESHOLD = 1000 const ROI_HIGH_THRESHOLD = 1000
@@ -194,7 +195,7 @@ export function buildComparisonTableConfig(
startY, startY,
margin: { left: PDF.margin, right: PDF.margin }, margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' }, 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 } }, columnStyles: { 0: { cellWidth: 42 } },
} }
} }

View File

@@ -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<string, unknown>) => 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<string, unknown>)
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<string, unknown>)
}
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<string, unknown>
)
}
/**
* 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 })
}
}

View File

@@ -1,12 +1,17 @@
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format' import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
import type { SimulationResult, Process } from '@/domain/types' 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 // 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 = { export const PDF = {
pageW: 210, pageH: 297, pageW: 210, pageH: 297,
margin: 20, margin: 20,
contentW: 170, contentW: 170,
blue: [30, 64, 175] as [number, number, number], inqOrange: PDF_COLORS.inqOrange,
slate900: [15, 23, 42] as [number, number, number], slate900: [15, 23, 42] as [number, number, number],
slate700: [51, 65, 85] as [number, number, number], slate700: [51, 65, 85] as [number, number, number],
slate500: [100, 116, 139] 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], 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<string, unknown>) => 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 { export function drawHeader(doc: DocLike, process: Process, simulatedAt: number): number {
let y = PDF.margin let y = PDF.margin
@@ -109,7 +95,7 @@ export function drawKpiCards(
// Valor grande // Valor grande
doc.setFont('helvetica', 'bold') doc.setFont('helvetica', 'bold')
doc.setFontSize(card.value.length > 16 ? 9 : 12) 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) doc.text(card.value, x + 4, y + 16)
maxY = Math.max(maxY, y + cardH) maxY = Math.max(maxY, y + cardH)

View File

@@ -24,7 +24,13 @@ const mockDoc = {
setPage: vi.fn(), setPage: vi.fn(),
save: vi.fn(), save: vi.fn(),
splitTextToSize: vi.fn().mockImplementation((text: string) => [text]), 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', () => { vi.mock('jspdf', () => {

View File

@@ -23,7 +23,13 @@ const mockDoc = {
setPage: vi.fn(), setPage: vi.fn(),
save: vi.fn(), save: vi.fn(),
splitTextToSize: vi.fn().mockImplementation((text: string) => [text]), 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 // Arrow functions no pueden ser constructoras — usar function() regular

View File

@@ -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<typeof makeMockDoc>
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<typeof makeMockDoc>
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)
})
})