Files
inq-roi-simulador-web/src/lib/export/pdf-sections.ts
Marcos Benítez 1ff2724735 feat(sprint-2): recursos por actividad — modelo, UX y PDF
Etapas 3-6 del Sprint 2. Resumen de cambios:

**Reporte web (Etapa 3)**
- ActivitiesTable: filas expandibles con desglose fijo + recursos por actividad
- CumulativeSavingsChart: fix payback label superpuesto con eje Y
- BpmnCanvas: badge  14→20px
- TECH_DEBT cerrado: chart -inversión (ya estaba), payback label, badge

**Workspace (Etapa 4)**
- WorkspacePage: edición inline de nombre de proceso y cliente
  (click → input → Enter/blur guarda en IndexedDB, checkmark naranja 1.5s)

**PDF (Etapas 5-6)**
- pdf-sections: drawResourcesSection — tabla condicional de recursos por actividad
  con chips de tipo (rol/persona/sistema/equipamiento/insumo)
- pdf-export: página de recursos (pág. 4 cuando hay datos, metodología pasa a pág. 5)
- Fix truncamiento: splitTextToSize en resourceName y top3 ROI
- Fix texto: "ejecución" con tilde, header "Esp. (USD)" sin truncar
- TECH_DEBT cerrado: truncamiento notas metodológicas

**Tests**
- 7 tests nuevos: drawResourcesSection (4) + comportamiento condicional PDF (3)
- Total: 541 → 548 tests verdes

BREAKING: exportScenarioPdf ahora recibe activities[] como parámetro adicional.
2026-05-20 08:24:32 -03:00

613 lines
20 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],
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
const steps = 40
const segW = barW / steps
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1)
let r: number, g: number, b: number
if (t <= 0.5) {
const lt = t * 2
r = Math.round(PDF.heatLow[0] + (PDF.heatMid[0] - PDF.heatLow[0]) * lt)
g = Math.round(PDF.heatLow[1] + (PDF.heatMid[1] - PDF.heatLow[1]) * lt)
b = Math.round(PDF.heatLow[2] + (PDF.heatMid[2] - PDF.heatLow[2]) * lt)
} else {
const lt = (t - 0.5) * 2
r = Math.round(PDF.heatMid[0] + (PDF.heatHigh[0] - PDF.heatMid[0]) * lt)
g = Math.round(PDF.heatMid[1] + (PDF.heatHigh[1] - PDF.heatMid[1]) * lt)
b = Math.round(PDF.heatMid[2] + (PDF.heatHigh[2] - PDF.heatMid[2]) * lt)
}
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
): 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 = 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
)
}
}