Files
inq-roi-simulador-web/sprints/sprint-3/ETAPA_5_PROMPT.md
Marcos Benítez 67e259e6ed feat(sprint-3): biblioteca, recursos TO-BE, ROI mejorado e identidad de marca
Sprint 3 completo — 7 etapas:

- Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4,
  LibraryPage, library-store, groupId/tags en Process)
- Etapa 3: modal de configuración global por proceso (horizonte, frecuencia,
  inversión, moneda) con validación y persistencia
- Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos
  de utilización (utilizationMinutes + units)
- Etapa 5: recursos en escenario automatizado — motor de simulación,
  UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte
- Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido,
  561 tests pasando
- Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo
  InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:49:43 -03:00

17 KiB
Raw Blame History

Sprint 3 — Etapa 5: Recursos en escenario automatizado

Contexto

Sos Claude Code trabajando en InQ ROI. Las reglas de implementación están en simulador-web/CLAUDE.md. El brief del sprint está en sprints/sprint-3/BRIEF.md.

Estado al iniciar esta etapa:

  • Etapas 14 completadas. Dexie v4 activo. Biblioteca operativa con grupos, tags, búsqueda. Indicador stale funcionando (persiste en recarga). Botón fit-viewport en BpmnCanvas.
  • Activity.automatedAssignedResources?: ActivityResourceAssignment[] existe en types.ts como campo opcional desde Etapa 1. La migración v4 ya popula las actividades existentes con [].
  • simulation.ts actualmente usa activity.assignedResources en ambos escenarios. La rama useAutomated solo cambia directCostFixed y executionTimeMinutes, pero no cambia los recursos — bug intencional pendiente de esta etapa.
  • ActivityPanel.tsx tiene la sección "Automatización" con toggle, automatedCostFixed e automatedTimeMinutes. Los recursos automatizados no tienen UI todavía.
  • ActivitiesTable.tsx recibe perActivity: ActivitySimResult[] y muestra resourceCostBreakdown en filas expandibles.

Esta etapa conecta las tres capas: motor de simulación, UI de edición, y visualización en reporte.


Objetivo

E.1 — Motor de simulación Cuando useAutomated = true, usar activity.automatedAssignedResources ?? [] en lugar de activity.assignedResources para el cálculo de costos de recursos.

E.2 — UI en ActivityPanel Agregar subsección "Recursos TO-BE" dentro de la sección Automatización del ActivityPanel (visible cuando automatable = true). Mismo patrón de asignación que los recursos AS-IS.

E.3 — Reporte web (versión mínima) Sin cambios necesarios: cuando el motor usa automatedAssignedResources, el resourceCostBreakdown de SimulationResult ya refleja correctamente los recursos del escenario automatizado. Las filas expandibles existentes en ActivitiesTable muestran el breakdown del escenario que corresponde a cada tab.

E.3 wow — Comparación lado a lado En el tab "Automatizado", las filas expandibles de actividades automatizables muestran AMBOS breakdowns: "Recursos AS-IS" y "Recursos TO-BE", para facilitar la comparación.


Arquitectura — leer antes de implementar

automatedAssignedResources sigue siendo opcional en types.ts

No cambiar types.ts en esta etapa. El campo permanece como automatedAssignedResources?: ActivityResourceAssignment[]. La promoción a requerido + actualización de tests va en Etapa 6 para hacer el cambio en un solo paso limpio.

Consecuencia: en todo el código nuevo usar activity.automatedAssignedResources ?? [] para manejar el caso de actividades antiguas que podrían no tener el campo.

Dependencias autorizadas

ActivityPanel ya importa de useProcessStore para leer resources. No necesita importar nuevas stores para esta feature.


Cambios autorizados y arquitectura

src/domain/simulation.ts

Cambio mínimo: usar los recursos correctos según el escenario.

Localizar el bloque de la línea ~207:

