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.
362 lines
13 KiB
Markdown
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]
|
|
```
|