feat(sprint-2): recursos por actividad — modelo, UX y PDF

Etapas 3-6 del Sprint 2. Resumen de cambios:

**Reporte web (Etapa 3)**
- ActivitiesTable: filas expandibles con desglose fijo + recursos por actividad
- CumulativeSavingsChart: fix payback label superpuesto con eje Y
- BpmnCanvas: badge  14→20px
- TECH_DEBT cerrado: chart -inversión (ya estaba), payback label, badge

**Workspace (Etapa 4)**
- WorkspacePage: edición inline de nombre de proceso y cliente
  (click → input → Enter/blur guarda en IndexedDB, checkmark naranja 1.5s)

**PDF (Etapas 5-6)**
- pdf-sections: drawResourcesSection — tabla condicional de recursos por actividad
  con chips de tipo (rol/persona/sistema/equipamiento/insumo)
- pdf-export: página de recursos (pág. 4 cuando hay datos, metodología pasa a pág. 5)
- Fix truncamiento: splitTextToSize en resourceName y top3 ROI
- Fix texto: "ejecución" con tilde, header "Esp. (USD)" sin truncar
- TECH_DEBT cerrado: truncamiento notas metodológicas

**Tests**
- 7 tests nuevos: drawResourcesSection (4) + comportamiento condicional PDF (3)
- Total: 541 → 548 tests verdes

BREAKING: exportScenarioPdf ahora recibe activities[] como parámetro adicional.
This commit is contained in:
2026-05-20 08:24:32 -03:00
parent 8b6ae5fa64
commit 1ff2724735
9 changed files with 1330 additions and 17 deletions

View File

