Files
inq-roi-simulador-web/src/features/workspace/GatewayPanel.tsx
Marcos Benítez bed161c92a feat: MVP Fase 0 — Process Cost Platform v0.1.0
Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:58:40 -03:00

278 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Info, AlertTriangle, RotateCcw, Check, GitMerge } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { parseBpmnXml } from '@/domain/bpmn-parser'
import { formatPercent } from '@/lib/format'
import type { GatewayConfig } from '@/domain/types'
interface GatewayPanelProps {
selectedElementId: string | null
}
// Tolerancia para validar que la suma de probs XOR = 1.0
const XOR_SUM_TOLERANCE = 0.005
function sumBranches(gw: GatewayConfig): number {
return gw.branches.reduce((s, b) => s + b.probability, 0)
}
export function isXorSumValid(gw: GatewayConfig): boolean {
if (gw.gatewayType !== 'exclusive') return true
return Math.abs(sumBranches(gw) - 1.0) <= XOR_SUM_TOLERANCE
}
export function GatewayPanel({ selectedElementId }: GatewayPanelProps) {
const { gateways, currentProcess, updateGateway, activities } = useProcessStore()
const [localGw, setLocalGw] = useState<GatewayConfig | null>(null)
const [isDirty, setIsDirty] = useState(false)
// Mapa bpmnElementId → nombre legible (parseamos el XML una sola vez)
const elementNames = useMemo(() => {
if (!currentProcess) return new Map<string, string>()
try {
const graph = parseBpmnXml(currentProcess.bpmnXml)
return new Map(Array.from(graph.nodes.values()).map((n) => [n.id, n.name || n.id]))
} catch {
return new Map<string, string>()
}
}, [currentProcess?.bpmnXml])
const gateway = gateways.find((g) => g.bpmnElementId === selectedElementId) ?? null
useEffect(() => {
setLocalGw(gateway ? structuredClone(gateway) : null)
setIsDirty(false)
}, [selectedElementId, gateways])
function targetLabel(targetId: string): string {
// Primero buscar en actividades (tienen nombre más descriptivo)
const act = activities.find((a) => a.bpmnElementId === targetId)
if (act?.name) return act.name
// Luego en el mapa del grafo
const fromGraph = elementNames.get(targetId)
if (fromGraph) return fromGraph
return targetId
}
function setProbability(branchIndex: number, value: number) {
if (!localGw) return
const updated = structuredClone(localGw)
updated.branches[branchIndex].probability = value
setLocalGw(updated)
setIsDirty(true)
}
function distributeUniformly() {
if (!localGw) return
const n = localGw.branches.length
const equalProb = parseFloat((1 / n).toFixed(4))
const updated = structuredClone(localGw)
updated.branches = updated.branches.map((b, i) => ({
...b,
probability: i === n - 1 ? parseFloat((1 - equalProb * (n - 1)).toFixed(4)) : equalProb,
}))
setLocalGw(updated)
setIsDirty(true)
}
async function handleSave() {
if (!localGw) return
await updateGateway(localGw)
setIsDirty(false)
}
// ── Empty state ──────────────────────────────────────────────────────────
if (!selectedElementId) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mb-3">
<GitMerge className="h-6 w-6 text-slate-400" />
</div>
<p className="text-sm font-medium text-slate-600 mb-1">Seleccioná un gateway</p>
<p className="text-xs text-slate-400">Hacé click sobre un gateway en el diagrama para configurar sus probabilidades</p>
</div>
)
}
if (!localGw) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-xs text-slate-400">Gateway sin configuración</p>
</div>
)
}
// ── AND Paralelo: informativo, sin sliders ────────────────────────────────
if (localGw.gatewayType === 'parallel') {
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Gateway Paralelo (AND)</span>
</div>
<p className="text-xs font-medium text-slate-800 font-mono">{localGw.bpmnElementId}</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-semibold text-blue-800">Ejecución paralela</p>
<p className="text-xs text-blue-700 mt-0.5">
En un gateway AND todas las salidas se ejecutan simultáneamente.
No hay probabilidades configurables la probabilidad de cada rama
es igual a la probabilidad de llegada al gateway.
</p>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium">Flujos de salida</Label>
{localGw.branches.map((branch) => (
<div key={branch.flowId} className="flex items-center justify-between p-2.5 rounded-lg bg-slate-50 border border-slate-100">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 truncate">{targetLabel(branch.targetElementId)}</p>
<p className="text-xs text-slate-400 font-mono">{branch.flowId}</p>
</div>
<span className="text-xs font-mono font-semibold text-blue-600 shrink-0 ml-2">100%</span>
</div>
))}
</div>
</div>
</ScrollArea>
)
}
// ── XOR Exclusivo / OR Inclusivo (con aviso) ──────────────────────────────
const isInclusive = localGw.gatewayType === 'inclusive'
const currentSum = sumBranches(localGw)
const xorValid = isInclusive || Math.abs(currentSum - 1.0) <= XOR_SUM_TOLERANCE
const sumColor = xorValid ? 'text-green-600' : 'text-destructive'
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Header */}
<div>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide block mb-1">
{isInclusive ? 'Gateway Inclusivo (OR)' : 'Gateway Exclusivo (XOR)'}
</span>
<p className="text-xs font-medium text-slate-800 font-mono">{localGw.bpmnElementId}</p>
</div>
{/* Aviso para OR inclusivo */}
{isInclusive && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
<span className="font-semibold">Gateway Inclusivo (OR)</span> En el MVP se simula como
XOR: solo una rama se toma por instancia. Las probabilidades deben sumar 1.
El soporte OR nativo llega en V1.0.
</p>
</div>
)}
{/* Suma actual + botón distribuir */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">Suma de probabilidades:</span>
<span className={`text-xs font-mono font-bold ${sumColor}`}>
{formatPercent(currentSum * 100, 1)}
</span>
{!xorValid && (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-3.5 w-3.5 text-destructive cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-44 text-xs">
La suma debe ser exactamente 100% para poder simular
</TooltipContent>
</Tooltip>
)}
{xorValid && (
<Tooltip>
<TooltipTrigger asChild>
<Check className="h-3.5 w-3.5 text-green-500 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
Suma 100% listo para simular
</TooltipContent>
</Tooltip>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1 text-slate-500"
onClick={distributeUniformly}
>
<RotateCcw className="h-3 w-3" />
Distribuir uniformemente
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-52 text-xs">
Reparte el 100% en partes iguales entre todas las ramas del gateway
</TooltipContent>
</Tooltip>
</div>
{/* Sliders por rama */}
<div className="space-y-4">
{localGw.branches.map((branch, idx) => (
<div key={branch.flowId} className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 truncate leading-tight">
{targetLabel(branch.targetElementId)}
</p>
<p className="text-[10px] text-slate-400 font-mono truncate">{branch.flowId}</p>
</div>
<span className="text-xs font-mono font-semibold text-slate-700 shrink-0 w-11 text-right tabular-nums">
{formatPercent(branch.probability * 100, 1)}
</span>
</div>
<Slider
value={[Math.round(branch.probability * 1000) / 10]}
min={0}
max={100}
step={1}
onValueChange={([v]) => setProbability(idx, parseFloat((v / 100).toFixed(4)))}
/>
</div>
))}
</div>
{/* Error de suma — banner prominente */}
{!xorValid && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive">
La suma es <strong>{formatPercent(currentSum * 100, 1)}</strong> debe ser 100%.
Ajustá los sliders o usá "Distribuir" para repartir uniformemente.
El botón <strong>Simular</strong> quedará deshabilitado hasta corregirlo.
</p>
</div>
)}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar probabilidades' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}