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.
613 lines
20 KiB
TypeScript
613 lines
20 KiB
TypeScript
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
|
||
)
|
||
}
|
||
}
|