Rollback - PDF Import funzt so semi
Dieser Commit ist enthalten in:
@ -16,6 +16,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"reactflow": "^11.10.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -8,6 +8,7 @@ import SkillManagement from './views/SkillManagement'
|
||||
import UserManagement from './views/UserManagement'
|
||||
import EmailSettings from './views/EmailSettings'
|
||||
import SyncSettings from './views/SyncSettings'
|
||||
import OrganizationEditor from './views/OrganizationEditor'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
function App() {
|
||||
@ -34,6 +35,7 @@ function App() {
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/organization" element={<OrganizationEditor />} />
|
||||
<Route path="/skills" element={<SkillManagement />} />
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
<Route path="/users/create-employee" element={<CreateEmployee />} />
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
SettingsIcon,
|
||||
MailIcon
|
||||
} from './icons'
|
||||
import { Building2 } from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
@ -14,6 +15,7 @@ interface LayoutProps {
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Organigramm', href: '/organization', icon: Building2 },
|
||||
{ name: 'Benutzerverwaltung', href: '/users', icon: UsersIcon },
|
||||
{ name: 'Skills verwalten', href: '/skills', icon: SettingsIcon },
|
||||
{ name: 'E-Mail-Einstellungen', href: '/email-settings', icon: MailIcon },
|
||||
|
||||
503
admin-panel/src/views/OrganizationEditor.tsx
Normale Datei
503
admin-panel/src/views/OrganizationEditor.tsx
Normale Datei
@ -0,0 +1,503 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren