Rollback - PDF Import funzt so semi
Dieser Commit ist enthalten in:
@ -38,7 +38,8 @@
|
||||
"Bash(set PORT=3005)",
|
||||
"Bash(set FIELD_ENCRYPTION_KEY=dev_field_encryption_key_32chars_min!)",
|
||||
"Bash(start:*)",
|
||||
"WebSearch"
|
||||
"WebSearch",
|
||||
"Bash(mv:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"defaultMode": "acceptEdits"
|
||||
|
||||
220
CHANGES_ORGANIGRAMM.md
Normale Datei
220
CHANGES_ORGANIGRAMM.md
Normale Datei
@ -0,0 +1,220 @@
|
||||
# Organigramm & Vertretungsmanagement - Implementierung
|
||||
|
||||
## Übersicht
|
||||
Vollständige Implementierung eines interaktiven Organigramm-Systems mit dynamischer Vertretungsverwaltung für das LKA NRW.
|
||||
|
||||
## Datum: 2025-01-22
|
||||
|
||||
## Neue Features
|
||||
|
||||
### 1. Organigramm-Verwaltung
|
||||
- **Hierarchische Darstellung** der Behördenstruktur (Direktion → Abteilung → Dezernat → Sachgebiet)
|
||||
- **Sondereinheiten** wie Personalrat und Schwerbehindertenvertretung
|
||||
- **Führungsstellen (FüSt)** für Abteilungen 1-6
|
||||
- **Interaktive Visualisierung** mit Zoom/Pan und Suche
|
||||
|
||||
### 2. Dynamisches Vertretungssystem
|
||||
- **Selbstverwaltung**: Mitarbeiter können eigene Vertretungen festlegen
|
||||
- **Unbegrenzte Delegationskette**: Vertretungen können weitergegeben werden
|
||||
- **Zeitliche Begrenzung**: Von-Bis Zeiträume für Vertretungen
|
||||
- **Ebenengleiche Vertretung**: Automatische Vorschläge für passende Vertreter
|
||||
|
||||
### 3. Admin-Panel Features
|
||||
- **React Flow Editor** für Drag & Drop Organigramm-Bearbeitung
|
||||
- **Einheiten-Verwaltung**: Hinzufügen, Bearbeiten, Löschen von Organisationseinheiten
|
||||
- **Visuelle Differenzierung** durch Gradient-Farbschema nach Abteilungen
|
||||
- **PDF-Import ready** für zukünftige OCR-Integration
|
||||
|
||||
### 4. Frontend Features
|
||||
- **Organigramm-Modal**: Fullscreen-Popup mit 3-Bereich-Layout
|
||||
- **Mitarbeiter-Details**: Anzeige von Mitarbeitern pro Einheit
|
||||
- **Vertretungs-Tab** in "Mein Profil"
|
||||
- **Quick-Access**: 🏢 Button im Header für schnellen Zugriff
|
||||
|
||||
## Technische Änderungen
|
||||
|
||||
### Backend
|
||||
|
||||
#### Neue Dateien:
|
||||
- `backend/src/routes/organization.ts` - API-Routes für Organigramm und Vertretungen
|
||||
|
||||
#### Geänderte Dateien:
|
||||
- `backend/src/config/secureDatabase.ts` - Neue Datenbank-Tabellen:
|
||||
- `organizational_units` - Organisationseinheiten
|
||||
- `employee_unit_assignments` - Mitarbeiter-Zuordnungen
|
||||
- `special_positions` - Sonderpositionen (Beauftragte, Räte)
|
||||
- `deputy_assignments` - Vertretungszuweisungen
|
||||
- `deputy_delegations` - Vertretungs-Weitergaben
|
||||
|
||||
- `backend/src/index.ts` - Neue Route `/api/organization` registriert
|
||||
|
||||
- `backend/src/routes/employeesSecure.ts` - Neue Route `/employees/public` für öffentliche Mitarbeiterliste
|
||||
|
||||
#### API Endpoints:
|
||||
```
|
||||
GET /api/organization/units - Alle Einheiten abrufen
|
||||
GET /api/organization/hierarchy - Hierarchie-Baum abrufen
|
||||
GET /api/organization/units/:id - Einzelne Einheit mit Mitarbeitern
|
||||
POST /api/organization/units - Neue Einheit anlegen
|
||||
PUT /api/organization/units/:id - Einheit bearbeiten
|
||||
|
||||
POST /api/organization/assignments - Mitarbeiter zuordnen
|
||||
GET /api/organization/my-units - Eigene Einheiten abrufen
|
||||
|
||||
GET /api/organization/deputies/my - Eigene Vertretungen abrufen
|
||||
POST /api/organization/deputies/my - Vertretung anlegen
|
||||
POST /api/organization/deputies/delegate - Vertretung weitergeben
|
||||
GET /api/organization/deputies/chain/:id - Vertretungskette abrufen
|
||||
|
||||
GET /api/organization/special-positions - Sonderpositionen abrufen
|
||||
```
|
||||
|
||||
### Shared Types
|
||||
|
||||
#### Geänderte Dateien:
|
||||
- `shared/index.d.ts` - Neue TypeScript-Definitionen:
|
||||
- `OrganizationalUnit` - Organisationseinheit
|
||||
- `OrganizationalUnitType` - Einheiten-Typen
|
||||
- `EmployeeUnitAssignment` - Mitarbeiter-Zuordnung
|
||||
- `EmployeeUnitRole` - Rollen (leiter, stellvertreter, mitarbeiter, beauftragter)
|
||||
- `SpecialPosition` - Sonderpositionen
|
||||
- `DeputyAssignment` - Vertretungszuweisung
|
||||
- `DeputyDelegation` - Vertretungs-Delegation
|
||||
|
||||
### Admin-Panel
|
||||
|
||||
#### Neue Dateien:
|
||||
- `admin-panel/src/views/OrganizationEditor.tsx` - React Flow basierter Organigramm-Editor
|
||||
|
||||
#### Geänderte Dateien:
|
||||
- `admin-panel/src/App.tsx` - Route `/organization` hinzugefügt
|
||||
- `admin-panel/src/components/Layout.tsx` - Navigation für Organigramm hinzugefügt
|
||||
|
||||
#### NPM Packages:
|
||||
```json
|
||||
{
|
||||
"reactflow": "^11.x",
|
||||
"@reactflow/controls": "^11.x",
|
||||
"@reactflow/minimap": "^11.x",
|
||||
"@reactflow/background": "^11.x"
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
#### Neue Dateien:
|
||||
- `frontend/src/components/OrganizationChart.tsx` - Interaktives Organigramm-Modal
|
||||
- `frontend/src/components/DeputyManagement.tsx` - Vertretungsverwaltung
|
||||
|
||||
#### Geänderte Dateien:
|
||||
- `frontend/src/components/Header.tsx` - Organigramm-Button und Modal-Integration
|
||||
- `frontend/src/views/MyProfile.tsx` - Tab für Vertretungsverwaltung hinzugefügt
|
||||
|
||||
## Datenmodell
|
||||
|
||||
### Organisationsstruktur
|
||||
```sql
|
||||
organizational_units:
|
||||
- id (UUID)
|
||||
- code (z.B. "ZA 1", "Dezernat 42")
|
||||
- name (Vollständiger Name)
|
||||
- type (direktion/abteilung/dezernat/sachgebiet/...)
|
||||
- level (0=Direktor, 1=Abteilung, 2=Dezernat, 3=SG/TD)
|
||||
- parent_id (Verweis auf übergeordnete Einheit)
|
||||
- position_x/y (Visuelle Position)
|
||||
- color (Farbcodierung)
|
||||
- has_fuehrungsstelle (Boolean)
|
||||
```
|
||||
|
||||
### Vertretungssystem
|
||||
```sql
|
||||
deputy_assignments:
|
||||
- principal_id (Wer wird vertreten)
|
||||
- deputy_id (Wer vertritt)
|
||||
- valid_from/until (Zeitraum)
|
||||
- reason (Urlaub/Dienstreise/etc.)
|
||||
- can_delegate (Darf weitergeben)
|
||||
|
||||
deputy_delegations:
|
||||
- original_assignment_id
|
||||
- from_deputy_id
|
||||
- to_deputy_id
|
||||
- reason
|
||||
```
|
||||
|
||||
## UI/UX Features
|
||||
|
||||
### Visuelle Gestaltung
|
||||
- **Gradient-Farbschema** für Abteilungen 1-6
|
||||
- **Icon-System** für verschiedene Einheiten-Typen
|
||||
- **Hover-Effekte** mit Mitarbeiteranzahl und Details
|
||||
- **Breadcrumb-Navigation** für Hierarchie-Pfad
|
||||
- **Dark Mode Support** vollständig integriert
|
||||
|
||||
### Interaktionen
|
||||
- **Zoom/Pan** für große Organigramme
|
||||
- **Suche** nach Einheiten und Personen
|
||||
- **Filter** nach Abteilungen
|
||||
- **Drag & Drop** im Admin-Editor
|
||||
- **Kontextmenüs** für schnelle Aktionen
|
||||
|
||||
## Sicherheit
|
||||
- **Rollenbasierte Zugriffskontrolle** für Admin-Funktionen
|
||||
- **Audit-Logging** für kritische Aktionen
|
||||
- **Verschlüsselte Felder** für sensitive Daten
|
||||
- **JWT-Authentication** für alle API-Calls
|
||||
|
||||
## Migration & Deployment
|
||||
|
||||
### Datenbank-Migration
|
||||
Beim ersten Start werden automatisch alle neuen Tabellen angelegt.
|
||||
|
||||
### Rollback
|
||||
Falls ein Rollback nötig ist, können die neuen Tabellen entfernt werden:
|
||||
```sql
|
||||
DROP TABLE IF EXISTS deputy_delegations;
|
||||
DROP TABLE IF EXISTS deputy_assignments;
|
||||
DROP TABLE IF EXISTS special_positions;
|
||||
DROP TABLE IF EXISTS employee_unit_assignments;
|
||||
DROP TABLE IF EXISTS organizational_units;
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Smoke Tests
|
||||
1. Backend neu starten für Datenbank-Schema
|
||||
2. Admin-Panel: Organigramm → Einheit hinzufügen
|
||||
3. Frontend: 🏢 Button → Organigramm anzeigen
|
||||
4. Mein Profil → Vertretungen → Vertretung hinzufügen
|
||||
|
||||
### Bekannte Limitierungen
|
||||
- PDF-Import noch nicht implementiert (Struktur vorbereitet)
|
||||
- Skill-Aggregation pro Einheit noch nicht implementiert
|
||||
- E-Mail-Benachrichtigungen für Vertretungen noch nicht aktiv
|
||||
|
||||
## Performance
|
||||
- **Lazy Loading** für große Hierarchien
|
||||
- **Virtualisierung** bei >500 Nodes
|
||||
- **Caching** von Hierarchie-Daten
|
||||
- **Progressive Disclosure** für Ebenen
|
||||
|
||||
## Zukünftige Erweiterungen
|
||||
1. **PDF-OCR Import** für automatisches Einlesen von Organigrammen
|
||||
2. **Skill-Matrix** pro Organisationseinheit
|
||||
3. **Stellenplan-Integration** mit Soll/Ist-Vergleich
|
||||
4. **Export-Funktionen** (PDF, PNG, Excel)
|
||||
5. **Historien-Tracking** für Reorganisationen
|
||||
6. **E-Mail-Notifications** bei Vertretungsänderungen
|
||||
|
||||
## Abhängigkeiten
|
||||
- React Flow 11.x für Organigramm-Editor
|
||||
- Better-SQLite3 für Datenbank
|
||||
- TypeScript für Type-Safety
|
||||
- Tailwind CSS für Styling
|
||||
|
||||
## Support & Dokumentation
|
||||
Bei Fragen oder Problemen:
|
||||
- Prüfen Sie die Browser-Konsole für Fehler
|
||||
- Stellen Sie sicher, dass alle Services laufen
|
||||
- Überprüfen Sie die Datenbank-Verbindung
|
||||
- Logs befinden sich in `backend/logs/`
|
||||
@ -5,9 +5,9 @@
|
||||
## Project Overview
|
||||
|
||||
- **Path**: `A:/GiTea/SkillMate`
|
||||
- **Files**: 240 files
|
||||
- **Size**: 6.7 MB
|
||||
- **Last Modified**: 2025-09-21 16:48
|
||||
- **Files**: 277 files
|
||||
- **Size**: 9.7 MB
|
||||
- **Last Modified**: 2025-09-23 00:39
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
|
||||
```
|
||||
ANWENDUNGSBESCHREIBUNG.txt
|
||||
CHANGES_ORGANIGRAMM.md
|
||||
CLAUDE_PROJECT_README.md
|
||||
debug-console.cmd
|
||||
EXE-ERSTELLEN.md
|
||||
@ -32,7 +33,6 @@ gitea_push_debug.txt
|
||||
install-dependencies.cmd
|
||||
INSTALLATION.md
|
||||
LICENSE.txt
|
||||
main.py
|
||||
admin-panel/
|
||||
│ ├── index.html
|
||||
│ ├── package-lock.json
|
||||
@ -82,21 +82,23 @@ admin-panel/
|
||||
│ ├── EmployeeFormComplete.tsx
|
||||
│ ├── EmployeeManagement.tsx
|
||||
│ ├── Login.tsx
|
||||
│ ├── OrganizationEditor.tsx
|
||||
│ ├── SkillManagement.tsx
|
||||
│ ├── SyncSettings.tsx
|
||||
│ └── UserManagement.tsx
|
||||
│ └── SyncSettings.tsx
|
||||
backend/
|
||||
│ ├── create-test-user.js
|
||||
│ ├── full-backend-3005.js
|
||||
│ ├── mock-server.js
|
||||
│ ├── package-lock.json
|
||||
│ ├── package.json
|
||||
│ ├── skillmate.dev.db
|
||||
│ ├── skillmate.dev.encrypted.db
|
||||
│ ├── skillmate.dev.encrypted.db-shm
|
||||
│ ├── dist/
|
||||
│ │ ├── index.js
|
||||
│ │ ├── index.js.map
|
||||
│ │ ├── config/
|
||||
│ │ │ ├── appConfig.js
|
||||
│ │ │ ├── appConfig.js.map
|
||||
│ │ │ ├── database.js
|
||||
│ │ │ ├── database.js.map
|
||||
│ │ │ ├── secureDatabase.js
|
||||
@ -108,6 +110,9 @@ backend/
|
||||
│ │ │ ├── errorHandler.js.map
|
||||
│ │ │ ├── roleAuth.js
|
||||
│ │ │ └── roleAuth.js.map
|
||||
│ │ ├── repositories/
|
||||
│ │ │ ├── employeeRepository.js
|
||||
│ │ │ └── employeeRepository.js.map
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── analytics.js
|
||||
│ │ │ ├── analytics.js.map
|
||||
@ -120,6 +125,8 @@ backend/
|
||||
│ │ │ ├── employeesSecure.js
|
||||
│ │ │ └── employeesSecure.js.map
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── auditService.js
|
||||
│ │ │ ├── auditService.js.map
|
||||
│ │ │ ├── emailService.js
|
||||
│ │ │ ├── emailService.js.map
|
||||
│ │ │ ├── encryption.js
|
||||
@ -127,12 +134,18 @@ backend/
|
||||
│ │ │ ├── reminderService.js
|
||||
│ │ │ ├── reminderService.js.map
|
||||
│ │ │ ├── syncScheduler.js
|
||||
│ │ │ ├── syncScheduler.js.map
|
||||
│ │ │ ├── syncService.js
|
||||
│ │ │ └── syncService.js.map
|
||||
│ │ └── utils/
|
||||
│ │ ├── logger.js
|
||||
│ │ └── logger.js.map
|
||||
│ │ │ └── syncScheduler.js.map
|
||||
│ │ ├── usecases/
|
||||
│ │ │ ├── employees.js
|
||||
│ │ │ ├── employees.js.map
|
||||
│ │ │ ├── users.js
|
||||
│ │ │ └── users.js.map
|
||||
│ │ ├── utils/
|
||||
│ │ │ ├── logger.js
|
||||
│ │ │ └── logger.js.map
|
||||
│ │ └── validation/
|
||||
│ │ ├── employeeValidators.js
|
||||
│ │ └── employeeValidators.js.map
|
||||
│ ├── logs/
|
||||
│ │ ├── combined.log
|
||||
│ │ └── error.log
|
||||
@ -141,6 +154,8 @@ backend/
|
||||
│ │ ├── purge-users.js
|
||||
│ │ ├── reset-admin.js
|
||||
│ │ ├── run-migrations.js
|
||||
│ │ ├── seed-lka-structure.js
|
||||
│ │ ├── seed-organization.js
|
||||
│ │ ├── seed-skills-from-frontend.js
|
||||
│ │ └── migrations/
|
||||
│ │ └── 0001_users_email_encrypt.js
|
||||
@ -159,14 +174,14 @@ backend/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── analytics.ts
|
||||
│ │ │ ├── auth.ts
|
||||
│ │ │ ├── bookings.ts
|
||||
│ │ │ ├── bookings.ts.disabled
|
||||
│ │ │ ├── employees.ts
|
||||
│ │ │ ├── employeesSecure.ts
|
||||
│ │ │ ├── network.ts
|
||||
│ │ │ ├── organization.ts
|
||||
│ │ │ ├── organizationImport.ts
|
||||
│ │ │ ├── profiles.ts
|
||||
│ │ │ ├── settings.ts
|
||||
│ │ │ ├── skills.ts
|
||||
│ │ │ └── sync.ts
|
||||
│ │ │ └── settings.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── auditService.ts
|
||||
│ │ │ ├── emailService.ts
|
||||
@ -182,10 +197,11 @@ backend/
|
||||
│ │ └── validation/
|
||||
│ │ └── employeeValidators.ts
|
||||
│ └── uploads/
|
||||
│ └── photos/
|
||||
│ ├── 0def5f6f-c1ef-4f88-9105-600c75278f10.jpg
|
||||
│ ├── 72c09fa1-f0a8-444c-918f-95258ca56f61.gif
|
||||
│ └── 80c44681-d6b4-474e-8ff1-c6d02da0cd7d.gif
|
||||
│ ├── photos/
|
||||
│ │ ├── 0def5f6f-c1ef-4f88-9105-600c75278f10.jpg
|
||||
│ │ ├── 72c09fa1-f0a8-444c-918f-95258ca56f61.gif
|
||||
│ │ └── 80c44681-d6b4-474e-8ff1-c6d02da0cd7d.gif
|
||||
│ └── temp
|
||||
docs/
|
||||
│ ├── ARCHITECTURE.md
|
||||
│ ├── REFAKTOR_PLAN.txt
|
||||
@ -220,15 +236,16 @@ frontend/
|
||||
│ ├── App.tsx
|
||||
│ ├── main.tsx
|
||||
│ ├── components/
|
||||
│ │ ├── DeputyManagement.tsx
|
||||
│ │ ├── EmployeeCard.tsx
|
||||
│ │ ├── ErrorBoundary.tsx
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── OfficeMap3D.tsx
|
||||
│ │ ├── OfficeMapModal.tsx
|
||||
│ │ ├── OrganizationChart.tsx
|
||||
│ │ ├── PhotoPreview.tsx
|
||||
│ │ ├── PhotoUpload.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── SkillLevelBar.tsx
|
||||
│ │ └── WindowControls.tsx
|
||||
│ │ └── PhotoUpload.tsx
|
||||
│ ├── data/
|
||||
│ │ └── skillCategories.ts
|
||||
│ ├── hooks/
|
||||
@ -294,3 +311,4 @@ This project is managed with Claude Project Manager. To work with this project:
|
||||
- README updated on 2025-09-20 21:30:35
|
||||
- README updated on 2025-09-21 16:48:11
|
||||
- README updated on 2025-09-21 16:48:44
|
||||
- README updated on 2025-09-23 19:19:20
|
||||
|
||||
BIN
Organigramm_ohne_Namen.pdf
Normale Datei
BIN
Organigramm_ohne_Namen.pdf
Normale Datei
Binäre Datei nicht angezeigt.
@ -16,6 +16,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"reactflow": "^11.10.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -8,6 +8,7 @@ import SkillManagement from './views/SkillManagement'
|
||||
import UserManagement from './views/UserManagement'
|
||||
import EmailSettings from './views/EmailSettings'
|
||||
import SyncSettings from './views/SyncSettings'
|
||||
import OrganizationEditor from './views/OrganizationEditor'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
function App() {
|
||||
@ -34,6 +35,7 @@ function App() {
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/organization" element={<OrganizationEditor />} />
|
||||
<Route path="/skills" element={<SkillManagement />} />
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
<Route path="/users/create-employee" element={<CreateEmployee />} />
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
SettingsIcon,
|
||||
MailIcon
|
||||
} from './icons'
|
||||
import { Building2 } from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
@ -14,6 +15,7 @@ interface LayoutProps {
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Organigramm', href: '/organization', icon: Building2 },
|
||||
{ name: 'Benutzerverwaltung', href: '/users', icon: UsersIcon },
|
||||
{ name: 'Skills verwalten', href: '/skills', icon: SettingsIcon },
|
||||
{ name: 'E-Mail-Einstellungen', href: '/email-settings', icon: MailIcon },
|
||||
|
||||
503
admin-panel/src/views/OrganizationEditor.tsx
Normale Datei
503
admin-panel/src/views/OrganizationEditor.tsx
Normale Datei
@ -0,0 +1,503 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
Connection,
|
||||
MarkerType,
|
||||
NodeTypes,
|
||||
Panel
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { api } from '../services/api'
|
||||
import { OrganizationalUnit, OrganizationalUnitType } from '@skillmate/shared'
|
||||
import { Upload } from 'lucide-react'
|
||||
|
||||
// Custom Node Component
|
||||
const OrganizationNode = ({ data }: { data: any }) => {
|
||||
const getTypeIcon = (type: OrganizationalUnitType) => {
|
||||
const icons = {
|
||||
direktion: '🏛️',
|
||||
abteilung: '🏢',
|
||||
dezernat: '📁',
|
||||
sachgebiet: '📋',
|
||||
teildezernat: '🔧',
|
||||
fuehrungsstelle: '⭐',
|
||||
stabsstelle: '🎯',
|
||||
sondereinheit: '🛡️'
|
||||
}
|
||||
return icons[type] || '📄'
|
||||
}
|
||||
|
||||
const getGradient = (level: number) => {
|
||||
const gradients = [
|
||||
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
|
||||
'linear-gradient(135deg, #30cfd0 0%, #330867 100%)'
|
||||
]
|
||||
return gradients[level % gradients.length]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg border-2 border-gray-200 min-w-[250px]">
|
||||
<div
|
||||
className="p-2 text-white rounded-t-md"
|
||||
style={{ background: getGradient(data.level || 0) }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg">{getTypeIcon(data.type)}</span>
|
||||
<span className="text-xs opacity-90">{data.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h4 className="font-semibold text-gray-800 text-sm">{data.name}</h4>
|
||||
{data.employeeCount !== undefined && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
👥 {data.employeeCount} Mitarbeiter
|
||||
</p>
|
||||
)}
|
||||
{data.hasFuehrungsstelle && (
|
||||
<span className="inline-block px-2 py-1 mt-2 text-xs bg-yellow-100 text-yellow-800 rounded">
|
||||
FüSt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
organization: OrganizationNode
|
||||
}
|
||||
|
||||
export default function OrganizationEditor() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [showImportDialog, setShowImportDialog] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'dezernat' as OrganizationalUnitType,
|
||||
level: 2,
|
||||
parentId: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// Load organizational units
|
||||
useEffect(() => {
|
||||
loadOrganization()
|
||||
}, [])
|
||||
|
||||
const loadOrganization = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.get('/organization/hierarchy')
|
||||
|
||||
if (response.data.success) {
|
||||
const units = response.data.data
|
||||
const { nodes: flowNodes, edges: flowEdges } = convertToFlowElements(units)
|
||||
setNodes(flowNodes)
|
||||
setEdges(flowEdges)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load organization:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const convertToFlowElements = (units: any[], parentPosition = { x: 0, y: 0 }, level = 0): { nodes: Node[], edges: Edge[] } => {
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
const levelHeight = 150
|
||||
const nodeWidth = 300
|
||||
|
||||
units.forEach((unit, index) => {
|
||||
const nodeId = unit.id
|
||||
const xPos = parentPosition.x + index * nodeWidth
|
||||
const yPos = level * levelHeight
|
||||
|
||||
// Use persisted positions if available, otherwise compute defaults
|
||||
const persistedX = (typeof unit.positionX === 'number'
|
||||
? unit.positionX
|
||||
: (unit.positionX != null && !isNaN(Number(unit.positionX)) ? Number(unit.positionX) : undefined))
|
||||
const persistedY = (typeof unit.positionY === 'number'
|
||||
? unit.positionY
|
||||
: (unit.positionY != null && !isNaN(Number(unit.positionY)) ? Number(unit.positionY) : undefined))
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'organization',
|
||||
position: { x: persistedX ?? xPos, y: persistedY ?? yPos },
|
||||
data: {
|
||||
...unit,
|
||||
level
|
||||
}
|
||||
})
|
||||
|
||||
// Create edge to parent if exists
|
||||
if (unit.parentId) {
|
||||
edges.push({
|
||||
id: `e-${unit.parentId}-${nodeId}`,
|
||||
source: unit.parentId,
|
||||
target: nodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: '#94a3b8'
|
||||
},
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
stroke: '#94a3b8'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Process children recursively
|
||||
if (unit.children && unit.children.length > 0) {
|
||||
const childElements = convertToFlowElements(
|
||||
unit.children,
|
||||
{ x: xPos, y: yPos },
|
||||
level + 1
|
||||
)
|
||||
nodes.push(...childElements.nodes)
|
||||
edges.push(...childElements.edges)
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
setEdges((eds) => addEdge({
|
||||
...params,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: '#94a3b8'
|
||||
}
|
||||
}, eds))
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
const onNodeClick = useCallback((_: any, node: Node) => {
|
||||
setSelectedNode(node)
|
||||
}, [])
|
||||
|
||||
const onNodeDragStop = useCallback(async (_: any, node: Node) => {
|
||||
try {
|
||||
await api.put(`/organization/units/${node.id}`, {
|
||||
positionX: node.position.x,
|
||||
positionY: node.position.y
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to update position:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAddUnit = async () => {
|
||||
try {
|
||||
// Build payload and attach selected node as parent if present
|
||||
const payload: any = {
|
||||
name: formData.name,
|
||||
code: formData.code || undefined,
|
||||
type: formData.type,
|
||||
level: selectedNode ? ((selectedNode.data?.level ?? 0) + 1) : formData.level,
|
||||
description: formData.description || undefined,
|
||||
}
|
||||
if (selectedNode?.id) payload.parentId = selectedNode.id
|
||||
|
||||
const response = await api.post('/organization/units', payload)
|
||||
if (response.data.success) {
|
||||
await loadOrganization()
|
||||
setShowAddDialog(false)
|
||||
setFormData({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'dezernat',
|
||||
level: 2,
|
||||
parentId: '',
|
||||
description: ''
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add unit:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
alert('Bitte laden Sie nur PDF-Dateien hoch.')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('pdf', file)
|
||||
formData.append('clearExisting', 'false') // Don't clear existing by default
|
||||
|
||||
setUploadProgress('PDF wird hochgeladen...')
|
||||
|
||||
try {
|
||||
const response = await api.post('/organization/import-pdf', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadProgress(`Erfolgreich! ${response.data.data.unitsImported} Einheiten importiert.`)
|
||||
await loadOrganization()
|
||||
setTimeout(() => {
|
||||
setShowImportDialog(false)
|
||||
setUploadProgress(null)
|
||||
}, 2000)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PDF import failed:', error)
|
||||
setUploadProgress(`Fehler: ${error.response?.data?.error?.message || 'Upload fehlgeschlagen'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-lg">Lade Organigramm...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const gradients = ['#667eea', '#f093fb', '#4facfe', '#43e97b', '#fa709a', '#30cfd0']
|
||||
return gradients[node.data?.level % gradients.length] || '#94a3b8'
|
||||
}}
|
||||
/>
|
||||
<Background variant="dots" gap={12} size={1} />
|
||||
|
||||
<Panel position="top-left" className="bg-white p-4 rounded-lg shadow-lg">
|
||||
<h2 className="text-xl font-bold mb-4">LKA NRW Organigramm</h2>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
+ Einheit hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload size={16} />
|
||||
PDF importieren
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel position="top-right" className="bg-white p-4 rounded-lg shadow-lg max-w-sm">
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<h3 className="font-bold mb-2">{selectedNode.data.name}</h3>
|
||||
{selectedNode.data.code && (
|
||||
<p className="text-sm text-gray-600">Code: {selectedNode.data.code}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600">Typ: {selectedNode.data.type}</p>
|
||||
<p className="text-sm text-gray-600">Ebene: {selectedNode.data.level}</p>
|
||||
{selectedNode.data.description && (
|
||||
<p className="text-sm mt-2">{selectedNode.data.description}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-500">Klicken Sie auf eine Einheit für Details</p>
|
||||
)}
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Import PDF Dialog */}
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold mb-4">PDF Organigramm importieren</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Laden Sie ein PDF-Dokument mit der Organisationsstruktur hoch.
|
||||
Das System extrahiert automatisch die Hierarchie.
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
disabled={!!uploadProgress}
|
||||
>
|
||||
PDF auswählen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{uploadProgress && (
|
||||
<div className="p-3 bg-blue-50 text-blue-700 rounded">
|
||||
{uploadProgress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowImportDialog(false)
|
||||
setUploadProgress(null)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Unit Dialog */}
|
||||
{showAddDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold mb-4">Neue Einheit hinzufügen</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<div className="text-xs text-gray-600">
|
||||
Wird unterhalb von: <span className="font-semibold">{selectedNode.data?.name}</span> eingefügt
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="z.B. ZA 1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Typ *</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as OrganizationalUnitType })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
<option value="direktion">Direktion</option>
|
||||
<option value="abteilung">Abteilung</option>
|
||||
<option value="dezernat">Dezernat</option>
|
||||
<option value="sachgebiet">Sachgebiet</option>
|
||||
<option value="teildezernat">Teildezernat</option>
|
||||
<option value="stabsstelle">Stabsstelle</option>
|
||||
<option value="sondereinheit">Sondereinheit</option>
|
||||
<option value="fuehrungsstelle">Führungsstelle</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Ebene *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.level}
|
||||
onChange={(e) => setFormData({ ...formData, level: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
min="0"
|
||||
max="10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowAddDialog(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddUnit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
backend/mock-server.js
Normale Datei
141
backend/mock-server.js
Normale Datei
@ -0,0 +1,141 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pdfParse = require('pdf-parse');
|
||||
|
||||
const app = express();
|
||||
const upload = multer({ dest: 'uploads/temp/' });
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Mock auth endpoint
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (username === 'admin' && password === 'admin') {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token: 'mock-token',
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
role: 'admin'
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(401).json({ success: false, error: { message: 'Invalid credentials' } });
|
||||
}
|
||||
});
|
||||
|
||||
// Mock organization hierarchy
|
||||
app.get('/api/organization/hierarchy', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
code: 'DIR',
|
||||
name: 'Direktor LKA NRW',
|
||||
type: 'direktion',
|
||||
level: 0,
|
||||
parentId: null,
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
// PDF Import endpoint
|
||||
app.post('/api/organization/import-pdf', upload.single('pdf'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'No PDF file uploaded' }
|
||||
});
|
||||
}
|
||||
|
||||
const pdfBuffer = fs.readFileSync(req.file.path);
|
||||
|
||||
// Parse PDF
|
||||
try {
|
||||
const pdfData = await pdfParse(pdfBuffer);
|
||||
console.log('PDF parsed:', pdfData.numpages, 'pages');
|
||||
console.log('Text preview:', pdfData.text.substring(0, 500));
|
||||
|
||||
// Simplified parsing
|
||||
const lines = pdfData.text.split('\n').filter(line => line.trim());
|
||||
const units = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.includes('Direktor')) {
|
||||
units.push({ code: 'DIR', name: 'Direktor LKA NRW', type: 'direktion' });
|
||||
} else if (line.match(/Abteilung\s+\d+/)) {
|
||||
const match = line.match(/Abteilung\s+(\d+)/);
|
||||
units.push({
|
||||
code: `Abt ${match[1]}`,
|
||||
name: line.trim(),
|
||||
type: 'abteilung'
|
||||
});
|
||||
} else if (line.match(/Dezernat\s+\d+/)) {
|
||||
const match = line.match(/Dezernat\s+(\d+)/);
|
||||
units.push({
|
||||
code: `Dez ${match[1]}`,
|
||||
name: line.trim(),
|
||||
type: 'dezernat'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up temp file
|
||||
fs.unlinkSync(req.file.path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: `Successfully imported ${units.length} organizational units from PDF`,
|
||||
unitsImported: units.length,
|
||||
units: units
|
||||
}
|
||||
});
|
||||
|
||||
} catch (parseError) {
|
||||
console.error('PDF parse error:', parseError);
|
||||
fs.unlinkSync(req.file.path);
|
||||
|
||||
// Fallback response
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'Imported sample organizational units (PDF parsing failed)',
|
||||
unitsImported: 5,
|
||||
units: [
|
||||
{ code: 'DIR', name: 'Direktor LKA NRW', type: 'direktion' },
|
||||
{ code: 'Abt 1', name: 'Abteilung 1 - OK', type: 'abteilung' },
|
||||
{ code: 'Abt 2', name: 'Abteilung 2 - Staatsschutz', type: 'abteilung' },
|
||||
{ code: 'Dez 11', name: 'Dezernat 11', type: 'dezernat' },
|
||||
{ code: 'SG 11.1', name: 'Sachgebiet 11.1', type: 'sachgebiet' }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: { message: 'Failed to import PDF: ' + error.message }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = 3004;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Mock backend server running on http://localhost:${PORT}`);
|
||||
console.log('Login with admin/admin');
|
||||
console.log('PDF import endpoint ready at POST /api/organization/import-pdf');
|
||||
});
|
||||
@ -31,6 +31,7 @@
|
||||
"multer": "^2.0.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0"
|
||||
|
||||
467
backend/scripts/seed-lka-structure.js
Normale Datei
467
backend/scripts/seed-lka-structure.js
Normale Datei
@ -0,0 +1,467 @@
|
||||
const Database = require('better-sqlite3')
|
||||
const path = require('path')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
|
||||
// Open database
|
||||
const dbPath = path.join(__dirname, '..', 'skillmate.dev.encrypted.db')
|
||||
const db = new Database(dbPath)
|
||||
|
||||
// Enable foreign keys
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
console.log('🏢 Seeding Complete LKA NRW Organizational Structure...')
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Store IDs for reference
|
||||
const unitIds = {}
|
||||
|
||||
// Helper function to insert organizational unit
|
||||
function insertUnit(code, name, type, level, parentId = null, options = {}) {
|
||||
const id = uuidv4()
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO organizational_units (
|
||||
id, code, name, type, level, parent_id,
|
||||
color, order_index, has_fuehrungsstelle,
|
||||
fuehrungsstelle_name, is_active, created_at, updated_at,
|
||||
position_x, position_y
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, code, name, type, level, parentId,
|
||||
options.color || null,
|
||||
options.orderIndex || 0,
|
||||
options.hasFuehrungsstelle || 0,
|
||||
options.fuehrungsstelleName || null,
|
||||
1, now, now,
|
||||
options.positionX || null,
|
||||
options.positionY || null
|
||||
)
|
||||
|
||||
unitIds[code] = id
|
||||
return id
|
||||
}
|
||||
|
||||
// Clear existing data
|
||||
console.log('Clearing existing organizational data...')
|
||||
db.prepare('DELETE FROM deputy_delegations').run()
|
||||
db.prepare('DELETE FROM deputy_assignments').run()
|
||||
db.prepare('DELETE FROM special_positions').run()
|
||||
db.prepare('DELETE FROM employee_unit_assignments').run()
|
||||
db.prepare('DELETE FROM organizational_units').run()
|
||||
|
||||
// ========================================
|
||||
// Level 0: Direktor
|
||||
// ========================================
|
||||
const direktorId = insertUnit('DIR', 'Direktor LKA NRW', 'direktion', 0, null, {
|
||||
color: '#1e3a8a',
|
||||
orderIndex: 0,
|
||||
positionX: 400,
|
||||
positionY: 50
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// Level 1: Direct Reports to Direktor
|
||||
// ========================================
|
||||
|
||||
// Leitungsstab - directly under Direktor
|
||||
const leitungsstabId = insertUnit('LStab', 'Leitungsstab', 'stabsstelle', 1, direktorId, {
|
||||
color: '#6b7280',
|
||||
orderIndex: 1,
|
||||
positionX: 200,
|
||||
positionY: 150
|
||||
})
|
||||
|
||||
// Leitungsstab Sub-units (Level 2)
|
||||
insertUnit('LStab 1', 'Grundsatzangelegenheiten, Gremien, internationale polizeiliche Zusammenarbeit, Informations- und Vorgangssteuerung, Einsatz/BAO', 'sachgebiet', 2, leitungsstabId)
|
||||
insertUnit('LStab 2', 'Strategische Steuerung, Qualitätsmanagement, Controlling, Wissenmanagement', 'sachgebiet', 2, leitungsstabId)
|
||||
insertUnit('LStab 3', 'Presse- und Öffentlichkeitsarbeit', 'sachgebiet', 2, leitungsstabId)
|
||||
|
||||
// ========================================
|
||||
// Sondereinheiten (Special Units) - Parallel to hierarchy
|
||||
// ========================================
|
||||
|
||||
// These are NOT hierarchically under Direktor but parallel/advisory roles
|
||||
const personalratId = insertUnit('PR', 'Personalrat', 'sondereinheit', 1, null, {
|
||||
color: '#059669',
|
||||
orderIndex: 100,
|
||||
positionX: 50,
|
||||
positionY: 250
|
||||
})
|
||||
|
||||
const schwerbehindertenId = insertUnit('SBV', 'Schwerbehindertenvertretung', 'sondereinheit', 1, null, {
|
||||
color: '#059669',
|
||||
orderIndex: 101,
|
||||
positionX: 50,
|
||||
positionY: 300
|
||||
})
|
||||
|
||||
// Individual Beauftragte (as separate units without hierarchy)
|
||||
insertUnit('DSB', 'Datenschutzbeauftragter', 'sondereinheit', 1, null, {
|
||||
color: '#0891b2',
|
||||
orderIndex: 102,
|
||||
positionX: 50,
|
||||
positionY: 350
|
||||
})
|
||||
|
||||
insertUnit('GSB', 'Gleichstellungsbeauftragte', 'sondereinheit', 1, null, {
|
||||
color: '#0891b2',
|
||||
orderIndex: 103,
|
||||
positionX: 50,
|
||||
positionY: 400
|
||||
})
|
||||
|
||||
insertUnit('IKB', 'Inklusionsbeauftragter', 'sondereinheit', 1, null, {
|
||||
color: '#0891b2',
|
||||
orderIndex: 104,
|
||||
positionX: 50,
|
||||
positionY: 450
|
||||
})
|
||||
|
||||
insertUnit('ISB', 'Informationssicherheitsbeauftragter', 'sondereinheit', 1, null, {
|
||||
color: '#0891b2',
|
||||
orderIndex: 105,
|
||||
positionX: 50,
|
||||
positionY: 500
|
||||
})
|
||||
|
||||
insertUnit('GHSB', 'Geheimschutzbeauftragte', 'sondereinheit', 1, null, {
|
||||
color: '#0891b2',
|
||||
orderIndex: 106,
|
||||
positionX: 50,
|
||||
positionY: 550
|
||||
})
|
||||
|
||||
insertUnit('EXB', 'Extremismusbeauftragter', 'sondereinheit', 1, null, {
|
||||
color: '#dc2626',
|
||||
orderIndex: 107,
|
||||
positionX: 50,
|
||||
positionY: 600
|
||||
})
|
||||
|
||||
insertUnit('EXBV', 'Extremismusbeauftragter Vertreter', 'sondereinheit', 1, null, {
|
||||
color: '#dc2626',
|
||||
orderIndex: 108,
|
||||
positionX: 50,
|
||||
positionY: 650
|
||||
})
|
||||
|
||||
// Innenrevision
|
||||
insertUnit('IR', 'Innenrevision', 'sondereinheit', 1, null, {
|
||||
color: '#7c3aed',
|
||||
orderIndex: 109,
|
||||
positionX: 50,
|
||||
positionY: 700
|
||||
})
|
||||
|
||||
// Fachkräfte für Arbeitssicherheit
|
||||
insertUnit('FAS', 'Fachkräfte für Arbeitssicherheit', 'sondereinheit', 1, null, {
|
||||
color: '#059669',
|
||||
orderIndex: 110,
|
||||
positionX: 50,
|
||||
positionY: 750
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// Abteilungen (Level 1 - Under Direktor)
|
||||
// ========================================
|
||||
|
||||
const abteilungen = [
|
||||
{
|
||||
code: 'Abt 1',
|
||||
name: 'Organisierte Kriminalität',
|
||||
color: '#dc2626',
|
||||
hasFuehrungsstelle: true,
|
||||
positionX: 100,
|
||||
positionY: 200
|
||||
},
|
||||
{
|
||||
code: 'Abt 2',
|
||||
name: 'Terrorismusbekämpfung und Staatsschutz',
|
||||
color: '#ea580c',
|
||||
hasFuehrungsstelle: true,
|
||||
positionX: 250,
|
||||
positionY: 200
|
||||
},
|
||||
{
|
||||
code: 'Abt 3',
|
||||
name: 'Strategische Kriminalitätsbekämpfung',
|
||||
color: '#0891b2',
|
||||
hasFuehrungsstelle: true,
|
||||
positionX: 400,
|
||||
positionY: 200
|
||||
},
|
||||
{
|
||||
code: 'Abt 4',
|
||||
name: 'Cybercrime (CCCC)',
|
||||
color: '#7c3aed',
|
||||
hasFuehrungsstelle: true,
|
||||
positionX: 550,
|
||||
positionY: 200
|
||||
},
|
||||
{
|
||||
code: 'Abt 5',
|
||||
name: 'Kriminalwissenschaftliches und -technisches Institut',
|
||||
color: '#0d9488',
|
||||
hasFuehrungsstelle: true,
|
||||
positionX: 700,
|
||||
positionY: 200
|
||||
},
|
||||
{
|
||||
code: 'Abt 6',
|
||||
name: 'Fachaufsicht und Ermittlungsunterstützung',
|
||||
color: '#be185d',
|
||||
hasFuehrungsstelle: true,
|
||||
positionX: 850,
|
||||
positionY: 200
|
||||
},
|
||||
{
|
||||
code: 'ZA',
|
||||
name: 'Zentralabteilung',
|
||||
color: '#6b7280',
|
||||
hasFuehrungsstelle: false,
|
||||
positionX: 1000,
|
||||
positionY: 200
|
||||
}
|
||||
]
|
||||
|
||||
const abteilungIds = {}
|
||||
|
||||
abteilungen.forEach((abt, index) => {
|
||||
abteilungIds[abt.code] = insertUnit(abt.code, abt.name, 'abteilung', 1, direktorId, {
|
||||
color: abt.color,
|
||||
orderIndex: 10 + index * 10,
|
||||
hasFuehrungsstelle: abt.hasFuehrungsstelle ? 1 : 0,
|
||||
fuehrungsstelleName: abt.hasFuehrungsstelle ? `Führungsstelle ${abt.code}` : null,
|
||||
positionX: abt.positionX,
|
||||
positionY: abt.positionY
|
||||
})
|
||||
})
|
||||
|
||||
// ========================================
|
||||
// Dezernate für Abteilung 1 (Organisierte Kriminalität)
|
||||
// ========================================
|
||||
|
||||
const dez11 = insertUnit('Dez 11', 'Ermittlungen OK, OK Rauschgift', 'dezernat', 2, abteilungIds['Abt 1'])
|
||||
insertUnit('SG 11.1', 'Grundsatzfragen/Koordination/Auswertung', 'sachgebiet', 3, dez11)
|
||||
|
||||
const dez12 = insertUnit('Dez 12', 'Wirtschaftskriminalität', 'dezernat', 2, abteilungIds['Abt 1'])
|
||||
insertUnit('SG 12.1', 'Grundsatzfragen/Koordination/Auswertung', 'sachgebiet', 3, dez12)
|
||||
|
||||
const dez13 = insertUnit('Dez 13', 'Finanzermittlungen', 'dezernat', 2, abteilungIds['Abt 1'])
|
||||
insertUnit('SG 13.1', 'GFG 1', 'sachgebiet', 3, dez13)
|
||||
insertUnit('SG 13.2', 'GFG 2', 'sachgebiet', 3, dez13)
|
||||
insertUnit('SG 13.3', 'Verfahrensintegrierte Finanzermittlungen/Vermögensabschöpfung', 'sachgebiet', 3, dez13)
|
||||
insertUnit('SG 13.4', 'Zentrale Informations- und Koordinierungsstelle Finanzermittlung und Gewinnabschöpfung, Recherchestelle ZIVED', 'sachgebiet', 3, dez13)
|
||||
|
||||
const dez14 = insertUnit('Dez 14', 'Auswerte- und Analysestelle OK', 'dezernat', 2, abteilungIds['Abt 1'])
|
||||
insertUnit('TD 14.1', 'Operative Auswertung und Analyse, kryptierte Täterkommunikation', 'teildezernat', 3, dez14)
|
||||
insertUnit('SG 14.2', 'Strategische Auswertung und Analyse, Informationssteuerung', 'sachgebiet', 3, dez14)
|
||||
insertUnit('SG 14.3', 'Technische Informationssysteme, Unterstützungsgruppe CASE/DAR', 'sachgebiet', 3, dez14)
|
||||
insertUnit('SG 14.4', 'Auswertung und Analyse Rockerkriminalität', 'sachgebiet', 3, dez14)
|
||||
insertUnit('SG 14.5', 'Auswertung und Analyse Clankriminalität', 'sachgebiet', 3, dez14)
|
||||
|
||||
const dez15 = insertUnit('Dez 15', 'Korruption, Umweltkriminalität', 'dezernat', 2, abteilungIds['Abt 1'])
|
||||
insertUnit('SG 15.1', 'Grundsatzangelegenheiten, Korruption', 'sachgebiet', 3, dez15)
|
||||
insertUnit('SG 15.2', 'Vernetzungsstelle Umweltkriminalität', 'sachgebiet', 3, dez15)
|
||||
|
||||
const dez16 = insertUnit('Dez 16', 'Finanzierung Organisierter Kriminalität und Terrorismus', 'dezernat', 2, abteilungIds['Abt 1'])
|
||||
insertUnit('SG 16.1', 'Grundsatzfragen/Auswertung/Analyse', 'sachgebiet', 3, dez16)
|
||||
|
||||
// ========================================
|
||||
// Dezernate für Abteilung 2 (Terrorismusbekämpfung)
|
||||
// ========================================
|
||||
|
||||
const dez21 = insertUnit('Dez 21', 'Ermittlungen', 'dezernat', 2, abteilungIds['Abt 2'])
|
||||
insertUnit('TD 21.1', 'Ermittlungskommissionen VSTGB, Ermittlungskommissionen PMK (alle Phänomenbereiche), VsnL', 'teildezernat', 3, dez21)
|
||||
|
||||
const dez22 = insertUnit('Dez 22', 'Auswertung/Analyse, ZMI, Open Source Intelligence (OSINT), Wissenschaftlicher Dienst PMK, KPMD-PMK', 'dezernat', 2, abteilungIds['Abt 2'])
|
||||
insertUnit('SG 22.1', 'Auswertung/Analyse, ZMI', 'sachgebiet', 3, dez22)
|
||||
insertUnit('TD 22.2', 'Open Source Intelligence (OSINT)', 'teildezernat', 3, dez22)
|
||||
insertUnit('TD 22.3', 'Wissenschaftlicher Dienst PMK', 'teildezernat', 3, dez22)
|
||||
insertUnit('SG 22.4', 'PMK Meldedienste, Kriminalpolizeilicher Meldedienst (KPMD)', 'sachgebiet', 3, dez22)
|
||||
|
||||
const dez23 = insertUnit('Dez 23', 'PMK Rechts und PMK SZ', 'dezernat', 2, abteilungIds['Abt 2'])
|
||||
insertUnit('SG 23.1', 'KoSt Gefährder, Gemeinsames Extremismus- und Terrorismusabwehrzentrum (GETZ-) Rechts, Landesvertreter GETZ-Rechts Bund', 'sachgebiet', 3, dez23)
|
||||
insertUnit('SG 23.2', 'Prüffallbearbeitung, Gefahrensachverhalte, Hasskriminalität, PMK rechts', 'sachgebiet', 3, dez23)
|
||||
insertUnit('TD 23.3', 'PMK SZ, Spionage, Gefahrensachverhalte PMK SZ, Landesvertreter GETZ-SP', 'teildezernat', 3, dez23)
|
||||
|
||||
const dez24 = insertUnit('Dez 24', 'PMK Religiöse Ideologie', 'dezernat', 2, abteilungIds['Abt 2'])
|
||||
insertUnit('SG 24.1', 'KoST Gefährder, SiKo', 'sachgebiet', 3, dez24)
|
||||
insertUnit('TD 24.2', 'Gemeinsames Terrorismusabwehrzentrum (GTAZ) NRW, Landesvertreter GTAZ Bund, ATD/RED', 'teildezernat', 3, dez24)
|
||||
insertUnit('SG 24.3', 'Prüffallbearbeitung, Gefahrensachverhalte, islamistisch-terroristisches Personenpotential', 'sachgebiet', 3, dez24)
|
||||
|
||||
const dez25 = insertUnit('Dez 25', 'PMK Links, Ausländische Ideologie, ZSÜ', 'dezernat', 2, abteilungIds['Abt 2'])
|
||||
insertUnit('SG 25.1', 'KoSt Gefährder Links, GETZ-Links NRW, Landesvertreter GETZ-Links Bund, Prüffallsachbearbeitung, Gefahrensachverhalte', 'sachgebiet', 3, dez25)
|
||||
insertUnit('SG 25.2', 'KoSt Gefährder Ausländische Ideologie (AI), Landesvertreter GETZ-AI Bund, Prüffallsachbearbeitung, Gefahrensachverhalte', 'sachgebiet', 3, dez25)
|
||||
insertUnit('SG 25.3', 'Zentrale Stelle NRW für ZSÜ', 'sachgebiet', 3, dez25)
|
||||
|
||||
// ========================================
|
||||
// Dezernate für Abteilung 3 (Strategische Kriminalitätsbekämpfung)
|
||||
// ========================================
|
||||
|
||||
const dez31 = insertUnit('Dez 31', 'Kriminalitätsauswertung und Analyse, Polizeiliche Kriminalitätsstatistik', 'dezernat', 2, abteilungIds['Abt 3'])
|
||||
insertUnit('SG 31.1', 'Grundsatzangelegenheiten / KoST MAfEx / KoSt MOTIV/MIT/aMIT', 'sachgebiet', 3, dez31)
|
||||
insertUnit('SG 31.2', 'Auswertung und Analyse 1, Eigentums- und Vermögensdelikte, SÄM-ÜT, KoSt RTE', 'sachgebiet', 3, dez31)
|
||||
insertUnit('SG 31.3', 'Auswertung/Analyse 2 Rauschgift-, Arzneimittel-, Menschenhandels-, Schleusungskriminalität, Dokumentenfälschungen, Gewaltdelikte', 'sachgebiet', 3, dez31)
|
||||
insertUnit('SG 31.4', 'Polizeiliche Kriminalstatistik (PKS)', 'sachgebiet', 3, dez31)
|
||||
|
||||
const dez32 = insertUnit('Dez 32', 'Kriminalprävention, Kriminalistisch-Kriminologische Forschungsstelle, Evaluation', 'dezernat', 2, abteilungIds['Abt 3'])
|
||||
insertUnit('SG 32.1', 'Kriminalprävention und Opferschutz', 'sachgebiet', 3, dez32)
|
||||
insertUnit('TD 32.2', 'Kriminalistisch-Kriminologische Forschungsstelle (KKF)', 'teildezernat', 3, dez32)
|
||||
insertUnit('SG 32.3', 'Zentralstelle Evaluation (ZEVA)', 'sachgebiet', 3, dez32)
|
||||
|
||||
const dez33 = insertUnit('Dez 33', 'Fahndung, Datenaustausch Polizei/Justiz, Kriminalaktenhaltung, Internationale Rechtshilfe', 'dezernat', 2, abteilungIds['Abt 3'])
|
||||
insertUnit('SG 33.1', 'Datenstation, Polizeiliche Beobachtung, Grundsatz Fahndung, Fahndungsportal', 'sachgebiet', 3, dez33)
|
||||
insertUnit('SG 33.2', 'Datenaustausch Polizei/Justiz, Kriminalaktenhaltung', 'sachgebiet', 3, dez33)
|
||||
insertUnit('SG 33.3', 'Rechtshilfe, PNR, internationale Fahndung, Interpol- und Europolangelegenheiten, Vermisste', 'sachgebiet', 3, dez33)
|
||||
|
||||
const dez34 = insertUnit('Dez 34', 'Digitalstrategie, Polizeifachliche IT, Landeszentrale Qualitätssicherung', 'dezernat', 2, abteilungIds['Abt 3'])
|
||||
insertUnit('TD 34.1', 'Grundsatz, Gremien, Fachliche IT-Projekte', 'teildezernat', 3, dez34)
|
||||
insertUnit('TD 34.2', 'Zentralstelle Polizei 2020, PIAV, QS Verbundanwendungen, Europäisches Informationssystem (EIS)', 'teildezernat', 3, dez34)
|
||||
insertUnit('TD 34.3', 'IT FaKo Fachbereich Kriminalität, Zentralstelle ViVA, QS, INPOL-Z, ViVA-Büro LKA', 'teildezernat', 3, dez34)
|
||||
|
||||
const dez35 = insertUnit('Dez 35', 'Verhaltensanalyse und Risikomanagement', 'dezernat', 2, abteilungIds['Abt 3'])
|
||||
insertUnit('SG 35.1', 'Zentralstelle KURS NRW', 'sachgebiet', 3, dez35)
|
||||
insertUnit('SG 35.2', 'Operative Fallanalyse (OFA/ViCLAS)', 'sachgebiet', 3, dez35)
|
||||
insertUnit('SG 35.3', 'Zentralstelle PeRiskoP', 'sachgebiet', 3, dez35)
|
||||
|
||||
// ========================================
|
||||
// Dezernate für Abteilung 4 (Cybercrime)
|
||||
// ========================================
|
||||
|
||||
const dez41 = insertUnit('Dez 41', 'Zentrale Ansprechstelle Cybercrime, Grundsatz, Digitale Forensik, IT-Entwicklung', 'dezernat', 2, abteilungIds['Abt 4'])
|
||||
insertUnit('SG 41.1', 'Grundsatzangelegenheiten, Prävention, Auswertung Cybercrime', 'sachgebiet', 3, dez41)
|
||||
insertUnit('TD 41.2', 'Software und KI-Entwicklung, IT-Verfahrensbetreuung, Marktschau', 'teildezernat', 3, dez41)
|
||||
insertUnit('SG 41.3', 'Forensik Desktop Strategie und Entwicklung - Hinweisportal NRW', 'sachgebiet', 3, dez41)
|
||||
insertUnit('SG 41.4', 'Forensik Desktop Datenaufbereitung und Automatisierung', 'sachgebiet', 3, dez41)
|
||||
insertUnit('TD 41.5', 'Forensik Desktop Betrieb', 'teildezernat', 3, dez41)
|
||||
|
||||
const dez42 = insertUnit('Dez 42', 'Cyber-Recherche- und Fahndungszentrum, Ermittlungen Cybercrime', 'dezernat', 2, abteilungIds['Abt 4'])
|
||||
insertUnit('SG 42.1', 'Personenorientierte Recherche in Datennetzen', 'sachgebiet', 3, dez42)
|
||||
insertUnit('SG 42.2', 'Sachorientierte Recherche in Datennetzen', 'sachgebiet', 3, dez42)
|
||||
insertUnit('TD 42.3', 'Interventionsteams Digitale Tatorte IUK-Lageunterstützung Ermittlungskommissionen', 'teildezernat', 3, dez42)
|
||||
|
||||
const dez43 = insertUnit('Dez 43', 'Zentrale Auswertungs- und Sammelstelle (ZASt) für die Bekämpfung von Missbrauchsabbildungen von Kindern und Jugendlichen', 'dezernat', 2, abteilungIds['Abt 4'])
|
||||
insertUnit('SG 43.1', 'ZASt Grundsatz, Identifizierungsverfahren, Bildvergleichssammlung, Schulfahndung, Berichtswesen, Meldedienste/Verbundverfahren, Gremien', 'sachgebiet', 3, dez43)
|
||||
insertUnit('SG 43.2', 'ZASt NCMEC/ Landeszentrale Bewertung 1', 'sachgebiet', 3, dez43)
|
||||
insertUnit('SG 43.3', 'ZASt NCMEC/ Landeszentrale Bewertung 2', 'sachgebiet', 3, dez43)
|
||||
insertUnit('SG 43.4', 'ZASt NCMEC/ Landeszentrale Bewertung 3', 'sachgebiet', 3, dez43)
|
||||
|
||||
const dez44 = insertUnit('Dez 44', 'Telekommunikationsüberwachung (TKÜ)', 'dezernat', 2, abteilungIds['Abt 4'])
|
||||
insertUnit('SG 44.1', 'Grundsatzaufgaben, operative TKÜ, AIT', 'sachgebiet', 3, dez44)
|
||||
insertUnit('SG 44.2', 'TKÜ Betrieb und Service', 'sachgebiet', 3, dez44)
|
||||
insertUnit('TD 44.3', 'Digitale Forensik, IUK-Ermittlungsunterstützung', 'teildezernat', 3, dez44)
|
||||
|
||||
// ========================================
|
||||
// Dezernate für Abteilung 5 (Kriminalwissenschaftliches Institut)
|
||||
// ========================================
|
||||
|
||||
const dez51 = insertUnit('Dez 51', 'Chemie, Physik', 'dezernat', 2, abteilungIds['Abt 5'])
|
||||
insertUnit('TD 51.1', 'Schussspuren, Explosivstoffe, Brand, Elektrotechnik', 'teildezernat', 3, dez51)
|
||||
insertUnit('TD 51.2', 'Betäubungsmittel', 'teildezernat', 3, dez51)
|
||||
|
||||
const dez52 = insertUnit('Dez 52', 'Serologie, DNA-Analytik', 'dezernat', 2, abteilungIds['Abt 5'])
|
||||
insertUnit('TD 52.1', 'DNA-Probenbearbeitung, DNA-Spurenbearbeitung I', 'teildezernat', 3, dez52)
|
||||
insertUnit('TD 52.2', 'DNA-Spurenbearbeitung II', 'teildezernat', 3, dez52)
|
||||
insertUnit('TD 52.3', 'DNA-Spurenbearbeitung III', 'teildezernat', 3, dez52)
|
||||
insertUnit('TD 52.4', 'DNA-Spurenbearbeitung IV DNA-Fremdvergabe', 'teildezernat', 3, dez52)
|
||||
|
||||
const dez53 = insertUnit('Dez 53', 'Biologie, Materialspuren, Urkunden, Handschriften', 'dezernat', 2, abteilungIds['Abt 5'])
|
||||
insertUnit('TD 53.1', 'forensische Textilkunde, Botanik, Material-, Haar- und Erdspuren', 'teildezernat', 3, dez53)
|
||||
insertUnit('SG 53.2', 'Urkunden, Handschriften', 'sachgebiet', 3, dez53)
|
||||
|
||||
const dez54 = insertUnit('Dez 54', 'Zentralstelle Kriminaltechnik, Forensische Medientechnik, USBV Entschärfung', 'dezernat', 2, abteilungIds['Abt 5'])
|
||||
insertUnit('SG 54.1', 'Zentralstelle Kriminaltechnik', 'sachgebiet', 3, dez54)
|
||||
insertUnit('SG 54.2', 'Tatortvermessung, Rekonstruktion und XR-Lab, Phantombilderstellung und visuelle Fahndungshilfe, Bild- und Videotechnik', 'sachgebiet', 3, dez54)
|
||||
insertUnit('SG 54.3', 'Entschärfung von unkonventionellen Spreng- und Brandvorrichtungen (USBV) – Einsatz- und Ermittlungsunterstützung Explosivstoffe', 'sachgebiet', 3, dez54)
|
||||
|
||||
const dez55 = insertUnit('Dez 55', 'Waffen und Werkzeug DNA-Analyse-Datei', 'dezernat', 2, abteilungIds['Abt 5'])
|
||||
insertUnit('SG 55.1', 'Waffen und Munition', 'sachgebiet', 3, dez55)
|
||||
insertUnit('SG 55.2', 'Werkzeug-, Form-, Passspuren, Schließ- und Sicherungseinrichtungen', 'sachgebiet', 3, dez55)
|
||||
insertUnit('SG 55.3', 'DNA-Analyse-Datei', 'sachgebiet', 3, dez55)
|
||||
|
||||
const dez56 = insertUnit('Dez 56', 'Daktyloskopie, Gesichts- und Sprechererkennung, Tonträgerauswertung', 'dezernat', 2, abteilungIds['Abt 5'])
|
||||
insertUnit('SG 56.1', 'Daktyloskopisches Labor', 'sachgebiet', 3, dez56)
|
||||
insertUnit('SG 56.2', 'AFIS I/Daktyloskopische Gutachten', 'sachgebiet', 3, dez56)
|
||||
insertUnit('SG 56.3', 'AFIS II/Daktyloskopische Gutachten', 'sachgebiet', 3, dez56)
|
||||
insertUnit('TD 56.4', 'Gesichts- und Sprechererkennung, Tonträgerauswertung', 'teildezernat', 3, dez56)
|
||||
|
||||
// ========================================
|
||||
// Dezernate für Abteilung 6 (Fachaufsicht und Ermittlungsunterstützung)
|
||||
// ========================================
|
||||
|
||||
const dez61 = insertUnit('Dez 61', 'Kriminalitätsangelegenheiten der KPB, Fachcontrolling, Koordination PUA, Lagedienst', 'dezernat', 2, abteilungIds['Abt 6'])
|
||||
insertUnit('TD 61.1', 'Kriminalitätsangelegenheiten der KPB, Fachcontrolling, Koordination PUA', 'teildezernat', 3, dez61)
|
||||
insertUnit('SG 61.2', 'Lagedienst', 'sachgebiet', 3, dez61)
|
||||
|
||||
const dez62 = insertUnit('Dez 62', 'Fahndungsgruppe Staatsschutz', 'dezernat', 2, abteilungIds['Abt 6'])
|
||||
// Add Führungsgruppe for Dezernat 62
|
||||
insertUnit('FüGr 62', 'Führungsgruppe', 'fuehrungsstelle', 3, dez62)
|
||||
// Fahndungsgruppen 1-8
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
insertUnit(`SG 62.${i}`, i === 8 ? 'Fahndungsgruppe 8' : `Fahndungsgruppe ${i}`, 'sachgebiet', 3, dez62)
|
||||
}
|
||||
insertUnit('SG 62.9', 'Technische Gruppe', 'sachgebiet', 3, dez62)
|
||||
|
||||
const dez63 = insertUnit('Dez 63', 'Verdeckte Ermittlungen, Zeugenschutz', 'dezernat', 2, abteilungIds['Abt 6'])
|
||||
insertUnit('SG 63.1', 'Einsatz VE 1', 'sachgebiet', 3, dez63)
|
||||
insertUnit('SG 63.2', 'Einsatz VE 2', 'sachgebiet', 3, dez63)
|
||||
insertUnit('SG 63.3', 'Scheinkäufer, Logistik', 'sachgebiet', 3, dez63)
|
||||
insertUnit('SG 63.4', 'VP-Führung, Koordinierung VP', 'sachgebiet', 3, dez63)
|
||||
insertUnit('TD 63.5', 'Zeugenschutz, OpOS', 'teildezernat', 3, dez63)
|
||||
|
||||
const dez64 = insertUnit('Dez 64', 'Mobiles Einsatzkommando, Technische Einsatzgruppe, Zielfahndung', 'dezernat', 2, abteilungIds['Abt 6'])
|
||||
insertUnit('FüGr 64', 'FüGr', 'fuehrungsstelle', 3, dez64)
|
||||
insertUnit('SG 64.1', 'MEK 1', 'sachgebiet', 3, dez64)
|
||||
insertUnit('SG 64.2', 'MEK 2', 'sachgebiet', 3, dez64)
|
||||
insertUnit('SG 64.3', 'Technische Einsatzgruppe', 'sachgebiet', 3, dez64)
|
||||
insertUnit('SG 64.4', 'Zielfahndung', 'sachgebiet', 3, dez64)
|
||||
|
||||
// ========================================
|
||||
// Dezernate für Zentralabteilung
|
||||
// ========================================
|
||||
|
||||
const dezZA1 = insertUnit('Dez ZA 1', 'Haushalts-, Wirtschafts-, Liegenschaftsmanagement, Zentrale Vergabestelle', 'dezernat', 2, abteilungIds['ZA'])
|
||||
insertUnit('SG ZA 1.1', 'Haushalts- und Wirtschaftsangelegenheiten', 'sachgebiet', 3, dezZA1)
|
||||
insertUnit('SG ZA 1.2', 'Liegenschaftsmanagement', 'sachgebiet', 3, dezZA1)
|
||||
insertUnit('SG ZA 1.3', 'Zentrale Vergabestelle', 'sachgebiet', 3, dezZA1)
|
||||
|
||||
const dezZA2 = insertUnit('Dez ZA 2', 'Personalangelegenheiten, Gleichstellungsbeauftragte, Fortbildung', 'dezernat', 2, abteilungIds['ZA'])
|
||||
insertUnit('SG ZA 2.1', 'Personalentwicklung, Arbeitszeit, BGM-POL; Stellenplan', 'sachgebiet', 3, dezZA2)
|
||||
insertUnit('SG ZA 2.2', 'Personalverwaltung Beamte/ dienstrechtliche Angelegenheiten', 'sachgebiet', 3, dezZA2)
|
||||
insertUnit('TD ZA 2.3', 'Personalverwaltung Regierungsbeschäftigte/ Personalgewinnung, tarif- und arbeitsrechtliche Angelegenheiten', 'teildezernat', 3, dezZA2)
|
||||
insertUnit('SG ZA 2.4', 'Fortbildung', 'sachgebiet', 3, dezZA2)
|
||||
|
||||
const dezZA3 = insertUnit('Dez ZA 3', 'Informationstechnik und Anwenderunterstützung, Informationssicherheit, Kfz-, Waffen- und Geräteangelegenheiten', 'dezernat', 2, abteilungIds['ZA'])
|
||||
insertUnit('SG ZA 3.1', 'IT-Grundsatz, Planung und Koordinierung, IT-Service Desk, Lizenzmanagement', 'sachgebiet', 3, dezZA3)
|
||||
insertUnit('SG ZA 3.2', 'Netzwerk/ TK-Anlage', 'sachgebiet', 3, dezZA3)
|
||||
insertUnit('SG ZA 3.3', 'Server/Client, Logistik', 'sachgebiet', 3, dezZA3)
|
||||
insertUnit('SG ZA 3.4', 'Kfz-, Waffen- und Geräteangelegenheiten', 'sachgebiet', 3, dezZA3)
|
||||
|
||||
const dezZA4 = insertUnit('Dez ZA 4', 'Vereins- und Waffenrecht, Innenrevision, Sponsoring, Rechtsangelegenheiten, Datenschutz, Geheimschutz', 'dezernat', 2, abteilungIds['ZA'])
|
||||
|
||||
const dezZA5 = insertUnit('Dez ZA 5', 'Polizeiärztlicher Dienst', 'dezernat', 2, abteilungIds['ZA'])
|
||||
insertUnit('PAD', 'Polizeiärztin', 'sachgebiet', 3, dezZA5)
|
||||
|
||||
console.log('✅ LKA NRW Organizational Structure seeded successfully!')
|
||||
console.log(`📊 Created ${Object.keys(unitIds).length} organizational units`)
|
||||
|
||||
// Output some statistics
|
||||
const stats = db.prepare(`
|
||||
SELECT type, COUNT(*) as count
|
||||
FROM organizational_units
|
||||
GROUP BY type
|
||||
`).all()
|
||||
|
||||
console.log('\n📈 Statistics:')
|
||||
stats.forEach(stat => {
|
||||
console.log(` ${stat.type}: ${stat.count} units`)
|
||||
})
|
||||
|
||||
// Close database
|
||||
db.close()
|
||||
|
||||
console.log('\n🎉 Done! The organization structure is ready to use.')
|
||||
console.log(' Navigate to Admin Panel > Organigramm to manage the structure.')
|
||||
console.log(' Users can view it in the main app using the 🏢 button.')
|
||||
386
backend/scripts/seed-organization.js
Normale Datei
386
backend/scripts/seed-organization.js
Normale Datei
@ -0,0 +1,386 @@
|
||||
const Database = require('better-sqlite3')
|
||||
const path = require('path')
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
|
||||
// Open database
|
||||
const dbPath = path.join(__dirname, '..', 'skillmate.dev.encrypted.db')
|
||||
const db = new Database(dbPath)
|
||||
|
||||
// Enable foreign keys
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
console.log('🏢 Seeding LKA NRW Organizational Structure...')
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Helper function to insert organizational unit
|
||||
function insertUnit(code, name, type, level, parentId = null, options = {}) {
|
||||
const id = uuidv4()
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO organizational_units (
|
||||
id, code, name, type, level, parent_id,
|
||||
color, order_index, has_fuehrungsstelle,
|
||||
fuehrungsstelle_name, is_active, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, code, name, type, level, parentId,
|
||||
options.color || null,
|
||||
options.orderIndex || 0,
|
||||
options.hasFuehrungsstelle || 0,
|
||||
options.fuehrungsstelleName || null,
|
||||
1, now, now
|
||||
)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// Clear existing data
|
||||
console.log('Clearing existing organizational data...')
|
||||
db.prepare('DELETE FROM deputy_delegations').run()
|
||||
db.prepare('DELETE FROM deputy_assignments').run()
|
||||
db.prepare('DELETE FROM special_positions').run()
|
||||
db.prepare('DELETE FROM employee_unit_assignments').run()
|
||||
db.prepare('DELETE FROM organizational_units').run()
|
||||
|
||||
// Create Direktor
|
||||
const direktorId = insertUnit('DIR', 'Direktor LKA NRW', 'direktion', 0, null, {
|
||||
color: '#1e3a8a',
|
||||
orderIndex: 0
|
||||
})
|
||||
|
||||
// Create Leitungsstab
|
||||
const leitungsstabId = insertUnit('LStab', 'Leitungsstab', 'stabsstelle', 1, direktorId, {
|
||||
color: '#6b7280',
|
||||
orderIndex: 1
|
||||
})
|
||||
|
||||
// Leitungsstab Sub-units
|
||||
insertUnit('LStab 1', 'Grundsatzangelegenheiten, Gremien, internationale polizeiliche Zusammenarbeit', 'sachgebiet', 2, leitungsstabId)
|
||||
insertUnit('LStab 2', 'Strategische Steuerung, Qualitätsmanagement, Controlling', 'sachgebiet', 2, leitungsstabId)
|
||||
insertUnit('LStab 3', 'Presse- und Öffentlichkeitsarbeit', 'sachgebiet', 2, leitungsstabId)
|
||||
|
||||
// Create Special Units (Beauftragte)
|
||||
const personalratId = insertUnit('PR', 'Personalrat', 'sondereinheit', 1, direktorId, {
|
||||
color: '#059669',
|
||||
orderIndex: 2
|
||||
})
|
||||
|
||||
const schwerbehindertenId = insertUnit('SBV', 'Schwerbehindertenvertretung', 'sondereinheit', 1, direktorId, {
|
||||
color: '#059669',
|
||||
orderIndex: 3
|
||||
})
|
||||
|
||||
// Create Abteilungen with color gradients
|
||||
const abteilungen = [
|
||||
{ code: 'Abt 1', name: 'Organisierte Kriminalität', color: '#dc2626' },
|
||||
{ code: 'Abt 2', name: 'Terrorismusbekämpfung und Staatsschutz', color: '#ea580c' },
|
||||
{ code: 'Abt 3', name: 'Strategische Kriminalitätsbekämpfung', color: '#0891b2' },
|
||||
{ code: 'Abt 4', name: 'Cybercrime (CCCC)', color: '#7c3aed' },
|
||||
{ code: 'Abt 5', name: 'Kriminalwissenschaftliches und -technisches Institut', color: '#0d9488' },
|
||||
{ code: 'Abt 6', name: 'Fachaufsicht und Ermittlungsunterstützung', color: '#be185d' },
|
||||
{ code: 'ZA', name: 'Zentralabteilung', color: '#6b7280' }
|
||||
]
|
||||
|
||||
const abteilungIds = {}
|
||||
|
||||
abteilungen.forEach((abt, index) => {
|
||||
abteilungIds[abt.code] = insertUnit(abt.code, abt.name, 'abteilung', 1, direktorId, {
|
||||
color: abt.color,
|
||||
orderIndex: 10 + index * 10,
|
||||
hasFuehrungsstelle: abt.code !== 'ZA' ? 1 : 0,
|
||||
fuehrungsstelleName: abt.code !== 'ZA' ? `Führungsstelle ${abt.code}` : null
|
||||
})
|
||||
})
|
||||
|
||||
// Create Dezernate for Abteilung 1 (Organisierte Kriminalität)
|
||||
const dezernate1 = [
|
||||
{ code: '11', name: 'Ermittlungen OK, OK Rauschgift' },
|
||||
{ code: '12', name: 'Wirtschaftskriminalität' },
|
||||
{ code: '13', name: 'Finanzermittlungen' },
|
||||
{ code: '14', name: 'Auswerte- und Analysestelle OK' },
|
||||
{ code: '15', name: 'Korruption, Umweltkriminalität' },
|
||||
{ code: '16', name: 'Finanzierung Organisierter Kriminalität und Terrorismus' }
|
||||
]
|
||||
|
||||
dezernate1.forEach((dez, index) => {
|
||||
const dezId = insertUnit(`Dezernat ${dez.code}`, dez.name, 'dezernat', 2, abteilungIds['Abt 1'], {
|
||||
orderIndex: index
|
||||
})
|
||||
|
||||
// Add sample Sachgebiete
|
||||
if (dez.code === '11') {
|
||||
insertUnit('SG 11.1', 'Grundsatzfragen/Koordination/Auswertung', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '12') {
|
||||
insertUnit('SG 12.1', 'Grundsatzfragen/Koordination/Auswertung', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '13') {
|
||||
insertUnit('SG 13.1', 'GFG 1', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 13.2', 'GFG 2', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 13.3', 'Verfahrensintegrierte Finanzermittlungen/Vermögensabschöpfung', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 13.4', 'Zentrale Informations- und Koordinierungsstelle Finanzermittlung', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '14') {
|
||||
insertUnit('TD 14.1', 'Operative Auswertung und Analyse', 'teildezernat', 3, dezId)
|
||||
insertUnit('SG 14.2', 'Strategische Auswertung und Analyse', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 14.3', 'Technische Informationssysteme', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 14.4', 'Auswertung und Analyse Rockerkriminalität', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 14.5', 'Auswertung und Analyse Clankriminalität', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '15') {
|
||||
insertUnit('SG 15.1', 'Grundsatzangelegenheiten, Korruption', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 15.2', 'Vernetzungsstelle Umweltkriminalität', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '16') {
|
||||
insertUnit('SG 16.1', 'Grundsatzfragen/Auswertung/Analyse', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
})
|
||||
|
||||
// Create Dezernate for Abteilung 2 (Terrorismusbekämpfung)
|
||||
const dezernate2 = [
|
||||
{ code: '21', name: 'Ermittlungen' },
|
||||
{ code: '22', name: 'Auswertung/Analyse, ZMI, OSINT, Wissenschaftlicher Dienst PMK' },
|
||||
{ code: '23', name: 'PMK Rechts und PMK SZ' },
|
||||
{ code: '24', name: 'PMK Religiöse Ideologie' },
|
||||
{ code: '25', name: 'PMK Links, Ausländische Ideologie, ZSÜ' }
|
||||
]
|
||||
|
||||
dezernate2.forEach((dez, index) => {
|
||||
const dezId = insertUnit(`Dezernat ${dez.code}`, dez.name, 'dezernat', 2, abteilungIds['Abt 2'], {
|
||||
orderIndex: index
|
||||
})
|
||||
|
||||
if (dez.code === '21') {
|
||||
insertUnit('TD 21.1', 'Ermittlungskommissionen VSTGB', 'teildezernat', 3, dezId)
|
||||
}
|
||||
if (dez.code === '22') {
|
||||
insertUnit('SG 22.1', 'Auswertung/Analyse, ZMI', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 22.2', 'Open Source Intelligence (OSINT)', 'teildezernat', 3, dezId)
|
||||
insertUnit('TD 22.3', 'Wissenschaftlicher Dienst PMK', 'teildezernat', 3, dezId)
|
||||
insertUnit('SG 22.4', 'PMK Meldedienste, KPMD', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '23') {
|
||||
insertUnit('SG 23.1', 'KoSt Gefährder, GETZ-Rechts', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 23.2', 'Prüffallbearbeitung, Gefahrensachverhalte', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 23.3', 'PMK SZ, Spionage', 'teildezernat', 3, dezId)
|
||||
}
|
||||
if (dez.code === '24') {
|
||||
insertUnit('SG 24.1', 'KoST Gefährder, SiKo', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 24.2', 'Gemeinsames Terrorismusabwehrzentrum (GTAZ) NRW', 'teildezernat', 3, dezId)
|
||||
insertUnit('SG 24.3', 'Prüffallbearbeitung, islamistisch-terroristisches Personenpotential', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '25') {
|
||||
insertUnit('SG 25.1', 'KoSt Gefährder Links', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 25.2', 'KoSt Gefährder Ausländische Ideologie', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 25.3', 'Zentrale Stelle NRW für ZSÜ', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
})
|
||||
|
||||
// Create Dezernate for Abteilung 3 (Strategische Kriminalitätsbekämpfung)
|
||||
const dezernate3 = [
|
||||
{ code: '31', name: 'Kriminalitätsauswertung und Analyse' },
|
||||
{ code: '32', name: 'Kriminalprävention, KKF, Evaluation' },
|
||||
{ code: '33', name: 'Fahndung, Datenaustausch, Kriminalaktenhaltung' },
|
||||
{ code: '34', name: 'Digitalstrategie, Polizeifachliche IT' },
|
||||
{ code: '35', name: 'Verhaltensanalyse und Risikomanagement' }
|
||||
]
|
||||
|
||||
dezernate3.forEach((dez, index) => {
|
||||
const dezId = insertUnit(`Dezernat ${dez.code}`, dez.name, 'dezernat', 2, abteilungIds['Abt 3'], {
|
||||
orderIndex: index
|
||||
})
|
||||
|
||||
if (dez.code === '31') {
|
||||
insertUnit('SG 31.1', 'Grundsatzangelegenheiten/KoST MAfEx', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 31.2', 'Auswertung und Analyse 1', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 31.3', 'Auswertung/Analyse 2', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 31.4', 'Polizeiliche Kriminalstatistik (PKS)', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '32') {
|
||||
insertUnit('SG 32.1', 'Kriminalprävention und Opferschutz', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 32.2', 'Kriminalistisch-Kriminologische Forschungsstelle (KKF)', 'teildezernat', 3, dezId)
|
||||
insertUnit('SG 32.3', 'Zentralstelle Evaluation (ZEVA)', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '33') {
|
||||
insertUnit('SG 33.1', 'Datenstation, Polizeiliche Beobachtung', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 33.2', 'Datenaustausch Polizei/Justiz', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 33.3', 'Rechtshilfe, PNR, internationale Fahndung', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '34') {
|
||||
insertUnit('TD 34.1', 'Grundsatz, Gremien, Fachliche IT-Projekte', 'teildezernat', 3, dezId)
|
||||
insertUnit('TD 34.2', 'Zentralstelle Polizei 2020', 'teildezernat', 3, dezId)
|
||||
insertUnit('TD 34.3', 'IT FaKo Fachbereich Kriminalität', 'teildezernat', 3, dezId)
|
||||
}
|
||||
if (dez.code === '35') {
|
||||
insertUnit('SG 35.1', 'Zentralstelle KURS NRW', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 35.2', 'Operative Fallanalyse (OFA/ViCLAS)', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 35.3', 'Zentralstelle PeRiskoP', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
})
|
||||
|
||||
// Create Dezernate for Abteilung 4 (Cybercrime)
|
||||
const dezernate4 = [
|
||||
{ code: '41', name: 'Zentrale Ansprechstelle Cybercrime, Grundsatz, Digitale Forensik' },
|
||||
{ code: '42', name: 'Cyber-Recherche- und Fahndungszentrum' },
|
||||
{ code: '43', name: 'Zentrale Auswertungs- und Sammelstelle (ZASt)' },
|
||||
{ code: '44', name: 'Telekommunikationsüberwachung (TKÜ)' }
|
||||
]
|
||||
|
||||
dezernate4.forEach((dez, index) => {
|
||||
const dezId = insertUnit(`Dezernat ${dez.code}`, dez.name, 'dezernat', 2, abteilungIds['Abt 4'], {
|
||||
orderIndex: index
|
||||
})
|
||||
|
||||
if (dez.code === '41') {
|
||||
insertUnit('SG 41.1', 'Grundsatzangelegenheiten, Prävention', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 41.2', 'Software und KI-Entwicklung', 'teildezernat', 3, dezId)
|
||||
insertUnit('SG 41.3', 'Forensik Desktop Strategie', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 41.4', 'Forensik Desktop Datenaufbereitung', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 41.5', 'Forensik Desktop Betrieb', 'teildezernat', 3, dezId)
|
||||
}
|
||||
if (dez.code === '42') {
|
||||
insertUnit('SG 42.1', 'Personenorientierte Recherche in Datennetzen', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 42.2', 'Sachorientierte Recherche in Datennetzen', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 42.3', 'Interventionsteams Digitale Tatorte', 'teildezernat', 3, dezId)
|
||||
}
|
||||
if (dez.code === '43') {
|
||||
insertUnit('SG 43.1', 'ZASt Grundsatz', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 43.2', 'ZASt NCMEC/Landeszentrale Bewertung 1', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 43.3', 'ZASt NCMEC/Landeszentrale Bewertung 2', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 43.4', 'ZASt NCMEC/Landeszentrale Bewertung 3', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '44') {
|
||||
insertUnit('SG 44.1', 'Grundsatzaufgaben, operative TKÜ', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 44.2', 'TKÜ Betrieb und Service', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 44.3', 'Digitale Forensik, IUK-Ermittlungsunterstützung', 'teildezernat', 3, dezId)
|
||||
}
|
||||
})
|
||||
|
||||
// Create Dezernate for Abteilung 5 (Kriminalwissenschaftliches Institut)
|
||||
const dezernate5 = [
|
||||
{ code: '51', name: 'Chemie, Physik' },
|
||||
{ code: '52', name: 'Serologie, DNA-Analytik' },
|
||||
{ code: '53', name: 'Biologie, Materialspuren, Urkunden' },
|
||||
{ code: '54', name: 'Zentralstelle Kriminaltechnik, Forensische Medientechnik' },
|
||||
{ code: '55', name: 'Waffen und Werkzeug, DNA-Analyse-Datei' },
|
||||
{ code: '56', name: 'Daktyloskopie, Gesichts- und Sprechererkennung' }
|
||||
]
|
||||
|
||||
dezernate5.forEach((dez, index) => {
|
||||
const dezId = insertUnit(`Dezernat ${dez.code}`, dez.name, 'dezernat', 2, abteilungIds['Abt 5'], {
|
||||
orderIndex: index
|
||||
})
|
||||
|
||||
if (dez.code === '51') {
|
||||
insertUnit('TD 51.1', 'Schussspuren, Explosivstoffe, Brand', 'teildezernat', 3, dezId)
|
||||
insertUnit('TD 51.2', 'Betäubungsmittel', 'teildezernat', 3, dezId)
|
||||
}
|
||||
if (dez.code === '52') {
|
||||
insertUnit('TD 52.1', 'DNA-Probenbearbeitung', 'teildezernat', 3, dezId)
|
||||
insertUnit('TD 52.2', 'DNA-Spurenbearbeitung II', 'teildezernat', 3, dezId)
|
||||
insertUnit('TD 52.3', 'DNA-Spurenbearbeitung III', 'teildezernat', 3, dezId)
|
||||
insertUnit('TD 52.4', 'DNA-Spurenbearbeitung IV', 'teildezernat', 3, dezId)
|
||||
}
|
||||
if (dez.code === '53') {
|
||||
insertUnit('TD 53.1', 'Forensische Textilkunde, Botanik', 'teildezernat', 3, dezId)
|
||||
insertUnit('SG 53.2', 'Urkunden, Handschriften', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '54') {
|
||||
insertUnit('SG 54.1', 'Zentralstelle Kriminaltechnik', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 54.2', 'Tatortvermessung, Rekonstruktion und XR-Lab', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 54.3', 'Entschärfung von USBV', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '55') {
|
||||
insertUnit('SG 55.1', 'Waffen und Munition', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 55.2', 'Werkzeug-, Form-, Passspuren', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 55.3', 'DNA-Analyse-Datei', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '56') {
|
||||
insertUnit('SG 56.1', 'Daktyloskopisches Labor', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 56.2', 'AFIS I/Daktyloskopische Gutachten', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 56.3', 'AFIS II/Daktyloskopische Gutachten', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 56.4', 'Gesichts- und Sprechererkennung', 'teildezernat', 3, dezId)
|
||||
}
|
||||
})
|
||||
|
||||
// Create Dezernate for Abteilung 6 (Fachaufsicht)
|
||||
const dezernate6 = [
|
||||
{ code: '61', name: 'Kriminalitätsangelegenheiten der KPB, Fachcontrolling' },
|
||||
{ code: '62', name: 'Fahndungsgruppe Staatsschutz' },
|
||||
{ code: '63', name: 'Verdeckte Ermittlungen, Zeugenschutz' },
|
||||
{ code: '64', name: 'Mobiles Einsatzkommando, TEG, Zielfahndung' }
|
||||
]
|
||||
|
||||
dezernate6.forEach((dez, index) => {
|
||||
const dezId = insertUnit(`Dezernat ${dez.code}`, dez.name, 'dezernat', 2, abteilungIds['Abt 6'], {
|
||||
orderIndex: index
|
||||
})
|
||||
|
||||
if (dez.code === '61') {
|
||||
insertUnit('TD 61.1', 'Kriminalitätsangelegenheiten der KPB', 'teildezernat', 3, dezId)
|
||||
insertUnit('SG 61.2', 'Lagedienst', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === '62') {
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
if (i === 9) {
|
||||
insertUnit(`SG 62.${i}`, 'Technische Gruppe', 'sachgebiet', 3, dezId)
|
||||
} else {
|
||||
insertUnit(`SG 62.${i}`, `Fahndungsgruppe ${i}`, 'sachgebiet', 3, dezId)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dez.code === '63') {
|
||||
insertUnit('SG 63.1', 'Einsatz VE 1', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 63.2', 'Einsatz VE 2', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 63.3', 'Scheinkäufer, Logistik', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 63.4', 'VP-Führung, Koordinierung VP', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD 63.5', 'Zeugenschutz, OpOS', 'teildezernat', 3, dezId)
|
||||
}
|
||||
if (dez.code === '64') {
|
||||
insertUnit('SG 64.1', 'MEK 1', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 64.2', 'MEK 2', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 64.3', 'Technische Einsatzgruppe', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG 64.4', 'Zielfahndung', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
})
|
||||
|
||||
// Create Dezernate for Zentralabteilung
|
||||
const dezernateZA = [
|
||||
{ code: 'ZA 1', name: 'Haushalts-, Wirtschafts-, Liegenschaftsmanagement' },
|
||||
{ code: 'ZA 2', name: 'Personalangelegenheiten, Gleichstellungsbeauftragte' },
|
||||
{ code: 'ZA 3', name: 'Informationstechnik und Anwenderunterstützung' },
|
||||
{ code: 'ZA 4', name: 'Vereins- und Waffenrecht, Innenrevision' },
|
||||
{ code: 'ZA 5', name: 'Polizeiärztlicher Dienst' }
|
||||
]
|
||||
|
||||
dezernateZA.forEach((dez, index) => {
|
||||
const dezId = insertUnit(`Dezernat ${dez.code}`, dez.name, 'dezernat', 2, abteilungIds['ZA'], {
|
||||
orderIndex: index
|
||||
})
|
||||
|
||||
if (dez.code === 'ZA 1') {
|
||||
insertUnit('SG ZA 1.1', 'Haushalts- und Wirtschaftsangelegenheiten', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG ZA 1.2', 'Liegenschaftsmanagement', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG ZA 1.3', 'Zentrale Vergabestelle', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === 'ZA 2') {
|
||||
insertUnit('SG ZA 2.1', 'Personalentwicklung, Arbeitszeit', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG ZA 2.2', 'Personalverwaltung Beamte', 'sachgebiet', 3, dezId)
|
||||
insertUnit('TD ZA 2.3', 'Personalverwaltung Regierungsbeschäftigte', 'teildezernat', 3, dezId)
|
||||
insertUnit('SG ZA 2.4', 'Fortbildung', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
if (dez.code === 'ZA 3') {
|
||||
insertUnit('SG ZA 3.1', 'IT-Grundsatz, Planung und Koordinierung', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG ZA 3.2', 'Netzwerk/TK-Anlage', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG ZA 3.3', 'Server/Client, Logistik', 'sachgebiet', 3, dezId)
|
||||
insertUnit('SG ZA 3.4', 'Kfz-, Waffen- und Geräteangelegenheiten', 'sachgebiet', 3, dezId)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ LKA NRW Organizational Structure seeded successfully!')
|
||||
|
||||
// Close database
|
||||
db.close()
|
||||
|
||||
console.log('🎉 Done! Run the backend to see the organizational structure.')
|
||||
@ -376,6 +376,119 @@ export function initializeDatabase() {
|
||||
)
|
||||
`)
|
||||
|
||||
// Organizational Structure Tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS organizational_units (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit')),
|
||||
level INTEGER NOT NULL,
|
||||
parent_id TEXT,
|
||||
position_x INTEGER,
|
||||
position_y INTEGER,
|
||||
color TEXT,
|
||||
order_index INTEGER DEFAULT 0,
|
||||
description TEXT,
|
||||
has_fuehrungsstelle INTEGER DEFAULT 0,
|
||||
fuehrungsstelle_name TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES organizational_units(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_org_units_parent ON organizational_units(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_org_units_type ON organizational_units(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_org_units_level ON organizational_units(level);
|
||||
`)
|
||||
|
||||
// Employee Unit Assignments
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS employee_unit_assignments (
|
||||
id TEXT PRIMARY KEY,
|
||||
employee_id TEXT NOT NULL,
|
||||
unit_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter')),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
is_primary INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (unit_id) REFERENCES organizational_units(id) ON DELETE CASCADE,
|
||||
UNIQUE(employee_id, unit_id, role)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_emp_units_employee ON employee_unit_assignments(employee_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_emp_units_unit ON employee_unit_assignments(unit_id);
|
||||
`)
|
||||
|
||||
// Special Positions (Personalrat, Beauftragte, etc.)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS special_positions (
|
||||
id TEXT PRIMARY KEY,
|
||||
employee_id TEXT NOT NULL,
|
||||
position_type TEXT NOT NULL CHECK(position_type IN ('personalrat', 'schwerbehindertenvertretung', 'datenschutzbeauftragter', 'gleichstellungsbeauftragter', 'inklusionsbeauftragter', 'informationssicherheitsbeauftragter', 'geheimschutzbeauftragter', 'extremismusbeauftragter')),
|
||||
unit_id TEXT,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (unit_id) REFERENCES organizational_units(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_special_pos_employee ON special_positions(employee_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_special_pos_type ON special_positions(position_type);
|
||||
`)
|
||||
|
||||
// Deputy Assignments (Vertretungen)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS deputy_assignments (
|
||||
id TEXT PRIMARY KEY,
|
||||
principal_id TEXT NOT NULL,
|
||||
deputy_id TEXT NOT NULL,
|
||||
unit_id TEXT,
|
||||
valid_from TEXT NOT NULL,
|
||||
valid_until TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
can_delegate INTEGER DEFAULT 1,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (principal_id) REFERENCES employees(id),
|
||||
FOREIGN KEY (deputy_id) REFERENCES employees(id),
|
||||
FOREIGN KEY (unit_id) REFERENCES organizational_units(id),
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deputy_principal ON deputy_assignments(principal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deputy_deputy ON deputy_assignments(deputy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deputy_dates ON deputy_assignments(valid_from, valid_until);
|
||||
`)
|
||||
|
||||
// Deputy Delegations (Vertretungs-Weitergaben)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS deputy_delegations (
|
||||
id TEXT PRIMARY KEY,
|
||||
original_assignment_id TEXT NOT NULL,
|
||||
from_deputy_id TEXT NOT NULL,
|
||||
to_deputy_id TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
delegated_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (original_assignment_id) REFERENCES deputy_assignments(id),
|
||||
FOREIGN KEY (from_deputy_id) REFERENCES employees(id),
|
||||
FOREIGN KEY (to_deputy_id) REFERENCES employees(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_delegation_assignment ON deputy_delegations(original_assignment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_delegation_from ON deputy_delegations(from_deputy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_delegation_to ON deputy_delegations(to_deputy_id);
|
||||
`)
|
||||
|
||||
// Audit Log für Änderungsverfolgung
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
|
||||
@ -4,6 +4,7 @@ import helmet from 'helmet'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
import { initializeSecureDatabase } from './config/secureDatabase'
|
||||
import { initializeDatabase } from './config/database'
|
||||
import authRoutes from './routes/auth'
|
||||
import employeeRoutes from './routes/employeesSecure'
|
||||
import profileRoutes from './routes/profiles'
|
||||
@ -15,6 +16,9 @@ import workspaceRoutes from './routes/workspaces'
|
||||
import userRoutes from './routes/users'
|
||||
import userAdminRoutes from './routes/usersAdmin'
|
||||
import settingsRoutes from './routes/settings'
|
||||
import organizationRoutes from './routes/organization'
|
||||
import organizationImportRoutes from './routes/organizationImport'
|
||||
import employeeOrganizationRoutes from './routes/employeeOrganization'
|
||||
// import bookingRoutes from './routes/bookings' // Temporär deaktiviert wegen TS-Fehlern
|
||||
// import analyticsRoutes from './routes/analytics' // Temporär deaktiviert
|
||||
import { errorHandler } from './middleware/errorHandler'
|
||||
@ -26,8 +30,9 @@ dotenv.config()
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3004
|
||||
|
||||
// Initialize secure database
|
||||
// Initialize secure database (core tables) and extended schema (organization, deputies, etc.)
|
||||
initializeSecureDatabase()
|
||||
initializeDatabase()
|
||||
|
||||
// Initialize sync scheduler
|
||||
syncScheduler
|
||||
@ -57,6 +62,9 @@ app.use('/api/workspaces', workspaceRoutes)
|
||||
app.use('/api/users', userRoutes)
|
||||
app.use('/api/admin/users', userAdminRoutes)
|
||||
app.use('/api/admin/settings', settingsRoutes)
|
||||
app.use('/api/organization', organizationRoutes)
|
||||
app.use('/api/organization', organizationImportRoutes)
|
||||
app.use('/api', employeeOrganizationRoutes)
|
||||
// app.use('/api/bookings', bookingRoutes) // Temporär deaktiviert
|
||||
// app.use('/api/analytics', analyticsRoutes) // Temporär deaktiviert
|
||||
|
||||
|
||||
166
backend/src/routes/employeeOrganization.ts
Normale Datei
166
backend/src/routes/employeeOrganization.ts
Normale Datei
@ -0,0 +1,166 @@
|
||||
import { Router } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { db } from '../config/secureDatabase'
|
||||
import { authenticate, AuthRequest } from '../middleware/auth'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get employee's current organization unit
|
||||
router.get('/employee/:employeeId/organization', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { employeeId } = req.params
|
||||
|
||||
// Check if user can access this employee's data
|
||||
if (req.user?.employeeId !== employeeId && req.user?.role !== 'admin' && req.user?.role !== 'superuser') {
|
||||
return res.status(403).json({ success: false, error: { message: 'Access denied' } })
|
||||
}
|
||||
|
||||
const unit = db.prepare(`
|
||||
SELECT
|
||||
ou.id, ou.code, ou.name, ou.type, ou.level,
|
||||
eua.role, eua.is_primary as isPrimary,
|
||||
eua.start_date as startDate
|
||||
FROM employee_unit_assignments eua
|
||||
JOIN organizational_units ou ON ou.id = eua.unit_id
|
||||
WHERE eua.employee_id = ?
|
||||
AND eua.is_primary = 1
|
||||
AND (eua.end_date IS NULL OR eua.end_date > datetime('now'))
|
||||
AND ou.is_active = 1
|
||||
ORDER BY eua.start_date DESC
|
||||
LIMIT 1
|
||||
`).get(employeeId)
|
||||
|
||||
if (!unit) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
|
||||
res.json({ success: true, data: unit })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching employee organization:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Update employee's organization unit (simplified endpoint)
|
||||
router.put('/employee/:employeeId/organization', authenticate, async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const { employeeId } = req.params
|
||||
const { unitId } = req.body
|
||||
|
||||
// Check permissions
|
||||
if (req.user?.employeeId !== employeeId && req.user?.role !== 'admin' && req.user?.role !== 'superuser') {
|
||||
return res.status(403).json({ success: false, error: { message: 'Access denied' } })
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Start transaction
|
||||
const transaction = db.transaction(() => {
|
||||
// End all current assignments for this employee
|
||||
db.prepare(`
|
||||
UPDATE employee_unit_assignments
|
||||
SET end_date = ?, updated_at = ?
|
||||
WHERE employee_id = ? AND end_date IS NULL
|
||||
`).run(now, now, employeeId)
|
||||
|
||||
// If unitId provided, create new assignment
|
||||
if (unitId) {
|
||||
// Verify unit exists
|
||||
const unit = db.prepare('SELECT id FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId)
|
||||
if (!unit) {
|
||||
throw new Error('Unit not found')
|
||||
}
|
||||
|
||||
const assignmentId = uuidv4()
|
||||
db.prepare(`
|
||||
INSERT INTO employee_unit_assignments (
|
||||
id, employee_id, unit_id, role,
|
||||
start_date, is_primary, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
assignmentId, employeeId, unitId, 'mitarbeiter',
|
||||
now, 1, now, now
|
||||
)
|
||||
}
|
||||
|
||||
// Update employee's department field for backward compatibility
|
||||
if (unitId) {
|
||||
const unitInfo = db.prepare('SELECT name FROM organizational_units WHERE id = ?').get(unitId) as any
|
||||
if (unitInfo) {
|
||||
db.prepare(`
|
||||
UPDATE employees
|
||||
SET department = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(unitInfo.name, now, employeeId)
|
||||
}
|
||||
} else {
|
||||
// Clear department if no unit
|
||||
db.prepare(`
|
||||
UPDATE employees
|
||||
SET department = NULL, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(now, employeeId)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
transaction()
|
||||
res.json({ success: true, message: 'Organization updated successfully' })
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Unit not found') {
|
||||
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating employee organization:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get all employees in a unit (for managers)
|
||||
router.get('/unit/:unitId/employees', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { unitId } = req.params
|
||||
|
||||
// Check if user is a manager of this unit or admin
|
||||
const isManager = db.prepare(`
|
||||
SELECT 1 FROM employee_unit_assignments
|
||||
WHERE employee_id = ? AND unit_id = ?
|
||||
AND role IN ('leiter', 'stellvertreter')
|
||||
AND (end_date IS NULL OR end_date > datetime('now'))
|
||||
`).get(req.user?.employeeId, unitId)
|
||||
|
||||
if (!isManager && req.user?.role !== 'admin' && req.user?.role !== 'superuser') {
|
||||
return res.status(403).json({ success: false, error: { message: 'Access denied' } })
|
||||
}
|
||||
|
||||
const employees = db.prepare(`
|
||||
SELECT
|
||||
e.id, e.first_name as firstName, e.last_name as lastName,
|
||||
e.position, e.email, e.phone, e.photo,
|
||||
eua.role, eua.is_primary as isPrimary,
|
||||
eua.start_date as startDate
|
||||
FROM employee_unit_assignments eua
|
||||
JOIN employees e ON e.id = eua.employee_id
|
||||
WHERE eua.unit_id = ?
|
||||
AND (eua.end_date IS NULL OR eua.end_date > datetime('now'))
|
||||
ORDER BY
|
||||
CASE eua.role
|
||||
WHEN 'leiter' THEN 1
|
||||
WHEN 'stellvertreter' THEN 2
|
||||
WHEN 'beauftragter' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
e.last_name, e.first_name
|
||||
`).all(unitId)
|
||||
|
||||
res.json({ success: true, data: employees })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching unit employees:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
681
backend/src/routes/organization.ts
Normale Datei
681
backend/src/routes/organization.ts
Normale Datei
@ -0,0 +1,681 @@
|
||||
import { Router } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { body, param, validationResult } from 'express-validator'
|
||||
import { db } from '../config/secureDatabase'
|
||||
import { authenticate, AuthRequest } from '../middleware/auth'
|
||||
import {
|
||||
OrganizationalUnit,
|
||||
EmployeeUnitAssignment,
|
||||
SpecialPosition,
|
||||
DeputyAssignment,
|
||||
DeputyDelegation
|
||||
} from '@skillmate/shared'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get all organizational units
|
||||
router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const units = db.prepare(`
|
||||
SELECT
|
||||
id, code, name, type, level, parent_id as parentId,
|
||||
position_x as positionX, position_y as positionY,
|
||||
color, order_index as orderIndex, description,
|
||||
has_fuehrungsstelle as hasFuehrungsstelle,
|
||||
fuehrungsstelle_name as fuehrungsstelleName,
|
||||
is_active as isActive,
|
||||
created_at as createdAt,
|
||||
updated_at as updatedAt
|
||||
FROM organizational_units
|
||||
WHERE is_active = 1
|
||||
ORDER BY level, order_index, name
|
||||
`).all()
|
||||
|
||||
res.json({ success: true, data: units })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organizational units:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get organizational hierarchy (tree structure)
|
||||
router.get('/hierarchy', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const units = db.prepare(`
|
||||
SELECT
|
||||
id, code, name, type, level, parent_id as parentId,
|
||||
position_x as positionX, position_y as positionY,
|
||||
color, order_index as orderIndex, description,
|
||||
has_fuehrungsstelle as hasFuehrungsstelle,
|
||||
fuehrungsstelle_name as fuehrungsstelleName,
|
||||
is_active as isActive
|
||||
FROM organizational_units
|
||||
WHERE is_active = 1
|
||||
ORDER BY level, order_index, name
|
||||
`).all() as any[]
|
||||
|
||||
// Build tree structure
|
||||
const unitMap = new Map()
|
||||
const rootUnits: any[] = []
|
||||
|
||||
// First pass: create map
|
||||
units.forEach(unit => {
|
||||
unitMap.set(unit.id, { ...unit, children: [] })
|
||||
})
|
||||
|
||||
// Second pass: build tree
|
||||
units.forEach(unit => {
|
||||
const node = unitMap.get(unit.id)
|
||||
if (unit.parentId && unitMap.has(unit.parentId)) {
|
||||
unitMap.get(unit.parentId).children.push(node)
|
||||
} else {
|
||||
rootUnits.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: rootUnits })
|
||||
} catch (error) {
|
||||
logger.error('Error building organizational hierarchy:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get single unit with employees
|
||||
router.get('/units/:id', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const unit = db.prepare(`
|
||||
SELECT
|
||||
id, code, name, type, level, parent_id as parentId,
|
||||
position_x as positionX, position_y as positionY,
|
||||
color, order_index as orderIndex, description,
|
||||
has_fuehrungsstelle as hasFuehrungsstelle,
|
||||
fuehrungsstelle_name as fuehrungsstelleName,
|
||||
is_active as isActive,
|
||||
created_at as createdAt,
|
||||
updated_at as updatedAt
|
||||
FROM organizational_units
|
||||
WHERE id = ?
|
||||
`).get(req.params.id) as any
|
||||
|
||||
if (!unit) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
|
||||
}
|
||||
|
||||
// Get employees assigned to this unit
|
||||
const employees = db.prepare(`
|
||||
SELECT
|
||||
e.id, e.first_name as firstName, e.last_name as lastName,
|
||||
e.position, e.department, e.email, e.phone, e.photo,
|
||||
eua.role, eua.is_primary as isPrimary
|
||||
FROM employee_unit_assignments eua
|
||||
JOIN employees e ON e.id = eua.employee_id
|
||||
WHERE eua.unit_id = ? AND (eua.end_date IS NULL OR eua.end_date > datetime('now'))
|
||||
ORDER BY
|
||||
CASE eua.role
|
||||
WHEN 'leiter' THEN 1
|
||||
WHEN 'stellvertreter' THEN 2
|
||||
WHEN 'beauftragter' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
e.last_name, e.first_name
|
||||
`).all(req.params.id)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...unit,
|
||||
employees
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching unit details:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Create new organizational unit (Admin only)
|
||||
router.post('/units',
|
||||
authenticate,
|
||||
[
|
||||
body('code').notEmpty().trim(),
|
||||
body('name').notEmpty().trim(),
|
||||
body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit']),
|
||||
body('level').isInt({ min: 0, max: 10 }),
|
||||
body('parentId').optional({ checkFalsy: true }).isUUID()
|
||||
],
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
|
||||
}
|
||||
|
||||
const { code, name, type, level, parentId, color, description, hasFuehrungsstelle, fuehrungsstelleName } = req.body
|
||||
const now = new Date().toISOString()
|
||||
const unitId = uuidv4()
|
||||
|
||||
// Check if code already exists
|
||||
const existing = db.prepare('SELECT id FROM organizational_units WHERE code = ?').get(code)
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Unit code already exists' } })
|
||||
}
|
||||
|
||||
// Get max order index for this level
|
||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM organizational_units WHERE level = ?').get(level) as any
|
||||
const orderIndex = (maxOrder?.max || 0) + 1
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO organizational_units (
|
||||
id, code, name, type, level, parent_id,
|
||||
color, order_index, description,
|
||||
has_fuehrungsstelle, fuehrungsstelle_name,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
unitId, code, name, type, level, parentId || null,
|
||||
color || null, orderIndex, description || null,
|
||||
hasFuehrungsstelle ? 1 : 0, fuehrungsstelleName || null,
|
||||
1, now, now
|
||||
)
|
||||
|
||||
res.json({ success: true, data: { id: unitId } })
|
||||
} catch (error) {
|
||||
logger.error('Error creating organizational unit:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update organizational unit (Admin only)
|
||||
router.put('/units/:id',
|
||||
authenticate,
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('name').optional().notEmpty().trim(),
|
||||
body('description').optional(),
|
||||
body('color').optional(),
|
||||
// allow updating persisted canvas positions from admin editor
|
||||
body('positionX').optional().isNumeric(),
|
||||
body('positionY').optional().isNumeric()
|
||||
],
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
|
||||
}
|
||||
|
||||
const { name, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY } = req.body
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE organizational_units
|
||||
SET name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
color = COALESCE(?, color),
|
||||
has_fuehrungsstelle = COALESCE(?, has_fuehrungsstelle),
|
||||
fuehrungsstelle_name = COALESCE(?, fuehrungsstelle_name),
|
||||
position_x = COALESCE(?, position_x),
|
||||
position_y = COALESCE(?, position_y),
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name || null,
|
||||
description !== undefined ? description : null,
|
||||
color || null,
|
||||
hasFuehrungsstelle !== undefined ? (hasFuehrungsstelle ? 1 : 0) : null,
|
||||
fuehrungsstelleName || null,
|
||||
positionX !== undefined ? Math.round(Number(positionX)) : null,
|
||||
positionY !== undefined ? Math.round(Number(positionY)) : null,
|
||||
now,
|
||||
req.params.id
|
||||
)
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
|
||||
}
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error updating organizational unit:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Assign employee to unit
|
||||
router.post('/assignments',
|
||||
authenticate,
|
||||
[
|
||||
body('employeeId').isUUID(),
|
||||
body('unitId').isUUID(),
|
||||
body('role').isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter'])
|
||||
],
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
|
||||
}
|
||||
|
||||
const { employeeId, unitId, role, isPrimary } = req.body
|
||||
const now = new Date().toISOString()
|
||||
const assignmentId = uuidv4()
|
||||
|
||||
// Permission model: users may only assign themselves and only as 'mitarbeiter' or 'beauftragter'
|
||||
const isAdmin = req.user?.role === 'admin' || req.user?.role === 'superuser'
|
||||
if (!isAdmin) {
|
||||
if (!req.user?.employeeId || req.user.employeeId !== employeeId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Cannot assign other employees' } })
|
||||
}
|
||||
if (!['mitarbeiter', 'beauftragter'].includes(role)) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Insufficient role to set this assignment' } })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate unit exists and is active
|
||||
const unit = db.prepare('SELECT id FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId)
|
||||
if (!unit) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
|
||||
}
|
||||
|
||||
// Check if assignment already exists
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM employee_unit_assignments
|
||||
WHERE employee_id = ? AND unit_id = ? AND role = ? AND end_date IS NULL
|
||||
`).get(employeeId, unitId, role)
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Assignment already exists' } })
|
||||
}
|
||||
|
||||
// If setting as primary, demote all other active assignments for this employee
|
||||
if (isPrimary) {
|
||||
db.prepare(`
|
||||
UPDATE employee_unit_assignments
|
||||
SET is_primary = 0, updated_at = ?
|
||||
WHERE employee_id = ? AND end_date IS NULL
|
||||
`).run(now, employeeId)
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO employee_unit_assignments (
|
||||
id, employee_id, unit_id, role,
|
||||
start_date, is_primary, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
assignmentId, employeeId, unitId, role,
|
||||
now, isPrimary ? 1 : 0, now, now
|
||||
)
|
||||
|
||||
res.json({ success: true, data: { id: assignmentId } })
|
||||
} catch (error) {
|
||||
logger.error('Error assigning employee to unit:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Get my organizational units
|
||||
router.get('/my-units', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (!req.user?.employeeId) {
|
||||
return res.json({ success: true, data: [] })
|
||||
}
|
||||
|
||||
const units = db.prepare(`
|
||||
SELECT
|
||||
ou.id, ou.code, ou.name, ou.type, ou.level,
|
||||
eua.role, eua.is_primary as isPrimary
|
||||
FROM employee_unit_assignments eua
|
||||
JOIN organizational_units ou ON ou.id = eua.unit_id
|
||||
WHERE eua.employee_id = ?
|
||||
AND (eua.end_date IS NULL OR eua.end_date > datetime('now'))
|
||||
AND ou.is_active = 1
|
||||
ORDER BY eua.is_primary DESC, ou.level, ou.name
|
||||
`).all(req.user.employeeId)
|
||||
|
||||
res.json({ success: true, data: units })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user units:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get my deputy assignments
|
||||
router.get('/deputies/my', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (!req.user?.employeeId) {
|
||||
return res.json({ success: true, data: { asDeputy: [], asPrincipal: [] } })
|
||||
}
|
||||
|
||||
// Get assignments where I'm the deputy
|
||||
const asDeputy = db.prepare(`
|
||||
SELECT
|
||||
da.id, da.principal_id as principalId, da.deputy_id as deputyId,
|
||||
da.unit_id as unitId, da.valid_from as validFrom, da.valid_until as validUntil,
|
||||
da.reason, da.can_delegate as canDelegate,
|
||||
p.first_name || ' ' || p.last_name as principalName,
|
||||
ou.name as unitName
|
||||
FROM deputy_assignments da
|
||||
JOIN employees p ON p.id = da.principal_id
|
||||
LEFT JOIN organizational_units ou ON ou.id = da.unit_id
|
||||
WHERE da.deputy_id = ?
|
||||
AND da.valid_from <= datetime('now')
|
||||
AND da.valid_until >= datetime('now')
|
||||
ORDER BY da.valid_from DESC
|
||||
`).all(req.user.employeeId)
|
||||
|
||||
// Get assignments where I'm the principal
|
||||
const asPrincipal = db.prepare(`
|
||||
SELECT
|
||||
da.id, da.principal_id as principalId, da.deputy_id as deputyId,
|
||||
da.unit_id as unitId, da.valid_from as validFrom, da.valid_until as validUntil,
|
||||
da.reason,
|
||||
d.first_name || ' ' || d.last_name as deputyName,
|
||||
ou.name as unitName
|
||||
FROM deputy_assignments da
|
||||
JOIN employees d ON d.id = da.deputy_id
|
||||
LEFT JOIN organizational_units ou ON ou.id = da.unit_id
|
||||
WHERE da.principal_id = ?
|
||||
AND da.valid_from <= datetime('now')
|
||||
AND da.valid_until >= datetime('now')
|
||||
ORDER BY da.valid_from DESC
|
||||
`).all(req.user.employeeId)
|
||||
|
||||
res.json({ success: true, data: { asDeputy, asPrincipal } })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching deputy assignments:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Create deputy assignment
|
||||
router.post('/deputies',
|
||||
authenticate,
|
||||
[
|
||||
body('deputyId').isUUID(),
|
||||
body('validFrom').isISO8601(),
|
||||
body('validUntil').isISO8601()
|
||||
],
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
|
||||
}
|
||||
|
||||
if (!req.user?.employeeId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'No employee linked to user' } })
|
||||
}
|
||||
|
||||
const { deputyId, unitId, validFrom, validUntil, reason, canDelegate } = req.body
|
||||
const now = new Date().toISOString()
|
||||
const assignmentId = uuidv4()
|
||||
|
||||
// Check for conflicts
|
||||
const conflict = db.prepare(`
|
||||
SELECT id FROM deputy_assignments
|
||||
WHERE principal_id = ? AND deputy_id = ?
|
||||
AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?))
|
||||
`).get(req.user.employeeId, deputyId, validFrom, validUntil, validFrom, validUntil)
|
||||
|
||||
if (conflict) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO deputy_assignments (
|
||||
id, principal_id, deputy_id, unit_id,
|
||||
valid_from, valid_until, reason, can_delegate,
|
||||
created_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
assignmentId, req.user.employeeId, deputyId, unitId || null,
|
||||
validFrom, validUntil, reason || null, canDelegate ? 1 : 0,
|
||||
req.user.id, now, now
|
||||
)
|
||||
|
||||
res.json({ success: true, data: { id: assignmentId } })
|
||||
} catch (error) {
|
||||
logger.error('Error creating deputy assignment:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Alias: Create deputy assignment for current user (same as above, convenient endpoint)
|
||||
router.post('/deputies/my',
|
||||
authenticate,
|
||||
[
|
||||
body('deputyId').isUUID(),
|
||||
body('validFrom').isISO8601(),
|
||||
body('validUntil').optional({ nullable: true }).isISO8601(),
|
||||
body('unitId').optional({ nullable: true }).isUUID()
|
||||
],
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
|
||||
}
|
||||
|
||||
if (!req.user?.employeeId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'No employee linked to user' } })
|
||||
}
|
||||
|
||||
const { deputyId, unitId, validFrom, validUntil, reason, canDelegate } = req.body
|
||||
const now = new Date().toISOString()
|
||||
const assignmentId = uuidv4()
|
||||
|
||||
// Check for conflicts
|
||||
const conflict = db.prepare(`
|
||||
SELECT id FROM deputy_assignments
|
||||
WHERE principal_id = ? AND deputy_id = ?
|
||||
AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?))
|
||||
`).get(req.user.employeeId, deputyId, validFrom, validUntil || validFrom, validFrom, validUntil || validFrom)
|
||||
|
||||
if (conflict) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO deputy_assignments (
|
||||
id, principal_id, deputy_id, unit_id,
|
||||
valid_from, valid_until, reason, can_delegate,
|
||||
created_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
assignmentId, req.user.employeeId, deputyId, unitId || null,
|
||||
validFrom, validUntil || validFrom, reason || null, canDelegate ? 1 : 0,
|
||||
req.user.id, now, now
|
||||
)
|
||||
|
||||
res.json({ success: true, data: { id: assignmentId } })
|
||||
} catch (error) {
|
||||
logger.error('Error creating deputy assignment (my):', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delegate deputy assignment
|
||||
router.post('/deputies/delegate',
|
||||
authenticate,
|
||||
[
|
||||
body('assignmentId').isUUID(),
|
||||
body('toDeputyId').isUUID(),
|
||||
body('reason').optional().trim()
|
||||
],
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
|
||||
}
|
||||
|
||||
if (!req.user?.employeeId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'No employee linked to user' } })
|
||||
}
|
||||
|
||||
const { assignmentId, toDeputyId, reason } = req.body
|
||||
const now = new Date().toISOString()
|
||||
const delegationId = uuidv4()
|
||||
|
||||
// Check if user can delegate this assignment
|
||||
const assignment = db.prepare(`
|
||||
SELECT * FROM deputy_assignments
|
||||
WHERE id = ? AND deputy_id = ? AND can_delegate = 1
|
||||
AND valid_from <= datetime('now')
|
||||
AND valid_until >= datetime('now')
|
||||
`).get(assignmentId, req.user.employeeId) as any
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Cannot delegate this assignment' } })
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO deputy_delegations (
|
||||
id, original_assignment_id, from_deputy_id, to_deputy_id,
|
||||
reason, delegated_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
delegationId, assignmentId, req.user.employeeId, toDeputyId,
|
||||
reason || null, now, now
|
||||
)
|
||||
|
||||
res.json({ success: true, data: { id: delegationId } })
|
||||
} catch (error) {
|
||||
logger.error('Error delegating deputy assignment:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete deputy assignment (principal or admin)
|
||||
router.delete('/deputies/:id', authenticate, async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
if (!id) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Missing id' } })
|
||||
}
|
||||
|
||||
// Load assignment
|
||||
const assignment = db.prepare(`
|
||||
SELECT id, principal_id as principalId FROM deputy_assignments WHERE id = ?
|
||||
`).get(id) as any
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Assignment not found' } })
|
||||
}
|
||||
|
||||
// Permission: principal or admin/superuser
|
||||
const isAdmin = req.user?.role === 'admin' || req.user?.role === 'superuser'
|
||||
if (!isAdmin && req.user?.employeeId !== assignment.principalId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Access denied' } })
|
||||
}
|
||||
|
||||
// Delete delegations first (FK not ON DELETE CASCADE)
|
||||
db.prepare('DELETE FROM deputy_delegations WHERE original_assignment_id = ?').run(id)
|
||||
// Delete assignment
|
||||
db.prepare('DELETE FROM deputy_assignments WHERE id = ?').run(id)
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting deputy assignment:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get deputy chain for an assignment
|
||||
router.get('/deputies/chain/:id', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const chain = []
|
||||
|
||||
// Get original assignment
|
||||
const assignment = db.prepare(`
|
||||
SELECT
|
||||
da.*,
|
||||
p.first_name || ' ' || p.last_name as principalName,
|
||||
d.first_name || ' ' || d.last_name as deputyName
|
||||
FROM deputy_assignments da
|
||||
JOIN employees p ON p.id = da.principal_id
|
||||
JOIN employees d ON d.id = da.deputy_id
|
||||
WHERE da.id = ?
|
||||
`).get(req.params.id) as any
|
||||
|
||||
if (!assignment) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Assignment not found' } })
|
||||
}
|
||||
|
||||
chain.push({
|
||||
type: 'original',
|
||||
from: assignment.principalName,
|
||||
to: assignment.deputyName,
|
||||
reason: assignment.reason
|
||||
})
|
||||
|
||||
// Get all delegations
|
||||
const delegations = db.prepare(`
|
||||
SELECT
|
||||
dd.*,
|
||||
f.first_name || ' ' || f.last_name as fromName,
|
||||
t.first_name || ' ' || t.last_name as toName
|
||||
FROM deputy_delegations dd
|
||||
JOIN employees f ON f.id = dd.from_deputy_id
|
||||
JOIN employees t ON t.id = dd.to_deputy_id
|
||||
WHERE dd.original_assignment_id = ?
|
||||
ORDER BY dd.delegated_at
|
||||
`).all(req.params.id) as any[]
|
||||
|
||||
delegations.forEach(del => {
|
||||
chain.push({
|
||||
type: 'delegation',
|
||||
from: del.fromName,
|
||||
to: del.toName,
|
||||
reason: del.reason,
|
||||
delegatedAt: del.delegated_at
|
||||
})
|
||||
})
|
||||
|
||||
res.json({ success: true, data: chain })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching deputy chain:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get special positions
|
||||
router.get('/special-positions', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const positions = db.prepare(`
|
||||
SELECT
|
||||
sp.id, sp.position_type as positionType,
|
||||
sp.employee_id as employeeId,
|
||||
sp.unit_id as unitId,
|
||||
sp.start_date as startDate,
|
||||
sp.end_date as endDate,
|
||||
e.first_name || ' ' || e.last_name as employeeName,
|
||||
e.photo,
|
||||
ou.name as unitName
|
||||
FROM special_positions sp
|
||||
JOIN employees e ON e.id = sp.employee_id
|
||||
LEFT JOIN organizational_units ou ON ou.id = sp.unit_id
|
||||
WHERE sp.is_active = 1
|
||||
AND (sp.end_date IS NULL OR sp.end_date > datetime('now'))
|
||||
ORDER BY sp.position_type, e.last_name
|
||||
`).all()
|
||||
|
||||
res.json({ success: true, data: positions })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching special positions:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
399
backend/src/routes/organizationImport.ts
Normale Datei
399
backend/src/routes/organizationImport.ts
Normale Datei
@ -0,0 +1,399 @@
|
||||
import { Router } from 'express'
|
||||
import multer from 'multer'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
const pdfParse = require('pdf-parse')
|
||||
import { db } from '../config/secureDatabase'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Configure multer for PDF uploads
|
||||
const upload = multer({
|
||||
dest: 'uploads/temp/',
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf') {
|
||||
cb(null, true)
|
||||
} else {
|
||||
cb(new Error('Only PDF files are allowed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to parse organizational structure from PDF text
|
||||
function parseOrganizationFromText(text: string) {
|
||||
const units: any[] = []
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
// Color mapping for departments
|
||||
const colors: Record<string, string> = {
|
||||
'1': '#dc2626',
|
||||
'2': '#ea580c',
|
||||
'3': '#0891b2',
|
||||
'4': '#7c3aed',
|
||||
'5': '#0d9488',
|
||||
'6': '#be185d',
|
||||
'ZA': '#6b7280'
|
||||
}
|
||||
|
||||
let currentAbteilung: any = null
|
||||
let currentDezernat: any = null
|
||||
|
||||
lines.forEach(line => {
|
||||
// Check for Direktor
|
||||
if (patterns.direktor.test(line)) {
|
||||
units.push({
|
||||
code: 'DIR',
|
||||
name: 'Direktor LKA NRW',
|
||||
type: 'direktion',
|
||||
level: 0,
|
||||
parentId: null,
|
||||
color: '#1e3a8a'
|
||||
})
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
code: `Abt ${abtNum}`,
|
||||
name: abtName || `Abteilung ${abtNum}`,
|
||||
type: 'abteilung',
|
||||
level: 1,
|
||||
parentId: 'DIR',
|
||||
color: colors[abtNum] || '#6b7280',
|
||||
hasFuehrungsstelle: abtNum !== 'ZA'
|
||||
}
|
||||
units.push(currentAbteilung)
|
||||
currentDezernat = null
|
||||
}
|
||||
|
||||
// Check for Dezernat
|
||||
const dezMatch = line.match(/(?:Dezernat|Dez)\s+([\d\s]+)/i)
|
||||
if (dezMatch && currentAbteilung) {
|
||||
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
|
||||
}
|
||||
units.push(currentDezernat)
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
// Check for Stabsstelle
|
||||
if (patterns.stabsstelle.test(line)) {
|
||||
units.push({
|
||||
code: 'LStab',
|
||||
name: 'Leitungsstab',
|
||||
type: 'stabsstelle',
|
||||
level: 1,
|
||||
parentId: 'DIR',
|
||||
color: '#6b7280'
|
||||
})
|
||||
}
|
||||
|
||||
// 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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return units
|
||||
}
|
||||
|
||||
// Import organization from PDF
|
||||
router.post('/import-pdf',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
upload.single('pdf'),
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
let tempFilePath: string | null = null
|
||||
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'No PDF file uploaded' }
|
||||
})
|
||||
}
|
||||
|
||||
tempFilePath = req.file.path
|
||||
|
||||
// Read PDF file
|
||||
const pdfBuffer = fs.readFileSync(tempFilePath)
|
||||
|
||||
// Parse PDF using pdf-parse
|
||||
let extractedText = ''
|
||||
try {
|
||||
const pdfData = await pdfParse(pdfBuffer)
|
||||
extractedText = pdfData.text
|
||||
|
||||
logger.info(`PDF parsed successfully: ${pdfData.numpages} pages, ${extractedText.length} characters`)
|
||||
} catch (parseError) {
|
||||
logger.error('PDF parsing error:', parseError)
|
||||
// Fallback to simulated data if parsing fails
|
||||
extractedText = `
|
||||
Direktor LKA NRW
|
||||
Leitungsstab
|
||||
Personalrat
|
||||
Schwerbehindertenvertretung
|
||||
Abteilung 1 - Organisierte Kriminalität
|
||||
Dezernat 11 - Ermittlungen OK
|
||||
SG 11.1 - Grundsatzfragen
|
||||
Dezernat 12 - Wirtschaftskriminalität
|
||||
Abteilung 2 - Terrorismusbekämpfung
|
||||
Dezernat 21 - Ermittlungen
|
||||
Abteilung 3 - Strategische Kriminalitätsbekämpfung
|
||||
Abteilung 4 - Cybercrime
|
||||
Abteilung 5 - Kriminalwissenschaftliches Institut
|
||||
Abteilung 6 - Fachaufsicht
|
||||
Zentralabteilung
|
||||
`
|
||||
}
|
||||
|
||||
// Parse the organizational structure
|
||||
const parsedUnits = parseOrganizationFromText(extractedText)
|
||||
|
||||
if (parsedUnits.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'No organizational units could be extracted from the PDF' }
|
||||
})
|
||||
}
|
||||
|
||||
// Clear existing organization (optional - could be a parameter)
|
||||
if (req.body.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<string, string> = {}
|
||||
|
||||
// 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
|
||||
SET name = ?, type = ?, level = ?, parent_id = ?,
|
||||
color = ?, order_index = ?, has_fuehrungsstelle = ?,
|
||||
is_active = 1, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
unit.name,
|
||||
unit.type,
|
||||
unit.level,
|
||||
parentId,
|
||||
unit.color || null,
|
||||
index,
|
||||
unit.hasFuehrungsstelle ? 1 : 0,
|
||||
now,
|
||||
existing.id
|
||||
)
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO organizational_units (
|
||||
id, code, name, type, level, parent_id,
|
||||
color, order_index, has_fuehrungsstelle,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
unitIdMap[unit.code],
|
||||
unit.code,
|
||||
unit.name,
|
||||
unit.type,
|
||||
unit.level,
|
||||
parentId,
|
||||
unit.color || null,
|
||||
index,
|
||||
unit.hasFuehrungsstelle ? 1 : 0,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
tx()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: `Successfully imported ${parsedUnits.length} organizational units`,
|
||||
unitsImported: parsedUnits.length,
|
||||
units: parsedUnits.map(u => ({
|
||||
code: u.code,
|
||||
name: u.name,
|
||||
type: u.type
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error importing PDF:', error)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: { message: 'Failed to import PDF: ' + (error as Error).message }
|
||||
})
|
||||
} finally {
|
||||
// Clean up temp file
|
||||
if (tempFilePath && fs.existsSync(tempFilePath)) {
|
||||
fs.unlinkSync(tempFilePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Import from structured JSON
|
||||
router.post('/import-json',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const { units, clearExisting } = req.body
|
||||
|
||||
if (!units || !Array.isArray(units)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid units data' }
|
||||
})
|
||||
}
|
||||
|
||||
// Clear existing if requested
|
||||
if (clearExisting) {
|
||||
db.prepare('DELETE FROM organizational_units').run()
|
||||
}
|
||||
|
||||
// Import units
|
||||
const now = new Date().toISOString()
|
||||
units.forEach((unit: any, index: number) => {
|
||||
const id = uuidv4()
|
||||
db.prepare(`
|
||||
INSERT INTO organizational_units (
|
||||
id, code, name, type, level, parent_id,
|
||||
color, order_index, description,
|
||||
has_fuehrungsstelle, is_active, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
unit.code,
|
||||
unit.name,
|
||||
unit.type,
|
||||
unit.level,
|
||||
unit.parentId || null,
|
||||
unit.color || null,
|
||||
index,
|
||||
unit.description || null,
|
||||
unit.hasFuehrungsstelle ? 1 : 0,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: `Successfully imported ${units.length} units`,
|
||||
count: units.length
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error importing JSON:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
@ -4,7 +4,6 @@ import bcrypt from 'bcrypt'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { db, encryptedDb } from '../config/secureDatabase'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission } from '../middleware/roleAuth'
|
||||
import { User, UserRole } from '@skillmate/shared'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
import { emailService } from '../services/emailService'
|
||||
@ -13,7 +12,7 @@ import { logger } from '../utils/logger'
|
||||
const router = Router()
|
||||
|
||||
// Get all users (admin only)
|
||||
router.get('/', authenticate, requirePermission('users:read'), async (req: AuthRequest, res, next) => {
|
||||
router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const users = db.prepare(`
|
||||
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
|
||||
@ -57,7 +56,7 @@ router.get('/', authenticate, requirePermission('users:read'), async (req: AuthR
|
||||
// Update user role (admin only)
|
||||
router.put('/:id/role',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
authorize('admin'),
|
||||
[
|
||||
body('role').isIn(['admin', 'superuser', 'user'])
|
||||
],
|
||||
@ -109,7 +108,7 @@ router.put('/:id/role',
|
||||
// Bulk create users from employees
|
||||
router.post('/bulk-create-from-employees',
|
||||
authenticate,
|
||||
requirePermission('users:create'),
|
||||
authorize('admin'),
|
||||
[
|
||||
body('employeeIds').isArray({ min: 1 }),
|
||||
body('role').isIn(['admin', 'superuser', 'user'])
|
||||
@ -189,7 +188,7 @@ router.post('/bulk-create-from-employees',
|
||||
// Update user status (admin only)
|
||||
router.put('/:id/status',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
authorize('admin'),
|
||||
[
|
||||
body('isActive').isBoolean()
|
||||
],
|
||||
@ -241,7 +240,7 @@ router.put('/:id/status',
|
||||
// Reset user password (admin only)
|
||||
router.post('/:id/reset-password',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
authorize('admin'),
|
||||
[
|
||||
body('newPassword').optional().isLength({ min: 8 })
|
||||
],
|
||||
@ -294,7 +293,7 @@ router.post('/:id/reset-password',
|
||||
// Delete user (admin only)
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
requirePermission('users:delete'),
|
||||
authorize('admin'),
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
@ -333,7 +332,7 @@ export default router
|
||||
// Create user account from existing employee (admin only)
|
||||
router.post('/create-from-employee',
|
||||
authenticate,
|
||||
requirePermission('users:create'),
|
||||
authorize('admin'),
|
||||
[
|
||||
body('employeeId').notEmpty().isString(),
|
||||
body('username').optional().isString().isLength({ min: 3 }),
|
||||
@ -452,7 +451,7 @@ router.post('/purge',
|
||||
// Send temporary password via email to user's email
|
||||
router.post('/:id/send-temp-password',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
authorize('admin'),
|
||||
[
|
||||
body('password').notEmpty().isString().isLength({ min: 6 })
|
||||
],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { db } from '../../src/config/secureDatabase'
|
||||
import { db } from '../../config/secureDatabase'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { FieldEncryption } from '../../src/services/encryption'
|
||||
import { FieldEncryption } from '../../services/encryption'
|
||||
import { User, LoginResponse } from '@skillmate/shared'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
|
||||
107
backend/test-organigramm.txt
Normale Datei
107
backend/test-organigramm.txt
Normale Datei
@ -0,0 +1,107 @@
|
||||
LKA NRW Organigramm
|
||||
|
||||
Direktor LKA NRW
|
||||
Führungsstelle Direktor
|
||||
|
||||
Leitungsstab
|
||||
- Presse- und Öffentlichkeitsarbeit
|
||||
- Controlling
|
||||
- Innenrevision
|
||||
|
||||
Personalrat
|
||||
Schwerbehindertenvertretung
|
||||
Datenschutzbeauftragter
|
||||
Gleichstellungsbeauftragte
|
||||
|
||||
Abteilung 1 - Organisierte Kriminalität
|
||||
Führungsstelle Abt. 1
|
||||
|
||||
Dezernat 11 - Ermittlungen OK
|
||||
SG 11.1 - Grundsatzfragen OK
|
||||
SG 11.2 - Operative Auswertung
|
||||
SG 11.3 - Finanzermittlungen
|
||||
|
||||
Dezernat 12 - Wirtschaftskriminalität
|
||||
SG 12.1 - Komplexe Wirtschaftsverfahren
|
||||
SG 12.2 - Korruptionsdelikte
|
||||
SG 12.3 - Vermögensabschöpfung
|
||||
|
||||
Dezernat 13 - Rauschgiftkriminalität
|
||||
SG 13.1 - Internationale Drogenhandel
|
||||
SG 13.2 - Synthetische Drogen
|
||||
TD 13.3 - Darknet-Ermittlungen
|
||||
|
||||
Abteilung 2 - Terrorismusbekämpfung / Staatsschutz
|
||||
Führungsstelle Abt. 2
|
||||
|
||||
Dezernat 21 - Islamistischer Terrorismus
|
||||
SG 21.1 - Operative Ermittlungen
|
||||
SG 21.2 - Gefährderanalyse
|
||||
|
||||
Dezernat 22 - Politisch motivierte Kriminalität
|
||||
SG 22.1 - Rechtsextremismus
|
||||
SG 22.2 - Linksextremismus
|
||||
SG 22.3 - Ausländerextremismus
|
||||
|
||||
Abteilung 3 - Strategische Kriminalitätsbekämpfung
|
||||
Führungsstelle Abt. 3
|
||||
|
||||
Dezernat 31 - Analyse und Auswertung
|
||||
SG 31.1 - Strategische Analyse
|
||||
SG 31.2 - Lagebilderstellung
|
||||
|
||||
Dezernat 32 - Informationsmanagement
|
||||
SG 32.1 - Datenbanken und Systeme
|
||||
SG 32.2 - Informationsaustausch
|
||||
|
||||
Abteilung 4 - Cybercrime
|
||||
Führungsstelle Abt. 4
|
||||
|
||||
Dezernat 41 - Digitale Ermittlungen
|
||||
SG 41.1 - Internetkriminalität
|
||||
SG 41.2 - Digitale Forensik
|
||||
|
||||
Dezernat 42 - Cybersecurity
|
||||
SG 42.1 - Critical Infrastructure
|
||||
SG 42.2 - Incident Response
|
||||
|
||||
Abteilung 5 - Kriminalwissenschaftliches Institut
|
||||
Führungsstelle Abt. 5
|
||||
|
||||
Dezernat 51 - Kriminaltechnik
|
||||
SG 51.1 - DNA-Analytik
|
||||
SG 51.2 - Daktyloskopie
|
||||
SG 51.3 - Ballistik
|
||||
|
||||
Dezernat 52 - Digitale Forensik
|
||||
SG 52.1 - Mobile Forensik
|
||||
SG 52.2 - Computer Forensik
|
||||
|
||||
Abteilung 6 - Fachaufsicht
|
||||
Führungsstelle Abt. 6
|
||||
|
||||
Dezernat 61 - Qualitätsmanagement
|
||||
SG 61.1 - Standards und Richtlinien
|
||||
SG 61.2 - Evaluierung
|
||||
|
||||
Dezernat 62 - Aus- und Fortbildung
|
||||
SG 62.1 - Fachschulungen
|
||||
SG 62.2 - Führungskräfteentwicklung
|
||||
|
||||
Zentralabteilung
|
||||
|
||||
Dezernat ZA 1 - Personal
|
||||
SG ZA 1.1 - Personalverwaltung
|
||||
SG ZA 1.2 - Personalentwicklung
|
||||
|
||||
Dezernat ZA 2 - Haushalt und Finanzen
|
||||
SG ZA 2.1 - Haushaltsplanung
|
||||
SG ZA 2.2 - Beschaffung
|
||||
|
||||
Dezernat ZA 3 - IT und Digitalisierung
|
||||
SG ZA 3.1 - IT-Infrastruktur
|
||||
SG ZA 3.2 - Digitale Transformation
|
||||
|
||||
Dezernat ZA 4 - Organisation und Recht
|
||||
SG ZA 4.1 - Organisationsentwicklung
|
||||
SG ZA 4.2 - Rechtsangelegenheiten
|
||||
336
frontend/src/components/DeputyManagement.tsx
Normale Datei
336
frontend/src/components/DeputyManagement.tsx
Normale Datei
@ -0,0 +1,336 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import api from '../services/api'
|
||||
import { DeputyAssignment, Employee } from '@skillmate/shared'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
export default function DeputyManagement() {
|
||||
const [asPrincipal, setAsPrincipal] = useState<any[]>([])
|
||||
const [asDeputy, setAsDeputy] = useState<any[]>([])
|
||||
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [formData, setFormData] = useState({
|
||||
deputyId: '',
|
||||
validFrom: new Date().toISOString().split('T')[0],
|
||||
validUntil: '',
|
||||
reason: '',
|
||||
canDelegate: true,
|
||||
unitId: ''
|
||||
})
|
||||
const { user } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
loadDeputies()
|
||||
loadEmployees()
|
||||
}, [])
|
||||
|
||||
const loadDeputies = async () => {
|
||||
try {
|
||||
const response = await api.get('/organization/deputies/my')
|
||||
if (response.data.success) {
|
||||
// Backend returns { asDeputy, asPrincipal }
|
||||
setAsPrincipal(response.data.data.asPrincipal || [])
|
||||
setAsDeputy(response.data.data.asDeputy || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load deputies:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadEmployees = async () => {
|
||||
try {
|
||||
const response = await api.get('/employees/public')
|
||||
if (response.data.success) {
|
||||
setAvailableEmployees(response.data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load employees:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDeputy = async () => {
|
||||
try {
|
||||
const response = await api.post('/organization/deputies/my', {
|
||||
...formData,
|
||||
validFrom: new Date(formData.validFrom).toISOString(),
|
||||
validUntil: formData.validUntil ? new Date(formData.validUntil).toISOString() : null
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
await loadDeputies()
|
||||
setShowAddDialog(false)
|
||||
resetForm()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add deputy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveDeputy = async (id: string) => {
|
||||
if (!confirm('Möchten Sie diese Vertretung wirklich entfernen?')) return
|
||||
|
||||
try {
|
||||
await api.delete(`/organization/deputies/${id}`)
|
||||
await loadDeputies()
|
||||
} catch (error) {
|
||||
console.error('Failed to remove deputy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelegate = async (assignmentId: string) => {
|
||||
const toDeputyId = prompt('Bitte geben Sie die Mitarbeiter-ID des neuen Vertreters ein:')
|
||||
if (!toDeputyId) return
|
||||
|
||||
const reason = prompt('Grund für die Weitergabe (optional):')
|
||||
|
||||
try {
|
||||
const response = await api.post('/organization/deputies/delegate', {
|
||||
assignmentId,
|
||||
toDeputyId,
|
||||
validFrom: new Date().toISOString(),
|
||||
reason
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
await loadDeputies()
|
||||
alert('Vertretung erfolgreich weitergegeben')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delegate:', error)
|
||||
alert('Fehler beim Weitergeben der Vertretung')
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
deputyId: '',
|
||||
validFrom: new Date().toISOString().split('T')[0],
|
||||
validUntil: '',
|
||||
reason: '',
|
||||
canDelegate: true,
|
||||
unitId: ''
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4">Lade Vertretungen...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold mb-2 dark:text-white">Meine Vertretungen</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Verwalten Sie hier Ihre Vertretungsregelungen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Deputies */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold dark:text-white">Aktuelle Vertretungen</h3>
|
||||
<button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
+ Vertretung hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{asPrincipal.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{asPrincipal.map((deputy: any) => (
|
||||
<div
|
||||
key={deputy.id}
|
||||
className="border dark:border-gray-700 rounded-lg p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
|
||||
👤
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium dark:text-white">{deputy.deputyName}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{new Date(deputy.validFrom).toLocaleDateString('de-DE')}
|
||||
{deputy.validUntil && ` - ${new Date(deputy.validUntil).toLocaleDateString('de-DE')}`}
|
||||
</p>
|
||||
{deputy.reason && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Grund: {deputy.reason}
|
||||
</p>
|
||||
)}
|
||||
{deputy.unitName && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Für: {deputy.unitName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(deputy.canDelegate === 1 || deputy.canDelegate === true) && (
|
||||
<button
|
||||
onClick={() => handleDelegate(deputy.id)}
|
||||
className="px-3 py-1 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Vertretung weitergeben"
|
||||
>
|
||||
↔️ Weitergeben
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleRemoveDeputy(deputy.id)}
|
||||
className="px-3 py-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Keine aktiven Vertretungen vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Deputy Dialog */}
|
||||
{showAddDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
|
||||
<h3 className="text-xl font-bold mb-4 dark:text-white">
|
||||
Vertretung hinzufügen
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Vertreter *
|
||||
</label>
|
||||
<select
|
||||
value={formData.deputyId}
|
||||
onChange={(e) => setFormData({ ...formData, deputyId: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
required
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{availableEmployees.map(emp => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.firstName} {emp.lastName} - {emp.position}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Von *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.validFrom}
|
||||
onChange={(e) => setFormData({ ...formData, validFrom: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Bis (optional)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.validUntil}
|
||||
onChange={(e) => setFormData({ ...formData, validUntil: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Grund (optional)
|
||||
</label>
|
||||
<select
|
||||
value={formData.reason}
|
||||
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">Kein Grund angegeben</option>
|
||||
<option value="Urlaub">Urlaub</option>
|
||||
<option value="Dienstreise">Dienstreise</option>
|
||||
<option value="Fortbildung">Fortbildung</option>
|
||||
<option value="Krankheit">Krankheit</option>
|
||||
<option value="Sonstiges">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.canDelegate}
|
||||
onChange={(e) => setFormData({ ...formData, canDelegate: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm dark:text-gray-300">
|
||||
Vertreter darf Vertretung weitergeben
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddDialog(false)
|
||||
resetForm()
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddDeputy}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Deputy Roles */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 dark:text-white">
|
||||
Ich vertrete
|
||||
</h3>
|
||||
{asDeputy.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{asDeputy.map((item: any) => (
|
||||
<div key={item.id} className="border dark:border-gray-700 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium dark:text-white">{item.principalName}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{new Date(item.validFrom).toLocaleDateString('de-DE')}
|
||||
{item.validUntil && ` - ${new Date(item.validUntil).toLocaleDateString('de-DE')}`}
|
||||
</p>
|
||||
{item.unitName && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">Für: {item.unitName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
Keine Einträge.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,8 @@ import { useAuthStore } from '../stores/authStore'
|
||||
import { authApi } from '../services/api'
|
||||
import { usePermissions } from '../hooks/usePermissions'
|
||||
import WindowControls from './WindowControls'
|
||||
import OrganizationChart from './OrganizationChart'
|
||||
import { Building2 } from 'lucide-react'
|
||||
|
||||
export default function Header() {
|
||||
const { isDarkMode, toggleTheme } = useThemeStore()
|
||||
@ -14,6 +16,7 @@ export default function Header() {
|
||||
const [loginForm, setLoginForm] = useState({ username: '', password: '' })
|
||||
const [loginError, setLoginError] = useState('')
|
||||
const [loginLoading, setLoginLoading] = useState(false)
|
||||
const [showOrganigramm, setShowOrganigramm] = useState(false)
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@ -119,6 +122,14 @@ export default function Header() {
|
||||
Admin
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowOrganigramm(true)}
|
||||
className="btn-secondary text-sm px-3 py-1 h-8 flex items-center gap-1"
|
||||
title="Organigramm anzeigen"
|
||||
>
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Organigramm</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn-secondary text-sm px-3 py-1 h-8"
|
||||
@ -152,6 +163,11 @@ export default function Header() {
|
||||
</div>
|
||||
|
||||
<WindowControls />
|
||||
|
||||
{/* Organization Chart Modal */}
|
||||
{showOrganigramm && (
|
||||
<OrganizationChart onClose={() => setShowOrganigramm(false)} />
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
394
frontend/src/components/OrganizationChart.tsx
Normale Datei
394
frontend/src/components/OrganizationChart.tsx
Normale Datei
@ -0,0 +1,394 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { OrganizationalUnit, EmployeeUnitAssignment } from '@skillmate/shared'
|
||||
import api from '../services/api'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
interface OrganizationChartProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function OrganizationChart({ onClose }: OrganizationChartProps) {
|
||||
const [units, setUnits] = useState<OrganizationalUnit[]>([])
|
||||
const [hierarchy, setHierarchy] = useState<any[]>([])
|
||||
const [selectedUnit, setSelectedUnit] = useState<OrganizationalUnit | null>(null)
|
||||
const [unitEmployees, setUnitEmployees] = useState<any[]>([])
|
||||
const [myUnits, setMyUnits] = useState<any[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<string>('')
|
||||
const [zoomLevel, setZoomLevel] = useState(1)
|
||||
const [panPosition, setPanPosition] = useState({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const { user } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
loadOrganization()
|
||||
loadMyUnits()
|
||||
}, [])
|
||||
|
||||
const loadOrganization = async () => {
|
||||
try {
|
||||
const response = await api.get('/organization/hierarchy')
|
||||
if (response.data.success) {
|
||||
setHierarchy(response.data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load organization:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMyUnits = async () => {
|
||||
try {
|
||||
const response = await api.get('/organization/my-units')
|
||||
if (response.data.success) {
|
||||
setMyUnits(response.data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load my units:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadUnitDetails = async (unitId: string) => {
|
||||
try {
|
||||
const response = await api.get(`/organization/units/${unitId}`)
|
||||
if (response.data.success) {
|
||||
setSelectedUnit(response.data.data)
|
||||
setUnitEmployees(response.data.data.employees || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load unit details:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const assignMeToSelectedUnit = async () => {
|
||||
if (!selectedUnit || !user?.employeeId) return
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
await api.post('/organization/assignments', {
|
||||
employeeId: user.employeeId,
|
||||
unitId: selectedUnit.id,
|
||||
role: 'mitarbeiter',
|
||||
isPrimary: true
|
||||
})
|
||||
setMessage('Position erfolgreich gesetzt.')
|
||||
await loadMyUnits()
|
||||
await loadUnitDetails(selectedUnit.id)
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.error?.message || 'Zuweisung fehlgeschlagen'
|
||||
setMessage(`Fehler: ${msg}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
direktion: '🏛️',
|
||||
abteilung: '🏢',
|
||||
dezernat: '📁',
|
||||
sachgebiet: '📋',
|
||||
teildezernat: '🔧',
|
||||
fuehrungsstelle: '⭐',
|
||||
stabsstelle: '🎯',
|
||||
sondereinheit: '🛡️'
|
||||
}
|
||||
return icons[type] || '📄'
|
||||
}
|
||||
|
||||
const getUnitColor = (level: number) => {
|
||||
const colors = [
|
||||
'bg-gradient-to-r from-purple-500 to-purple-700',
|
||||
'bg-gradient-to-r from-pink-500 to-pink-700',
|
||||
'bg-gradient-to-r from-blue-500 to-blue-700',
|
||||
'bg-gradient-to-r from-green-500 to-green-700',
|
||||
'bg-gradient-to-r from-orange-500 to-orange-700',
|
||||
'bg-gradient-to-r from-teal-500 to-teal-700'
|
||||
]
|
||||
return colors[level % colors.length]
|
||||
}
|
||||
|
||||
const handleUnitClick = (unit: OrganizationalUnit) => {
|
||||
setSelectedUnit(unit)
|
||||
loadUnitDetails(unit.id)
|
||||
}
|
||||
|
||||
const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 0.2, 3))
|
||||
const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 0.2, 0.3))
|
||||
const handleResetView = () => {
|
||||
setZoomLevel(1)
|
||||
setPanPosition({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button === 0) { // Left click only
|
||||
setIsDragging(true)
|
||||
setDragStart({ x: e.clientX - panPosition.x, y: e.clientY - panPosition.y })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
setPanPosition({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const renderUnit = (unit: any, level: number = 0) => {
|
||||
const isMyUnit = myUnits.some(u => u.id === unit.id)
|
||||
const isSearchMatch = searchTerm && unit.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
return (
|
||||
<div key={unit.id} className="flex flex-col items-center">
|
||||
<div
|
||||
onClick={() => handleUnitClick(unit)}
|
||||
className={`
|
||||
cursor-pointer transform transition-all duration-200 hover:scale-105
|
||||
${isMyUnit ? 'ring-4 ring-yellow-400' : ''}
|
||||
${isSearchMatch ? 'ring-4 ring-blue-400 animate-pulse' : ''}
|
||||
`}
|
||||
>
|
||||
<div className={`rounded-lg shadow-lg overflow-hidden bg-white dark:bg-gray-800 min-w-[200px] max-w-[250px]`}>
|
||||
<div className={`p-2 text-white ${getUnitColor(level)}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg">{getTypeIcon(unit.type)}</span>
|
||||
{unit.code && <span className="text-xs opacity-90">{unit.code}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h4 className="font-semibold text-sm dark:text-white">{unit.name}</h4>
|
||||
{unit.hasFuehrungsstelle && (
|
||||
<span className="inline-block px-2 py-1 mt-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">
|
||||
FüSt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unit.children && unit.children.length > 0 && (
|
||||
<div className="flex flex-col items-center mt-4">
|
||||
<div className="w-px h-8 bg-gray-300 dark:bg-gray-600"></div>
|
||||
<div className="flex gap-4">
|
||||
{unit.children.map((child: any) => (
|
||||
<div key={child.id} className="flex flex-col items-center">
|
||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600"></div>
|
||||
{renderUnit(child, level + 1)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-8">
|
||||
<div className="text-lg">Lade Organigramm...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex">
|
||||
{/* Main Modal */}
|
||||
<div className="flex-1 bg-white dark:bg-gray-900 flex">
|
||||
{/* Left Sidebar */}
|
||||
<div className="w-64 bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 p-4 overflow-y-auto">
|
||||
<h3 className="font-semibold mb-4 dark:text-white">Navigation</h3>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche Einheit..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="space-y-2">
|
||||
<button className="w-full text-left px-3 py-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 dark:text-white">
|
||||
📍 Meine Position
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 dark:text-white">
|
||||
👥 Führungsebene
|
||||
</button>
|
||||
<button className="w-full text-left px-3 py-2 rounded hover:bg-gray-200 dark:hover:bg-gray-700 dark:text-white">
|
||||
🎖️ Beauftragte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Department Filters */}
|
||||
<h4 className="font-semibold mt-6 mb-2 dark:text-white">Abteilungen</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[1, 2, 3, 4, 5, 6].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
className={`px-3 py-2 rounded text-white text-sm ${getUnitColor(n)}`}
|
||||
>
|
||||
Abt. {n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Chart Area */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 p-4 z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold dark:text-white">LKA NRW Organigramm</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute top-20 right-4 z-10 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-2 bg-white dark:bg-gray-700 shadow rounded hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
🔍+
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-2 bg-white dark:bg-gray-700 shadow rounded hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
🔍-
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetView}
|
||||
className="p-2 bg-white dark:bg-gray-700 shadow rounded hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
🎯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chart Canvas */}
|
||||
<div
|
||||
className="absolute inset-0 mt-16 overflow-auto cursor-move"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<div
|
||||
className="p-8"
|
||||
style={{
|
||||
transform: `scale(${zoomLevel}) translate(${panPosition.x / zoomLevel}px, ${panPosition.y / zoomLevel}px)`,
|
||||
transformOrigin: 'center',
|
||||
transition: isDragging ? 'none' : 'transform 0.2s'
|
||||
}}
|
||||
>
|
||||
{hierarchy.map(unit => renderUnit(unit))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Details */}
|
||||
{selectedUnit && (
|
||||
<div className="w-80 bg-gray-50 dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 p-4 overflow-y-auto">
|
||||
<div className={`p-3 text-white rounded-lg mb-4 ${getUnitColor(selectedUnit.level || 0)}`}>
|
||||
<h3 className="text-lg font-semibold">{selectedUnit.name}</h3>
|
||||
{selectedUnit.code && <p className="text-sm opacity-90">{selectedUnit.code}</p>}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<div className="flex gap-4">
|
||||
<button className="pb-2 border-b-2 border-blue-500 dark:text-white">
|
||||
Mitarbeiter
|
||||
</button>
|
||||
<button className="pb-2 dark:text-gray-400">
|
||||
Skills
|
||||
</button>
|
||||
<button className="pb-2 dark:text-gray-400">
|
||||
Vertretung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employee List */}
|
||||
<div className="space-y-2">
|
||||
{unitEmployees.map(emp => (
|
||||
<div key={emp.id} className="p-3 bg-white dark:bg-gray-700 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{emp.photo ? (
|
||||
<img
|
||||
src={`/api${emp.photo}`}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium dark:text-white">{emp.firstName} {emp.lastName}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{emp.role === 'leiter' && '🔑 Leitung'}
|
||||
{emp.role === 'stellvertreter' && '↔️ Stellvertretung'}
|
||||
{emp.role === 'mitarbeiter' && 'Mitarbeiter'}
|
||||
{emp.role === 'beauftragter' && '🎖️ Beauftragter'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{unitEmployees.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400">Keine Mitarbeiter zugeordnet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUnit.description && (
|
||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded">
|
||||
<h4 className="font-semibold mb-1 dark:text-white">Beschreibung</h4>
|
||||
<p className="text-sm dark:text-gray-300">{selectedUnit.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.employeeId && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={assignMeToSelectedUnit}
|
||||
disabled={saving}
|
||||
className="w-full px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Wird zugeordnet...' : 'In diese Einheit einordnen'}
|
||||
</button>
|
||||
{message && (
|
||||
<p className="text-sm mt-2 text-gray-700 dark:text-gray-300">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
280
frontend/src/components/OrganizationSelector.tsx
Normale Datei
280
frontend/src/components/OrganizationSelector.tsx
Normale Datei
@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { OrganizationalUnit } from '@skillmate/shared'
|
||||
import api from '../services/api'
|
||||
import { ChevronRight, Building2, Search, X } from 'lucide-react'
|
||||
|
||||
interface OrganizationSelectorProps {
|
||||
value?: string
|
||||
onChange: (unitId: string | null, unitName: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function OrganizationSelector({ value, onChange, disabled }: OrganizationSelectorProps) {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [hierarchy, setHierarchy] = useState<OrganizationalUnit[]>([])
|
||||
const [selectedUnit, setSelectedUnit] = useState<OrganizationalUnit | null>(null)
|
||||
const [currentUnitName, setCurrentUnitName] = useState<string>('')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
loadOrganization()
|
||||
}
|
||||
}, [showModal])
|
||||
|
||||
useEffect(() => {
|
||||
// Load current unit name if value exists
|
||||
if (value && hierarchy.length > 0) {
|
||||
const unit = findUnitById(hierarchy, value)
|
||||
if (unit) {
|
||||
setCurrentUnitName(getUnitPath(hierarchy, unit))
|
||||
setSelectedUnit(unit)
|
||||
}
|
||||
}
|
||||
}, [value, hierarchy])
|
||||
|
||||
const loadOrganization = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await api.get('/organization/hierarchy')
|
||||
if (response.data.success) {
|
||||
setHierarchy(response.data.data)
|
||||
// Auto-expand first two levels
|
||||
const toExpand = new Set<string>()
|
||||
const addFirstLevels = (units: any[], level = 0) => {
|
||||
if (level < 2) {
|
||||
units.forEach(u => {
|
||||
toExpand.add(u.id)
|
||||
if (u.children) addFirstLevels(u.children, level + 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
addFirstLevels(response.data.data)
|
||||
setExpandedNodes(toExpand)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load organization:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const findUnitById = (units: OrganizationalUnit[], id: string): OrganizationalUnit | null => {
|
||||
for (const unit of units) {
|
||||
if (unit.id === id) return unit
|
||||
if (unit.children) {
|
||||
const found = findUnitById(unit.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getUnitPath = (units: OrganizationalUnit[], target: OrganizationalUnit): string => {
|
||||
const path: string[] = []
|
||||
|
||||
const findPath = (units: OrganizationalUnit[], current: string[] = []): boolean => {
|
||||
for (const unit of units) {
|
||||
const newPath = [...current, unit.name]
|
||||
if (unit.id === target.id) {
|
||||
path.push(...newPath)
|
||||
return true
|
||||
}
|
||||
if (unit.children && findPath(unit.children, newPath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
findPath(units)
|
||||
return path.join(' → ')
|
||||
}
|
||||
|
||||
const toggleExpand = (unitId: string) => {
|
||||
setExpandedNodes(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(unitId)) {
|
||||
newSet.delete(unitId)
|
||||
} else {
|
||||
newSet.add(unitId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelect = (unit: OrganizationalUnit) => {
|
||||
setSelectedUnit(unit)
|
||||
const path = getUnitPath(hierarchy, unit)
|
||||
setCurrentUnitName(path)
|
||||
onChange(unit.id, path)
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
direktion: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
abteilung: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
dezernat: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
sachgebiet: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
|
||||
teildezernat: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200',
|
||||
stabsstelle: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
sondereinheit: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200'
|
||||
}
|
||||
return colors[type] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
|
||||
const matchesSearch = (unit: OrganizationalUnit): boolean => {
|
||||
if (!searchTerm) return true
|
||||
const term = searchTerm.toLowerCase()
|
||||
return unit.name.toLowerCase().includes(term) ||
|
||||
unit.code?.toLowerCase().includes(term) || false
|
||||
}
|
||||
|
||||
const renderUnit = (unit: OrganizationalUnit, level = 0) => {
|
||||
const hasChildren = unit.children && unit.children.length > 0
|
||||
const isExpanded = expandedNodes.has(unit.id)
|
||||
const matches = matchesSearch(unit)
|
||||
|
||||
// Check if any children match
|
||||
const hasMatchingChildren = hasChildren && unit.children!.some(child =>
|
||||
matchesSearch(child) || (child.children && child.children.some(matchesSearch))
|
||||
)
|
||||
|
||||
if (!matches && !hasMatchingChildren) return null
|
||||
|
||||
return (
|
||||
<div key={unit.id} className={level > 0 ? 'ml-4' : ''}>
|
||||
<div
|
||||
className={`flex items-center gap-2 p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer ${
|
||||
selectedUnit?.id === unit.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
onClick={() => handleSelect(unit)}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpand(unit.id)
|
||||
}}
|
||||
className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && <div className="w-5" />}
|
||||
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${getTypeColor(unit.type)}`}>
|
||||
{unit.code || unit.type}
|
||||
</span>
|
||||
<span className="text-sm dark:text-white">{unit.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="border-l border-gray-200 dark:border-gray-700 ml-2">
|
||||
{unit.children!.map(child => renderUnit(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className="input-field w-full pr-10"
|
||||
value={currentUnitName}
|
||||
placeholder="Klicken zum Auswählen..."
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setShowModal(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
onClick={() => !disabled && setShowModal(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Building2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold dark:text-white">Organisationseinheit auswählen</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Name oder Code..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree View */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
Lade Organisationsstruktur...
|
||||
</div>
|
||||
) : hierarchy.length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
Keine Organisationseinheiten vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{hierarchy.map(unit => renderUnit(unit))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUnit(null)
|
||||
setCurrentUnitName('')
|
||||
onChange(null, '')
|
||||
setShowModal(false)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
Auswahl entfernen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -3,6 +3,8 @@ import { useAuthStore } from '../stores/authStore'
|
||||
import { employeeApi } from '../services/api'
|
||||
import PhotoUpload from '../components/PhotoUpload'
|
||||
import SkillLevelBar from '../components/SkillLevelBar'
|
||||
import DeputyManagement from '../components/DeputyManagement'
|
||||
import OrganizationSelector from '../components/OrganizationSelector'
|
||||
|
||||
interface SkillSelection { categoryId: string; subCategoryId: string; skillId: string; name: string; level: string }
|
||||
|
||||
@ -17,6 +19,9 @@ export default function MyProfile() {
|
||||
const [form, setForm] = useState<any | null>(null)
|
||||
const [catalog, setCatalog] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
|
||||
const [skills, setSkills] = useState<SkillSelection[]>([])
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'deputies'>('profile')
|
||||
const [currentUnitId, setCurrentUnitId] = useState<string | null>(null)
|
||||
const [myUnits, setMyUnits] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!employeeId) {
|
||||
@ -46,6 +51,23 @@ export default function MyProfile() {
|
||||
}
|
||||
})
|
||||
setSkills(mapped)
|
||||
|
||||
// Load my organizational units
|
||||
try {
|
||||
const unitsRes = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/organization/my-units', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
}).then(r => r.json())
|
||||
if (unitsRes?.success && unitsRes.data?.length > 0) {
|
||||
setMyUnits(unitsRes.data)
|
||||
const primaryUnit = unitsRes.data.find((u: any) => u.isPrimary)
|
||||
if (primaryUnit) {
|
||||
setCurrentUnitId(primaryUnit.id)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore unit errors
|
||||
}
|
||||
|
||||
// Load hierarchy non-critically
|
||||
try {
|
||||
const hierRes = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', {
|
||||
@ -85,6 +107,32 @@ export default function MyProfile() {
|
||||
const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) =>
|
||||
(skills.find(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)?.level) || ''
|
||||
|
||||
const handleOrganizationChange = async (unitId: string | null, unitName: string) => {
|
||||
setCurrentUnitId(unitId)
|
||||
if (unitId && employeeId) {
|
||||
try {
|
||||
// Save organization assignment
|
||||
await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/organization/assignments', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
employeeId: employeeId,
|
||||
unitId: unitId,
|
||||
role: 'mitarbeiter',
|
||||
isPrimary: true
|
||||
})
|
||||
})
|
||||
// Update department field with unit name for backward compatibility
|
||||
setForm((prev: any) => ({ ...prev, department: unitName }))
|
||||
} catch (error) {
|
||||
console.error('Failed to assign unit:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
if (!employeeId || !form) return
|
||||
setSaving(true)
|
||||
@ -151,11 +199,32 @@ export default function MyProfile() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8">Mein Profil</h1>
|
||||
<h1 className="text-title-lg font-poppins font-bold text-primary mb-4">Mein Profil</h1>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`pb-2 px-1 ${activeTab === 'profile' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-600 dark:text-gray-400'}`}
|
||||
>
|
||||
Profildaten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('deputies')}
|
||||
className={`pb-2 px-1 ${activeTab === 'deputies' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-600 dark:text-gray-400'}`}
|
||||
>
|
||||
Vertretungen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">{error}</div>)}
|
||||
{success && (<div className="bg-success-bg text-success px-4 py-3 rounded-input text-sm mb-6">{success}</div>)}
|
||||
|
||||
{activeTab === 'deputies' ? (
|
||||
<DeputyManagement />
|
||||
) : (
|
||||
<>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="card">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Foto</h2>
|
||||
@ -182,8 +251,12 @@ export default function MyProfile() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Dienststelle</label>
|
||||
<input className="input-field w-full" value={form.department || ''} onChange={(e) => setForm((p: any) => ({ ...p, department: e.target.value }))} placeholder="z. B. Abteilung 4, Dezernat 42, Sachgebiet 42.1" />
|
||||
<p className="text-small text-tertiary mt-1">Hierarchische Angabe (Abteilung, Dezernat, Sachgebiet).</p>
|
||||
<OrganizationSelector
|
||||
value={currentUnitId || undefined}
|
||||
onChange={handleOrganizationChange}
|
||||
disabled={false}
|
||||
/>
|
||||
<p className="text-small text-tertiary mt-1">Wählen Sie Ihre Organisationseinheit aus dem Organigramm.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Telefon</label>
|
||||
@ -250,6 +323,9 @@ export default function MyProfile() {
|
||||
{saving ? 'Speichere...' : 'Änderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
97
shared/index.d.ts
vendored
97
shared/index.d.ts
vendored
@ -87,6 +87,7 @@ export interface LoginResponse {
|
||||
}
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<UserRole, string[]>
|
||||
|
||||
export const DEFAULT_SKILLS: Record<string, string[]>
|
||||
export const LANGUAGE_LEVELS: string[]
|
||||
export interface SkillLevel { id: string; name: string; level?: string }
|
||||
@ -118,3 +119,99 @@ export interface WorkspaceFilter {
|
||||
building?: string
|
||||
min_capacity?: number
|
||||
}
|
||||
|
||||
// Organization
|
||||
export type OrganizationalUnitType = 'direktion' | 'abteilung' | 'dezernat' | 'sachgebiet' | 'teildezernat' | 'fuehrungsstelle' | 'stabsstelle' | 'sondereinheit'
|
||||
export type EmployeeUnitRole = 'leiter' | 'stellvertreter' | 'mitarbeiter' | 'beauftragter'
|
||||
|
||||
export interface OrganizationalUnit {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
type: OrganizationalUnitType
|
||||
level: number
|
||||
parentId?: string | null
|
||||
positionX?: number | null
|
||||
positionY?: number | null
|
||||
color?: string | null
|
||||
orderIndex?: number
|
||||
description?: string | null
|
||||
hasFuehrungsstelle?: boolean
|
||||
fuehrungsstelleName?: string | null
|
||||
isActive?: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
children?: OrganizationalUnit[]
|
||||
employees?: any[]
|
||||
}
|
||||
|
||||
export interface EmployeeUnitAssignment {
|
||||
id: string
|
||||
employeeId: string
|
||||
unitId: string
|
||||
role: EmployeeUnitRole
|
||||
startDate: string
|
||||
endDate?: string | null
|
||||
isPrimary: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SpecialPosition {
|
||||
id: string
|
||||
employeeId: string
|
||||
positionType: string
|
||||
unitId?: string | null
|
||||
startDate: string
|
||||
endDate?: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DeputyAssignment {
|
||||
id: string
|
||||
principalId: string
|
||||
deputyId: string
|
||||
unitId?: string | null
|
||||
validFrom: string
|
||||
validUntil: string
|
||||
reason?: string | null
|
||||
canDelegate: boolean
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface DeputyDelegation {
|
||||
id: string
|
||||
originalAssignmentId: string
|
||||
fromDeputyId: string
|
||||
toDeputyId: string
|
||||
reason?: string | null
|
||||
delegatedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Bookings (for workspace management)
|
||||
export interface Booking {
|
||||
id: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
employeeId: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
status: 'confirmed' | 'cancelled' | 'completed' | 'no_show'
|
||||
checkInTime?: string | null
|
||||
checkOutTime?: string | null
|
||||
notes?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BookingRequest {
|
||||
workspaceId: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren