Files
inq-roi-simulador-web/sprints/sprint-2/ETAPA_2_PROMPT.md

368 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 193199:
```tsx
// 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 (0100) 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 `<Slider` en el archivo.
### 2. Agregar `currentProcess` al destructuring del store
```tsx
// 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:
```tsx
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:
```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 159228) con:
```tsx
{/* 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 &quot;Recursos&quot; 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
```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 -- <archivo>`
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+]
```