# Sprint 2 — Etapa 2: ActivityPanel "wow" > **Tipo:** UI — un solo componente. Sin cambios en dominio, persistencia ni reporte. > **Objetivo:** reescribir la sección de recursos de `ActivityPanel.tsx`: reemplazar el slider roto por inputs numéricos, agregar campo de unidades, mostrar cálculo de costo en tiempo real y helper contextual. > **Resultado esperado:** panel funcional y visualmente consistente con el sistema de diseño InQ ROI. Validación visual del director requerida antes del OK formal. --- ## Estado de partida — lo que hay que corregir El archivo `src/features/workspace/ActivityPanel.tsx` quedó en estado intermedio después de Etapa 1. El bug está en líneas 193–199: ```tsx // ROTO — slider con divisor /100 del modelo anterior setUtilization(assignment.resourceId, v / 100)} // ← bug /> ``` El divisor `/100` hace que cualquier posición del slider (0–100) se divida antes de `Math.round()`, produciendo solo 0 o 1 minuto. El slider es inusable. **Esta etapa reemplaza ese bloque completamente** con inputs numéricos. El componente conserva toda la lógica no-rota (guardar, cambios, automatización, etc.) — solo se rehace la sección de recursos asignados. --- ## Archivo a modificar **Un único archivo:** `src/features/workspace/ActivityPanel.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. --- ## Cambios requeridos en detalle ### 1. Eliminar `Slider` del import y del componente Remover: ```tsx import { Slider } from '@/components/ui/slider' ``` Asegurarse de que no quede ningún uso de ` r.resourceId === resourceId ? { ...r, utilizationMinutes: Math.max(0, Math.round(minutes)) } : r ) ) } function setUnits(resourceId: string, units: number) { if (!localActivity) return handleChange( 'assignedResources', localActivity.assignedResources.map((r) => r.resourceId === resourceId ? { ...r, units: Math.max(1, Math.round(units)) } : r ) ) } ``` ### 4. Calcular el costo total de la actividad (en tiempo real, sin guardar) Agregar estas constantes calculadas antes del `return`, usando los datos locales del estado: ```tsx // Costo de recursos calculado en tiempo real (misma fórmula que simulation.ts) const localResourceCost = localActivity.assignedResources.reduce((total, assignment) => { const resource = resources.find((r) => r.id === assignment.resourceId) if (!resource) return total return total + (resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units) }, 0) const localTotalCost = localActivity.directCostFixed + localResourceCost ``` ### 5. Reemplazar el bloque de recursos asignados completo Reemplazar desde `{/* Recursos asignados */}` hasta el cierre del bloque `{resources.length === 0 && ...}` (líneas 159–228) con: ```tsx {/* Recursos asignados */}
Cada recurso aporta costo según: costo/hora × minutos por ejecución × unidades
{localActivity.assignedResources.length === 0 ? (

Sin recursos asignados

) : (
{localActivity.assignedResources.map((assignment) => { const resource = resources.find((r) => r.id === assignment.resourceId) if (!resource) return null // Costo de este recurso en tiempo real const resourceCost = resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units return (
{/* Header del recurso */}

{resource.name}

{formatCurrency(resource.costPerHour)}/h

{/* Inputs: minutos y unidades */}
setUtilizationMinutes(assignment.resourceId, parseFloat(e.target.value) || 0)} className="font-mono h-7 text-xs" placeholder="0" />
setUnits(assignment.resourceId, parseFloat(e.target.value) || 1)} className="font-mono h-7 text-xs" />
{/* Costo calculado en tiempo real */}
Costo por ejecución {formatCurrency(resourceCost)}
) })}
)} {/* Selector para agregar recurso */} {availableResources.length > 0 && ( )} {resources.length === 0 && (

Creá recursos en la tab "Recursos" para asignarlos

)}
{/* ── Resumen de costos en tiempo real ─────────────────────────────── */} {(localActivity.assignedResources.length > 0 || localActivity.directCostFixed > 0) && (

Costo por ejecución

{localActivity.directCostFixed > 0 && (
Fijo (insumos, licencias) {formatCurrency(localActivity.directCostFixed)}
)} {localActivity.assignedResources.map((assignment) => { const resource = resources.find((r) => r.id === assignment.resourceId) if (!resource) return null const cost = resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units if (cost === 0) return null return (
{resource.name} {formatCurrency(cost)}
) })}
Total {formatCurrency(localTotalCost)}
)} {/* ── Helper contextual ────────────────────────────────────────────── */} {(() => { const annualFrequency = currentProcess?.annualFrequency ?? 0 if (annualFrequency === 0 || localActivity.assignedResources.length === 0) return null // Recurso con mayor costo como referencia del helper const topAssignment = [...localActivity.assignedResources] .map((a) => { const r = resources.find((res) => res.id === a.resourceId) return r ? { name: r.name, cost: r.costPerHour * a.utilizationMinutes / 60 * a.units, utilizationMinutes: a.utilizationMinutes } : null }) .filter(Boolean) .sort((a, b) => (b?.cost ?? 0) - (a?.cost ?? 0))[0] if (!topAssignment || topAssignment.utilizationMinutes === 0) return null const execsPerMonth = annualFrequency / 12 const minutesPerMonth = execsPerMonth * topAssignment.utilizationMinutes const daysPerMonth = minutesPerMonth / 60 / 8 if (daysPerMonth < 0.1) return null return (

A {Math.round(execsPerMonth)} ejecuciones/mes, {topAssignment.name} dedica{' '} {daysPerMonth < 1 ? `${Math.round(daysPerMonth * 8)}h` : `${daysPerMonth.toFixed(1)} días`}{' '} al mes a esta actividad.

) })()} ``` --- ## Verificaciones obligatorias antes de reportar ### 1. Grep de limpieza — sin Slider ```bash grep -n "Slider\|utilizationPercent" src/features/workspace/ActivityPanel.tsx ``` Debe devolver 0 resultados. ### 2. Verificación de scope con git diff — OBLIGATORIO ```bash git diff --name-only HEAD ``` **El resultado debe contener únicamente:** ``` src/features/workspace/ActivityPanel.tsx ``` Si aparece cualquier otro archivo: 1. Revertilo: `git checkout HEAD -- ` 2. Listarlo bajo "Conflictos de scope" en el reporte 3. No incluirlo en el entregable de esta etapa ### 3. Build limpio ```bash npm run build ``` Sin errores TypeScript. ### 4. Tests ```bash npm run test ``` Todos los tests existentes deben seguir verdes. Esta etapa no agrega tests nuevos (es UI pura). ### 5. Smoke test manual Abrir `npm run dev`, cargar un proceso, asignar un recurso a una actividad y verificar: - [ ] Los inputs de minutos y unidades son editables - [ ] El costo por ejecución se actualiza al tipear - [ ] El resumen total aparece cuando hay datos - [ ] El helper naranja aparece si `annualFrequency > 0` y hay recursos con minutos > 0 - [ ] El botón Guardar se activa al cambiar valores - [ ] La sección de Automatización sigue funcionando igual --- ## Alcance estricto — lo que NO entra en esta etapa - ❌ `ResourcesPanel.tsx` — no requiere cambios, CRUD de recursos funciona correctamente - ❌ Ningún archivo de reporte (`src/features/report/`) — Etapa 3 - ❌ Ningún archivo de dominio (`src/domain/`) — Etapa 1 ya los cerró - ❌ Ningún archivo de persistencia (`src/persistence/`) — no tocar - ❌ Edición inline de proceso/cliente — Etapa 4 - ❌ PDFs — Etapa 5 --- ## Reporte esperado al terminar ``` ## Etapa 2 completada ### Archivos modificados - src/features/workspace/ActivityPanel.tsx ### Cambios realizados - Slider eliminado (import removido, sin referencias en el archivo) - Inputs numéricos de utilizationMinutes y units implementados - Cálculo en tiempo real: costo por recurso + resumen total - Helper contextual con annualFrequency del proceso - [cualquier variación respecto al diseño propuesto] ### Verificaciones - grep Slider/utilizationPercent en ActivityPanel: 0 resultados ✅ - git diff --name-only HEAD: solo ActivityPanel.tsx ✅ - npm run build: ✅ - npm run test: X tests, todos verdes ✅ - Smoke test: [descripción breve de lo que se verificó manualmente] ### Notas para Etapas siguientes [hallazgos relevantes para Etapas 3+] ```