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