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.
This commit is contained in:
2026-05-20 08:24:32 -03:00
parent 8b6ae5fa64
commit 1ff2724735
9 changed files with 1330 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
import type { SimulationResult, Process } from '@/domain/types'
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)
@@ -228,8 +228,12 @@ export function drawAnalysisSection(
if (result.perResource.length > 0) {
const topResource = [...result.perResource].sort((a, b) => b.totalCost - a.totalCost)[0]
doc.text(`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`, m, y)
y += 5
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)
@@ -466,6 +470,129 @@ export function drawScenarioKpiGrid(
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)