diff --git a/admin-panel/src/views/OrganizationEditor.tsx b/admin-panel/src/views/OrganizationEditor.tsx index de41915..5dfa310 100644 --- a/admin-panel/src/views/OrganizationEditor.tsx +++ b/admin-panel/src/views/OrganizationEditor.tsx @@ -87,6 +87,15 @@ export default function OrganizationEditor() { const [showImportDialog, setShowImportDialog] = useState(false) const [uploadProgress, setUploadProgress] = useState(null) const fileInputRef = useRef(null) + const [clearExisting, setClearExisting] = useState(false) + const [issues, setIssues] = useState<{ orphans: Set }>({ orphans: new Set() }) + const [showValidation, setShowValidation] = useState(false) + const [reparentMode, setReparentMode] = useState<{ source: Node | null }>({ source: null }) + const [preview, setPreview] = useState(null) + const [pendingOverrides, setPendingOverrides] = useState>({}) + const [rememberRules, setRememberRules] = useState(true) + const [selectedFile, setSelectedFile] = useState(null) + const [parentPicker, setParentPicker] = useState<{ open: boolean; target?: string; suggestions: any[] }>({ open: false, target: undefined, suggestions: [] }) const [formData, setFormData] = useState({ name: '', code: '', @@ -111,6 +120,7 @@ export default function OrganizationEditor() { const { nodes: flowNodes, edges: flowEdges } = convertToFlowElements(units) setNodes(flowNodes) setEdges(flowEdges) + computeIssues(flowNodes) } } catch (error) { console.error('Failed to load organization:', error) @@ -202,7 +212,13 @@ export default function OrganizationEditor() { ) 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) => { @@ -249,6 +265,7 @@ export default function OrganizationEditor() { const handleFileUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return + setSelectedFile(file) if (file.type !== 'application/pdf') { alert('Bitte laden Sie nur PDF-Dateien hoch.') @@ -257,24 +274,21 @@ export default function OrganizationEditor() { const formData = new FormData() 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 { const response = await api.post('/organization/import-pdf', formData, { headers: { 'Content-Type': 'multipart/form-data' - } + }, + params: { previewOnly: 'true' } }) if (response.data.success) { - setUploadProgress(`Erfolgreich! ${response.data.data.unitsImported} Einheiten importiert.`) - await loadOrganization() - setTimeout(() => { - setShowImportDialog(false) - setUploadProgress(null) - }, 2000) + setPreview(response.data.data) + setUploadProgress(null) } } catch (error: any) { 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 = {} + 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() + 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 = {} + 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() + 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) { return (
@@ -329,6 +574,31 @@ export default function OrganizationEditor() { PDF importieren + + + +
@@ -341,6 +611,9 @@ export default function OrganizationEditor() { )}

Typ: {selectedNode.data.type}

Ebene: {selectedNode.data.level}

+ {issues.orphans.has(selectedNode.id) && ( +

Warnung: Parent fehlt (Waisen-Knoten)

+ )} {selectedNode.data.description && (

{selectedNode.data.description}

)} @@ -354,51 +627,149 @@ export default function OrganizationEditor() { {/* Import PDF Dialog */} {showImportDialog && (
-
+

PDF Organigramm importieren

-
-
- -

- Laden Sie ein PDF-Dokument mit der Organisationsstruktur hoch. - Das System extrahiert automatisch die Hierarchie. -

- - - - -
- - {uploadProgress && ( -
- {uploadProgress} + {!preview ? ( +
+
+ +

+ Laden Sie ein PDF hoch. Es wird zunächst nur eine Vorschau (Diff) berechnet. +

+ + + + +
- )} -
+ + {uploadProgress && ( +
+ {uploadProgress} +
+ )} +
+ ) : ( +
+
+
+

Neu

+

{preview.counts?.create} Einheiten

+
+ {preview.diff?.create?.slice(0,50).map((u: any)=> ( +
{u.code} – {u.name}
+ ))} +
+
+
+

Änderungen

+

{preview.counts?.update} Einheiten

+
+ {preview.diff?.update?.slice(0,50).map((d: any)=> ( +
{d.incoming.code} – {d.existing.name} → {d.incoming.name}
+ ))} +
+
+
+

Waisen (Parent fehlt)

+

{preview.counts?.orphans} Einheiten

+
+ {preview.diff?.orphans?.map((u: any)=> ( +
+
{u.code} – {u.name}
+ setPendingOverrides(prev => ({...prev, [u.code]: { ...(prev[u.code]||{}), parentCode: e.target.value }}))} + /> + +
+ ))} +
+
+
+ + {uploadProgress && ( +
{uploadProgress}
+ )} +
+ )}
+ {preview && ( + + )} +
+
+
+ )} + + {/* Parent Picker Modal */} + {parentPicker.open && ( +
+
+

Parent auswählen für {parentPicker.target}

+
+ {(parentPicker.suggestions || []).map((s: any) => ( + + ))} +
+
+
@@ -498,6 +869,33 @@ export default function OrganizationEditor() {
)} + + {/* Validation Dialog */} + {showValidation && ( +
+
+

Validierung

+
+
+

Waisen-Knoten (Parent fehlt)

+ {issues.orphans.size === 0 ? ( +

Keine

+ ) : ( +
    + {Array.from(issues.orphans).map(id => { + const n = nodes.find(n=>n.id===id) + return
  • {n?.data?.code || id} – {n?.data?.name}
  • + })} +
+ )} +
+
+
+ +
+
+
+ )} ) } diff --git a/backend/scripts/extract-pdf.js b/backend/scripts/extract-pdf.js new file mode 100644 index 0000000..c3a3381 --- /dev/null +++ b/backend/scripts/extract-pdf.js @@ -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) }) diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index dbc82e1..d122d5a 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -403,6 +403,19 @@ export function initializeDatabase() { 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 db.exec(` 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_workspace_analytics_date ON workspace_analytics(date); `) -} \ No newline at end of file +} diff --git a/backend/src/routes/organization.ts b/backend/src/routes/organization.ts index 8080a9c..1ee63ee 100644 --- a/backend/src/routes/organization.ts +++ b/backend/src/routes/organization.ts @@ -200,6 +200,8 @@ router.put('/units/:id', body('name').optional().notEmpty().trim(), body('description').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 body('positionX').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' } }) } - 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() + // 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(` UPDATE organizational_units SET name = COALESCE(?, name), @@ -220,6 +245,8 @@ router.put('/units/:id', color = COALESCE(?, color), has_fuehrungsstelle = COALESCE(?, has_fuehrungsstelle), fuehrungsstelle_name = COALESCE(?, fuehrungsstelle_name), + parent_id = COALESCE(?, parent_id), + level = COALESCE(?, level), position_x = COALESCE(?, position_x), position_y = COALESCE(?, position_y), updated_at = ? @@ -230,6 +257,8 @@ router.put('/units/:id', color || null, hasFuehrungsstelle !== undefined ? (hasFuehrungsstelle ? 1 : 0) : null, fuehrungsstelleName || null, + newParentId !== undefined ? newParentId : null, + level !== undefined ? Number(level) : null, positionX !== undefined ? Math.round(Number(positionX)) : null, positionY !== undefined ? Math.round(Number(positionY)) : null, now, diff --git a/backend/src/routes/organizationImport.ts b/backend/src/routes/organizationImport.ts index 66ba07e..0b73443 100644 --- a/backend/src/routes/organizationImport.ts +++ b/backend/src/routes/organizationImport.ts @@ -28,153 +28,179 @@ const upload = multer({ // Helper to parse organizational structure from PDF text function parseOrganizationFromText(text: string) { const units: any[] = [] + const byCode = new Map() const lines = text.split('\n').map(line => line.trim()).filter(line => line && line.length > 2) - - // Pattern matching for different organizational levels + const patterns = { direktor: /(Direktor|Director)\s*(LKA)?/i, - abteilung: /^Abteilung\s+(\d+)/i, - dezernat: /^Dezernat\s+([\d]+)/i, + abteilung: /^Abteilung\s+(\d+|Zentralabteilung)/i, + dezernat: /^(?:Dezernat|Dez)\s+([\d]+)/i, sachgebiet: /^SG\s+([\d\.]+)/i, teildezernat: /^TD\s+([\d\.]+)/i, stabsstelle: /(Leitungsstab|LStab|Führungsgruppe)/i, - fahndung: /Fahndungsgruppe/i, - sondereinheit: /(Personalrat|Schwerbehindertenvertretung|beauftragt|Innenrevision|IUK-Lage)/i + sondereinheit: /(Personalrat|Schwerbehindertenvertretung|Datenschutzbeauftrag|Gleichstellungsbeauftrag|Innenrevision|IUK-Lage)/i } - - // Color mapping for departments + const colors: Record = { - '1': '#dc2626', - '2': '#ea580c', - '3': '#0891b2', - '4': '#7c3aed', - '5': '#0d9488', - '6': '#be185d', - 'ZA': '#6b7280' + '1': '#dc2626', '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 currentDezernat: any = null - - lines.forEach(line => { - // Check for Direktor + + // Always ensure root + 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)) { - units.push({ - code: 'DIR', - name: 'Direktor LKA NRW', - type: 'direktion', - level: 0, - parentId: null, - color: '#1e3a8a' - }) + ensure({ code: 'DIR', name: 'Direktor LKA NRW', type: 'direktion', level: 0, parentId: null, color: '#1e3a8a' }) + continue } - - // Check for Abteilung + const abtMatch = line.match(/Abteilung\s+(\d+|Zentralabteilung)/i) if (abtMatch) { const abtNum = abtMatch[1] === 'Zentralabteilung' ? 'ZA' : abtMatch[1] - const abtName = line.replace(/^Abteilung\s+\d+\s*[-–]\s*/, '') - currentAbteilung = { + const abtName = line.replace(/^Abteilung\s+(?:\d+|Zentralabteilung)\s*-?\s*/i, '').trim() || `Abteilung ${abtNum}` + currentAbteilung = ensure({ code: `Abt ${abtNum}`, - name: abtName || `Abteilung ${abtNum}`, + name: abtName, type: 'abteilung', level: 1, parentId: 'DIR', color: colors[abtNum] || '#6b7280', hasFuehrungsstelle: abtNum !== 'ZA' - } - units.push(currentAbteilung) + }) currentDezernat = null + continue } - - // Check for Dezernat - const dezMatch = line.match(/(?:Dezernat|Dez)\s+([\d\s]+)/i) - if (dezMatch && currentAbteilung) { + + const dezMatch = line.match(/^(?:Dezernat|Dez)\s+([\d]+)/i) + if (dezMatch) { const dezNum = dezMatch[1].trim() - const dezName = line.replace(/^(?:Dezernat|Dez)\s+[\d\s]+\s*[-–]?\s*/, '').trim() - currentDezernat = { - code: `Dez ${dezNum}`, - name: dezName || `Dezernat ${dezNum}`, - type: 'dezernat', - level: 2, - parentId: currentAbteilung.code + const dezName = line.replace(/^(?:Dezernat|Dez)\s+[\d]+\s*-?\s*/i, '').trim() || `Dezernat ${dezNum}` + // Strict mapping: first digit of dezNum determines Abteilung + const inferredAbt = inferAbteilungCode(dezNum) + let parentCode: string | null = null + 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' }) + 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) - if (sgMatch && currentDezernat) { - const sgNum = sgMatch[1] - const sgName = line.replace(/^SG\s+[\d\.]+\s*[-–]?\s*/, '').trim() - units.push({ - code: `SG ${sgNum}`, - name: sgName || `Sachgebiet ${sgNum}`, - type: 'sachgebiet', - level: 3, - parentId: currentDezernat.code - }) + + const sgMatch = line.match(/^SG\s+([\d\.]+)/i) + if (sgMatch) { + const sgNum = sgMatch[1].trim() + const sgName = line.replace(/^SG\s+[\d\.]+\s*-?\s*/i, '').trim() || `Sachgebiet ${sgNum}` + // Strict mapping: SG NN.X -> Dez NN under Abt N + const prefix = sgNum.split('.')[0] + let dezCode: string | null = null + if (prefix) { + dezCode = `Dez ${prefix}` + const inferredAbt = inferAbteilungCode(prefix) + 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) - if (tdMatch && currentDezernat) { - const tdNum = tdMatch[1] - const tdName = line.replace(/^TD\s+[\d\.]+\s*[-–]?\s*/, '').trim() - units.push({ - code: `TD ${tdNum}`, - name: tdName || `Teildezernat ${tdNum}`, - type: 'teildezernat', - level: 3, - parentId: currentDezernat.code - }) + + const tdMatch = line.match(/^TD\s+([\d\.]+)/i) + if (tdMatch) { + const tdNum = tdMatch[1].trim() + const tdName = line.replace(/^TD\s+[\d\.]+\s*-?\s*/i, '').trim() || `Teildezernat ${tdNum}` + // Strict mapping: TD NN.X -> Dez NN under Abt N + const prefix = tdNum.split('.')[0] + let dezCode: string | null = null + if (prefix) { + dezCode = `Dez ${prefix}` + const inferredAbt = inferAbteilungCode(prefix) + 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)) { - units.push({ - code: 'LStab', - name: 'Leitungsstab', - type: 'stabsstelle', - level: 1, - parentId: 'DIR', - color: '#6b7280' - }) + ensure({ code: 'LStab', name: 'Leitungsstab', type: 'stabsstelle', level: 1, parentId: 'DIR', color: '#6b7280' }) + continue } - - // Check for Sondereinheiten (non-hierarchical) + if (patterns.sondereinheit.test(line)) { let code = 'SE' let name = line - - if (line.includes('Personalrat')) { - code = 'PR' - name = 'Personalrat' - } else if (line.includes('Schwerbehindertenvertretung')) { - code = 'SBV' - name = 'Schwerbehindertenvertretung' - } 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' - }) + if (/Personalrat/i.test(line)) { code = 'PR'; name = 'Personalrat' } + else if (/Schwerbehindertenvertretung/i.test(line)) { code = 'SBV'; name = 'Schwerbehindertenvertretung' } + else if (/Datenschutzbeauftrag/i.test(line)) { code = 'DSB'; name = 'Datenschutzbeauftragter' } + else if (/Gleichstellungsbeauftrag/i.test(line)) { code = 'GSB'; name = 'Gleichstellungsbeauftragte' } + else if (/Innenrevision/i.test(line)) { code = 'IR'; name = 'Innenrevision' } + ensure({ code, name, type: 'sondereinheit', level: 1, parentId: null, color: '#059669' }) + continue } - }) - + } + + // 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 } @@ -229,7 +255,24 @@ router.post('/import-pdf', } // 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 = {} + 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) { return res.status(400).json({ @@ -238,29 +281,84 @@ router.post('/import-pdf', }) } - // Clear existing organization (optional - could be a parameter) - if (req.body.clearExisting === 'true') { + // PREVIEW MODE: if requested, compute diff and return without writing + 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 = {} + 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() + 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() } // Prepare insert/update with stable parent references and FK-safe order - const now = new Date().toISOString() const unitIdMap: Record = {} - - // Preload existing IDs by code for (const unit of parsedUnits) { const existing = db.prepare('SELECT id FROM organizational_units WHERE code = ?').get(unit.code) as any 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 tx = db.transaction(() => { sorted.forEach((unit, index) => { const parentId = unit.parentId ? unitIdMap[unit.parentId] : null const existing = db.prepare('SELECT id FROM organizational_units WHERE code = ?').get(unit.code) as any - if (existing) { db.prepare(` UPDATE organizational_units @@ -303,8 +401,20 @@ router.post('/import-pdf', } }) }) - 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({ success: true, diff --git a/organigramm_text.txt b/organigramm_text.txt new file mode 100644 index 0000000..d90f420 --- /dev/null +++ b/organigramm_text.txt @@ -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 \ No newline at end of file