// ACTUAL (usa siempre assignedResources):
for (const assignment of activity.assignedResources) {

Reemplazar por:

// CORRECTO: seleccionar el array según el escenario
const resourceAssignments = useAutomated
  ? (activity.automatedAssignedResources ?? [])
  : activity.assignedResources

for (const assignment of resourceAssignments) {

Este es el único cambio en simulation.ts. No tocar nada más en este archivo.

src/features/import/ImportPage.tsx

Agregar automatedAssignedResources: [] a la construcción de actividades (~línea 89):

const activities: Activity[] = activityElements.map((el) => ({
  id: uuidv4(),
  processId,
  bpmnElementId: el.bpmnElementId,
  name: el.name,
  type: el.type,
  directCostFixed: 0,
  executionTimeMinutes: 0,
  assignedResources: [],
  automatable: false,
  automatedCostFixed: 0,
  automatedTimeMinutes: 0,
  automatedAssignedResources: [],  // 🆕
}))

Clasificación de iniciativa: Nivel 1 — compatibilidad preventiva, no hay cambio visible.

src/features/workspace/ActivityPanel.tsx

1 — Inicialización del estado local

Actualizar el setLocalActivity inicial para incluir automatedAssignedResources:

setLocalActivity(activity ? {
  ...activity,
  assignedResources: [...activity.assignedResources],
  automatedAssignedResources: [...(activity.automatedAssignedResources ?? [])],
} : null)

2 — Handlers de recursos automatizados

Agregar tres handlers paralelos a los de AS-IS (cerca de addResource, removeResource, updateResourceAssignment):

const addAutomatedResource = (resourceId: string) => {
  if (!localActivity) return
  const already = (localActivity.automatedAssignedResources ?? []).some(
    (r) => r.resourceId === resourceId
  )
  if (already) return
  handleChange('automatedAssignedResources', [
    ...(localActivity.automatedAssignedResources ?? []),
    { resourceId, utilizationMinutes: 60, units: 1 },
  ])
}

const removeAutomatedResource = (resourceId: string) => {
  if (!localActivity) return
  handleChange(
    'automatedAssignedResources',
    (localActivity.automatedAssignedResources ?? []).filter((r) => r.resourceId !== resourceId)
  )
}

const updateAutomatedAssignment = (
  resourceId: string,
  field: 'utilizationMinutes' | 'units',
  value: number
) => {
  if (!localActivity) return
  handleChange(
    'automatedAssignedResources',
    (localActivity.automatedAssignedResources ?? []).map((r) =>
      r.resourceId === resourceId ? { ...r, [field]: value } : r
    )
  )
}

3 — Dropdown de recursos disponibles para TO-BE

const availableAutomatedResources = resources.filter(
  (r) => !(localActivity.automatedAssignedResources ?? []).some((ar) => ar.resourceId === r.id)
)

4 — Costo total de recursos TO-BE (para mostrar en UI)

const localAutomatedResourceCost = (localActivity.automatedAssignedResources ?? []).reduce(
  (total, assignment) => {
    const r = resources.find((res) => res.id === assignment.resourceId)
    if (!r) return total
    return total + r.costPerHour * (assignment.utilizationMinutes / 60) * assignment.units
  },
  0
)

5 — UI dentro del contenedor de Automatización

Cambio requerido previo: el contenedor animado actual tiene max-h-80 (320px), insuficiente para el nuevo contenido. Cambiar a max-h-[640px].

Dentro del contenedor animado (después de los inputs automatedCostFixed y automatedTimeMinutes), agregar:

{/* Separator + Recursos TO-BE */}
<Separator />

<div className="space-y-2">
  <div className="flex items-center justify-between">
    <p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
      Recursos TO-BE
    </p>
    {/* Dropdown para agregar recurso */}
    {availableAutomatedResources.length > 0 && (
      <Select onValueChange={addAutomatedResource}>
        <SelectTrigger className="h-7 text-xs w-[120px]">
          <SelectValue placeholder="+ Agregar" />
        </SelectTrigger>
        <SelectContent>
          {availableAutomatedResources.map((r) => (
            <SelectItem key={r.id} value={r.id} className="text-xs">
              {r.name}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    )}
  </div>

  {/* Lista de recursos asignados al escenario automatizado */}
  {(localActivity.automatedAssignedResources ?? []).length === 0 ? (
    <p className="text-xs text-slate-400 italic">
      Sin recursos TO-BE  solo costo fijo automatizado.
    </p>
  ) : (
    <div className="space-y-2">
      {(localActivity.automatedAssignedResources ?? []).map((assignment) => {
        const resource = resources.find((r) => r.id === assignment.resourceId)
        if (!resource) return null
        const cost = resource.costPerHour * (assignment.utilizationMinutes / 60) * assignment.units
        return (
          <div key={assignment.resourceId} className="rounded-lg border border-border bg-card p-2 space-y-1.5">
            <div className="flex items-center justify-between">
              <span className="text-xs font-medium truncate max-w-[120px]">{resource.name}</span>
              <div className="flex items-center gap-2">
                <span className="text-xs font-mono text-slate-500">
                  {formatCurrency(cost, currency)}
                </span>
                <button
                  type="button"
                  onClick={() => removeAutomatedResource(assignment.resourceId)}
                  className="text-slate-400 hover:text-red-500 transition-colors"
                  aria-label={`Quitar ${resource.name}`}
                >
                  <X className="h-3.5 w-3.5" />
                </button>
              </div>
            </div>
            {/* Utilización y unidades */}
            <div className="flex items-center gap-2">
              <div className="flex-1 space-y-0.5">
                <p className="text-[10px] text-slate-400">Min. por ejecución</p>
                <Input
                  type="number"
                  min={0}
                  step={1}
                  value={assignment.utilizationMinutes || ''}
                  onChange={(e) =>
                    updateAutomatedAssignment(
                      assignment.resourceId,
                      'utilizationMinutes',
                      Math.max(0, parseFloat(e.target.value) || 0)
                    )
                  }
                  className="h-6 text-xs font-mono"
                />
              </div>
              <div className="w-16 space-y-0.5">
                <p className="text-[10px] text-slate-400">Unidades</p>
                <Input
                  type="number"
                  min={1}
                  step={1}
                  value={assignment.units || ''}
                  onChange={(e) =>
                    updateAutomatedAssignment(
                      assignment.resourceId,
                      'units',
                      Math.max(1, parseInt(e.target.value) || 1)
                    )
                  }
                  className="h-6 text-xs font-mono"
                />
              </div>
            </div>
          </div>
        )
      })}
    </div>
  )}
</div>

Nota: Si resources (el catálogo del proceso) está vacío, el dropdown no aparece y la sección muestra solo el empty state. Esto es correcto — el usuario primero carga recursos en el panel "Recursos" y luego los asigna aquí.

6 — Actualizar el helper ámbar

El helper ámbar existente muestra cuando automatedCostFixed === 0 && automatedTimeMinutes === 0. Ahora también debe considerar recursos:

const showHelper = localActivity.automatable
  && localActivity.automatedCostFixed === 0
  && localActivity.automatedTimeMinutes === 0
  && (localActivity.automatedAssignedResources ?? []).length === 0

Versión con wow — Comparación lado a lado en reporte

Implementar si el costo extra es < 40% del mínimo.

El objetivo: en el tab "Automatizado" del reporte, las filas expandibles de actividades automatizables muestran el desglose de recursos de AMBOS escenarios.

src/features/report/ActivitiesTable.tsx

Agregar prop opcional:

interface ActivitiesTableProps {
  perActivity: ActivitySimResult[]
  pairedBreakdown?: Record<string, Array<{ resourceId: string; resourceName: string; cost: number }>>
  // ...props existentes sin cambio...
}

En la fila expandida, si pairedBreakdown tiene data para esta actividad Y la actividad tiene su propio breakdown, mostrar dos secciones:

{/* En la fila expandida — cuando hay pairedBreakdown para esta actividad */}
{pairedBreakdown?.[act.activityId] && (
  <>
    <p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-1">AS-IS</p>
    {/* Lista del pairedBreakdown (recursos AS-IS) */}
    {pairedBreakdown[act.activityId].map((r) => (
      <div key={r.resourceId} className="flex justify-between text-xs">
        <span className="text-slate-600">{r.resourceName}</span>
        <span className="font-mono">{formatCurrency(r.cost, currency)}</span>
      </div>
    ))}
    <p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-2">TO-BE</p>
    {act.resourceCostBreakdown.length === 0
      ? <p className="text-xs text-slate-400 italic">Sin recursos TO-BE</p>
      : act.resourceCostBreakdown.map((r) => (
          <div key={r.resourceId} className="flex justify-between text-xs">
            <span className="text-slate-600">{r.resourceName}</span>
            <span className="font-mono">{formatCurrency(r.cost, currency)}</span>
          </div>
        ))
    }
  </>
)}

Si no hay pairedBreakdown, el comportamiento existente se mantiene sin cambios.

src/features/report/ReportPage.tsx

Agregar el cálculo del breakdown pareado:

const actualBreakdownById = useMemo(() => {
  if (!simulation?.result) return undefined
  return Object.fromEntries(
    simulation.result.perActivity.map((a) => [a.activityId, a.resourceCostBreakdown])
  )
}, [simulation?.result])

En el ActivitiesTable del tab "Automatizado", pasar el prop:

<ActivitiesTable
  perActivity={resultAutomated.perActivity}
  pairedBreakdown={actualBreakdownById}
  {/* ...props existentes... */}
/>

Lo que NO entra en esta etapa

  • src/domain/types.ts — no modificar. El campo automatedAssignedResources? permanece opcional hasta Etapa 6.
  • Tests nuevos — van en Etapa 6 junto con la promoción del campo a requerido.
  • src/store/process-store.ts — no tocar.
  • src/store/simulation-store.ts — no tocar.
  • src/persistence/db.ts — no tocar (migración ya hecha).
  • PDF — va en Etapa 6.
  • ActivityPanel.tsx sección AS-IS de recursos — no modificar la sección existente, solo agregar la sección TO-BE.
  • ResourcesPanel.tsx — no tocar.
  • GatewayPanel.tsx — no tocar.

Verificación antes de entregar

# 1. Build limpio
npm run build

# 2. Tests (todos los previos deben seguir verdes)
npm run test

# 3. Control de scope
git diff --name-only HEAD

El diff debe mostrar únicamente:

  • src/domain/simulation.ts
  • src/features/workspace/ActivityPanel.tsx
  • src/features/import/ImportPage.tsx
  • src/features/report/ActivitiesTable.tsx (solo si se implementó wow)
  • src/features/report/ReportPage.tsx (solo si se implementó wow)

Si aparece src/domain/types.ts, src/store/, src/persistence/, o cualquier panel que no sea ActivityPanel: revertir y reportar.

Verificación funcional mandatoria

Antes de reportar, verificar manualmente:

  1. Recursos AS-IS no contaminan TO-BE: asignar recursos en AS-IS → simular → el tab Automatizado NO debe incluir esos recursos si automatedAssignedResources está vacío.
  2. Recursos TO-BE correctos: asignar un recurso en TO-BE (en ActivityPanel) → simular → el tab Automatizado MUESTRA ese recurso en el breakdown de la actividad.
  3. Mixto funciona: actividad con automatedCostFixed = 500 + un recurso TO-BE → el costo automatizado total = 500 + costo del recurso.
  4. Actividad no automatizable: recursos AS-IS se usan en ambos escenarios (comportamiento anterior sin cambio).
  5. Sin recursos TO-BE: proceso sin recursos asignados en TO-BE → el motor no crashea, muestra 0 en recursos del tab Automatizado.

Criterios de aceptación

  • Motor de simulación usa automatedAssignedResources al calcular escenario automatizado.
  • Actividades no automatizables: comportamiento sin cambio (siguen usando assignedResources).
  • Sección "Recursos TO-BE" visible en ActivityPanel cuando automatable = true.
  • Asignar recurso TO-BE → Guardar → recargar página → recurso persiste.
  • Dropdown de recursos TO-BE excluye los ya asignados.
  • Costo TO-BE en reporte refleja recursos TO-BE + automatedCostFixed.
  • ImportPage crea actividades con automatedAssignedResources: [].
  • Build sin errores TypeScript.
  • Todos los tests previos siguen verdes.
  • Validación visual del director aprobada.

Reporte esperado al terminar

  1. Resultado de git diff --name-only HEAD.
  2. Resultado de npm run build y npm run test.
  3. Confirmación de los 5 casos de verificación funcional.
  4. Si se implementó comparación lado a lado (wow): declararlo.
  5. Cualquier decisión de iniciativa (Nivel 1/2/3).
  6. Si apareció algún archivo fuera del scope: declararlo con justificación.

Referencias

  • sprints/sprint-3/BRIEF.md — sección 4, Feature E y sección 3.3 (fórmula de costo automatizado)
  • src/domain/simulation.ts — línea ~207, loop de assignedResources
  • src/features/workspace/ActivityPanel.tsx — sección Automatización (~línea 352), handlers de recursos AS-IS como referencia de patrón
  • src/features/report/ActivitiesTable.tsx — prop interface y filas expandibles
  • src/features/report/ReportPage.tsx — tabs actual/automatizado, uso de simulation.result y simulation.resultAutomated
  • methodology/ANTI_PATTERNS.md — AP.8 (adelanto silencioso), AP.9 (restricciones contradictorias)
  • methodology/PATTERNS.md — C.7 (git diff como control preventivo)