Test
Dieser Commit ist enthalten in:
@ -5,9 +5,9 @@
|
||||
## Project Overview
|
||||
|
||||
- **Path**: `A:\GiTea\SkillMate`
|
||||
- **Files**: 191 files
|
||||
- **Size**: 3.3 MB
|
||||
- **Last Modified**: 2025-09-26 00:23
|
||||
- **Files**: 189 files
|
||||
- **Size**: 5.3 MB
|
||||
- **Last Modified**: 2025-09-28 01:28
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
- JavaScript
|
||||
- Python
|
||||
- React TypeScript
|
||||
- Shell
|
||||
- TypeScript
|
||||
|
||||
### Frameworks & Libraries
|
||||
@ -27,12 +26,12 @@
|
||||
ANWENDUNGSBESCHREIBUNG.txt
|
||||
CHANGES_ORGANIGRAMM.md
|
||||
CLAUDE_PROJECT_README.md
|
||||
debug-console.cmd
|
||||
EXE-ERSTELLEN.md
|
||||
gitea_push_debug.txt
|
||||
install-dependencies.cmd
|
||||
INSTALLATION.md
|
||||
LICENSE.txt
|
||||
main.py
|
||||
Organigramm_ohne_Namen.pdf
|
||||
admin-panel/
|
||||
│ ├── index.html
|
||||
│ ├── package-lock.json
|
||||
@ -52,11 +51,11 @@ admin-panel/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── MoonIcon.tsx
|
||||
│ │ ├── OrganizationSelector.tsx
|
||||
│ │ ├── SearchIcon.tsx
|
||||
│ │ ├── SettingsIcon.tsx
|
||||
│ │ ├── SunIcon.tsx
|
||||
│ │ ├── SyncStatus.tsx
|
||||
│ │ └── UsersIcon.tsx
|
||||
│ │ └── SyncStatus.tsx
|
||||
│ ├── services/
|
||||
│ │ ├── api.ts
|
||||
│ │ └── networkApi.ts
|
||||
@ -94,9 +93,11 @@ backend/
|
||||
│ │ ├── purge-users.js
|
||||
│ │ ├── reset-admin.js
|
||||
│ │ ├── run-migrations.js
|
||||
│ │ ├── seed-demo-data.ts
|
||||
│ │ ├── seed-lka-structure.js
|
||||
│ │ ├── seed-organization.js
|
||||
│ │ ├── seed-skills-from-frontend.js
|
||||
│ │ ├── seed-skills-from-shared.js
|
||||
│ │ └── migrations/
|
||||
│ │ └── 0001_users_email_encrypt.js
|
||||
│ ├── src/
|
||||
@ -127,6 +128,7 @@ backend/
|
||||
│ │ │ ├── emailService.ts
|
||||
│ │ │ ├── encryption.ts
|
||||
│ │ │ ├── reminderService.ts
|
||||
│ │ │ ├── skillSeeder.ts
|
||||
│ │ │ ├── syncScheduler.ts
|
||||
│ │ │ └── syncService.ts
|
||||
│ │ ├── usecases/
|
||||
@ -245,3 +247,4 @@ This project is managed with Claude Project Manager. To work with this project:
|
||||
- README updated on 2025-09-23 19:19:20
|
||||
- README updated on 2025-09-25 22:01:37
|
||||
- README updated on 2025-09-27 12:01:06
|
||||
- README updated on 2025-09-28 18:39:07
|
||||
|
||||
279
admin-panel/src/components/OrganizationSelector.tsx
Normale Datei
279
admin-panel/src/components/OrganizationSelector.tsx
Normale Datei
@ -0,0 +1,279 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { OrganizationalUnit } from '@skillmate/shared'
|
||||
import { ChevronRight, Building2, Search, X } from 'lucide-react'
|
||||
import { api } from '../services/api'
|
||||
|
||||
interface OrganizationSelectorProps {
|
||||
value?: string | null
|
||||
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('')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
loadOrganization()
|
||||
}
|
||||
}, [showModal])
|
||||
|
||||
useEffect(() => {
|
||||
if (value && hierarchy.length > 0) {
|
||||
const unit = findUnitById(hierarchy, value)
|
||||
if (unit) {
|
||||
const path = getUnitPath(hierarchy, unit)
|
||||
setSelectedUnit(unit)
|
||||
setCurrentUnitName(path)
|
||||
}
|
||||
}
|
||||
if (!value) {
|
||||
setSelectedUnit(null)
|
||||
setCurrentUnitName('')
|
||||
}
|
||||
}, [value, hierarchy])
|
||||
|
||||
const loadOrganization = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await api.get('/organization/hierarchy')
|
||||
if (response.data.success) {
|
||||
const units = response.data.data as OrganizationalUnit[]
|
||||
setHierarchy(units)
|
||||
const defaults = new Set<string>()
|
||||
const expandToLevel = (nodes: OrganizationalUnit[], level = 0) => {
|
||||
if (level > 1) return
|
||||
nodes.forEach(node => {
|
||||
defaults.add(node.id)
|
||||
if (node.children) expandToLevel(node.children, level + 1)
|
||||
})
|
||||
}
|
||||
expandToLevel(units)
|
||||
setExpandedNodes(defaults)
|
||||
}
|
||||
} 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 result = findUnitById(unit.children, id)
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getUnitPath = (units: OrganizationalUnit[], target: OrganizationalUnit): string => {
|
||||
const path: string[] = []
|
||||
const traverse = (nodes: OrganizationalUnit[], trail: string[] = []): boolean => {
|
||||
for (const node of nodes) {
|
||||
const currentTrail = [...trail, node.name]
|
||||
if (node.id === target.id) {
|
||||
path.push(...currentTrail)
|
||||
return true
|
||||
}
|
||||
if (node.children && traverse(node.children, currentTrail)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
traverse(units)
|
||||
return path.join(' → ')
|
||||
}
|
||||
|
||||
const toggleExpand = (unitId: string) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(unitId)) {
|
||||
next.delete(unitId)
|
||||
} else {
|
||||
next.add(unitId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelect = (unit: OrganizationalUnit | null) => {
|
||||
if (!unit) {
|
||||
setSelectedUnit(null)
|
||||
setCurrentUnitName('')
|
||||
onChange(null, '')
|
||||
return
|
||||
}
|
||||
setSelectedUnit(unit)
|
||||
const path = getUnitPath(hierarchy, unit)
|
||||
setCurrentUnitName(path)
|
||||
onChange(unit.id, path)
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
const typeBadgeStyle = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
direktion: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
|
||||
abteilung: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200',
|
||||
dezernat: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
|
||||
sachgebiet: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200',
|
||||
teildezernat: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-200',
|
||||
stabsstelle: 'bg-gray-100 text-gray-800 dark:bg-gray-900/40 dark:text-gray-200',
|
||||
sonderenheit: 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-200'
|
||||
}
|
||||
return colors[type] || 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-200'
|
||||
}
|
||||
|
||||
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 hasMatchingDescendant = (unit: OrganizationalUnit): boolean => {
|
||||
if (!unit.children) return false
|
||||
for (const child of unit.children) {
|
||||
if (matchesSearch(child) || hasMatchingDescendant(child)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const renderUnit = (unit: OrganizationalUnit, level = 0) => {
|
||||
const isVisible = matchesSearch(unit) || hasMatchingDescendant(unit)
|
||||
if (!isVisible) return null
|
||||
const hasChildren = unit.children && unit.children.length > 0
|
||||
const expanded = expandedNodes.has(unit.id)
|
||||
|
||||
return (
|
||||
<div key={unit.id} className={level ? 'ml-4' : ''}>
|
||||
<div
|
||||
className={`flex items-center gap-2 p-2 rounded transition-colors ${
|
||||
selectedUnit?.id === unit.id
|
||||
? 'bg-blue-100 dark:bg-blue-800/40'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
toggleExpand(unit.id)
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left"
|
||||
onClick={() => handleSelect(unit)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="w-5 h-5 text-tertiary" />
|
||||
<div>
|
||||
<div className="font-medium text-secondary">{unit.name}</div>
|
||||
<div className="text-xs text-tertiary flex items-center gap-2 mt-0.5">
|
||||
{unit.code && <span>{unit.code}</span>}
|
||||
<span className={`px-2 py-0.5 rounded-full ${typeBadgeStyle(unit.type)}`}>
|
||||
{unit.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div className="mt-1">
|
||||
{unit.children!.map(child => renderUnit(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className={`input-field flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`} onClick={() => !disabled && setShowModal(true)}>
|
||||
<span className={currentUnitName ? 'text-secondary' : 'text-tertiary'}>
|
||||
{currentUnitName || 'Organisationseinheit auswählen'}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
{currentUnitName && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-tertiary hover:text-error flex items-center gap-1"
|
||||
onClick={() => handleSelect(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border-default px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-primary">Organisationseinheit auswählen</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="text-tertiary hover:text-secondary"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b border-border-default">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-tertiary absolute left-3 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={event => setSearchTerm(event.target.value)}
|
||||
className="input-field pl-9"
|
||||
placeholder="Suche nach Name oder Code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-tertiary">Lade Organisation...</div>
|
||||
) : (
|
||||
hierarchy.map(unit => renderUnit(unit))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border-default flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -3,4 +3,5 @@ export { default as UsersIcon } from './UsersIcon'
|
||||
export { default as SearchIcon } from './SearchIcon'
|
||||
export { default as SettingsIcon } from './SettingsIcon'
|
||||
export { default as SunIcon } from './SunIcon'
|
||||
export { default as MoonIcon } from './MoonIcon'
|
||||
export { default as MoonIcon } from './MoonIcon'
|
||||
export { default as OrganizationSelector } from './OrganizationSelector'
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../services/api'
|
||||
import type { UserRole } from '@skillmate/shared'
|
||||
import type { EmployeeUnitRole, UserRole } from '@skillmate/shared'
|
||||
import { OrganizationSelector } from '../components'
|
||||
|
||||
interface CreateEmployeeData {
|
||||
firstName: string
|
||||
@ -18,10 +19,12 @@ export default function CreateEmployee() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [createdUser, setCreatedUser] = useState<{password?: string}>({})
|
||||
const [creationDone, setCreationDone] = useState(false)
|
||||
const [createdUser, setCreatedUser] = useState<{ password?: string }>({})
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null)
|
||||
const [selectedUnitName, setSelectedUnitName] = useState('')
|
||||
const [selectedUnitRole, setSelectedUnitRole] = useState<EmployeeUnitRole>('mitarbeiter')
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, watch } = useForm<CreateEmployeeData>({
|
||||
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm<CreateEmployeeData>({
|
||||
defaultValues: {
|
||||
userRole: 'user',
|
||||
createUser: true
|
||||
@ -30,6 +33,25 @@ export default function CreateEmployee() {
|
||||
|
||||
const watchCreateUser = watch('createUser')
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUnitName) {
|
||||
setValue('department', selectedUnitName)
|
||||
}
|
||||
}, [selectedUnitName, setValue])
|
||||
|
||||
const getUnitRoleLabel = (role: EmployeeUnitRole): string => {
|
||||
switch (role) {
|
||||
case 'leiter':
|
||||
return 'Leitung'
|
||||
case 'stellvertreter':
|
||||
return 'Stellvertretung'
|
||||
case 'beauftragter':
|
||||
return 'Beauftragte:r'
|
||||
default:
|
||||
return 'Mitarbeiter:in'
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: CreateEmployeeData) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@ -42,7 +64,9 @@ export default function CreateEmployee() {
|
||||
email: data.email,
|
||||
department: data.department,
|
||||
userRole: data.createUser ? data.userRole : undefined,
|
||||
createUser: data.createUser
|
||||
createUser: data.createUser,
|
||||
organizationUnitId: selectedUnitId || undefined,
|
||||
organizationRole: selectedUnitId ? selectedUnitRole : undefined
|
||||
}
|
||||
|
||||
const response = await api.post('/employees', payload)
|
||||
@ -53,8 +77,6 @@ export default function CreateEmployee() {
|
||||
} else {
|
||||
setSuccess('Mitarbeiter erfolgreich erstellt!')
|
||||
}
|
||||
// Manuelles Weiterklicken statt Auto-Navigation, damit Passwort kopiert werden kann
|
||||
setCreationDone(true)
|
||||
} catch (err: any) {
|
||||
console.error('Error:', err.response?.data)
|
||||
const errorMessage = err.response?.data?.error?.message || 'Speichern fehlgeschlagen'
|
||||
@ -107,6 +129,11 @@ export default function CreateEmployee() {
|
||||
{success && (
|
||||
<div className="bg-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
|
||||
{success}
|
||||
{selectedUnitName && (
|
||||
<div className="mt-2 text-green-700">
|
||||
Zugeordnete Einheit: <strong>{selectedUnitName}</strong> · {getUnitRoleLabel(selectedUnitRole)}
|
||||
</div>
|
||||
)}
|
||||
{createdUser.password && (
|
||||
<div className="mt-4 p-4 bg-white rounded border border-green-300">
|
||||
<h4 className="font-semibold mb-2">🔑 Temporäres Passwort:</h4>
|
||||
@ -192,8 +219,9 @@ export default function CreateEmployee() {
|
||||
</label>
|
||||
<input
|
||||
{...register('department', { required: 'Abteilung ist erforderlich' })}
|
||||
className="input-field w-full"
|
||||
className={`input-field w-full ${selectedUnitId ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||
placeholder="IT, Personal, Marketing, etc."
|
||||
readOnly={Boolean(selectedUnitId)}
|
||||
/>
|
||||
{errors.department && (
|
||||
<p className="text-error text-sm mt-1">{errors.department.message}</p>
|
||||
@ -202,6 +230,54 @@ export default function CreateEmployee() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
|
||||
Organisation & Rolle
|
||||
</h2>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Organisationseinheit
|
||||
</label>
|
||||
<OrganizationSelector
|
||||
value={selectedUnitId}
|
||||
onChange={(unitId, unitPath) => {
|
||||
setSelectedUnitId(unitId)
|
||||
setSelectedUnitName(unitPath)
|
||||
if (!unitId) {
|
||||
setSelectedUnitRole('mitarbeiter')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-secondary-light mt-2">
|
||||
Wählen Sie die primäre Organisationseinheit für den neuen Mitarbeiter. Die Abteilung wird automatisch anhand der Auswahl gesetzt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedUnitId && (
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Rolle innerhalb der Einheit
|
||||
</label>
|
||||
<select
|
||||
value={selectedUnitRole}
|
||||
onChange={event => setSelectedUnitRole(event.target.value as EmployeeUnitRole)}
|
||||
className="input-field w-full"
|
||||
>
|
||||
<option value="leiter">Leiter:in</option>
|
||||
<option value="stellvertreter">Stellvertretung</option>
|
||||
<option value="mitarbeiter">Mitarbeiter:in</option>
|
||||
<option value="beauftragter">Beauftragte:r</option>
|
||||
</select>
|
||||
<p className="text-sm text-secondary-light mt-2">
|
||||
Diese Rolle wird für Vertretungs- und Organigramm-Funktionen verwendet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
|
||||
Benutzerkonto
|
||||
@ -253,6 +329,7 @@ export default function CreateEmployee() {
|
||||
<ul className="space-y-2 text-body text-secondary">
|
||||
<li>• Der Mitarbeiter wird mit Grunddaten angelegt (Position: "Mitarbeiter", Telefon: "Nicht angegeben")</li>
|
||||
<li>• {watchCreateUser ? 'Ein Benutzerkonto wird erstellt und ein temporäres Passwort generiert' : 'Kein Benutzerkonto wird erstellt'}</li>
|
||||
<li>• {selectedUnitId ? `Die Organisationseinheit ${selectedUnitName} (${getUnitRoleLabel(selectedUnitRole)}) wird als primäre Zuordnung hinterlegt` : 'Organisationseinheit kann später im Organigramm zugewiesen werden'}</li>
|
||||
<li>• Der Mitarbeiter kann später im Frontend seine Profildaten vervollständigen</li>
|
||||
<li>• Alle Daten werden verschlüsselt in der Datenbank gespeichert</li>
|
||||
</ul>
|
||||
|
||||
445
backend/scripts/seed-demo-data.ts
Normale Datei
445
backend/scripts/seed-demo-data.ts
Normale Datei
@ -0,0 +1,445 @@
|
||||
import dotenv from 'dotenv'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { initializeSecureDatabase, encryptedDb, db } from '../src/config/secureDatabase'
|
||||
import { initializeDatabase } from '../src/config/database'
|
||||
import { FieldEncryption } from '../src/services/encryption'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
initializeSecureDatabase()
|
||||
initializeDatabase()
|
||||
|
||||
type SampleSkill = {
|
||||
id: string
|
||||
level: string
|
||||
verified?: boolean
|
||||
}
|
||||
|
||||
type SampleEmployee = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
username: string
|
||||
email: string
|
||||
role: 'admin' | 'superuser' | 'user'
|
||||
department: string
|
||||
position: string
|
||||
employeeNumber: string
|
||||
phone: string
|
||||
mobile?: string
|
||||
office?: string
|
||||
availability?: string
|
||||
skills: SampleSkill[]
|
||||
languages?: { language: string; proficiency: string; certified?: number }[]
|
||||
clearance?: { level: string; validUntil: string; issuedDate: string }
|
||||
}
|
||||
|
||||
const SAMPLE_EMPLOYEES: SampleEmployee[] = [
|
||||
{
|
||||
firstName: 'Anna',
|
||||
lastName: 'Meyer',
|
||||
username: 'a.meyer',
|
||||
email: 'anna.meyer@example.local',
|
||||
role: 'admin',
|
||||
department: 'Leitungsstab',
|
||||
position: 'Leitung PMO',
|
||||
employeeNumber: 'SM-001',
|
||||
phone: '+49 201 123-1001',
|
||||
mobile: '+49 171 2001001',
|
||||
office: 'LS-2.14',
|
||||
availability: 'available',
|
||||
skills: [
|
||||
{ id: 'communication.languages.de', level: '9', verified: true },
|
||||
{ id: 'analytical.data_analysis.statistics', level: '7' },
|
||||
{ id: 'technical.programming.python', level: '6' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'C2', certified: 1 },
|
||||
{ language: 'en', proficiency: 'C1' }
|
||||
],
|
||||
clearance: {
|
||||
level: 'Ü3',
|
||||
validUntil: '2027-06-30',
|
||||
issuedDate: '2022-07-01'
|
||||
}
|
||||
},
|
||||
{
|
||||
firstName: 'Daniel',
|
||||
lastName: 'Schulz',
|
||||
username: 'd.schulz',
|
||||
email: 'daniel.schulz@example.local',
|
||||
role: 'superuser',
|
||||
department: 'Cybercrime',
|
||||
position: 'Teamleiter Digitale Forensik',
|
||||
employeeNumber: 'SM-002',
|
||||
phone: '+49 201 123-1002',
|
||||
mobile: '+49 171 2001002',
|
||||
office: 'CC-3.04',
|
||||
availability: 'available',
|
||||
skills: [
|
||||
{ id: 'technical.security.forensics', level: '8', verified: true },
|
||||
{ id: 'analytical.intelligence.threat', level: '6' },
|
||||
{ id: 'technical.programming.python', level: '5' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'C1' },
|
||||
{ language: 'en', proficiency: 'B2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Miriam',
|
||||
lastName: 'Koch',
|
||||
username: 'm.koch',
|
||||
email: 'miriam.koch@example.local',
|
||||
role: 'user',
|
||||
department: 'Kriminalprävention',
|
||||
position: 'Referentin Prävention',
|
||||
employeeNumber: 'SM-003',
|
||||
phone: '+49 201 123-1003',
|
||||
availability: 'busy',
|
||||
skills: [
|
||||
{ id: 'communication.interpersonal.presentation', level: '7' },
|
||||
{ id: 'operational.investigation.interrogation', level: '4' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'C2' },
|
||||
{ language: 'fr', proficiency: 'B2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Jonas',
|
||||
lastName: 'Becker',
|
||||
username: 'j.becker',
|
||||
email: 'jonas.becker@example.local',
|
||||
role: 'user',
|
||||
department: 'Cybercrime',
|
||||
position: 'Analyst OSINT',
|
||||
employeeNumber: 'SM-004',
|
||||
phone: '+49 201 123-1004',
|
||||
skills: [
|
||||
{ id: 'analytical.intelligence.osint', level: '8', verified: true },
|
||||
{ id: 'analytical.intelligence.pattern', level: '6' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'C1' },
|
||||
{ language: 'en', proficiency: 'C1' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Leonie',
|
||||
lastName: 'Graf',
|
||||
username: 'l.graf',
|
||||
email: 'leonie.graf@example.local',
|
||||
role: 'superuser',
|
||||
department: 'Organisierte Kriminalität',
|
||||
position: 'Ermittlerin',
|
||||
employeeNumber: 'SM-005',
|
||||
phone: '+49 201 123-1005',
|
||||
availability: 'available',
|
||||
skills: [
|
||||
{ id: 'operational.investigation.surveillance', level: '7' },
|
||||
{ id: 'operational.tactical.planning', level: '6' }
|
||||
],
|
||||
clearance: {
|
||||
level: 'Ü2',
|
||||
validUntil: '2026-05-15',
|
||||
issuedDate: '2021-05-16'
|
||||
}
|
||||
},
|
||||
{
|
||||
firstName: 'Yusuf',
|
||||
lastName: 'Öztürk',
|
||||
username: 'y.oeztuerk',
|
||||
email: 'yusuf.oeztuerk@example.local',
|
||||
role: 'user',
|
||||
department: 'Cybercrime',
|
||||
position: 'Incident Responder',
|
||||
employeeNumber: 'SM-006',
|
||||
phone: '+49 201 123-1006',
|
||||
skills: [
|
||||
{ id: 'technical.security.siem', level: '7' },
|
||||
{ id: 'technical.security.malware', level: '5' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'C1' },
|
||||
{ language: 'tr', proficiency: 'C1' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Klara',
|
||||
lastName: 'Heinrich',
|
||||
username: 'k.heinrich',
|
||||
email: 'klara.heinrich@example.local',
|
||||
role: 'user',
|
||||
department: 'Kriminalprävention',
|
||||
position: 'Datenanalystin',
|
||||
employeeNumber: 'SM-007',
|
||||
phone: '+49 201 123-1007',
|
||||
skills: [
|
||||
{ id: 'analytical.data_analysis.statistics', level: '8' },
|
||||
{ id: 'analytical.data_analysis.financial', level: '6' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Sebastian',
|
||||
lastName: 'Ulrich',
|
||||
username: 's.ulrich',
|
||||
email: 'sebastian.ulrich@example.local',
|
||||
role: 'user',
|
||||
department: 'Cybercrime',
|
||||
position: 'Digitaler Spurensicherer',
|
||||
employeeNumber: 'SM-008',
|
||||
phone: '+49 201 123-1008',
|
||||
availability: 'offline',
|
||||
skills: [
|
||||
{ id: 'operational.investigation.evidence', level: '7', verified: true },
|
||||
{ id: 'technical.security.forensics', level: '6' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Jana',
|
||||
lastName: 'Reuter',
|
||||
username: 'j.reuter',
|
||||
email: 'jana.reuter@example.local',
|
||||
role: 'superuser',
|
||||
department: 'Nachrichtendienstliche Analyse',
|
||||
position: 'Analystin Gefährdungsbewertung',
|
||||
employeeNumber: 'SM-009',
|
||||
phone: '+49 201 123-1009',
|
||||
skills: [
|
||||
{ id: 'analytical.intelligence.threat', level: '8', verified: true },
|
||||
{ id: 'analytical.intelligence.risk', level: '7' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'C2' },
|
||||
{ language: 'en', proficiency: 'C1' },
|
||||
{ language: 'ru', proficiency: 'B2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Harald',
|
||||
lastName: 'Pohl',
|
||||
username: 'h.pohl',
|
||||
email: 'harald.pohl@example.local',
|
||||
role: 'user',
|
||||
department: 'Mobiles Einsatzkommando',
|
||||
position: 'Einsatzführer',
|
||||
employeeNumber: 'SM-010',
|
||||
phone: '+49 201 123-1010',
|
||||
availability: 'available',
|
||||
skills: [
|
||||
{ id: 'operational.tactical.protection', level: '8' },
|
||||
{ id: 'certifications.weapons.sniper', level: '7', verified: true }
|
||||
],
|
||||
clearance: {
|
||||
level: 'Ü2',
|
||||
validUntil: '2025-12-31',
|
||||
issuedDate: '2020-01-01'
|
||||
}
|
||||
},
|
||||
{
|
||||
firstName: 'Melanie',
|
||||
lastName: 'Franke',
|
||||
username: 'm.franke',
|
||||
email: 'melanie.franke@example.local',
|
||||
role: 'user',
|
||||
department: 'Kriminalprävention',
|
||||
position: 'Projektmanagerin Prävention',
|
||||
employeeNumber: 'SM-011',
|
||||
phone: '+49 201 123-1011',
|
||||
skills: [
|
||||
{ id: 'communication.interpersonal.teamwork', level: '8' },
|
||||
{ id: 'communication.interpersonal.conflict', level: '6' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Farid',
|
||||
lastName: 'Rahmani',
|
||||
username: 'f.rahmani',
|
||||
email: 'farid.rahmani@example.local',
|
||||
role: 'user',
|
||||
department: 'Cybercrime',
|
||||
position: 'Entwickler Automatisierung',
|
||||
employeeNumber: 'SM-012',
|
||||
phone: '+49 201 123-1012',
|
||||
skills: [
|
||||
{ id: 'technical.programming.javascript', level: '7' },
|
||||
{ id: 'technical.programming.python', level: '8' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'B2' },
|
||||
{ language: 'fa', proficiency: 'C1' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Lena',
|
||||
lastName: 'Zimmer',
|
||||
username: 'l.zimmer',
|
||||
email: 'lena.zimmer@example.local',
|
||||
role: 'user',
|
||||
department: 'Kriminalprävention',
|
||||
position: 'Datenvisualisierung',
|
||||
employeeNumber: 'SM-013',
|
||||
phone: '+49 201 123-1013',
|
||||
availability: 'busy',
|
||||
skills: [
|
||||
{ id: 'analytical.data_analysis.network_analysis', level: '6' },
|
||||
{ id: 'communication.presentation.presentation', level: '5' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Erik',
|
||||
lastName: 'Brandt',
|
||||
username: 'e.brandt',
|
||||
email: 'erik.brandt@example.local',
|
||||
role: 'superuser',
|
||||
department: 'Nachrichtendienstliche Analyse',
|
||||
position: 'Projektleiter Prognosemodelle',
|
||||
employeeNumber: 'SM-014',
|
||||
phone: '+49 201 123-1014',
|
||||
skills: [
|
||||
{ id: 'analytical.intelligence.forecasting', level: '8' },
|
||||
{ id: 'analytical.data_analysis.statistics', level: '7' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'C1' },
|
||||
{ language: 'en', proficiency: 'C1' }
|
||||
]
|
||||
},
|
||||
{
|
||||
firstName: 'Sofia',
|
||||
lastName: 'Lindner',
|
||||
username: 's.lindner',
|
||||
email: 'sofia.lindner@example.local',
|
||||
role: 'user',
|
||||
department: 'Cybercrime',
|
||||
position: 'Malware Analystin',
|
||||
employeeNumber: 'SM-015',
|
||||
phone: '+49 201 123-1015',
|
||||
skills: [
|
||||
{ id: 'technical.security.malware', level: '8' },
|
||||
{ id: 'technical.security.crypto', level: '6' }
|
||||
],
|
||||
languages: [
|
||||
{ language: 'de', proficiency: 'C1' },
|
||||
{ language: 'en', proficiency: 'B2' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const PASSWORD = 'test123'
|
||||
|
||||
const deleteExistingByUsername = db.prepare('DELETE FROM users WHERE username = ?')
|
||||
const deleteEmployeeById = db.prepare('DELETE FROM employees WHERE id = ?')
|
||||
const selectUserByUsername = db.prepare('SELECT id, employee_id FROM users WHERE username = ?')
|
||||
const deleteEmployeeSkills = db.prepare('DELETE FROM employee_skills WHERE employee_id = ?')
|
||||
const deleteLanguageSkills = db.prepare('DELETE FROM language_skills WHERE employee_id = ?')
|
||||
|
||||
const insertSkill = db.prepare(`
|
||||
INSERT INTO employee_skills (
|
||||
employee_id, skill_id, level, verified, verified_by, verified_date
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const insertLanguage = db.prepare(`
|
||||
INSERT INTO language_skills (
|
||||
id, employee_id, language, proficiency, certified, certificate_type, is_native, can_interpret
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 0, 0)
|
||||
`)
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (
|
||||
id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
`)
|
||||
|
||||
const nowIso = () => new Date().toISOString()
|
||||
|
||||
const hashPassword = (plain: string) => bcrypt.hashSync(plain, 12)
|
||||
|
||||
function upsertEmployee(sample: SampleEmployee) {
|
||||
const existingUser = selectUserByUsername.get(sample.username) as { id: string; employee_id: string } | undefined
|
||||
|
||||
if (existingUser) {
|
||||
deleteEmployeeSkills.run(existingUser.employee_id)
|
||||
deleteLanguageSkills.run(existingUser.employee_id)
|
||||
deleteEmployeeById.run(existingUser.employee_id)
|
||||
deleteExistingByUsername.run(sample.username)
|
||||
}
|
||||
|
||||
const employeeId = uuidv4()
|
||||
const createdAt = nowIso()
|
||||
const availability = sample.availability || 'available'
|
||||
|
||||
encryptedDb.insertEmployee({
|
||||
id: employeeId,
|
||||
first_name: sample.firstName,
|
||||
last_name: sample.lastName,
|
||||
employee_number: sample.employeeNumber,
|
||||
photo: null,
|
||||
position: sample.position,
|
||||
department: sample.department,
|
||||
email: sample.email,
|
||||
phone: sample.phone,
|
||||
mobile: sample.mobile || null,
|
||||
office: sample.office || null,
|
||||
availability,
|
||||
clearance_level: sample.clearance?.level || null,
|
||||
clearance_valid_until: sample.clearance?.validUntil || null,
|
||||
clearance_issued_date: sample.clearance?.issuedDate || null,
|
||||
primary_unit_id: null,
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
created_by: 'system'
|
||||
})
|
||||
|
||||
const verifiedBy = sample.role === 'admin' ? 'system-admin' : 'system'
|
||||
|
||||
for (const skill of sample.skills) {
|
||||
insertSkill.run(
|
||||
employeeId,
|
||||
skill.id,
|
||||
skill.level,
|
||||
skill.verified ? 1 : 0,
|
||||
skill.verified ? verifiedBy : null,
|
||||
skill.verified ? createdAt : null
|
||||
)
|
||||
}
|
||||
|
||||
for (const language of sample.languages || []) {
|
||||
insertLanguage.run(
|
||||
uuidv4(),
|
||||
employeeId,
|
||||
language.language,
|
||||
language.proficiency,
|
||||
language.certified || 0,
|
||||
language.certified ? 'Zertifikat' : null
|
||||
)
|
||||
}
|
||||
|
||||
const userId = uuidv4()
|
||||
const encryptedEmail = FieldEncryption.encrypt(sample.email) || ''
|
||||
const emailHash = FieldEncryption.hash(sample.email)
|
||||
const hashedPassword = hashPassword(PASSWORD)
|
||||
const timestamps = nowIso()
|
||||
|
||||
insertUser.run(
|
||||
userId,
|
||||
sample.username,
|
||||
encryptedEmail,
|
||||
emailHash,
|
||||
hashedPassword,
|
||||
sample.role,
|
||||
employeeId,
|
||||
timestamps,
|
||||
timestamps
|
||||
)
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
for (const employee of SAMPLE_EMPLOYEES) {
|
||||
upsertEmployee(employee)
|
||||
}
|
||||
})()
|
||||
|
||||
console.log(`✅ ${SAMPLE_EMPLOYEES.length} Demo-Mitarbeiter mit Passwort "${PASSWORD}" angelegt.`)
|
||||
@ -5,7 +5,7 @@ import bcrypt from 'bcrypt'
|
||||
import { db } from '../config/database'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
|
||||
import { Employee, LanguageSkill, Skill, UserRole } from '@skillmate/shared'
|
||||
import { Employee, LanguageSkill, Skill, UserRole, EmployeeUnitRole } from '@skillmate/shared'
|
||||
import { syncService } from '../services/syncService'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
|
||||
@ -203,7 +203,9 @@ router.post('/',
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('email').isEmail(),
|
||||
body('department').notEmpty().trim()
|
||||
body('department').notEmpty().trim(),
|
||||
body('organizationUnitId').optional({ checkFalsy: true }).isUUID(),
|
||||
body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@ -221,30 +223,55 @@ router.post('/',
|
||||
const {
|
||||
firstName, lastName, employeeNumber, photo, position,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance, skills, languages, specializations, userRole, createUser
|
||||
clearance, skills, languages, specializations,
|
||||
userRole, createUser, organizationUnitId, organizationRole
|
||||
} = req.body
|
||||
|
||||
if (organizationRole && !organizationUnitId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Organization role requires unit selection' }
|
||||
})
|
||||
}
|
||||
|
||||
let resolvedDepartment = department
|
||||
let resolvedUnitId: string | null = null
|
||||
let resolvedUnitRole: EmployeeUnitRole = 'mitarbeiter'
|
||||
|
||||
if (organizationUnitId) {
|
||||
const unitRow = db.prepare('SELECT id, name FROM organizational_units WHERE id = ? AND is_active = 1').get(organizationUnitId) as { id: string; name: string } | undefined
|
||||
if (!unitRow) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Organization unit not found' } })
|
||||
}
|
||||
resolvedUnitId = unitRow.id
|
||||
resolvedDepartment = unitRow.name
|
||||
if (organizationRole) {
|
||||
resolvedUnitRole = organizationRole as EmployeeUnitRole
|
||||
}
|
||||
}
|
||||
|
||||
// Insert employee with default values for missing fields
|
||||
db.prepare(`
|
||||
INSERT INTO employees (
|
||||
id, first_name, last_name, employee_number, photo, position,
|
||||
department, email, phone, mobile, office, availability,
|
||||
department, email, phone, mobile, office, availability, primary_unit_id,
|
||||
clearance_level, clearance_valid_until, clearance_issued_date,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
employeeId,
|
||||
firstName,
|
||||
lastName,
|
||||
employeeNumber || null,
|
||||
photo || null,
|
||||
employeeId,
|
||||
firstName,
|
||||
lastName,
|
||||
employeeNumber || null,
|
||||
photo || null,
|
||||
position || 'Mitarbeiter', // Default position
|
||||
department,
|
||||
email,
|
||||
resolvedDepartment,
|
||||
email,
|
||||
phone || 'Nicht angegeben', // Default phone
|
||||
mobile || null,
|
||||
office || null,
|
||||
mobile || null,
|
||||
office || null,
|
||||
availability || 'available', // Default availability
|
||||
resolvedUnitId,
|
||||
clearance?.level || null,
|
||||
clearance?.validUntil || null,
|
||||
clearance?.issuedDate || null,
|
||||
@ -313,7 +340,7 @@ router.post('/',
|
||||
employeeNumber: employeeNumber || null,
|
||||
photo: photo || null,
|
||||
position: position || 'Mitarbeiter',
|
||||
department,
|
||||
department: resolvedDepartment,
|
||||
email,
|
||||
phone: phone || 'Nicht angegeben',
|
||||
mobile: mobile || null,
|
||||
@ -323,11 +350,13 @@ router.post('/',
|
||||
skills: skills || [],
|
||||
languages: languages || [],
|
||||
specializations: specializations || [],
|
||||
primaryUnitId: resolvedUnitId,
|
||||
unitRole: resolvedUnitId ? resolvedUnitRole : undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: req.user!.id
|
||||
}
|
||||
|
||||
|
||||
// Create user account if requested
|
||||
let userId = null
|
||||
let temporaryPassword = null
|
||||
@ -356,6 +385,24 @@ router.post('/',
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedUnitId) {
|
||||
const assignmentId = uuidv4()
|
||||
db.prepare(`
|
||||
INSERT INTO employee_unit_assignments (
|
||||
id, employee_id, unit_id, role, start_date, end_date,
|
||||
is_primary, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, NULL, 1, ?, ?)
|
||||
`).run(
|
||||
assignmentId,
|
||||
employeeId,
|
||||
resolvedUnitId,
|
||||
resolvedUnitRole,
|
||||
now,
|
||||
now,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
await syncService.queueSync('employees', 'create', newEmployee)
|
||||
|
||||
res.status(201).json({
|
||||
@ -363,7 +410,9 @@ router.post('/',
|
||||
data: {
|
||||
id: employeeId,
|
||||
userId: userId,
|
||||
temporaryPassword: temporaryPassword
|
||||
temporaryPassword: temporaryPassword,
|
||||
primaryUnitId: resolvedUnitId,
|
||||
unitRole: resolvedUnitId ? resolvedUnitRole : undefined
|
||||
},
|
||||
message: `Employee created successfully${createUser ? ' with user account' : ''}`
|
||||
})
|
||||
@ -558,4 +607,4 @@ router.post('/search', authenticate, requirePermission('employees:read'), async
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren