Files
SkillMate/admin-panel/src/views/OrganizationEditor.tsx
2025-09-23 22:40:37 +02:00

504 Zeilen
16 KiB
TypeScript

import { useCallback, useEffect, useState, useRef } from 'react'
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
Connection,
MarkerType,
NodeTypes,
Panel
} from 'reactflow'
import 'reactflow/dist/style.css'
import { api } from '../services/api'
import { OrganizationalUnit, OrganizationalUnitType } from '@skillmate/shared'
import { Upload } from 'lucide-react'
// Custom Node Component
const OrganizationNode = ({ data }: { data: any }) => {
const getTypeIcon = (type: OrganizationalUnitType) => {
const icons = {
direktion: '🏛️',
abteilung: '🏢',
dezernat: '📁',
sachgebiet: '📋',
teildezernat: '🔧',
fuehrungsstelle: '⭐',
stabsstelle: '🎯',
sondereinheit: '🛡️'
}
return icons[type] || '📄'
}
const getGradient = (level: number) => {
const gradients = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
'linear-gradient(135deg, #30cfd0 0%, #330867 100%)'
]
return gradients[level % gradients.length]
}
return (
<div className="bg-white rounded-lg shadow-lg border-2 border-gray-200 min-w-[250px]">
<div
className="p-2 text-white rounded-t-md"
style={{ background: getGradient(data.level || 0) }}
>
<div className="flex items-center justify-between">
<span className="text-lg">{getTypeIcon(data.type)}</span>
<span className="text-xs opacity-90">{data.code}</span>
</div>
</div>
<div className="p-3">
<h4 className="font-semibold text-gray-800 text-sm">{data.name}</h4>
{data.employeeCount !== undefined && (
<p className="text-xs text-gray-600 mt-1">
👥 {data.employeeCount} Mitarbeiter
</p>
)}
{data.hasFuehrungsstelle && (
<span className="inline-block px-2 py-1 mt-2 text-xs bg-yellow-100 text-yellow-800 rounded">
FüSt
</span>
)}
</div>
</div>
)
}
const nodeTypes: NodeTypes = {
organization: OrganizationNode
}
export default function OrganizationEditor() {
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [loading, setLoading] = useState(true)
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [showImportDialog, setShowImportDialog] = useState(false)
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [formData, setFormData] = useState({
name: '',
code: '',
type: 'dezernat' as OrganizationalUnitType,
level: 2,
parentId: '',
description: ''
})
// Load organizational units
useEffect(() => {
loadOrganization()
}, [])
const loadOrganization = async () => {
try {
setLoading(true)
const response = await api.get('/organization/hierarchy')
if (response.data.success) {
const units = response.data.data
const { nodes: flowNodes, edges: flowEdges } = convertToFlowElements(units)
setNodes(flowNodes)
setEdges(flowEdges)
}
} catch (error) {
console.error('Failed to load organization:', error)
} finally {
setLoading(false)
}
}
const convertToFlowElements = (units: any[], parentPosition = { x: 0, y: 0 }, level = 0): { nodes: Node[], edges: Edge[] } => {
const nodes: Node[] = []
const edges: Edge[] = []
const levelHeight = 150
const nodeWidth = 300
units.forEach((unit, index) => {
const nodeId = unit.id
const xPos = parentPosition.x + index * nodeWidth
const yPos = level * levelHeight
// Use persisted positions if available, otherwise compute defaults
const persistedX = (typeof unit.positionX === 'number'
? unit.positionX
: (unit.positionX != null && !isNaN(Number(unit.positionX)) ? Number(unit.positionX) : undefined))
const persistedY = (typeof unit.positionY === 'number'
? unit.positionY
: (unit.positionY != null && !isNaN(Number(unit.positionY)) ? Number(unit.positionY) : undefined))
nodes.push({
id: nodeId,
type: 'organization',
position: { x: persistedX ?? xPos, y: persistedY ?? yPos },
data: {
...unit,
level
}
})
// Create edge to parent if exists
if (unit.parentId) {
edges.push({
id: `e-${unit.parentId}-${nodeId}`,
source: unit.parentId,
target: nodeId,
type: 'smoothstep',
animated: false,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 20,
height: 20,
color: '#94a3b8'
},
style: {
strokeWidth: 2,
stroke: '#94a3b8'
}
})
}
// Process children recursively
if (unit.children && unit.children.length > 0) {
const childElements = convertToFlowElements(
unit.children,
{ x: xPos, y: yPos },
level + 1
)
nodes.push(...childElements.nodes)
edges.push(...childElements.edges)
}
})
return { nodes, edges }
}
const onConnect = useCallback(
(params: Connection) => {
setEdges((eds) => addEdge({
...params,
type: 'smoothstep',
animated: false,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 20,
height: 20,
color: '#94a3b8'
}
}, eds))
},
[setEdges]
)
const onNodeClick = useCallback((_: any, node: Node) => {
setSelectedNode(node)
}, [])
const onNodeDragStop = useCallback(async (_: any, node: Node) => {
try {
await api.put(`/organization/units/${node.id}`, {
positionX: node.position.x,
positionY: node.position.y
})
} catch (error) {
console.error('Failed to update position:', error)
}
}, [])
const handleAddUnit = async () => {
try {
// Build payload and attach selected node as parent if present
const payload: any = {
name: formData.name,
code: formData.code || undefined,
type: formData.type,
level: selectedNode ? ((selectedNode.data?.level ?? 0) + 1) : formData.level,
description: formData.description || undefined,
}
if (selectedNode?.id) payload.parentId = selectedNode.id
const response = await api.post('/organization/units', payload)
if (response.data.success) {
await loadOrganization()
setShowAddDialog(false)
setFormData({
name: '',
code: '',
type: 'dezernat',
level: 2,
parentId: '',
description: ''
})
}
} catch (error) {
console.error('Failed to add unit:', error)
}
}
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
if (file.type !== 'application/pdf') {
alert('Bitte laden Sie nur PDF-Dateien hoch.')
return
}
const formData = new FormData()
formData.append('pdf', file)
formData.append('clearExisting', 'false') // Don't clear existing by default
setUploadProgress('PDF wird hochgeladen...')
try {
const response = await api.post('/organization/import-pdf', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data.success) {
setUploadProgress(`Erfolgreich! ${response.data.data.unitsImported} Einheiten importiert.`)
await loadOrganization()
setTimeout(() => {
setShowImportDialog(false)
setUploadProgress(null)
}, 2000)
}
} catch (error: any) {
console.error('PDF import failed:', error)
setUploadProgress(`Fehler: ${error.response?.data?.error?.message || 'Upload fehlgeschlagen'}`)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-lg">Lade Organigramm...</div>
</div>
)
}
return (
<div className="h-screen w-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypes}
fitView
attributionPosition="bottom-left"
>
<Controls />
<MiniMap
nodeColor={(node) => {
const gradients = ['#667eea', '#f093fb', '#4facfe', '#43e97b', '#fa709a', '#30cfd0']
return gradients[node.data?.level % gradients.length] || '#94a3b8'
}}
/>
<Background variant="dots" gap={12} size={1} />
<Panel position="top-left" className="bg-white p-4 rounded-lg shadow-lg">
<h2 className="text-xl font-bold mb-4">LKA NRW Organigramm</h2>
<div className="space-y-2">
<button
onClick={() => setShowAddDialog(true)}
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
+ Einheit hinzufügen
</button>
<button
onClick={() => setShowImportDialog(true)}
className="w-full px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center justify-center gap-2"
>
<Upload size={16} />
PDF importieren
</button>
</div>
</Panel>
<Panel position="top-right" className="bg-white p-4 rounded-lg shadow-lg max-w-sm">
{selectedNode ? (
<>
<h3 className="font-bold mb-2">{selectedNode.data.name}</h3>
{selectedNode.data.code && (
<p className="text-sm text-gray-600">Code: {selectedNode.data.code}</p>
)}
<p className="text-sm text-gray-600">Typ: {selectedNode.data.type}</p>
<p className="text-sm text-gray-600">Ebene: {selectedNode.data.level}</p>
{selectedNode.data.description && (
<p className="text-sm mt-2">{selectedNode.data.description}</p>
)}
</>
) : (
<p className="text-gray-500">Klicken Sie auf eine Einheit für Details</p>
)}
</Panel>
</ReactFlow>
{/* Import PDF Dialog */}
{showImportDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">PDF Organigramm importieren</h2>
<div className="space-y-4">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<p className="text-sm text-gray-600 mb-4">
Laden Sie ein PDF-Dokument mit der Organisationsstruktur hoch.
Das System extrahiert automatisch die Hierarchie.
</p>
<input
ref={fileInputRef}
type="file"
accept=".pdf"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
disabled={!!uploadProgress}
>
PDF auswählen
</button>
</div>
{uploadProgress && (
<div className="p-3 bg-blue-50 text-blue-700 rounded">
{uploadProgress}
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => {
setShowImportDialog(false)
setUploadProgress(null)
}}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Schließen
</button>
</div>
</div>
</div>
)}
{/* Add Unit Dialog */}
{showAddDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Neue Einheit hinzufügen</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border rounded"
required
/>
</div>
{selectedNode && (
<div className="text-xs text-gray-600">
Wird unterhalb von: <span className="font-semibold">{selectedNode.data?.name}</span> eingefügt
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">Code *</label>
<input
type="text"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
className="w-full px-3 py-2 border rounded"
placeholder="z.B. ZA 1"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Typ *</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as OrganizationalUnitType })}
className="w-full px-3 py-2 border rounded"
>
<option value="direktion">Direktion</option>
<option value="abteilung">Abteilung</option>
<option value="dezernat">Dezernat</option>
<option value="sachgebiet">Sachgebiet</option>
<option value="teildezernat">Teildezernat</option>
<option value="stabsstelle">Stabsstelle</option>
<option value="sondereinheit">Sondereinheit</option>
<option value="fuehrungsstelle">Führungsstelle</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Ebene *</label>
<input
type="number"
value={formData.level}
onChange={(e) => setFormData({ ...formData, level: parseInt(e.target.value) })}
className="w-full px-3 py-2 border rounded"
min="0"
max="10"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border rounded"
rows={3}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowAddDialog(false)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Abbrechen
</button>
<button
onClick={handleAddUnit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Hinzufügen
</button>
</div>
</div>
</div>
)}
</div>
)
}