@@ -0,0 +1,254 @@
# Sprint 2 — Etapa 4: Edición inline de nombre de proceso y cliente
> **Tipo:** UI — un solo componente. Sin cambios en dominio, persistencia ni reporte.
> **Objetivo:** hacer editable el nombre del proceso y el cliente directamente desde el header del workspace. Hoy son campos estáticos (`<p>`); deben convertirse en inputs inline que guardan en IndexedDB al confirmar.
> **Resultado esperado:** click sobre nombre o cliente → input inline → Enter o blur guarda → checkmark naranja breve confirma. Validación visual del director requerida antes del OK formal.
---
## Archivo a modificar
**Un único archivo:** `src/features/workspace/WorkspacePage.tsx`
Si durante la implementación identificás que otro archivo necesita cambio, **detenete y reportá el conflicto antes de tocarlo**. No procedas sin confirmación.
---
## Contexto: estado actual del header
El header del workspace (líneas ~139144) tiene hoy:
```tsx
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
{currentProcess.clientName && (
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
)}
</div>
```
Ambos son texto estático. La función `updateProcess` ya existe en el store
(`useProcessStore`) y persiste en IndexedDB — no requiere ningún cambio fuera de este archivo.
---
## Cambios requeridos
### 1. Agregar `updateProcess` al destructuring del store
```tsx
// ANTES (línea ~26)
const { currentProcess, activities, isLoading, error, loadProcess, gateways } = useProcessStore()
// DESPUÉS
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
```
### 2. Agregar iconos al import de lucide-react
Agregar `Check` y `Pencil` al import existente:
```tsx
import {
Loader2, BarChart3, Play, AlertTriangle,
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2,
Check, Pencil
} from 'lucide-react'
```
### 3. Agregar estado local para edición inline
Insertar antes del `return`, junto a los otros estados del componente:
```tsx
// Estado para edición inline del header
const [editingField, setEditingField] = useState<'name' | 'client' | null>(null)
const [editValue, setEditValue] = useState('')
const [savedField, setSavedField] = useState<'name' | 'client' | null>(null)
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
```
`useRef` ya está importado desde React — no requiere cambio en imports de React.
### 4. Agregar función `saveField`
Insertar junto a las otras funciones del componente (cerca de `handleSimulate`):
```tsx
async function saveField(field: 'name' | 'client') {
if (field === 'name') {
const trimmed = editValue.trim()
if (trimmed && trimmed !== currentProcess?.name) {
await updateProcess({ name: trimmed })
}
} else {
const trimmed = editValue.trim()
if (trimmed !== (currentProcess?.clientName ?? '')) {
await updateProcess({ clientName: trimmed })
}
}
setEditingField(null)
// Wow: checkmark naranja breve
setSavedField(field)
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
savedTimerRef.current = setTimeout(() => setSavedField(null), 1500)
}
```
### 5. Reemplazar el bloque estático del header
Reemplazar las líneas ~139144 (el `<div className="flex-1 min-w-0">` completo) con:
```tsx
<div className="flex-1 min-w-0">
{/* Nombre del proceso — editable inline */}
{editingField === 'name' ? (
<input
autoFocus
className="text-sm font-semibold text-slate-800 bg-transparent border-b border-[#F59845] outline-none w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveField('name')}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); saveField('name') }
if (e.key === 'Escape') setEditingField(null)
}}
/>
) : (
<div
className="flex items-center gap-1 group cursor-text"
onClick={() => { setEditValue(currentProcess.name); setEditingField('name') }}
>
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
{savedField === 'name'
? <Check className="h-3.5 w-3.5 text-[#F59845] shrink-0" />
: <Pencil className="h-3 w-3 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
}
</div>
)}
{/* Cliente — editable inline */}
{editingField === 'client' ? (
<input
autoFocus
className="text-xs text-slate-400 bg-transparent border-b border-[#F59845] outline-none w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveField('client')}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); saveField('client') }
if (e.key === 'Escape') setEditingField(null)
}}
placeholder="Agregar cliente…"
/>
) : (
<div
className="flex items-center gap-1 group cursor-text"
onClick={() => { setEditValue(currentProcess.clientName ?? ''); setEditingField('client') }}
>
{currentProcess.clientName
? <p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
: <p className="text-xs text-slate-300 truncate italic">Agregar cliente</p>
}
{savedField === 'client'
? <Check className="h-3 w-3 text-[#F59845] shrink-0" />
: <Pencil className="h-2.5 w-2.5 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
}
</div>
)}
</div>
```
---
## Comportamientos esperados
- **Click sobre el nombre** → input inline con el valor actual, borde inferior naranja
- **Enter o blur** → guarda, vuelve a modo lectura, aparece ✓ naranja 1.5 segundos
- **Escape** → cancela sin guardar, vuelve al valor anterior
- **Nombre vacío al guardar** → no guarda (el `if (trimmed && ...)` lo previene), vuelve al valor anterior
- **Cliente vacío al guardar** → guarda string vacío (limpiar el cliente es una acción válida)
- **Sin cliente asignado** → muestra "Agregar cliente…" en gris itálico; al hacer click se abre el input vacío
- El ícono ✏️ es invisible hasta hacer hover sobre la fila — indica que es editable sin ocupar espacio visualmente
---
## Verificaciones obligatorias antes de reportar
### 1. Verificación de scope con git diff — OBLIGATORIO
```bash
git diff --name-only HEAD
```
El resultado debe contener únicamente:
```
src/features/workspace/WorkspacePage.tsx
```
Si aparece cualquier archivo no listado:
1. Revertilo: `git checkout HEAD -- <archivo>`
2. Listarlo bajo "Conflictos de scope" en el reporte
3. No incluirlo en el entregable
### 2. Build limpio
```bash
npm run build
```
Sin errores TypeScript.
### 3. Tests
```bash
npm run test
```
Todos los tests existentes deben seguir verdes. Esta etapa no agrega tests nuevos (UI pura, sin lógica de dominio nueva).
### 4. Smoke test manual
- [ ] Click sobre el nombre del proceso → se abre input con valor actual
- [ ] Tipear un nombre nuevo → Enter → se cierra, aparece ✓ naranja breve, el nombre cambió
- [ ] Recargar la página → el nombre nuevo persiste (IndexedDB)
- [ ] Click sobre el cliente → input editable
- [ ] Borrar el cliente completamente → guardar → el campo queda vacío mostrando "Agregar cliente…"
- [ ] Escape cancela sin guardar
---
## Alcance estricto — lo que NO entra en esta etapa
-`GlobalSettingsPanel.tsx` — no tocar (edición desde settings sigue funcionando igual)
- ❌ Ningún archivo de reporte (`src/features/report/`) — Etapa 3 ya cerrada
- ❌ Ningún archivo de dominio (`src/domain/`) — Etapas 13 ya cerraron dominio
- ❌ Ningún archivo de persistencia (`src/persistence/`) — `updateProcess` del store es suficiente
- ❌ PDFs — Etapa 5
- ❌ Edición de campos adicionales del proceso (frecuencia anual, overhead) — BACKLOG
---
## Reporte esperado al terminar
```
## Etapa 4 completada
### Archivos modificados
- src/features/workspace/WorkspacePage.tsx
### Cambios realizados
- Edición inline de nombre de proceso: click → input → Enter/blur guarda
- Edición inline de cliente: click → input → Enter/blur guarda
- Checkmark naranja (#F59845) confirmatorio con timeout 1.5s
- Ícono ✏️ visible al hover, oculto en reposo
- Escape cancela sin guardar en ambos campos
### Verificaciones
- git diff --name-only HEAD: solo WorkspacePage.tsx ✅
- npm run build: ✅
- npm run test: X/X tests verdes ✅
- Smoke test: [descripción breve]
### Conflictos de scope
[ninguno / lista de archivos revertidos y razón]
### Notas para Etapas siguientes
[hallazgos relevantes para Etapas 5+]
```