"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:
14
.claude/settings.json
Normal file
14
.claude/settings.json
Normal 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
1
.gitignore
vendored
@@ -29,3 +29,4 @@ test-results/
|
|||||||
|
|
||||||
# Wrangler
|
# Wrangler
|
||||||
.wrangler/
|
.wrangler/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -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.
|
||||||
311
sprints/sprint-1-5/ETAPA_4_PROMPT.md
Normal file
311
sprints/sprint-1-5/ETAPA_4_PROMPT.md
Normal 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.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
191
src/lib/export/pdf-sections-shared.ts
Normal file
191
src/lib/export/pdf-sections-shared.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
148
tests/lib/export/pdf-sections-shared.test.ts
Normal file
148
tests/lib/export/pdf-sections-shared.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user