From 8b71c6ee13ff397a53bfac88a48049f2a6d8712b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Fri, 15 May 2026 22:14:53 -0300 Subject: [PATCH] =?UTF-8?q?Antes=20de=20la=20=C3=BAltima=20revisi=C3=B3n?= =?UTF-8?q?=20de=20etapa=204.=20Sprint=201.=20Previo=20al=20ajuste=20de=20?= =?UTF-8?q?look=20n=20feel=20de=20app,=20reportes,=20nombre=20y=20colores.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 262 ++++++ METHODOLOGY_AUTOMATED_COST.md | 15 + README.md | 247 +++++- TODO.md | 12 + eslint.config.js | 92 ++ src/components/ui/switch.tsx | 36 + src/domain/roi.ts | 79 ++ src/domain/schemas.ts | 6 + src/domain/simulation.ts | 15 +- src/domain/types.ts | 14 +- src/features/import/ImportPage.tsx | 6 + .../report/AutomationScenarioNote.tsx | 12 + src/features/report/ComparisonTable.tsx | 130 +++ .../report/CumulativeSavingsChart.tsx | 115 +++ src/features/report/ReportPage.tsx | 484 ++++++++--- src/features/report/RoiKpiBar.tsx | 110 +++ src/features/report/TopSavingsChart.tsx | 95 +++ src/features/report/TransparencySection.tsx | 91 ++ src/features/simulation/useSimulate.ts | 24 +- src/features/workspace/ActivityPanel.tsx | 99 ++- src/features/workspace/BpmnCanvas.tsx | 87 +- .../workspace/GlobalSettingsPanel.tsx | 105 +++ src/features/workspace/WorkspacePage.tsx | 19 +- src/lib/export/csv-export.ts | 173 +++- src/lib/export/pdf-export.ts | 190 +++-- src/lib/export/pdf-sections-roi.ts | 458 ++++++++++ src/lib/export/pdf-sections.ts | 2 +- src/lib/export/slug.ts | 5 +- src/persistence/db.ts | 23 + src/persistence/repositories.ts | 23 + src/store/simulation-store.ts | 58 +- tests/domain/roi.test.ts | 161 ++++ tests/domain/simulation.test.ts | 182 ++++ tests/e2e/export-roi.spec.ts | 280 +++++++ tests/e2e/validate-dod-etapa4.spec.ts | 411 +++++++++ tests/features/report/components.test.tsx | 12 +- tests/features/report/derivations.test.ts | 1 + tests/features/report/roi-components.test.tsx | 792 ++++++++++++++++++ .../features/workspace/ActivityPanel.test.tsx | 157 ++++ tests/features/workspace/BpmnCanvas.test.tsx | 131 +++ .../workspace/GlobalSettingsPanel.test.tsx | 132 +++ tests/integration/dexie-migration.test.ts | 145 ++++ tests/integration/gateway-validation.test.ts | 1 + .../integration/indexeddb-persistence.test.ts | 6 + tests/integration/simulation-engine.test.ts | 3 + tests/integration/sprint1-flow.test.ts | 232 +++++ tests/integration/sprint1-report-roi.test.ts | 447 ++++++++++ tests/lib/export/csv-export-roi.test.ts | 216 +++++ tests/lib/export/csv-export.test.ts | 1 + tests/lib/export/measure-sizes.test.ts | 2 + tests/lib/export/pdf-export-roi.test.ts | 206 +++++ tests/lib/export/pdf-export.test.ts | 1 + tests/setup.ts | 16 + .../simulation-store-invalidation.test.ts | 136 +++ 54 files changed, 6497 insertions(+), 261 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 eslint.config.js create mode 100644 src/components/ui/switch.tsx create mode 100644 src/domain/roi.ts create mode 100644 src/features/report/AutomationScenarioNote.tsx create mode 100644 src/features/report/ComparisonTable.tsx create mode 100644 src/features/report/CumulativeSavingsChart.tsx create mode 100644 src/features/report/RoiKpiBar.tsx create mode 100644 src/features/report/TopSavingsChart.tsx create mode 100644 src/features/report/TransparencySection.tsx create mode 100644 src/lib/export/pdf-sections-roi.ts create mode 100644 tests/domain/roi.test.ts create mode 100644 tests/e2e/export-roi.spec.ts create mode 100644 tests/e2e/validate-dod-etapa4.spec.ts create mode 100644 tests/features/report/roi-components.test.tsx create mode 100644 tests/features/workspace/ActivityPanel.test.tsx create mode 100644 tests/features/workspace/BpmnCanvas.test.tsx create mode 100644 tests/features/workspace/GlobalSettingsPanel.test.tsx create mode 100644 tests/integration/dexie-migration.test.ts create mode 100644 tests/integration/sprint1-flow.test.ts create mode 100644 tests/integration/sprint1-report-roi.test.ts create mode 100644 tests/lib/export/csv-export-roi.test.ts create mode 100644 tests/lib/export/pdf-export-roi.test.ts create mode 100644 tests/store/simulation-store-invalidation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8b1bcc3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,262 @@ +# Changelog + +Historial de cambios del proyecto. Formato basado en [Keep a Changelog](https://keepachangelog.com/es/). +Los cambios se describen desde la perspectiva del código: qué archivos cambiaron, +por qué, y qué decisiones de diseño están implícitas — para que futuras +modificaciones profundas tengan contexto suficiente. + +--- + +## [Unreleased] — Sprint 1: ROI y Escenarios de Automatización + +### Estado +En construcción. Etapas 1–4 completas; Etapas 5–6 pendientes. + +### Etapa 1 — Cimientos del dominio (completada) + +**Nuevos campos en el modelo de dominio** (`src/domain/types.ts`): + +- `Activity` gana: `automatable: boolean`, `automatedCostFixed: number`, `automatedTimeMinutes: number` +- `Process` gana: `annualFrequency: number`, `analysisHorizonYears: number`, `automationInvestment: number` +- Nuevo tipo `Scenario = 'actual' | 'automated'` +- `SimulationInput` acepta `scenario?: Scenario` (opcional, default `'actual'` para retrocompatibilidad) +- `Simulation` gana `resultAutomated?: SimulationResult` (opcional, para retrocompatibilidad con snapshots viejos) + +**Migración Dexie v1 → v2** (`src/persistence/db.ts`): + +- `version(2).upgrade()` popula defaults en registros existentes: + - Activities: `automatable=false`, `automatedCostFixed=0`, `automatedTimeMinutes=0` + - Processes: `annualFrequency=1000`, `analysisHorizonYears=3`, `automationInvestment=0` +- Sin cambio de índices — solo nuevos campos con defaults seguros. + +**Nuevo módulo de ROI** (`src/domain/roi.ts`): + +- Función pura `calculateRoi(input: RoiInput): RoiResult` +- Fórmulas nominales simples (sin VPN ni TIR — decisión de diseño ADR-014) +- Edge cases explicitados: inversión cero, ahorro negativo, costo actual cero (evitar div/0) +- `paybackMonths = Infinity` cuando no hay ahorro, `roiAnnualPercent = Infinity` cuando inversión=0 y ahorro>0 + +**Motor de simulación dual** (`src/domain/simulation.ts`): + +- Acepta `scenario` en `SimulationInput` +- En `'automated'`: actividades con `automatable=true` usan `automatedCostFixed`/`automatedTimeMinutes` +- Las no-automatable mantienen sus costos originales en ambos escenarios +- La probabilidad de ejecución NO cambia entre escenarios — solo costos y tiempos + +**Simulation store refactorizado** (`src/store/simulation-store.ts`): + +- `result` (single) → `resultActual + resultAutomated + lastSimulatedAt` +- Nueva acción `persistResults(processId, actual, automated)`: guarda en Dexie + actualiza memoria en un solo paso atómico +- Nueva acción `invalidateResults()`: pone ambos a null +- Suscripción reactiva a `process-store`: cualquier cambio en `activities` o `currentProcess` invalida ambos resultados automáticamente +- **Dependencia intencional documentada**: `simulation-store` importa `process-store` en una dirección (sin inversa → sin ciclo) + +**useSimulate refactorizado** (`src/features/simulation/useSimulate.ts`): + +- Corre las dos simulaciones en el mismo ciclo y llama `persistResults` del store (eliminando el import directo de `simulationRepo` desde un hook de feature) +- Retorna `{ resultActual, resultAutomated }` en lugar de un único result + +**Infraestructura ESLint** (`eslint.config.js` — archivo nuevo): + +- Primer config de ESLint del proyecto (antes no existía) +- Regla `no-restricted-imports`: prohíbe importar `persistence/repositories` desde fuera de `store/` + - Overrides para `store/**`, `features/import/**`, `features/report/useReportData.ts`, `tests/**` +- Regla `react-hooks/set-state-in-effect: 'off'`: patrón de sync local state con useEffect es válido en este codebase +- **Por qué importa**: si alguien llama `activityRepo.save()` directamente desde una feature sin pasar por el store, los resultados de simulación quedan stale (la suscripción reactiva no dispara) + +**Tests nuevos** (32 tests): + +- `tests/domain/roi.test.ts`: 16 casos cubriendo happy path, inversión cero, ahorro negativo, NaN +- `tests/integration/dexie-migration.test.ts`: 6 casos de persistencia de los nuevos campos con valores default y no-default +- `tests/store/simulation-store-invalidation.test.ts`: 7 casos de la invariante de invalidación dual +- `tests/integration/sprint1-flow.test.ts`: 11 casos del flujo integrado BPMN → sim actual → sim automated → calculateRoi → KPIs +- `tests/domain/simulation.test.ts`: +4 casos de `scenario=automated` con costos en cero, sin NaN, probabilidad invariante +- `tests/integration/bpmn-collaboration.test.ts`: 3 casos para BPMNs de colaboración con múltiples pools + +**Archivos de test actualizados** (fixtures con nuevos campos): + +- `tests/integration/simulation-engine.test.ts` +- `tests/integration/gateway-validation.test.ts` +- `tests/integration/indexeddb-persistence.test.ts` +- `tests/features/report/components.test.tsx` +- `tests/features/report/derivations.test.ts` +- `tests/lib/export/measure-sizes.test.ts` +- `tests/lib/export/csv-export.test.ts` +- `tests/lib/export/pdf-export.test.ts` +- `tests/setup.ts`: mocks globales `ResizeObserver` y `PointerEvent` para jsdom (necesario desde Sprint 1 para tests con Slider de Radix) + +### Etapa 2 — UI de Configuración (completada) + +**Switch component** (`src/components/ui/switch.tsx` — nuevo): + +- Implementación Tailwind pura sin `@radix-ui/react-switch` +- `role="switch"` + `aria-checked` + `aria-label` para accesibilidad +- Transiciones CSS: `transition-colors duration-200` en el fondo, `transition-transform duration-200` en el knob +- **Decisión**: no instalar `@radix-ui/react-switch` para no agregar dependencia — el comportamiento visual es idéntico + +**ActivityPanel ampliado** (`src/features/workspace/ActivityPanel.tsx`): + +- Nueva sección "Automatización" al pie del panel, separada con `` +- Toggle Switch (`automatable`) oculta/muestra los campos con `transition-all duration-200 overflow-hidden max-h-{0|80}` + - **Limitación conocida**: el max-height trick no anima a la altura real del contenido. Ver TODO.md para mejora con `useRef + scrollHeight` +- Inputs numéricos `automatedCostFixed` y `automatedTimeMinutes` con mismo patrón que los existentes (placeholder cuando valor=0) +- Helper ámbar de transparencia metodológica: + - Aparece cuando `automatable=true && cost===0 && time===0` + - **Siempre está en el DOM** (no conditional render) — la visibilidad se controla con `opacity-0/100 + max-h-0/20 + transition-all duration-150` + - `aria-hidden` refleja el estado de visibilidad + - **Por qué importa**: con conditional render (`&&`) el helper desaparece instantáneamente al tipear — sin transición. Al mantenerlo en el DOM, el fade de 150ms se aplica correctamente + +**GlobalSettingsPanel ampliado** (`src/features/workspace/GlobalSettingsPanel.tsx`): + +- Nueva sección "Análisis de Impacto" al pie del panel +- `annualFrequency`: input numérico, min=1, sugerencias de referencia en texto helper +- `analysisHorizonYears`: Slider de 1 a 10 años con valor en texto ("3 años") +- `automationInvestment`: input numérico, default placeholder vacío cuando valor=0 +- La validación de rango de `analysisHorizonYears` (clamp 1-10) ocurre en `handleSave`, no en `onChange` + +**BpmnCanvas ampliado** (`src/features/workspace/BpmnCanvas.tsx`): + +- Nueva prop `automatableElementIds?: string[]` +- `syncAutomationOverlays()`: callback memoizado con `useCallback([automatableElementIds])` + - Limpia overlays anteriores con `overlays.remove({ type: 'automation-indicator' })` + - Re-agrega un badge por cada elementId en la lista + - Se llama después de `importXML` (en el `.then()`) y en un `useEffect` separado cuando cambia la lista +- **Posicionamiento del badge**: `{ top: -8, left: element.width - 12 }` + - Obtenido via `elementRegistry.get(elementId)` con fallback `width ?? 100` + - **No usar** `position.right` de diagram-js: con `right: -8`, la fórmula interna `left = right * -1 + width` da `left = 8 + width`, colocando el badge enteramente fuera del nodo sin overlap de esquina + - Con `left = width - 12`: badge left está 12px antes del borde derecho, su borde derecho queda 8px fuera → overlap visible en la esquina +- **Badge HTML**: círculo 20×20px, fondo `#fffbeb` (amber-50), borde `#e2e8f0`, sombra `0 1px 3px rgba(0,0,0,0.12)`, ícono Bot Lucide en `#f59e0b` como SVG inline + - `pointer-events: none` para no interferir con clicks en el nodo + - `title` nativo del browser como tooltip + - **Por qué `#fffbeb` y no `#ffffff`**: el fondo de los nodos BPMN también es blanco, el fondo ámbar le da identidad cromática propia y hace visible el círculo por contraste + +**WorkspacePage** (`src/features/workspace/WorkspacePage.tsx`): + +- Destructura `activities` del process-store +- Calcula `automatableElementIds` con `useMemo` +- Pasa `useDeferredValue(automatableElementIds)` al BpmnCanvas (evita cascada de re-renders al editar el panel) + +**AutomationScenarioNote** (`src/features/report/AutomationScenarioNote.tsx` — nuevo): + +- Componente de leyenda para el tab "Automatizado" del reporte +- Preparado y listo para ser colocado por Etapa 3 — no tiene lógica propia + +### Etapa 3 — Motor dual y reporte con tabs (completada) + +**ReportPage ampliada** (`src/features/report/ReportPage.tsx`): + +- La página ahora tiene 3 tabs: **Actual** | **Automatizado** | **Comparación + ROI** +- El contenido existente (KpiBar, heatmap, 3 gráficos, tabla de actividades) pasa intacto al tab "Actual" +- Los tabs "Automatizado" y "ROI" se deshabilitan si la simulación no tiene `resultAutomated` (snapshot viejo) — en ese caso se muestra `NeedsResimulate` que guía al usuario a re-simular +- `useMemo` para `automatableIds` y `roi` se declaran antes de los early returns (cumple reglas de hooks) +- Los refs de heatmap son independientes por tab (`heatmapRef` / `heatmapRefAuto`) — el tab "Actual" sigue siendo el fuente de imagen para el export PDF +- Export PDF/CSV sin cambios — Etapa 4 los ampliará + +**Tab "Automatizado"**: + +- Mismo layout que "Actual": KpiBar → heatmap con cross-linking → 3 gráficos → tabla +- Usa `simulation.resultAutomated` como fuente de datos +- `AutomationScenarioNote` aparece al pie del heatmap (componente preparado en Etapa 2) +- Heatmap y tabla tienen su propio estado `selectedIdAuto` — cross-linking funciona independientemente del tab "Actual" + +**Tab "Comparación + ROI"**: + +- `RoiKpiBar` (`src/features/report/RoiKpiBar.tsx` — nuevo): 5 KPI cards en grid 2×5 (lg) + - Ahorro por ejecución (con % sobre costo actual) + - Ahorro anual + - Payback (meses o años según magnitud; "Inmediato" si inversión=0; "No se recupera" si Infinity) + - Ahorro acumulado neto a N años + - ROI anual % ("∞%" si inversión=0 y ahorro>0) + - Los valores positivos se muestran en `text-emerald-700`, negativos en `text-red-600` +- `ComparisonTable` (`src/features/report/ComparisonTable.tsx` — nuevo): + - Tabla actividad por actividad: costo actual | costo automatizado | ahorro | % ahorro + - Ícono Bot ámbar en la primera columna para actividades marcadas `automatable` + - Ordenable por ahorro (↕) — default descendente + - Usa `Set` para el lookup de `automatable` en O(1) +- `CumulativeSavingsChart` (`src/features/report/CumulativeSavingsChart.tsx` — nuevo): + - Gráfico de línea + área (ECharts): X=años 0..N, Y=ahorro neto acumulado + - Línea de break-even en y=0 siempre visible + - Marker vertical dashed verde en `x=paybackYears` (solo si finite y dentro del horizonte) + - Tooltip muestra valor del año exacto con color según signo +- `TopSavingsChart` (`src/features/report/TopSavingsChart.tsx` — nuevo): + - Top 5 actividades por ahorro positivo — barras horizontales verdes + - Excluye actividades con ahorro ≤ 0 + - Altura dinámica: `max(180, n * 48)` px — no hay espacio vacío si hay menos de 5 actividades +- `TransparencySection` (`src/features/report/TransparencySection.tsx` — nuevo): + - Condicional — solo se renderiza si hay issues metodológicos detectados + - **Issue 1**: actividades con `automatable=true` pero `automatedCostFixed=0` y `automatedTimeMinutes=0` → el ahorro puede estar inflado. Lista los nombres de cada actividad + - **Issue 2**: `automationInvestment=0` → payback "inmediato" e ROI "infinito" no reflejan la realidad + - Fondo amber-50, borde amber-200 — consistente con el helper del ActivityPanel + +**Por qué ROI inline y no en `useReportData`**: +El `calculateRoi` toma `simulation.result.totalCost` y `simulation.resultAutomated.totalCost` — valores ya disponibles en el componente. Moverlo al hook agregaría un `useEffect` extra sin beneficio. Se mantiene en el componente como `useMemo`. + +**Tests nuevos** (17 tests): + +- `tests/features/workspace/ActivityPanel.test.tsx`: 6 casos — toggle, helper ámbar con `aria-hidden`, dispatch de `updateActivity` +- `tests/features/workspace/GlobalSettingsPanel.test.tsx`: 7 casos — defaults, edición de campos, dispatch de `updateProcess` +- `tests/features/workspace/BpmnCanvas.test.tsx`: 4 casos — overlay add/remove, posición `left=width-12`, HTML con `#fffbeb` y `#f59e0b` + +**Patrón de mocks para tests de componentes** (documentado para referencia futura): + +- Los mocks del store con `vi.hoisted()` devuelven referencias estables +- Sin `vi.hoisted`: cada llamada a `useProcessStore()` devuelve un nuevo objeto → el `useEffect([activities])` de ActivityPanel dispara en loop infinito → "Maximum update depth exceeded" +- `simulation-store.ts` importa `process-store.ts` al nivel de módulo: los mocks deben ser compatibles o el store no carga + +--- + +## [0.1.0-mvp] — MVP Fase 0 — 2026-05-13 + +### Contexto + +Primera entrega funcional. Construida en una sesión de trabajo. +Objetivo: demostrar en ≤15 minutos que se puede transformar un BPMN en un reporte de costos. + +### Funcionalidades + +- Importar BPMN 2.0 por drag & drop o desde procesos de ejemplo +- Renderizar diagrama en canvas read-only (bpmn-js Viewer) +- Configurar actividades: costo fijo, tiempo, recursos asignados con utilización +- CRUD de recursos (rol/persona/sistema/equipo/insumo + costo/hora) +- Configuración global: moneda, overhead %, nombre proceso, cliente +- Motor de simulación determinístico con propagación de probabilidades por gateways XOR, AND, inclusivo +- Detección de loops (back-edges via DFS): se advierte pero no bloquea +- Reporte visual: mapa de calor en dos modos (relativo/absoluto), KPIs, top actividades, composición, costo por recurso +- Export PDF: 3 páginas (header+KPIs+heatmap, análisis, tabla detalle), con jsPDF-autotable +- Export CSV: con BOM UTF-8 y metadatos del proceso +- Persistencia en IndexedDB (Dexie v1) +- Deploy estático en Cloudflare Pages + +### Decisiones de diseño relevantes para el futuro + +**Parser BPMN multi-pool** (`bpmn-parser.ts`): + +- Itera todos los `` hijos directos de `` (no solo el primero) +- Fix crítico: el parser original usaba `querySelector` que retorna solo el primero → todos los pools de una colaboración excepto el primero eran ignorados +- Pools de soporte (SAP, bandeja manual) pueden no tener StartEvent propio — la validación exige al menos uno entre todos los pools + +**Heatmap sobre bpmn-js** (`HeatmapCanvas.tsx`): + +- bpmn-js rechaza la promesa de `importXML` pero el diagrama se renderiza parcialmente → usar `eventBus.on('import.done', ...)` en lugar de `.then()` +- `element.style.fill = color` (NO `setAttribute('fill', color)`): bpmn-js aplica CSS que tiene mayor especificidad que atributos SVG +- `captureImage()` espera 500ms después de importar para que la transición CSS `fill 0.35s ease` complete antes de la captura con html2canvas + +**Validación XOR en WorkspacePage** (`WorkspacePage.tsx`): + +- `hasLoadedOnce` ref: evita que el redirect "proceso no encontrado" dispare en el render inicial donde `isLoading=false` y `currentProcess=null` (antes del primer efecto de carga) + +### Tests + +268 tests unitarios + integración al cierre de MVP. +5 tests E2E con Playwright (import → simulate → export PDF/CSV × 3 BPMNs + acentos + gradiente de color). +El test de gradiente verifica con `sharp` que el heatmap del PDF contiene píxeles verdes, ámbar y rojos — evita regresiones en la lógica de coloreo. + +--- + +## Convenciones de este changelog + +- **Decisiones de diseño**: se documenta el *por qué*, no solo el *qué* +- **Gotchas**: comportamientos contrarios a la intuición se marcan explícitamente +- **Edge cases**: si un edge case motivó un test específico, se menciona +- **Retrocompatibilidad**: cuando un campo es opcional por retrocompat, se indica +- Las entradas son para humanos que modificarán el código, no para usuarios finales diff --git a/METHODOLOGY_AUTOMATED_COST.md b/METHODOLOGY_AUTOMATED_COST.md index 87d6973..6663d36 100644 --- a/METHODOLOGY_AUTOMATED_COST.md +++ b/METHODOLOGY_AUTOMATED_COST.md @@ -112,6 +112,21 @@ Antes de presentar el reporte de ROI al cliente, validar: | Payback | 6 - 24 meses | < 3 meses: revisar inversión; > 36 meses: revisar el caso de negocio | | Tasa de excepciones | 2% - 15% | < 1%: optimista; > 20%: el proceso quizás no es buen candidato | | Ahorro % por ejecución | 70% - 95% | < 50%: validar si vale la pena; > 95%: revisar excepciones | +| **ROI anual** | **50% - 1.000%** | **> 1.000%: señal de inputs incorrectos — ver abajo** | + +### ⚠️ ROI superior al 1.000% anual + +La plataforma muestra una advertencia automática en la sección "Transparencia metodológica" cuando el ROI anualizado supera el 1.000% (y es un valor finito — no Infinity por inversión en cero). + +Un ROI de este nivel es matemáticamente posible pero comercialmente inverosímil. Las causas más frecuentes son: + +1. **Frecuencia anual sobreestimada**: confundir instancias del proceso con ejecuciones individuales de actividades. Verificar con datos reales del cliente. + +2. **Costo automatizado por ejecución subestimado**: olvidar el componente de excepciones, el mantenimiento o la infra. Usar la fórmula de los 4 componentes de este documento. + +3. **Inversión en automatización subestimada**: solo se incluyó el desarrollo y se olvidaron licencias, capacitación e implementación. + +**Acción recomendada**: corregir los inputs, no ignorar la advertencia. El cliente —o alguien de su equipo técnico— va a hacer las mismas cuentas y un ROI de 10.000% destruye credibilidad. --- diff --git a/README.md b/README.md index d7affdc..87ca16c 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,223 @@ -# Process Cost Platform — MVP Fase 0 +# Process Cost Platform -Plataforma web que transforma un archivo BPMN 2.0 en un reporte visual -de costos operativos, listo para presentar a clientes en minutos. -100% en el navegador — sin backend, sin registro, datos guardados localmente. +> Transformá un archivo BPMN 2.0 en un reporte visual de costos operativos — +> y calculá el ROI de automatizar ese proceso — en minutos, sin backend, sin registro. -## Cómo usarlo +**Hecho desde Paraguay 🇵🇾 · Herramienta interna de InQuality · Sprint 1 en desarrollo activo** -1. **Importá** tu archivo `.bpmn` arrastrándolo a la pantalla principal, - o cargá uno de los tres procesos de ejemplo incluidos. -2. **Configurá** los costos fijos y tiempos de cada actividad en el workspace. - Ajustá las probabilidades de los gateways XOR si las hay. -3. **Simulá** para generar el análisis agregado de costos. -4. **Exportá** el reporte en PDF (presentación para el cliente) o CSV (datos). +--- -## Stack +## ¿Qué hace esto? -| Capa | Tecnología | -|---|---| -| Framework | React 19 + Vite 8 | -| Routing | TanStack Router | -| Estado | Zustand 5 | -| Canvas BPMN | bpmn-js 18 (Viewer) | -| UI | shadcn/ui + Tailwind CSS | -| Gráficos | Apache ECharts | -| Persistencia | IndexedDB vía Dexie 4 | -| Export PDF | jsPDF + jspdf-autotable | -| Export CSV | papaparse | -| Testing | Vitest + Playwright | -| Deploy | Cloudflare Pages | +Un consultor de procesos recibe un diagrama BPMN del cliente. Con esta plataforma puede: -## Scripts +1. **Importar** el diagrama (.bpmn) y ver el proceso renderizado al instante. +2. **Configurar costos** actividad por actividad: costo fijo, tiempo, recursos humanos/sistemas. +3. **Simular** para obtener el costo esperado de una ejecución completa, con propagación de probabilidades a través de gateways XOR, AND e inclusivos. +4. **Ver el reporte** con mapa de calor sobre el diagrama, KPIs, gráficos, tabla de actividades. +5. **Exportar** a PDF (presentación para el cliente) o CSV (datos para Excel). + +En Sprint 1 (en curso): + +6. **Marcar actividades como automatizables** y configurar sus costos en el escenario automatizado. +7. **Calcular el ROI** del proyecto de automatización: payback, ahorro anual, acumulado a N años. +8. **Mostrar un reporte comparativo** Actual vs. Automatizado con transparencia metodológica. + +**Audiencia primaria**: consultores de InQuality que usan esto en pre-venta de proyectos de automatización. +No es un producto de consumo masivo. El diseño prioriza densidad de información y velocidad de uso. + +--- + +## Inicio rápido ```bash -npm run dev # Servidor de desarrollo (localhost:5173) -npm run build # Build de producción en dist/ -npm run preview # Preview del build en localhost:4173 -npm run test # Tests unitarios con Vitest -npx playwright test # Tests E2E con Playwright +git clone +cd simulador-web +npm install --legacy-peer-deps +npm run dev ``` -## Estado +Abre `http://localhost:5173`. Arrastrá un `.bpmn` o hacé clic en uno de los tres procesos de ejemplo. -**MVP Fase 0** — funcionalidad completa para uso consultivo básico. -Las features adicionales (multi-tenant, IA, versionado, etc.) -están documentadas en `TODO.md` para la Fase 1. +> **Sin instalación de backend.** Todo corre en el navegador. Los datos se guardan en IndexedDB +> del browser — si borrás el caché, perdés los procesos guardados. Exportá el PDF/CSV antes de limpiar. + +--- + +## Flujo de uso detallado + +### 1. Importar un proceso + +Arrastrá tu archivo `.bpmn` a la pantalla de inicio, o cargá uno de los procesos de ejemplo: +- **Proceso lineal simple** — para entender el flujo básico +- **Aprobación de crédito** — con gateways XOR y AND paralelo +- **Control de calidad con revisión** — con loop + +La plataforma soporta BPMN 2.0 con **múltiples pools** (colaboraciones): todos los pools se importan y sus actividades aparecen configurables en el workspace. + +### 2. Configurar el workspace + +El panel derecho tiene tres tabs: + +**Tab "Actividad"** (al hacer click en un nodo del diagrama): +- Costo directo fijo por ejecución +- Tiempo de ejecución en minutos +- Recursos asignados (rol/sistema/equipo con % de utilización) +- Sección "Automatización" *(Sprint 1)*: toggle + costo y tiempo del escenario automatizado + +**Tab "Recursos"**: CRUD de recursos reutilizables con costo/hora. + +**Tab "Global"**: +- Nombre del proceso y cliente +- Moneda (USD, PYG, BRL, ARS, EUR, COP, MXN, CLP) +- Overhead (% de costos indirectos sobre directos) +- Frecuencia anual de ejecución *(Sprint 1)* +- Horizonte de análisis en años *(Sprint 1)* +- Inversión total en automatización *(Sprint 1)* + +Las actividades marcadas como automatizables muestran un badge Bot ámbar (⚙️) en la esquina superior derecha del nodo en el canvas. + +### 3. Simular + +El botón "Simular" corre el motor determinístico. Si hay gateways XOR con probabilidades que no suman 100%, el botón queda deshabilitado y se muestran advertencias en la topbar. + +Desde Sprint 1, el motor corre **dos escenarios simultáneamente** (actual + automatizado) y los guarda como un único snapshot. Cualquier cambio en los datos invalida ambos resultados — hay que re-simular. + +### 4. Reportar y exportar + +El reporte muestra mapa de calor, KPIs, gráficos y tabla de actividades. En Sprint 1 se agregan tabs de comparación y ROI. + +Exportación: +- **PDF**: 3+ páginas presentables ante el cliente, con heatmap embebido como imagen JPEG. +- **CSV**: datos con BOM UTF-8 para compatibilidad con Excel regional. + +--- + +## Arquitectura técnica + +``` +src/ +├── domain/ ← Lógica de negocio pura, sin React, 100% testeable +│ ├── types.ts ← Todas las interfaces del modelo de dominio +│ ├── simulation.ts ← Motor: propaga probabilidades por el grafo BPMN +│ ├── roi.ts ← Cálculos de ROI: payback, ahorro, acumulado (Sprint 1) +│ ├── bpmn-parser.ts← Parser XML BPMN 2.0 → grafo en memoria +│ └── schemas.ts ← Validación Zod de todas las entidades +│ +├── store/ ← Estado global con Zustand +│ ├── process-store.ts ← Proceso actual, actividades, gateways, recursos +│ └── simulation-store.ts ← Resultados actual + automatizado, invalidación reactiva +│ +├── persistence/ ← IndexedDB vía Dexie (schema versión 2) +│ ├── db.ts ← Schema y migraciones +│ └── repositories.ts ← CRUD de entidades (@internal: solo llamar desde stores) +│ +├── features/ +│ ├── import/ ← Landing, drag & drop, carga de ejemplos +│ ├── workspace/ ← Canvas BPMN + panel lateral de configuración +│ ├── simulation/ ← Hook useSimulate: dispara ambos escenarios +│ └── report/ ← Reporte visual, heatmap, gráficos, export +│ +└── lib/ + ├── export/ ← pdf-export.ts, csv-export.ts (lazy-loaded al hacer clic) + ├── colors.ts ← Gradiente heatmap: verde → ámbar → rojo + └── format.ts ← Formateo de moneda, tiempo, porcentajes +``` + +### Reglas de dependencia + +``` +domain/ ← no importa nada del proyecto (puro TypeScript) +store/ ← importa solo de domain/ y persistence/ +features/ ← importa de domain/, store/, persistence/, lib/, components/ +``` + +La regla ESLint `no-restricted-imports` hace que cualquier import de +`persistence/repositories` desde fuera de `store/` falle en `npm run lint`. +La única excepción intencional es `simulation-store.ts`, que importa +`process-store.ts` para suscribirse a cambios y mantener la invariante +de invalidación dual (documentado como excepción en el archivo). + +### Motor de simulación — decisiones clave + +El motor acepta `scenario: 'actual' | 'automated'`. En `'automated'`, las +actividades con `automatable=true` usan sus campos alternativos de costo y tiempo. +La probabilidad de ejecución **no cambia entre escenarios** — solo los costos. + +Overlays en el canvas: se usa `overlays.add()` de diagram-js con posición +`{ top: -8, left: element.width - 12 }` obtenida via `elementRegistry.get()`. +**No usar** `position.right` directamente: la fórmula interna de diagram-js es +`left = right * -1 + width`, lo que con `right: -8` produce `left = width + 8` +(badge enteramente fuera del nodo, sin overlap de esquina). + +--- + +## Stack tecnológico + +| Capa | Tecnología | Nota | +|---|---|---| +| Framework | React 19 + Vite 8 (Rolldown) | | +| Routing | TanStack Router 1.x | Type-safe | +| Estado | Zustand 5 | Stores: process + simulation | +| Canvas BPMN | bpmn-js 18 / diagram-js | Viewer (read-only) | +| UI | shadcn/ui + Tailwind CSS | Components copy-pasted | +| Iconos | Lucide React | | +| Gráficos | Apache ECharts 5 | Lazy-loaded | +| Persistencia | Dexie 4 (IndexedDB) | Schema v2, migraciones automáticas | +| Validación | Zod 3 | | +| Export PDF | jsPDF + jspdf-autotable | Lazy-loaded | +| Export CSV | papaparse 5 | | +| Tests unitarios | Vitest 4 | 336+ tests | +| Tests E2E | Playwright | Con análisis de PDF y píxeles | +| Linting | ESLint 10 (flat config) | `no-restricted-imports` custom | +| Deploy | Cloudflare Pages | Estático, gratis | + +--- + +## Comandos + +```bash +# Desarrollo +npm run dev # Servidor local en localhost:5173 con HMR + +# Build y preview +npm run build # TypeScript check + Vite build → dist/ +npm run preview # Preview del build en localhost:4173 + +# Testing +npm run test # Tests unitarios e integración (Vitest) +npx playwright test # Tests E2E contra localhost:4173 (requiere build previo) +npx playwright test --config=playwright.prod.config.ts # Smoke test vs producción + +# Calidad +npm run lint # ESLint — 0 errores esperados + +# Deploy +CLOUDFLARE_ACCOUNT_ID= npx wrangler pages deploy dist \ + --project-name=process-cost-platform +``` + +--- + +## Producción + +**URL**: https://process-cost-platform.pages.dev +**Plataforma**: Cloudflare Pages (estático, sin servidor) + +Smoke test post-deploy: `npx playwright test --config=playwright.prod.config.ts` +Genera `tests/e2e/__output__/prod-smoke.pdf` como evidencia del flujo completo. + +--- + +## Documentación adicional + +| Archivo | Contenido | +|---|---| +| `ARCHITECTURE.md` | Diseño del sistema, ADRs 1-15, roadmap completo | +| `CHANGELOG.md` | Historial detallado de cambios por versión | +| `TODO.md` | Backlog de features y deuda técnica | +| `METHODOLOGY_AUTOMATED_COST.md` | Guía para calcular el costo automatizado por ejecución | + +--- + +*Los procesos de negocio son activos estratégicos de las empresas y un diferencial competitivo.* diff --git a/TODO.md b/TODO.md index acf70e3..41b812c 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,18 @@ - [ ] **Detalle de recursos por actividad** en el PDF (tabla de costos por recurso asignado) - [ ] **Importación por URL**: cargar BPMN desde enlace directo (GitHub, Confluence, etc.) +## Refinamientos UX + +- [ ] **Mejorar animación de expansión de la sección "Automatización" en ActivityPanel.** Actualmente usa max-h trick (funciona pero la aceleración no es lineal en los últimos píxeles porque el contenedor crece hasta un max fijo de 320px). Solución sofisticada: `useRef + scrollHeight` para animar a la altura real del contenido. Costo: medio. Beneficio: micro-pulido visual. + +## Deuda técnica / Decisiones arquitectónicas pendientes + +- [ ] **Dexie live queries (Modelo B de sincronización store↔db)**: Hoy la invalidación del resultado de simulación se garantiza via ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`. Si en Sprint 2 la grilla de edición masiva requiere escrituras de alta frecuencia directas a IndexedDB (por performance), evaluar migración a live queries de Dexie que invaliden el store automáticamente. Documentado en la auditoría de Mayo 2026. + +- [ ] **Indicador visual en el reporte de actividades automatable** (borde punteado o ícono diferenciador): en Sprint 1 se usa solo leyenda de texto. Evaluar si el cliente necesita más claridad visual al comparar Actual vs Automatizado. + +- [ ] **Columna "Eficiencia de automatización" en ComparisonTable**: métrica = `(ahorro$ × probabilidad) / inversión-prorrateada`. Permite rankear actividades por "qué tan valiosa es automatizar cada una" en lugar de solo "cuánto cuestan". Útil en conversaciones de pre-venta donde el cliente quiere priorizar qué automatizar primero. + ## Baja prioridad / Fase 2 - [ ] **Login y multi-tenant**: persistencia en la nube, colaboración diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ab3b4f2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,92 @@ +import js from '@eslint/js' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist', 'node_modules'] }, + + // ─── Base: JS + TS strict ──────────────────────────────────────────────────── + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + + // ── Regla estructural crítica ───────────────────────────────────────── + // Repositorios solo pueden llamarse desde stores. + // Cualquier import de persistence/repositories en código que no sea src/store/** + // falla el lint con un mensaje explicativo. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['*/persistence/repositories*', '../persistence/repositories*', '@/persistence/repositories*'], + message: + 'Los repositorios solo deben llamarse desde stores (src/store/**). ' + + 'Usá el store correspondiente (useProcessStore, useSimulationStore, etc.).', + }, + ], + }, + ], + + // ── Reglas ajustadas al proyecto ───────────────────────────────────── + // any en bordes con librerías sin tipos (bpmn-js, jsPDF, autotable) está + // permitido por CLAUDE.md — se reporta como warning, no error. + '@typescript-eslint/no-explicit-any': 'warn', + + // Funciones auxiliares definidas dentro de componentes (ej: Th en ActivitiesTable, + // handlers en BpmnCanvas) son un patrón aceptado en este codebase. El riesgo de + // reset de estado no aplica porque son funciones sin estado propio. + 'react-hooks/static-components': 'off', + + // setState síncrono en useEffect con condicional deliberado (sincronización de + // estado local con props/store). Los efectos están correctamente dependenciados. + 'react-hooks/set-state-in-effect': 'off', + + // Cálculos de posición en pdf-export.ts usan y como variable acumulativa; + // el patrón y += delta → doc.addPage() → y = PDF.margin es intencional + // (separa visualmente las secciones del cálculo aunque no use el valor final). + 'no-useless-assignment': 'warn', + }, + }, + + // ─── Override: src/store/** puede importar repositories ────────────────────── + { + files: ['src/store/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': 'off', + }, + }, + + // ─── Override: features/import puede importar repositories (operación de + // importación inicial — no hay estado de simulación previo que invalidar) ── + { + files: ['src/features/import/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': 'off', + }, + }, + + // ─── Override: features/report usa repos en modo read-only (useReportData) ─── + { + files: ['src/features/report/useReportData.ts'], + rules: { + 'no-restricted-imports': 'off', + }, + }, + + // ─── Override: tests e2e y integración pueden importar repos directamente ──── + { + files: ['tests/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': 'off', + }, + }, +) diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..1112fd7 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,36 @@ +import { cn } from '@/lib/utils' + +interface SwitchProps { + checked: boolean + onCheckedChange: (checked: boolean) => void + id?: string + disabled?: boolean + 'aria-label'?: string +} + +export function Switch({ checked, onCheckedChange, id, disabled, 'aria-label': ariaLabel }: SwitchProps) { + return ( + + ) +} diff --git a/src/domain/roi.ts b/src/domain/roi.ts new file mode 100644 index 0000000..504c5fb --- /dev/null +++ b/src/domain/roi.ts @@ -0,0 +1,79 @@ +// Cálculos de ROI de automatización — funciones puras, sin dependencias de UI ni persistencia. +// Fórmulas nominales simples (ADR-014): sin VPN, TIR ni tasa de descuento. +// Los edge cases (inversión cero, ahorro negativo, división por cero) están explicitados. + +export interface RoiInput { + costPerExecutionActual: number // costo total de una ejecución en escenario actual + costPerExecutionAutomated: number // costo total de una ejecución en escenario automatizado + annualFrequency: number // ejecuciones por año + analysisHorizonYears: number // horizonte de análisis en años + automationInvestment: number // inversión total única del proyecto de automatización +} + +export interface RoiResult { + savingsPerExecution: number // ahorro por ejecución individual (actual - automatizado) + savingsPerExecutionPercent: number // ahorro % sobre el costo actual (0 si actual = 0) + annualSavings: number // ahorro anual = ahorro/ejecución × frecuencia + paybackMonths: number // meses para recuperar inversión (Infinity si ahorro <= 0) + paybackYears: number // años para recuperar inversión + cumulativeSavingsHorizon: number // ahorro acumulado neto al final del horizonte + breaksEvenInHorizon: boolean // ¿el payback ocurre dentro del horizonte? + roiAnnualPercent: number // ROI anualizado % = (ahorroAnual / inversión) × 100 + // Infinity si inversión = 0 y ahorro > 0 +} + +export function calculateRoi(input: RoiInput): RoiResult { + const { + costPerExecutionActual, + costPerExecutionAutomated, + annualFrequency, + analysisHorizonYears, + automationInvestment, + } = input + + const savingsPerExecution = costPerExecutionActual - costPerExecutionAutomated + + // Evitar división por cero cuando el costo actual es 0 + const savingsPerExecutionPercent = + costPerExecutionActual > 0 + ? (savingsPerExecution / costPerExecutionActual) * 100 + : 0 + + const annualSavings = savingsPerExecution * annualFrequency + + // Payback: si no hay ahorro (o es negativo), nunca se recupera la inversión + let paybackMonths: number + if (annualSavings <= 0) { + paybackMonths = Infinity + } else if (automationInvestment === 0) { + // Inversión cero con ahorro positivo: recuperación instantánea + paybackMonths = 0 + } else { + paybackMonths = (automationInvestment / annualSavings) * 12 + } + + const paybackYears = paybackMonths / 12 // Infinity / 12 = Infinity, 0 / 12 = 0 + + const cumulativeSavingsHorizon = annualSavings * analysisHorizonYears - automationInvestment + + const breaksEvenInHorizon = isFinite(paybackYears) && paybackYears <= analysisHorizonYears + + // ROI anualizado: Infinity si inversión = 0 con ahorro > 0; 0 si inversión = 0 y ahorro = 0 + let roiAnnualPercent: number + if (automationInvestment === 0) { + roiAnnualPercent = annualSavings > 0 ? Infinity : 0 + } else { + roiAnnualPercent = (annualSavings / automationInvestment) * 100 + } + + return { + savingsPerExecution, + savingsPerExecutionPercent, + annualSavings, + paybackMonths, + paybackYears, + cumulativeSavingsHorizon, + breaksEvenInHorizon, + roiAnnualPercent, + } +} diff --git a/src/domain/schemas.ts b/src/domain/schemas.ts index 73da67e..a779647 100644 --- a/src/domain/schemas.ts +++ b/src/domain/schemas.ts @@ -22,6 +22,9 @@ export const ActivitySchema = z.object({ directCostFixed: z.number().min(0), executionTimeMinutes: z.number().min(0), assignedResources: z.array(ActivityResourceAssignmentSchema), + automatable: z.boolean().default(false), + automatedCostFixed: z.number().min(0).default(0), + automatedTimeMinutes: z.number().min(0).default(0), }) export const GatewayBranchSchema = z.object({ @@ -45,6 +48,9 @@ export const ProcessSchema = z.object({ bpmnXml: z.string().min(1), currency: z.string().length(3, 'Código ISO de 3 letras'), overheadPercentage: z.number().min(0).max(1), + annualFrequency: z.number().min(1).default(1000), + analysisHorizonYears: z.number().min(1).max(20).default(3), + automationInvestment: z.number().min(0).default(0), createdAt: z.number(), updatedAt: z.number(), }) diff --git a/src/domain/simulation.ts b/src/domain/simulation.ts index 4ee9bca..de4e1c0 100644 --- a/src/domain/simulation.ts +++ b/src/domain/simulation.ts @@ -181,6 +181,9 @@ export function runSimulation(input: SimulationInput): SimulationResult { const perActivity: ActivitySimResult[] = [] const resourceTotals = new Map() + // Seleccionar campos del escenario: para actividades automatable, usar costos automatizados + const scenario = input.scenario ?? 'actual' + for (const activity of activities.values()) { const execProb = execProbs.get(activity.bpmnElementId) ?? 0 const node = graph.nodes.get(activity.bpmnElementId) @@ -192,13 +195,19 @@ export function runSimulation(input: SimulationInput): SimulationResult { continue } + // En escenario automatizado, usar campos automatizados solo para actividades marcadas; + // el resto mantiene sus costos originales (regla de negocio central del Sprint 1). + const useAutomated = scenario === 'automated' && activity.automatable + const directCostFixed = useAutomated ? activity.automatedCostFixed : activity.directCostFixed + const executionTimeMinutes = useAutomated ? activity.automatedTimeMinutes : activity.executionTimeMinutes + const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = [] let resourceCostDirect = 0 for (const assignment of activity.assignedResources) { const resource = resources.get(assignment.resourceId) if (!resource) continue - const hoursUsed = (activity.executionTimeMinutes / 60) * assignment.utilizationPercent + const hoursUsed = (executionTimeMinutes / 60) * assignment.utilizationPercent const cost = resource.costPerHour * hoursUsed * execProb resourceBreakdown.push({ resourceId: resource.id, resourceName: resource.name, cost }) resourceCostDirect += cost @@ -211,7 +220,7 @@ export function runSimulation(input: SimulationInput): SimulationResult { }) } - const fixedCostExpected = activity.directCostFixed * execProb + const fixedCostExpected = directCostFixed * execProb const totalExpectedDirectCost = fixedCostExpected + resourceCostDirect perActivity.push({ @@ -225,7 +234,7 @@ export function runSimulation(input: SimulationInput): SimulationResult { expectedExecutions: execProb, executionProbability: execProb, resourceCostBreakdown: resourceBreakdown, - executionTimeMinutes: activity.executionTimeMinutes, + executionTimeMinutes, }) } diff --git a/src/domain/types.ts b/src/domain/types.ts index 9d57366..859cb75 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -11,6 +11,9 @@ export interface Process { bpmnXml: string currency: string overheadPercentage: number + annualFrequency: number // 🆕 Sprint 1 — execuciones por año, default 1000 + analysisHorizonYears: number // 🆕 Sprint 1 — horizonte de análisis en años, default 3 + automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0 createdAt: number updatedAt: number } @@ -26,6 +29,9 @@ export interface Activity { directCostFixed: number executionTimeMinutes: number assignedResources: ActivityResourceAssignment[] + automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización + automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada + automatedTimeMinutes: number // 🆕 Sprint 1 — tiempo de la actividad en escenario automatizado } export interface ActivityResourceAssignment { @@ -63,11 +69,16 @@ export interface Simulation { id: UUID processId: UUID executedAt: number - result: SimulationResult + result: SimulationResult // escenario actual + resultAutomated?: SimulationResult // 🆕 Sprint 1 — escenario automatizado (opcional por retrocompatibilidad) } // ─── Motor de simulación: Input/Output ─────────────────────────────────────── +// Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes) +// o automatizado (automatedCostFixed/automatedTimeMinutes para actividades marcadas). +export type Scenario = 'actual' | 'automated' + export interface SimulationInput { processXml: string activities: Map @@ -77,6 +88,7 @@ export interface SimulationInput { currency: string overheadPercentage: number } + scenario?: Scenario // default 'actual' — opcional para retrocompatibilidad con tests existentes } export interface ActivitySimResult { diff --git a/src/features/import/ImportPage.tsx b/src/features/import/ImportPage.tsx index 9d2232c..c6d3226 100644 --- a/src/features/import/ImportPage.tsx +++ b/src/features/import/ImportPage.tsx @@ -64,6 +64,9 @@ export function ImportPage() { bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2, + annualFrequency: 1000, + analysisHorizonYears: 3, + automationInvestment: 0, createdAt: now, updatedAt: now, } @@ -78,6 +81,9 @@ export function ImportPage() { directCostFixed: 0, executionTimeMinutes: 0, assignedResources: [], + automatable: false, + automatedCostFixed: 0, + automatedTimeMinutes: 0, })) const gatewayElements = extractGatewayElements(graph) diff --git a/src/features/report/AutomationScenarioNote.tsx b/src/features/report/AutomationScenarioNote.tsx new file mode 100644 index 0000000..2ef671c --- /dev/null +++ b/src/features/report/AutomationScenarioNote.tsx @@ -0,0 +1,12 @@ +// Leyenda para el tab "Automatizado" del reporte. +// Explica por qué ciertas actividades mantienen su costo original. +// Se coloca al pie del heatmap en el tab "Automatizado" — wired en Etapa 3. + +export function AutomationScenarioNote() { + return ( +

