'use client' /** * BPMN Workflow Editor * * Admin interface for: * - Creating and editing BPMN 2.0 process diagrams * - Deploying processes to Camunda 7 * - Managing deployed process definitions * - Viewing and completing pending tasks */ import { useState, useEffect, useRef, useCallback } from 'react' import AdminLayout from '@/components/admin/AdminLayout' import SystemInfoSection, { SYSTEM_INFO_CONFIGS } from '@/components/admin/SystemInfoSection' // Types interface ProcessDefinition { id: string key: string name: string version: number deploymentId: string } interface Task { id: string name: string processDefinitionId: string processInstanceId: string assignee?: string created: string } interface CamundaHealth { connected: boolean engines?: Array<{ name: string }> error?: string } // Default empty BPMN diagram const EMPTY_BPMN = ` ` export default function WorkflowPage() { // Refs const containerRef = useRef(null) const modelerRef = useRef(null) const fileInputRef = useRef(null) // State const [isLoading, setIsLoading] = useState(true) const [camundaHealth, setCamundaHealth] = useState({ connected: false }) const [processes, setProcesses] = useState([]) const [tasks, setTasks] = useState([]) const [showProcessPanel, setShowProcessPanel] = useState(false) const [showTaskPanel, setShowTaskPanel] = useState(false) const [elementCount, setElementCount] = useState(0) const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null) // Show toast notification const showToast = useCallback((message: string, type: 'success' | 'error') => { setToast({ message, type }) setTimeout(() => setToast(null), 3000) }, []) // Check Camunda health const checkCamundaHealth = useCallback(async () => { try { const response = await fetch('/api/bpmn/health') const data = await response.json() setCamundaHealth(data) } catch { setCamundaHealth({ connected: false, error: 'Connection failed' }) } }, []) // Initialize BPMN modeler useEffect(() => { let mounted = true const initModeler = async () => { if (!containerRef.current) return try { // Dynamic import of bpmn-js (client-side only) const BpmnModeler = (await import('bpmn-js/lib/Modeler')).default if (!mounted) return const modeler = new BpmnModeler({ container: containerRef.current, keyboard: { bindTo: document } }) modelerRef.current = modeler // Load empty diagram await modeler.importXML(EMPTY_BPMN) // Fit viewport const canvas = modeler.get('canvas') as any canvas.zoom('fit-viewport') // Update element count on changes modeler.on('elements.changed', () => { if (modelerRef.current) { const elementRegistry = modelerRef.current.get('elementRegistry') as any setElementCount(elementRegistry.getAll().length) } }) // Initial element count const elementRegistry = modeler.get('elementRegistry') as any setElementCount(elementRegistry.getAll().length) setIsLoading(false) } catch (err) { console.error('Error initializing BPMN modeler:', err) showToast('Fehler beim Initialisieren des Editors', 'error') setIsLoading(false) } } initModeler() checkCamundaHealth() return () => { mounted = false if (modelerRef.current) { modelerRef.current.destroy() } } }, [checkCamundaHealth, showToast]) // Load deployed processes const loadProcesses = async () => { try { const response = await fetch('/api/bpmn/process-definition') if (response.ok) { const data = await response.json() setProcesses(data) } } catch (err) { console.error('Error loading processes:', err) } } // Load pending tasks const loadTasks = async () => { try { const response = await fetch('/api/bpmn/tasks/pending') if (response.ok) { const data = await response.json() setTasks(data) } } catch (err) { console.error('Error loading tasks:', err) } } // Create new diagram const handleNew = async () => { if (!modelerRef.current) return try { await modelerRef.current.importXML(EMPTY_BPMN) ;(modelerRef.current.get('canvas') as any).zoom('fit-viewport') showToast('Neues Diagramm erstellt', 'success') } catch (err) { showToast('Fehler beim Erstellen', 'error') } } // Open file const handleOpen = () => { fileInputRef.current?.click() } // Load file const handleFileLoad = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file || !modelerRef.current) return try { const xml = await file.text() await modelerRef.current.importXML(xml) ;(modelerRef.current.get('canvas') as any).zoom('fit-viewport') showToast(`Datei geladen: ${file.name}`, 'success') } catch (err) { showToast('Fehler beim Laden der Datei', 'error') } // Reset input event.target.value = '' } // Save as XML const handleSaveXML = async () => { if (!modelerRef.current) return try { const { xml } = await modelerRef.current.saveXML({ format: true }) downloadFile(xml, 'process.bpmn', 'application/xml') showToast('XML exportiert', 'success') } catch (err) { showToast('Fehler beim Speichern', 'error') } } // Save as SVG const handleSaveSVG = async () => { if (!modelerRef.current) return try { const { svg } = await modelerRef.current.saveSVG() downloadFile(svg, 'process.svg', 'image/svg+xml') showToast('SVG exportiert', 'success') } catch (err) { showToast('Fehler beim Speichern', 'error') } } // Deploy to Camunda const handleDeploy = async () => { if (!modelerRef.current) return if (!camundaHealth.connected) { showToast('Camunda nicht verbunden', 'error') return } try { const { xml } = await modelerRef.current.saveXML({ format: true }) const formData = new FormData() formData.append('deployment-name', `BreakPilot-Process-${Date.now()}`) formData.append('data', new Blob([xml], { type: 'application/octet-stream' }), 'process.bpmn') const response = await fetch('/api/bpmn/deployment/create', { method: 'POST', body: formData }) if (response.ok) { const result = await response.json() showToast(`Deployment erfolgreich: ${result.name}`, 'success') loadProcesses() } else { throw new Error('Deployment failed') } } catch (err) { showToast('Deployment fehlgeschlagen', 'error') } } // Load process definition XML const handleLoadProcess = async (definitionId: string) => { try { const response = await fetch(`/api/bpmn/process-definition/${definitionId}/xml`) const data = await response.json() if (data.bpmn20Xml && modelerRef.current) { await modelerRef.current.importXML(data.bpmn20Xml) ;(modelerRef.current.get('canvas') as any).zoom('fit-viewport') setShowProcessPanel(false) showToast('Prozess geladen', 'success') } } catch (err) { showToast('Fehler beim Laden des Prozesses', 'error') } } // Complete task const handleCompleteTask = async (taskId: string) => { try { const response = await fetch(`/api/bpmn/task/${taskId}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) if (response.ok) { showToast('Task abgeschlossen', 'success') loadTasks() } else { throw new Error('Failed to complete task') } } catch (err) { showToast('Fehler beim Abschliessen', 'error') } } // Zoom controls const handleZoomIn = () => { if (!modelerRef.current) return const canvas = modelerRef.current.get('canvas') as any canvas.zoom(canvas.zoom() * 1.2) } const handleZoomOut = () => { if (!modelerRef.current) return const canvas = modelerRef.current.get('canvas') as any canvas.zoom(canvas.zoom() / 1.2) } const handleZoomFit = () => { if (!modelerRef.current) return ;(modelerRef.current.get('canvas') as any).zoom('fit-viewport') } // Download helper const downloadFile = (content: string, filename: string, contentType: string) => { const blob = new Blob([content], { type: contentType }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename a.click() URL.revokeObjectURL(url) } return ( {/* Hidden file input */} {/* Toolbar */} {/* File operations */} + Neu Oeffnen {/* Export */} XML SVG {/* Camunda */} Deployen { loadProcesses() setShowProcessPanel(true) }} className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm font-medium" > Prozesse { loadTasks() setShowTaskPanel(!showTaskPanel) }} className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm font-medium" > Tasks {/* Zoom */} + - Fit {/* Main Canvas */} {isLoading && ( Lade Editor... )} {/* Status Bar */} Camunda: {camundaHealth.connected ? 'Verbunden' : 'Nicht verbunden'} Elemente: {elementCount} {/* Task Panel */} {showTaskPanel && ( Offene Tasks {tasks.length} {tasks.length === 0 ? ( Keine offenen Tasks ) : ( {tasks.map((task) => ( {task.name} {task.processDefinitionId} handleCompleteTask(task.id)} className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm hover:bg-primary-700" > Erledigen ))} )} )} {/* Process Panel (Slide-in) */} {showProcessPanel && ( setShowProcessPanel(false)} /> Deployed Processes setShowProcessPanel(false)} className="text-slate-400 hover:text-slate-600" > X {processes.length === 0 ? ( Keine Prozesse deployed ) : ( {processes.map((process) => ( handleLoadProcess(process.id)} className="p-3 border border-slate-200 rounded-lg hover:border-primary-300 cursor-pointer" > {process.name || process.key} Version {process.version} | {process.key} ))} )} )} {/* Toast Notification */} {toast && ( {toast.message} )} {/* System Info Section */} ) }