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.
13 KiB
Sprint 2 — Etapa 5: PDF — Tabla de recursos por actividad + verificación de truncamiento
Tipo: export PDF. Sin cambios en dominio, store, persistencia ni componentes React. Objetivo: agregar una sección condicional al PDF de escenario (actual/automatizado) con la tabla de recursos por actividad. Cerrar el ítem de truncamiento de TECH_DEBT. Resultado esperado: PDF con nueva página de recursos (solo cuando hay datos), texto sin truncamiento en notas metodológicas. Validación visual del director requerida antes del OK formal.
Archivos a modificar
src/lib/export/pdf-sections.ts ← nueva función drawResourcesSection + fix truncamiento
src/lib/export/pdf-export.ts ← agregar activities al llamado de exportScenarioPdf
Si durante la implementación identificás que src/lib/export/pdf-sections-roi.ts también
tiene líneas truncadas, podés incluirlo. Si aparece cualquier otro archivo no listado,
detenete y reportá el conflicto antes de tocarlo.
Feature 1 — Tabla de recursos por actividad en PDF escenario
Contexto: dónde vive el dato
result.perActivity[].resourceCostBreakdown: Array<{ resourceId, resourceName, cost }>
— ya está en el resultado de simulación. El campo cost es el costo esperado
(ponderado por probabilidad de ejecución).
Para mostrar costPerHour, utilizationMinutes y units (columnas que el cliente
puede verificar), necesitamos cruzar con Activity.assignedResources. Eso requiere
pasar activities a exportScenarioPdf.
Cambio 1 — pdf-export.ts: pasar activities a exportScenarioPdf
// Firma actual (línea ~54)
async function exportScenarioPdf(
doc: any,
autoTable: Function,
process: Process,
simulation: Simulation,
result: Simulation['result'],
resources: Resource[],
heatmapImageData: string | null,
currency: string,
tab: 'actual' | 'automatizado'
): Promise<void>
// Firma nueva — agregar activities al final
async function exportScenarioPdf(
doc: any,
autoTable: Function,
process: Process,
simulation: Simulation,
result: Simulation['result'],
resources: Resource[],
heatmapImageData: string | null,
currency: string,
tab: 'actual' | 'automatizado',
activities: Activity[] // ← nuevo parámetro, default []
): Promise<void>
En la llamada desde exportToPdf (línea ~44), agregar activities al final:
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities)
activities ya está destructurado en exportToPdf — no hay que cambiarlo ahí.
Cambio 2 — pdf-export.ts: insertar página condicional de recursos
En exportScenarioPdf, reemplazar el bloque de página 4 (metodología) actual por:
// Evaluar si hay recursos para mostrar
const hasResources = result.perActivity.some(act => act.resourceCostBreakdown.length > 0)
// ── Página 4 (condicional): Tabla de recursos ────────────────────────────────
if (hasResources) {
docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 })
drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y)
}
// ── Página 4 o 5: Nota metodológica ─────────────────────────────────────────
docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: hasResources ? 5 : 4 })
drawMethodologySection(docAny, process, y)
Cambio 3 — pdf-sections.ts: nueva función drawResourcesSection
Agregar esta función exportada al final de pdf-sections.ts, antes del bloque de
funciones privadas. Importar los tipos necesarios si no están importados ya
(ActivitySimResult, Resource, Activity).
export function drawResourcesSection(
doc: any,
autoTable: Function,
result: { perActivity: ActivitySimResult[] },
resources: Resource[],
activities: Activity[],
currency: string,
startY: number
): void {
const m = PDF.margin
const cW = PDF.contentW
// Título de sección
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('DESGLOSE DE RECURSOS POR ACTIVIDAD', m, startY)
startY += 2
doc.setDrawColor(...PDF.slate200)
doc.line(m, startY, m + cW, startY)
startY += 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, startY)
startY += 6
// Mapa rápido: resourceId → Resource para lookup de tipo y costo/hora
const resourceMap = new Map(resources.map(r => [r.id, r]))
// Mapa: activityId → Activity para lookup de assignedResources
const activityMap = new Map(activities.map(a => [a.id, a]))
// Tipos de recurso en español y colores de celda (RGB para jsPDF)
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], // naranja claro
person: [220, 252, 231], // verde claro
system: [219, 234, 254], // azul claro
equipment: [241, 245, 249], // slate claro
supply: [254, 249, 195], // amarillo claro
}
const RA = 'right' as const
// Construir filas agrupadas por actividad
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: `Costo esp. (${currency})`, styles: { halign: RA, cellWidth: 26 } },
]]
const tableBody: any[] = []
const cellStyles: Record<string, any> = {}
let rowIndex = 0
for (const act of result.perActivity) {
if (act.resourceCostBreakdown.length === 0) continue
const activityRow = activityMap.get(act.activityId)
const firstRow = true
for (let i = 0; i < act.resourceCostBreakdown.length; i++) {
const br = act.resourceCostBreakdown[i]
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 ? formatNum(resource.costPerHour) : '—'
const minutes = assignment ? String(assignment.utilizationMinutes) : '—'
const units = assignment ? String(assignment.units) : '—'
tableBody.push([
i === 0 ? { content: act.activityName, rowSpan: act.resourceCostBreakdown.length } : null,
{ 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: formatNum(br.cost), styles: { halign: RA, font: 'courier' } },
].filter(Boolean))
rowIndex++
}
// 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: formatNum(subtotal), styles: { halign: RA, font: 'courier', fontStyle: 'bold', fillColor: [255, 248, 237] } },
])
rowIndex++
}
if (tableBody.length === 0) return
autoTable(doc, {
head: tableHead,
body: tableBody,
startY,
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 },
})
}
Nota sobre rowSpan: jsPDF-autotable no soporta rowSpan en body rows de la misma
forma que en HTML. Si hay problemas con rowSpan, reemplazarlo por repetir el nombre de
la actividad en cada fila (sin rowSpan) — es menos elegante pero funciona igual. Reportar
qué solución se usó.
Feature 2 — Verificación de truncamiento (TECH_DEBT)
Contexto
El ítem en TECH_DEBT.md indica que las notas metodológicas podrían tener líneas largas. Los caracteres rotos (Unicode) ya fueron resueltos en Sprint 1.5. El riesgo pendiente es si alguna fórmula o línea larga supera el ancho de página portrait.
Acción requerida
Paso 1 — Búsqueda grep:
grep -n "doc\.text(" src/lib/export/pdf-sections.ts src/lib/export/pdf-sections-roi.ts
Identificar todas las llamadas doc.text(string, x, y) donde string es una variable
de longitud potencialmente grande Y no está precedida por splitTextToSize.
Paso 2 — Evaluación:
- Para cada línea identificada, evaluar si el string puede superar
PDF.contentW(170mm) - Si es una cadena hardcodeada corta → sin riesgo, documentar y cerrar
- Si es variable (resourceName, processName, etc.) → aplicar
splitTextToSize
Paso 3 — Fix específico conocido:
En pdf-sections.ts, función drawAnalysisSection, la línea:
doc.text(`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`, m, y)
…usa un resourceName de longitud variable sin wrap. Cambiar a:
const resourceLine = doc.splitTextToSize(
`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`,
cW
)
doc.text(resourceLine, m, y)
y += (resourceLine.length - 1) * 4.5
Paso 4 — Reporte: listar cada línea revisada, con resultado (sin riesgo / corregida).
Verificaciones obligatorias antes de reportar
1. Verificación de scope con git diff — OBLIGATORIO
git diff --name-only HEAD
El resultado debe contener únicamente:
src/lib/export/pdf-sections.ts
src/lib/export/pdf-export.ts
src/lib/export/pdf-sections-roi.ts ← solo si se detectó y corrigió truncamiento ahí
Si aparece cualquier archivo no listado → revertirlo, listarlo bajo "Conflictos de scope".
2. Build limpio
npm run build
Sin errores TypeScript.
3. Tests
npm run test
Todos los tests existentes deben seguir verdes. Esta etapa no agrega tests nuevos.
4. Smoke test visual
Exportar PDF de escenario actual con un proceso que tenga al menos una actividad con recursos asignados. Verificar:
- El PDF incluye la nueva página de recursos antes de la metodología
- La tabla muestra correctamente: actividad, recurso, tipo, $/h, min, unidades, costo esperado
- La celda de "Tipo" tiene color de fondo según el tipo del recurso
- La fila de subtotal aparece al final de cada actividad con el total correcto
- El número de página en el header es correcto (4 si hay recursos, la metodología pasa a 5)
- Exportar un proceso SIN recursos: la página de recursos no aparece, metodología es página 4
Alcance estricto — lo que NO entra en esta etapa
- ❌ PDF de ROI (
exportRoiPdf) — no agregar tabla de recursos ahí. La tabla ROI ya tiene su propia lógica. - ❌ Ningún componente React (
src/features/) - ❌ Dominio o store
- ❌ Cambios en el reporte web — Etapa 3 ya cerrada
- ❌ Nuevos tipos o schemas en
src/domain/
Reporte esperado al terminar
## Etapa 5 completada
### Archivos modificados
- src/lib/export/pdf-sections.ts — drawResourcesSection + fix truncamiento
- src/lib/export/pdf-export.ts — activities param + página condicional
- src/lib/export/pdf-sections-roi.ts — [sin cambios / fix truncamiento aplicado]
### Cambios realizados
- drawResourcesSection: [descripción breve del approach final — en especial si rowSpan se usó o se descartó]
- Página condicional: aparece si `result.perActivity.some(act => act.resourceCostBreakdown.length > 0)`
- Numeración de página: metodología pasa a pág. 5 cuando hay recursos, se mantiene en 4 si no
- Truncamiento: [lista de líneas auditadas y resultado de cada una]
### TECH_DEBT cerrados
- Truncamiento notas metodológicas: ✅ [auditado sin cambios / corregido en X líneas]
### Verificaciones
- git diff --name-only HEAD: [lista] ✅
- npm run build: ✅
- npm run test: X/X ✅
- Smoke test: [descripción breve]
### Conflictos de scope
[ninguno / lista con razones]
### Notas para Etapas siguientes
[hallazgos para Etapa 6 E2E]