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:
2026-05-14 01:58:40 -03:00
commit bed161c92a
97 changed files with 19638 additions and 0 deletions

View 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>
)
}