Files
inq-roi-simulador-web/src/features/workspace/WorkspacePage.tsx

490 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}