diff --git a/sprints/sprint-6/ETAPA_1_PROMPT.md b/sprints/sprint-6/ETAPA_1_PROMPT.md new file mode 100644 index 0000000..71a0ba0 --- /dev/null +++ b/sprints/sprint-6/ETAPA_1_PROMPT.md @@ -0,0 +1,156 @@ +# Etapa 1 — Logo InQuality en header de PDFs + whitespace-nowrap + +**Sprint:** 6 +**Fecha:** 2026-06-23 +**Alcance:** 3 PDFs (Actual, Automatizado, ROI) + un fix de una línea en ComparisonTable. + +--- + +## Contexto + +El header compartido de los 3 PDFs (`src/lib/export/pdf-sections-shared.ts`, función `drawSharedHeader`) hoy dibuja el texto "InQ ROI" en naranja bold como identidad de marca. El logo PNG de InQuality ya existe en `assets/`. El objetivo es reemplazar el texto por el logo, manteniendo el layout del header sin cambios estructurales. + +--- + +## Parte A — Logo en header de PDFs + +### Assets disponibles + +En `assets/` (ruta desde la raíz del proyecto): +- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png` — logo horizontal color, sobre fondos claros — **usar este** +- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png` — logo horizontal blanco (no aplica, el header es blanco) + +### Cómo cargar el PNG en jsPDF + +Importar la URL del asset con Vite: + +```ts +// En pdf-export.ts (o en un helper de carga) +import logoColorUrl from '../../../assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png?url' +``` + +Convertir a base64 antes de generar cualquier PDF (operación async, una vez por export): + +```ts +async function loadImageBase64(url: string): Promise { + const response = await fetch(url) + const blob = await response.blob() + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(blob) + }) +} +``` + +Llamar una vez al inicio de cada función de export (`exportPdf`, `exportRoiPdf`) y pasar el resultado como parámetro. + +### Cambios en `PdfHeaderOptions` + +Agregar un campo opcional: + +```ts +export interface PdfHeaderOptions { + pageNumber: number + processName: string + sectionTitle?: string + clientName?: string + simulatedAt: number + logoBase64?: string // ← nuevo, opcional para retrocompatibilidad +} +``` + +### Cambios en `drawSharedHeader` + +Reemplazar el bloque de texto "InQ ROI" por lógica condicional: + +```ts +// ANTES: +doc.setFont('helvetica', 'bold') +doc.setFontSize(13) +doc.setTextColor(...PDF_COLORS.inqOrange) +doc.text('InQ ROI', MARGIN, y) + +// DESPUÉS: +if (options.logoBase64) { + // Logo PNG: ancho 32mm, alto proporcional (~9mm para este logo horizontal) + // Alinear el baseline del logo con la primera línea de texto: y - 7 (arriba del baseline) + doc.addImage(options.logoBase64, 'PNG', MARGIN, y - 7, 32, 9) +} else { + // Fallback al texto (retrocompatibilidad y tests) + doc.setFont('helvetica', 'bold') + doc.setFontSize(13) + doc.setTextColor(...PDF_COLORS.inqOrange) + doc.text('InQ ROI', MARGIN, y) +} +``` + +**Nota sobre dimensiones:** el logo horizontal de InQuality tiene una proporción aproximada de 3.5:1 (ancho:alto). A 32mm de ancho → ~9mm de alto. Ajustar si el resultado visual no queda bien — la validación visual del director confirma antes del OK formal. + +### Cambios en `pdf-export.ts` + +En las dos funciones de export (`exportPdf` y `exportRoiPdf`), agregar la carga del logo al inicio: + +```ts +export async function exportPdf(...): Promise { + const logoBase64 = await loadImageBase64(logoColorUrl).catch(() => undefined) + + const headerOpts: PdfHeaderOptions = { + // ...opciones existentes... + logoBase64, + } + // resto sin cambios +} +``` + +El `.catch(() => undefined)` garantiza que si el fetch del asset falla (ej: entorno sin red), el PDF se genera igual con el fallback de texto. No bloquear el export por el logo. + +### Importante — NO cambiar + +- La estructura del header (posiciones de proceso, cliente, fecha, border-bottom) no cambia. +- El footer ("Powered by InQuality") no cambia. +- `drawSharedFooter` y `applyFootersToAllPages` no se tocan. +- No agregar el logo en páginas 1 de cada PDF que tengan su propio diseño de portada (verificar si `drawScenarioPage1` y `drawRoiPage1` usan o no `drawSharedHeader` — si no lo usan, el logo solo aplica a las páginas que sí lo llaman). + +--- + +## Parte B — whitespace-nowrap en celda de Eficiencia + +En `src/features/report/ComparisonTable.tsx`, dentro de `MetricCell`, el `` que muestra el valor numérico de la columna "Eficiencia" se corta en dos líneas ("1.02" / "USD/min") porque la celda es angosta. + +Fix: agregar `whitespace-nowrap` al className del ``: + +```tsx +// Buscar el span que tiene el valor formateado de la Eficiencia y agregar whitespace-nowrap + + {format(value)} + +``` + +Una línea de cambio. Sin tests que actualizar. + +--- + +## Criterios de validación + +- [ ] Logo visible en el header de todas las páginas de los 3 PDFs (Actual, Automatizado, ROI) +- [ ] Logo en tamaño coherente con el header — no dominante, no microscópico +- [ ] Alineación correcta: logo a la izquierda, fecha y cliente a la derecha +- [ ] El footer "Powered by InQuality" sigue presente en todas las páginas +- [ ] Si el asset no carga (fetch falla), el PDF se genera igual con texto "InQ ROI" como fallback +- [ ] "1.02 USD/min" aparece en una sola línea en ComparisonTable +- [ ] `npm run lint` sin errores +- [ ] `npm run build` limpio +- [ ] `npm run test` → 542 tests verdes (sin regresiones — los tests de PDF existentes usan mocks y no verifican el logo) +- [ ] **Validación visual del director sobre el PDF exportado antes del OK formal** — esta es la validación principal de la etapa + +--- + +## Archivos a modificar + +1. `src/lib/export/pdf-sections-shared.ts` — `PdfHeaderOptions` + `drawSharedHeader` +2. `src/lib/export/pdf-export.ts` — carga del logo + pasar `logoBase64` a `headerOpts` +3. `src/features/report/ComparisonTable.tsx` — `whitespace-nowrap` en `MetricCell` + +No modificar ningún otro archivo. En particular, no tocar los tests de PDF existentes — el fallback de texto garantiza que sigan pasando sin cambios. diff --git a/src/features/report/ComparisonTable.tsx b/src/features/report/ComparisonTable.tsx index 7121a87..3872e45 100644 --- a/src/features/report/ComparisonTable.tsx +++ b/src/features/report/ComparisonTable.tsx @@ -51,7 +51,7 @@ function MetricCell({ style={{ width: `${max > 0 ? (value / max) * 100 : 0}%` }} /> - + {format(value)} diff --git a/src/lib/export/pdf-export.ts b/src/lib/export/pdf-export.ts index 2cf0258..63ad453 100644 --- a/src/lib/export/pdf-export.ts +++ b/src/lib/export/pdf-export.ts @@ -12,9 +12,46 @@ import { } from './pdf-sections-roi' import { drawSharedHeader, applyFootersToAllPages } from './pdf-sections-shared' import type { Process, Simulation, Resource, Activity } from '@/domain/types' +import logoColorUrl from '../../../assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png?url' export type ReportTab = 'actual' | 'automatizado' | 'roi' +interface LoadedLogo { + base64: string + pixelWidth: number + pixelHeight: number +} + +// Convierte el logo a base64 para incrustarlo en el PDF vía doc.addImage(). +// Si el fetch falla (ej. entorno sin red), el caller usa .catch(() => undefined) +// y drawSharedHeader cae al fallback de texto "InQ ROI" — el export nunca se bloquea por esto. +// +// Redimensiona antes de convertir: el asset fuente es 724×474px, pero en el PDF se +// usa a ~15mm de ancho. jsPDF embebe PNG con canal alpha como bitmap RGBA sin +// recomprimir (no reutiliza la compresión del PNG original) — sin este resize, cada +// export engordaba ~1.3MB (de ~60KB a ~1.4MB) solo por el logo. 320px de ancho es +// suficiente para nitidez en impresión a ese tamaño físico. +// +// Retorna las dimensiones en píxeles del bitmap resultante junto al base64: drawSharedHeader +// las necesita para calcular el alto proporcional real (724×474px ≈ ratio 1.53:1, no 3.56:1 +// como asumía el hardcode original de 32×9mm — eso aplastaba el logo horizontalmente). +async function loadImageBase64(url: string): Promise { + const response = await fetch(url) + const blob = await response.blob() + const bitmap = await createImageBitmap(blob) + + const targetW = 320 + const targetH = Math.round(targetW * (bitmap.height / bitmap.width)) + const canvas = document.createElement('canvas') + canvas.width = targetW + canvas.height = targetH + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('No se pudo obtener contexto 2D del canvas') + ctx.drawImage(bitmap, 0, 0, targetW, targetH) + + return { base64: canvas.toDataURL('image/png'), pixelWidth: targetW, pixelHeight: targetH } +} + export interface PdfExportParams { process: Process simulation: Simulation @@ -35,13 +72,15 @@ export async function exportToPdf(params: PdfExportParams): Promise { const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }) + const logo = await loadImageBase64(logoColorUrl).catch(() => undefined) + if (tab === 'roi') { - await exportRoiPdf(doc, autoTable, process, simulation, activities, currency) + await exportRoiPdf(doc, autoTable, process, simulation, activities, currency, logo) } else { const result = tab === 'automatizado' ? (simulation.resultAutomated ?? simulation.result) : simulation.result - await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities) + await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities, logo) } const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual' @@ -62,7 +101,8 @@ async function exportScenarioPdf( heatmapImageData: string | null, currency: string, tab: 'actual' | 'automatizado', - activities: Activity[] = [] + activities: Activity[] = [], + logo?: LoadedLogo ): Promise { const docAny = doc as any const scenarioLabel = tab === 'actual' ? 'Escenario actual' : 'Escenario automatizado' @@ -75,7 +115,14 @@ async function exportScenarioPdf( keywords: 'bpmn, costos, simulación', }) - const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt } + const headerOpts = { + processName: process.name, + clientName: process.clientName || undefined, + simulatedAt: simulation.executedAt, + logoBase64: logo?.base64, + logoPixelWidth: logo?.pixelWidth, + logoPixelHeight: logo?.pixelHeight, + } // ── Página 1 (portrait): Header denso + metadata + KPI grid (Etapa 8) ──────── drawScenarioPage1(docAny, tab, process, simulation.executedAt, result, currency) @@ -164,7 +211,8 @@ async function exportRoiPdf( process: Process, simulation: Simulation, activities: Activity[], - currency: string + currency: string, + logo?: LoadedLogo ): Promise { const result = simulation.result const resultAutomated = simulation.resultAutomated ?? result @@ -185,7 +233,14 @@ async function exportRoiPdf( keywords: 'bpmn, roi, automatización, payback', }) - const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt } + const roiHeaderOpts = { + processName: process.name, + clientName: process.clientName || undefined, + simulatedAt: simulation.executedAt, + logoBase64: logo?.base64, + logoPixelWidth: logo?.pixelWidth, + logoPixelHeight: logo?.pixelHeight, + } // ── Página 1: Rediseño completo InQ (Etapa 6) ─────────────────────────────── drawRoiPage1(doc, process, roi, simulation.executedAt, result.perActivity, resultAutomated.perActivity, currency) diff --git a/src/lib/export/pdf-sections-shared.ts b/src/lib/export/pdf-sections-shared.ts index d6d3b2e..f76714d 100644 --- a/src/lib/export/pdf-sections-shared.ts +++ b/src/lib/export/pdf-sections-shared.ts @@ -64,11 +64,14 @@ export interface PdfHeaderOptions { sectionTitle?: string // subtítulo de sección, ej. "Análisis de retorno de inversión" clientName?: string simulatedAt: number // Unix timestamp ms + logoBase64?: string // logo PNG de InQuality, opcional — fallback a texto "InQ ROI" si no se provee + logoPixelWidth?: number // dimensiones reales del bitmap — para calcular el alto proporcional sin distorsión + logoPixelHeight?: number } /** * Dibuja el header InQ-branded. - * - Logo "InQ ROI" naranja a la izquierda, fecha a la derecha + * - Logo InQuality (o texto "InQ ROI" naranja como fallback) 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. @@ -77,20 +80,37 @@ export function drawSharedHeader(doc: DocLike, options: PdfHeaderOptions): numbe const pageW = doc.internal.pageSize.getWidth() const contentRight = pageW - MARGIN let y = MARGIN + const hasLogo = Boolean(options.logoBase64) - // Logo "InQ ROI" — naranja bold - doc.setFont('helvetica', 'bold') - doc.setFontSize(13) - doc.setTextColor(...PDF_COLORS.inqOrange) - doc.text('InQ ROI', MARGIN, y) + if (hasLogo) { + // Logo compacto arriba a la izquierda, alineado con el margen. Ancho 15mm con + // alto proporcional calculado desde las dimensiones reales del bitmap — el PNG + // fuente es 724×474px (ratio ~1.53:1), no 3.56:1 como asumía el hardcode anterior + // de 32×9mm (eso aplastaba el logo horizontalmente). + const LOGO_WIDTH_MM = 15 + const logoHeightMM = options.logoPixelWidth && options.logoPixelHeight + ? LOGO_WIDTH_MM * (options.logoPixelHeight / options.logoPixelWidth) + : 8 + doc.addImage(options.logoBase64 as string, 'PNG', MARGIN, MARGIN - 1, LOGO_WIDTH_MM, logoHeightMM) + y = MARGIN + logoHeightMM + 2 + } else { + // Fallback al texto — retrocompatibilidad y tests (logo no disponible, ej. fetch falló) + doc.setFont('helvetica', 'bold') + doc.setFontSize(13) + doc.setTextColor(...PDF_COLORS.inqOrange) + doc.text('InQ ROI', MARGIN, y) + } - // Fecha — derecha, slate-400 + // Fecha — derecha, slate-400. Sin logo comparte línea con el texto "InQ ROI" (layout + // original). Con logo, el logo ya desplazó el bloque hacia abajo — la fecha cae en + // la misma línea que el nombre del proceso, debajo del logo. 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 + + if (!hasLogo) y += 7 // Nombre del proceso — bold slate-900 doc.setFont('helvetica', 'bold') @@ -98,12 +118,16 @@ export function drawSharedHeader(doc: DocLike, options: PdfHeaderOptions): numbe doc.setTextColor(...PDF_COLORS.textPrimary) doc.text(options.processName, MARGIN, y) - // Cliente — derecha, slate-500 + // Cliente — derecha, slate-500. Sin logo comparte línea con el proceso (layout + // original). Con logo, esa línea ya tiene la fecha — el cliente baja una línea para + // no superponerse, y el border-bottom se ajusta para no pisarlo. if (options.clientName) { + const clientY = hasLogo ? y + 5 : y doc.setFont('helvetica', 'normal') doc.setFontSize(7.5) doc.setTextColor(...PDF_COLORS.textTertiary) - doc.text(options.clientName, contentRight, y, { align: 'right' } as Record) + doc.text(options.clientName, contentRight, clientY, { align: 'right' } as Record) + if (hasLogo) y = clientY } y += 5