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

13 KiB
Raw Permalink Blame History

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:

// 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:

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 159228) 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 &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

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:

  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

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 > 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+]