Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
626 lines
20 KiB
TypeScript
626 lines
20 KiB
TypeScript
'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 = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
|
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
|
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
|
xmlns:camunda="http://camunda.org/schema/1.0/bpmn"
|
|
id="Definitions_1"
|
|
targetNamespace="http://bpmn.io/schema/bpmn">
|
|
<bpmn:process id="Process_1" name="Neuer Prozess" isExecutable="true">
|
|
<bpmn:startEvent id="StartEvent_1" name="Start" />
|
|
</bpmn:process>
|
|
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
|
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1">
|
|
<bpmndi:BPMNShape id="StartEvent_1_di" bpmnElement="StartEvent_1">
|
|
<dc:Bounds x="180" y="160" width="36" height="36" />
|
|
<bpmndi:BPMNLabel>
|
|
<dc:Bounds x="186" y="203" width="24" height="14" />
|
|
</bpmndi:BPMNLabel>
|
|
</bpmndi:BPMNShape>
|
|
</bpmndi:BPMNPlane>
|
|
</bpmndi:BPMNDiagram>
|
|
</bpmn:definitions>`
|
|
|
|
export default function WorkflowPage() {
|
|
// Refs
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const modelerRef = useRef<any>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// State
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [camundaHealth, setCamundaHealth] = useState<CamundaHealth>({ connected: false })
|
|
const [processes, setProcesses] = useState<ProcessDefinition[]>([])
|
|
const [tasks, setTasks] = useState<Task[]>([])
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<AdminLayout title="BPMN Workflow Editor" description="Geschaeftsprozesse modellieren und automatisieren">
|
|
{/* Hidden file input */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".bpmn,.xml"
|
|
onChange={handleFileLoad}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex flex-wrap items-center gap-3 mb-4 p-3 bg-white rounded-xl border border-slate-200">
|
|
{/* File operations */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleNew}
|
|
className="px-3 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-sm font-medium flex items-center gap-2"
|
|
>
|
|
<span>+</span> Neu
|
|
</button>
|
|
<button
|
|
onClick={handleOpen}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm font-medium"
|
|
>
|
|
Oeffnen
|
|
</button>
|
|
</div>
|
|
|
|
<div className="w-px h-6 bg-slate-200" />
|
|
|
|
{/* Export */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleSaveXML}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm font-medium"
|
|
>
|
|
XML
|
|
</button>
|
|
<button
|
|
onClick={handleSaveSVG}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm font-medium"
|
|
>
|
|
SVG
|
|
</button>
|
|
</div>
|
|
|
|
<div className="w-px h-6 bg-slate-200" />
|
|
|
|
{/* Camunda */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleDeploy}
|
|
disabled={!camundaHealth.connected}
|
|
className="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium flex items-center gap-2"
|
|
>
|
|
Deployen
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
loadProcesses()
|
|
setShowProcessPanel(true)
|
|
}}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm font-medium"
|
|
>
|
|
Prozesse
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
loadTasks()
|
|
setShowTaskPanel(!showTaskPanel)
|
|
}}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm font-medium"
|
|
>
|
|
Tasks
|
|
</button>
|
|
</div>
|
|
|
|
<div className="w-px h-6 bg-slate-200" />
|
|
|
|
{/* Zoom */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleZoomIn}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm"
|
|
>
|
|
+
|
|
</button>
|
|
<button
|
|
onClick={handleZoomOut}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm"
|
|
>
|
|
-
|
|
</button>
|
|
<button
|
|
onClick={handleZoomFit}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 text-sm"
|
|
>
|
|
Fit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Canvas */}
|
|
<div className="relative bg-white rounded-xl border border-slate-200 overflow-hidden" style={{ height: 'calc(100vh - 340px)' }}>
|
|
{isLoading && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white z-10">
|
|
<div className="text-slate-500">Lade Editor...</div>
|
|
</div>
|
|
)}
|
|
<div ref={containerRef} className="w-full h-full" />
|
|
|
|
{/* Status Bar */}
|
|
<div className="absolute bottom-0 left-0 right-0 flex justify-between items-center px-4 py-2 bg-slate-50 border-t border-slate-200 text-xs text-slate-500">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${camundaHealth.connected ? 'bg-green-500' : 'bg-red-500'}`}
|
|
/>
|
|
<span>Camunda: {camundaHealth.connected ? 'Verbunden' : 'Nicht verbunden'}</span>
|
|
</div>
|
|
<div>Elemente: {elementCount}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Task Panel */}
|
|
{showTaskPanel && (
|
|
<div className="mt-4 bg-white rounded-xl border border-slate-200 p-4">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="font-semibold text-slate-900">Offene Tasks</h3>
|
|
<span className="px-2 py-1 bg-primary-100 text-primary-700 rounded-full text-xs font-medium">
|
|
{tasks.length}
|
|
</span>
|
|
</div>
|
|
{tasks.length === 0 ? (
|
|
<div className="text-center py-6 text-slate-500">Keine offenen Tasks</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{tasks.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="flex justify-between items-center p-3 bg-slate-50 rounded-lg"
|
|
>
|
|
<div>
|
|
<div className="font-medium text-slate-900">{task.name}</div>
|
|
<div className="text-xs text-slate-500">{task.processDefinitionId}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => handleCompleteTask(task.id)}
|
|
className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm hover:bg-primary-700"
|
|
>
|
|
Erledigen
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Process Panel (Slide-in) */}
|
|
{showProcessPanel && (
|
|
<div className="fixed inset-0 z-50 flex justify-end">
|
|
<div
|
|
className="absolute inset-0 bg-black/50"
|
|
onClick={() => setShowProcessPanel(false)}
|
|
/>
|
|
<div className="relative w-80 bg-white h-full shadow-xl overflow-y-auto">
|
|
<div className="sticky top-0 bg-white border-b border-slate-200 p-4 flex justify-between items-center">
|
|
<h3 className="font-semibold text-slate-900">Deployed Processes</h3>
|
|
<button
|
|
onClick={() => setShowProcessPanel(false)}
|
|
className="text-slate-400 hover:text-slate-600"
|
|
>
|
|
X
|
|
</button>
|
|
</div>
|
|
<div className="p-4">
|
|
{processes.length === 0 ? (
|
|
<div className="text-center py-8 text-slate-500">Keine Prozesse deployed</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{processes.map((process) => (
|
|
<div
|
|
key={process.id}
|
|
onClick={() => handleLoadProcess(process.id)}
|
|
className="p-3 border border-slate-200 rounded-lg hover:border-primary-300 cursor-pointer"
|
|
>
|
|
<div className="font-medium text-slate-900">{process.name || process.key}</div>
|
|
<div className="text-xs text-slate-500">
|
|
Version {process.version} | {process.key}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Toast Notification */}
|
|
{toast && (
|
|
<div
|
|
className={`fixed bottom-6 right-6 px-4 py-3 rounded-lg shadow-lg z-50 ${
|
|
toast.type === 'success'
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-red-600 text-white'
|
|
}`}
|
|
>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* System Info Section */}
|
|
<div className="mt-8 border-t border-slate-200 pt-8">
|
|
<SystemInfoSection config={SYSTEM_INFO_CONFIGS.workflow || {
|
|
title: 'BPMN Workflow Engine',
|
|
description: 'Camunda 7 basierte Prozessautomatisierung',
|
|
version: '7.21.0',
|
|
architecture: {
|
|
layers: [
|
|
{
|
|
name: 'Frontend',
|
|
color: '#3b82f6',
|
|
components: ['bpmn-js Editor', 'React Components']
|
|
},
|
|
{
|
|
name: 'Backend',
|
|
color: '#8b5cf6',
|
|
components: ['FastAPI Proxy', 'REST API']
|
|
},
|
|
{
|
|
name: 'Engine',
|
|
color: '#10b981',
|
|
components: ['Camunda 7', 'Process Executor']
|
|
},
|
|
{
|
|
name: 'Datenbank',
|
|
color: '#f59e0b',
|
|
components: ['PostgreSQL', 'Camunda Schema']
|
|
}
|
|
]
|
|
},
|
|
features: [
|
|
{ name: 'BPMN 2.0 Modellierung', status: 'active' as const, description: 'Visueller Editor mit bpmn-js' },
|
|
{ name: 'Process Deployment', status: 'active' as const, description: 'Deploy zu Camunda Engine' },
|
|
{ name: 'Task Management', status: 'active' as const, description: 'User Tasks bearbeiten' },
|
|
{ name: 'Process History', status: 'planned' as const, description: 'Historische Prozessdaten' }
|
|
],
|
|
roadmap: [
|
|
{
|
|
title: 'Phase 1: Editor Integration',
|
|
priority: 'high' as const,
|
|
items: ['bpmn-js Editor', 'Camunda Deployment', 'Task Inbox']
|
|
},
|
|
{
|
|
title: 'Phase 2: Consent Workflow',
|
|
priority: 'high' as const,
|
|
items: ['Document Workflow', 'DSB Approval', 'Auto-Publishing']
|
|
},
|
|
{
|
|
title: 'Phase 3: Weitere Prozesse',
|
|
priority: 'medium' as const,
|
|
items: ['GDPR DSR Workflow', 'Exam Correction', 'Onboarding']
|
|
}
|
|
],
|
|
technicalDetails: [
|
|
{ key: 'Engine', value: 'Camunda 7.21.0' },
|
|
{ key: 'Editor', value: 'bpmn-js 17.11.1' },
|
|
{ key: 'Lizenz', value: 'Apache 2.0' },
|
|
{ key: 'Port', value: '8089' }
|
|
],
|
|
privacyNotes: [
|
|
'Prozessdaten werden lokal in PostgreSQL gespeichert',
|
|
'Keine Daten werden an externe Dienste gesendet',
|
|
'Apache 2.0 Lizenz - kommerziell nutzbar'
|
|
]
|
|
}} />
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|