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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user