# 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 // 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 ``` 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 = { role: 'Rol', person: 'Persona', system: 'Sistema', equipment: 'Equipamiento', supply: 'Insumo', } const TYPE_COLOR: Record = { 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 = {} 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] ```