feat(sprint-3): biblioteca, recursos TO-BE, ROI mejorado e identidad de marca

Sprint 3 completo — 7 etapas:

- Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4,
  LibraryPage, library-store, groupId/tags en Process)
- Etapa 3: modal de configuración global por proceso (horizonte, frecuencia,
  inversión, moneda) con validación y persistencia
- Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos
  de utilización (utilizationMinutes + units)
- Etapa 5: recursos en escenario automatizado — motor de simulación,
  UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte
- Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido,
  561 tests pasando
- Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo
  InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:49:43 -03:00
parent d2746f07d1
commit 67e259e6ed
57 changed files with 4372 additions and 82 deletions

View File

@@ -0,0 +1,307 @@
# Sprint 3 — Etapa 4: Indicador stale + zoom BPMN
## Contexto
Sos Claude Code trabajando en **InQ ROI**. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`.
**Estado al iniciar esta etapa:**
- Etapas 1-3 completadas. Biblioteca de procesos operativa. Tags y búsqueda funcionando. `SettingsPage` disponible.
- `process-store.ts` expone: `updateActivity`, `updateGateway`, `updateResource`, `addResource`, `deleteResource`, `updateProcess`. Ninguna de estas acciones actualiza `process.updatedAt` excepto `updateProcess`.
- `simulation-store.ts` tiene `lastSimulatedAt: Date | null` (in-memory, se pierde al recargar) y `persistResults` (guarda en IndexedDB con `executedAt: Date.now()`).
- `BpmnCanvas.tsx`: viewer bpmn-js puro, sin toolbar, retorna un `<div>` que es el contenedor del canvas. Ya llama `canvas.zoom('fit-viewport', 'auto')` al importar el XML.
**Esta etapa agrega dos features independientes:**
1. **Indicador stale** — aviso persistente cuando el proceso tiene cambios sin simular.
2. **Botón fit-viewport** — encuadrar el diagrama BPMN en cualquier momento.
---
## Objetivo
**D.1 — Indicador de simulación desactualizada**
Cuando el usuario modifica actividades, recursos o gateways sin volver a simular, el workspace muestra un indicador visible y persistente (sobrevive a recargas de página).
**D.2 — Botón fit-viewport en BpmnCanvas**
Un botón flotante sobre el canvas que ejecuta `canvas.zoom('fit-viewport')`. Detectado en validación Sprint 2 como necesidad real en talleres con diagramas grandes.
---
## Arquitectura — leer antes de implementar
### Dependencias entre stores (no crear dependencias circulares)
```
process-store.ts
↑ importado por
simulation-store.ts (excepción documentada en el archivo)
```
Para evitar dependencia circular, **`process-store.ts` NO debe importar `simulation-store.ts`**. La coordinación entre stores la hace `WorkspacePage`.
### Flujo correcto para el indicador stale
1. `process-store.ts` — bump `process.updatedAt` en mutaciones de actividades/gateways/recursos.
2. `simulation-store.ts` — agregar `lastPersistedExecutedAt: number | null` (cargado desde IndexedDB, sobrevive recargas) y la acción `loadLatestForProcess(processId)`.
3. `WorkspacePage` — orquesta: llama `loadLatestForProcess` cuando el proceso está cargado, y computa `isStale` comparando los dos valores.
---
## Cambios autorizados y arquitectura
### `src/store/process-store.ts`
Agregar bump de `process.updatedAt` en todas las acciones que mutan datos del proceso sin ser `updateProcess`:
```typescript
// Patrón a aplicar en: updateActivity, updateGateway, updateResource, addResource, deleteResource
// (updateProcess ya actualiza updatedAt correctamente — no modificar)
// Ejemplo — updateActivity (aplicar el mismo patrón a las demás):
updateActivity: async (activity) => {
await activityRepo.save(activity)
const current = get().currentProcess
if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() }
await processRepo.save(updatedProcess)
set((state) => ({
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
currentProcess: updatedProcess,
}))
} else {
set((state) => ({
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
}))
}
},
```
**Nota:** la suscripción en `simulation-store.ts` ya llama `invalidateResults()` cuando `currentProcess` cambia. Esto es correcto y esperado — no modificar esa suscripción.
### `src/store/simulation-store.ts`
Agregar campo y acción para la comparación persistente:
```typescript
interface SimulationState {
// ... campos existentes ...
lastPersistedExecutedAt: number | null // 🆕 executedAt de la última sim guardada en IndexedDB
loadLatestForProcess: (processId: string) => Promise<void> // 🆕
}
```
Implementación:
```typescript
lastPersistedExecutedAt: null,
loadLatestForProcess: async (processId) => {
const sim = await simulationRepo.getLatestByProcess(processId)
set({ lastPersistedExecutedAt: sim?.executedAt ?? null })
},
```
Actualizar también `persistResults` para mantener el campo sincronizado:
```typescript
persistResults: async (processId, actual, automated) => {
const executedAt = Date.now()
await simulationRepo.save({
id: uuidv4(),
processId,
executedAt,
result: actual,
resultAutomated: automated,
})
set({
resultActual: actual,
resultAutomated: automated,
lastSimulatedAt: new Date(),
lastPersistedExecutedAt: executedAt, // 🆕
error: null,
})
},
```
Y limpiar en `reset`:
```typescript
reset: () => set({ ..., lastPersistedExecutedAt: null })
```
### `src/features/workspace/WorkspacePage.tsx`
**1 — Orquestación (nuevo `useEffect`)**
Después de que el proceso se carga, cargar el último `executedAt` persistido:
```typescript
const { loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore()
useEffect(() => {
if (currentProcess) {
loadLatestForProcess(currentProcess.id)
}
}, [currentProcess?.id, loadLatestForProcess])
```
**2 — Cómputo del indicador**
```typescript
const isStale = Boolean(
currentProcess &&
currentProcess.updatedAt > (lastPersistedExecutedAt ?? 0)
)
```
**3 — UI del indicador**
Mostrar un badge sutil **junto al botón Simular** cuando `isStale === true`. El botón Simular ya existe en el header del workspace.
Diseño del indicador:
- Chip naranja claro: fondo `#fff3e6`, borde `#F59845`, texto `#7a3e00`, ícono `AlertCircle` (Lucide, `h-3 w-3`).
- Texto: `"Hay cambios sin simular"`.
- Ubicación: inline junto al botón Simular, o como fila adicional debajo del header — a criterio del agente según el layout actual (Nivel 1).
- Visible solo cuando `isStale === true`. Desaparece al completar la simulación (porque `persistResults` actualiza `lastPersistedExecutedAt`).
### `src/features/workspace/BpmnCanvas.tsx`
Agregar botón flotante de fit-viewport. El canvas actualmente retorna:
```tsx
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
```
Cambiar a wrapper con botón superpuesto:
```tsx
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
<button
onClick={() => {
const canvas = viewerRef.current?.get('canvas') as any
canvas?.zoom('fit-viewport')
}}
title="Encuadrar diagrama"
style={{
position: 'absolute',
bottom: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 6,
background: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
zIndex: 10,
}}
>
<Maximize2 className="h-4 w-4 text-muted-foreground" />
</button>
</div>
)
```
Importar `Maximize2` desde `lucide-react`.
---
## Versión con wow (recomendada si costo < 40% del mínimo)
**Banner en ReportPage:**
En `src/features/report/ReportPage.tsx`, si el proceso tiene cambios sin simular, mostrar un banner sutil en la parte superior del reporte:
```
⚠️ Los resultados pueden estar desactualizados — hay cambios sin simular.
[Ir al workspace →]
```
Diseño: fondo `#fffbeb`, borde `#f59e0b`, texto `#92400e` (amber). Full-width, 40px de alto máximo. Link "Ir al workspace →" navega a `/workspace/$processId`.
`ReportPage` ya usa `useProcessStore` para leer `currentProcess`. Necesita también `useSimulationStore().lastPersistedExecutedAt` para el cálculo de `isStale`.
---
## ❌ Lo que NO entra en esta etapa
- Cambios en `ActivityPanel.tsx`, `ResourcesPanel.tsx`, `GatewayPanel.tsx` — no tocar
- `automatedAssignedResources` — sigue siendo opcional, no promover (Etapa 5)
- Cambios en el motor de simulación (`domain/simulation.ts`)
- Export PDF/CSV
- Biblioteca o library-store
- Tags, settings
---
## Verificación antes de entregar
```bash
# 1. Build limpio
npm run build
# 2. Tests (todos los previos deben seguir verdes)
npm run test
# 3. Control de scope
git diff --name-only HEAD
```
El diff debe mostrar únicamente:
- `src/store/process-store.ts`
- `src/store/simulation-store.ts`
- `src/features/workspace/WorkspacePage.tsx`
- `src/features/workspace/BpmnCanvas.tsx`
- `src/features/report/ReportPage.tsx` (solo si se implementó wow)
Si aparece cualquier otro archivo de `src/features/` que no sea workspace/ o report/: revertir y reportar.
### Verificación funcional mandatoria
Antes de reportar, verificar manualmente:
1. **Stale persiste en recarga:** modificar una actividad → recargar página → el indicador sigue visible → simular → desaparece.
2. **Sin sim previa:** proceso recién importado sin simular nunca → indicador visible (ya que `updatedAt > 0 > null ?? 0`).
3. **Fit-viewport:** abrir un diagrama complejo → click en el botón → el canvas se encuadra.
4. **Sin falsos positivos:** proceso recién simulado sin cambios posteriores → indicador ausente.
---
## Criterios de aceptación
- [ ] Modificar actividad/recurso/gateway actualiza `process.updatedAt` en IndexedDB.
- [ ] Indicador "Hay cambios sin simular" aparece en WorkspacePage al detectar `isStale`.
- [ ] Indicador desaparece al simular exitosamente.
- [ ] Indicador persiste al recargar la página (comparación contra IndexedDB, no solo memoria).
- [ ] Proceso sin historial de simulación → indicador visible.
- [ ] Botón fit-viewport en BpmnCanvas encuadra el diagrama correctamente.
- [ ] Build sin errores TypeScript.
- [ ] Todos los tests previos siguen verdes.
- [ ] Validación visual del director aprobada.
---
## Reporte esperado al terminar
1. Resultado de `git diff --name-only HEAD`.
2. Resultado de `npm run build` y `npm run test`.
3. Confirmación de la verificación funcional (4 casos).
4. Si se implementó el banner en ReportPage (wow): declararlo.
5. Cualquier decisión de iniciativa tomada (Nivel 1/2/3).
6. Si hubo archivo fuera del scope: declararlo con justificación.
---
## Referencias
- `sprints/sprint-3/BRIEF.md` — sección 4, Feature D
- `src/store/process-store.ts` — acciones a modificar: `updateActivity`, `updateGateway`, `updateResource`, `addResource`, `deleteResource`
- `src/store/simulation-store.ts` — estado actual, suscripción a process-store
- `src/features/workspace/BpmnCanvas.tsx` — canvas actual, sin toolbar
- `methodology/ANTI_PATTERNS.md` — AP.8 (adelanto silencioso)
- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo)