Sprint 2 Etapas 1-2: modelo recursos utilizationMinutes+units, Dexie v3, ActivityPanel wow
This commit is contained in:
367
sprints/sprint-2/ETAPA_2_PROMPT.md
Normal file
367
sprints/sprint-2/ETAPA_2_PROMPT.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 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
|
||||
<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:
|
||||
```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 159–228) 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 "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
|
||||
```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+]
|
||||
```
|
||||
Reference in New Issue
Block a user