490 lines
20 KiB
TypeScript
490 lines
20 KiB
TypeScript
import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react'
|
||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||
import {
|
||
Loader2, BarChart3, Play, AlertTriangle, AlertCircle,
|
||
PanelRightClose, PanelRightOpen, ArrowLeft, GitMerge, MousePointer2,
|
||
Check, Pencil
|
||
} 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 { BpmnCanvas, type BpmnElementCategory } from './BpmnCanvas'
|
||
import { ActivityPanel } from './ActivityPanel'
|
||
import { GatewayPanel, isXorSumValid } from './GatewayPanel'
|
||
import { ResourcesPanel } from './ResourcesPanel'
|
||
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
|
||
import { WorkspaceSkeleton } from './WorkspaceSkeleton'
|
||
import { useProcessStore } from '@/store/process-store'
|
||
import { useSimulationStore } from '@/store/simulation-store'
|
||
import { useSimulate } from '../simulation/useSimulate'
|
||
import { useLibraryStore } from '@/store/library-store'
|
||
import { useAuth } from '@/auth/AuthContext'
|
||
import { AppHeader } from '@/components/AppHeader'
|
||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||
|
||
type SelectedCategory = 'activity' | 'gateway' | null
|
||
|
||
export function WorkspacePage() {
|
||
const { processId } = useParams({ from: '/workspace/$processId' })
|
||
const navigate = useNavigate()
|
||
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
|
||
const { isRunning, resultActual, error: simError, selectActivity, loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore()
|
||
const { simulate } = useSimulate()
|
||
const { user } = useAuth()
|
||
|
||
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')
|
||
|
||
// Estado para edición inline del header
|
||
const [editingField, setEditingField] = useState<'name' | 'client' | null>(null)
|
||
const [editValue, setEditValue] = useState('')
|
||
const [savedField, setSavedField] = useState<'name' | 'client' | null>(null)
|
||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
|
||
// Tags chip input
|
||
const [tagInput, setTagInput] = useState('')
|
||
const [showTagSuggestions, setShowTagSuggestions] = useState(false)
|
||
const getAllTags = useLibraryStore((state) => state.getAllTags)
|
||
|
||
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
|
||
|
||
// Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale).
|
||
// Depende de currentProcess?.id (no del objeto) a propósito: evita re-disparar el efecto
|
||
// en cada edición de campos del proceso, solo cuando cambia de proceso.
|
||
useEffect(() => {
|
||
if (currentProcess) loadLatestForProcess(currentProcess.id)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [currentProcess?.id, loadLatestForProcess])
|
||
|
||
// client_viewer no puede editar — redirigir al reporte
|
||
useEffect(() => {
|
||
if (user?.platformRole === 'client_viewer') {
|
||
void navigate({ to: '/report/$processId', params: { processId } })
|
||
}
|
||
}, [user, processId, navigate])
|
||
|
||
// orgName viene directamente del proceso cargado via JOIN en getById (sin fetch separado)
|
||
|
||
// 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
|
||
|
||
// Hay cambios sin simular cuando updatedAt supera al executedAt de la última simulación persistida
|
||
const isStale = Boolean(
|
||
currentProcess &&
|
||
currentProcess.updatedAt > (lastPersistedExecutedAt ?? 0)
|
||
)
|
||
|
||
// IDs de actividades marcadas como automatizables — alimenta los overlays del canvas.
|
||
// useDeferredValue evita re-renders en cascada mientras el usuario edita el panel.
|
||
const automatableElementIds = useMemo(
|
||
() => activities.filter((a) => a.automatable).map((a) => a.bpmnElementId),
|
||
[activities]
|
||
)
|
||
const deferredAutomatableIds = useDeferredValue(automatableElementIds)
|
||
|
||
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 saveField(field: 'name' | 'client') {
|
||
if (field === 'name') {
|
||
const trimmed = editValue.trim()
|
||
if (trimmed && trimmed !== currentProcess?.name) {
|
||
await updateProcess({ name: trimmed }, user!.id)
|
||
}
|
||
} else {
|
||
const trimmed = editValue.trim()
|
||
if (trimmed !== (currentProcess?.clientName ?? '')) {
|
||
await updateProcess({ clientName: trimmed }, user!.id)
|
||
}
|
||
}
|
||
setEditingField(null)
|
||
setSavedField(field)
|
||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
|
||
savedTimerRef.current = setTimeout(() => setSavedField(null), 1500)
|
||
}
|
||
|
||
function addTag(raw: string) {
|
||
const normalized = raw.trim().toLowerCase()
|
||
if (!normalized) return
|
||
const current = currentProcess?.tags ?? []
|
||
if (current.some((t) => t === normalized)) return
|
||
updateProcess({ tags: [...current, normalized] }, user!.id)
|
||
setTagInput('')
|
||
setShowTagSuggestions(false)
|
||
}
|
||
|
||
function removeTag(tag: string) {
|
||
const current = currentProcess?.tags ?? []
|
||
updateProcess({ tags: current.filter((t) => t !== tag) }, user!.id)
|
||
}
|
||
|
||
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||
if (e.key === 'Enter') { e.preventDefault(); if (tagInput.trim()) addTag(tagInput) }
|
||
if (e.key === 'Escape') { setTagInput(''); setShowTagSuggestions(false) }
|
||
}
|
||
|
||
async function handleSimulate() {
|
||
if (!canSimulate) return
|
||
const simResult = await simulate()
|
||
if (simResult) navigate({ to: '/report/$processId', params: { processId } })
|
||
}
|
||
|
||
|
||
|
||
// isLoading manda siempre, incluso si currentProcess todavía tiene el proceso
|
||
// anterior en el store (navegación directa entre dos workspaces distintos).
|
||
if (isLoading || user?.platformRole === 'client_viewer') {
|
||
return <WorkspaceSkeleton />
|
||
}
|
||
|
||
if (error || !currentProcess) {
|
||
return (
|
||
<div className="h-screen flex flex-col items-center justify-center gap-4">
|
||
<AlertCircle className="h-8 w-8 text-muted-foreground" />
|
||
<p className="text-sm text-muted-foreground max-w-sm text-center">
|
||
{error ?? 'No se pudo cargar el proceso. Revisá tu conexión.'}
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={() => loadProcess(processId)}>
|
||
Reintentar
|
||
</Button>
|
||
<Button variant="ghost" onClick={() => navigate({ to: '/' })}>
|
||
Volver a la biblioteca
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// client_viewer ya fue redirigido antes de este punto; solo verificamos client_editor aquí
|
||
const isClientRole = user?.platformRole === 'client_editor'
|
||
const backLabel = isClientRole
|
||
? 'Procesos'
|
||
: currentProcess.orgName ?? 'Biblioteca'
|
||
|
||
function handleBack() {
|
||
void navigate({
|
||
to: '/library',
|
||
search: { org: currentProcess?.orgId ?? undefined },
|
||
})
|
||
}
|
||
|
||
// 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">
|
||
<AppHeader />
|
||
|
||
{/* ── Topbar ─────────────────────────────────────────────────── */}
|
||
<header className="min-h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="gap-1 text-xs text-slate-500 h-8 px-2 shrink-0"
|
||
onClick={handleBack}
|
||
>
|
||
<ArrowLeft className="h-3.5 w-3.5" />
|
||
{backLabel}
|
||
</Button>
|
||
|
||
<Separator orientation="vertical" className="h-5" />
|
||
|
||
<div className="flex-1 min-w-0">
|
||
|
||
{/* Nombre del proceso — editable inline */}
|
||
{editingField === 'name' ? (
|
||
<input
|
||
autoFocus
|
||
className="text-sm font-semibold text-slate-800 bg-transparent border-b border-[#F59845] outline-none w-full"
|
||
value={editValue}
|
||
onChange={(e) => setEditValue(e.target.value)}
|
||
onBlur={() => saveField('name')}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); saveField('name') }
|
||
if (e.key === 'Escape') setEditingField(null)
|
||
}}
|
||
/>
|
||
) : (
|
||
<div
|
||
className="flex items-center gap-1 group cursor-text"
|
||
onClick={() => { setEditValue(currentProcess.name); setEditingField('name') }}
|
||
>
|
||
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
|
||
{savedField === 'name'
|
||
? <Check className="h-3.5 w-3.5 text-[#F59845] shrink-0" />
|
||
: <Pencil className="h-3 w-3 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||
}
|
||
</div>
|
||
)}
|
||
|
||
{/* Área — display de solo lectura (editable desde el panel de configuración) */}
|
||
{currentProcess.areaName && (
|
||
<p className="text-xs text-slate-400 truncate">{currentProcess.areaName}</p>
|
||
)}
|
||
|
||
{/* Tags — chips removibles + input inline */}
|
||
{(() => {
|
||
const processTags = currentProcess.tags ?? []
|
||
const suggestions = getAllTags()
|
||
.filter((t) => t.includes(tagInput.toLowerCase()) && !processTags.includes(t))
|
||
.slice(0, 6)
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||
{processTags.map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full bg-secondary border border-border text-secondary-foreground"
|
||
>
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
onClick={() => removeTag(tag)}
|
||
className="ml-0.5 text-muted-foreground hover:text-foreground leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
value={tagInput}
|
||
onChange={(e) => { setTagInput(e.target.value); setShowTagSuggestions(true) }}
|
||
onKeyDown={handleTagKeyDown}
|
||
onFocus={() => setShowTagSuggestions(true)}
|
||
onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}
|
||
placeholder="Agregar tag..."
|
||
className="text-xs bg-transparent border-b border-muted outline-none w-24 placeholder:text-muted-foreground/50"
|
||
/>
|
||
{showTagSuggestions && suggestions.length > 0 && (
|
||
<div className="absolute top-full left-0 mt-1 bg-background border border-border rounded-md shadow-sm z-50 min-w-[120px]">
|
||
{suggestions.map((s) => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
className="w-full text-left px-2 py-1 text-[11px] hover:bg-secondary transition-colors"
|
||
onMouseDown={(e) => { e.preventDefault(); addTag(s) }}
|
||
>
|
||
{s}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
</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 */}
|
||
{resultActual && (
|
||
<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>
|
||
)}
|
||
|
||
{/* Indicador stale */}
|
||
{isStale && (
|
||
<div className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium"
|
||
style={{ background: '#fff3e6', border: '1px solid #F59845', color: '#7a3e00' }}>
|
||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||
Hay cambios sin simular
|
||
</div>
|
||
)}
|
||
|
||
{/* 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}
|
||
automatableElementIds={deferredAutomatableIds}
|
||
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>
|
||
)
|
||
}
|
||
|
||
export function WorkspacePageWithBoundary() {
|
||
return (
|
||
<ErrorBoundary>
|
||
<WorkspacePage />
|
||
</ErrorBoundary>
|
||
)
|
||
}
|