Files
inq-roi-simulador-web/sprints/sprint-2/ETAPA_5_PROMPT.md
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

362 lines
13 KiB
Markdown

# 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`
```typescript
// 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:
```typescript
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:
```typescript
// 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`).
```typescript
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:**
```bash
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:
```typescript
doc.text(`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`, m, y)
```
…usa un `resourceName` de longitud variable sin wrap. Cambiar a:
```typescript
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
```bash
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
```bash
npm run build
```
Sin errores TypeScript.
### 3. Tests
```bash
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]
```