13 KiB
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:
// ROTO — slider con divisor /100 del modelo anterior
<Slider
value={[assignment.utilizationMinutes]}
min={0}
max={100}
step={5}
onValueChange={([v]) => 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:
import { Slider } from '@/components/ui/slider'
Asegurarse de que no quede ningún uso de <Slider en el archivo.
2. Agregar currentProcess al destructuring del store
// ANTES
const { activities, resources, updateActivity } = useProcessStore()
// DESPUÉS
const { activities, resources, updateActivity, currentProcess } = useProcessStore()
currentProcess ya existe en el store (src/store/process-store.ts línea 6). No requiere ningún cambio en el store.
3. Actualizar la función setUtilization — renombrar y agregar setUnits
Reemplazar la función setUtilization actual por dos funciones separadas y claras:
function setUtilizationMinutes(resourceId: string, minutes: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
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:
// 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:
{/* Recursos asignados */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium block">Recursos asignados</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-52 text-xs">
Cada recurso aporta costo según: costo/hora × minutos por ejecución × unidades
</TooltipContent>
</Tooltip>
</div>
{localActivity.assignedResources.length === 0 ? (
<p className="text-xs text-slate-400 py-1">Sin recursos asignados</p>
) : (
<div className="space-y-2">
{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 (
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2.5">
{/* Header del recurso */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
<p className="text-xs text-slate-400 font-mono">{formatCurrency(resource.costPerHour)}/h</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-destructive"
onClick={() => removeResource(assignment.resourceId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Inputs: minutos y unidades */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-slate-500">Minutos / ejecución</Label>
<Input
type="number"
min={0}
step={1}
value={assignment.utilizationMinutes === 0 ? '' : assignment.utilizationMinutes}
onChange={(e) => setUtilizationMinutes(assignment.resourceId, parseFloat(e.target.value) || 0)}
className="font-mono h-7 text-xs"
placeholder="0"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-slate-500">Unidades</Label>
<Input
type="number"
min={1}
step={1}
value={assignment.units}
onChange={(e) => setUnits(assignment.resourceId, parseFloat(e.target.value) || 1)}
className="font-mono h-7 text-xs"
/>
</div>
</div>
{/* Costo calculado en tiempo real */}
<div className="flex items-center justify-between pt-0.5">
<span className="text-xs text-slate-400">Costo por ejecución</span>
<span className="text-xs font-mono font-semibold text-slate-700">
{formatCurrency(resourceCost)}
</span>
</div>
</div>
)
})}
</div>
)}
{/* Selector para agregar recurso */}
{availableResources.length > 0 && (
<Select onValueChange={addResource}>
<SelectTrigger className="h-8 text-xs">
<div className="flex items-center gap-1.5 text-slate-500">
<PlusCircle className="h-3.5 w-3.5" />
<SelectValue placeholder="Agregar recurso…" />
</div>
</SelectTrigger>
<SelectContent>
{availableResources.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name} — {formatCurrency(r.costPerHour)}/h
</SelectItem>
))}
</SelectContent>
</Select>
)}
{resources.length === 0 && (
<p className="text-xs text-slate-400">
Creá recursos en la tab "Recursos" para asignarlos
</p>
)}
</div>
{/* ── Resumen de costos en tiempo real ─────────────────────────────── */}
{(localActivity.assignedResources.length > 0 || localActivity.directCostFixed > 0) && (
<div className="rounded-lg border border-slate-200 bg-white p-3 space-y-1.5">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Costo por ejecución</p>
{localActivity.directCostFixed > 0 && (
<div className="flex justify-between">
<span className="text-xs text-slate-500">Fijo (insumos, licencias)</span>
<span className="text-xs font-mono text-slate-600">{formatCurrency(localActivity.directCostFixed)}</span>
</div>
)}
{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 (
<div key={assignment.resourceId} className="flex justify-between">
<span className="text-xs text-slate-500">{resource.name}</span>
<span className="text-xs font-mono text-slate-600">{formatCurrency(cost)}</span>
</div>
)
})}
<div className="flex justify-between border-t border-slate-100 pt-1.5 mt-1">
<span className="text-xs font-semibold text-slate-700">Total</span>
<span className="text-xs font-mono font-bold text-[#F59845]">{formatCurrency(localTotalCost)}</span>
</div>
</div>
)}
{/* ── 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 (
<div className="flex items-start gap-2 rounded-lg bg-orange-50 border border-orange-100 px-3 py-2">
<Info className="h-3.5 w-3.5 text-[#F59845] shrink-0 mt-0.5" />
<p className="text-xs text-orange-700 leading-snug">
A {Math.round(execsPerMonth)} ejecuciones/mes, <strong>{topAssignment.name}</strong> dedica{' '}
<strong>{daysPerMonth < 1 ? `${Math.round(daysPerMonth * 8)}h` : `${daysPerMonth.toFixed(1)} días`}</strong>{' '}
al mes a esta actividad.
</p>
</div>
)
})()}
Verificaciones obligatorias antes de reportar
1. Grep de limpieza — sin Slider
grep -n "Slider\|utilizationPercent" src/features/workspace/ActivityPanel.tsx
Debe devolver 0 resultados.
2. Verificación de scope con git diff — OBLIGATORIO
git diff --name-only HEAD
El resultado debe contener únicamente:
src/features/workspace/ActivityPanel.tsx
Si aparece cualquier otro archivo:
- Revertilo:
git checkout HEAD -- <archivo> - Listarlo bajo "Conflictos de scope" en el reporte
- No incluirlo en el entregable de esta etapa
3. Build limpio
npm run build
Sin errores TypeScript.
4. Tests
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 > 0y 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+]