From 8b6ae5fa6438d685ffd010c9935aefe9c7e76c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Tue, 19 May 2026 22:08:00 -0300 Subject: [PATCH] Fix etapa 3 --- BACKLOG.md | 6 + sprints/sprint-2/ETAPA_3_PROMPT.md | 237 ++++++++++++++++++ src/features/report/ActivitiesTable.tsx | 140 ++++++++--- .../report/CumulativeSavingsChart.tsx | 2 + src/features/workspace/BpmnCanvas.tsx | 6 +- 5 files changed, 351 insertions(+), 40 deletions(-) create mode 100644 sprints/sprint-2/ETAPA_3_PROMPT.md diff --git a/BACKLOG.md b/BACKLOG.md index 415b510..99ea64c 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -20,6 +20,9 @@ Al cerrar un sprint, los ítems `[SPRINT-N]` se promocionan a `[SPRINT-(N+1)]` o ## Features de alta prioridad +### [BACKLOG] Indicador de simulación desactualizada +Cuando el usuario guarda cambios en una actividad (costos, recursos, automatización) pero no vuelve a simular, el reporte muestra resultados del estado anterior sin ningún aviso. Agregar un indicador sutil ("Hay cambios sin simular" o badge en el botón Simular) que desaparezca al ejecutar la simulación. Detectado en uso real durante Sprint 2 — genera confusión al no ver los nuevos costos reflejados en el reporte. + ### [BACKLOG] Múltiples simulaciones por proceso Historial de simulaciones con comparación de escenarios. @@ -54,6 +57,9 @@ Manifest + service worker para uso offline. ### [BACKLOG] Zoom/pan del heatmap desde el reporte Hoy es visualización estática. Permitir interacción. +### [BACKLOG] Controles de zoom en canvas BPMN (fit-to-screen / reset zoom) +El canvas puede quedar muy zoomed out sin forma de volver al estado inicial. bpmn-js expone `canvas.zoom('fit-viewport')` — agregar botón "Encuadrar diagrama" o equivalente. Detectado en validación visual Sprint 2 Etapa 3. + ### [BACKLOG] Detalle de recursos por actividad en el PDF Tabla de costos por recurso asignado dentro de cada actividad. diff --git a/sprints/sprint-2/ETAPA_3_PROMPT.md b/sprints/sprint-2/ETAPA_3_PROMPT.md new file mode 100644 index 0000000..af94cc4 --- /dev/null +++ b/sprints/sprint-2/ETAPA_3_PROMPT.md @@ -0,0 +1,237 @@ +# Sprint 2 — Etapa 3: Reporte web + cierre de deuda técnica + +> **Tipo:** UI de reporte + deuda técnica. Sin cambios en dominio, persistencia ni workspace. +> **Objetivo:** agregar desglose de recursos por actividad en la tabla del reporte, y cerrar los tres ítems de TECH_DEBT pendientes (CumulativeSavingsChart, payback label, badge ⚡). +> **Resultado esperado:** tabla con filas expandibles mostrando el desglose de costos, gráfico financieramente correcto, badge evaluado. Validación visual del director requerida antes del OK formal. + +--- + +## Archivos a modificar + +``` +src/features/report/ActivitiesTable.tsx ← principal (expandable rows) +src/features/report/CumulativeSavingsChart.tsx ← verificar -inversión + payback label +src/features/workspace/BpmnCanvas.tsx ← badge ⚡ tamaño (si la decisión es cambiar) +``` + +Si durante la implementación identificás que otro archivo necesita cambio, **detenete y reportá el conflicto antes de tocarlo**. + +--- + +## Feature 1 — ActivitiesTable: filas expandibles con desglose de recursos + +### Contexto + +`ActivitySimResult` ya incluye `resourceCostBreakdown: Array<{ resourceId, resourceName, cost }>`. Los datos están disponibles — solo falta mostrarlos. + +El `expectedDirectCost` actual ya incluye el costo de recursos (es `fixedCost + resourceCost`). Lo que la tabla no muestra es el desglose interno de ese costo directo. + +### Comportamiento esperado + +- Cada fila de la tabla tiene un chevron expandible a la izquierda del indicador de color +- Al hacer click en la fila (o en el chevron), se expande una fila secundaria inline +- La fila expandida muestra el desglose del costo directo: + - Fijo (insumos, licencias): `directCostFixed` — **nota:** este valor no está en `ActivitySimResult`, ver abajo + - Cada recurso: nombre + costo esperado (ya en `resourceCostBreakdown[].cost`) +- Si `resourceCostBreakdown` está vacío, la fila sigue siendo expandible pero muestra "Sin recursos asignados — costo es 100% fijo" +- Si `resourceCostBreakdown` está vacío Y `expectedDirectCost === 0`, no mostrar chevron (no hay desglose útil) + +### Sobre el costo fijo vs. costo de recursos + +`ActivitySimResult` tiene `expectedDirectCost` (total) y `resourceCostBreakdown` (recursos). El costo fijo esperado = `expectedDirectCost - sum(resourceCostBreakdown[].cost)`. + +**No tocar el motor de simulación ni los tipos.** Calcular el costo fijo esperado en el componente con esa resta. + +### Cambios en ActivitiesTable.tsx + +**Agregar al estado local:** +```tsx +const [expandedRows, setExpandedRows] = useState>(new Set()) + +function toggleRow(activityId: string) { + setExpandedRows(prev => { + const next = new Set(prev) + if (next.has(activityId)) next.delete(activityId) + else next.add(activityId) + return next + }) +} +``` + +**Cambio en el header:** reemplazar la columna de color `` por una columna ligeramente más ancha que incluye tanto el chevron como el indicador de color. O bien agregar una columna de chevron antes del indicador. + +**En cada fila:** reemplazar el `onClick={() => onRowClick(act.bpmnElementId)}` para manejar expansión Y selección en el heatmap. La expansión se dispara desde el chevron, el click en la fila sigue navegando al heatmap. + +**Fila expandida** (insertar como `` adicional inmediatamente después de la fila principal cuando está expandida): +```tsx +{isExpanded && ( + + {/* chevron vacío */} + +
+ {/* Costo fijo */} + {fixedCostExpected > 0 && ( +
+ Fijo (insumos, licencias) + {formatCurrency(fixedCostExpected, currency)} +
+ )} + {/* Recursos */} + {act.resourceCostBreakdown.map((r) => ( +
+ {r.resourceName} + {formatCurrency(r.cost, currency)} +
+ ))} + {/* Si solo hay costo fijo */} + {act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && ( +

Sin desglose disponible

+ )} +
+
+
+)} +``` + +### Versión con wow + +El chevron rota 90° al expandir (transición CSS). El color del desglose de recursos usa un tono ligeramente más cálido para el total (naranja muy sutil, no el naranja InQ completo). + +--- + +## Feature 2 — CumulativeSavingsChart: verificar -inversión + fix payback label + +### 2.1 Verificación de -inversión + +Antes de tocar el archivo, verificar la línea: +```typescript +const values = years.map((y) => roi.annualSavings * y - investment) +``` + +Cuando `y=0`: `0 - investment = -investment`. **Si esto es lo que hay en el código, el ítem de TECH_DEBT ya está resuelto.** Documentarlo en el reporte y cerrar el ítem. No hacer ningún cambio en ese aspecto. + +Si por alguna razón el código es diferente (ej: `roi.annualSavings * y` sin restar la inversión), corregirlo. + +### 2.2 Fix del payback label superpuesto + +El label actual tiene `position: 'insideEndTop'`. Cuando el payback cae en años tempranos (cerca de x=0), el label se superpone con los labels del eje Y izquierdo. + +Cambiar a `position: 'end'` con un offset explícito, o usar `position: 'insideStartTop'` con padding. La solución más robusta: + +```typescript +label: { + formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`, + position: 'insideEndTop', + fontSize: 10, + color: '#16A34A', + padding: [0, 0, 0, 8], // padding izquierdo para separar del eje Y + offset: [4, 0], +}, +``` + +Verificar visualmente que el label no colisione con el eje Y en casos de payback temprano (año 1) ni tardío (año 3). + +--- + +## Feature 3 — Badge ⚡: evaluar tamaño + +### Contexto + +El badge actual es 14×14px (`width:14px; height:14px; line-height:14px` en `makeBadgeHtml()`). El director observó "podría ser un poquito más grande". + +### Decisión a tomar en la implementación + +1. Cambiar a 16×16px +2. Verificar visualmente que no colisione con bordes de nodos chicos del BPMN (los nodos de evento son típicamente 36px de diámetro) +3. Si no hay colisión: mantener 16×16px y reportar +4. Si hay colisión visible: revertir a 14×14px y documentar por qué + +El badge se posiciona 8px fuera del borde derecho del nodo. Con 16×16px, verificar también que ese offset siga siendo visualmente correcto. + +--- + +## 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/report/ActivitiesTable.tsx +src/features/report/CumulativeSavingsChart.tsx +src/features/workspace/BpmnCanvas.tsx ← solo si se cambió el badge +``` + +Si aparece cualquier archivo no listado: +1. Revertilo: `git checkout HEAD -- ` +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 (cambios de UI pura y fix trivial de layout). + +### 4. Smoke test visual +- [ ] Tabla del reporte: chevron visible en actividades con datos +- [ ] Expandir una actividad con recursos: muestra desglose fijo + recursos +- [ ] Expandir una actividad sin recursos: muestra "Sin recursos asignados" +- [ ] CumulativeSavingsChart: el gráfico arranca debajo de 0 cuando hay inversión +- [ ] Payback label: no colisiona con eje Y +- [ ] Badge ⚡: tamaño correcto, sin colisión con nodos chicos + +--- + +## Alcance estricto — lo que NO entra en esta etapa + +- ❌ `ActivityPanel.tsx` ni `ResourcesPanel.tsx` — Etapa 2 ya los cerró +- ❌ Edición inline de proceso/cliente — Etapa 4 +- ❌ PDFs — Etapa 5 +- ❌ Indicador "datos pendientes de simular" — BACKLOG (requiere decisión de UX) +- ❌ `ComparisonTable.tsx` — no está en scope de esta etapa +- ❌ Cualquier cambio en dominio (`src/domain/`) o persistencia (`src/persistence/`) + +--- + +## Reporte esperado al terminar + +``` +## Etapa 3 completada + +### Archivos modificados +- src/features/report/ActivitiesTable.tsx — filas expandibles con desglose +- src/features/report/CumulativeSavingsChart.tsx — [qué se cambió] +- src/features/workspace/BpmnCanvas.tsx — [badge 14→16px / mantenido en 14px, razón] + +### Cambios realizados +- ActivitiesTable: expandable rows con chevron, desglose fijo + recursos +- CumulativeSavingsChart -inversión: [ya estaba correcto / corregido] +- Payback label: offset ajustado, sin colisión +- Badge: [16px sin colisión / mantenido 14px por colisión en nodos chicos] + +### TECH_DEBT cerrados +- CumulativeSavingsChart arranca en -inversión: ✅ [ya estaba / corregido] +- Payback label superpuesto: ✅ corregido +- Badge ⚡ tamaño: ✅ [16px / 14px mantenido con razón] + +### Verificaciones +- git diff --name-only HEAD: [lista de archivos] ✅ +- 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 para Etapas 4+] +``` diff --git a/src/features/report/ActivitiesTable.tsx b/src/features/report/ActivitiesTable.tsx index 0705f16..7fd2263 100644 --- a/src/features/report/ActivitiesTable.tsx +++ b/src/features/report/ActivitiesTable.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' +import { ArrowUpDown, ArrowUp, ArrowDown, ChevronRight } from 'lucide-react' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { activityColor } from '@/lib/colors' import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format' @@ -26,6 +26,7 @@ function SortIcon({ column, sortKey, dir }: { column: SortKey; sortKey: SortKey; export function ActivitiesTable({ perActivity, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) { const [sortKey, setSortKey] = useState('expectedTotalCost') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') + const [expandedRows, setExpandedRows] = useState>(new Set()) const rowRefs = useRef(new Map()) function handleSort(key: SortKey) { @@ -37,6 +38,15 @@ export function ActivitiesTable({ perActivity, mode, currency, highlightedId, on } } + function toggleRow(activityId: string) { + setExpandedRows(prev => { + const next = new Set(prev) + if (next.has(activityId)) next.delete(activityId) + else next.add(activityId) + return next + }) + } + const sorted = useMemo(() => { return [...perActivity].sort((a, b) => { const av = a[sortKey] @@ -73,7 +83,7 @@ export function ActivitiesTable({ perActivity, mode, currency, highlightedId, on - +
@@ -87,43 +97,99 @@ export function ActivitiesTable({ perActivity, mode, currency, highlightedId, on {sorted.map((act) => { const color = activityColor(act, perActivity, mode) const isHighlighted = act.bpmnElementId === highlightedId + const isExpanded = expandedRows.has(act.activityId) + + // Costo fijo esperado = total directo − suma de costos de recursos + const resourceTotal = act.resourceCostBreakdown.reduce((s, r) => s + r.cost, 0) + const fixedCostExpected = act.expectedDirectCost - resourceTotal + + // Mostrar chevron a menos que no haya absolutamente nada que mostrar + const hasBreakdown = !(act.resourceCostBreakdown.length === 0 && act.expectedDirectCost === 0) return ( - { if (el) rowRefs.current.set(act.bpmnElementId, el) }} - onClick={() => onRowClick(act.bpmnElementId)} - className={`cursor-pointer ${isHighlighted ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`} - data-state={isHighlighted ? 'selected' : undefined} - > - -
- - - {act.activityName} - - - {formatCurrency(act.expectedDirectCost, currency)} - - - {formatCurrency(act.expectedIndirectCost, currency)} - - - {formatCurrency(act.expectedTotalCost, currency)} - - - {formatPercent(act.percentOfTotal)} - - - {act.executionTimeMinutes > 0 ? formatMinutes(act.executionTimeMinutes) : '—'} - - - {formatPercent(act.executionProbability * 100)} - - + <> + { if (el) rowRefs.current.set(act.bpmnElementId, el) }} + onClick={() => onRowClick(act.bpmnElementId)} + className={`cursor-pointer ${isHighlighted ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`} + data-state={isHighlighted ? 'selected' : undefined} + > + {/* Chevron + indicador de color en la misma celda */} + +
+ {hasBreakdown ? ( + + ) : ( + + )} +
+
+ + + {act.activityName} + + + {formatCurrency(act.expectedDirectCost, currency)} + + + {formatCurrency(act.expectedIndirectCost, currency)} + + + {formatCurrency(act.expectedTotalCost, currency)} + + + {formatPercent(act.percentOfTotal)} + + + {act.executionTimeMinutes > 0 ? formatMinutes(act.executionTimeMinutes) : '—'} + + + {formatPercent(act.executionProbability * 100)} + + + + {/* Fila expandida con desglose de costos */} + {isExpanded && ( + + + +
+ {fixedCostExpected > 0 && ( +
+ Fijo (insumos, licencias) + {formatCurrency(fixedCostExpected, currency)} +
+ )} + {act.resourceCostBreakdown.map((r) => ( +
+ {r.resourceName} + {formatCurrency(r.cost, currency)} +
+ ))} + {act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && ( +

Sin desglose disponible

+ )} + {act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && ( +

Sin recursos asignados — costo es 100% fijo

+ )} +
+
+
+ )} + ) })} diff --git a/src/features/report/CumulativeSavingsChart.tsx b/src/features/report/CumulativeSavingsChart.tsx index 92ad3c3..de809e6 100644 --- a/src/features/report/CumulativeSavingsChart.tsx +++ b/src/features/report/CumulativeSavingsChart.tsx @@ -30,6 +30,8 @@ export function CumulativeSavingsChart({ position: 'insideEndTop', fontSize: 10, color: '#16A34A', + padding: [0, 0, 0, 8], + offset: [4, 0], }, lineStyle: { color: '#16A34A', type: 'dashed', width: 2 }, }) diff --git a/src/features/workspace/BpmnCanvas.tsx b/src/features/workspace/BpmnCanvas.tsx index 7f4bec7..5801e5d 100644 --- a/src/features/workspace/BpmnCanvas.tsx +++ b/src/features/workspace/BpmnCanvas.tsx @@ -24,15 +24,15 @@ function classifyElement(type: string): BpmnElementCategory { // Emoji ⚡ como símbolo provisional — reemplazo por agendado Sprint 2-3. function makeBadgeHtml(): string { return `