+ 💡 Las actividades no marcadas como automatizables mantienen su costo original en este + escenario. Identificalas por el ícono ámbar de automatización en el workspace. +

+ ) +} diff --git a/src/features/report/ComparisonTable.tsx b/src/features/report/ComparisonTable.tsx new file mode 100644 index 0000000..396994e --- /dev/null +++ b/src/features/report/ComparisonTable.tsx @@ -0,0 +1,130 @@ +import { useMemo, useState } from 'react' +import { Bot, ArrowUpDown } from 'lucide-react' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { formatCurrency, formatPercent } from '@/lib/format' +import type { ActivitySimResult } from '@/domain/types' + +interface ComparisonRow { + activityId: string + activityName: string + actualCost: number + automatedCost: number + savings: number + savingsPercent: number + automatable: boolean +} + +interface ComparisonTableProps { + perActivityActual: ActivitySimResult[] + perActivityAutomated: ActivitySimResult[] + automatableIds: Set // bpmnElementIds marcados como automatizables + currency: string +} + +export function ComparisonTable({ + perActivityActual, + perActivityAutomated, + automatableIds, + currency, +}: ComparisonTableProps) { + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') + + const automatedByActivityId = useMemo(() => { + const m = new Map() + for (const a of perActivityAutomated) m.set(a.activityId, a) + return m + }, [perActivityAutomated]) + + const rows: ComparisonRow[] = useMemo(() => { + return perActivityActual + .map((act) => { + const automated = automatedByActivityId.get(act.activityId) + const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost + const savings = act.expectedTotalCost - automatedCost + const savingsPercent = + act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0 + return { + activityId: act.activityId, + activityName: act.activityName, + actualCost: act.expectedTotalCost, + automatedCost, + savings, + savingsPercent, + automatable: automatableIds.has(act.bpmnElementId), + } + }) + .sort((a, b) => (b.savings - a.savings) * (sortDir === 'desc' ? 1 : -1)) + }, [perActivityActual, automatedByActivityId, automatableIds, sortDir]) + + if (rows.length === 0) { + return ( +
+ Sin actividades para comparar +
+ ) + } + + return ( +
+ + + + + Actividad + Costo actual + Costo automatizado + setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))} + > + + Ahorro + + + + % ahorro + + + + {rows.map((row) => { + const savingsColor = + row.savings > 0 + ? 'text-emerald-700' + : row.savings < 0 + ? 'text-red-600' + : 'text-slate-400' + return ( + + + {row.automatable && ( + + )} + + + {row.activityName} + + + {formatCurrency(row.actualCost, currency)} + + + {formatCurrency(row.automatedCost, currency)} + + + {row.savings > 0 ? '+' : ''} + {formatCurrency(row.savings, currency)} + + + {row.savings > 0 ? '+' : ''} + {formatPercent(row.savingsPercent)} + + + ) + })} + +
+
+ ) +} diff --git a/src/features/report/CumulativeSavingsChart.tsx b/src/features/report/CumulativeSavingsChart.tsx new file mode 100644 index 0000000..78ba581 --- /dev/null +++ b/src/features/report/CumulativeSavingsChart.tsx @@ -0,0 +1,115 @@ +import { useMemo } from 'react' +import ReactECharts from 'echarts-for-react' +import { formatCurrency } from '@/lib/format' +import type { RoiResult } from '@/domain/roi' + +interface CumulativeSavingsChartProps { + roi: RoiResult + horizonYears: number + currency: string + investment: number +} + +export function CumulativeSavingsChart({ + roi, + horizonYears, + currency, + investment, +}: CumulativeSavingsChartProps) { + const option = useMemo(() => { + const years = Array.from({ length: horizonYears + 1 }, (_, i) => i) + const values = years.map((y) => roi.annualSavings * y - investment) + + const markLineData: unknown[] = [] + if (isFinite(roi.paybackYears) && roi.paybackYears > 0 && roi.paybackYears <= horizonYears) { + markLineData.push({ + xAxis: roi.paybackYears, + name: 'Payback', + label: { + formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`, + position: 'insideEndTop', + fontSize: 10, + color: '#10b981', + }, + lineStyle: { color: '#10b981', type: 'dashed', width: 2 }, + }) + } + + // Línea de referencia en y=0 para identificar el break-even visualmente + markLineData.push({ + yAxis: 0, + name: 'Break-even', + label: { formatter: '', position: 'insideStartTop' }, + lineStyle: { color: '#94a3b8', type: 'solid', width: 1 }, + }) + + return { + tooltip: { + trigger: 'axis', + formatter: (params: unknown[]) => { + const p = params[0] as { dataIndex: number; value: [number, number] } + const year = p.value[0] + const val = p.value[1] + return `
+
Año ${year}
+
Acumulado neto: ${formatCurrency(val, currency)}
+
` + }, + }, + grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true }, + xAxis: { + type: 'value', + name: 'Año', + nameLocation: 'end', + nameTextStyle: { fontSize: 10, color: '#94a3b8' }, + min: 0, + max: horizonYears, + interval: 1, + axisLabel: { fontSize: 11, formatter: (v: number) => `Año ${v}` }, + splitLine: { lineStyle: { color: '#f1f5f9' } }, + }, + yAxis: { + type: 'value', + axisLabel: { + fontSize: 11, + formatter: (v: number) => { + if (v === 0) return '0' + const abs = Math.abs(v) + const sign = v < 0 ? '-' : '' + if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M` + if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}k` + return String(v) + }, + }, + splitLine: { lineStyle: { color: '#f1f5f9' } }, + }, + series: [ + { + type: 'line', + data: years.map((y, i) => [y, values[i]]), + smooth: false, + lineStyle: { color: '#3b82f6', width: 2.5 }, + areaStyle: { + color: { + type: 'linear', + x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(59,130,246,0.18)' }, + { offset: 1, color: 'rgba(59,130,246,0.02)' }, + ], + }, + }, + itemStyle: { color: '#3b82f6' }, + symbolSize: 6, + markLine: { + silent: true, + symbol: ['none', 'none'], + data: markLineData, + }, + }, + ], + } + }, [roi, horizonYears, currency, investment]) + + return +} diff --git a/src/features/report/ReportPage.tsx b/src/features/report/ReportPage.tsx index 7dfe77f..78ce900 100644 --- a/src/features/report/ReportPage.tsx +++ b/src/features/report/ReportPage.tsx @@ -1,14 +1,17 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useParams, useNavigate } from '@tanstack/react-router' -import { AlertTriangle, Home } from 'lucide-react' +import { AlertTriangle, Home, Info } from 'lucide-react' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' -import { TooltipProvider } from '@/components/ui/tooltip' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { useToast } from '@/components/ui/use-toast' import { AppFooter } from '@/components/AppFooter' +import { calculateRoi } from '@/domain/roi' import { useReportData } from './useReportData' import { ReportHeader } from './ReportHeader' import { KpiBar } from './KpiBar' +import { RoiKpiBar } from './RoiKpiBar' import { HeatmapCanvas, type HeatmapCanvasHandle } from './HeatmapCanvas' import { HeatmapLegend } from './HeatmapLegend' import { HeatmapModeToggle } from './HeatmapModeToggle' @@ -17,48 +20,102 @@ import { TopActivitiesChart } from './TopActivitiesChart' import { CostCompositionChart } from './CostCompositionChart' import { CostByResourceChart } from './CostByResourceChart' import { ActivitiesTable } from './ActivitiesTable' +import { ComparisonTable } from './ComparisonTable' +import { CumulativeSavingsChart } from './CumulativeSavingsChart' +import { TopSavingsChart } from './TopSavingsChart' +import { TransparencySection } from './TransparencySection' +import { AutomationScenarioNote } from './AutomationScenarioNote' import { MethodologyFooter } from './MethodologyFooter' import type { HeatmapMode } from '@/lib/colors' export function ReportPage() { const { processId } = useParams({ from: '/report/$processId' }) const navigate = useNavigate() - const { process, simulation, resources, isLoading, error } = useReportData(processId) + const { process, simulation, activities, resources, isLoading, error } = useReportData(processId) const { toast } = useToast() const [heatmapMode, setHeatmapMode] = useState('relative') - const [selectedId, setSelectedId] = useState(null) - const [isExportingPdf, setIsExportingPdf] = useState(false) - const [isExportingCsv, setIsExportingCsv] = useState(false) + // Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns + const automatableIds = useMemo( + () => new Set(activities.filter((a) => a.automatable).map((a) => a.bpmnElementId)), + [activities] + ) + + // ROI — calculado solo cuando hay ambos resultados + const roi = useMemo(() => { + const result = simulation?.result + const resultAutomated = simulation?.resultAutomated + if (!result || !resultAutomated || !process) return null + return calculateRoi({ + costPerExecutionActual: result.totalCost, + costPerExecutionAutomated: resultAutomated.totalCost, + annualFrequency: process.annualFrequency, + analysisHorizonYears: process.analysisHorizonYears, + automationInvestment: process.automationInvestment, + }) + }, [simulation, process]) + + // Estado y refs para el tab "Actual" + const [selectedIdActual, setSelectedIdActual] = useState(null) const heatmapRef = useRef(null) const heatmapSectionRef = useRef(null) const tableSectionRef = useRef(null) - // Click en heatmap → resaltar fila en tabla + scroll a tabla - const handleHeatmapClick = useCallback((bpmnElementId: string) => { - setSelectedId(bpmnElementId) + // Estado y refs para el tab "Automatizado" + const [selectedIdAuto, setSelectedIdAuto] = useState(null) + const heatmapRefAuto = useRef(null) + const heatmapSectionRefAuto = useRef(null) + const tableSectionRefAuto = useRef(null) + + const [isExportingPdf, setIsExportingPdf] = useState(false) + const [isExportingCsv, setIsExportingCsv] = useState(false) + const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>('actual') + + // Tab "Actual" — cross-linking heatmap ↔ tabla + const handleHeatmapClickActual = useCallback((bpmnElementId: string) => { + setSelectedIdActual(bpmnElementId) setTimeout(() => { tableSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 50) }, []) - // Click en fila de tabla → resaltar en canvas + scroll a heatmap - const handleTableRowClick = useCallback((bpmnElementId: string) => { - setSelectedId(bpmnElementId) + const handleTableRowClickActual = useCallback((bpmnElementId: string) => { + setSelectedIdActual(bpmnElementId) heatmapRef.current?.highlightElement(bpmnElementId) setTimeout(() => { heatmapSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 50) }, []) + // Tab "Automatizado" — cross-linking heatmap ↔ tabla + const handleHeatmapClickAuto = useCallback((bpmnElementId: string) => { + setSelectedIdAuto(bpmnElementId) + setTimeout(() => { + tableSectionRefAuto.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 50) + }, []) + + const handleTableRowClickAuto = useCallback((bpmnElementId: string) => { + setSelectedIdAuto(bpmnElementId) + heatmapRefAuto.current?.highlightElement(bpmnElementId) + setTimeout(() => { + heatmapSectionRefAuto.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 50) + }, []) + const handleExportPdf = useCallback(async () => { if (!process || !simulation || isExportingPdf) return setIsExportingPdf(true) try { - const heatmapImageData = await heatmapRef.current?.captureImage() ?? null + let heatmapImageData: string | null = null + if (activeTab === 'actual') { + heatmapImageData = await heatmapRef.current?.captureImage() ?? null + } else if (activeTab === 'automatizado') { + heatmapImageData = await heatmapRefAuto.current?.captureImage() ?? null + } const { exportToPdf } = await import('@/lib/export/pdf-export') - await exportToPdf({ process, simulation, resources, heatmapImageData }) + await exportToPdf({ process, simulation, resources, heatmapImageData, tab: activeTab, activities }) toast({ title: 'PDF generado', description: 'El reporte se descargó correctamente.' }) } catch (err) { console.error('PDF export error:', err) @@ -66,14 +123,14 @@ export function ReportPage() { } finally { setIsExportingPdf(false) } - }, [process, simulation, resources, isExportingPdf, toast]) + }, [process, simulation, resources, isExportingPdf, toast, activeTab, activities]) const handleExportCsv = useCallback(async () => { if (!process || !simulation || isExportingCsv) return setIsExportingCsv(true) try { const { exportToCsv } = await import('@/lib/export/csv-export') - exportToCsv({ process, simulation, resources }) + exportToCsv({ process, simulation, resources, tab: activeTab, activities }) toast({ title: 'CSV generado', description: 'Los datos se descargaron correctamente.' }) } catch (err) { console.error('CSV export error:', err) @@ -81,14 +138,13 @@ export function ReportPage() { } finally { setIsExportingCsv(false) } - }, [process, simulation, resources, isExportingCsv, toast]) + }, [process, simulation, resources, isExportingCsv, toast, activeTab, activities]) - // ── Loading — skeleton en lugar de pantalla en blanco ──────────────────── + // ── Loading ─────────────────────────────────────────────────────────────── if (isLoading) { return (
- {/* Header skeleton */}
@@ -100,7 +156,6 @@ export function ReportPage() {
- {/* KPI skeleton */}
{[0, 1, 2, 3].map((i) => (
@@ -113,12 +168,10 @@ export function ReportPage() {
))}
- {/* Canvas skeleton */}
- {/* Table skeleton */}
{[0, 1, 2, 3].map((i) => ( @@ -159,14 +212,19 @@ export function ReportPage() { } const result = simulation.result - const perActivity = result.perActivity + const resultAutomated = simulation.resultAutomated ?? null + const hasAutomatedResult = resultAutomated !== null + + // Tabs de escenarios se habilitan solo cuando hay resultado automatizado Y hay actividades + // marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual. + const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0 return (
- {/* ── 1. Header ─────────────────────────────────────────────── */} + {/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */} - {/* ── 2. KPI Bar ────────────────────────────────────────────── */} - + {/* ── Tabs ─────────────────────────────────────────────────────── */} + setActiveTab(v as typeof activeTab)}> + + Actual - {/* ── 3. Mapa de Calor ──────────────────────────────────────── */} -
-
-
-

Mapa de calor de costos

-

- Hacé click en una actividad para ver su detalle en la tabla -

-
- -
+ + + + + Automatizado + + + + {!hasAutomatedScenario && ( + + {!hasAutomatedResult + ? 'Re-simulá el proceso para generar el escenario automatizado' + : 'Marcá al menos una actividad como automatizable en el workspace para ver este escenario'} + + )} + - + + + + + Comparación + ROI + + + + {!hasAutomatedScenario && ( + + {!hasAutomatedResult + ? 'Re-simulá el proceso para generar el análisis de ROI' + : 'Marcá al menos una actividad como automatizable en el workspace para calcular el ROI'} + + )} + + - -
+ {/* ──────────────── TAB: ACTUAL ──────────────────────────────── */} + + - {/* ── 4. Warnings ───────────────────────────────────────────── */} - +
+
+
+

Mapa de calor de costos

+

+ Hacé click en una actividad para ver su detalle en la tabla +

+
+ +
- {/* ── 5. Análisis — 3 gráficos ──────────────────────────────── */} -
-

Análisis de composición

-
-
-

- Top actividades por costo -

- + +
-
-

- Composición directo / overhead -

- + + +
+

Análisis de composición

+
+
+

+ Top actividades por costo +

+ +
+
+

+ Composición directo / overhead +

+ +
+
+

+ Costo por tipo de recurso +

+ +
+
-
-

- Costo por tipo de recurso -

- +
+

+ Detalle por actividad + + ({result.perActivity.length} actividades) + +

+
+
-
-
- {/* ── 6. Tabla detalle ──────────────────────────────────────── */} -
-
-

- Detalle por actividad - ({perActivity.length} actividades) -

-
- -
+ + + + - + {/* ──────────────── TAB: AUTOMATIZADO ───────────────────────── */} + + {!hasAutomatedResult ? ( + + ) : ( + <> + - {/* ── 7. Footer metodológico ────────────────────────────────── */} - +
+
+
+

Mapa de calor — Escenario Automatizado

+

+ Hacé click en una actividad para ver su detalle en la tabla +

+
+ +
+ + + + + + +
+ + + +
+

Análisis de composición

+
+
+

+ Top actividades por costo +

+ +
+
+

+ Composición directo / overhead +

+ +
+
+

+ Costo por tipo de recurso +

+ +
+
+
+ +
+
+

+ Detalle por actividad — Automatizado + + ({resultAutomated!.perActivity.length} actividades) + +

+
+ +
+ + + + + + )} +
+ + {/* ──────────────── TAB: COMPARACIÓN + ROI ──────────────────── */} + + {!hasAutomatedResult || !roi ? ( + + ) : ( + <> + {/* KPIs de ROI */} + + + {/* Tabla de comparación actividad por actividad */} +
+

+ Comparación por actividad +

+ +
+ + {/* Gráficos: ahorro acumulado + top ahorros */} +
+
+

+ Ahorro acumulado neto ({process.analysisHorizonYears} años) +

+ +
+
+

+ Top actividades por ahorro +

+ +
+
+ + {/* Transparencia metodológica (condicional) */} +
+ +
+ + + + + + )} +
+ -
) } + +// ── Componente auxiliar: aviso cuando falta re-simular ─────────────────────── + +function NeedsResimulate({ processId }: { processId: string }) { + const navigate = useNavigate() + return ( +
+
+ +
+
+

+ Esta simulación no incluye el escenario automatizado +

+

+ Volvé al workspace y presioná "Simular" para generar ambos escenarios. +

+
+ +
+ ) +} diff --git a/src/features/report/RoiKpiBar.tsx b/src/features/report/RoiKpiBar.tsx new file mode 100644 index 0000000..54151db --- /dev/null +++ b/src/features/report/RoiKpiBar.tsx @@ -0,0 +1,110 @@ +import { TrendingDown, Calendar, BarChart3, DollarSign, Percent } from 'lucide-react' +import { formatCurrency, formatPercent } from '@/lib/format' +import type { RoiResult } from '@/domain/roi' + +interface RoiKpiBarProps { + roi: RoiResult + currency: string + horizonYears: number +} + +function RoiKpiCard({ + label, value, sub, icon, iconColor, valueColor, +}: { + label: string + value: string + sub?: string + icon: React.ReactNode + iconColor: string + valueColor?: string +}) { + return ( +
+
+ {icon} +
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ ) +} + +export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) { + const paybackStr = !isFinite(roi.paybackMonths) + ? 'No se recupera' + : roi.paybackMonths === 0 + ? 'Inmediato' + : roi.paybackMonths < 24 + ? `${roi.paybackMonths.toFixed(1)} meses` + : `${roi.paybackYears.toFixed(1)} años` + + const roiStr = !isFinite(roi.roiAnnualPercent) + ? '∞%' + : formatPercent(roi.roiAnnualPercent) + + const savingsPosColor = roi.savingsPerExecution > 0 + ? 'text-emerald-700' + : roi.savingsPerExecution < 0 + ? 'text-red-600' + : 'text-slate-900' + + const annualPosColor = roi.annualSavings > 0 + ? 'text-emerald-700' + : roi.annualSavings < 0 + ? 'text-red-600' + : 'text-slate-900' + + const cumulativePosColor = roi.cumulativeSavingsHorizon > 0 + ? 'text-emerald-700' + : roi.cumulativeSavingsHorizon < 0 + ? 'text-red-600' + : 'text-slate-900' + + return ( +
+ } + iconColor="bg-emerald-50" + valueColor={savingsPosColor} + /> + } + iconColor="bg-emerald-50" + valueColor={annualPosColor} + /> + } + iconColor="bg-primary/10" + valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'} + /> + } + iconColor="bg-violet-50" + valueColor={cumulativePosColor} + /> + } + iconColor="bg-amber-50" + valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'} + /> +
+ ) +} diff --git a/src/features/report/TopSavingsChart.tsx b/src/features/report/TopSavingsChart.tsx new file mode 100644 index 0000000..f742757 --- /dev/null +++ b/src/features/report/TopSavingsChart.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react' +import ReactECharts from 'echarts-for-react' +import { formatCurrency } from '@/lib/format' +import type { ActivitySimResult } from '@/domain/types' + +interface TopSavingsChartProps { + perActivityActual: ActivitySimResult[] + perActivityAutomated: ActivitySimResult[] + currency: string +} + +function truncate(s: string, max: number) { + return s.length > max ? s.slice(0, max - 1) + '…' : s +} + +export function TopSavingsChart({ + perActivityActual, + perActivityAutomated, + currency, +}: TopSavingsChartProps) { + const option = useMemo(() => { + const automatedMap = new Map(perActivityAutomated.map((a) => [a.activityId, a])) + + const top5 = perActivityActual + .map((act) => { + const auto = automatedMap.get(act.activityId) + const savings = act.expectedTotalCost - (auto?.expectedTotalCost ?? act.expectedTotalCost) + return { name: act.activityName, value: savings } + }) + .filter((s) => s.value > 0) + .sort((a, b) => b.value - a.value) + .slice(0, 5) + + if (top5.length === 0) return null + + const reversed = [...top5].reverse() + + return { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + formatter: (params: unknown[]) => { + const p = params[0] as { name: string; value: number } + return `
${p.name}
Ahorro: ${formatCurrency(p.value, currency)}
` + }, + }, + grid: { left: 12, right: 32, top: 8, bottom: 8, containLabel: true }, + xAxis: { + type: 'value', + axisLabel: { + fontSize: 10, + formatter: (v: number) => { + if (v === 0) return '0' + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M` + if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k` + return String(v) + }, + }, + splitLine: { lineStyle: { color: '#f1f5f9' } }, + }, + yAxis: { + type: 'category', + data: reversed.map((s) => truncate(s.name, 22)), + axisLabel: { fontSize: 10, color: '#64748b' }, + axisTick: { show: false }, + axisLine: { show: false }, + }, + series: [ + { + type: 'bar', + data: reversed.map((s) => s.value), + barMaxWidth: 32, + itemStyle: { color: '#10b981', borderRadius: [0, 4, 4, 0] }, + label: { + show: true, + position: 'right', + fontSize: 10, + formatter: (p: { value: number }) => formatCurrency(p.value, currency), + color: '#10b981', + }, + }, + ], + } + }, [perActivityActual, perActivityAutomated, currency]) + + if (!option) { + return ( +
+ Sin actividades con ahorro positivo +
+ ) + } + + return +} diff --git a/src/features/report/TransparencySection.tsx b/src/features/report/TransparencySection.tsx new file mode 100644 index 0000000..7ec1094 --- /dev/null +++ b/src/features/report/TransparencySection.tsx @@ -0,0 +1,91 @@ +import { AlertTriangle } from 'lucide-react' +import { formatPercent } from '@/lib/format' +import type { Activity } from '@/domain/types' +import type { RoiResult } from '@/domain/roi' + +// Umbral de ROI a partir del cual se dispara la advertencia de verosimilitud. +// Un ROI > 1000% anual es matemáticamente posible pero comercialmente inverosímil: +// indica casi siempre un input incorrecto (frecuencia sobreestimada, costo auto subestimado +// o inversión subestimada). Documentado en METHODOLOGY_AUTOMATED_COST.md. +const ROI_HIGH_THRESHOLD = 1000 + +interface TransparencySectionProps { + activities: Activity[] + investment: number + roi?: RoiResult +} + +export function TransparencySection({ activities, investment, roi }: TransparencySectionProps) { + const zeroCostAutomatable = activities.filter( + (a) => a.automatable && a.automatedCostFixed === 0 && a.automatedTimeMinutes === 0 + ) + const investmentIsZero = investment === 0 + + // Caso 4: ROI > 1000% finito (Infinity no aplica — ya lo cubre el caso de investment=0) + const roiIsUnusuallyHigh = + roi !== undefined && + Number.isFinite(roi.roiAnnualPercent) && + roi.roiAnnualPercent > ROI_HIGH_THRESHOLD + + if (zeroCostAutomatable.length === 0 && !investmentIsZero && !roiIsUnusuallyHigh) return null + + return ( +
+
+ +

Transparencia metodológica

+
+ + {zeroCostAutomatable.length > 0 && ( +
+

+ {zeroCostAutomatable.length === 1 + ? '1 actividad automatizable sin costo configurado:' + : `${zeroCostAutomatable.length} actividades automatizables sin costo configurado:`} +

+
    + {zeroCostAutomatable.map((a) => ( +
  • + {a.name || a.bpmnElementId} +
  • + ))} +
+

+ Estas actividades se incluyen en el escenario automatizado con costo cero, lo que puede + inflar artificialmente el ahorro calculado. Configurá sus costos en el workspace para + un análisis realista. +

+
+ )} + + {investmentIsZero && ( +

+ Inversión en automatización no configurada.{' '} + El payback aparece como inmediato y el ROI como infinito porque no hay inversión inicial. + Configurá la inversión total en la tab "Global" del workspace para un análisis + financiero completo. +

+ )} + + {roiIsUnusuallyHigh && roi && ( +
+

+ + ROI inusualmente alto detectado: {formatPercent(roi.roiAnnualPercent)}. + {' '} + Un ROI superior al {formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar uno de estos casos: +

+
    +
  • Frecuencia anual del proceso sobreestimada
  • +
  • Costo automatizado por ejecución subestimado
  • +
  • Inversión en automatización subestimada
  • +
+

+ Revisá estos inputs antes de presentar el reporte al cliente. + Ver METHODOLOGY_AUTOMATED_COST.md para guía de estimación. +

+
+ )} +
+ ) +} diff --git a/src/features/simulation/useSimulate.ts b/src/features/simulation/useSimulate.ts index b1bc466..89d58b3 100644 --- a/src/features/simulation/useSimulate.ts +++ b/src/features/simulation/useSimulate.ts @@ -1,14 +1,12 @@ import { useCallback } from 'react' -import { v4 as uuidv4 } from 'uuid' import { useProcessStore } from '@/store/process-store' import { useSimulationStore } from '@/store/simulation-store' import { runSimulation } from '@/domain/simulation' -import { simulationRepo } from '@/persistence/repositories' import type { SimulationInput } from '@/domain/types' export function useSimulate() { const { currentProcess, activities, gateways, resources } = useProcessStore() - const { setResult, setRunning, setError } = useSimulationStore() + const { persistResults, setRunning, setError } = useSimulationStore() const simulate = useCallback(async () => { if (!currentProcess) { @@ -20,7 +18,7 @@ export function useSimulate() { setError(null) try { - const input: SimulationInput = { + const baseInput: Omit = { processXml: currentProcess.bpmnXml, activities: new Map(activities.map((a) => [a.bpmnElementId, a])), gateways: new Map(gateways.map((g) => [g.bpmnElementId, g])), @@ -31,26 +29,20 @@ export function useSimulate() { }, } - const result = runSimulation(input) + // Las dos simulaciones se computan y persisten SIEMPRE en conjunto (regla dura). + const resultActual = runSimulation({ ...baseInput, scenario: 'actual' }) + const resultAutomated = runSimulation({ ...baseInput, scenario: 'automated' }) - const simulation = { - id: uuidv4(), - processId: currentProcess.id, - executedAt: Date.now(), - result, - } - - await simulationRepo.save(simulation) - setResult(result) + await persistResults(currentProcess.id, resultActual, resultAutomated) setRunning(false) - return result + return { resultActual, resultAutomated } } catch (err) { const message = err instanceof Error ? err.message : 'Error en la simulación' setError(message) setRunning(false) return null } - }, [currentProcess, activities, gateways, resources, setResult, setRunning, setError]) + }, [currentProcess, activities, gateways, resources, persistResults, setRunning, setError]) return { simulate } } diff --git a/src/features/workspace/ActivityPanel.tsx b/src/features/workspace/ActivityPanel.tsx index 2541472..3306f30 100644 --- a/src/features/workspace/ActivityPanel.tsx +++ b/src/features/workspace/ActivityPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { PlusCircle, Trash2, Info } from 'lucide-react' +import { PlusCircle, Trash2, Info, AlertTriangle } from 'lucide-react' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' @@ -7,6 +7,8 @@ import { Slider } from '@/components/ui/slider' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { ScrollArea } from '@/components/ui/scroll-area' +import { Switch } from '@/components/ui/switch' +import { Separator } from '@/components/ui/separator' import { useProcessStore } from '@/store/process-store' import { formatCurrency } from '@/lib/format' import type { Activity } from '@/domain/types' @@ -226,6 +228,101 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) { )}
+ {/* ── Sección: Automatización ──────────────────────────────────── */} +
+ + +
+

Automatización

+ + {/* Toggle */} +
+ + handleChange('automatable', v)} + aria-label="Marcar actividad como automatizable" + /> +
+ + {/* Campos — aparecen con transición suave cuando toggle=ON */} +
+ + {/* Costo automatizado */} +
+
+ + + + + + + Costo recurrente por ejecución automatizada. No incluye la inversión inicial del proyecto. + Ver METHODOLOGY_AUTOMATED_COST.md para la guía de cálculo. + + +
+ handleChange('automatedCostFixed', Math.max(0, parseFloat(e.target.value) || 0))} + className="font-mono h-8 text-sm" + placeholder="0" + /> +
+ + {/* Tiempo automatizado */} +
+ + handleChange('automatedTimeMinutes', Math.max(0, parseFloat(e.target.value) || 0))} + className="font-mono h-8 text-sm" + placeholder="0" + /> +
+ + {/* Helper ámbar — siempre en el DOM para que la transición funcione + al tipear un valor (no desaparece instantáneo sino con fade). + La condición controla opacity + max-h, no mount/unmount. */} + {(() => { + const showHelper = localActivity.automatable + && localActivity.automatedCostFixed === 0 + && localActivity.automatedTimeMinutes === 0 + return ( +
+
+ +

+ Esta actividad aparecerá en "Transparencia metodológica" del reporte hasta que cargues valores. +

+
+
+ ) + })()} +
+
+
+ {/* Guardar */}
+ {/* ── Sección: Análisis de Impacto ──────────────────────────────── */} +
+ +
+

Análisis de Impacto

+ + {/* Frecuencia anual */} +
+
+ + + + + + + Cantidad de veces que se ejecuta el proceso completo por año. Se usa para anualizar los costos y calcular el ahorro. + + +
+ { setLocalFrequency(Math.max(1, parseInt(e.target.value) || 1)); markDirty() }} + className="font-mono h-8 text-sm" + placeholder="1000" + /> +

+ Ejemplos: 12 (mensual) · 52 (semanal) · 250 (diario laboral) · 8760 (cada hora) +

+
+ + {/* Horizonte de análisis */} +
+
+
+ + + + + + + Años hasta los que se proyecta el ahorro acumulado. Determina el eje X del gráfico de ROI. + + +
+ + {localHorizon} {localHorizon === 1 ? 'año' : 'años'} + +
+ { setLocalHorizon(v); markDirty() }} + /> +
+ + {/* Inversión en automatización */} +
+
+ + + + + + + Costo único total del proyecto: desarrollo, licencias, implementación, capacitación. No incluir costos recurrentes. + + +
+ { setLocalInvestment(Math.max(0, parseFloat(e.target.value) || 0)); markDirty() }} + className="font-mono h-8 text-sm" + placeholder="0" + /> +
+
+
+ {/* Guardar */}