Files
breakpilot-lehrer/website/app/admin/workflow/page.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
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>
2026-02-11 23:47:26 +01:00

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>
)
}