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

164 lines
6.4 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, useCallback } 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'
}
// SVG inline del ícono Bot de Lucide (12×12 px, color ámbar #f59e0b).
// Copiado de lucide.dev/icons/bot — robot con antena, cuerpo rectangular y ojos.
const BOT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8V4H8"/>
<rect width="16" height="12" x="4" y="8" rx="2"/>
<path d="M2 14h2"/>
<path d="M20 14h2"/>
<path d="M15 13v2"/>
<path d="M9 13v2"/>
</svg>`
// HTML del badge de automatización.
// Fondo #fffbeb (amber-50): identidad cromática con el ícono Bot ámbar,
// contrasta con el fondo blanco de los nodos BPMN.
function makeBadgeHtml(): string {
return `<div title="Actividad marcada como automatizable" style="
width:20px;height:20px;
border-radius:50%;
background:#fffbeb;
border:1px solid #e2e8f0;
box-shadow:0 1px 3px rgba(0,0,0,0.12);
display:flex;align-items:center;justify-content:center;
cursor:default;
pointer-events:none;
">${BOT_SVG}</div>`
}
// Identificador del tipo de overlay para poder remover solo los nuestros.
const OVERLAY_TYPE = 'automation-indicator'
interface BpmnCanvasProps {
xml: string
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
selectedElementId?: string | null
automatableElementIds?: string[] // IDs de actividades marcadas como automatizables
className?: string
}
export function BpmnCanvas({
xml,
onElementClick,
selectedElementId,
automatableElementIds = [],
className,
}: BpmnCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
const isImportedRef = useRef(false)
// ─── Inicializar viewer ────────────────────────────────────────────────────
useEffect(() => {
if (!containerRef.current) return
const viewer = new BpmnViewer({ container: containerRef.current })
viewerRef.current = viewer
return () => {
isImportedRef.current = false
viewer.destroy()
viewerRef.current = null
}
}, [])
// ─── Sincronizar overlays de automatización ───────────────────────────────
const syncAutomationOverlays = useCallback(() => {
const viewer = viewerRef.current
if (!viewer || !isImportedRef.current) return
const overlays = viewer.get('overlays') as any
const reg = viewer.get('elementRegistry') as any
// Remover overlays anteriores del tipo propio antes de re-agregar
overlays.remove({ type: OVERLAY_TYPE })
for (const elementId of automatableElementIds) {
try {
// Leer el ancho real del elemento para posicionar el badge en la
// esquina superior derecha con overlap: left = width - 12 pone el
// borde derecho del badge 8px fuera del borde derecho del nodo.
// (right: -8 en la API tiene semántica distinta: left = -right + width
// con -8 daría left = 8 + width, enteramente fuera del nodo.)
const el = reg?.get(elementId)
const width = el?.width ?? 100
overlays.add(elementId, OVERLAY_TYPE, {
position: { top: -8, left: width - 12 },
html: makeBadgeHtml(),
})
} catch {
// El elemento puede no existir en el diagrama actual — ignorar
}
}
}, [automatableElementIds])
// ─── Importar XML ──────────────────────────────────────────────────────────
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !xml) return
isImportedRef.current = false
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')
isImportedRef.current = true
syncAutomationOverlays()
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
}, [xml, syncAutomationOverlays])
// ─── Re-sincronizar overlays cuando cambia la lista de automatizables ─────
useEffect(() => {
syncAutomationOverlays()
}, [syncAutomationOverlays])
// ─── Click handler ─────────────────────────────────────────────────────────
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])
// ─── Highlight del elemento seleccionado ──────────────────────────────────
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%' }} />
}