Resumen del fix:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

loadImageBase64 ahora retorna { base64, pixelWidth, pixelHeight } (320×210px tras el resize) en vez de solo el string.
PdfHeaderOptions agrega logoPixelWidth/logoPixelHeight.
drawSharedHeader: logo a 15mm de ancho con alto calculado proporcionalmente desde las dimensiones reales (≈9.8mm, ratio real ~1.53:1, no el 3.56:1 hardcodeado anterior). El logo se posiciona arriba (MARGIN - 1), y processName + fecha bajan a la línea siguiente sin superponerse. clientName baja una línea adicional cuando hay logo (para no chocar con la fecha), ajustando y antes del border-bottom.
Fallback a texto "InQ ROI" intacto cuando no hay logo.
This commit is contained in:
2026-06-24 12:16:25 -03:00
parent f47cf82abf
commit dc610f8a1e
4 changed files with 252 additions and 17 deletions

View File

@@ -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<string> {
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<void> {
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 `<span>` 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 `<span>`:
```tsx
// Buscar el span que tiene el valor formateado de la Eficiencia y agregar whitespace-nowrap
<span className="font-mono text-slate-700 text-xs w-14 text-right whitespace-nowrap">
{format(value)}
</span>
```
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.

View File

@@ -51,7 +51,7 @@ function MetricCell({
style={{ width: `${max > 0 ? (value / max) * 100 : 0}%` }} style={{ width: `${max > 0 ? (value / max) * 100 : 0}%` }}
/> />
</div> </div>
<span className="font-mono text-slate-700 text-xs w-14 text-right"> <span className="font-mono text-slate-700 text-xs w-14 text-right whitespace-nowrap">
{format(value)} {format(value)}
</span> </span>
</div> </div>

View File

@@ -12,9 +12,46 @@ import {
} from './pdf-sections-roi' } from './pdf-sections-roi'
import { drawSharedHeader, applyFootersToAllPages } from './pdf-sections-shared' import { drawSharedHeader, applyFootersToAllPages } from './pdf-sections-shared'
import type { Process, Simulation, Resource, Activity } from '@/domain/types' 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' 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<LoadedLogo> {
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 { export interface PdfExportParams {
process: Process process: Process
simulation: Simulation simulation: Simulation
@@ -35,13 +72,15 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }) const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
const logo = await loadImageBase64(logoColorUrl).catch(() => undefined)
if (tab === 'roi') { if (tab === 'roi') {
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency) await exportRoiPdf(doc, autoTable, process, simulation, activities, currency, logo)
} else { } else {
const result = tab === 'automatizado' const result = tab === 'automatizado'
? (simulation.resultAutomated ?? simulation.result) ? (simulation.resultAutomated ?? simulation.result)
: 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' const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
@@ -62,7 +101,8 @@ async function exportScenarioPdf(
heatmapImageData: string | null, heatmapImageData: string | null,
currency: string, currency: string,
tab: 'actual' | 'automatizado', tab: 'actual' | 'automatizado',
activities: Activity[] = [] activities: Activity[] = [],
logo?: LoadedLogo
): Promise<void> { ): Promise<void> {
const docAny = doc as any const docAny = doc as any
const scenarioLabel = tab === 'actual' ? 'Escenario actual' : 'Escenario automatizado' const scenarioLabel = tab === 'actual' ? 'Escenario actual' : 'Escenario automatizado'
@@ -75,7 +115,14 @@ async function exportScenarioPdf(
keywords: 'bpmn, costos, simulación', 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) ──────── // ── Página 1 (portrait): Header denso + metadata + KPI grid (Etapa 8) ────────
drawScenarioPage1(docAny, tab, process, simulation.executedAt, result, currency) drawScenarioPage1(docAny, tab, process, simulation.executedAt, result, currency)
@@ -164,7 +211,8 @@ async function exportRoiPdf(
process: Process, process: Process,
simulation: Simulation, simulation: Simulation,
activities: Activity[], activities: Activity[],
currency: string currency: string,
logo?: LoadedLogo
): Promise<void> { ): Promise<void> {
const result = simulation.result const result = simulation.result
const resultAutomated = simulation.resultAutomated ?? result const resultAutomated = simulation.resultAutomated ?? result
@@ -185,7 +233,14 @@ 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 } 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) ─────────────────────────────── // ── Página 1: Rediseño completo InQ (Etapa 6) ───────────────────────────────
drawRoiPage1(doc, process, roi, simulation.executedAt, result.perActivity, resultAutomated.perActivity, currency) drawRoiPage1(doc, process, roi, simulation.executedAt, result.perActivity, resultAutomated.perActivity, currency)

View File

@@ -64,11 +64,14 @@ export interface PdfHeaderOptions {
sectionTitle?: string // subtítulo de sección, ej. "Análisis de retorno de inversión" sectionTitle?: string // subtítulo de sección, ej. "Análisis de retorno de inversión"
clientName?: string clientName?: string
simulatedAt: number // Unix timestamp ms 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. * 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 * - Nombre del proceso + cliente
* - Border-bottom naranja (más grueso en página 1) * - Border-bottom naranja (más grueso en página 1)
* Retorna la coordenada y donde empieza el contenido. * 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 pageW = doc.internal.pageSize.getWidth()
const contentRight = pageW - MARGIN const contentRight = pageW - MARGIN
let y = MARGIN let y = MARGIN
const hasLogo = Boolean(options.logoBase64)
// Logo "InQ ROI" — naranja bold if (hasLogo) {
doc.setFont('helvetica', 'bold') // Logo compacto arriba a la izquierda, alineado con el margen. Ancho 15mm con
doc.setFontSize(13) // alto proporcional calculado desde las dimensiones reales del bitmap — el PNG
doc.setTextColor(...PDF_COLORS.inqOrange) // fuente es 724×474px (ratio ~1.53:1), no 3.56:1 como asumía el hardcode anterior
doc.text('InQ ROI', MARGIN, y) // 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) const { date } = formatSimulationTimestamp(options.simulatedAt)
doc.setFont('helvetica', 'normal') doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5) doc.setFontSize(7.5)
doc.setTextColor(...PDF_COLORS.textDisabled) doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text(date, contentRight, y, { align: 'right' } as Record<string, unknown>) doc.text(date, contentRight, y, { align: 'right' } as Record<string, unknown>)
y += 7
if (!hasLogo) y += 7
// Nombre del proceso — bold slate-900 // Nombre del proceso — bold slate-900
doc.setFont('helvetica', 'bold') doc.setFont('helvetica', 'bold')
@@ -98,12 +118,16 @@ export function drawSharedHeader(doc: DocLike, options: PdfHeaderOptions): numbe
doc.setTextColor(...PDF_COLORS.textPrimary) doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(options.processName, MARGIN, y) 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) { if (options.clientName) {
const clientY = hasLogo ? y + 5 : y
doc.setFont('helvetica', 'normal') doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5) doc.setFontSize(7.5)
doc.setTextColor(...PDF_COLORS.textTertiary) doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text(options.clientName, contentRight, y, { align: 'right' } as Record<string, unknown>) doc.text(options.clientName, contentRight, clientY, { align: 'right' } as Record<string, unknown>)
if (hasLogo) y = clientY
} }
y += 5 y += 5