Files
inq-roi-simulador-web/src/lib/export/pdf-sections.ts
Marcos Benítez a65bec45c3 feat(sprint-5/etapa-3): heatmap gradiente 4 stops + modo percentil + localStorage + fix zoom
- colors.ts: gradiente continuo verde→amarillo→naranja→rojo (interpolación N-stops genérica)
- Renombra modos: relative→percentile (rank-based), absolute→minmax
- Hook useHeatmapMode(): persiste selección en localStorage con clave inq-roi:heatmap-mode
- HeatmapLegend: barra de 4 stops, labels P0/P50/P100 en modo percentil
- HeatmapModeToggle: etiquetas "Percentil" / "Min-max"
- pdf-sections.ts: leyenda PDF con 4 stops interpolados
- BpmnCanvas: controles de zoom movidos a esquina inferior izquierda (evita logo bpmn.io)
- Tests: 528 unitarios pasan; E2E recalibrados con vecino más cercano (distancia euclidiana)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:28:10 -03:00

626 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
import type { SimulationResult, Process, ActivitySimResult, Resource, Activity } 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'
// Colores locales para página 1 de PDFs Actual/Automatizado (sin gradiente — Etapa 8)
const _SC = {
orange: [245, 152, 69] as [number, number, number], // #F59845
orangeLight: [254, 243, 226] as [number, number, number], // #FEF3E2
orangeDarker: [146, 64, 14] as [number, number, number], // #92400E
orangeDark: [180, 83, 9] as [number, number, number], // #B45309
metaBg: [250, 251, 252] as [number, number, number], // #FAFBFC
}
// 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 = {
pageW: 210, pageH: 297,
margin: 20,
contentW: 170,
inqOrange: PDF_COLORS.inqOrange,
slate900: [15, 23, 42] as [number, number, number],
slate700: [51, 65, 85] as [number, number, number],
slate500: [100, 116, 139] as [number, number, number],
slate200: [226, 232, 240] as [number, number, number],
slate50: [248, 250, 252] as [number, number, number],
heatLow: [16, 185, 129] as [number, number, number],
heatMid: [245, 158, 11] as [number, number, number],
heatHighMid: [249, 115, 22] as [number, number, number],
heatHigh: [239, 68, 68] as [number, number, number],
}
export function drawHeader(doc: DocLike, process: Process, simulatedAt: number): number {
let y = PDF.margin
// Nombre del proceso
doc.setFont('helvetica', 'bold')
doc.setFontSize(20)
doc.setTextColor(...PDF.slate900)
doc.text(process.name, PDF.margin, y)
y += 8
// Cliente + fecha + moneda
const { date, time } = formatSimulationTimestamp(simulatedAt)
const subtitle = [
process.clientName ? `Cliente: ${process.clientName}` : null,
`Simulado el ${date} a las ${time}`,
`Moneda: ${process.currency}`,
].filter(Boolean).join(' · ')
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate500)
doc.text(subtitle, PDF.margin, y)
y += 5
// Línea separadora
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.3)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 5
return y
}
export function drawKpiCards(
doc: DocLike,
result: SimulationResult,
currency: string,
startY: number
): number {
const cardW = 80, cardH = 24, gap = 10
const col1 = PDF.margin, col2 = PDF.margin + cardW + gap
const cards = [
{ label: 'COSTO TOTAL ESPERADO', value: formatCurrency(result.totalCost, currency), accent: true },
{ label: 'COSTO DIRECTO / OVERHEAD', value: `${formatCurrency(result.totalDirectCost, currency)} / ${formatCurrency(result.totalIndirectCost, currency)}`, accent: false },
{ label: 'TIEMPO TOTAL ESPERADO', value: formatMinutes(result.totalTimeMinutes), accent: false },
{ label: 'ACTIVIDADES COSTEADAS', value: String(result.perActivity.length), accent: false },
]
let maxY = startY
cards.forEach((card, idx) => {
const x = idx % 2 === 0 ? col1 : col2
const y = idx < 2 ? startY : startY + cardH + 4
// Fondo
doc.setFillColor(...PDF.slate50)
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F')
// Borde
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'S')
// Label pequeño
doc.setFont('helvetica', 'normal')
doc.setFontSize(6.5)
doc.setTextColor(...PDF.slate500)
doc.text(card.label, x + 4, y + 6)
// Valor grande
doc.setFont('helvetica', 'bold')
doc.setFontSize(card.value.length > 16 ? 9 : 12)
doc.setTextColor(...(card.accent ? PDF.inqOrange : PDF.slate900))
doc.text(card.value, x + 4, y + 16)
maxY = Math.max(maxY, y + cardH)
})
return maxY + 6
}
export function drawHeatmapImage(
doc: DocLike,
imageDataUrl: string | null,
startY: number
): number {
if (!imageDataUrl) return startY
// Usar dimensiones dinámicas — funciona en portrait (210mm) y landscape (297mm)
const pageW = doc.internal.pageSize.getWidth()
const pageH = doc.internal.pageSize.getHeight()
const imgW = pageW - 2 * PDF.margin
// Altura: proporción ~2.2:1 del canvas BPMN, limitada por espacio disponible hasta el footer
const maxH = pageH - startY - PDF.margin - 20 // 20mm reservado para footer
const imgH = Math.min(imgW / 2.2, maxH)
const x = PDF.margin
if (startY + imgH > pageH - PDF.margin - 10) {
doc.addPage()
startY = PDF.margin
}
// Borde sutil
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.rect(x, startY, imgW, imgH, 'S')
doc.addImage(imageDataUrl, 'JPEG', x, startY, imgW, imgH)
return startY + imgH + 4
}
export function drawHeatmapLegend(doc: DocLike, startY: number): number {
const pageW = doc.internal.pageSize.getWidth()
const x = PDF.margin, barW = pageW - 2 * PDF.margin, barH = 4
const labelY = startY + barH + 3.5
doc.setFontSize(7)
doc.setFont('helvetica', 'normal')
// Etiqueta izquierda
doc.setTextColor(...PDF.heatLow)
doc.text('Bajo costo', x, labelY)
// Barra de gradiente: dibujada como N segmentos de colores, 4 stops
// (mismo gradiente que src/lib/colors.ts — verde → amarillo → naranja → rojo)
const heatStops: Array<[number, [number, number, number]]> = [
[0.00, PDF.heatLow],
[0.33, PDF.heatMid],
[0.67, PDF.heatHighMid],
[1.00, PDF.heatHigh],
]
const steps = 40
const segW = barW / steps
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1)
let r = heatStops[heatStops.length - 1][1][0]
let g = heatStops[heatStops.length - 1][1][1]
let b = heatStops[heatStops.length - 1][1][2]
for (let s = 0; s < heatStops.length - 1; s++) {
const [t0, c0] = heatStops[s]
const [t1, c1] = heatStops[s + 1]
if (t <= t1) {
const lt = t1 === t0 ? 0 : (t - t0) / (t1 - t0)
r = Math.round(c0[0] + (c1[0] - c0[0]) * lt)
g = Math.round(c0[1] + (c1[1] - c0[1]) * lt)
b = Math.round(c0[2] + (c1[2] - c0[2]) * lt)
break
}
}
doc.setFillColor(r, g, b)
doc.rect(x + i * segW, startY, segW + 0.1, barH, 'F')
}
// Etiqueta derecha
doc.setTextColor(...PDF.heatHigh)
doc.text('Alto costo', x + barW, labelY, { align: 'right' } as any)
return labelY + 3
}
export function drawAnalysisSection(
doc: DocLike,
result: SimulationResult,
currency: string,
startY: number
): number {
let y = startY
const m = PDF.margin, cW = PDF.contentW
// Título uppercase naranja oscuro
doc.setFont('helvetica', 'bold')
doc.setFontSize(11)
doc.setTextColor(..._SC.orangeDarker)
doc.text('ANÁLISIS DE COMPOSICIÓN', m, y)
y += 5
doc.setDrawColor(..._SC.orange)
doc.setLineWidth(0.5)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 7
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
const directPct = result.totalCost > 0
? ((result.totalDirectCost / result.totalCost) * 100).toFixed(1) : '0'
const indirectPct = result.totalCost > 0
? ((result.totalIndirectCost / result.totalCost) * 100).toFixed(1) : '0'
const compositionText = `Composición del costo: ${directPct}% directo (${formatCurrency(result.totalDirectCost, currency)}) · ${indirectPct}% overhead (${formatCurrency(result.totalIndirectCost, currency)})`
const compositionLines = doc.splitTextToSize(compositionText, cW)
doc.text(compositionLines, m, y)
y += compositionLines.length * 4.5 + 4
const top3 = result.perActivity.slice(0, 3)
const top3Text = top3.map((a, i) => `${i + 1}. ${a.activityName} (${a.percentOfTotal.toFixed(1)}% - ${formatCurrency(a.expectedTotalCost, currency)})`).join(' ')
doc.setTextColor(...PDF_COLORS.textSecondary)
const top3Lines = doc.splitTextToSize(`Top 3 actividades por costo: ${top3Text}`, cW)
doc.text(top3Lines, m, y)
y += top3Lines.length * 4.5 + 2
if (result.perResource.length > 0) {
const topResource = [...result.perResource].sort((a, b) => b.totalCost - a.totalCost)[0]
const resourceLine = doc.splitTextToSize(
`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`,
cW
)
doc.text(resourceLine, m, y)
y += resourceLine.length * 4.5
} else {
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('Sin recursos asignados (costos fijos por actividad)', m, y)
y += 5
}
return y + 2
}
export function drawMethodologySection(
doc: DocLike,
process: Process,
startY: number
): number {
let y = startY
const m = PDF.margin, cW = PDF.contentW
const boxPad = 6
// Título uppercase naranja oscuro
doc.setFont('helvetica', 'bold')
doc.setFontSize(11)
doc.setTextColor(..._SC.orangeDarker)
doc.text('NOTA METODOLÓGICA', m, y)
y += 5
doc.setDrawColor(..._SC.orange)
doc.setLineWidth(0.5)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 8
// Caja contenedora slate-50 con border-left gris (mismo patrón que ROI Etapa 7)
const sections = [
{
title: 'Costo total:',
body: `Costo total = SUM(actividades) costo_directo_esperado + costo_indirecto_esperado.`,
},
{
title: 'Costo directo por actividad:',
body: `(costo_fijo + SUM(recurso x costo_hora x tiempo x utilizacion / 60)) x probabilidad_ejecucion.`,
},
{
title: 'Costo indirecto:',
body: `costo_directo_total x overhead_global (${(process.overheadPercentage * 100).toFixed(0)}%).`,
},
{
title: 'Probabilidades de ejecución:',
body: 'Calculadas mediante propagacion hacia adelante desde el StartEvent, considerando gateways XOR y AND. Modelo determinístico agregado.',
},
]
let boxHeight = boxPad * 2
for (const s of sections) {
const bodyLines = doc.splitTextToSize(s.body, cW - boxPad * 2 - 4)
boxHeight += 5 + bodyLines.length * 4.2 + 4
}
doc.setFillColor(248, 250, 252) // slate-50
doc.rect(m, y, cW, boxHeight, 'F')
doc.setFillColor(..._SC.metaBg)
doc.rect(m, y, 2, boxHeight, 'F')
let ty = y + boxPad
const contentX = m + boxPad + 3
for (const s of sections) {
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(s.title, contentX, ty)
ty += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
doc.setTextColor(...PDF_COLORS.textSecondary)
const lines = doc.splitTextToSize(s.body, cW - boxPad * 2 - 4)
doc.text(lines, contentX, ty)
ty += lines.length * 4.2 + 4
}
return y + boxHeight + 4
}
// ─── Página 1 Actual/Automatizado: header denso + metadata + KPI grid ─────────
// Sin gradiente — jerarquía visual: ROI es el reporte principal (Etapa 8).
export function drawScenarioPage1(
doc: DocLike,
tab: 'actual' | 'automatizado',
process: Process,
simulatedAt: number,
result: SimulationResult,
currency: string
): number {
const pageW = doc.internal.pageSize.getWidth()
const m = 20, cW = pageW - 2 * m
let y = m
y = _drawScenarioDenseHeader(doc, pageW, m, cW, tab, y)
y = _drawScenarioMetadataStrip(doc, process, simulatedAt, m, cW, y)
y = drawScenarioKpiGrid(doc, result, currency, m, cW, y)
return y
}
function _drawScenarioDenseHeader(
doc: DocLike, pageW: number, m: number, cW: number,
tab: 'actual' | 'automatizado', y: number
): number {
const right = pageW - m
const subtitle = tab === 'actual'
? 'ANÁLISIS DE COSTOS — ESCENARIO ACTUAL'
: 'ANÁLISIS DE COSTOS — ESCENARIO AUTOMATIZADO'
doc.setFont('helvetica', 'bold')
doc.setFontSize(22)
doc.setTextColor(..._SC.orange)
doc.text('InQ ROI', m, y)
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text('UN PRODUCTO DE', right, y, { align: 'right' } as Record<string, unknown>)
y += 9
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text(subtitle, m, y)
doc.setFont('helvetica', 'bold')
doc.setFontSize(16)
doc.setTextColor(...PDF_COLORS.textSecondary)
doc.text('InQ', right, y, { align: 'right' } as Record<string, unknown>)
y += 6
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('InQuality', right, y, { align: 'right' } as Record<string, unknown>)
y += 10
doc.setDrawColor(..._SC.orange)
doc.setLineWidth(0.8)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 10
return y
}
function _drawScenarioMetadataStrip(
doc: DocLike, process: Process, simulatedAt: number,
m: number, cW: number, y: number
): number {
const h = 16, r = 2
doc.setFillColor(..._SC.metaBg)
doc.roundedRect(m, y, cW, h, r, r, 'F')
const { date } = formatSimulationTimestamp(simulatedAt)
const col2 = m + cW * 0.5
const col3 = m + cW * 0.75
const cells = [
{ label: 'PROCESO', value: process.name, x: m + 4 },
{ label: 'FECHA', value: date, x: col2 + 4 },
{ label: 'MONEDA', value: process.currency, x: col3 + 4 },
]
for (const cell of cells) {
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text(cell.label, cell.x, y + 5)
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textPrimary)
const val = cell.value.length > 28 ? cell.value.slice(0, 26) + '...' : cell.value
doc.text(val, cell.x, y + 12)
}
return y + h + 6
}
export function drawScenarioKpiGrid(
doc: DocLike,
result: SimulationResult,
currency: string,
m: number,
cW: number,
startY: number
): number {
const cardW = (cW - 4) / 2
const cardH = 26
const gapX = 4, gapY = 4
const r = 2
const directPct = result.totalCost > 0
? (result.totalDirectCost / result.totalCost * 100).toFixed(1) : '0'
const cards = [
{ label: 'COSTO TOTAL ESPERADO', value: formatCurrency(result.totalCost, currency), sub: '', col: 0, row: 0 },
{ label: 'COSTO DIRECTO / OVERHEAD', value: formatCurrency(result.totalDirectCost, currency), sub: `${directPct}% directo`, col: 1, row: 0 },
{ label: 'TIEMPO TOTAL ESPERADO', value: formatMinutes(result.totalTimeMinutes), sub: '', col: 0, row: 1 },
{ label: 'ACTIVIDADES COSTEADAS', value: String(result.perActivity.length), sub: '', col: 1, row: 1 },
]
let maxY = startY
for (const card of cards) {
const cx = m + card.col * (cardW + gapX)
const cy = startY + card.row * (cardH + gapY)
doc.setFillColor(..._SC.orangeLight)
doc.roundedRect(cx, cy, cardW, cardH, r, r, 'F')
doc.setFont('helvetica', 'bold')
doc.setFontSize(7)
doc.setTextColor(..._SC.orangeDarker)
doc.text(card.label, cx + 5, cy + 7)
doc.setFont('helvetica', 'bold')
doc.setFontSize(card.value.length > 16 ? 10 : 14)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(card.value, cx + 5, cy + 17)
if (card.sub) {
doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5)
doc.setTextColor(..._SC.orangeDark)
doc.text(card.sub, cx + 5, cy + 23)
}
maxY = Math.max(maxY, cy + cardH)
}
return maxY + 8
}
// ─── Tabla de recursos por actividad ─────────────────────────────────────────
function _fmtNum(n: number): string {
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function drawResourcesSection(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
doc: any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
autoTable: Function,
result: { perActivity: ActivitySimResult[] },
resources: Resource[],
activities: Activity[],
currency: string,
startY: number,
scenario: 'actual' | 'automatizado' = 'actual'
): void {
let y = startY
const m = PDF.margin
const cW = PDF.contentW
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('DESGLOSE DE RECURSOS POR ACTIVIDAD', m, y)
y += 2
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.5)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
doc.text('Costo por ejecución, ponderado por probabilidad de ejecución de cada actividad.', m, y)
y += 6
const resourceMap = new Map(resources.map((r) => [r.id, r]))
const activityMap = new Map(activities.map((a) => [a.id, a]))
const TYPE_LABEL: Record<string, string> = {
role: 'Rol', person: 'Persona', system: 'Sistema', equipment: 'Equipamiento', supply: 'Insumo',
}
const TYPE_COLOR: Record<string, [number, number, number]> = {
role: [255, 237, 213],
person: [220, 252, 231],
system: [219, 234, 254],
equipment: [241, 245, 249],
supply: [254, 249, 195],
}
const RA = 'right' as const
const tableHead = [[
{ content: 'Actividad', styles: { cellWidth: 40 } },
{ content: 'Recurso', styles: { cellWidth: 38 } },
{ content: 'Tipo', styles: { cellWidth: 20 } },
{ content: `$/h (${currency})`, styles: { halign: RA, cellWidth: 20 } },
{ content: 'Min.', styles: { halign: RA, cellWidth: 14 } },
{ content: 'Uds.', styles: { halign: RA, cellWidth: 12 } },
{ content: `Esp. (${currency})`, styles: { halign: RA, cellWidth: 26 } },
]]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tableBody: any[] = []
for (const act of result.perActivity) {
if (act.resourceCostBreakdown.length === 0) continue
const activityRow = activityMap.get(act.activityId)
for (const br of act.resourceCostBreakdown) {
const resource = resourceMap.get(br.resourceId)
const assignment = scenario === 'automatizado'
? activityRow?.automatedAssignedResources?.find((a) => a.resourceId === br.resourceId)
: activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId)
const typeName = resource ? (TYPE_LABEL[resource.type] ?? resource.type) : '—'
const typeColor = resource ? (TYPE_COLOR[resource.type] ?? PDF.slate50) : PDF.slate50
const costPerHour = resource ? _fmtNum(resource.costPerHour) : '—'
const minutes = assignment ? String(assignment.utilizationMinutes) : '—'
const units = assignment ? String(assignment.units) : '—'
// Nombre de actividad repetido por fila — rowSpan no está soportado en body de autotable
tableBody.push([
{ content: act.activityName },
{ content: br.resourceName },
{ content: typeName, styles: { fillColor: typeColor, fontStyle: 'bold', fontSize: 7 } },
{ content: costPerHour, styles: { halign: RA, font: 'courier' } },
{ content: minutes, styles: { halign: RA, font: 'courier' } },
{ content: units, styles: { halign: RA, font: 'courier' } },
{ content: _fmtNum(br.cost), styles: { halign: RA, font: 'courier' } },
])
}
// Fila de subtotal por actividad
const subtotal = act.resourceCostBreakdown.reduce((s, r) => s + r.cost, 0)
tableBody.push([
{ content: '', styles: { fillColor: [255, 248, 237] } },
{ content: 'Subtotal recursos', colSpan: 5, styles: { fontStyle: 'italic', fontSize: 7.5, fillColor: [255, 248, 237] } },
{ content: _fmtNum(subtotal), styles: { halign: RA, font: 'courier', fontStyle: 'bold', fillColor: [255, 248, 237] } },
])
}
if (tableBody.length === 0) return
autoTable(doc, {
head: tableHead,
body: tableBody,
startY: y,
margin: { left: m, right: m },
styles: { fontSize: 7.5, cellPadding: 2.5, overflow: 'ellipsize', lineColor: [226, 232, 240], lineWidth: 0.2 },
headStyles: {
fillColor: [255, 248, 237],
textColor: [146, 64, 14],
fontStyle: 'bold',
fontSize: 8,
lineColor: [245, 152, 69],
lineWidth: 0.5,
},
alternateRowStyles: { fillColor: PDF.slate50 },
})
}
export function addPageNumbers(doc: DocLike, platformName: string, generatedAt: number): void {
const total = doc.internal.getNumberOfPages()
const { date } = formatSimulationTimestamp(generatedAt)
for (let i = 1; i <= total; i++) {
doc.setPage(i)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(180, 180, 180)
doc.text(
`${platformName} · ${date} · Página ${i} de ${total}`,
105,
PDF.pageH - 8,
{ align: 'center' } as any
)
}
}