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.
This commit is contained in:
361
sprints/sprint-2/ETAPA_5_PROMPT.md
Normal file
361
sprints/sprint-2/ETAPA_5_PROMPT.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# 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]
|
||||
```
|
||||
Reference in New Issue
Block a user