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

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]