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:
@@ -51,7 +51,7 @@ function MetricCell({
|
||||
style={{ width: `${max > 0 ? (value / max) * 100 : 0}%` }}
|
||||
/>
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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<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 {
|
||||
process: Process
|
||||
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 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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
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<string, unknown>)
|
||||
doc.text(options.clientName, contentRight, clientY, { align: 'right' } as Record<string, unknown>)
|
||||
if (hasLogo) y = clientY
|
||||
}
|
||||
y += 5
|
||||
|
||||
|
||||
Reference in New Issue
Block a user