Roll Back Punkt - Ansicht klappt so semi
Dieser Commit ist enthalten in:
@ -87,6 +87,15 @@ export default function OrganizationEditor() {
|
|||||||
const [showImportDialog, setShowImportDialog] = useState(false)
|
const [showImportDialog, setShowImportDialog] = useState(false)
|
||||||
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
|
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [clearExisting, setClearExisting] = useState(false)
|
||||||
|
const [issues, setIssues] = useState<{ orphans: Set<string> }>({ orphans: new Set() })
|
||||||
|
const [showValidation, setShowValidation] = useState(false)
|
||||||
|
const [reparentMode, setReparentMode] = useState<{ source: Node | null }>({ source: null })
|
||||||
|
const [preview, setPreview] = useState<any | null>(null)
|
||||||
|
const [pendingOverrides, setPendingOverrides] = useState<Record<string, { parentCode?: string; type?: string; name?: string }>>({})
|
||||||
|
const [rememberRules, setRememberRules] = useState(true)
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
const [parentPicker, setParentPicker] = useState<{ open: boolean; target?: string; suggestions: any[] }>({ open: false, target: undefined, suggestions: [] })
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
code: '',
|
code: '',
|
||||||
@ -111,6 +120,7 @@ export default function OrganizationEditor() {
|
|||||||
const { nodes: flowNodes, edges: flowEdges } = convertToFlowElements(units)
|
const { nodes: flowNodes, edges: flowEdges } = convertToFlowElements(units)
|
||||||
setNodes(flowNodes)
|
setNodes(flowNodes)
|
||||||
setEdges(flowEdges)
|
setEdges(flowEdges)
|
||||||
|
computeIssues(flowNodes)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load organization:', error)
|
console.error('Failed to load organization:', error)
|
||||||
@ -202,7 +212,13 @@ export default function OrganizationEditor() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onNodeClick = useCallback((_: any, node: Node) => {
|
const onNodeClick = useCallback((_: any, node: Node) => {
|
||||||
setSelectedNode(node)
|
if (reparentMode.source && reparentMode.source.id !== node.id) {
|
||||||
|
// Reparent action: set node as new parent
|
||||||
|
handleReparent(reparentMode.source, node)
|
||||||
|
setReparentMode({ source: null })
|
||||||
|
} else {
|
||||||
|
setSelectedNode(node)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onNodeDragStop = useCallback(async (_: any, node: Node) => {
|
const onNodeDragStop = useCallback(async (_: any, node: Node) => {
|
||||||
@ -249,6 +265,7 @@ export default function OrganizationEditor() {
|
|||||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
setSelectedFile(file)
|
||||||
|
|
||||||
if (file.type !== 'application/pdf') {
|
if (file.type !== 'application/pdf') {
|
||||||
alert('Bitte laden Sie nur PDF-Dateien hoch.')
|
alert('Bitte laden Sie nur PDF-Dateien hoch.')
|
||||||
@ -257,24 +274,21 @@ export default function OrganizationEditor() {
|
|||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('pdf', file)
|
formData.append('pdf', file)
|
||||||
formData.append('clearExisting', 'false') // Don't clear existing by default
|
formData.append('clearExisting', String(clearExisting))
|
||||||
|
|
||||||
setUploadProgress('PDF wird hochgeladen...')
|
setUploadProgress('PDF wird analysiert (Vorschau)...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/organization/import-pdf', formData, {
|
const response = await api.post('/organization/import-pdf', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
}
|
},
|
||||||
|
params: { previewOnly: 'true' }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setUploadProgress(`Erfolgreich! ${response.data.data.unitsImported} Einheiten importiert.`)
|
setPreview(response.data.data)
|
||||||
await loadOrganization()
|
setUploadProgress(null)
|
||||||
setTimeout(() => {
|
|
||||||
setShowImportDialog(false)
|
|
||||||
setUploadProgress(null)
|
|
||||||
}, 2000)
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('PDF import failed:', error)
|
console.error('PDF import failed:', error)
|
||||||
@ -282,6 +296,237 @@ export default function OrganizationEditor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyImport = async () => {
|
||||||
|
try {
|
||||||
|
setUploadProgress('Import wird angewendet...')
|
||||||
|
const overrides = Object.entries(pendingOverrides).map(([code, v]) => ({ code, ...v }))
|
||||||
|
const fd = new FormData()
|
||||||
|
if (selectedFile) fd.append('pdf', selectedFile)
|
||||||
|
fd.append('clearExisting', String(clearExisting))
|
||||||
|
fd.append('overrides', JSON.stringify(overrides))
|
||||||
|
fd.append('previewOnly', 'false')
|
||||||
|
fd.append('rememberRules', String(rememberRules))
|
||||||
|
const response = await api.post('/organization/import-pdf', fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
if (response.data.success) {
|
||||||
|
setUploadProgress('Erfolgreich importiert.')
|
||||||
|
await loadOrganization()
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowImportDialog(false)
|
||||||
|
setUploadProgress(null)
|
||||||
|
setPreview(null)
|
||||||
|
setPendingOverrides({})
|
||||||
|
}, 1200)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Import apply failed:', error)
|
||||||
|
setUploadProgress(`Fehler: ${error.response?.data?.error?.message || 'Import fehlgeschlagen'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openParentPicker = async (targetCode: string) => {
|
||||||
|
try {
|
||||||
|
// Build suggestions from preview (preferred) + existing units
|
||||||
|
const suggestions: any[] = []
|
||||||
|
if (preview?.diff) {
|
||||||
|
const push = (u: any) => { if (u && (u.type === 'dezernat' || u.type === 'abteilung' || u.type === 'direktion')) suggestions.push({ code: u.code, name: u.name, type: u.type }) }
|
||||||
|
(preview.diff.create || []).forEach(push)
|
||||||
|
;(preview.diff.update || []).forEach((d: any) => { push(d.incoming); push(d.existing) })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.get('/organization/units')
|
||||||
|
if (res.data.success) {
|
||||||
|
res.data.data.forEach((u: any) => {
|
||||||
|
if (u && (u.type === 'dezernat' || u.type === 'abteilung' || u.type === 'direktion')) suggestions.push({ code: u.code, name: u.name, type: u.type })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// de-duplicate by code
|
||||||
|
const uniq: Record<string, any> = {}
|
||||||
|
suggestions.forEach(s => { uniq[s.code] = uniq[s.code] || s })
|
||||||
|
const list = Object.values(uniq)
|
||||||
|
setParentPicker({ open: true, target: targetCode, suggestions: list as any[] })
|
||||||
|
} catch (e) {
|
||||||
|
setParentPicker({ open: true, target: targetCode, suggestions: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute simple validation issues (orphans)
|
||||||
|
const computeIssues = (flowNodes: Node[]) => {
|
||||||
|
const ids = new Set(flowNodes.map(n => n.id))
|
||||||
|
const orphanIds = new Set<string>()
|
||||||
|
flowNodes.forEach(n => {
|
||||||
|
const parentId = (n.data?.parentId as string | undefined)
|
||||||
|
if (parentId && !ids.has(parentId)) {
|
||||||
|
orphanIds.add(n.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setIssues({ orphans: orphanIds })
|
||||||
|
// Apply visual styles
|
||||||
|
setNodes(ns => ns.map(n => ({
|
||||||
|
...n,
|
||||||
|
style: orphanIds.has(n.id) ? { ...(n.style||{}), border: '2px solid #ef4444' } : { ...(n.style||{}), border: '2px solid #e5e7eb' }
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto layout: Swimlanes by Abteilung (columns), vertical stacking within lanes
|
||||||
|
const handleAutoLayout = () => {
|
||||||
|
const laneWidth = 420
|
||||||
|
const xMargin = 40
|
||||||
|
const yStart = 80
|
||||||
|
const yGap = 110
|
||||||
|
|
||||||
|
const newNodes: Node[] = nodes.map(n => ({ ...n, position: { ...n.position } }))
|
||||||
|
const byId: Record<string, Node> = {}
|
||||||
|
newNodes.forEach(n => { byId[n.id] = n })
|
||||||
|
|
||||||
|
const getLaneKey = (n: Node): string => {
|
||||||
|
const code: string = n.data?.code || ''
|
||||||
|
const type: string = n.data?.type || ''
|
||||||
|
if (type === 'direktion') return 'ROOT'
|
||||||
|
if (type === 'abteilung') {
|
||||||
|
const m = code.match(/Abt\s+(ZA|\d)/i)
|
||||||
|
return m ? m[1].toUpperCase() : 'UNK'
|
||||||
|
}
|
||||||
|
if (type === 'dezernat') {
|
||||||
|
if (/ZA/i.test(code)) return 'ZA'
|
||||||
|
const m = code.match(/Dez\s+(\d+)/i)
|
||||||
|
return m ? m[1].charAt(0) : 'UNK'
|
||||||
|
}
|
||||||
|
if (type === 'sachgebiet' || type === 'teildezernat') {
|
||||||
|
if (/ZA/i.test(code)) return 'ZA'
|
||||||
|
const m = code.match(/\s(\d+)\./)
|
||||||
|
return m ? m[1].charAt(0) : 'UNK'
|
||||||
|
}
|
||||||
|
if (type === 'stabsstelle' || type === 'sondereinheit' || type === 'fuehrungsstelle') {
|
||||||
|
return 'ROOT'
|
||||||
|
}
|
||||||
|
// fallback: derive from parent's lane
|
||||||
|
const parentId = n.data?.parentId
|
||||||
|
if (parentId && byId[parentId]) return getLaneKey(byId[parentId])
|
||||||
|
return 'UNK'
|
||||||
|
}
|
||||||
|
|
||||||
|
const lanesMap = new Map<string, Node[]>()
|
||||||
|
newNodes.forEach(n => {
|
||||||
|
const lane = getLaneKey(n)
|
||||||
|
if (!lanesMap.has(lane)) lanesMap.set(lane, [])
|
||||||
|
lanesMap.get(lane)!.push(n)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort lanes: 1..6, ZA, ROOT, UNK
|
||||||
|
const laneOrder = Array.from(lanesMap.keys()).sort((a, b) => {
|
||||||
|
const rank = (k: string) => (k === 'ROOT' ? 99 : (k === 'UNK' ? 98 : (k === 'ZA' ? 7 : parseInt(k, 10))))
|
||||||
|
return rank(a) - rank(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper to sort Dez/SG numerically within lane
|
||||||
|
const dezOrder = (a: Node, b: Node) => {
|
||||||
|
const parseDez = (n: Node) => {
|
||||||
|
const c: string = n.data?.code || ''
|
||||||
|
if (/ZA/i.test(c)) {
|
||||||
|
const m = c.match(/Dez\s+ZA\s*(\d+)/i)
|
||||||
|
return 100 + (m ? parseInt(m[1] || '0', 10) : 0)
|
||||||
|
}
|
||||||
|
const m = c.match(/Dez\s+(\d+)/i)
|
||||||
|
return m ? parseInt(m[1], 10) : 0
|
||||||
|
}
|
||||||
|
return parseDez(a) - parseDez(b)
|
||||||
|
}
|
||||||
|
const childOrder = (a: Node, b: Node) => {
|
||||||
|
const pa = (a.data?.code || '').match(/(\d+)\.(\d+)/)
|
||||||
|
const pb = (b.data?.code || '').match(/(\d+)\.(\d+)/)
|
||||||
|
const va = pa ? (parseInt(pa[1],10)*100 + parseInt(pa[2],10)) : 0
|
||||||
|
const vb = pb ? (parseInt(pb[1],10)*100 + parseInt(pb[2],10)) : 0
|
||||||
|
return va - vb
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position Direktion and root-like nodes at the top center
|
||||||
|
const rootNodes = lanesMap.get('ROOT') || []
|
||||||
|
let globalMinX = xMargin
|
||||||
|
const totalLanes = laneOrder.filter(k => k !== 'ROOT' && k !== 'UNK').length
|
||||||
|
const rootX = Math.max(xMargin, (totalLanes * laneWidth) / 2 - 150)
|
||||||
|
let rootY = 10
|
||||||
|
rootNodes.forEach(n => {
|
||||||
|
n.position = { x: rootX, y: rootY }
|
||||||
|
rootY += yGap
|
||||||
|
})
|
||||||
|
|
||||||
|
// Arrange lanes
|
||||||
|
let laneIndex = 0
|
||||||
|
laneOrder.forEach(key => {
|
||||||
|
if (key === 'ROOT' || key === 'UNK') return
|
||||||
|
const laneNodes = lanesMap.get(key) || []
|
||||||
|
const xBase = xMargin + laneIndex * laneWidth
|
||||||
|
globalMinX = Math.min(globalMinX, xBase)
|
||||||
|
|
||||||
|
// Find the Abteilung node for this lane (if any)
|
||||||
|
const abteilung = laneNodes.find(n => n.data?.type === 'abteilung')
|
||||||
|
let yCursor = yStart
|
||||||
|
if (abteilung) {
|
||||||
|
abteilung.position = { x: xBase, y: yCursor }
|
||||||
|
yCursor += yGap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place Dezernate
|
||||||
|
const dezernate = laneNodes.filter(n => n.data?.type === 'dezernat').sort(dezOrder)
|
||||||
|
dezernate.forEach(dez => {
|
||||||
|
dez.position = { x: xBase, y: yCursor }
|
||||||
|
yCursor += yGap
|
||||||
|
// children SG/TD under this dez vertically
|
||||||
|
const children = laneNodes.filter(n => (n.data?.type === 'sachgebiet' || n.data?.type === 'teildezernat') && n.data?.parentId === dez.id).sort(childOrder)
|
||||||
|
children.forEach((c) => {
|
||||||
|
c.position = { x: xBase + 40, y: yCursor }
|
||||||
|
yCursor += yGap
|
||||||
|
})
|
||||||
|
// small gap after each dezernat block
|
||||||
|
yCursor += 12
|
||||||
|
})
|
||||||
|
|
||||||
|
// Any remaining nodes (fallback)
|
||||||
|
laneNodes.filter(n => !['abteilung','dezernat','sachgebiet','teildezernat'].includes(n.data?.type || '')).forEach(n => {
|
||||||
|
n.position = { x: xBase, y: yCursor }
|
||||||
|
yCursor += yGap
|
||||||
|
})
|
||||||
|
|
||||||
|
laneIndex += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
setNodes(newNodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reparenting: select source then target
|
||||||
|
const handleStartReparent = () => {
|
||||||
|
setReparentMode({ source: selectedNode })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReparent = async (child: Node, newParent: Node) => {
|
||||||
|
try {
|
||||||
|
await api.put(`/organization/units/${child.id}`, {
|
||||||
|
parentId: newParent.id,
|
||||||
|
level: (newParent.data?.level ?? 0) + 1
|
||||||
|
})
|
||||||
|
await loadOrganization()
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.response?.data?.error?.message || 'Konnte Parent nicht ändern')
|
||||||
|
console.error('Failed to reparent:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistPositions = async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all(nodes.map(n => api.put(`/organization/units/${n.id}`, {
|
||||||
|
positionX: Math.round(n.position.x),
|
||||||
|
positionY: Math.round(n.position.y)
|
||||||
|
})))
|
||||||
|
alert('Positionen gespeichert')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist positions:', error)
|
||||||
|
alert('Fehler beim Speichern der Positionen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen">
|
<div className="flex items-center justify-center h-screen">
|
||||||
@ -329,6 +574,31 @@ export default function OrganizationEditor() {
|
|||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
PDF importieren
|
PDF importieren
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAutoLayout}
|
||||||
|
className="w-full px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||||
|
>
|
||||||
|
Auto anordnen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={persistPositions}
|
||||||
|
className="w-full px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Positionen speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowValidation(true)}
|
||||||
|
className="w-full px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Validierung anzeigen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStartReparent}
|
||||||
|
disabled={!selectedNode}
|
||||||
|
className={`w-full px-4 py-2 rounded text-white ${selectedNode ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-400'}`}
|
||||||
|
>
|
||||||
|
Parent neu setzen {reparentMode.source ? '(Quelle gewählt)' : ''}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@ -341,6 +611,9 @@ export default function OrganizationEditor() {
|
|||||||
)}
|
)}
|
||||||
<p className="text-sm text-gray-600">Typ: {selectedNode.data.type}</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>
|
<p className="text-sm text-gray-600">Ebene: {selectedNode.data.level}</p>
|
||||||
|
{issues.orphans.has(selectedNode.id) && (
|
||||||
|
<p className="text-xs text-red-600 mt-1">Warnung: Parent fehlt (Waisen-Knoten)</p>
|
||||||
|
)}
|
||||||
{selectedNode.data.description && (
|
{selectedNode.data.description && (
|
||||||
<p className="text-sm mt-2">{selectedNode.data.description}</p>
|
<p className="text-sm mt-2">{selectedNode.data.description}</p>
|
||||||
)}
|
)}
|
||||||
@ -354,51 +627,149 @@ export default function OrganizationEditor() {
|
|||||||
{/* Import PDF Dialog */}
|
{/* Import PDF Dialog */}
|
||||||
{showImportDialog && (
|
{showImportDialog && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||||
<h2 className="text-xl font-bold mb-4">PDF Organigramm importieren</h2>
|
<h2 className="text-xl font-bold mb-4">PDF Organigramm importieren</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{!preview ? (
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
<div className="space-y-4">
|
||||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||||
Laden Sie ein PDF-Dokument mit der Organisationsstruktur hoch.
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
Das System extrahiert automatisch die Hierarchie.
|
Laden Sie ein PDF hoch. Es wird zunächst nur eine Vorschau (Diff) berechnet.
|
||||||
</p>
|
</p>
|
||||||
|
<label className="flex items-center justify-center gap-2 text-sm mb-4">
|
||||||
<input
|
<input type="checkbox" checked={clearExisting} onChange={(e)=>setClearExisting(e.target.checked)} />
|
||||||
ref={fileInputRef}
|
Bestehende Einheiten löschen
|
||||||
type="file"
|
</label>
|
||||||
accept=".pdf"
|
|
||||||
onChange={handleFileUpload}
|
<input
|
||||||
className="hidden"
|
ref={fileInputRef}
|
||||||
/>
|
type="file"
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
<button
|
onChange={handleFileUpload}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
className="hidden"
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
/>
|
||||||
disabled={!!uploadProgress}
|
|
||||||
>
|
<button
|
||||||
PDF auswählen
|
onClick={() => fileInputRef.current?.click()}
|
||||||
</button>
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||||
</div>
|
disabled={!!uploadProgress}
|
||||||
|
>
|
||||||
{uploadProgress && (
|
PDF auswählen
|
||||||
<div className="p-3 bg-blue-50 text-blue-700 rounded">
|
</button>
|
||||||
{uploadProgress}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{uploadProgress && (
|
||||||
|
<div className="p-3 bg-blue-50 text-blue-700 rounded">
|
||||||
|
{uploadProgress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-3 border rounded">
|
||||||
|
<h3 className="font-semibold mb-2">Neu</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">{preview.counts?.create} Einheiten</p>
|
||||||
|
<div className="max-h-40 overflow-auto text-sm">
|
||||||
|
{preview.diff?.create?.slice(0,50).map((u: any)=> (
|
||||||
|
<div key={u.code}>{u.code} – {u.name}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border rounded">
|
||||||
|
<h3 className="font-semibold mb-2">Änderungen</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">{preview.counts?.update} Einheiten</p>
|
||||||
|
<div className="max-h-40 overflow-auto text-sm">
|
||||||
|
{preview.diff?.update?.slice(0,50).map((d: any)=> (
|
||||||
|
<div key={d.incoming.code}>{d.incoming.code} – {d.existing.name} → {d.incoming.name}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border rounded">
|
||||||
|
<h3 className="font-semibold mb-2">Waisen (Parent fehlt)</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-2">{preview.counts?.orphans} Einheiten</p>
|
||||||
|
<div className="max-h-40 overflow-auto text-sm space-y-2">
|
||||||
|
{preview.diff?.orphans?.map((u: any)=> (
|
||||||
|
<div key={u.code} className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 truncate" title={`${u.code} – ${u.name}`}>{u.code} – {u.name}</div>
|
||||||
|
<input
|
||||||
|
placeholder="Parent-Code (z.B. Dez 11 / Abt 1)"
|
||||||
|
className="border rounded px-2 py-1 text-xs w-48"
|
||||||
|
value={pendingOverrides[u.code]?.parentCode || ''}
|
||||||
|
onChange={(e)=> setPendingOverrides(prev => ({...prev, [u.code]: { ...(prev[u.code]||{}), parentCode: e.target.value }}))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => openParentPicker(u.code)}
|
||||||
|
className="px-2 py-1 text-xs bg-slate-100 rounded border"
|
||||||
|
title="Parent auswählen"
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={rememberRules} onChange={(e)=>setRememberRules(e.target.checked)} />
|
||||||
|
Zuordnungen als Regeln speichern (für künftige Importe)
|
||||||
|
</label>
|
||||||
|
{uploadProgress && (
|
||||||
|
<div className="p-3 bg-blue-50 text-blue-700 rounded">{uploadProgress}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-6">
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowImportDialog(false)
|
setShowImportDialog(false)
|
||||||
setUploadProgress(null)
|
setUploadProgress(null)
|
||||||
|
setPreview(null)
|
||||||
|
setPendingOverrides({})
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||||
>
|
>
|
||||||
Schließen
|
Schließen
|
||||||
</button>
|
</button>
|
||||||
|
{preview && (
|
||||||
|
<button
|
||||||
|
onClick={applyImport}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Import anwenden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parent Picker Modal */}
|
||||||
|
{parentPicker.open && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-4 max-w-lg w-full">
|
||||||
|
<h3 className="font-semibold mb-3">Parent auswählen für {parentPicker.target}</h3>
|
||||||
|
<div className="max-h-80 overflow-auto border rounded">
|
||||||
|
{(parentPicker.suggestions || []).map((s: any) => (
|
||||||
|
<button
|
||||||
|
key={s.code}
|
||||||
|
onClick={() => {
|
||||||
|
if (parentPicker.target) {
|
||||||
|
setPendingOverrides(prev => ({ ...prev, [parentPicker.target!]: { ...(prev[parentPicker.target!]||{}), parentCode: s.code } }))
|
||||||
|
}
|
||||||
|
setParentPicker({ open: false, target: undefined, suggestions: [] })
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-slate-100 border-b last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs mr-2">{s.code}</span>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end mt-3">
|
||||||
|
<button onClick={() => setParentPicker({ open: false, target: undefined, suggestions: [] })} className="px-3 py-1 text-slate-600 hover:text-slate-800">Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -498,6 +869,33 @@ export default function OrganizationEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Validation Dialog */}
|
||||||
|
{showValidation && (
|
||||||
|
<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-lg w-full">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Validierung</h2>
|
||||||
|
<div className="space-y-3 max-h-[60vh] overflow-auto">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-2">Waisen-Knoten (Parent fehlt)</h3>
|
||||||
|
{issues.orphans.size === 0 ? (
|
||||||
|
<p className="text-gray-600">Keine</p>
|
||||||
|
) : (
|
||||||
|
<ul className="list-disc ml-5 text-sm">
|
||||||
|
{Array.from(issues.orphans).map(id => {
|
||||||
|
const n = nodes.find(n=>n.id===id)
|
||||||
|
return <li key={id}>{n?.data?.code || id} – {n?.data?.name}</li>
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<button onClick={()=>setShowValidation(false)} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
25
backend/scripts/extract-pdf.js
Normale Datei
25
backend/scripts/extract-pdf.js
Normale Datei
@ -0,0 +1,25 @@
|
|||||||
|
// Simple helper to extract text from the root PDF for inspection
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const pdfParse = require('pdf-parse')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pdfPath = path.resolve(__dirname, '..', '..', 'Organigramm_ohne_Namen.pdf')
|
||||||
|
if (!fs.existsSync(pdfPath)) {
|
||||||
|
console.error('PDF not found at', pdfPath)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const buf = fs.readFileSync(pdfPath)
|
||||||
|
const res = await pdfParse(buf)
|
||||||
|
const outPath = path.resolve(__dirname, '..', '..', 'organigramm_text.txt')
|
||||||
|
fs.writeFileSync(outPath, res.text, 'utf8')
|
||||||
|
console.log('Extracted text length:', res.text.length)
|
||||||
|
console.log('Pages:', res.numpages)
|
||||||
|
console.log('Saved to:', outPath)
|
||||||
|
// Print first 200 lines to stdout for quick view
|
||||||
|
const lines = res.text.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
console.log('--- First lines ---')
|
||||||
|
console.log(lines.slice(0, 200).join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1) })
|
||||||
@ -403,6 +403,19 @@ export function initializeDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_org_units_level ON organizational_units(level);
|
CREATE INDEX IF NOT EXISTS idx_org_units_level ON organizational_units(level);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Import rules for organization parsing (persist admin overrides)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS organization_import_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_code TEXT UNIQUE NOT NULL,
|
||||||
|
parent_code TEXT,
|
||||||
|
type_override TEXT,
|
||||||
|
name_override TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_rules_target ON organization_import_rules(target_code);
|
||||||
|
`)
|
||||||
|
|
||||||
// Employee Unit Assignments
|
// Employee Unit Assignments
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS employee_unit_assignments (
|
CREATE TABLE IF NOT EXISTS employee_unit_assignments (
|
||||||
@ -554,4 +567,4 @@ export function initializeDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
|
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_workspace_analytics_date ON workspace_analytics(date);
|
CREATE INDEX IF NOT EXISTS idx_workspace_analytics_date ON workspace_analytics(date);
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -200,6 +200,8 @@ router.put('/units/:id',
|
|||||||
body('name').optional().notEmpty().trim(),
|
body('name').optional().notEmpty().trim(),
|
||||||
body('description').optional(),
|
body('description').optional(),
|
||||||
body('color').optional(),
|
body('color').optional(),
|
||||||
|
body('parentId').optional({ checkFalsy: true }).isUUID(),
|
||||||
|
body('level').optional().isInt({ min: 0, max: 10 }),
|
||||||
// allow updating persisted canvas positions from admin editor
|
// allow updating persisted canvas positions from admin editor
|
||||||
body('positionX').optional().isNumeric(),
|
body('positionX').optional().isNumeric(),
|
||||||
body('positionY').optional().isNumeric()
|
body('positionY').optional().isNumeric()
|
||||||
@ -210,9 +212,32 @@ router.put('/units/:id',
|
|||||||
return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
|
return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY } = req.body
|
const { name, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
// Optional: validate parentId and avoid cycles
|
||||||
|
let newParentId: string | null | undefined = undefined
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
if (parentId === null || parentId === '' ) {
|
||||||
|
newParentId = null
|
||||||
|
} else {
|
||||||
|
const parent = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(parentId)
|
||||||
|
if (!parent) {
|
||||||
|
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
|
||||||
|
}
|
||||||
|
// cycle check: walk up from parent to root; id must not appear
|
||||||
|
const targetId = req.params.id
|
||||||
|
let cursor: any = parent
|
||||||
|
while (cursor && cursor.parentId) {
|
||||||
|
if (cursor.parentId === targetId) {
|
||||||
|
return res.status(400).json({ success: false, error: { message: 'Cyclic parent assignment is not allowed' } })
|
||||||
|
}
|
||||||
|
cursor = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(cursor.parentId)
|
||||||
|
}
|
||||||
|
newParentId = parentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
UPDATE organizational_units
|
UPDATE organizational_units
|
||||||
SET name = COALESCE(?, name),
|
SET name = COALESCE(?, name),
|
||||||
@ -220,6 +245,8 @@ router.put('/units/:id',
|
|||||||
color = COALESCE(?, color),
|
color = COALESCE(?, color),
|
||||||
has_fuehrungsstelle = COALESCE(?, has_fuehrungsstelle),
|
has_fuehrungsstelle = COALESCE(?, has_fuehrungsstelle),
|
||||||
fuehrungsstelle_name = COALESCE(?, fuehrungsstelle_name),
|
fuehrungsstelle_name = COALESCE(?, fuehrungsstelle_name),
|
||||||
|
parent_id = COALESCE(?, parent_id),
|
||||||
|
level = COALESCE(?, level),
|
||||||
position_x = COALESCE(?, position_x),
|
position_x = COALESCE(?, position_x),
|
||||||
position_y = COALESCE(?, position_y),
|
position_y = COALESCE(?, position_y),
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
@ -230,6 +257,8 @@ router.put('/units/:id',
|
|||||||
color || null,
|
color || null,
|
||||||
hasFuehrungsstelle !== undefined ? (hasFuehrungsstelle ? 1 : 0) : null,
|
hasFuehrungsstelle !== undefined ? (hasFuehrungsstelle ? 1 : 0) : null,
|
||||||
fuehrungsstelleName || null,
|
fuehrungsstelleName || null,
|
||||||
|
newParentId !== undefined ? newParentId : null,
|
||||||
|
level !== undefined ? Number(level) : null,
|
||||||
positionX !== undefined ? Math.round(Number(positionX)) : null,
|
positionX !== undefined ? Math.round(Number(positionX)) : null,
|
||||||
positionY !== undefined ? Math.round(Number(positionY)) : null,
|
positionY !== undefined ? Math.round(Number(positionY)) : null,
|
||||||
now,
|
now,
|
||||||
|
|||||||
@ -28,153 +28,179 @@ const upload = multer({
|
|||||||
// Helper to parse organizational structure from PDF text
|
// Helper to parse organizational structure from PDF text
|
||||||
function parseOrganizationFromText(text: string) {
|
function parseOrganizationFromText(text: string) {
|
||||||
const units: any[] = []
|
const units: any[] = []
|
||||||
|
const byCode = new Map<string, any>()
|
||||||
const lines = text.split('\n').map(line => line.trim()).filter(line => line && line.length > 2)
|
const lines = text.split('\n').map(line => line.trim()).filter(line => line && line.length > 2)
|
||||||
|
|
||||||
// Pattern matching for different organizational levels
|
|
||||||
const patterns = {
|
const patterns = {
|
||||||
direktor: /(Direktor|Director)\s*(LKA)?/i,
|
direktor: /(Direktor|Director)\s*(LKA)?/i,
|
||||||
abteilung: /^Abteilung\s+(\d+)/i,
|
abteilung: /^Abteilung\s+(\d+|Zentralabteilung)/i,
|
||||||
dezernat: /^Dezernat\s+([\d]+)/i,
|
dezernat: /^(?:Dezernat|Dez)\s+([\d]+)/i,
|
||||||
sachgebiet: /^SG\s+([\d\.]+)/i,
|
sachgebiet: /^SG\s+([\d\.]+)/i,
|
||||||
teildezernat: /^TD\s+([\d\.]+)/i,
|
teildezernat: /^TD\s+([\d\.]+)/i,
|
||||||
stabsstelle: /(Leitungsstab|LStab|Führungsgruppe)/i,
|
stabsstelle: /(Leitungsstab|LStab|Führungsgruppe)/i,
|
||||||
fahndung: /Fahndungsgruppe/i,
|
sondereinheit: /(Personalrat|Schwerbehindertenvertretung|Datenschutzbeauftrag|Gleichstellungsbeauftrag|Innenrevision|IUK-Lage)/i
|
||||||
sondereinheit: /(Personalrat|Schwerbehindertenvertretung|beauftragt|Innenrevision|IUK-Lage)/i
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color mapping for departments
|
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
'1': '#dc2626',
|
'1': '#dc2626', '2': '#ea580c', '3': '#0891b2', '4': '#7c3aed', '5': '#0d9488', '6': '#be185d', 'ZA': '#6b7280'
|
||||||
'2': '#ea580c',
|
|
||||||
'3': '#0891b2',
|
|
||||||
'4': '#7c3aed',
|
|
||||||
'5': '#0d9488',
|
|
||||||
'6': '#be185d',
|
|
||||||
'ZA': '#6b7280'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensure = (u: any) => {
|
||||||
|
const existing = byCode.get(u.code)
|
||||||
|
if (existing) {
|
||||||
|
// Update minimal fields if missing
|
||||||
|
existing.name = existing.name || u.name
|
||||||
|
existing.type = existing.type || u.type
|
||||||
|
existing.level = existing.level ?? u.level
|
||||||
|
if (!existing.parentId && u.parentId) existing.parentId = u.parentId
|
||||||
|
if (!existing.color && u.color) existing.color = u.color
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
byCode.set(u.code, u)
|
||||||
|
units.push(u)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferAbteilungCode = (dezNum: string) => {
|
||||||
|
const digits = (dezNum || '').replace(/\D/g, '')
|
||||||
|
const first = digits.charAt(0)
|
||||||
|
if (['1','2','3','4','5','6'].includes(first)) return `Abt ${first}`
|
||||||
|
if (dezNum.toUpperCase().includes('ZA') || first === '0') return 'Abt ZA'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
let currentAbteilung: any = null
|
let currentAbteilung: any = null
|
||||||
let currentDezernat: any = null
|
let currentDezernat: any = null
|
||||||
|
|
||||||
lines.forEach(line => {
|
// Always ensure root
|
||||||
// Check for Direktor
|
ensure({ code: 'DIR', name: 'Direktor LKA NRW', type: 'direktion', level: 0, parentId: null, color: '#1e3a8a' })
|
||||||
|
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.replace(/[–—]/g, '-')
|
||||||
|
|
||||||
if (patterns.direktor.test(line)) {
|
if (patterns.direktor.test(line)) {
|
||||||
units.push({
|
ensure({ code: 'DIR', name: 'Direktor LKA NRW', type: 'direktion', level: 0, parentId: null, color: '#1e3a8a' })
|
||||||
code: 'DIR',
|
continue
|
||||||
name: 'Direktor LKA NRW',
|
|
||||||
type: 'direktion',
|
|
||||||
level: 0,
|
|
||||||
parentId: null,
|
|
||||||
color: '#1e3a8a'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Abteilung
|
|
||||||
const abtMatch = line.match(/Abteilung\s+(\d+|Zentralabteilung)/i)
|
const abtMatch = line.match(/Abteilung\s+(\d+|Zentralabteilung)/i)
|
||||||
if (abtMatch) {
|
if (abtMatch) {
|
||||||
const abtNum = abtMatch[1] === 'Zentralabteilung' ? 'ZA' : abtMatch[1]
|
const abtNum = abtMatch[1] === 'Zentralabteilung' ? 'ZA' : abtMatch[1]
|
||||||
const abtName = line.replace(/^Abteilung\s+\d+\s*[-–]\s*/, '')
|
const abtName = line.replace(/^Abteilung\s+(?:\d+|Zentralabteilung)\s*-?\s*/i, '').trim() || `Abteilung ${abtNum}`
|
||||||
currentAbteilung = {
|
currentAbteilung = ensure({
|
||||||
code: `Abt ${abtNum}`,
|
code: `Abt ${abtNum}`,
|
||||||
name: abtName || `Abteilung ${abtNum}`,
|
name: abtName,
|
||||||
type: 'abteilung',
|
type: 'abteilung',
|
||||||
level: 1,
|
level: 1,
|
||||||
parentId: 'DIR',
|
parentId: 'DIR',
|
||||||
color: colors[abtNum] || '#6b7280',
|
color: colors[abtNum] || '#6b7280',
|
||||||
hasFuehrungsstelle: abtNum !== 'ZA'
|
hasFuehrungsstelle: abtNum !== 'ZA'
|
||||||
}
|
})
|
||||||
units.push(currentAbteilung)
|
|
||||||
currentDezernat = null
|
currentDezernat = null
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Dezernat
|
const dezMatch = line.match(/^(?:Dezernat|Dez)\s+([\d]+)/i)
|
||||||
const dezMatch = line.match(/(?:Dezernat|Dez)\s+([\d\s]+)/i)
|
if (dezMatch) {
|
||||||
if (dezMatch && currentAbteilung) {
|
|
||||||
const dezNum = dezMatch[1].trim()
|
const dezNum = dezMatch[1].trim()
|
||||||
const dezName = line.replace(/^(?:Dezernat|Dez)\s+[\d\s]+\s*[-–]?\s*/, '').trim()
|
const dezName = line.replace(/^(?:Dezernat|Dez)\s+[\d]+\s*-?\s*/i, '').trim() || `Dezernat ${dezNum}`
|
||||||
currentDezernat = {
|
// Strict mapping: first digit of dezNum determines Abteilung
|
||||||
code: `Dez ${dezNum}`,
|
const inferredAbt = inferAbteilungCode(dezNum)
|
||||||
name: dezName || `Dezernat ${dezNum}`,
|
let parentCode: string | null = null
|
||||||
type: 'dezernat',
|
if (inferredAbt) {
|
||||||
level: 2,
|
const abtNum = inferredAbt.split(' ')[1]
|
||||||
parentId: currentAbteilung.code
|
ensure({ code: inferredAbt, name: inferredAbt.replace('Abt', 'Abteilung'), type: 'abteilung', level: 1, parentId: 'DIR', color: colors[abtNum] || '#6b7280', hasFuehrungsstelle: abtNum !== 'ZA' })
|
||||||
|
parentCode = inferredAbt
|
||||||
}
|
}
|
||||||
units.push(currentDezernat)
|
currentDezernat = ensure({ code: `Dez ${dezNum}`, name: dezName, type: 'dezernat', level: 2, parentId: parentCode })
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Sachgebiet
|
const sgMatch = line.match(/^SG\s+([\d\.]+)/i)
|
||||||
const sgMatch = line.match(/SG\s+([\d\.]+)/i)
|
if (sgMatch) {
|
||||||
if (sgMatch && currentDezernat) {
|
const sgNum = sgMatch[1].trim()
|
||||||
const sgNum = sgMatch[1]
|
const sgName = line.replace(/^SG\s+[\d\.]+\s*-?\s*/i, '').trim() || `Sachgebiet ${sgNum}`
|
||||||
const sgName = line.replace(/^SG\s+[\d\.]+\s*[-–]?\s*/, '').trim()
|
// Strict mapping: SG NN.X -> Dez NN under Abt N
|
||||||
units.push({
|
const prefix = sgNum.split('.')[0]
|
||||||
code: `SG ${sgNum}`,
|
let dezCode: string | null = null
|
||||||
name: sgName || `Sachgebiet ${sgNum}`,
|
if (prefix) {
|
||||||
type: 'sachgebiet',
|
dezCode = `Dez ${prefix}`
|
||||||
level: 3,
|
const inferredAbt = inferAbteilungCode(prefix)
|
||||||
parentId: currentDezernat.code
|
if (inferredAbt) {
|
||||||
})
|
const abtNum = inferredAbt.split(' ')[1]
|
||||||
|
ensure({ code: inferredAbt, name: inferredAbt.replace('Abt', 'Abteilung'), type: 'abteilung', level: 1, parentId: 'DIR', color: colors[abtNum] || '#6b7280', hasFuehrungsstelle: abtNum !== 'ZA' })
|
||||||
|
}
|
||||||
|
ensure({ code: dezCode, name: `Dezernat ${prefix}`, type: 'dezernat', level: 2, parentId: inferredAbt || null })
|
||||||
|
}
|
||||||
|
ensure({ code: `SG ${sgNum}`, name: sgName, type: 'sachgebiet', level: 3, parentId: dezCode })
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Teildezernat
|
const tdMatch = line.match(/^TD\s+([\d\.]+)/i)
|
||||||
const tdMatch = line.match(/TD\s+([\d\.]+)/i)
|
if (tdMatch) {
|
||||||
if (tdMatch && currentDezernat) {
|
const tdNum = tdMatch[1].trim()
|
||||||
const tdNum = tdMatch[1]
|
const tdName = line.replace(/^TD\s+[\d\.]+\s*-?\s*/i, '').trim() || `Teildezernat ${tdNum}`
|
||||||
const tdName = line.replace(/^TD\s+[\d\.]+\s*[-–]?\s*/, '').trim()
|
// Strict mapping: TD NN.X -> Dez NN under Abt N
|
||||||
units.push({
|
const prefix = tdNum.split('.')[0]
|
||||||
code: `TD ${tdNum}`,
|
let dezCode: string | null = null
|
||||||
name: tdName || `Teildezernat ${tdNum}`,
|
if (prefix) {
|
||||||
type: 'teildezernat',
|
dezCode = `Dez ${prefix}`
|
||||||
level: 3,
|
const inferredAbt = inferAbteilungCode(prefix)
|
||||||
parentId: currentDezernat.code
|
if (inferredAbt) {
|
||||||
})
|
const abtNum = inferredAbt.split(' ')[1]
|
||||||
|
ensure({ code: inferredAbt, name: inferredAbt.replace('Abt', 'Abteilung'), type: 'abteilung', level: 1, parentId: 'DIR', color: colors[abtNum] || '#6b7280', hasFuehrungsstelle: abtNum !== 'ZA' })
|
||||||
|
}
|
||||||
|
ensure({ code: dezCode, name: `Dezernat ${prefix}`, type: 'dezernat', level: 2, parentId: inferredAbt || null })
|
||||||
|
}
|
||||||
|
ensure({ code: `TD ${tdNum}`, name: tdName, type: 'teildezernat', level: 3, parentId: dezCode })
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Stabsstelle
|
|
||||||
if (patterns.stabsstelle.test(line)) {
|
if (patterns.stabsstelle.test(line)) {
|
||||||
units.push({
|
ensure({ code: 'LStab', name: 'Leitungsstab', type: 'stabsstelle', level: 1, parentId: 'DIR', color: '#6b7280' })
|
||||||
code: 'LStab',
|
continue
|
||||||
name: 'Leitungsstab',
|
|
||||||
type: 'stabsstelle',
|
|
||||||
level: 1,
|
|
||||||
parentId: 'DIR',
|
|
||||||
color: '#6b7280'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Sondereinheiten (non-hierarchical)
|
|
||||||
if (patterns.sondereinheit.test(line)) {
|
if (patterns.sondereinheit.test(line)) {
|
||||||
let code = 'SE'
|
let code = 'SE'
|
||||||
let name = line
|
let name = line
|
||||||
|
if (/Personalrat/i.test(line)) { code = 'PR'; name = 'Personalrat' }
|
||||||
if (line.includes('Personalrat')) {
|
else if (/Schwerbehindertenvertretung/i.test(line)) { code = 'SBV'; name = 'Schwerbehindertenvertretung' }
|
||||||
code = 'PR'
|
else if (/Datenschutzbeauftrag/i.test(line)) { code = 'DSB'; name = 'Datenschutzbeauftragter' }
|
||||||
name = 'Personalrat'
|
else if (/Gleichstellungsbeauftrag/i.test(line)) { code = 'GSB'; name = 'Gleichstellungsbeauftragte' }
|
||||||
} else if (line.includes('Schwerbehindertenvertretung')) {
|
else if (/Innenrevision/i.test(line)) { code = 'IR'; name = 'Innenrevision' }
|
||||||
code = 'SBV'
|
ensure({ code, name, type: 'sondereinheit', level: 1, parentId: null, color: '#059669' })
|
||||||
name = 'Schwerbehindertenvertretung'
|
continue
|
||||||
} else if (line.includes('Datenschutzbeauftragter')) {
|
|
||||||
code = 'DSB'
|
|
||||||
name = 'Datenschutzbeauftragter'
|
|
||||||
} else if (line.includes('Gleichstellungsbeauftragte')) {
|
|
||||||
code = 'GSB'
|
|
||||||
name = 'Gleichstellungsbeauftragte'
|
|
||||||
} else if (line.includes('Innenrevision')) {
|
|
||||||
code = 'IR'
|
|
||||||
name = 'Innenrevision'
|
|
||||||
}
|
|
||||||
|
|
||||||
units.push({
|
|
||||||
code,
|
|
||||||
name,
|
|
||||||
type: 'sondereinheit',
|
|
||||||
level: 1,
|
|
||||||
parentId: null, // Non-hierarchical
|
|
||||||
color: '#059669'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Post-processing: infer missing parents
|
||||||
|
const has = (code: string) => byCode.has(code)
|
||||||
|
const get = (code: string) => byCode.get(code)
|
||||||
|
for (const u of units) {
|
||||||
|
if (u.type === 'dezernat' && !u.parentId) {
|
||||||
|
const inferred = inferAbteilungCode(String(u.code).replace(/^Dez\s+/i,''))
|
||||||
|
if (inferred) {
|
||||||
|
if (!has(inferred)) {
|
||||||
|
const abtNum = inferred.split(' ')[1]
|
||||||
|
ensure({ code: inferred, name: inferred.replace('Abt', 'Abteilung'), type: 'abteilung', level: 1, parentId: 'DIR', color: colors[abtNum] || '#6b7280', hasFuehrungsstelle: abtNum !== 'ZA' })
|
||||||
|
}
|
||||||
|
u.parentId = inferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((u.type === 'sachgebiet' || u.type === 'teildezernat') && !u.parentId) {
|
||||||
|
const num = String(u.code).replace(/^(SG|TD)\s+/i,'')
|
||||||
|
const prefix = num.split('.')[0]
|
||||||
|
if (prefix) {
|
||||||
|
const dezCode = `Dez ${prefix}`
|
||||||
|
if (!has(dezCode)) {
|
||||||
|
ensure({ code: dezCode, name: `Dezernat ${prefix}`, type: 'dezernat', level: 2, parentId: inferAbteilungCode(prefix) })
|
||||||
|
}
|
||||||
|
u.parentId = dezCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return units
|
return units
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,7 +255,24 @@ router.post('/import-pdf',
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse the organizational structure
|
// Parse the organizational structure
|
||||||
const parsedUnits = parseOrganizationFromText(extractedText)
|
let parsedUnits = parseOrganizationFromText(extractedText)
|
||||||
|
|
||||||
|
// Apply saved import rules (overrides)
|
||||||
|
try {
|
||||||
|
const rules = db.prepare('SELECT target_code, parent_code, type_override, name_override FROM organization_import_rules').all() as any[]
|
||||||
|
if (rules && rules.length) {
|
||||||
|
const byCode: Record<string, any> = {}
|
||||||
|
parsedUnits.forEach(u => { byCode[u.code] = u })
|
||||||
|
rules.forEach(r => {
|
||||||
|
const u = byCode[r.target_code]
|
||||||
|
if (u) {
|
||||||
|
if (r.parent_code) u.parentId = r.parent_code
|
||||||
|
if (r.type_override) u.type = r.type_override
|
||||||
|
if (r.name_override) u.name = r.name_override
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
if (parsedUnits.length === 0) {
|
if (parsedUnits.length === 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@ -238,29 +281,84 @@ router.post('/import-pdf',
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing organization (optional - could be a parameter)
|
// PREVIEW MODE: if requested, compute diff and return without writing
|
||||||
if (req.body.clearExisting === 'true') {
|
const previewOnly = String((req.body as any).previewOnly ?? (req.query as any).previewOnly) === 'true'
|
||||||
|
const overridesRaw = (req.body as any).overrides ?? (req.query as any).overrides
|
||||||
|
let overrides: Array<{ code: string; parentCode?: string; type?: string; name?: string }> = []
|
||||||
|
try {
|
||||||
|
if (overridesRaw) overrides = typeof overridesRaw === 'string' ? JSON.parse(overridesRaw) : overridesRaw
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (overrides.length) {
|
||||||
|
const byCode: Record<string, any> = {}
|
||||||
|
parsedUnits.forEach(u => { byCode[u.code] = u })
|
||||||
|
overrides.forEach(o => {
|
||||||
|
const u = byCode[o.code]
|
||||||
|
if (u) {
|
||||||
|
if (o.parentCode) u.parentId = o.parentCode
|
||||||
|
if (o.type) u.type = o.type
|
||||||
|
if (o.name) u.name = o.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const existingMap = new Map<string, any>()
|
||||||
|
const existingRows = db.prepare(`SELECT id, code, name, type, level, parent_id as parentId FROM organizational_units`).all() as any[]
|
||||||
|
existingRows.forEach(r => existingMap.set(r.code, r))
|
||||||
|
|
||||||
|
// Diff classification
|
||||||
|
const orphans: any[] = []
|
||||||
|
const toCreate: any[] = []
|
||||||
|
const toUpdate: any[] = []
|
||||||
|
parsedUnits.forEach(u => {
|
||||||
|
const parentCode = u.parentId || null
|
||||||
|
const exists = existingMap.get(u.code)
|
||||||
|
if (!parentCode) {
|
||||||
|
orphans.push(u)
|
||||||
|
}
|
||||||
|
if (!exists) {
|
||||||
|
toCreate.push(u)
|
||||||
|
} else {
|
||||||
|
const diff = (exists.name !== u.name) || (exists.type !== u.type) || (exists.level !== u.level)
|
||||||
|
// Parent diff: compare codes by looking up parent id -> code
|
||||||
|
let existingParentCode: string | null = null
|
||||||
|
if (exists.parentId) {
|
||||||
|
const parentRow = db.prepare('SELECT code FROM organizational_units WHERE id = ?').get(exists.parentId) as any
|
||||||
|
existingParentCode = parentRow?.code || null
|
||||||
|
}
|
||||||
|
const parentDiff = existingParentCode !== parentCode
|
||||||
|
if (diff || parentDiff) {
|
||||||
|
toUpdate.push({ existing: exists, incoming: u })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (previewOnly) {
|
||||||
|
return res.json({ success: true, data: {
|
||||||
|
message: 'Preview generated',
|
||||||
|
counts: { create: toCreate.length, update: toUpdate.length, orphans: orphans.length },
|
||||||
|
diff: { create: toCreate, update: toUpdate, orphans }
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing organization (optional)
|
||||||
|
if (((req.body as any).clearExisting ?? (req.query as any).clearExisting) === 'true') {
|
||||||
db.prepare('DELETE FROM organizational_units').run()
|
db.prepare('DELETE FROM organizational_units').run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare insert/update with stable parent references and FK-safe order
|
// Prepare insert/update with stable parent references and FK-safe order
|
||||||
const now = new Date().toISOString()
|
|
||||||
const unitIdMap: Record<string, string> = {}
|
const unitIdMap: Record<string, string> = {}
|
||||||
|
|
||||||
// Preload existing IDs by code
|
|
||||||
for (const unit of parsedUnits) {
|
for (const unit of parsedUnits) {
|
||||||
const existing = db.prepare('SELECT id FROM organizational_units WHERE code = ?').get(unit.code) as any
|
const existing = db.prepare('SELECT id FROM organizational_units WHERE code = ?').get(unit.code) as any
|
||||||
unitIdMap[unit.code] = existing?.id || uuidv4()
|
unitIdMap[unit.code] = existing?.id || uuidv4()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by level ascending so parents are processed first
|
|
||||||
const sorted = [...parsedUnits].sort((a, b) => (a.level ?? 0) - (b.level ?? 0))
|
const sorted = [...parsedUnits].sort((a, b) => (a.level ?? 0) - (b.level ?? 0))
|
||||||
|
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
sorted.forEach((unit, index) => {
|
sorted.forEach((unit, index) => {
|
||||||
const parentId = unit.parentId ? unitIdMap[unit.parentId] : null
|
const parentId = unit.parentId ? unitIdMap[unit.parentId] : null
|
||||||
const existing = db.prepare('SELECT id FROM organizational_units WHERE code = ?').get(unit.code) as any
|
const existing = db.prepare('SELECT id FROM organizational_units WHERE code = ?').get(unit.code) as any
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE organizational_units
|
UPDATE organizational_units
|
||||||
@ -303,8 +401,20 @@ router.post('/import-pdf',
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
tx()
|
tx()
|
||||||
|
|
||||||
|
// Remember rules (optional)
|
||||||
|
const rememberRules = String((req.body as any).rememberRules ?? (req.query as any).rememberRules) === 'true'
|
||||||
|
if (rememberRules && overrides.length) {
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO organization_import_rules (id, target_code, parent_code, type_override, name_override, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(target_code) DO UPDATE SET parent_code=excluded.parent_code, type_override=excluded.type_override, name_override=excluded.name_override
|
||||||
|
`)
|
||||||
|
overrides.forEach(o => {
|
||||||
|
upsert.run(uuidv4(), o.code, o.parentCode || null, o.type || null, o.name || null, now)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
339
organigramm_text.txt
Normale Datei
339
organigramm_text.txt
Normale Datei
@ -0,0 +1,339 @@
|
|||||||
|
|
||||||
|
|
||||||
|
Stand: 08/2025
|
||||||
|
Dezernat 62
|
||||||
|
Fahndungsgruppe Staatsschutz
|
||||||
|
Führungsgruppe
|
||||||
|
Fahndungsgruppen 1 - 8
|
||||||
|
SG 62.1
|
||||||
|
SG 62.2
|
||||||
|
SG 62.3
|
||||||
|
SG 62.4
|
||||||
|
SG 62.5
|
||||||
|
SG 62.6
|
||||||
|
SG 62.7
|
||||||
|
SG 62.8
|
||||||
|
SG 62.9 - Technische Gruppe
|
||||||
|
Dezernat 42
|
||||||
|
Cyber-Recherche- und Fahndungszentrum,
|
||||||
|
Ermittlungen Cybercrime
|
||||||
|
SG 42.1 - Personenorientierte Recherche in
|
||||||
|
Datennetzen
|
||||||
|
SG 42.2 - Sachorientierte Recherche in
|
||||||
|
Datennetzen
|
||||||
|
TD 42.3 Interventionsteams Digitale Tatorte
|
||||||
|
IUK-Lageunterstützung
|
||||||
|
Ermittlungskommissionen
|
||||||
|
Dezernat 33
|
||||||
|
Fahndung, Datenaustausch Polizei/Justiz,
|
||||||
|
Kriminalaktenhaltung, Internationale Rechtshilfe
|
||||||
|
SG 33.1 - Datenstation, Polizeiliche Beobachtung,
|
||||||
|
Grundsatz Fahndung, Fahndungsportal
|
||||||
|
SG 33.2 - Datenaustausch Polizei/Justiz,
|
||||||
|
Kriminalaktenhaltung
|
||||||
|
SG 33.3 - Rechtshilfe, PNR, internationale
|
||||||
|
Fahndung, Interpol- und Europolangelegenheiten,
|
||||||
|
Vermisste
|
||||||
|
Dezernat 32
|
||||||
|
Kriminalprävention, Kriminalistisch-
|
||||||
|
Kriminologische Forschungsstelle, Evaluation
|
||||||
|
SG 32.1 - Kriminalprävention und Opferschutz
|
||||||
|
TD 32.2 - Kriminalistisch-Kriminologische
|
||||||
|
Forschungsstelle (KKF)
|
||||||
|
SG 32.3 - Zentralstelle Evaluation (ZEVA)
|
||||||
|
Dezernat 31
|
||||||
|
Kriminalitätsauswertung und Analyse,
|
||||||
|
Polizeiliche Kriminalitätsstatistik
|
||||||
|
SG 31.1 - Grundsatzangelegenheiten / KoST
|
||||||
|
MAfEx / KoSt MOTIV/MIT/aMIT
|
||||||
|
SG 31.2 - Auswertung und Analyse 1, Eigentums-
|
||||||
|
und Vermögensdelikte, SÄM-ÜT, KoSt RTE
|
||||||
|
SG 31.3 - Auswertung/Analyse 2
|
||||||
|
Rauschgift-, Arzneimittel-, Menschenhandels-,
|
||||||
|
Schleusungskriminalität, Dokumentenfäschungen,
|
||||||
|
Gewaltdelikte
|
||||||
|
SG 31.4 Polizeiliche Kriminalstatistik (PKS)
|
||||||
|
Dezernat 64
|
||||||
|
Mobiles Einsatzkommando, Technische
|
||||||
|
Einsatzgruppe, Zielfahndung
|
||||||
|
FüGr
|
||||||
|
SG 64.1 - MEK 1
|
||||||
|
SG 64.2 - MEK 2
|
||||||
|
SG 64.3 - Technische Einsatzgruppe
|
||||||
|
SG 64.4 - Zielfahndung
|
||||||
|
Dezernat 63
|
||||||
|
Verdeckte Ermittlungen, Zeugenschutz
|
||||||
|
SG 63.1 - Einsatz VE 1
|
||||||
|
SG 63.2 - Einsatz VE 2
|
||||||
|
SG 63.3 - Scheinkäufer, Logistik
|
||||||
|
SG 63.4 - VP-Führung, Koordinierung
|
||||||
|
VP
|
||||||
|
TD 63.5 - Zeugenschutz, OpOS
|
||||||
|
Dezernat 34
|
||||||
|
Digitalstrategie, Polizeifachliche IT,
|
||||||
|
Landeszentrale Qualitätssicherung
|
||||||
|
TD 34.1 - Grundsatz , Gremien, Fachliche
|
||||||
|
IT-Projekte
|
||||||
|
TD 34.2 - Zentralstelle Polizei 2020, PIAV, QS
|
||||||
|
Verbundanwendungen, Europäisches
|
||||||
|
Informationssystem (EIS)
|
||||||
|
TD 34.3 - IT FaKo Fachbereich Kriminalität,
|
||||||
|
Zentralstelle ViVA, QS, INPOL-Z, ViVA-Büro LKA
|
||||||
|
Dezernat 51
|
||||||
|
Chemie, Physik
|
||||||
|
TD 51.1 - Schussspuren, Explosivstoffe, Brand,
|
||||||
|
Elektrotechnik
|
||||||
|
TD 51.2 - Betäubungsmittel
|
||||||
|
Dezernat 61
|
||||||
|
Kriminalitätsangelegenheiten der KPB,
|
||||||
|
Fachcontrolling, Koordination PUA,
|
||||||
|
Lagedienst
|
||||||
|
TD 61.1 - Kriminalitätsangelegenheiten
|
||||||
|
der KPB, Fachcontrolling, Koordination
|
||||||
|
PUA
|
||||||
|
SG 61.2 - Lagedienst
|
||||||
|
Dezernat 55
|
||||||
|
Waffen und Werkzeug
|
||||||
|
DNA-Analyse-Datei
|
||||||
|
SG 55.1 - Waffen und Munition
|
||||||
|
SG 55.2 - Werkzeug-, Form-, Passspuren,
|
||||||
|
Schließ- und Sicherungseinrichtungen
|
||||||
|
SG 55.3 - DNA-Analyse-Datei
|
||||||
|
Dezernat 52
|
||||||
|
Serologie, DNA-Analytik
|
||||||
|
TD 52.1 - DNA-Probenbearbeitung,
|
||||||
|
DNA-Spurenbearbeitung I
|
||||||
|
TD 52.2 DNA-Spurenbearbeitung II
|
||||||
|
TD 52.3 DNA-Spurenbearbeitung III
|
||||||
|
TD 52.4 - DNA-Spurenbearbeitung IV
|
||||||
|
DNA-Fremdvergabe
|
||||||
|
Dezernat 41
|
||||||
|
Zentrale Ansprechstelle Cybercrime, Grundsatz,
|
||||||
|
Digitale Forensik, IT-Entwicklung
|
||||||
|
SG 41.1 - Grundsatzangelegenheiten, Prävention,
|
||||||
|
Auswertung Cybercrime
|
||||||
|
TD 41.2 - Software und KI-Entwicklung,
|
||||||
|
IT-Verfahrensbetreuung, Marktschau
|
||||||
|
SG 41.3 -Forensik Desktop
|
||||||
|
Strategie und Entwicklung - Hinweisportal NRW
|
||||||
|
SG 41.4 Forensik Desktop
|
||||||
|
Datenaufbereitung und Automatisierung
|
||||||
|
TD 41.5 Forensik Desktop
|
||||||
|
Betrieb
|
||||||
|
Dezernat 53
|
||||||
|
Biologie, Materialspuren, Urkunden, Handschriften
|
||||||
|
TD 53.1 - forensische Textilkunde, Botanik,
|
||||||
|
Material-, Haar- und Erdspuren
|
||||||
|
SG 53.2 - Urkunden, Handschriften
|
||||||
|
Dezernat 54
|
||||||
|
Zentralstelle Kriminaltechnik, Forensische
|
||||||
|
Medientechnik,
|
||||||
|
USBV Entschärfung
|
||||||
|
SG 54.1 - Zentralstelle Kriminaltechnik
|
||||||
|
SG 54.2 -Tatortvermessung, Rekonstruktion und
|
||||||
|
XR-Lab, Phantombilderstellung
|
||||||
|
und visuelle Fahndungshilfe, Bild- und
|
||||||
|
Videotechnik
|
||||||
|
SG 54.3 - Entschärfung von unkonventionellen
|
||||||
|
Spreng- und Brandvorrichtungen
|
||||||
|
(USBV) – Einsatz- und Ermittlungsunterstützung
|
||||||
|
Explosivstoffe
|
||||||
|
Völklinger Straße 49, 40221 Düsseldorf
|
||||||
|
Telefon +49 211 939 - 0
|
||||||
|
Telefax +49 211 939 - 6599
|
||||||
|
E-Mail Poststelle.LKA@polizei.nrw.de
|
||||||
|
Internet https://lka.polizei.nrw.de
|
||||||
|
Dezernat 16
|
||||||
|
Finanzierung Organisierter Kriminalität und
|
||||||
|
Terrorismus
|
||||||
|
SG 16.1 - Grundsatzfragen/Auswertung/
|
||||||
|
Analyse
|
||||||
|
Ermittlungskommissionen
|
||||||
|
Dezernat 25
|
||||||
|
PMK Links, Ausländische Ideologie, ZSÜ
|
||||||
|
SG 25.1 - KoSt Gefährder Links, GETZ-Links
|
||||||
|
NRW, Landesvertreter GETZ-Links Bund,
|
||||||
|
Prüffallsachbearbeitung, Gefahrensachverhalte
|
||||||
|
SG 25.2 - KoSt Gefährder Ausländische Ideologie
|
||||||
|
(AI), Landesvertreter GETZ-AI Bund,
|
||||||
|
Prüffallsachbearbeitung, Gefahrensachverhalte
|
||||||
|
SG 25.3 - Zentrale Stelle NRW für ZSÜ
|
||||||
|
Dezernat 24
|
||||||
|
PMK Religiöse Ideologie
|
||||||
|
SG 24.1 - KoST Gefährder, SiKo
|
||||||
|
TD 24.2 - Gemeinsames
|
||||||
|
Terrorismusabwehrzentrum
|
||||||
|
(GTAZ) NRW, Landesvertreter GTAZ Bund,
|
||||||
|
ATD/RED
|
||||||
|
SG 24.3 - Prüffallbearbeitung,
|
||||||
|
Gefahrensachverhalte, islamistisch-terroristisches
|
||||||
|
Personenpotential
|
||||||
|
Dezernat 14
|
||||||
|
Auswerte- und Analysestelle OK
|
||||||
|
TD 14.1 - Operative Auswertung und Analyse,
|
||||||
|
kryptierte Täterkommunikation
|
||||||
|
SG 14.2 - Strategische Auswertung und Analyse,
|
||||||
|
Informationssteuerung
|
||||||
|
SG 14.3 - Technische Informationssysteme,
|
||||||
|
Unterstützungsgruppe CASE/DAR
|
||||||
|
SG 14.4 - Auswertung und Analyse
|
||||||
|
Rockerkriminalität
|
||||||
|
SG 14.5 Auswertung und Analyse Clankriminalität
|
||||||
|
Dezernat 15
|
||||||
|
Korruption, Umweltkriminalität
|
||||||
|
SG 15.1 - Grundsatzangelegenheiten, Korruption
|
||||||
|
SG 15.2 - Vernetzungsstelle Umweltkriminalität
|
||||||
|
Ermittlungskommissionen
|
||||||
|
Abteilung 1
|
||||||
|
Organisierte Kriminalität
|
||||||
|
Abteilung 2
|
||||||
|
Terrorismusbekämpfung und Staatsschutz
|
||||||
|
Dezernat 21
|
||||||
|
Ermittlungen
|
||||||
|
TD 21.1 - Ermittlungskommissionen VSTGB
|
||||||
|
Ermittlungskommissionen PMK (alle
|
||||||
|
Phänomenbereiche), VsnL
|
||||||
|
Dezernat 12
|
||||||
|
Wirtschaftskriminalität
|
||||||
|
SG 12.1 - Grundsatzfragen/Koordination/
|
||||||
|
Auswertung
|
||||||
|
Ermittlungskommissionen
|
||||||
|
Dezernat 22
|
||||||
|
Auswertung/Analyse, ZMI, Open Source
|
||||||
|
Intelligence (OSINT), Wissenschaftlicher Dienst
|
||||||
|
PMK, KPMD-PMK
|
||||||
|
SG 22.1 - Auswertung/Analyse, ZMI
|
||||||
|
TD 22.2 - Open Source Intelligence (OSINT)
|
||||||
|
TD 22.3 - Wissenschaftlicher
|
||||||
|
Dienst PMK
|
||||||
|
SG 22.4 - PMK Meldedienste, Kriminalpolizeilicher
|
||||||
|
Meldedienst (KPMD)
|
||||||
|
Dezernat 13
|
||||||
|
Finanzermittlungen
|
||||||
|
SG 13.1 - GFG 1
|
||||||
|
SG 13.2 - GFG 2
|
||||||
|
SG 13.3 - Verfahrensintegrierte
|
||||||
|
Finanzermittlungen/ Vermögensabschöpfung
|
||||||
|
SG 13.4 - Zentrale Informations- und
|
||||||
|
Koordinierungsstelle Finanzermittlung und
|
||||||
|
Gewinnabschöpfung,
|
||||||
|
Recherchestelle ZIVED
|
||||||
|
Ermittlungskommissionen
|
||||||
|
Dezernat 23
|
||||||
|
PMK Rechts und PMK SZ
|
||||||
|
SG 23.1 - KoSt Gefährder, Gemeinsames
|
||||||
|
Extremismus- und Terrorismusabwehrzentrum
|
||||||
|
(GETZ-) Rechts, Landesvertreter GETZ-Rechts
|
||||||
|
Bund
|
||||||
|
SG 23.2 - Prüffallbearbeitung,
|
||||||
|
Gefahrensachverhalte, Hasskriminalität, PMK
|
||||||
|
rechts
|
||||||
|
TD 23.3 PMK SZ, Spionage,
|
||||||
|
Gefahrensachverhalte PMK SZ, Landesvertreter
|
||||||
|
GETZ-SP
|
||||||
|
Schwerbehindertenvertretung
|
||||||
|
Zentralabteilung
|
||||||
|
Dezernat ZA 5
|
||||||
|
Polizeiärztlicher Dienst
|
||||||
|
Polizeiärztin
|
||||||
|
Direktor LKA NRW
|
||||||
|
Extremismusbeauftragter
|
||||||
|
Extremismusbeauftragter Vertreter
|
||||||
|
Datenschutzbeauftragter
|
||||||
|
Gleichstellungsbeauftragte
|
||||||
|
Inklusionsbeauftragter
|
||||||
|
Informationssicherheitsbeauftragter
|
||||||
|
Geheimschutzbeauftragte
|
||||||
|
Innenrevision
|
||||||
|
Fachkräfte für Arbeitssicherheit
|
||||||
|
Leitungsstab
|
||||||
|
SG LStab 1 - Grundsatzangelegenheiten, Gremien, internationale polizeiliche Zusammen-
|
||||||
|
arbeit, Informations- und Vorgangssteuerung, Einsatz/BAO
|
||||||
|
SG LStab 2 - Strategische Steuerung, Qualitätsmanagement, Controlling,
|
||||||
|
Wissenmanagement
|
||||||
|
SG LStab 3 - Presse-und Öffentlichkeitsarbeit
|
||||||
|
Personalrat
|
||||||
|
Abteilung 3
|
||||||
|
Strategische Kriminalitätsbekämpfung
|
||||||
|
Abteilung 6
|
||||||
|
Fachaufsicht und
|
||||||
|
Ermittlungsunterstützung
|
||||||
|
Abteilung 5
|
||||||
|
Kriminalwissenschaftliches und
|
||||||
|
-technisches Institut
|
||||||
|
Abteilung 4
|
||||||
|
Cybercrime (CCCC)
|
||||||
|
Dezernat 35
|
||||||
|
Verhaltensanalyse und Risikomanagement
|
||||||
|
SG 35.1 - Zentralstelle KURS NRW
|
||||||
|
SG 35.2 - Operative Fallanalyse (OFA/ViCLAS)
|
||||||
|
SG 35.3 - Zentralstelle PeRiskoP
|
||||||
|
Dezernat 56
|
||||||
|
Daktyloskopie, Gesichts- und Sprechererkennung,
|
||||||
|
Tonträgerauswertung
|
||||||
|
SG 56.1 - Daktyloskopisches Labor
|
||||||
|
SG 56.2 - AFIS I/Daktyloskopische Gutachten
|
||||||
|
SG 56.3 - AFIS II/Daktyloskopische Gutachten
|
||||||
|
TD 56.4 - Gesichts- und Sprechererkennung,
|
||||||
|
Tonträgerauswertung
|
||||||
|
Dezernat 43
|
||||||
|
Zentrale Auswertungs- und Sammelstelle (ZASt)
|
||||||
|
für die Bekämpfung von Missbrauchsabbildungen
|
||||||
|
von Kindern und Jugendlichen
|
||||||
|
SG 43.1 - ZASt Grundsatz,
|
||||||
|
Identifizierungsverfahren,
|
||||||
|
Bildvergleichssammlung, Schulfahndung,
|
||||||
|
Berichtswesen, Meldedienste/Verbundverfahren,
|
||||||
|
Gremien
|
||||||
|
SG 43.2 - ZASt NCMEC/ Landeszentrale
|
||||||
|
Bewertung 1
|
||||||
|
SG 43.3 - ZASt NCMEC/ Landeszentrale
|
||||||
|
Bewertung 2
|
||||||
|
SG 43.4 - ZASt NCMEC/ Landeszentrale
|
||||||
|
Bewertung 3
|
||||||
|
Dezernat 11
|
||||||
|
Ermittlungen OK, OK Rauschgift
|
||||||
|
SG 11.1 - Grundsatzfragen/ Koordination/
|
||||||
|
Auswertung
|
||||||
|
Ermittlungskommissionen
|
||||||
|
Dezernat ZA 2
|
||||||
|
Personalangelegenheiten, Gleichstellungs-
|
||||||
|
beauftragte, Fortbildung
|
||||||
|
SG ZA 2.1 - Personalentwicklung, Arbeitszeit,
|
||||||
|
BGM-POL; Stellenplan
|
||||||
|
SG ZA 2.2 - Personalverwaltung Beamte/
|
||||||
|
dienstrechtliche Angelegenheiten
|
||||||
|
TD ZA 2.3 - Personalverwaltung
|
||||||
|
Regierungsbeschäftigte/ Personalgewinnung, tarif-
|
||||||
|
und arbeitsrechtliche Angelegenheiten
|
||||||
|
SG ZA 2.4 - Fortbildung
|
||||||
|
Dezernat ZA 4
|
||||||
|
Vereins- und Wafffenrecht, Innenrevision,
|
||||||
|
Sponsoring, Rechtsangelegenheiten, Datenschutz,
|
||||||
|
Geheimschutz
|
||||||
|
Dezernat ZA 1
|
||||||
|
Haushalts-, Wirtschafts-,
|
||||||
|
Liegenschaftsmanagement, Zentrale
|
||||||
|
Vergabestelle
|
||||||
|
SG ZA 1.1 - Haushalts- und
|
||||||
|
Wirtschaftsangelegenheiten
|
||||||
|
SG ZA 1.2 - Liegenschaftsmanagement
|
||||||
|
SG ZA 1.3 - Zentrale Vergabestelle
|
||||||
|
Dezernat ZA 3
|
||||||
|
Informationstechnik und Anwenderunterstützung,
|
||||||
|
Informationssicherheit, Kfz-, Waffen- und
|
||||||
|
Geräteangelegenheiten;
|
||||||
|
SG ZA 3.1 - IT-Grundsatz, Planung und
|
||||||
|
Koordinierung, IT-Service Desk,
|
||||||
|
Lizenzmanagement
|
||||||
|
SG ZA 3.2 - Netzwerk/ TK-Anlage
|
||||||
|
SG ZA 3.3 - Server/Client, Logistik
|
||||||
|
SG ZA 3.4 - Kfz-, Waffen- und
|
||||||
|
Geräteangelegenheiten
|
||||||
|
Dezernat 44
|
||||||
|
Telekommunikatonsüberwachung (TKÜ)
|
||||||
|
SG 44.1 - Grundsatzaufgaben, operative TKÜ,
|
||||||
|
AIT
|
||||||
|
SG 44.2 - TKÜ Betrieb und Service
|
||||||
|
TD 44.3 - Digitale Forensik,
|
||||||
|
IUK-Ermittlungsunterstützung
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren