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:
254
sprints/sprint-2/ETAPA_4_PROMPT.md
Normal file
254
sprints/sprint-2/ETAPA_4_PROMPT.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Sprint 2 — Etapa 4: Edición inline de nombre de proceso y cliente
|
||||
|
||||
> **Tipo:** UI — un solo componente. Sin cambios en dominio, persistencia ni reporte.
|
||||
> **Objetivo:** hacer editable el nombre del proceso y el cliente directamente desde el header del workspace. Hoy son campos estáticos (`<p>`); deben convertirse en inputs inline que guardan en IndexedDB al confirmar.
|
||||
> **Resultado esperado:** click sobre nombre o cliente → input inline → Enter o blur guarda → checkmark naranja breve confirma. Validación visual del director requerida antes del OK formal.
|
||||
|
||||
---
|
||||
|
||||
## Archivo a modificar
|
||||
|
||||
**Un único archivo:** `src/features/workspace/WorkspacePage.tsx`
|
||||
|
||||
Si durante la implementación identificás que otro archivo necesita cambio, **detenete y reportá el conflicto antes de tocarlo**. No procedas sin confirmación.
|
||||
|
||||
---
|
||||
|
||||
## Contexto: estado actual del header
|
||||
|
||||
El header del workspace (líneas ~139–144) tiene hoy:
|
||||
|
||||
```tsx
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
|
||||
{currentProcess.clientName && (
|
||||
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
Ambos son texto estático. La función `updateProcess` ya existe en el store
|
||||
(`useProcessStore`) y persiste en IndexedDB — no requiere ningún cambio fuera de este archivo.
|
||||
|
||||
---
|
||||
|
||||
## Cambios requeridos
|
||||
|
||||
### 1. Agregar `updateProcess` al destructuring del store
|
||||
|
||||
```tsx
|
||||
// ANTES (línea ~26)
|
||||
const { currentProcess, activities, isLoading, error, loadProcess, gateways } = useProcessStore()
|
||||
|
||||
// DESPUÉS
|
||||
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
|
||||
```
|
||||
|
||||
### 2. Agregar iconos al import de lucide-react
|
||||
|
||||
Agregar `Check` y `Pencil` al import existente:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Loader2, BarChart3, Play, AlertTriangle,
|
||||
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2,
|
||||
Check, Pencil
|
||||
} from 'lucide-react'
|
||||
```
|
||||
|
||||
### 3. Agregar estado local para edición inline
|
||||
|
||||
Insertar antes del `return`, junto a los otros estados del componente:
|
||||
|
||||
```tsx
|
||||
// Estado para edición inline del header
|
||||
const [editingField, setEditingField] = useState<'name' | 'client' | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [savedField, setSavedField] = useState<'name' | 'client' | null>(null)
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
```
|
||||
|
||||
`useRef` ya está importado desde React — no requiere cambio en imports de React.
|
||||
|
||||
### 4. Agregar función `saveField`
|
||||
|
||||
Insertar junto a las otras funciones del componente (cerca de `handleSimulate`):
|
||||
|
||||
```tsx
|
||||
async function saveField(field: 'name' | 'client') {
|
||||
if (field === 'name') {
|
||||
const trimmed = editValue.trim()
|
||||
if (trimmed && trimmed !== currentProcess?.name) {
|
||||
await updateProcess({ name: trimmed })
|
||||
}
|
||||
} else {
|
||||
const trimmed = editValue.trim()
|
||||
if (trimmed !== (currentProcess?.clientName ?? '')) {
|
||||
await updateProcess({ clientName: trimmed })
|
||||
}
|
||||
}
|
||||
setEditingField(null)
|
||||
// Wow: checkmark naranja breve
|
||||
setSavedField(field)
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
|
||||
savedTimerRef.current = setTimeout(() => setSavedField(null), 1500)
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Reemplazar el bloque estático del header
|
||||
|
||||
Reemplazar las líneas ~139–144 (el `<div className="flex-1 min-w-0">` completo) con:
|
||||
|
||||
```tsx
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
{/* Nombre del proceso — editable inline */}
|
||||
{editingField === 'name' ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="text-sm font-semibold text-slate-800 bg-transparent border-b border-[#F59845] outline-none w-full"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveField('name')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); saveField('name') }
|
||||
if (e.key === 'Escape') setEditingField(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 group cursor-text"
|
||||
onClick={() => { setEditValue(currentProcess.name); setEditingField('name') }}
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
|
||||
{savedField === 'name'
|
||||
? <Check className="h-3.5 w-3.5 text-[#F59845] shrink-0" />
|
||||
: <Pencil className="h-3 w-3 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cliente — editable inline */}
|
||||
{editingField === 'client' ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="text-xs text-slate-400 bg-transparent border-b border-[#F59845] outline-none w-full"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveField('client')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); saveField('client') }
|
||||
if (e.key === 'Escape') setEditingField(null)
|
||||
}}
|
||||
placeholder="Agregar cliente…"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 group cursor-text"
|
||||
onClick={() => { setEditValue(currentProcess.clientName ?? ''); setEditingField('client') }}
|
||||
>
|
||||
{currentProcess.clientName
|
||||
? <p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
: <p className="text-xs text-slate-300 truncate italic">Agregar cliente…</p>
|
||||
}
|
||||
{savedField === 'client'
|
||||
? <Check className="h-3 w-3 text-[#F59845] shrink-0" />
|
||||
: <Pencil className="h-2.5 w-2.5 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportamientos esperados
|
||||
|
||||
- **Click sobre el nombre** → input inline con el valor actual, borde inferior naranja
|
||||
- **Enter o blur** → guarda, vuelve a modo lectura, aparece ✓ naranja 1.5 segundos
|
||||
- **Escape** → cancela sin guardar, vuelve al valor anterior
|
||||
- **Nombre vacío al guardar** → no guarda (el `if (trimmed && ...)` lo previene), vuelve al valor anterior
|
||||
- **Cliente vacío al guardar** → guarda string vacío (limpiar el cliente es una acción válida)
|
||||
- **Sin cliente asignado** → muestra "Agregar cliente…" en gris itálico; al hacer click se abre el input vacío
|
||||
- El ícono ✏️ es invisible hasta hacer hover sobre la fila — indica que es editable sin ocupar espacio visualmente
|
||||
|
||||
---
|
||||
|
||||
## 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/features/workspace/WorkspacePage.tsx
|
||||
```
|
||||
|
||||
Si aparece cualquier archivo no listado:
|
||||
1. Revertilo: `git checkout HEAD -- <archivo>`
|
||||
2. Listarlo bajo "Conflictos de scope" en el reporte
|
||||
3. No incluirlo en el entregable
|
||||
|
||||
### 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 (UI pura, sin lógica de dominio nueva).
|
||||
|
||||
### 4. Smoke test manual
|
||||
- [ ] Click sobre el nombre del proceso → se abre input con valor actual
|
||||
- [ ] Tipear un nombre nuevo → Enter → se cierra, aparece ✓ naranja breve, el nombre cambió
|
||||
- [ ] Recargar la página → el nombre nuevo persiste (IndexedDB)
|
||||
- [ ] Click sobre el cliente → input editable
|
||||
- [ ] Borrar el cliente completamente → guardar → el campo queda vacío mostrando "Agregar cliente…"
|
||||
- [ ] Escape cancela sin guardar
|
||||
|
||||
---
|
||||
|
||||
## Alcance estricto — lo que NO entra en esta etapa
|
||||
|
||||
- ❌ `GlobalSettingsPanel.tsx` — no tocar (edición desde settings sigue funcionando igual)
|
||||
- ❌ Ningún archivo de reporte (`src/features/report/`) — Etapa 3 ya cerrada
|
||||
- ❌ Ningún archivo de dominio (`src/domain/`) — Etapas 1–3 ya cerraron dominio
|
||||
- ❌ Ningún archivo de persistencia (`src/persistence/`) — `updateProcess` del store es suficiente
|
||||
- ❌ PDFs — Etapa 5
|
||||
- ❌ Edición de campos adicionales del proceso (frecuencia anual, overhead) — BACKLOG
|
||||
|
||||
---
|
||||
|
||||
## Reporte esperado al terminar
|
||||
|
||||
```
|
||||
## Etapa 4 completada
|
||||
|
||||
### Archivos modificados
|
||||
- src/features/workspace/WorkspacePage.tsx
|
||||
|
||||
### Cambios realizados
|
||||
- Edición inline de nombre de proceso: click → input → Enter/blur guarda
|
||||
- Edición inline de cliente: click → input → Enter/blur guarda
|
||||
- Checkmark naranja (#F59845) confirmatorio con timeout 1.5s
|
||||
- Ícono ✏️ visible al hover, oculto en reposo
|
||||
- Escape cancela sin guardar en ambos campos
|
||||
|
||||
### Verificaciones
|
||||
- git diff --name-only HEAD: solo WorkspacePage.tsx ✅
|
||||
- npm run build: ✅
|
||||
- npm run test: X/X tests verdes ✅
|
||||
- Smoke test: [descripción breve]
|
||||
|
||||
### Conflictos de scope
|
||||
[ninguno / lista de archivos revertidos y razón]
|
||||
|
||||
### Notas para Etapas siguientes
|
||||
[hallazgos relevantes para Etapas 5+]
|
||||
```
|
||||
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]
|
||||
```
|
||||
203
sprints/sprint-2/ETAPA_6_PROMPT.md
Normal file
203
sprints/sprint-2/ETAPA_6_PROMPT.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Sprint 2 — Etapa 6: Fixes post-validación + Tests Sprint 2
|
||||
|
||||
> **Tipo:** correcciones menores de texto/layout + tests unitarios. Sin features nuevas, sin cambios de dominio.
|
||||
> **Objetivo:** cerrar los dos hallazgos de la validación visual de Etapa 5 y agregar tests que cubran el comportamiento nuevo del PDF de recursos (condicional de página, drawResourcesSection).
|
||||
> **Resultado esperado:** PDF sin typos de acentos, columna "Costo esp." no truncada, suite de tests verde que incluye cobertura de la nueva sección de recursos. Validación visual del director requerida antes del OK formal.
|
||||
|
||||
---
|
||||
|
||||
## Archivos a modificar
|
||||
|
||||
```
|
||||
src/lib/export/pdf-sections.ts ← fix acentos + ancho columna
|
||||
tests/lib/export/pdf-resources-section.test.ts ← NUEVO — tests de drawResourcesSection
|
||||
```
|
||||
|
||||
Opcionalmente, extender `tests/lib/export/pdf-export.test.ts` con 2 tests de comportamiento
|
||||
condicional (ver Feature 2). Si lo considerás más limpio, crear un archivo separado
|
||||
`tests/lib/export/pdf-export-sprint2.test.ts`.
|
||||
|
||||
Si durante la implementación necesitás tocar cualquier otro archivo, **detenete y reportá
|
||||
el conflicto antes de proceder**.
|
||||
|
||||
---
|
||||
|
||||
## Fix 1 — Acentos faltantes en drawResourcesSection
|
||||
|
||||
En `pdf-sections.ts`, función `drawResourcesSection`, el subtítulo dice:
|
||||
```
|
||||
"Costo por ejecucion, ponderado por probabilidad de ejecucion de cada actividad."
|
||||
```
|
||||
|
||||
Corregir a:
|
||||
```
|
||||
"Costo por ejecución, ponderado por probabilidad de ejecución de cada actividad."
|
||||
```
|
||||
|
||||
Verificar con grep que no queden otras ocurrencias de "ejecucion" (sin tilde) en el archivo.
|
||||
|
||||
---
|
||||
|
||||
## Fix 2 — Columna "Costo esp." truncada en drawResourcesSection
|
||||
|
||||
La última columna del header aparece como "Costo esp. (U..." porque `cellWidth: 26` es
|
||||
insuficiente para "Costo esp. (USD)".
|
||||
|
||||
**Solución preferida:** cambiar el header a `'Esp. (USD)'` (abreviado) manteniendo `cellWidth: 26`.
|
||||
Esto es más legible a 7.5pt en una celda angosta que forzar más ancho.
|
||||
|
||||
Si la moneda es variable (no siempre USD), usar `\`Esp. (${currency})\`` en lugar del string hardcodeado.
|
||||
Revisar si las otras columnas ya usan `currency` en el header — ser consistente.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2 — Tests unitarios: drawResourcesSection y página condicional
|
||||
|
||||
### 2.1 Tests para drawResourcesSection
|
||||
|
||||
Crear `tests/lib/export/pdf-resources-section.test.ts`.
|
||||
|
||||
El mock de jsPDF es el mismo patrón que usa `pdf-export.test.ts` — reusar o importar el
|
||||
fixture. El mock de `autoTable` es `vi.fn()`.
|
||||
|
||||
**Tests requeridos:**
|
||||
|
||||
```typescript
|
||||
describe('drawResourcesSection', () => {
|
||||
|
||||
it('no lanza error con actividades que tienen recursos', () => {
|
||||
// result con al menos una actividad con resourceCostBreakdown no vacío
|
||||
// resources con el recurso referenciado
|
||||
// activities con el assignedResource correspondiente
|
||||
// Verificar: no lanza, autoTable fue llamado
|
||||
})
|
||||
|
||||
it('no llama a autoTable si todas las actividades tienen resourceCostBreakdown vacío', () => {
|
||||
// result con perActivity donde todos tienen resourceCostBreakdown: []
|
||||
// Verificar: autoTable NO fue llamado (la función retorna early)
|
||||
})
|
||||
|
||||
it('el header de autoTable incluye la moneda correcta', () => {
|
||||
// Llamar con currency: 'PYG'
|
||||
// Verificar que el primer arg de autoTable.head contiene 'PYG'
|
||||
})
|
||||
|
||||
it('llama a autoTable por cada actividad con recursos (una tabla total, no una por actividad)', () => {
|
||||
// result con 2 actividades con recursos
|
||||
// Verificar: autoTable fue llamado exactamente 1 vez (la tabla agrupa todo)
|
||||
})
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
### 2.2 Tests de comportamiento condicional en exportToPdf
|
||||
|
||||
Extender el describe existente en `pdf-export.test.ts` (o en nuevo archivo):
|
||||
|
||||
```typescript
|
||||
describe('exportToPdf — página condicional de recursos (Sprint 2)', () => {
|
||||
|
||||
it('NO llama a drawResourcesSection cuando ninguna actividad tiene recursos', async () => {
|
||||
// mockSimulation con perActivity donde resourceCostBreakdown: [] en todas
|
||||
// Verificar: autoTable llamado exactamente 1 vez (solo la tabla principal de actividades)
|
||||
})
|
||||
|
||||
it('llama a autoTable dos veces cuando hay actividades con recursos', async () => {
|
||||
// mockSimulation con al menos una actividad con resourceCostBreakdown no vacío
|
||||
// resources con el recurso referenciado
|
||||
// Verificar: autoTable llamado al menos 2 veces (tabla principal + tabla de recursos)
|
||||
})
|
||||
|
||||
it('addPage se llama una vez más cuando hay recursos (página extra)', async () => {
|
||||
const addPageCallsWithout = /* llamar sin recursos, contar addPage.mock.calls.length */
|
||||
const addPageCallsWith = /* llamar con recursos, contar addPage.mock.calls.length */
|
||||
expect(addPageCallsWith).toBe(addPageCallsWithout + 1)
|
||||
})
|
||||
|
||||
})
|
||||
```
|
||||
|
||||
**Nota sobre el mock:** El mock de `jsPDF` en `pdf-export.test.ts` hace `vi.clearAllMocks()` en
|
||||
`beforeEach`, así que los contadores se resetean entre tests. Asegurate de resetear también
|
||||
el mock de `autoTable` si lo extendés en el mismo archivo.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
tests/lib/export/pdf-resources-section.test.ts
|
||||
tests/lib/export/pdf-export.test.ts ← si se extendió este archivo
|
||||
tests/lib/export/pdf-export-sprint2.test.ts ← si se creó archivo nuevo
|
||||
```
|
||||
|
||||
Si aparece cualquier archivo no listado → revertirlo y documentarlo.
|
||||
|
||||
**Nota:** `src/features/workspace/WorkspacePage.tsx`, `src/lib/export/pdf-export.ts` y
|
||||
`src/lib/export/pdf-sections-roi.ts` probablemente aparezcan en el diff porque son cambios
|
||||
de Etapas 4 y 5 que aún no han sido commiteados. Eso es correcto — no revertirlos.
|
||||
|
||||
### 2. Build limpio
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 3. Tests — todos verdes incluyendo los nuevos
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
El reporte debe indicar cuántos tests se agregaron respecto de los 541 anteriores.
|
||||
|
||||
### 4. Smoke test visual del fix de acentos
|
||||
|
||||
Exportar un PDF de escenario con recursos y verificar que la página 4 muestra:
|
||||
- "Costo por ejecución" (con tilde, dos veces) en el subtítulo
|
||||
- El header de la última columna no truncado ("Esp. (USD)" o similar)
|
||||
|
||||
---
|
||||
|
||||
## Alcance estricto — lo que NO entra en esta etapa
|
||||
|
||||
- ❌ Features nuevas de ningún tipo
|
||||
- ❌ Cambios en dominio, store, o componentes React
|
||||
- ❌ Tests E2E de Playwright (los existentes son suficientes para Sprint 2)
|
||||
- ❌ Cambios en pdf-export.ts o pdf-sections-roi.ts (Etapa 5 ya los cerró)
|
||||
- ❌ Refactors de código existente que no estén relacionados con los dos fixes
|
||||
|
||||
---
|
||||
|
||||
## Reporte esperado al terminar
|
||||
|
||||
```
|
||||
## Etapa 6 completada
|
||||
|
||||
### Archivos modificados
|
||||
- src/lib/export/pdf-sections.ts — fix acentos + fix header columna
|
||||
- tests/lib/export/pdf-resources-section.test.ts — NUEVO (X tests)
|
||||
- tests/lib/export/pdf-export.test.ts — extendido (X tests nuevos) / pdf-export-sprint2.test.ts — NUEVO
|
||||
|
||||
### Fixes aplicados
|
||||
- Acentos: "ejecución" corregido (2 ocurrencias en drawResourcesSection)
|
||||
- Columna: header cambiado a "Esp. (USD)" — ya no se trunca
|
||||
|
||||
### Tests agregados
|
||||
- pdf-resources-section.test.ts: X tests — [descripción breve]
|
||||
- pdf-export.test.ts / sprint2: X tests — comportamiento condicional
|
||||
|
||||
### Verificaciones
|
||||
- git diff --name-only HEAD: [lista] ✅
|
||||
- npm run build: ✅
|
||||
- npm run test: [nuevo total]/[anterior] tests verdes ✅
|
||||
- Smoke test visual: [descripción breve del fix visual confirmado]
|
||||
|
||||
### Conflictos de scope
|
||||
[ninguno / lista con razones]
|
||||
```
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } f
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Loader2, BarChart3, Play, AlertTriangle,
|
||||
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2
|
||||
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2,
|
||||
Check, Pencil
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
@@ -23,7 +24,7 @@ type SelectedCategory = 'activity' | 'gateway' | null
|
||||
export function WorkspacePage() {
|
||||
const { processId } = useParams({ from: '/workspace/$processId' })
|
||||
const navigate = useNavigate()
|
||||
const { currentProcess, activities, isLoading, error, loadProcess, gateways } = useProcessStore()
|
||||
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
|
||||
const { isRunning, resultActual, error: simError, selectActivity } = useSimulationStore()
|
||||
const { simulate } = useSimulate()
|
||||
const { toast } = useToast()
|
||||
@@ -34,6 +35,12 @@ export function WorkspacePage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState<SelectedCategory>(null)
|
||||
const [activeTab, setActiveTab] = useState('elemento')
|
||||
|
||||
// Estado para edición inline del header
|
||||
const [editingField, setEditingField] = useState<'name' | 'client' | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [savedField, setSavedField] = useState<'name' | 'client' | null>(null)
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Usamos un ref para saber si loadProcess ya terminó al menos una vez.
|
||||
// Esto evita que el redirect se dispare en el render inicial donde
|
||||
// isLoading=false y currentProcess=null (antes del primer efecto).
|
||||
@@ -93,6 +100,24 @@ export function WorkspacePage() {
|
||||
selectActivity(null)
|
||||
}
|
||||
|
||||
async function saveField(field: 'name' | 'client') {
|
||||
if (field === 'name') {
|
||||
const trimmed = editValue.trim()
|
||||
if (trimmed && trimmed !== currentProcess?.name) {
|
||||
await updateProcess({ name: trimmed })
|
||||
}
|
||||
} else {
|
||||
const trimmed = editValue.trim()
|
||||
if (trimmed !== (currentProcess?.clientName ?? '')) {
|
||||
await updateProcess({ clientName: trimmed })
|
||||
}
|
||||
}
|
||||
setEditingField(null)
|
||||
setSavedField(field)
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
|
||||
savedTimerRef.current = setTimeout(() => setSavedField(null), 1500)
|
||||
}
|
||||
|
||||
async function handleSimulate() {
|
||||
if (!canSimulate) return
|
||||
const simResult = await simulate()
|
||||
@@ -137,10 +162,63 @@ export function WorkspacePage() {
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
|
||||
{currentProcess.clientName && (
|
||||
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
|
||||
{/* Nombre del proceso — editable inline */}
|
||||
{editingField === 'name' ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="text-sm font-semibold text-slate-800 bg-transparent border-b border-[#F59845] outline-none w-full"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveField('name')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); saveField('name') }
|
||||
if (e.key === 'Escape') setEditingField(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 group cursor-text"
|
||||
onClick={() => { setEditValue(currentProcess.name); setEditingField('name') }}
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
|
||||
{savedField === 'name'
|
||||
? <Check className="h-3.5 w-3.5 text-[#F59845] shrink-0" />
|
||||
: <Pencil className="h-3 w-3 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cliente — editable inline */}
|
||||
{editingField === 'client' ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="text-xs text-slate-400 bg-transparent border-b border-[#F59845] outline-none w-full"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => saveField('client')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); saveField('client') }
|
||||
if (e.key === 'Escape') setEditingField(null)
|
||||
}}
|
||||
placeholder="Agregar cliente…"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 group cursor-text"
|
||||
onClick={() => { setEditValue(currentProcess.clientName ?? ''); setEditingField('client') }}
|
||||
>
|
||||
{currentProcess.clientName
|
||||
? <p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
: <p className="text-xs text-slate-300 truncate italic">Agregar cliente…</p>
|
||||
}
|
||||
{savedField === 'client'
|
||||
? <Check className="h-3 w-3 text-[#F59845] shrink-0" />
|
||||
: <Pencil className="h-2.5 w-2.5 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Errores de simulación */}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { calculateRoi } from '@/domain/roi'
|
||||
import {
|
||||
drawScenarioPage1,
|
||||
drawHeatmapImage, drawHeatmapLegend,
|
||||
drawAnalysisSection, drawMethodologySection, PDF,
|
||||
drawAnalysisSection, drawMethodologySection, drawResourcesSection, PDF,
|
||||
} from './pdf-sections'
|
||||
import {
|
||||
drawRoiPage1, buildComparisonTableConfig,
|
||||
@@ -41,7 +41,7 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado')
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities)
|
||||
}
|
||||
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
@@ -61,7 +61,8 @@ async function exportScenarioPdf(
|
||||
resources: Resource[],
|
||||
heatmapImageData: string | null,
|
||||
currency: string,
|
||||
tab: 'actual' | 'automatizado'
|
||||
tab: 'actual' | 'automatizado',
|
||||
activities: Activity[] = []
|
||||
): Promise<void> {
|
||||
const docAny = doc as any
|
||||
const scenarioLabel = tab === 'actual' ? 'Escenario actual' : 'Escenario automatizado'
|
||||
@@ -98,8 +99,6 @@ async function exportScenarioPdf(
|
||||
y = drawAnalysisSection(docAny, result, currency, y)
|
||||
y += 6
|
||||
|
||||
void resources
|
||||
|
||||
const RA = 'right' as const
|
||||
const tableHead = [[
|
||||
{ content: 'Actividad', styles: { cellWidth: 48 } },
|
||||
@@ -139,9 +138,18 @@ async function exportScenarioPdf(
|
||||
columnStyles: { 0: { cellWidth: 48 } },
|
||||
})
|
||||
|
||||
// ── Página 4 (portrait): Nota metodológica ────────────────────────────────────
|
||||
// ── Página 4 condicional: Tabla de recursos (solo si hay actividades con recursos)
|
||||
const hasResources = result.perActivity.some((act) => act.resourceCostBreakdown.length > 0)
|
||||
|
||||
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: 4 })
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: hasResources ? 5 : 4 })
|
||||
drawMethodologySection(docAny, process, y)
|
||||
|
||||
applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
|
||||
|
||||
@@ -723,8 +723,12 @@ export function drawRoiAnalysisPage(
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
for (const act of top3) {
|
||||
const actPct = totalSavingsAll > 0 ? (act.savings / totalSavingsAll * 100).toFixed(1) : '0'
|
||||
doc.text(` - ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, m, y)
|
||||
y += 4.5
|
||||
const actLine = doc.splitTextToSize(
|
||||
` - ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`,
|
||||
cW
|
||||
)
|
||||
doc.text(actLine, m, y)
|
||||
y += actLine.length * 4.5
|
||||
}
|
||||
} else {
|
||||
doc.setTextColor(...PDF_COLORS.textTertiary)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
|
||||
import type { SimulationResult, Process } from '@/domain/types'
|
||||
import type { SimulationResult, Process, ActivitySimResult, Resource, Activity } from '@/domain/types'
|
||||
import { PDF_COLORS, type DocLike } from './pdf-sections-shared'
|
||||
|
||||
// Re-exportar DocLike para compatibilidad de imports existentes (pdf-sections-roi, tests)
|
||||
@@ -228,8 +228,12 @@ export function drawAnalysisSection(
|
||||
|
||||
if (result.perResource.length > 0) {
|
||||
const topResource = [...result.perResource].sort((a, b) => b.totalCost - a.totalCost)[0]
|
||||
doc.text(`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`, m, y)
|
||||
y += 5
|
||||
const resourceLine = doc.splitTextToSize(
|
||||
`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`,
|
||||
cW
|
||||
)
|
||||
doc.text(resourceLine, m, y)
|
||||
y += resourceLine.length * 4.5
|
||||
} else {
|
||||
doc.setTextColor(...PDF_COLORS.textTertiary)
|
||||
doc.text('Sin recursos asignados (costos fijos por actividad)', m, y)
|
||||
@@ -466,6 +470,129 @@ export function drawScenarioKpiGrid(
|
||||
return maxY + 8
|
||||
}
|
||||
|
||||
// ─── Tabla de recursos por actividad ─────────────────────────────────────────
|
||||
|
||||
function _fmtNum(n: number): string {
|
||||
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export function drawResourcesSection(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
doc: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
autoTable: Function,
|
||||
result: { perActivity: ActivitySimResult[] },
|
||||
resources: Resource[],
|
||||
activities: Activity[],
|
||||
currency: string,
|
||||
startY: number
|
||||
): void {
|
||||
let y = startY
|
||||
const m = PDF.margin
|
||||
const cW = PDF.contentW
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('DESGLOSE DE RECURSOS POR ACTIVIDAD', m, y)
|
||||
y += 2
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setLineWidth(0.5)
|
||||
doc.line(m, y, m + cW, y)
|
||||
doc.setLineWidth(0.2)
|
||||
y += 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, y)
|
||||
y += 6
|
||||
|
||||
const resourceMap = new Map(resources.map((r) => [r.id, r]))
|
||||
const activityMap = new Map(activities.map((a) => [a.id, a]))
|
||||
|
||||
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],
|
||||
person: [220, 252, 231],
|
||||
system: [219, 234, 254],
|
||||
equipment: [241, 245, 249],
|
||||
supply: [254, 249, 195],
|
||||
}
|
||||
|
||||
const RA = 'right' as const
|
||||
|
||||
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: `Esp. (${currency})`, styles: { halign: RA, cellWidth: 26 } },
|
||||
]]
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tableBody: any[] = []
|
||||
|
||||
for (const act of result.perActivity) {
|
||||
if (act.resourceCostBreakdown.length === 0) continue
|
||||
|
||||
const activityRow = activityMap.get(act.activityId)
|
||||
|
||||
for (const br of act.resourceCostBreakdown) {
|
||||
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 ? _fmtNum(resource.costPerHour) : '—'
|
||||
const minutes = assignment ? String(assignment.utilizationMinutes) : '—'
|
||||
const units = assignment ? String(assignment.units) : '—'
|
||||
|
||||
// Nombre de actividad repetido por fila — rowSpan no está soportado en body de autotable
|
||||
tableBody.push([
|
||||
{ content: act.activityName },
|
||||
{ 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: _fmtNum(br.cost), styles: { halign: RA, font: 'courier' } },
|
||||
])
|
||||
}
|
||||
|
||||
// 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: _fmtNum(subtotal), styles: { halign: RA, font: 'courier', fontStyle: 'bold', fillColor: [255, 248, 237] } },
|
||||
])
|
||||
}
|
||||
|
||||
if (tableBody.length === 0) return
|
||||
|
||||
autoTable(doc, {
|
||||
head: tableHead,
|
||||
body: tableBody,
|
||||
startY: y,
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
export function addPageNumbers(doc: DocLike, platformName: string, generatedAt: number): void {
|
||||
const total = doc.internal.getNumberOfPages()
|
||||
const { date } = formatSimulationTimestamp(generatedAt)
|
||||
|
||||
154
tests/lib/export/pdf-export-sprint2.test.ts
Normal file
154
tests/lib/export/pdf-export-sprint2.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Tests de comportamiento condicional del PDF de escenario con recursos (Sprint 2).
|
||||
* Verifica que la página extra de recursos aparece (o no) según los datos.
|
||||
*
|
||||
* Usa el mismo patrón de mocks que pdf-export.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ─── Mock de jsPDF ────────────────────────────────────────────────────────────
|
||||
|
||||
const mockDoc = {
|
||||
setProperties: vi.fn(),
|
||||
setFont: vi.fn(), setFontSize: vi.fn(), setTextColor: vi.fn(),
|
||||
setFillColor: vi.fn(), setDrawColor: vi.fn(), setLineWidth: vi.fn(),
|
||||
text: vi.fn(), rect: vi.fn(), roundedRect: vi.fn(), line: vi.fn(),
|
||||
addImage: vi.fn(), addPage: vi.fn(), setPage: vi.fn(), save: vi.fn(),
|
||||
splitTextToSize: vi.fn().mockImplementation((text: string) => [text]),
|
||||
internal: {
|
||||
getNumberOfPages: vi.fn().mockReturnValue(3),
|
||||
pageSize: {
|
||||
getWidth: vi.fn().mockReturnValue(210),
|
||||
getHeight: vi.fn().mockReturnValue(297),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('jspdf', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function MockJsPDF(this: any) { return mockDoc }
|
||||
return { jsPDF: MockJsPDF }
|
||||
})
|
||||
|
||||
vi.mock('jspdf-autotable', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
|
||||
const mockProcess: Process = {
|
||||
id: 'p1', name: 'Test Process', clientName: 'Test Client',
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
|
||||
// Simulación SIN recursos en ninguna actividad
|
||||
const mockSimNoResources: Simulation = {
|
||||
id: 'sim1', processId: 'p1',
|
||||
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
|
||||
result: {
|
||||
totalCost: 1000, totalDirectCost: 800, totalIndirectCost: 200,
|
||||
totalTimeMinutes: 60,
|
||||
perActivity: [{
|
||||
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Tarea A',
|
||||
expectedDirectCost: 800, expectedIndirectCost: 200, expectedTotalCost: 1000,
|
||||
percentOfTotal: 100, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60, resourceCostBreakdown: [],
|
||||
}],
|
||||
perResource: [], warnings: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Simulación CON recursos en una actividad
|
||||
const mockResource: Resource = {
|
||||
id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60,
|
||||
}
|
||||
|
||||
const mockActivity: Activity = {
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task1', name: 'Tarea A', type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}
|
||||
|
||||
const mockSimWithResources: Simulation = {
|
||||
id: 'sim2', processId: 'p1',
|
||||
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
|
||||
result: {
|
||||
totalCost: 1000, totalDirectCost: 800, totalIndirectCost: 200,
|
||||
totalTimeMinutes: 60,
|
||||
perActivity: [{
|
||||
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Tarea A',
|
||||
expectedDirectCost: 860, expectedIndirectCost: 140, expectedTotalCost: 1000,
|
||||
percentOfTotal: 100, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60,
|
||||
resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista', cost: 60 }],
|
||||
}],
|
||||
perResource: [], warnings: [],
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('exportToPdf — página condicional de recursos (Sprint 2)', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('NO llama a drawResourcesSection cuando ninguna actividad tiene recursos', async () => {
|
||||
const autoTable = (await import('jspdf-autotable')).default as ReturnType<typeof vi.fn>
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
|
||||
await exportToPdf({
|
||||
process: mockProcess,
|
||||
simulation: mockSimNoResources,
|
||||
resources: [],
|
||||
heatmapImageData: null,
|
||||
})
|
||||
|
||||
// Sin recursos: autoTable se llama exactamente 1 vez (tabla principal de actividades)
|
||||
expect(autoTable).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('llama a autoTable dos veces cuando hay actividades con recursos', async () => {
|
||||
const autoTable = (await import('jspdf-autotable')).default as ReturnType<typeof vi.fn>
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
|
||||
await exportToPdf({
|
||||
process: mockProcess,
|
||||
simulation: mockSimWithResources,
|
||||
resources: [mockResource],
|
||||
heatmapImageData: null,
|
||||
activities: [mockActivity],
|
||||
})
|
||||
|
||||
// Con recursos: tabla principal + tabla de recursos = 2 llamadas
|
||||
expect(autoTable).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('addPage se llama una vez más cuando hay recursos (página extra)', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
|
||||
await exportToPdf({
|
||||
process: mockProcess,
|
||||
simulation: mockSimNoResources,
|
||||
resources: [],
|
||||
heatmapImageData: null,
|
||||
})
|
||||
const addPageCallsWithout = mockDoc.addPage.mock.calls.length
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
await exportToPdf({
|
||||
process: mockProcess,
|
||||
simulation: mockSimWithResources,
|
||||
resources: [mockResource],
|
||||
heatmapImageData: null,
|
||||
activities: [mockActivity],
|
||||
})
|
||||
const addPageCallsWith = mockDoc.addPage.mock.calls.length
|
||||
|
||||
expect(addPageCallsWith).toBe(addPageCallsWithout + 1)
|
||||
})
|
||||
})
|
||||
124
tests/lib/export/pdf-resources-section.test.ts
Normal file
124
tests/lib/export/pdf-resources-section.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Tests unitarios de drawResourcesSection (Sprint 2, Etapa 6).
|
||||
*
|
||||
* drawResourcesSection toma doc: any y autoTable: Function directamente,
|
||||
* por lo que no necesita mock de jspdf — se pasa un objeto plano.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { ActivitySimResult, Resource, Activity } from '@/domain/types'
|
||||
|
||||
// ─── Mock doc (jsPDF API mínima usada por drawResourcesSection) ───────────────
|
||||
|
||||
const mockDoc = {
|
||||
setFont: vi.fn(),
|
||||
setFontSize: vi.fn(),
|
||||
setTextColor: vi.fn(),
|
||||
setDrawColor: vi.fn(),
|
||||
setLineWidth: vi.fn(),
|
||||
text: vi.fn(),
|
||||
line: vi.fn(),
|
||||
}
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockResource: Resource = {
|
||||
id: 'r1', processId: 'p1', name: 'Analista Senior', type: 'role', costPerHour: 60,
|
||||
}
|
||||
|
||||
const mockActivity: Activity = {
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task1', name: 'Recibir solicitud', type: 'task',
|
||||
directCostFixed: 600, executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}
|
||||
|
||||
const activityWithResources: ActivitySimResult = {
|
||||
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
|
||||
expectedDirectCost: 660, expectedIndirectCost: 0, expectedTotalCost: 660,
|
||||
percentOfTotal: 100, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60,
|
||||
resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista Senior', cost: 60 }],
|
||||
}
|
||||
|
||||
const activityWithoutResources: ActivitySimResult = {
|
||||
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Archivar',
|
||||
expectedDirectCost: 100, expectedIndirectCost: 0, expectedTotalCost: 100,
|
||||
percentOfTotal: 0, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 10, resourceCostBreakdown: [],
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('drawResourcesSection', () => {
|
||||
let autoTableMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
autoTableMock = vi.fn()
|
||||
})
|
||||
|
||||
it('no lanza error con actividades que tienen recursos', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
expect(() =>
|
||||
drawResourcesSection(
|
||||
mockDoc,
|
||||
autoTableMock,
|
||||
{ perActivity: [activityWithResources] },
|
||||
[mockResource],
|
||||
[mockActivity],
|
||||
'USD',
|
||||
40
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(autoTableMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('no llama a autoTable si todas las actividades tienen resourceCostBreakdown vacío', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
drawResourcesSection(
|
||||
mockDoc,
|
||||
autoTableMock,
|
||||
{ perActivity: [activityWithoutResources] },
|
||||
[mockResource],
|
||||
[mockActivity],
|
||||
'USD',
|
||||
40
|
||||
)
|
||||
expect(autoTableMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('el header de autoTable incluye la moneda correcta', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
drawResourcesSection(
|
||||
mockDoc,
|
||||
autoTableMock,
|
||||
{ perActivity: [activityWithResources] },
|
||||
[mockResource],
|
||||
[mockActivity],
|
||||
'PYG',
|
||||
40
|
||||
)
|
||||
const callArgs = autoTableMock.mock.calls[0][1] as { head: unknown[][] }
|
||||
const headFlat = JSON.stringify(callArgs.head)
|
||||
expect(headFlat).toContain('PYG')
|
||||
})
|
||||
|
||||
it('llama a autoTable exactamente una vez aunque haya varias actividades con recursos', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
const secondActivity: ActivitySimResult = {
|
||||
...activityWithResources,
|
||||
activityId: 'a3', activityName: 'Evaluar solicitud',
|
||||
resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista Senior', cost: 30 }],
|
||||
}
|
||||
drawResourcesSection(
|
||||
mockDoc,
|
||||
autoTableMock,
|
||||
{ perActivity: [activityWithResources, secondActivity] },
|
||||
[mockResource],
|
||||
[mockActivity],
|
||||
'USD',
|
||||
40
|
||||
)
|
||||
expect(autoTableMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user