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>
This commit is contained in:
241
src/features/workspace/ActivityPanel.tsx
Normal file
241
src/features/workspace/ActivityPanel.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PlusCircle, Trash2, Info } from 'lucide-react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { Activity } from '@/domain/types'
|
||||
|
||||
interface ActivityPanelProps {
|
||||
selectedElementId: string | null
|
||||
}
|
||||
|
||||
export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
const { activities, resources, updateActivity } = useProcessStore()
|
||||
const [localActivity, setLocalActivity] = useState<Activity | null>(null)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
const activity = activities.find((a) => a.bpmnElementId === selectedElementId) ?? null
|
||||
|
||||
// Sincronizar con el store cuando cambia la selección
|
||||
useEffect(() => {
|
||||
setLocalActivity(activity ? { ...activity, assignedResources: [...activity.assignedResources] } : null)
|
||||
setIsDirty(false)
|
||||
}, [selectedElementId, activities])
|
||||
|
||||
function handleChange<K extends keyof Activity>(key: K, value: Activity[K]) {
|
||||
if (!localActivity) return
|
||||
setLocalActivity((prev) => prev ? { ...prev, [key]: value } : null)
|
||||
setIsDirty(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!localActivity) return
|
||||
await updateActivity(localActivity)
|
||||
setIsDirty(false)
|
||||
}
|
||||
|
||||
function addResource(resourceId: string) {
|
||||
if (!localActivity) return
|
||||
const already = localActivity.assignedResources.some((r) => r.resourceId === resourceId)
|
||||
if (already) return
|
||||
handleChange('assignedResources', [
|
||||
...localActivity.assignedResources,
|
||||
{ resourceId, utilizationPercent: 1.0 },
|
||||
])
|
||||
}
|
||||
|
||||
function removeResource(resourceId: string) {
|
||||
if (!localActivity) return
|
||||
handleChange(
|
||||
'assignedResources',
|
||||
localActivity.assignedResources.filter((r) => r.resourceId !== resourceId)
|
||||
)
|
||||
}
|
||||
|
||||
function setUtilization(resourceId: string, value: number) {
|
||||
if (!localActivity) return
|
||||
handleChange(
|
||||
'assignedResources',
|
||||
localActivity.assignedResources.map((r) =>
|
||||
r.resourceId === resourceId ? { ...r, utilizationPercent: value } : r
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-600 mb-1">Seleccioná una actividad</p>
|
||||
<p className="text-xs text-slate-400">Hacé click sobre una tarea en el diagrama para editar sus atributos de costo</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!localActivity) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-xs text-slate-400">Esta actividad no tiene configuración todavía</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const availableResources = resources.filter(
|
||||
(r) => !localActivity.assignedResources.some((ar) => ar.resourceId === r.id)
|
||||
)
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-5">
|
||||
{/* Nombre (read-only, viene del BPMN) */}
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500 mb-1 block">Actividad</Label>
|
||||
<p className="font-semibold text-slate-800 text-sm leading-tight">{localActivity.name || localActivity.bpmnElementId}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5 font-mono">{localActivity.bpmnElementId}</p>
|
||||
</div>
|
||||
|
||||
{/* Costo directo fijo */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="direct-cost" className="text-xs font-medium">Costo directo fijo</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-slate-400 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-48 text-xs">
|
||||
Costo fijo por cada ejecución de esta actividad (materiales, licencias, etc.) — independiente del tiempo
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="direct-cost"
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
value={localActivity.directCostFixed}
|
||||
onChange={(e) => handleChange('directCostFixed', Math.max(0, parseFloat(e.target.value) || 0))}
|
||||
className="font-mono h-8 text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tiempo de ejecución */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="exec-time" className="text-xs font-medium">Tiempo de ejecución (min)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-slate-400 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-48 text-xs">
|
||||
Tiempo promedio estimado para completar esta actividad
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
id="exec-time"
|
||||
type="number"
|
||||
min={0}
|
||||
step={5}
|
||||
value={localActivity.executionTimeMinutes}
|
||||
onChange={(e) => handleChange('executionTimeMinutes', Math.max(0, parseFloat(e.target.value) || 0))}
|
||||
className="font-mono h-8 text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recursos asignados */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium block">Recursos asignados</Label>
|
||||
|
||||
{localActivity.assignedResources.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 py-2">Sin recursos asignados</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{localActivity.assignedResources.map((assignment) => {
|
||||
const resource = resources.find((r) => r.id === assignment.resourceId)
|
||||
if (!resource) return null
|
||||
const hourlyShare = (resource.costPerHour * assignment.utilizationPercent).toFixed(2)
|
||||
|
||||
return (
|
||||
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<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">{formatCurrency(resource.costPerHour)}/h · {formatCurrency(parseFloat(hourlyShare))}/h efectivo</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>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs text-slate-500">Utilización</span>
|
||||
<span className="text-xs font-mono font-medium">{Math.round(assignment.utilizationPercent * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[assignment.utilizationPercent * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={([v]) => setUtilization(assignment.resourceId, v / 100)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Guardar */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty}
|
||||
className="w-full"
|
||||
>
|
||||
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
82
src/features/workspace/BpmnCanvas.tsx
Normal file
82
src/features/workspace/BpmnCanvas.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
||||
|
||||
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
|
||||
|
||||
const ACTIVITY_TYPES = new Set([
|
||||
'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask',
|
||||
'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess',
|
||||
])
|
||||
|
||||
const GATEWAY_TYPES = new Set([
|
||||
'bpmn:ExclusiveGateway', 'bpmn:ParallelGateway',
|
||||
'bpmn:InclusiveGateway', 'bpmn:EventBasedGateway',
|
||||
])
|
||||
|
||||
function classifyElement(type: string): BpmnElementCategory {
|
||||
if (ACTIVITY_TYPES.has(type)) return 'activity'
|
||||
if (GATEWAY_TYPES.has(type)) return 'gateway'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
interface BpmnCanvasProps {
|
||||
xml: string
|
||||
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
|
||||
selectedElementId?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }: BpmnCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const viewer = new BpmnViewer({ container: containerRef.current })
|
||||
viewerRef.current = viewer
|
||||
return () => {
|
||||
viewer.destroy()
|
||||
viewerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer || !xml) return
|
||||
viewer.importXML(xml).then(({ warnings }) => {
|
||||
if (warnings.length) console.warn('bpmn-js warnings:', warnings)
|
||||
const canvas = viewer.get('canvas') as any
|
||||
canvas.zoom('fit-viewport', 'auto')
|
||||
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
|
||||
}, [xml])
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer || !onElementClick) return
|
||||
const eventBus = viewer.get('eventBus') as any
|
||||
|
||||
const handler = (event: any) => {
|
||||
const el = event.element
|
||||
if (!el) return
|
||||
const category = classifyElement(el.type)
|
||||
if (category === 'other') return
|
||||
onElementClick(el.id, el.businessObject?.name ?? el.id, category)
|
||||
}
|
||||
|
||||
eventBus.on('element.click', handler)
|
||||
return () => eventBus.off('element.click', handler)
|
||||
}, [onElementClick])
|
||||
|
||||
useEffect(() => {
|
||||
const viewer = viewerRef.current
|
||||
if (!viewer) return
|
||||
const canvas = viewer.get('canvas') as any
|
||||
const reg = viewer.get('elementRegistry') as any
|
||||
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
|
||||
if (selectedElementId) {
|
||||
try { canvas.addMarker(selectedElementId, 'bpmn-selected') } catch { /* ignorar */ }
|
||||
}
|
||||
}, [selectedElementId])
|
||||
|
||||
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
|
||||
}
|
||||
277
src/features/workspace/GatewayPanel.tsx
Normal file
277
src/features/workspace/GatewayPanel.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
140
src/features/workspace/GlobalSettingsPanel.tsx
Normal file
140
src/features/workspace/GlobalSettingsPanel.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { formatPercent } from '@/lib/format'
|
||||
|
||||
const CURRENCIES = [
|
||||
{ code: 'USD', label: 'USD — Dólar estadounidense' },
|
||||
{ code: 'PYG', label: 'PYG — Guaraní paraguayo' },
|
||||
{ code: 'BRL', label: 'BRL — Real brasileño' },
|
||||
{ code: 'ARS', label: 'ARS — Peso argentino' },
|
||||
{ code: 'EUR', label: 'EUR — Euro' },
|
||||
{ code: 'COP', label: 'COP — Peso colombiano' },
|
||||
{ code: 'MXN', label: 'MXN — Peso mexicano' },
|
||||
{ code: 'CLP', label: 'CLP — Peso chileno' },
|
||||
]
|
||||
|
||||
export function GlobalSettingsPanel() {
|
||||
const { currentProcess, updateProcess } = useProcessStore()
|
||||
const [localName, setLocalName] = useState('')
|
||||
const [localClient, setLocalClient] = useState('')
|
||||
const [localCurrency, setLocalCurrency] = useState('USD')
|
||||
const [localOverhead, setLocalOverhead] = useState(20)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProcess) return
|
||||
setLocalName(currentProcess.name)
|
||||
setLocalClient(currentProcess.clientName)
|
||||
setLocalCurrency(currentProcess.currency)
|
||||
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
||||
setIsDirty(false)
|
||||
}, [currentProcess])
|
||||
|
||||
function markDirty() { setIsDirty(true) }
|
||||
|
||||
async function handleSave() {
|
||||
await updateProcess({
|
||||
name: localName.trim() || 'Proceso sin nombre',
|
||||
clientName: localClient.trim(),
|
||||
currency: localCurrency,
|
||||
overheadPercentage: localOverhead / 100,
|
||||
})
|
||||
setIsDirty(false)
|
||||
}
|
||||
|
||||
if (!currentProcess) return null
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-5">
|
||||
{/* Nombre del proceso */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proc-name" className="text-xs font-medium">Nombre del proceso</Label>
|
||||
<Input
|
||||
id="proc-name"
|
||||
value={localName}
|
||||
onChange={(e) => { setLocalName(e.target.value); markDirty() }}
|
||||
placeholder="ej. Proceso de ventas"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cliente */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client-name" className="text-xs font-medium">Cliente</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
value={localClient}
|
||||
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
|
||||
placeholder="ej. Empresa ABC"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Moneda */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Moneda</Label>
|
||||
<Select value={localCurrency} onValueChange={(v) => { setLocalCurrency(v); markDirty() }}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code} className="text-xs">{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Overhead */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs font-medium">Overhead (costos indirectos)</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">
|
||||
Porcentaje adicional sobre los costos directos que representa los costos indirectos: alquiler, administración, infraestructura general.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[localOverhead]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([v]) => { setLocalOverhead(v); markDirty() }}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-mono font-semibold text-slate-700 w-12 text-right">
|
||||
{formatPercent(localOverhead, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Típicamente entre 15% y 40% para empresas de servicios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Guardar */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty}
|
||||
className="w-full"
|
||||
>
|
||||
{isDirty ? 'Guardar configuración' : 'Sin cambios'}
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
201
src/features/workspace/ResourcesPanel.tsx
Normal file
201
src/features/workspace/ResourcesPanel.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react'
|
||||
import { PlusCircle, Pencil, Trash2, Check, X } from 'lucide-react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { Resource, ResourceType } from '@/domain/types'
|
||||
|
||||
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
||||
role: 'Rol',
|
||||
person: 'Persona',
|
||||
system: 'Sistema',
|
||||
equipment: 'Equipo',
|
||||
supply: 'Insumo',
|
||||
}
|
||||
|
||||
const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
|
||||
role: 'bg-blue-100 text-blue-700',
|
||||
person: 'bg-green-100 text-green-700',
|
||||
system: 'bg-purple-100 text-purple-700',
|
||||
equipment: 'bg-amber-100 text-amber-700',
|
||||
supply: 'bg-slate-100 text-slate-700',
|
||||
}
|
||||
|
||||
interface ResourceFormState {
|
||||
name: string
|
||||
type: ResourceType
|
||||
costPerHour: string
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ResourceFormState = { name: '', type: 'role', costPerHour: '' }
|
||||
|
||||
export function ResourcesPanel() {
|
||||
const { currentProcess, resources, addResource, updateResource, deleteResource } = useProcessStore()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
|
||||
|
||||
function startAdd() {
|
||||
setForm(EMPTY_FORM)
|
||||
setEditingId(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function startEdit(resource: Resource) {
|
||||
setForm({ name: resource.name, type: resource.type, costPerHour: resource.costPerHour.toString() })
|
||||
setEditingId(resource.id)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.name.trim() || !currentProcess) return
|
||||
const costPerHour = parseFloat(form.costPerHour) || 0
|
||||
|
||||
if (editingId) {
|
||||
const existing = resources.find((r) => r.id === editingId)
|
||||
if (!existing) return
|
||||
await updateResource({ ...existing, name: form.name.trim(), type: form.type, costPerHour })
|
||||
} else {
|
||||
const newResource: Resource = {
|
||||
id: uuidv4(),
|
||||
processId: currentProcess.id,
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
costPerHour,
|
||||
}
|
||||
await addResource(newResource)
|
||||
}
|
||||
cancelForm()
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-700">Recursos del proceso</p>
|
||||
<p className="text-xs text-slate-400">{resources.length} definido{resources.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button size="sm" variant="outline" onClick={startAdd} className="h-7 text-xs gap-1">
|
||||
<PlusCircle className="h-3.5 w-3.5" />
|
||||
Nuevo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Formulario */}
|
||||
{showForm && (
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-lg p-3 space-y-3">
|
||||
<p className="text-xs font-semibold text-slate-700">{editingId ? 'Editar recurso' : 'Nuevo recurso'}</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Nombre</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="ej. Analista Senior"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Tipo</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v as ResourceType }))}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Costo por hora</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={5}
|
||||
placeholder="0.00"
|
||||
value={form.costPerHour}
|
||||
onChange={(e) => setForm((f) => ({ ...f, costPerHour: e.target.value }))}
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!form.name.trim()} className="flex-1 h-7 text-xs gap-1">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{editingId ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelForm} className="h-7 text-xs">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista */}
|
||||
{resources.length === 0 && !showForm ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-xs text-slate-400 mb-2">Sin recursos definidos</p>
|
||||
<p className="text-xs text-slate-300">Los recursos (roles, sistemas, equipos) se asignan a actividades para calcular su costo por hora</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{resources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-slate-100 hover:border-slate-200 bg-white group"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="text-xs font-medium text-slate-800 truncate">{resource.name}</p>
|
||||
<span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full ${RESOURCE_TYPE_COLORS[resource.type]}`}>
|
||||
{RESOURCE_TYPE_LABELS[resource.type]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-mono">{formatCurrency(resource.costPerHour)}/hora</p>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-slate-400 hover:text-slate-600"
|
||||
onClick={() => startEdit(resource)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-slate-400 hover:text-destructive"
|
||||
onClick={() => deleteResource(resource.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
293
src/features/workspace/WorkspacePage.tsx
Normal file
293
src/features/workspace/WorkspacePage.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Loader2, BarChart3, Play, AlertTriangle,
|
||||
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { BpmnCanvas, type BpmnElementCategory } from './BpmnCanvas'
|
||||
import { ActivityPanel } from './ActivityPanel'
|
||||
import { GatewayPanel, isXorSumValid } from './GatewayPanel'
|
||||
import { ResourcesPanel } from './ResourcesPanel'
|
||||
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { useSimulate } from '../simulation/useSimulate'
|
||||
|
||||
type SelectedCategory = 'activity' | 'gateway' | null
|
||||
|
||||
export function WorkspacePage() {
|
||||
const { processId } = useParams({ from: '/workspace/$processId' })
|
||||
const navigate = useNavigate()
|
||||
const { currentProcess, isLoading, error, loadProcess, gateways } = useProcessStore()
|
||||
const { isRunning, result, error: simError, selectActivity } = useSimulationStore()
|
||||
const { simulate } = useSimulate()
|
||||
const { toast } = useToast()
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [selectedElementId, setSelectedElementId] = useState<string | null>(null)
|
||||
const [selectedElementName, setSelectedElementName] = useState<string>('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<SelectedCategory>(null)
|
||||
const [activeTab, setActiveTab] = useState('elemento')
|
||||
|
||||
// Usamos un ref para saber si loadProcess ya terminó al menos una vez.
|
||||
// Esto evita que el redirect se dispare en el render inicial donde
|
||||
// isLoading=false y currentProcess=null (antes del primer efecto).
|
||||
const hasLoadedOnce = useRef(false)
|
||||
|
||||
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) { hasLoadedOnce.current = true; return }
|
||||
if (!hasLoadedOnce.current) return // carga no iniciada aún
|
||||
if ((error || !currentProcess) && processId) {
|
||||
toast({
|
||||
title: 'Proceso no encontrado',
|
||||
description: 'El proceso que buscás no existe o fue eliminado.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
}, [isLoading, error, currentProcess, processId, toast, navigate])
|
||||
|
||||
// Validación XOR: todos los gateways exclusivos deben sumar 1.0
|
||||
const xorValidation = useMemo(() => {
|
||||
const invalidIds = gateways
|
||||
.filter((gw) => gw.gatewayType === 'exclusive' && !isXorSumValid(gw))
|
||||
.map((gw) => gw.bpmnElementId)
|
||||
return { valid: invalidIds.length === 0, invalidIds }
|
||||
}, [gateways])
|
||||
|
||||
const canSimulate = !isRunning && xorValidation.valid
|
||||
|
||||
const handleElementClick = useCallback((
|
||||
elementId: string,
|
||||
elementName: string,
|
||||
category: BpmnElementCategory
|
||||
) => {
|
||||
if (category === 'other') return
|
||||
setSelectedElementId(elementId)
|
||||
setSelectedElementName(elementName)
|
||||
setSelectedCategory(category)
|
||||
selectActivity(category === 'activity' ? elementId : null)
|
||||
setActiveTab('elemento')
|
||||
if (!sidebarOpen) setSidebarOpen(true)
|
||||
}, [selectActivity, sidebarOpen])
|
||||
|
||||
function clearSelection() {
|
||||
setSelectedElementId(null)
|
||||
setSelectedElementName('')
|
||||
setSelectedCategory(null)
|
||||
selectActivity(null)
|
||||
}
|
||||
|
||||
async function handleSimulate() {
|
||||
if (!canSimulate) return
|
||||
const simResult = await simulate()
|
||||
if (simResult) navigate({ to: '/report/$processId', params: { processId } })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !currentProcess) {
|
||||
return null // el useEffect ya inició el redirect con toast
|
||||
}
|
||||
|
||||
// Label del tab "elemento" según lo seleccionado
|
||||
const elementTabLabel = selectedCategory === 'gateway' ? 'Gateway' : 'Actividad'
|
||||
const elementTabIcon = selectedCategory === 'gateway'
|
||||
? <GitMerge className="h-3 w-3 mr-1" />
|
||||
: <MousePointer2 className="h-3 w-3 mr-1" />
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
|
||||
|
||||
{/* ── Topbar ─────────────────────────────────────────────────── */}
|
||||
<header className="h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navigate({ to: '/' })}>
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Inicio</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
|
||||
{currentProcess.clientName && (
|
||||
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errores de simulación */}
|
||||
{simError && (
|
||||
<div className="flex items-center gap-1.5 text-destructive text-xs bg-destructive/10 px-2 py-1 rounded">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
{simError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advertencia de XOR inválido */}
|
||||
{!xorValidation.valid && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 text-amber-700 text-xs bg-amber-50 border border-amber-200 px-2 py-1 rounded cursor-help">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
{xorValidation.invalidIds.length} gateway{xorValidation.invalidIds.length > 1 ? 's' : ''} con probabilidades inválidas
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-60 text-xs">
|
||||
<p className="font-semibold mb-1">Gateways XOR que no suman 100%:</p>
|
||||
{xorValidation.invalidIds.map((id) => (
|
||||
<p key={id} className="font-mono">• {id}</p>
|
||||
))}
|
||||
<p className="mt-1 text-slate-300">Ajustá las probabilidades para habilitar la simulación</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Ver reporte si ya hay simulación */}
|
||||
{result && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5 text-primary border-primary/30"
|
||||
onClick={() => navigate({ to: '/report/$processId', params: { processId } })}
|
||||
>
|
||||
<BarChart3 className="h-3.5 w-3.5" />
|
||||
Ver reporte
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Simular */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={handleSimulate}
|
||||
disabled={!canSimulate}
|
||||
>
|
||||
{isRunning
|
||||
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Simulando…</>
|
||||
: <><Play className="h-3.5 w-3.5" />Simular</>
|
||||
}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!xorValidation.valid && (
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Corregí las probabilidades de los gateways XOR primero
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
>
|
||||
{sidebarOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{sidebarOpen ? 'Cerrar panel' : 'Abrir panel'}</TooltipContent>
|
||||
</Tooltip>
|
||||
</header>
|
||||
|
||||
{/* ── Main ───────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
|
||||
{/* Canvas BPMN */}
|
||||
<div className="flex-1 relative overflow-hidden bpmn-container">
|
||||
<BpmnCanvas
|
||||
xml={currentProcess.bpmnXml}
|
||||
onElementClick={handleElementClick}
|
||||
selectedElementId={selectedElementId}
|
||||
className="absolute inset-0"
|
||||
/>
|
||||
|
||||
{/* Chip de elemento seleccionado */}
|
||||
{selectedElementId && (
|
||||
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur-sm border border-slate-200 rounded-lg px-3 py-1.5 shadow-sm flex items-center gap-2 max-w-xs">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${selectedCategory === 'gateway' ? 'bg-amber-500' : 'bg-primary'}`} />
|
||||
<span className="text-xs font-medium text-slate-700 truncate">{selectedElementName || selectedElementId}</span>
|
||||
<button onClick={clearSelection} className="ml-1 text-slate-400 hover:text-slate-600 shrink-0">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint inicial */}
|
||||
{!selectedElementId && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white/80 backdrop-blur-sm border border-slate-200 rounded-full px-4 py-2 shadow-sm">
|
||||
<p className="text-xs text-slate-500">Hacé click en una tarea o gateway para configurarlo</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel lateral */}
|
||||
<div className={`
|
||||
shrink-0 bg-white border-l border-slate-200 flex flex-col overflow-hidden
|
||||
transition-all duration-300 ease-in-out
|
||||
${sidebarOpen ? 'w-72' : 'w-0'}
|
||||
`}>
|
||||
{sidebarOpen && (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||
<TabsList className="shrink-0 mx-3 mt-3 grid grid-cols-3 w-auto">
|
||||
<TabsTrigger value="elemento" className="text-xs flex items-center">
|
||||
{elementTabIcon}{elementTabLabel}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recursos" className="text-xs">Recursos</TabsTrigger>
|
||||
<TabsTrigger value="global" className="text-xs">Global</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="elemento" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
{selectedCategory === 'gateway'
|
||||
? <GatewayPanel selectedElementId={selectedElementId} />
|
||||
: <ActivityPanel selectedElementId={selectedElementId} />
|
||||
}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="recursos" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<ResourcesPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="global" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
|
||||
<GlobalSettingsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle flotante cuando sidebar cerrado */}
|
||||
{!sidebarOpen && (
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 bg-white border border-slate-200 border-r-0 rounded-l-lg p-1.5 shadow-md hover:bg-slate-50 z-20"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4 text-slate-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user