Dieser Commit ist enthalten in:
Claude Project Manager
2025-10-16 08:24:01 +02:00
Ursprung 01d0988515
Commit 4d509d255f
51 geänderte Dateien mit 5922 neuen und 1502 gelöschten Zeilen

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@ -27,6 +27,10 @@
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.0",
"vite": "^5.0.7"
"vite": "^7.1.8"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.52.3",
"@rollup/rollup-win32-x64-msvc": "^4.52.3"
}
}

Datei anzeigen

@ -9,6 +9,8 @@ import UserManagement from './views/UserManagement'
import EmailSettings from './views/EmailSettings'
import SyncSettings from './views/SyncSettings'
import OrganizationEditor from './views/OrganizationEditor'
import OfficialTitles from './views/OfficialTitles'
import Positions from './views/Positions'
import { useEffect } from 'react'
function App() {
@ -40,6 +42,8 @@ function App() {
<Route path="/users" element={<UserManagement />} />
<Route path="/users/create-employee" element={<CreateEmployee />} />
<Route path="/email-settings" element={<EmailSettings />} />
<Route path="/positions" element={<Positions />} />
<Route path="/official-titles" element={<OfficialTitles />} />
<Route path="/sync" element={<SyncSettings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@ -48,4 +52,4 @@ function App() {
)
}
export default App
export default App

Datei anzeigen

@ -1,5 +1,5 @@
import { ReactNode } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { ComponentType, ReactNode, SVGProps, useEffect, useState } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import {
HomeIcon,
@ -7,24 +7,68 @@ import {
SettingsIcon,
MailIcon
} from './icons'
import { Building2 } from 'lucide-react'
import { Building2, ChevronRight } from 'lucide-react'
interface LayoutProps {
children: ReactNode
}
const navigation = [
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
type NavItem = {
name: string
href: string
icon: IconComponent
}
type NavGroup = {
name: string
icon: IconComponent
children: NavItem[]
}
type NavigationEntry = NavItem | NavGroup
const navigation: NavigationEntry[] = [
{ 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: 'Skillverwaltung', href: '/skills', icon: SettingsIcon },
{
name: 'Frontend-Daten',
icon: SettingsIcon,
children: [
{ name: 'Organigramm', href: '/organization', icon: Building2 },
{ name: 'Positionen', href: '/positions', icon: SettingsIcon },
{ name: 'Amtsbezeichnungen', href: '/official-titles', icon: SettingsIcon }
]
},
{ name: 'E-Mail-Einstellungen', href: '/email-settings', icon: MailIcon },
{ name: 'Synchronisation', href: '/sync', icon: SettingsIcon },
{ name: 'Synchronisation', href: '/sync', icon: SettingsIcon }
]
export default function Layout({ children }: LayoutProps) {
const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuthStore()
const findGroupForPath = (pathname: string) => navigation.find((entry): entry is NavGroup => 'children' in entry && entry.children.some(child => child.href === pathname))
const [openGroups, setOpenGroups] = useState<string[]>(() => {
const activeGroup = findGroupForPath(location.pathname)
return activeGroup ? [activeGroup.name] : []
})
useEffect(() => {
const activeGroup = findGroupForPath(location.pathname)
if (activeGroup) {
setOpenGroups((prev) => (prev.includes(activeGroup.name) ? prev : [...prev, activeGroup.name]))
}
}, [location.pathname])
const toggleGroup = (name: string) => {
setOpenGroups((prev) => (prev.includes(name) ? prev.filter(item => item !== name) : [...prev, name]))
}
const isGroupOpen = (name: string) => openGroups.includes(name)
const handleLogout = () => {
logout()
@ -41,18 +85,57 @@ export default function Layout({ children }: LayoutProps) {
</div>
<nav className="px-5 space-y-1">
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
`sidebar-item ${isActive ? 'sidebar-item-active' : ''}`
}
>
<item.icon className="w-5 h-5 mr-3 flex-shrink-0" />
<span className="font-poppins font-medium">{item.name}</span>
</NavLink>
))}
{navigation.map((entry) => {
if ('children' in entry) {
const group = entry as NavGroup
const open = isGroupOpen(group.name)
return (
<div key={group.name}>
<button
type="button"
onClick={() => toggleGroup(group.name)}
className={`sidebar-item w-full flex items-center justify-between ${open ? 'sidebar-item-active' : ''}`}
>
<div className="flex items-center">
<group.icon className="w-5 h-5 mr-3 flex-shrink-0" />
<span className="font-poppins font-medium">{group.name}</span>
</div>
<ChevronRight className={`w-4 h-4 transition-transform ${open ? 'rotate-90' : ''}`} />
</button>
{open && (
<div className="mt-1 ml-8 space-y-1">
{group.children.map((child) => (
<NavLink
key={child.name}
to={child.href}
className={({ isActive }) =>
`sidebar-item ${isActive ? 'sidebar-item-active' : ''}`
}
>
<child.icon className="w-4 h-4 mr-3 flex-shrink-0" />
<span className="font-poppins text-sm">{child.name}</span>
</NavLink>
))}
</div>
)}
</div>
)
}
const item = entry as NavItem
return (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
`sidebar-item ${isActive ? 'sidebar-item-active' : ''}`
}
>
<item.icon className="w-5 h-5 mr-3 flex-shrink-0" />
<span className="font-poppins font-medium">{item.name}</span>
</NavLink>
)
})}
</nav>
<div className="absolute bottom-0 left-0 right-0 p-5 border-t border-border-default">

Datei anzeigen

@ -3,9 +3,19 @@ import type { OrganizationalUnit } from '@skillmate/shared'
import { ChevronRight, Building2, Search, X } from 'lucide-react'
import { api } from '../services/api'
interface SelectedUnitDetails {
unit: OrganizationalUnit
storageValue: string
codePath: string
displayPath: string
namesPath: string
descriptionPath?: string
tasks?: string
}
interface OrganizationSelectorProps {
value?: string | null
onChange: (unitId: string | null, unitName: string) => void
onChange: (unitId: string | null, formattedValue: string, details?: SelectedUnitDetails | null) => void
disabled?: boolean
}
@ -28,9 +38,9 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
if (value && hierarchy.length > 0) {
const unit = findUnitById(hierarchy, value)
if (unit) {
const path = getUnitPath(hierarchy, unit)
const selection = buildSelectionDetails(hierarchy, unit)
setSelectedUnit(unit)
setCurrentUnitName(path)
setCurrentUnitName(selection.displayPath)
}
}
if (!value) {
@ -75,23 +85,63 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
return null
}
const getUnitPath = (units: OrganizationalUnit[], target: OrganizationalUnit): string => {
const path: string[] = []
const traverse = (nodes: OrganizationalUnit[], trail: string[] = []): boolean => {
const buildSelectionDetails = (units: OrganizationalUnit[], target: OrganizationalUnit): SelectedUnitDetails => {
const pathUnits: OrganizationalUnit[] = []
const traverse = (nodes: OrganizationalUnit[], trail: OrganizationalUnit[] = []): boolean => {
for (const node of nodes) {
const currentTrail = [...trail, node.name]
const extended = [...trail, node]
if (node.id === target.id) {
path.push(...currentTrail)
pathUnits.splice(0, pathUnits.length, ...extended)
return true
}
if (node.children && traverse(node.children, currentTrail)) {
if (node.children && traverse(node.children, extended)) {
return true
}
}
return false
}
traverse(units)
return path.join(' → ')
const normalize = (value?: string | null) => (value ?? '').trim()
const codeSegments = pathUnits
.map(unit => normalize(unit.code) || normalize(unit.name))
.filter(Boolean)
const nameSegments = pathUnits
.map(unit => normalize(unit.name))
.filter(Boolean)
const displaySegments = pathUnits
.map(unit => {
const code = normalize(unit.code)
const name = normalize(unit.name)
if (code && name && code !== name) {
return `${code}${name}`
}
return code || name || ''
})
.filter(Boolean)
const codePath = codeSegments.join(' -> ')
const displayPath = displaySegments.join(' → ')
const namesPath = nameSegments.join(' → ')
const descriptionPath = nameSegments.slice(0, -1).join(' → ') || undefined
const rawTask = normalize(target.description) || normalize(target.name)
const tasks = rawTask || undefined
const storageValue = tasks ? (codePath ? `${codePath} -> ${tasks}` : tasks) : codePath
return {
unit: target,
storageValue,
codePath: codePath || namesPath,
displayPath: displayPath || namesPath || tasks || '',
namesPath,
descriptionPath,
tasks
}
}
const toggleExpand = (unitId: string) => {
@ -110,13 +160,13 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
if (!unit) {
setSelectedUnit(null)
setCurrentUnitName('')
onChange(null, '')
onChange(null, '', null)
return
}
const selection = buildSelectionDetails(hierarchy, unit)
setSelectedUnit(unit)
const path = getUnitPath(hierarchy, unit)
setCurrentUnitName(path)
onChange(unit.id, path)
setCurrentUnitName(selection.displayPath)
onChange(unit.id, selection.storageValue, selection)
setShowModal(false)
}

Datei anzeigen

@ -36,4 +36,36 @@ api.interceptors.response.use(
}
)
export default api
export default api
export const positionsApi = {
getLabels: async (unitId?: string | null): Promise<string[]> => {
const params = unitId ? { unitId } : undefined
const response = await api.get('/positions', { params })
const list = Array.isArray(response.data?.data) ? response.data.data : []
const seen = new Set<string>()
return list
.map((item: any) => item?.label)
.filter((label: any) => typeof label === 'string' && label.trim().length > 0)
.filter((label: string) => {
const key = label.trim().toLowerCase()
if (seen.has(key)) return false
seen.add(key)
return true
})
},
listAdmin: async (unitId?: string | null) => {
const params = unitId ? { unitId } : undefined
const response = await api.get('/positions/admin', { params })
return Array.isArray(response.data?.data) ? response.data.data : []
},
create: async (payload: { label: string; organizationUnitId?: string | null }) => {
return api.post('/positions', payload)
},
update: async (id: string, payload: { label?: string; isActive?: boolean; orderIndex?: number; organizationUnitId?: string | null }) => {
return api.put(`/positions/${id}`, payload)
},
remove: async (id: string) => {
return api.delete(`/positions/${id}`)
}
}

Datei anzeigen

@ -1,8 +1,9 @@
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { api } from '../services/api'
import type { EmployeeUnitRole, UserRole } from '@skillmate/shared'
import { POWER_FUNCTIONS } from '@skillmate/shared'
import type { EmployeeUnitRole, UserRole, OrganizationalUnitType, PowerFunctionDefinition } from '@skillmate/shared'
import { OrganizationSelector } from '../components'
interface CreateEmployeeData {
@ -12,6 +13,7 @@ interface CreateEmployeeData {
department: string
userRole: 'admin' | 'superuser' | 'user'
createUser: boolean
powerFunction: string
}
export default function CreateEmployee() {
@ -22,22 +24,57 @@ export default function CreateEmployee() {
const [createdUser, setCreatedUser] = useState<{ password?: string }>({})
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null)
const [selectedUnitName, setSelectedUnitName] = useState('')
const [selectedUnitType, setSelectedUnitType] = useState<OrganizationalUnitType | null>(null)
const [selectedUnitRole, setSelectedUnitRole] = useState<EmployeeUnitRole>('mitarbeiter')
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm<CreateEmployeeData>({
const { register, handleSubmit, formState: { errors }, watch, setValue, setError: setFormError, clearErrors } = useForm<CreateEmployeeData>({
defaultValues: {
userRole: 'user',
createUser: true
createUser: true,
powerFunction: ''
}
})
const watchCreateUser = watch('createUser')
const watchUserRole = watch('userRole')
const watchPowerFunction = watch('powerFunction')
const availablePowerFunctions = useMemo<PowerFunctionDefinition[]>(() => {
if (!selectedUnitType) return POWER_FUNCTIONS
return POWER_FUNCTIONS.filter(def => def.unitTypes.includes(selectedUnitType))
}, [selectedUnitType])
const selectedPowerFunctionDef = useMemo(() => {
if (!watchPowerFunction) return null
return availablePowerFunctions.find(def => def.id === watchPowerFunction) || null
}, [availablePowerFunctions, watchPowerFunction])
useEffect(() => {
if (selectedUnitName) {
setValue('department', selectedUnitName)
clearErrors('department')
}
}, [selectedUnitName, setValue])
}, [selectedUnitName, setValue, clearErrors])
useEffect(() => {
if (!watchCreateUser || watchUserRole !== 'superuser') {
setValue('powerFunction', '')
clearErrors('powerFunction')
}
}, [watchCreateUser, watchUserRole, setValue, clearErrors])
useEffect(() => {
if (!selectedUnitType) {
return
}
if (watchPowerFunction) {
const stillValid = availablePowerFunctions.some(def => def.id === watchPowerFunction)
if (!stillValid) {
setValue('powerFunction', '')
}
}
}, [selectedUnitType, availablePowerFunctions, watchPowerFunction, setValue])
const getUnitRoleLabel = (role: EmployeeUnitRole): string => {
switch (role) {
@ -53,10 +90,34 @@ export default function CreateEmployee() {
}
const onSubmit = async (data: CreateEmployeeData) => {
setError('')
setSuccess('')
const requiresPowerMetadata = data.createUser && data.userRole === 'superuser'
if (requiresPowerMetadata && !selectedUnitId) {
setFormError('department', { type: 'manual', message: 'Bitte wählen Sie eine Organisationseinheit für den Poweruser aus' })
setError('Poweruser benötigen eine zugeordnete Organisationseinheit.')
return
}
if (requiresPowerMetadata && !data.powerFunction) {
setFormError('powerFunction', { type: 'manual', message: 'Bitte wählen Sie eine Funktion aus' })
setError('Poweruser benötigen eine Funktion (z. B. Sachgebietsleitung).')
return
}
if (requiresPowerMetadata && data.powerFunction) {
const definition = POWER_FUNCTIONS.find(def => def.id === data.powerFunction)
if (definition && selectedUnitType && !definition.unitTypes.includes(selectedUnitType)) {
setFormError('powerFunction', { type: 'manual', message: `Die Funktion ${definition.label} passt nicht zur gewählten Einheit` })
setError('Die ausgewählte Funktion ist für diesen Einheitstyp nicht zulässig.')
return
}
}
try {
setLoading(true)
setError('')
setSuccess('')
const payload = {
firstName: data.firstName,
@ -65,8 +126,10 @@ export default function CreateEmployee() {
department: data.department,
userRole: data.createUser ? data.userRole : undefined,
createUser: data.createUser,
organizationUnitId: selectedUnitId || undefined,
organizationRole: selectedUnitId ? selectedUnitRole : undefined
// employees API expects primaryUnitId + assignmentRole
primaryUnitId: selectedUnitId || undefined,
assignmentRole: selectedUnitId ? selectedUnitRole : undefined,
powerFunction: requiresPowerMetadata ? data.powerFunction : undefined
}
const response = await api.post('/employees', payload)
@ -96,7 +159,7 @@ export default function CreateEmployee() {
const getRoleDescription = (role: UserRole): string => {
const descriptions = {
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung',
superuser: 'Kann Mitarbeitende anlegen und verwalten, aber kein Zugriff auf Admin Panel',
superuser: 'Kann Mitarbeitende im eigenen Bereich anlegen und verwalten (abhängig von der zugewiesenen Funktion), kein Zugriff auf Admin Panel',
user: 'Kann nur das eigene Profil bearbeiten und Mitarbeitende durchsuchen'
}
return descriptions[role]
@ -223,9 +286,6 @@ export default function CreateEmployee() {
placeholder="IT, Personal, Marketing, etc."
readOnly={Boolean(selectedUnitId)}
/>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div>
</div>
</div>
@ -242,17 +302,29 @@ export default function CreateEmployee() {
</label>
<OrganizationSelector
value={selectedUnitId}
onChange={(unitId, unitPath) => {
onChange={(unitId, formattedValue, details) => {
setSelectedUnitId(unitId)
setSelectedUnitName(unitPath)
setSelectedUnitName(details?.displayPath || formattedValue)
setSelectedUnitType(details?.unit.type || null)
if (!unitId) {
setSelectedUnitRole('mitarbeiter')
setValue('powerFunction', '')
clearErrors('powerFunction')
} else {
clearErrors('department')
clearErrors('powerFunction')
if (formattedValue) {
setValue('department', formattedValue)
}
}
}}
/>
<p className="text-sm text-secondary-light mt-2">
Wählen Sie die primäre Organisationseinheit für die neue Person. Die Abteilung wird automatisch anhand der Auswahl gesetzt.
</p>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div>
{selectedUnitId && (
@ -315,10 +387,51 @@ export default function CreateEmployee() {
<option value="admin">Administrator</option>
</select>
<p className="text-sm text-secondary-light mt-2">
{getRoleDescription(watch('userRole'))}
{getRoleDescription(watchUserRole)}
</p>
</div>
)}
{watchCreateUser && watchUserRole === 'superuser' && (
<div>
<label className="block text-body font-medium text-secondary mb-2">
Poweruser-Funktion *
</label>
<select
{...register('powerFunction', {
validate: value => {
if (!watchCreateUser || watchUserRole !== 'superuser') return true
return value ? true : 'Bitte wählen Sie eine Funktion aus'
}
})}
className="input-field w-full"
disabled={!selectedUnitId || availablePowerFunctions.length === 0}
>
<option value="">Bitte auswählen</option>
{availablePowerFunctions.map(fn => (
<option key={fn.id} value={fn.id}>{fn.label}</option>
))}
</select>
{errors.powerFunction && (
<p className="text-error text-sm mt-1">{errors.powerFunction.message}</p>
)}
{!selectedUnitId && (
<p className="text-sm text-secondary-light mt-2">
Wählen Sie zuerst eine Organisationseinheit, um verfügbare Funktionen zu sehen.
</p>
)}
{selectedUnitId && availablePowerFunctions.length === 0 && (
<p className="text-sm text-warning mt-2">
Für den ausgewählten Einheitstyp sind derzeit keine Poweruser-Funktionen definiert.
</p>
)}
{selectedPowerFunctionDef && !selectedPowerFunctionDef.canManageEmployees && (
<p className="text-sm text-secondary-light mt-2">
Hinweis: Diese Funktion dient der Übersicht. Mitarbeitende können damit nicht angelegt werden.
</p>
)}
</div>
)}
</div>
</div>

Datei anzeigen

@ -1,9 +1,18 @@
import { useNavigate, useParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import { api, positionsApi } from '../services/api'
import type { UserRole } from '@skillmate/shared'
const DEFAULT_POSITION_OPTIONS = [
'Sachbearbeitung',
'stellvertretende Sachgebietsleitung',
'Sachgebietsleitung',
'Dezernatsleitung',
'Abteilungsleitung',
'Behördenleitung'
]
interface EmployeeFormData {
firstName: string
lastName: string
@ -28,6 +37,7 @@ export default function EmployeeFormComplete() {
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [createdUser, setCreatedUser] = useState<{password?: string}>({})
const [positionOptions, setPositionOptions] = useState<string[]>(DEFAULT_POSITION_OPTIONS)
const { register, handleSubmit, formState: { errors }, reset, watch } = useForm<EmployeeFormData>({
defaultValues: {
@ -38,6 +48,20 @@ export default function EmployeeFormComplete() {
})
const watchCreateUser = watch('createUser')
const selectedPosition = watch('position')
useEffect(() => {
const loadPositions = async () => {
try {
const options = await positionsApi.getLabels()
setPositionOptions(options.length ? options : DEFAULT_POSITION_OPTIONS)
} catch (error) {
console.error('Failed to load position options', error)
setPositionOptions(DEFAULT_POSITION_OPTIONS)
}
}
loadPositions()
}, [])
useEffect(() => {
if (isEdit) {
@ -277,11 +301,18 @@ export default function EmployeeFormComplete() {
<label className="block text-body font-medium text-secondary mb-2">
Position *
</label>
<input
<select
{...register('position', { required: 'Position ist erforderlich' })}
className="input-field w-full"
placeholder="Software Developer"
/>
>
<option value="">Neutrale Funktionsbezeichnung auswählen</option>
{positionOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
{selectedPosition && !positionOptions.includes(selectedPosition) && (
<option value={selectedPosition}>{`${selectedPosition} (bestehender Wert)`}</option>
)}
</select>
{errors.position && (
<p className="text-error text-sm mt-1">{errors.position.message}</p>
)}

Datei anzeigen

@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
import { SettingsIcon, TrashIcon } from '../components/icons'
interface OfficialTitle {
id: string
label: string
orderIndex?: number
isActive?: boolean
}
export default function OfficialTitles() {
const [titles, setTitles] = useState<OfficialTitle[]>([])
const [newTitle, setNewTitle] = useState('')
const [loading, setLoading] = useState(true)
const [savingId, setSavingId] = useState<string | null>(null)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
loadTitles()
}, [])
const loadTitles = async () => {
try {
setLoading(true)
const response = await api.get('/official-titles/admin')
setTitles(response.data.data || [])
} catch (err) {
console.error('Failed to load official titles', err)
setError('Amtsbezeichnungen konnten nicht geladen werden.')
} finally {
setLoading(false)
}
}
const handleAdd = async () => {
if (!newTitle.trim()) return
try {
setSavingId('new')
setError('')
setSuccess('')
await api.post('/official-titles', { label: newTitle.trim() })
setNewTitle('')
await loadTitles()
setSuccess('Amtsbezeichnung hinzugefügt.')
} catch (err) {
console.error('Failed to create official title', err)
setError('Amtsbezeichnung konnte nicht hinzugefügt werden.')
} finally {
setSavingId(null)
}
}
const handleUpdate = async (id: string, label: string) => {
if (!label.trim()) {
setError('Die Bezeichnung darf nicht leer sein.')
return
}
try {
setSavingId(id)
setError('')
setSuccess('')
await api.put(`/official-titles/${id}`, { label: label.trim() })
await loadTitles()
setSuccess('Amtsbezeichnung aktualisiert.')
} catch (err) {
console.error('Failed to update official title', err)
setError('Amtsbezeichnung konnte nicht aktualisiert werden.')
} finally {
setSavingId(null)
}
}
const handleDelete = async (id: string) => {
if (!window.confirm('Amtsbezeichnung wirklich löschen?')) return
try {
setSavingId(id)
setError('')
setSuccess('')
await api.delete(`/official-titles/${id}`)
await loadTitles()
setSuccess('Amtsbezeichnung entfernt.')
} catch (err) {
console.error('Failed to delete official title', err)
setError('Amtsbezeichnung konnte nicht gelöscht werden.')
} finally {
setSavingId(null)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-secondary">Lade Amtsbezeichnungen...</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">Amtsbezeichnungen</h1>
<p className="text-body text-secondary">Verwalten Sie die vordefinierten Amtsbezeichnungen für Mitarbeitende.</p>
</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-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
{success}
</div>
)}
<div className="card">
<div className="flex items-center mb-6">
<SettingsIcon className="w-6 h-6 text-primary-blue mr-3" />
<h2 className="text-title-card font-poppins font-semibold text-primary">
Vordefinierte Amtsbezeichnungen
</h2>
</div>
<div className="space-y-4">
{titles.length === 0 && (
<p className="text-small text-secondary">Noch keine Amtsbezeichnungen hinterlegt.</p>
)}
{titles.map((title) => (
<div key={title.id} className="flex items-center gap-3 p-3 border border-border-light rounded-input">
<input
className="flex-1 input-field"
value={title.label}
onChange={(e) => {
const updated = titles.map(t => t.id === title.id ? { ...t, label: e.target.value } : t)
setTitles(updated)
}}
onBlur={(e) => {
if (e.target.value !== title.label) {
handleUpdate(title.id, e.target.value)
}
}}
placeholder="Amtsbezeichnung"
/>
<button
onClick={() => handleDelete(title.id)}
className="p-2 text-error hover:bg-red-50 rounded-full"
disabled={savingId === title.id}
title="Löschen"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
))}
</div>
<div className="mt-6 border-t border-border-light pt-4">
<h3 className="text-body font-medium text-secondary mb-2">Neue Amtsbezeichnung hinzufügen</h3>
<div className="flex gap-3">
<input
className="input-field flex-1"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z. B. Sachbearbeitung"
disabled={savingId === 'new'}
/>
<button
className="btn-primary"
onClick={handleAdd}
disabled={savingId === 'new' || !newTitle.trim()}
>
Hinzufügen
</button>
</div>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -1,9 +1,10 @@
import { useCallback, useEffect, useState, useRef } from 'react'
import { useCallback, useEffect, useState, useRef, useMemo } from 'react'
import ReactFlow, {
Node,
Edge,
Controls,
Background,
BackgroundVariant,
MiniMap,
useNodesState,
useEdgesState,
@ -15,7 +16,7 @@ import ReactFlow, {
} from 'reactflow'
import 'reactflow/dist/style.css'
import { api } from '../services/api'
import { OrganizationalUnit, OrganizationalUnitType } from '@skillmate/shared'
import { OrganizationalUnitType } from '@skillmate/shared'
import { Upload } from 'lucide-react'
// Custom Node Component (vorheriger Look, aber mit soliden dezenten Farben)
@ -27,6 +28,7 @@ const OrganizationNode = ({ data }: { data: any }) => {
dezernat: '📁',
sachgebiet: '📋',
teildezernat: '🔧',
ermittlungskommission: '🕵️',
fuehrungsstelle: '⭐',
stabsstelle: '🎯',
sondereinheit: '🛡️'
@ -41,6 +43,7 @@ const OrganizationNode = ({ data }: { data: any }) => {
case 'dezernat': return '#1D4ED8' // blue-700
case 'sachgebiet': return '#64748B' // slate-500
case 'teildezernat': return '#64748B' // slate-500
case 'ermittlungskommission': return '#6366f1' // indigo-500
case 'stabsstelle': return '#374151' // gray-700
case 'sondereinheit': return '#475569' // slate-600
default: return '#334155'
@ -85,6 +88,41 @@ const nodeTypes: NodeTypes = {
organization: OrganizationNode
}
const UNIT_TYPE_OPTIONS: { value: OrganizationalUnitType; label: string }[] = [
{ value: 'direktion', label: 'Direktion' },
{ value: 'abteilung', label: 'Abteilung' },
{ value: 'dezernat', label: 'Dezernat' },
{ value: 'sachgebiet', label: 'Sachgebiet' },
{ value: 'teildezernat', label: 'Teildezernat' },
{ value: 'ermittlungskommission', label: 'Ermittlungskommission' },
{ value: 'stabsstelle', label: 'Stabsstelle' },
{ value: 'sondereinheit', label: 'Sondereinheit' },
{ value: 'fuehrungsstelle', label: 'Führungsstelle' }
]
const PARENT_RULES: Record<OrganizationalUnitType, OrganizationalUnitType[] | null> = {
direktion: null,
abteilung: ['direktion'],
dezernat: ['abteilung'],
sachgebiet: ['dezernat', 'teildezernat'],
teildezernat: ['dezernat'],
ermittlungskommission: ['dezernat'],
fuehrungsstelle: ['abteilung'],
stabsstelle: ['direktion'],
sondereinheit: ['direktion', 'abteilung']
}
type EditFormState = {
code: string
name: string
type: OrganizationalUnitType
level: number
description: string
hasFuehrungsstelle: boolean
fuehrungsstelleName: string
parentId: string | null
}
export default function OrganizationEditor() {
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
@ -109,6 +147,79 @@ export default function OrganizationEditor() {
parentId: '',
description: ''
})
const [editDialog, setEditDialog] = useState<{ open: boolean; nodeId: string | null }>({ open: false, nodeId: null })
const [editForm, setEditForm] = useState<EditFormState>(() => ({
code: '',
name: '',
type: 'dezernat',
level: 0,
description: '',
hasFuehrungsstelle: false,
fuehrungsstelleName: '',
parentId: null
}))
const [editSaving, setEditSaving] = useState(false)
const [editError, setEditError] = useState<string | null>(null)
const nodesById = useMemo(() => {
const map: Record<string, Node> = {}
nodes.forEach(node => { map[node.id] = node })
return map
}, [nodes])
const descendantIds = useMemo(() => {
if (!editDialog.nodeId) return new Set<string>()
const adjacency = new Map<string, string[]>()
edges.forEach(edge => {
if (!adjacency.has(edge.source)) adjacency.set(edge.source, [])
adjacency.get(edge.source)!.push(edge.target)
})
const collected = new Set<string>()
const stack = [...(adjacency.get(editDialog.nodeId) || [])]
while (stack.length) {
const current = stack.pop()!
if (collected.has(current)) continue
collected.add(current)
const children = adjacency.get(current)
if (children) stack.push(...children)
}
return collected
}, [edges, editDialog.nodeId])
const allowedParentTypes = PARENT_RULES[editForm.type] ?? null
const allowRootSelection = allowedParentTypes === null
const parentOptions = useMemo(() => {
if (!editDialog.nodeId) return []
const options = nodes
.filter(node => {
if (node.id === editDialog.nodeId) return false
if (descendantIds.has(node.id)) return false
const nodeType = node.data?.type as OrganizationalUnitType | undefined
if (allowedParentTypes && allowedParentTypes.length > 0) {
return nodeType ? allowedParentTypes.includes(nodeType) : false
}
return true
})
.map(node => ({
value: node.id,
label: `${node.data?.code ? `${node.data.code}` : ''}${node.data?.name || ''}`.trim() || node.id,
type: node.data?.type as OrganizationalUnitType | undefined
}))
if (editForm.parentId && !options.some(option => option.value === editForm.parentId)) {
const currentParent = nodesById[editForm.parentId]
if (currentParent) {
options.push({
value: currentParent.id,
label: `${currentParent.data?.code ? `${currentParent.data.code}` : ''}${currentParent.data?.name || ''}` || currentParent.id,
type: currentParent.data?.type as OrganizationalUnitType | undefined
})
}
}
return options.sort((a, b) => a.label.localeCompare(b.label, 'de', { numeric: true, sensitivity: 'base' }))
}, [nodes, editDialog.nodeId, allowedParentTypes, descendantIds, editForm.parentId, nodesById])
// Load organizational units
useEffect(() => {
@ -126,12 +237,14 @@ export default function OrganizationEditor() {
setNodes(flowNodes)
setEdges(flowEdges)
computeIssues(flowNodes)
return { nodes: flowNodes, edges: flowEdges }
}
} catch (error) {
console.error('Failed to load organization:', error)
} finally {
setLoading(false)
}
return null
}
const convertToFlowElements = (units: any[], parentPosition = { x: 0, y: 0 }, level = 0): { nodes: Node[], edges: Edge[] } => {
@ -351,6 +464,104 @@ export default function OrganizationEditor() {
}
}
const resetEditForm = () => {
setEditForm({
code: '',
name: '',
type: 'dezernat',
level: 0,
description: '',
hasFuehrungsstelle: false,
fuehrungsstelleName: '',
parentId: null
})
}
const closeEditModal = () => {
setEditDialog({ open: false, nodeId: null })
setEditError(null)
resetEditForm()
}
const openEditModal = useCallback((node: Node) => {
setSelectedNode(node)
const nodeData: any = node.data || {}
const typeCandidate = nodeData.type
const allowedType = UNIT_TYPE_OPTIONS.some(option => option.value === typeCandidate)
? typeCandidate
: 'dezernat'
const levelCandidate = typeof nodeData.level === 'number'
? nodeData.level
: (nodeData.level != null && !isNaN(Number(nodeData.level)) ? Number(nodeData.level) : 0)
setEditForm({
code: nodeData.code || '',
name: nodeData.name || '',
type: allowedType,
level: levelCandidate,
description: nodeData.description || '',
hasFuehrungsstelle: !!nodeData.hasFuehrungsstelle,
fuehrungsstelleName: nodeData.fuehrungsstelleName || '',
parentId: nodeData.parentId || null
})
setEditError(null)
setEditDialog({ open: true, nodeId: node.id })
}, [])
const handleNodeDoubleClick = useCallback((_: any, node: Node) => {
openEditModal(node)
}, [openEditModal])
const handleUpdateUnit = async () => {
if (!editDialog.nodeId) return
const trimmedName = editForm.name.trim()
const trimmedCode = editForm.code.trim()
if (!trimmedName || !trimmedCode) {
setEditError('Name und Code dürfen nicht leer sein.')
return
}
setEditSaving(true)
setEditError(null)
try {
const normalizedLevel = Number.isFinite(editForm.level)
? Math.max(0, Math.min(10, Math.round(editForm.level)))
: 0
const payload: any = {
name: trimmedName,
code: trimmedCode,
type: editForm.type,
level: normalizedLevel,
description: editForm.description,
hasFuehrungsstelle: editForm.hasFuehrungsstelle
}
if (editForm.hasFuehrungsstelle && editForm.fuehrungsstelleName.trim()) {
payload.fuehrungsstelleName = editForm.fuehrungsstelleName.trim()
}
payload.parentId = editForm.parentId ?? null
const response = await api.put(`/organization/units/${editDialog.nodeId}`, payload)
if (response.data?.success) {
const updated = await loadOrganization()
if (updated?.nodes) {
const refreshed = updated.nodes.find((n: Node) => n.id === editDialog.nodeId)
if (refreshed) {
setSelectedNode(refreshed)
}
}
closeEditModal()
}
} catch (error: any) {
console.error('Update unit failed:', error)
setEditError(error?.response?.data?.error?.message || 'Aktualisierung fehlgeschlagen')
} finally {
setEditSaving(false)
}
}
// Compute simple validation issues (orphans)
const computeIssues = (flowNodes: Node[]) => {
const ids = new Set(flowNodes.map(n => n.id))
@ -590,6 +801,7 @@ export default function OrganizationEditor() {
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypes}
fitView
@ -611,7 +823,7 @@ export default function OrganizationEditor() {
return (type && map[type]) || '#94a3b8'
}}
/>
<Background variant="dots" gap={12} size={1} />
<Background variant={BackgroundVariant.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>
@ -654,6 +866,12 @@ export default function OrganizationEditor() {
{selectedNode.data.description && (
<p className="text-sm mt-2">{selectedNode.data.description}</p>
)}
<button
onClick={() => openEditModal(selectedNode)}
className="mt-3 w-full px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
>
Einheit bearbeiten
</button>
</>
) : (
<p className="text-gray-500">Klicken Sie auf eine Einheit für Details</p>
@ -812,6 +1030,158 @@ export default function OrganizationEditor() {
</div>
)}
{/* Edit Unit Dialog */}
{editDialog.open && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Einheit bearbeiten</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name *</label>
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border rounded"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Code *</label>
<input
type="text"
value={editForm.code}
onChange={(e) => setEditForm(prev => ({ ...prev, code: e.target.value }))}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Typ *</label>
<select
value={editForm.type}
onChange={(e) => {
const nextType = e.target.value as OrganizationalUnitType
setEditForm(prev => {
let nextParentId = prev.parentId
const allowed = PARENT_RULES[nextType] ?? null
if (allowed && allowed.length > 0) {
const currentParentType = nextParentId ? nodesById[nextParentId]?.data?.type as OrganizationalUnitType | undefined : undefined
if (!currentParentType || !allowed.includes(currentParentType)) {
const candidate = nodes.find(node => {
if (node.id === editDialog.nodeId) return false
if (descendantIds.has(node.id)) return false
const nodeType = node.data?.type as OrganizationalUnitType | undefined
return nodeType ? allowed.includes(nodeType) : false
})
nextParentId = candidate ? candidate.id : null
}
}
return { ...prev, type: nextType, parentId: nextParentId }
})
}}
className="w-full px-3 py-2 border rounded"
>
{UNIT_TYPE_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Übergeordnete Einheit</label>
<select
value={editForm.parentId ?? ''}
onChange={(e) => setEditForm(prev => ({ ...prev, parentId: e.target.value === '' ? null : e.target.value }))}
className="w-full px-3 py-2 border rounded"
disabled={!allowRootSelection && parentOptions.length === 0}
>
{allowRootSelection && <option value="">(Oberste Ebene)</option>}
{parentOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<p className="text-xs text-slate-500 mt-1">
{allowRootSelection
? 'Optional: Kann auf oberster Ebene stehen oder einer übergeordneten Einheit zugeordnet werden.'
: 'Wählen Sie eine passende übergeordnete Einheit gemäß der Hierarchie.'}
</p>
{!allowRootSelection && parentOptions.length === 0 && (
<p className="text-xs text-amber-600 mt-1">Keine passende übergeordnete Einheit verfügbar.</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Ebene</label>
<input
type="number"
value={editForm.level}
onChange={(e) => setEditForm(prev => ({ ...prev, level: Number(e.target.value) || 0 }))}
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={editForm.description}
onChange={(e) => setEditForm(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 border rounded"
rows={3}
/>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={editForm.hasFuehrungsstelle}
onChange={(e) => setEditForm(prev => ({ ...prev, hasFuehrungsstelle: e.target.checked }))}
/>
Führungsstelle vorhanden
</label>
{editForm.hasFuehrungsstelle && (
<input
type="text"
value={editForm.fuehrungsstelleName}
onChange={(e) => setEditForm(prev => ({ ...prev, fuehrungsstelleName: e.target.value }))}
className="w-full px-3 py-2 border rounded"
placeholder="Bezeichnung der Führungsstelle"
/>
)}
</div>
{editError && (
<div className="p-3 bg-red-50 text-red-700 rounded text-sm">
{editError}
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={closeEditModal}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
disabled={editSaving}
>
Abbrechen
</button>
<button
onClick={handleUpdateUnit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-70"
disabled={editSaving}
>
{editSaving ? 'Speichert...' : 'Änderungen speichern'}
</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">

Datei anzeigen

@ -0,0 +1,266 @@
import { useCallback, useEffect, useState } from 'react'
import { positionsApi } from '../services/api'
import { OrganizationSelector } from '../components'
import { SettingsIcon, TrashIcon } from '../components/icons'
interface PositionEntry {
id: string
label: string
organizationUnitId: string | null
orderIndex?: number
isActive?: boolean
originalLabel?: string
}
export default function Positions() {
const [scope, setScope] = useState<'global' | 'unit'>('global')
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null)
const [selectedUnitName, setSelectedUnitName] = useState('')
const [positions, setPositions] = useState<PositionEntry[]>([])
const [newLabel, setNewLabel] = useState('')
const [loading, setLoading] = useState(true)
const [savingId, setSavingId] = useState<string | null>(null)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const activeUnitId = scope === 'unit' ? selectedUnitId : null
const loadPositions = useCallback(async () => {
try {
setLoading(true)
setError('')
const data = await positionsApi.listAdmin(activeUnitId)
const normalized = (Array.isArray(data) ? data : []).map((item: any) => ({
...item,
originalLabel: item.label
})) as PositionEntry[]
setPositions(normalized)
} catch (err) {
console.error('Failed to load positions', err)
setError('Positionen konnten nicht geladen werden.')
setPositions([])
} finally {
setLoading(false)
}
}, [activeUnitId])
useEffect(() => {
if (scope === 'unit' && !selectedUnitId) {
setPositions([])
setLoading(false)
return
}
loadPositions()
}, [scope, selectedUnitId, loadPositions])
const handleAdd = async () => {
if (!newLabel.trim()) return
if (scope === 'unit' && !selectedUnitId) {
setError('Bitte zuerst eine Organisationseinheit auswählen.')
return
}
try {
setSavingId('new')
setError('')
setSuccess('')
await positionsApi.create({ label: newLabel.trim(), organizationUnitId: activeUnitId })
setNewLabel('')
await loadPositions()
setSuccess('Position gespeichert.')
} catch (err: any) {
console.error('Failed to create position', err)
if (err?.response?.status === 409) {
setError('Diese Position existiert bereits für die gewählte Organisationseinheit.')
} else {
setError('Position konnte nicht hinzugefügt werden.')
}
} finally {
setSavingId(null)
}
}
const handleUpdate = async (id: string, label: string) => {
if (!label.trim()) {
setError('Die Bezeichnung darf nicht leer sein.')
return
}
try {
setSavingId(id)
setError('')
setSuccess('')
await positionsApi.update(id, { label: label.trim(), organizationUnitId: activeUnitId ?? null })
await loadPositions()
setSuccess('Position aktualisiert.')
} catch (err: any) {
console.error('Failed to update position', err)
if (err?.response?.status === 409) {
setError('Diese Position existiert bereits für die gewählte Organisationseinheit.')
} else {
setError('Position konnte nicht aktualisiert werden.')
}
} finally {
setSavingId(null)
}
}
const handleDelete = async (id: string) => {
if (!window.confirm('Position wirklich löschen?')) return
try {
setSavingId(id)
setError('')
setSuccess('')
await positionsApi.remove(id)
await loadPositions()
setSuccess('Position entfernt.')
} catch (err) {
console.error('Failed to delete position', err)
setError('Position konnte nicht gelöscht werden.')
} finally {
setSavingId(null)
}
}
const scopeLabel = scope === 'global' ? 'Allgemeine Positionen' : (selectedUnitName || 'Organisationseinheit auswählen')
return (
<div>
<div className="mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">Positionen</h1>
<p className="text-body text-secondary">Pflegen Sie neutrale Positionsbezeichnungen zentral oder für einzelne Organisationseinheiten.</p>
</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-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
{success}
</div>
)}
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Bereich auswählen</h2>
<div className="space-y-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
checked={scope === 'global'}
onChange={() => {
setScope('global')
setSelectedUnitId(null)
setSelectedUnitName('')
}}
className="mt-1 text-primary-blue focus:ring-primary-blue"
/>
<div>
<div className="font-medium text-secondary">Allgemeine Positionen</div>
<div className="text-small text-tertiary">Gilt für alle Organisationseinheiten.</div>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="radio"
checked={scope === 'unit'}
onChange={() => setScope('unit')}
className="mt-1 text-primary-blue focus:ring-primary-blue"
/>
<div className="flex-1">
<div className="font-medium text-secondary">Positionsliste für eine Organisationseinheit</div>
<div className="text-small text-tertiary mb-3">
Überschreibt die allgemeinen Positionen nur für die gewählte Einheit.
</div>
{scope === 'unit' && (
<div className="max-w-md">
<OrganizationSelector
value={selectedUnitId}
onChange={(unitId, formattedValue, details) => {
setSelectedUnitId(unitId)
setSelectedUnitName(details?.displayPath || formattedValue)
}}
/>
{selectedUnitName && (
<div className="text-small text-tertiary mt-2">
Ausgewählt: {selectedUnitName}
</div>
)}
</div>
)}
</div>
</label>
</div>
</div>
<div className="card">
<div className="flex items-center mb-6">
<SettingsIcon className="w-6 h-6 text-primary-blue mr-3" />
<h2 className="text-title-card font-poppins font-semibold text-primary">
{scopeLabel}
</h2>
</div>
{loading ? (
<div className="text-secondary">Positionen werden geladen...</div>
) : scope === 'unit' && !selectedUnitId ? (
<div className="text-secondary">Bitte wählen Sie eine Organisationseinheit aus, um positionsspezifische Einträge zu verwalten.</div>
) : (
<div className="space-y-4">
{positions.length === 0 && (
<p className="text-small text-secondary">Noch keine Positionen hinterlegt.</p>
)}
{positions.map(position => (
<div key={position.id} className="flex items-center gap-3 p-3 border border-border-light rounded-input">
<input
className="flex-1 input-field"
value={position.label}
onChange={event => {
const updated = positions.map(item => item.id === position.id ? { ...item, label: event.target.value } : item)
setPositions(updated)
}}
onBlur={event => {
if (event.target.value.trim() !== (position.originalLabel || '').trim()) {
handleUpdate(position.id, event.target.value)
}
}}
placeholder="Position"
/>
<button
onClick={() => handleDelete(position.id)}
className="p-2 text-error hover:bg-red-50 rounded-full"
disabled={savingId === position.id}
title="Löschen"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
))}
</div>
)}
<div className="mt-6 border-t border-border-light pt-4">
<h3 className="text-body font-medium text-secondary mb-2">Neue Position hinzufügen</h3>
<div className="flex gap-3">
<input
className="input-field flex-1"
value={newLabel}
onChange={event => setNewLabel(event.target.value)}
placeholder="z. B. Sachbearbeitung"
disabled={savingId === 'new'}
/>
<button
className="btn-primary"
onClick={handleAdd}
disabled={savingId === 'new' || !newLabel.trim() || (scope === 'unit' && !selectedUnitId)}
>
Hinzufügen
</button>
</div>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -1,9 +1,11 @@
import { useState, useEffect, DragEvent } from 'react'
import { useState, useEffect, useMemo, DragEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../services/api'
import { User, UserRole } from '@skillmate/shared'
import type { User, UserRole, PowerFunctionDefinition, OrganizationalUnitType } from '@skillmate/shared'
import { POWER_FUNCTIONS } from '@skillmate/shared'
import { TrashIcon, ShieldIcon, KeyIcon } from '../components/icons'
import { useAuthStore } from '../stores/authStore'
import { OrganizationSelector } from '../components'
interface UserWithEmployee extends User {
employeeName?: string
@ -17,6 +19,11 @@ export default function UserManagement() {
const [error, setError] = useState('')
const [editingUser, setEditingUser] = useState<string | null>(null)
const [editRole, setEditRole] = useState<UserRole>('user')
const [editPowerUnitId, setEditPowerUnitId] = useState<string | null>(null)
const [editPowerUnitName, setEditPowerUnitName] = useState('')
const [editPowerUnitType, setEditPowerUnitType] = useState<OrganizationalUnitType | null>(null)
const [editPowerFunction, setEditPowerFunction] = useState('')
const [roleValidationError, setRoleValidationError] = useState('')
const [resetPasswordUser, setResetPasswordUser] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState('')
@ -45,7 +52,6 @@ export default function UserManagement() {
})
setUsers(enrichedUsers)
setEmployees(employeesData)
} catch (err: any) {
console.error('Failed to fetch users:', err)
setError('Benutzer konnten nicht geladen werden')
@ -54,8 +60,6 @@ export default function UserManagement() {
}
}
const [employees, setEmployees] = useState<any[]>([])
// Import state
type ImportRow = { firstName: string; lastName: string; email: string; department: string }
const [dragActive, setDragActive] = useState(false)
@ -76,13 +80,70 @@ export default function UserManagement() {
// Store temporary passwords per user to show + email
const [tempPasswords, setTempPasswords] = useState<Record<string, { password: string }>>({})
const availableEditFunctions = useMemo<PowerFunctionDefinition[]>(() => {
if (!editPowerUnitType) return POWER_FUNCTIONS
return POWER_FUNCTIONS.filter(def => def.unitTypes.includes(editPowerUnitType))
}, [editPowerUnitType])
const selectedEditPowerFunction = useMemo(() => {
if (!editPowerFunction) return null
return availableEditFunctions.find(def => def.id === editPowerFunction) || null
}, [availableEditFunctions, editPowerFunction])
useEffect(() => {
if (!editPowerFunction) return
const stillValid = availableEditFunctions.some(def => def.id === editPowerFunction)
if (!stillValid) {
setEditPowerFunction('')
}
}, [availableEditFunctions, editPowerFunction])
const powerFunctionLabelMap = useMemo(() => {
return POWER_FUNCTIONS.reduce<Record<string, PowerFunctionDefinition>>((acc, def) => {
acc[def.id] = def
return acc
}, {})
}, [])
const getPowerFunctionLabel = (id?: string | null) => {
if (!id) return undefined
return powerFunctionLabelMap[id]?.label || undefined
}
// removed legacy creation helpers for employees without user accounts
const handleRoleChange = async (userId: string) => {
setRoleValidationError('')
if (editRole === 'superuser') {
if (!editPowerUnitId) {
setRoleValidationError('Bitte wählen Sie eine Organisationseinheit für den Poweruser aus.')
return
}
if (!editPowerFunction) {
setRoleValidationError('Bitte wählen Sie eine Poweruser-Funktion aus.')
return
}
const def = POWER_FUNCTIONS.find(fn => fn.id === editPowerFunction)
if (def && editPowerUnitType && !def.unitTypes.includes(editPowerUnitType)) {
setRoleValidationError(`Die Funktion ${def.label} ist für den gewählten Einheitstyp nicht zulässig.`)
return
}
}
try {
await api.put(`/admin/users/${userId}/role`, { role: editRole })
await api.put(`/admin/users/${userId}/role`, {
role: editRole,
powerUnitId: editRole === 'superuser' ? editPowerUnitId : null,
powerFunction: editRole === 'superuser' ? editPowerFunction : null
})
await fetchUsers()
setEditingUser(null)
setEditPowerUnitId(null)
setEditPowerUnitName('')
setEditPowerUnitType(null)
setEditPowerFunction('')
} catch (err: any) {
setError('Rolle konnte nicht geändert werden')
}
@ -339,35 +400,132 @@ export default function UserManagement() {
<span className="text-tertiary italic">Nicht verknüpft</span>
)}
</td>
<td className="py-4 px-4">
<td className="py-4 px-4 align-top">
{editingUser === user.id ? (
<div className="flex items-center space-x-2">
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)}
className="input-field py-1 text-sm"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<button
onClick={() => handleRoleChange(user.id)}
className="text-primary-blue hover:text-primary-blue-hover"
>
</button>
<button
onClick={() => setEditingUser(null)}
className="text-error hover:text-red-700"
>
</button>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<select
value={editRole}
onChange={(e) => {
const nextRole = e.target.value as UserRole
setEditRole(nextRole)
setRoleValidationError('')
if (nextRole !== 'superuser') {
setEditPowerFunction('')
}
}}
className="input-field py-1 text-sm"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<button
onClick={() => handleRoleChange(user.id)}
className="text-primary-blue hover:text-primary-blue-hover"
>
</button>
<button
onClick={() => {
setEditingUser(null)
setEditPowerUnitId(null)
setEditPowerUnitName('')
setEditPowerUnitType(null)
setEditPowerFunction('')
setRoleValidationError('')
}}
className="text-error hover:text-red-700"
>
</button>
</div>
{editRole === 'superuser' && (
<div className="space-y-3 rounded-lg border border-border-light bg-gray-50 p-3">
<div>
<label className="block text-xs font-medium text-secondary mb-1">
Organisationseinheit *
</label>
<OrganizationSelector
value={editPowerUnitId}
onChange={(unitId, formattedValue, details) => {
setEditPowerUnitId(unitId)
setEditPowerUnitName(details?.displayPath || formattedValue)
setEditPowerUnitType(details?.unit.type || null)
setRoleValidationError('')
if (!unitId) {
setEditPowerFunction('')
}
}}
/>
<p className="text-xs text-secondary mt-1">
{editPowerUnitName || 'Keine Einheit ausgewählt'}
</p>
</div>
<div>
<label className="block text-xs font-medium text-secondary mb-1">
Funktion *
</label>
<select
value={editPowerFunction}
onChange={(e) => {
setEditPowerFunction(e.target.value)
setRoleValidationError('')
}}
className="input-field py-1 text-sm"
disabled={!editPowerUnitId || availableEditFunctions.length === 0}
>
<option value="">Bitte auswählen</option>
{availableEditFunctions.map(fn => (
<option key={fn.id} value={fn.id}>{fn.label}</option>
))}
</select>
{!editPowerUnitId && (
<p className="text-xs text-secondary mt-1">
Bitte wählen Sie zuerst eine Organisationseinheit aus.
</p>
)}
{editPowerUnitId && availableEditFunctions.length === 0 && (
<p className="text-xs text-warning mt-1">
Für den gewählten Einheitstyp sind keine Poweruser-Funktionen definiert.
</p>
)}
{selectedEditPowerFunction && !selectedEditPowerFunction.canManageEmployees && (
<p className="text-xs text-secondary mt-1">
Hinweis: Diese Funktion berechtigt nicht zum Anlegen neuer Mitarbeitender.
</p>
)}
</div>
{roleValidationError && (
<p className="text-xs text-error">{roleValidationError}</p>
)}
</div>
)}
</div>
) : (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{getRoleLabel(user.role)}
</span>
<div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{getRoleLabel(user.role)}
</span>
{user.role === 'superuser' && (
<div className="text-xs text-secondary mt-1 space-y-0.5">
{user.powerUnitName ? (
<div>{user.powerUnitName}</div>
) : (
<div className="text-tertiary">Keine Einheit zugewiesen</div>
)}
<div>
{user.powerFunction ? getPowerFunctionLabel(user.powerFunction) || 'Keine Funktion' : 'Keine Funktion'}
{user.powerFunction && (user.canManageEmployees === false || powerFunctionLabelMap[user.powerFunction]?.canManageEmployees === false) && (
<span className="ml-1 text-tertiary">(keine Mitarbeitendenanlage)</span>
)}
</div>
</div>
)}
</div>
)}
</td>
<td className="py-4 px-4">
@ -389,6 +547,11 @@ export default function UserManagement() {
onClick={() => {
setEditingUser(user.id)
setEditRole(user.role)
setEditPowerUnitId(user.powerUnitId || null)
setEditPowerUnitName(user.powerUnitName || '')
setEditPowerUnitType(user.powerUnitType || null)
setEditPowerFunction(user.powerFunction || '')
setRoleValidationError('')
}}
className="p-1 text-secondary hover:text-primary-blue transition-colors"
title="Rolle bearbeiten"
@ -483,7 +646,7 @@ export default function UserManagement() {
</h3>
<ul className="space-y-2 text-body text-secondary">
<li> <strong>Administrator:</strong> Vollzugriff auf alle Funktionen und Einstellungen</li>
<li> <strong>Poweruser:</strong> Kann Mitarbeitende und Skills verwalten, aber keine Systemeinstellungen ändern</li>
<li> <strong>Poweruser:</strong> Kann Mitarbeitende und die Skillverwaltung nutzen, aber keine Systemeinstellungen ändern</li>
<li> <strong>Benutzer:</strong> Kann nur eigenes Profil bearbeiten und Daten einsehen</li>
<li> Neue Benutzer können über den Import oder die Mitarbeitendenverwaltung angelegt werden</li>
<li> Der Admin-Benutzer kann nicht gelöscht werden</li>

Datei anzeigen

@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
// Use relative asset paths so the build works when deployed under a subdirectory
base: './',
plugins: [react()],
server: {
port: Number(process.env.VITE_PORT || 5174),

Datei anzeigen

@ -3,32 +3,43 @@
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const Database = require('better-sqlite3')
function parseFrontendHierarchy() {
function loadHierarchy() {
const sharedPath = path.join(process.cwd(), '..', 'shared', 'skills.js')
if (fs.existsSync(sharedPath)) {
const sharedModule = require(sharedPath)
if (Array.isArray(sharedModule?.SKILL_HIERARCHY)) {
return sharedModule.SKILL_HIERARCHY
}
throw new Error('SKILL_HIERARCHY missing or invalid in shared/skills.js')
}
const tsPath = path.join(process.cwd(), '..', 'frontend', 'src', 'data', 'skillCategories.ts')
if (!fs.existsSync(tsPath)) {
throw new Error('No skill hierarchy definition found in shared/skills.js or frontend/src/data/skillCategories.ts')
}
const src = fs.readFileSync(tsPath, 'utf8')
// Remove interface declarations and LANGUAGE_LEVELS export, keep the array literal
let code = src
.replace(/export interface[\s\S]*?\n\}/g, '')
.replace(/export const LANGUAGE_LEVELS[\s\S]*?\n\n/, '')
.replace(/export const SKILL_HIERARCHY:[^=]*=/, 'module.exports =')
const sandbox = { module: {}, exports: {} }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
return sandbox.module.exports || sandbox.exports
require('vm').runInNewContext(code, sandbox)
const hierarchy = sandbox.module?.exports || sandbox.exports
if (!Array.isArray(hierarchy)) {
throw new Error('Parsed hierarchy is not an array')
}
return hierarchy
}
function main() {
const dbPath = path.join(process.cwd(), 'skillmate.dev.encrypted.db')
const db = new Database(dbPath)
try {
const hierarchy = parseFrontendHierarchy()
if (!Array.isArray(hierarchy)) {
throw new Error('Parsed hierarchy is not an array')
}
const hierarchy = loadHierarchy()
const insert = db.prepare(`
INSERT OR IGNORE INTO skills (id, name, category, description, expires_after)

Datei anzeigen

@ -207,6 +207,86 @@ export function initializeSecureDatabase() {
CREATE INDEX IF NOT EXISTS idx_employees_phone_hash ON employees(phone_hash);
`)
// Official titles catalog managed via admin panel
db.exec(`
CREATE TABLE IF NOT EXISTS official_titles (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
order_index INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_official_titles_label ON official_titles(label COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_official_titles_order ON official_titles(order_index);
`)
try {
const existingTitles = db.prepare('SELECT COUNT(*) as count FROM official_titles').get() as { count: number }
if (!existingTitles || existingTitles.count === 0) {
const defaults = [
'Sachbearbeitung',
'stellvertretende Sachgebietsleitung',
'Sachgebietsleitung',
'Dezernatsleitung',
'Abteilungsleitung',
'Behördenleitung'
]
const now = new Date().toISOString()
const insert = db.prepare(`
INSERT INTO official_titles (id, label, order_index, is_active, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
`)
defaults.forEach((label, index) => {
insert.run(uuidv4(), label, index, now, now)
})
}
} catch (error) {
console.warn('Failed to seed official titles:', error)
}
// Position catalog managed via admin panel (optionally scoped per organisationseinheit)
db.exec(`
CREATE TABLE IF NOT EXISTS position_catalog (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
organization_unit_id TEXT,
order_index INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_position_catalog_unit ON position_catalog(organization_unit_id);
CREATE INDEX IF NOT EXISTS idx_position_catalog_order ON position_catalog(order_index);
CREATE UNIQUE INDEX IF NOT EXISTS idx_position_catalog_unique ON position_catalog(label COLLATE NOCASE, IFNULL(organization_unit_id, 'GLOBAL'));
`)
try {
const existingPositions = db.prepare('SELECT COUNT(*) as count FROM position_catalog WHERE organization_unit_id IS NULL').get() as { count: number }
if (!existingPositions || existingPositions.count === 0) {
const defaults = [
'Sachbearbeitung',
'stellvertretende Sachgebietsleitung',
'Sachgebietsleitung',
'Dezernatsleitung',
'Abteilungsleitung',
'Behördenleitung'
]
const now = new Date().toISOString()
const insert = db.prepare(`
INSERT INTO position_catalog (id, label, organization_unit_id, order_index, is_active, created_at, updated_at)
VALUES (?, ?, NULL, ?, 1, ?, ?)
`)
defaults.forEach((label, index) => {
insert.run(uuidv4(), label, index, now, now)
})
}
} catch (error) {
console.warn('Failed to seed position catalog:', error)
}
// Users table with encrypted email
db.exec(`
CREATE TABLE IF NOT EXISTS users (
@ -217,19 +297,37 @@ export function initializeSecureDatabase() {
password TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
employee_id TEXT,
power_unit_id TEXT,
power_function TEXT,
last_login TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(email_hash)
UNIQUE(email_hash),
FOREIGN KEY(power_unit_id) REFERENCES organizational_units(id)
)
`)
// Create index for email hash
db.exec(`
CREATE INDEX IF NOT EXISTS idx_users_email_hash ON users(email_hash);
`)
// Ensure new power user columns exist (legacy migrations)
try {
const userCols: any[] = db.prepare(`PRAGMA table_info(users)`).all() as any
const hasPowerUnit = userCols.some(c => c.name === 'power_unit_id')
const hasPowerFunction = userCols.some(c => c.name === 'power_function')
if (!hasPowerUnit) {
db.exec(`ALTER TABLE users ADD COLUMN power_unit_id TEXT`)
}
if (!hasPowerFunction) {
db.exec(`ALTER TABLE users ADD COLUMN power_function TEXT`)
}
} catch (error) {
console.error('Failed to ensure power user columns:', error)
}
// Skills table
db.exec(`
CREATE TABLE IF NOT EXISTS skills (

Datei anzeigen

@ -16,6 +16,8 @@ import workspaceRoutes from './routes/workspaces'
import userRoutes from './routes/users'
import userAdminRoutes from './routes/usersAdmin'
import settingsRoutes from './routes/settings'
import officialTitlesRoutes from './routes/officialTitles'
import positionsRoutes from './routes/positions'
import organizationRoutes from './routes/organization'
import organizationImportRoutes from './routes/organizationImport'
import employeeOrganizationRoutes from './routes/employeeOrganization'
@ -65,6 +67,8 @@ 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/positions', positionsRoutes)
app.use('/api/official-titles', officialTitlesRoutes)
app.use('/api/organization', organizationRoutes)
app.use('/api/organization', organizationImportRoutes)
app.use('/api', employeeOrganizationRoutes)

Datei anzeigen

@ -2,10 +2,11 @@ import { Router, Request, Response, NextFunction } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { body, validationResult } from 'express-validator'
import { db } from '../config/secureDatabase'
import { User, LoginRequest, LoginResponse } from '@skillmate/shared'
import { db, encryptedDb } from '../config/secureDatabase'
import { User, LoginRequest, LoginResponse, POWER_FUNCTIONS } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption'
import { logger } from '../utils/logger'
import { emailService } from '../services/emailService'
const router = Router()
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
@ -77,7 +78,20 @@ router.post('/login',
const now = new Date().toISOString()
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id)
// Enrich with power user meta (unit + function)
const power = db.prepare(`
SELECT u.power_unit_id as powerUnitId,
u.power_function as powerFunction,
ou.name as powerUnitName,
ou.type as powerUnitType
FROM users u
LEFT JOIN organizational_units ou ON ou.id = u.power_unit_id
WHERE u.id = ?
`).get(userRow.id) as any
// Create user object without password (decrypt email)
const powerFunctionId = power?.powerFunction || null
const powerDefinition = powerFunctionId ? POWER_FUNCTIONS.find(def => def.id === powerFunctionId) : undefined
const user: User = {
id: userRow.id,
username: userRow.username,
@ -87,7 +101,12 @@ router.post('/login',
lastLogin: new Date(now),
isActive: Boolean(userRow.is_active),
createdAt: new Date(userRow.created_at),
updatedAt: new Date(userRow.updated_at)
updatedAt: new Date(userRow.updated_at),
powerUnitId: power?.powerUnitId || null,
powerUnitName: power?.powerUnitName || null,
powerUnitType: power?.powerUnitType || null,
powerFunction: powerFunctionId,
canManageEmployees: userRow.role === 'admin' || (userRow.role === 'superuser' && Boolean(powerDefinition?.canManageEmployees))
}
// Generate token
@ -114,8 +133,87 @@ router.post('/login',
}
)
router.post('/forgot-password',
[
body('email').isEmail().normalizeEmail()
],
async (req: Request, res: Response, next: NextFunction) => {
const genericMessage = 'Falls die angegebene E-Mail im System hinterlegt ist, erhalten Sie in Kürze ein neues Passwort.'
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
// Immer generische Antwort senden, um keine Information preiszugeben
logger.warn('Forgot password request received with invalid email input')
return res.json({ success: true, message: genericMessage })
}
const { email } = req.body as { email: string }
const normalizedEmail = email.trim().toLowerCase()
const emailHash = FieldEncryption.hash(normalizedEmail)
const userRow = db.prepare(`
SELECT id, username, email, employee_id, is_active
FROM users
WHERE email_hash = ?
`).get(emailHash) as any
if (!userRow || !userRow.is_active) {
logger.info('Forgot password request for non-existing or inactive account')
return res.json({ success: true, message: genericMessage })
}
const decryptedEmail = FieldEncryption.decrypt(userRow.email) || normalizedEmail
let firstName: string | undefined
if (userRow.employee_id) {
try {
const employee = encryptedDb.getEmployee(userRow.employee_id)
if (employee?.first_name) {
firstName = employee.first_name
}
} catch (error) {
logger.warn(`Failed to resolve employee for password reset: ${error}`)
}
}
const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
const canSendEmail = emailNotificationsEnabled && emailService.isServiceEnabled()
if (!canSendEmail) {
logger.warn('Password reset requested but email notifications are disabled or email service unavailable')
return res.json({ success: true, message: genericMessage })
}
const temporaryPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const sent = await emailService.sendInitialPassword(decryptedEmail, temporaryPassword, firstName)
if (!sent) {
logger.warn(`Password reset email could not be sent to ${decryptedEmail}`)
return res.json({ success: true, message: genericMessage })
}
const hashedPassword = await bcrypt.hash(temporaryPassword, 12)
const now = new Date().toISOString()
db.prepare(`
UPDATE users
SET password = ?, updated_at = ?
WHERE id = ?
`).run(hashedPassword, now, userRow.id)
logger.info(`Password reset processed for user ${userRow.username}`)
return res.json({ success: true, message: genericMessage })
} catch (error) {
logger.error('Error processing forgot password request:', error)
return res.json({ success: true, message: genericMessage })
}
}
)
router.post('/logout', (req, res) => {
res.json({ success: true, message: 'Logged out successfully' })
})
export default router
export default router

Datei anzeigen

@ -82,17 +82,24 @@ router.put('/employee/:employeeId/organization', authenticate, async (req: AuthR
assignmentId, employeeId, unitId, 'mitarbeiter',
now, 1, now, now
)
// Keep employees.primary_unit_id in sync for listings
db.prepare(`
UPDATE employees
SET primary_unit_id = ?, updated_at = ?
WHERE id = ?
`).run(unitId, now, employeeId)
}
// 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
const unitInfo = db.prepare('SELECT code, 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)
`).run(unitInfo.code || unitInfo.name, now, employeeId)
}
} else {
// Clear department if no unit
@ -163,4 +170,4 @@ router.get('/unit/:unitId/employees', authenticate, async (req: AuthRequest, res
}
})
export default router
export default router

Datei anzeigen

@ -5,12 +5,99 @@ 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, EmployeeUnitRole } from '@skillmate/shared'
import { Employee, LanguageSkill, Skill, UserRole, EmployeeUnitRole, EmployeeDeputySummary, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption'
import { decodeHtmlEntities } from '../utils/html'
import { createDepartmentResolver } from '../utils/department'
const router = Router()
function toSqlDateTime(date: Date): string {
const pad = (value: number) => value.toString().padStart(2, '0')
const year = date.getUTCFullYear()
const month = pad(date.getUTCMonth() + 1)
const day = pad(date.getUTCDate())
const hours = pad(date.getUTCHours())
const minutes = pad(date.getUTCMinutes())
const seconds = pad(date.getUTCSeconds())
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function clearActiveDeputiesForPrincipal(principalId: string) {
const now = new Date()
const past = new Date(now.getTime() - 1000)
const pastSql = toSqlDateTime(past)
const nowSql = toSqlDateTime(now)
db.prepare(`
UPDATE deputy_assignments
SET valid_until = ?, updated_at = ?
WHERE principal_id = ?
AND valid_until >= datetime('now')
`).run(pastSql, nowSql, principalId)
db.prepare(`
DELETE FROM deputy_assignments
WHERE principal_id = ?
AND valid_from > datetime('now')
`).run(principalId)
}
const resolveDepartmentInfo = createDepartmentResolver(db)
function getActiveDeputies(principalId: string): EmployeeDeputySummary[] {
const rows = db.prepare(`
SELECT
da.id as assignmentId,
da.deputy_id as deputyId,
e.first_name as firstName,
e.last_name as lastName,
e.availability as availability,
e.position as position
FROM deputy_assignments da
JOIN employees e ON e.id = da.deputy_id
WHERE da.principal_id = ?
AND da.valid_from <= datetime('now')
AND da.valid_until >= datetime('now')
ORDER BY e.last_name, e.first_name
`).all(principalId) as any[]
return rows.map(row => ({
id: row.deputyId,
assignmentId: row.assignmentId,
firstName: row.firstName,
lastName: row.lastName,
availability: row.availability,
position: row.position || undefined
}))
}
function getActivePrincipals(deputyId: string): EmployeeDeputySummary[] {
const rows = db.prepare(`
SELECT
da.id as assignmentId,
da.principal_id as principalId,
e.first_name as firstName,
e.last_name as lastName,
e.availability as availability,
e.position as position
FROM deputy_assignments da
JOIN employees e ON e.id = da.principal_id
WHERE da.deputy_id = ?
AND da.valid_from <= datetime('now')
AND da.valid_until >= datetime('now')
ORDER BY e.last_name, e.first_name
`).all(deputyId) as any[]
return rows.map(row => ({
id: row.principalId,
assignmentId: row.assignmentId,
firstName: row.firstName,
lastName: row.lastName,
availability: row.availability,
position: row.position || undefined
}))
}
// Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
const mapping: Record<string, 'basic' | 'fluent' | 'native' | 'business'> = {
@ -34,15 +121,65 @@ function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'nativ
router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const employees = db.prepare(`
SELECT id, first_name, last_name, employee_number, photo, position, official_title,
department, email, phone, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
created_at, updated_at, created_by, updated_by
FROM employees
ORDER BY last_name, first_name
SELECT
e.id,
e.first_name,
e.last_name,
e.employee_number,
e.photo,
e.position,
e.official_title,
e.department,
e.email,
e.phone,
e.mobile,
e.office,
e.availability,
e.primary_unit_id as primaryUnitId,
ou.code as primaryUnitCode,
ou.name as primaryUnitName,
ou.description as primaryUnitDescription,
e.clearance_level,
e.clearance_valid_until,
e.clearance_issued_date,
e.created_at,
e.updated_at,
e.created_by,
e.updated_by
FROM employees e
LEFT JOIN organizational_units ou ON ou.id = e.primary_unit_id
ORDER BY e.last_name, e.first_name
`).all()
const employeesWithDetails = employees.map((emp: any) => {
const decodeValue = (val: string | null) => {
if (val === null || val === undefined) return undefined
return decodeHtmlEntities(val) ?? val
}
const decodedFirstName = decodeValue(emp.first_name) || emp.first_name
const decodedLastName = decodeValue(emp.last_name) || emp.last_name
const decodedPosition = decodeValue(emp.position) || emp.position
const decodedOfficialTitle = decodeValue(emp.official_title)
const decodedDepartmentRaw = decodeValue(emp.department)
const decodedEmail = decodeValue(emp.email) || emp.email
const decodedPhone = decodeValue(emp.phone) || emp.phone
const decodedMobile = decodeValue(emp.mobile)
const decodedOffice = decodeValue(emp.office)
const decodedPrimaryUnitCode = decodeValue(emp.primaryUnitCode) || undefined
const decodedPrimaryUnitName = decodeValue(emp.primaryUnitName) || undefined
const decodedPrimaryUnitDescription = decodeValue(emp.primaryUnitDescription) || undefined
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primaryUnitId,
primaryUnitCode: emp.primaryUnitCode,
primaryUnitName: emp.primaryUnitName,
primaryUnitDescription: emp.primaryUnitDescription,
})
const departmentLabel = departmentInfo.label || (decodedDepartmentRaw || '')
const departmentDescription = departmentInfo.description
const departmentTasks = departmentInfo.tasks || decodedPrimaryUnitDescription
// Get skills
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
@ -65,17 +202,22 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
firstName: decodedFirstName,
lastName: decodedLastName,
employeeNumber: emp.employee_number,
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
position: decodedPosition,
officialTitle: decodedOfficialTitle,
department: departmentLabel,
departmentDescription,
departmentTasks,
primaryUnitId: emp.primaryUnitId || undefined,
primaryUnitCode: decodedPrimaryUnitCode,
primaryUnitName: decodedPrimaryUnitName,
email: decodedEmail,
phone: decodedPhone,
mobile: decodedMobile,
office: decodedOffice,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
@ -99,7 +241,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
updatedBy: emp.updated_by,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
}
return employee
@ -117,12 +261,34 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
const { id } = req.params
const emp = db.prepare(`
SELECT id, first_name, last_name, employee_number, photo, position, official_title,
department, email, phone, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
created_at, updated_at, created_by, updated_by
FROM employees
WHERE id = ?
SELECT
e.id,
e.first_name,
e.last_name,
e.employee_number,
e.photo,
e.position,
e.official_title,
e.department,
e.email,
e.phone,
e.mobile,
e.office,
e.availability,
e.primary_unit_id as primaryUnitId,
ou.code as primaryUnitCode,
ou.name as primaryUnitName,
ou.description as primaryUnitDescription,
e.clearance_level,
e.clearance_valid_until,
e.clearance_issued_date,
e.created_at,
e.updated_at,
e.created_by,
e.updated_by
FROM employees e
LEFT JOIN organizational_units ou ON ou.id = e.primary_unit_id
WHERE e.id = ?
`).get(id) as any
if (!emp) {
@ -152,19 +318,52 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const decodeValue = (val: string | null) => {
if (val === null || val === undefined) return undefined
return decodeHtmlEntities(val) ?? val
}
const decodedFirstName = decodeValue(emp.first_name) || emp.first_name
const decodedLastName = decodeValue(emp.last_name) || emp.last_name
const decodedPosition = decodeValue(emp.position) || emp.position
const decodedOfficialTitle = decodeValue(emp.official_title)
const decodedDepartmentRaw = decodeValue(emp.department)
const decodedEmail = decodeValue(emp.email) || emp.email
const decodedPhone = decodeValue(emp.phone) || emp.phone
const decodedMobile = decodeValue(emp.mobile)
const decodedOffice = decodeValue(emp.office)
const decodedPrimaryUnitCode = decodeValue(emp.primaryUnitCode) || undefined
const decodedPrimaryUnitName = decodeValue(emp.primaryUnitName) || undefined
const decodedPrimaryUnitDescription = decodeValue(emp.primaryUnitDescription) || undefined
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primaryUnitId,
primaryUnitCode: emp.primaryUnitCode,
primaryUnitName: emp.primaryUnitName,
primaryUnitDescription: emp.primaryUnitDescription,
})
const departmentLabel = departmentInfo.label || (decodedDepartmentRaw || '')
const departmentDescription = departmentInfo.description
const departmentTasks = departmentInfo.tasks || decodedPrimaryUnitDescription
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
firstName: decodedFirstName,
lastName: decodedLastName,
employeeNumber: emp.employee_number,
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
position: decodedPosition,
officialTitle: decodedOfficialTitle,
department: departmentLabel,
departmentDescription,
departmentTasks,
primaryUnitId: emp.primaryUnitId || undefined,
primaryUnitCode: decodedPrimaryUnitCode,
primaryUnitName: decodedPrimaryUnitName,
email: decodedEmail,
phone: decodedPhone,
mobile: decodedMobile,
office: decodedOffice,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
@ -188,7 +387,9 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
updatedBy: emp.updated_by,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
}
res.json({ success: true, data: employee })
@ -204,11 +405,17 @@ router.post('/',
[
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('employeeNumber').optional({ checkFalsy: true }).trim(),
body('position').optional({ checkFalsy: true }).trim(),
body('officialTitle').optional().trim(),
body('email').isEmail(),
body('department').notEmpty().trim(),
body('organizationUnitId').optional({ checkFalsy: true }).isUUID(),
body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter'])
body('department').optional({ checkFalsy: true }).trim(),
body('phone').optional({ checkFalsy: true }).trim(),
body('mobile').optional({ checkFalsy: true }).trim(),
body('office').optional({ checkFalsy: true }).trim(),
body('organizationUnitId').notEmpty().isUUID(),
body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']),
body('powerFunction').optional({ checkFalsy: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@ -227,9 +434,16 @@ router.post('/',
firstName, lastName, employeeNumber, photo, position, officialTitle,
department, email, phone, mobile, office, availability,
clearance, skills, languages, specializations,
userRole, createUser, organizationUnitId, organizationRole
userRole, createUser, organizationUnitId: organizationUnitIdRaw, organizationRole, powerFunction
} = req.body
const organizationUnitId = typeof organizationUnitIdRaw === 'string' ? organizationUnitIdRaw : ''
const normalizedDepartment = typeof department === 'string' ? department.trim() : ''
if (!organizationUnitId) {
return res.status(400).json({ success: false, error: { message: 'Organisatorische Einheit ist erforderlich' } })
}
if (organizationRole && !organizationUnitId) {
return res.status(400).json({
success: false,
@ -237,20 +451,66 @@ router.post('/',
})
}
let resolvedDepartment = department
const requestedPowerFunction = typeof powerFunction === 'string' && powerFunction.length > 0 ? powerFunction : null
const powerFunctionDef = requestedPowerFunction ? POWER_FUNCTIONS.find(def => def.id === requestedPowerFunction) : undefined
if (userRole === 'superuser' && !requestedPowerFunction) {
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert die Auswahl einer Funktion' } })
}
if (userRole === 'superuser' && !organizationUnitId && req.user?.role !== 'superuser') {
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert eine Organisationseinheit' } })
}
let resolvedDepartment = normalizedDepartment
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
const unitRow = db.prepare('SELECT id, code, name, type FROM organizational_units WHERE id = ? AND is_active = 1').get(organizationUnitId) as { id: string; code: string | null; name: string; type: OrganizationalUnitType } | undefined
if (!unitRow) {
return res.status(404).json({ success: false, error: { message: 'Organization unit not found' } })
}
resolvedUnitId = unitRow.id
resolvedDepartment = unitRow.name
resolvedDepartment = unitRow.code || unitRow.name
if (organizationRole) {
resolvedUnitRole = organizationRole as EmployeeUnitRole
}
if (requestedPowerFunction && powerFunctionDef && !powerFunctionDef.unitTypes.includes(unitRow.type)) {
return res.status(400).json({
success: false,
error: { message: `Funktion ${powerFunctionDef.label} kann nicht einer Einheit vom Typ ${unitRow.type} zugeordnet werden` }
})
}
}
if (req.user?.role === 'superuser') {
const canManage = req.user.canManageEmployees
if (!canManage) {
return res.status(403).json({ success: false, error: { message: 'Keine Berechtigung zum Anlegen von Mitarbeitenden' } })
}
if (!req.user.powerUnitId) {
return res.status(403).json({ success: false, error: { message: 'Poweruser hat keine Organisationseinheit zugewiesen' } })
}
if (organizationUnitId && organizationUnitId !== req.user.powerUnitId) {
return res.status(403).json({ success: false, error: { message: 'Mitarbeitende dürfen nur im eigenen Bereich angelegt werden' } })
}
const unitRow = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ?').get(req.user.powerUnitId) as { id: string; code: string | null; name: string } | undefined
if (!unitRow) {
return res.status(403).json({ success: false, error: { message: 'Zugeordnete Organisationseinheit nicht gefunden' } })
}
resolvedUnitId = unitRow.id
resolvedDepartment = unitRow.code || unitRow.name
resolvedUnitRole = 'mitarbeiter'
}
if (!resolvedDepartment) {
resolvedDepartment = 'Noch nicht zugewiesen'
}
// Insert employee with default values for missing fields
@ -373,12 +633,35 @@ router.post('/',
// Encrypt email for user table storage
const encryptedEmail = FieldEncryption.encrypt(email)
const emailHash = FieldEncryption.hash(email)
let powerUnitForUser: string | null = null
let powerFunctionForUser: string | null = null
if (userRole === 'superuser') {
if (!resolvedUnitId || !requestedPowerFunction) {
throw new Error('Poweruser benötigt Organisationseinheit und Funktion')
}
powerUnitForUser = resolvedUnitId
powerFunctionForUser = requestedPowerFunction
}
db.prepare(`
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId, email, encryptedEmail, hashedPassword, userRole, employeeId, 1, now, now
userId,
email,
encryptedEmail,
emailHash,
hashedPassword,
userRole,
employeeId,
powerUnitForUser,
powerFunctionForUser,
1,
now,
now
)
console.log(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
@ -438,7 +721,7 @@ router.put('/:id',
body('department').notEmpty().trim(),
body('email').isEmail(),
body('phone').notEmpty().trim(),
body('availability').isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
body('availability').isIn(['available', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@ -459,7 +742,7 @@ router.put('/:id',
} = req.body
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
@ -482,6 +765,10 @@ router.put('/:id',
now, req.user!.id, id
)
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
// Update skills
if (skills !== undefined) {
// Delete existing skills
@ -530,6 +817,10 @@ router.put('/:id',
await syncService.queueSync('employees', 'update', updatedEmployee)
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
res.json({
success: true,
message: 'Employee updated successfully'
@ -549,7 +840,7 @@ router.delete('/:id',
const { id } = req.params
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,

Datei anzeigen

@ -5,13 +5,99 @@ import bcrypt from 'bcrypt'
import { db, encryptedDb } from '../config/secureDatabase'
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, EmployeeDeputySummary, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService'
import { logger } from '../utils/logger'
import { createDepartmentResolver } from '../utils/department'
const router = Router()
const resolveDepartmentInfo = createDepartmentResolver(db)
function toSqlDateTime(date: Date): string {
const pad = (value: number) => value.toString().padStart(2, '0')
const year = date.getUTCFullYear()
const month = pad(date.getUTCMonth() + 1)
const day = pad(date.getUTCDate())
const hours = pad(date.getUTCHours())
const minutes = pad(date.getUTCMinutes())
const seconds = pad(date.getUTCSeconds())
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function clearActiveDeputiesForPrincipal(principalId: string) {
const now = new Date()
const past = new Date(now.getTime() - 1000)
const pastSql = toSqlDateTime(past)
const nowSql = toSqlDateTime(now)
db.prepare(`
UPDATE deputy_assignments
SET valid_until = ?, updated_at = ?
WHERE principal_id = ?
AND valid_until >= datetime('now')
`).run(pastSql, nowSql, principalId)
db.prepare(`
DELETE FROM deputy_assignments
WHERE principal_id = ?
AND valid_from > datetime('now')
`).run(principalId)
}
function getActiveDeputies(principalId: string): EmployeeDeputySummary[] {
const rows = db.prepare(`
SELECT
da.id as assignmentId,
da.deputy_id as deputyId,
e.first_name as firstName,
e.last_name as lastName,
e.availability as availability,
e.position as position
FROM deputy_assignments da
JOIN employees e ON e.id = da.deputy_id
WHERE da.principal_id = ?
AND da.valid_from <= datetime('now')
AND da.valid_until >= datetime('now')
ORDER BY e.last_name, e.first_name
`).all(principalId) as any[]
return rows.map(row => ({
id: row.deputyId,
assignmentId: row.assignmentId,
firstName: row.firstName,
lastName: row.lastName,
availability: row.availability,
position: row.position || undefined
}))
}
function getActivePrincipals(deputyId: string): EmployeeDeputySummary[] {
const rows = db.prepare(`
SELECT
da.id as assignmentId,
da.principal_id as principalId,
e.first_name as firstName,
e.last_name as lastName,
e.availability as availability,
e.position as position
FROM deputy_assignments da
JOIN employees e ON e.id = da.principal_id
WHERE da.deputy_id = ?
AND da.valid_from <= datetime('now')
AND da.valid_until >= datetime('now')
ORDER BY e.last_name, e.first_name
`).all(deputyId) as any[]
return rows.map(row => ({
id: row.principalId,
assignmentId: row.assignmentId,
firstName: row.firstName,
lastName: row.lastName,
availability: row.availability,
position: row.position || undefined
}))
}
// Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
@ -114,6 +200,11 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primary_unit_id,
})
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
@ -122,7 +213,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
department: emp.department,
department: departmentInfo.label || emp.department,
departmentDescription: departmentInfo.description,
departmentTasks: departmentInfo.tasks,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
@ -150,7 +243,10 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
updatedBy: emp.updated_by,
primaryUnitId: emp.primary_unit_id || undefined,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
}
return employee
@ -197,6 +293,11 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primary_unit_id,
})
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
@ -205,7 +306,9 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
department: emp.department,
department: departmentInfo.label || emp.department,
departmentDescription: departmentInfo.description,
departmentTasks: departmentInfo.tasks,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
@ -233,7 +336,10 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
updatedBy: emp.updated_by,
primaryUnitId: emp.primary_unit_id || undefined,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
}
return employee
@ -334,13 +440,16 @@ router.post('/',
authenticate,
requirePermission('employees:create'),
[
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('department').notEmpty().trim().escape(),
body('position').optional().trim().escape(), // Optional
body('department').notEmpty().trim(),
body('position').optional().trim(), // Optional
body('phone').optional().trim(), // Optional - kann später ergänzt werden
body('employeeNumber').optional().trim() // Optional - wird automatisch generiert wenn leer
body('employeeNumber').optional().trim(), // Optional - wird automatisch generiert wenn leer
body('primaryUnitId').optional({ checkFalsy: true }).isUUID(),
body('assignmentRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']),
body('powerFunction').optional({ checkFalsy: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
@ -358,10 +467,21 @@ router.post('/',
const {
firstName, lastName, employeeNumber, photo, position = 'Teammitglied', officialTitle,
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
clearance, skills = [], languages = [], specializations = [], userRole, createUser,
primaryUnitId, assignmentRole
} = req.body
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
clearance, skills = [], languages = [], specializations = [], userRole, createUser,
primaryUnitId, assignmentRole, powerFunction
} = req.body
const requestedPowerFunction = typeof powerFunction === 'string' && powerFunction.length > 0 ? powerFunction : null
const powerFunctionDef = requestedPowerFunction ? POWER_FUNCTIONS.find(def => def.id === requestedPowerFunction) : undefined
if (userRole === 'superuser' && !requestedPowerFunction) {
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert die Auswahl einer Funktion' } })
}
if (userRole === 'superuser' && !primaryUnitId && req.user?.role !== 'superuser') {
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert eine Organisationseinheit' } })
}
// Generate employee number if not provided
const finalEmployeeNumber = employeeNumber || `EMP${Date.now()}`
@ -375,6 +495,48 @@ router.post('/',
})
}
let resolvedDepartment = department
let resolvedPrimaryUnitId: string | null = primaryUnitId || null
let resolvedAssignmentRole = assignmentRole || 'mitarbeiter'
if (primaryUnitId) {
const unit = db.prepare('SELECT id, type, code, name FROM organizational_units WHERE id = ? AND is_active = 1').get(primaryUnitId) as { id: string; type: OrganizationalUnitType; code: string | null; name: string } | undefined
if (!unit) {
return res.status(400).json({ success: false, error: { message: 'Invalid primary unit' } })
}
if (requestedPowerFunction && powerFunctionDef && !powerFunctionDef.unitTypes.includes(unit.type)) {
return res.status(400).json({ success: false, error: { message: `Funktion ${powerFunctionDef.label} kann nicht einer Einheit vom Typ ${unit.type} zugeordnet werden` } })
}
resolvedDepartment = unit.code || unit.name
resolvedPrimaryUnitId = unit.id
}
if (req.user?.role === 'superuser') {
const canManage = req.user.canManageEmployees
if (!canManage) {
return res.status(403).json({ success: false, error: { message: 'Keine Berechtigung zum Anlegen von Mitarbeitenden' } })
}
if (!req.user.powerUnitId) {
return res.status(403).json({ success: false, error: { message: 'Poweruser hat keine Organisationseinheit zugewiesen' } })
}
if (primaryUnitId && primaryUnitId !== req.user.powerUnitId) {
return res.status(403).json({ success: false, error: { message: 'Mitarbeitende dürfen nur im eigenen Bereich angelegt werden' } })
}
const unitRow = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ?').get(req.user.powerUnitId) as { id: string; code: string | null; name: string } | undefined
if (!unitRow) {
return res.status(403).json({ success: false, error: { message: 'Zugeordnete Organisationseinheit nicht gefunden' } })
}
resolvedPrimaryUnitId = unitRow.id
resolvedDepartment = unitRow.code || unitRow.name
resolvedAssignmentRole = 'mitarbeiter'
}
// Insert employee with encrypted fields
encryptedDb.insertEmployee({
id: employeeId,
@ -384,7 +546,7 @@ router.post('/',
photo: photo || null,
position,
official_title: officialTitle || null,
department,
department: resolvedDepartment,
email,
phone,
mobile: mobile || null,
@ -393,24 +555,20 @@ router.post('/',
clearance_level: clearance?.level || null,
clearance_valid_until: clearance?.validUntil || null,
clearance_issued_date: clearance?.issuedDate || null,
primary_unit_id: primaryUnitId || null,
primary_unit_id: resolvedPrimaryUnitId,
created_at: now,
updated_at: now,
created_by: req.user!.id
})
// Create primary assignment if provided
if (primaryUnitId) {
const unit = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(primaryUnitId)
if (!unit) {
return res.status(400).json({ success: false, error: { message: 'Invalid primary unit' } })
}
if (resolvedPrimaryUnitId) {
db.prepare(`
INSERT INTO employee_unit_assignments (
id, employee_id, unit_id, role, start_date, end_date, is_primary, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(), employeeId, primaryUnitId, assignmentRole || 'mitarbeiter',
uuidv4(), employeeId, resolvedPrimaryUnitId, resolvedAssignmentRole,
now, null, 1, now, now
)
}
@ -486,10 +644,20 @@ router.post('/',
const hashedPassword = bcrypt.hashSync(tempPassword, 12)
// Enforce role policy: only admins may assign roles; others default to 'user'
const assignedRole = req.user?.role === 'admin' && userRole ? userRole : 'user'
let powerUnitForUser: string | null = null
let powerFunctionForUser: string | null = null
if (assignedRole === 'superuser') {
if (!resolvedPrimaryUnitId || !requestedPowerFunction) {
throw new Error('Poweruser benötigt Organisationseinheit und Funktion')
}
powerUnitForUser = resolvedPrimaryUnitId
powerFunctionForUser = requestedPowerFunction
}
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
email,
@ -498,6 +666,8 @@ router.post('/',
hashedPassword,
assignedRole,
employeeId,
powerUnitForUser,
powerFunctionForUser,
1,
now,
now
@ -593,15 +763,15 @@ router.put('/:id',
authenticate,
requireEditPermission(req => req.params.id),
[
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('position').optional().trim().escape(),
body('officialTitle').optional().trim().escape(),
body('department').notEmpty().trim().escape(),
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('position').optional().trim(),
body('officialTitle').optional().trim(),
body('department').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('phone').optional().trim(),
body('employeeNumber').optional().trim(),
body('availability').optional().isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
body('availability').optional().isIn(['available', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
@ -618,13 +788,13 @@ router.put('/:id',
const now = new Date().toISOString()
const {
firstName, lastName, position = 'Teammitglied', officialTitle, department, email, phone = 'Nicht angegeben',
firstName, lastName, position = 'Teammitglied', officialTitle, department, email, phone = 'Nicht angegeben',
mobile, office, availability = 'available', clearance, skills, languages, specializations,
employeeNumber
} = req.body
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
@ -727,6 +897,10 @@ router.put('/:id',
logger.error('Failed to queue sync:', err)
})
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
return res.json({
success: true,
message: 'Employee updated successfully'
@ -762,7 +936,7 @@ router.delete('/:id',
const { id } = req.params
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,

Datei anzeigen

@ -0,0 +1,158 @@
import { Router, Response, NextFunction } from 'express'
import { body, validationResult } from 'express-validator'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../config/secureDatabase'
import { authenticate, AuthRequest } from '../middleware/auth'
import { requirePermission } from '../middleware/roleAuth'
import { logger } from '../utils/logger'
const router = Router()
const mapRow = (row: any) => ({
id: row.id,
label: row.label,
orderIndex: row.order_index ?? row.orderIndex ?? 0,
isActive: row.is_active === undefined ? row.isActive ?? true : Boolean(row.is_active),
createdAt: row.created_at ?? row.createdAt,
updatedAt: row.updated_at ?? row.updatedAt,
})
// Public list for regular users (active titles only)
router.get('/', authenticate, (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const titles = db.prepare(
'SELECT id, label, order_index, is_active FROM official_titles WHERE is_active = 1 ORDER BY order_index ASC, label COLLATE NOCASE ASC'
).all()
res.json({
success: true,
data: titles.map((row: any) => ({ id: row.id, label: row.label })),
})
} catch (error) {
next(error)
}
})
// Admin list with full metadata
router.get('/admin', authenticate, requirePermission('settings:read'), (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const titles = db.prepare(
'SELECT id, label, order_index, is_active, created_at, updated_at FROM official_titles ORDER BY order_index ASC, label COLLATE NOCASE ASC'
).all()
res.json({ success: true, data: titles.map(mapRow) })
} catch (error) {
next(error)
}
})
router.post('/',
authenticate,
requirePermission('settings:update'),
[body('label').trim().notEmpty()],
(req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
}
const { label } = req.body as { label: string }
const now = new Date().toISOString()
const maxOrder = db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM official_titles').get() as { maxOrder: number }
const nextOrder = (maxOrder?.maxOrder ?? -1) + 1
const id = uuidv4()
db.prepare(`
INSERT INTO official_titles (id, label, order_index, is_active, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
`).run(id, label.trim(), nextOrder, now, now)
logger.info(`Official title "${label}" created by user ${req.user?.username}`)
res.status(201).json({ success: true, data: { id, label: label.trim(), orderIndex: nextOrder, isActive: true } })
} catch (error) {
next(error)
}
}
)
router.put('/:id',
authenticate,
requirePermission('settings:update'),
[
body('label').optional().trim().notEmpty(),
body('isActive').optional().isBoolean(),
body('orderIndex').optional().isInt({ min: 0 }),
],
(req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
}
const { id } = req.params
const { label, isActive, orderIndex } = req.body as { label?: string; isActive?: boolean; orderIndex?: number }
const now = new Date().toISOString()
const existing = db.prepare('SELECT id FROM official_titles WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Official title not found' } })
}
const fields: string[] = []
const values: any[] = []
if (label !== undefined) {
fields.push('label = ?')
values.push(label.trim())
}
if (isActive !== undefined) {
fields.push('is_active = ?')
values.push(isActive ? 1 : 0)
}
if (orderIndex !== undefined) {
fields.push('order_index = ?')
values.push(orderIndex)
}
if (fields.length === 0) {
return res.json({ success: true, message: 'No changes applied' })
}
fields.push('updated_at = ?')
values.push(now)
values.push(id)
db.prepare(`
UPDATE official_titles SET ${fields.join(', ')} WHERE id = ?
`).run(...values)
logger.info(`Official title ${id} updated by user ${req.user?.username}`)
const updated = db.prepare('SELECT id, label, order_index, is_active, created_at, updated_at FROM official_titles WHERE id = ?').get(id)
res.json({ success: true, data: mapRow(updated) })
} catch (error) {
next(error)
}
}
)
router.delete('/:id', authenticate, requirePermission('settings:update'), (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const existing = db.prepare('SELECT id FROM official_titles WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Official title not found' } })
}
db.prepare('DELETE FROM official_titles WHERE id = ?').run(id)
logger.info(`Official title ${id} deleted by user ${req.user?.username}`)
res.json({ success: true })
} catch (error) {
next(error)
}
})
export default router

Datei anzeigen

@ -5,15 +5,72 @@ import { db } from '../config/secureDatabase'
import { authenticate, AuthRequest } from '../middleware/auth'
import {
OrganizationalUnit,
OrganizationalUnitType,
EmployeeUnitAssignment,
SpecialPosition,
DeputyAssignment,
DeputyDelegation
} from '@skillmate/shared'
import { logger } from '../utils/logger'
import { decodeHtmlEntities } from '../utils/html'
const router = Router()
function toSqlDateTime(date: Date): string {
const pad = (value: number) => value.toString().padStart(2, '0')
const year = date.getUTCFullYear()
const month = pad(date.getUTCMonth() + 1)
const day = pad(date.getUTCDate())
const hours = pad(date.getUTCHours())
const minutes = pad(date.getUTCMinutes())
const seconds = pad(date.getUTCSeconds())
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function normalizeAssignmentRange(validFrom: string, validUntil?: string | null) {
if (!validFrom) {
throw new Error('Startdatum ist erforderlich')
}
if (!validUntil) {
throw new Error('Enddatum ist erforderlich')
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(validFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(validUntil)) {
throw new Error('Datumsangaben müssen im Format JJJJ-MM-TT erfolgen')
}
const startDate = new Date(`${validFrom}T00:00:00Z`)
if (Number.isNaN(startDate.getTime())) {
throw new Error('Ungültiges Startdatum')
}
const endDate = new Date(`${validUntil}T23:59:59Z`)
if (Number.isNaN(endDate.getTime())) {
throw new Error('Ungültiges Enddatum')
}
if (endDate.getTime() < startDate.getTime()) {
throw new Error('Enddatum muss nach dem Startdatum liegen')
}
const startSql = toSqlDateTime(startDate)
const endSql = toSqlDateTime(endDate)
return { startSql, endSql }
}
const PARENT_RULES: Record<OrganizationalUnitType, OrganizationalUnitType[] | null> = {
direktion: null,
abteilung: ['direktion'],
dezernat: ['abteilung'],
sachgebiet: ['dezernat', 'teildezernat'],
teildezernat: ['dezernat'],
ermittlungskommission: ['dezernat'],
fuehrungsstelle: ['abteilung'],
stabsstelle: ['direktion'],
sondereinheit: ['direktion', 'abteilung']
}
// Get all organizational units
router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
try {
@ -32,7 +89,14 @@ router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
ORDER BY level, order_index, name
`).all()
res.json({ success: true, data: units })
const decodedUnits = units.map((unit: any) => ({
...unit,
code: decodeHtmlEntities(unit.code) ?? unit.code,
name: decodeHtmlEntities(unit.name) ?? unit.name,
description: decodeHtmlEntities(unit.description) ?? (decodeHtmlEntities(unit.name) ?? unit.name)
}))
res.json({ success: true, data: decodedUnits })
} catch (error) {
logger.error('Error fetching organizational units:', error)
next(error)
@ -171,6 +235,18 @@ router.get('/hierarchy', authenticate, async (req: AuthRequest, res, next) => {
// Apply sorting from the top
rootUnits.forEach(sortTree)
const decodeTree = (node: any) => {
if (!node) return
node.code = decodeHtmlEntities(node.code) ?? node.code
node.name = decodeHtmlEntities(node.name) ?? node.name
node.description = decodeHtmlEntities(node.description) ?? (decodeHtmlEntities(node.name) ?? node.name)
if (Array.isArray(node.children)) {
node.children.forEach(decodeTree)
}
}
rootUnits.forEach(decodeTree)
res.json({ success: true, data: rootUnits })
} catch (error) {
logger.error('Error building organizational hierarchy:', error)
@ -218,11 +294,28 @@ router.get('/units/:id', authenticate, async (req: AuthRequest, res, next) => {
e.last_name, e.first_name
`).all(req.params.id)
res.json({
success: true,
const decodedUnit = {
...unit,
code: decodeHtmlEntities(unit.code) ?? unit.code,
name: decodeHtmlEntities(unit.name) ?? unit.name,
description: decodeHtmlEntities(unit.description) ?? (decodeHtmlEntities(unit.name) ?? unit.name)
}
const decodedEmployees = employees.map((emp: any) => ({
...emp,
firstName: decodeHtmlEntities(emp.firstName) ?? emp.firstName,
lastName: decodeHtmlEntities(emp.lastName) ?? emp.lastName,
position: decodeHtmlEntities(emp.position) ?? emp.position,
department: decodeHtmlEntities(emp.department) ?? emp.department,
email: decodeHtmlEntities(emp.email) ?? emp.email,
phone: decodeHtmlEntities(emp.phone) ?? emp.phone
}))
res.json({
success: true,
data: {
...unit,
employees
...decodedUnit,
employees: decodedEmployees
}
})
} catch (error) {
@ -237,7 +330,7 @@ router.post('/units',
[
body('code').notEmpty().trim(),
body('name').notEmpty().trim(),
body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit']),
body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
body('level').isInt({ min: 0, max: 10 }),
body('parentId').optional({ checkFalsy: true }).isUUID()
],
@ -263,6 +356,22 @@ router.post('/units',
return res.status(400).json({ success: false, error: { message: 'Unit code already exists' } })
}
const unitType = type as OrganizationalUnitType
const requiredParents = PARENT_RULES[unitType] ?? null
let parentType: OrganizationalUnitType | null = null
if (parentId) {
const parentRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ? AND is_active = 1').get(parentId) as { id: string; type: OrganizationalUnitType } | undefined
if (!parentRow) {
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
}
parentType = parentRow.type
if (requiredParents && requiredParents.length > 0 && !requiredParents.includes(parentType)) {
return res.status(400).json({ success: false, error: { message: 'Ungültige übergeordnete Einheit für diesen Typ' } })
}
} else if (requiredParents && requiredParents.length > 0) {
return res.status(400).json({ success: false, error: { message: 'Dieser Einheitstyp benötigt eine übergeordnete Einheit' } })
}
// 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
@ -294,9 +403,13 @@ router.put('/units/:id',
authenticate,
[
param('id').isUUID(),
body('code').optional().isString().trim().notEmpty(),
body('name').optional().notEmpty().trim(),
body('type').optional().isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
body('description').optional(),
body('color').optional(),
body('hasFuehrungsstelle').optional().isBoolean().toBoolean(),
body('fuehrungsstelleName').optional().isString().trim(),
body('parentId').optional({ checkFalsy: true }).isUUID(),
body('level').optional().isInt({ min: 0, max: 10 }),
// allow updating persisted canvas positions from admin editor
@ -305,67 +418,126 @@ router.put('/units/:id',
],
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?.role !== 'admin') {
return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
}
const { name, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
const { code, name, type, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
const now = new Date().toISOString()
// Optional: validate parentId and avoid cycles
let newParentId: string | null | undefined = undefined
const existingUnit = db.prepare('SELECT id, type, parent_id as parentId FROM organizational_units WHERE id = ?').get(req.params.id) as { id: string; type: OrganizationalUnitType; parentId: string | null } | undefined
if (!existingUnit) {
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
}
// Resolve target type
const targetType: OrganizationalUnitType = (type as OrganizationalUnitType) || existingUnit.type
// Determine final parent
let finalParentId: string | null = existingUnit.parentId || null
let finalParentType: OrganizationalUnitType | null = null
if (parentId !== undefined) {
if (parentId === null || parentId === '' ) {
newParentId = null
if (parentId === null || parentId === '') {
finalParentId = null
} else {
const parent = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(parentId)
if (!parent) {
const parentRow = db.prepare('SELECT id, parent_id as parentId, type FROM organizational_units WHERE id = ? AND is_active = 1').get(parentId) as { id: string; parentId: string | null; type: OrganizationalUnitType } | undefined
if (!parentRow) {
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
}
// cycle check: walk up from parent to root; id must not appear
// cycle check
const targetId = req.params.id
let cursor: any = parent
let cursor: any = parentRow
while (cursor && cursor.parentId) {
if (cursor.parentId === targetId) {
return res.status(400).json({ success: false, error: { message: 'Cyclic parent assignment is not allowed' } })
}
cursor = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(cursor.parentId)
}
newParentId = parentId
finalParentId = parentRow.id
finalParentType = parentRow.type
}
}
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),
parent_id = COALESCE(?, parent_id),
level = COALESCE(?, level),
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,
newParentId !== undefined ? newParentId : null,
level !== undefined ? Number(level) : 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' } })
if (finalParentId && !finalParentType) {
const parentRow = db.prepare('SELECT type FROM organizational_units WHERE id = ?').get(finalParentId) as { type: OrganizationalUnitType } | undefined
finalParentType = parentRow?.type ?? null
}
const allowedParents = PARENT_RULES[targetType] ?? null
if (allowedParents && allowedParents.length > 0) {
if (!finalParentId) {
return res.status(400).json({ success: false, error: { message: 'Dieser Einheitstyp benötigt eine übergeordnete Einheit' } })
}
if (!finalParentType || !allowedParents.includes(finalParentType)) {
return res.status(400).json({ success: false, error: { message: 'Ungültige übergeordnete Einheit für diesen Typ' } })
}
}
const updates: string[] = []
const params: any[] = []
if (code !== undefined) {
updates.push('code = ?')
params.push(code)
}
if (name !== undefined) {
updates.push('name = ?')
params.push(name)
}
if (type !== undefined) {
updates.push('type = ?')
params.push(type)
}
if (description !== undefined) {
updates.push('description = ?')
params.push(description)
}
if (color !== undefined) {
updates.push('color = ?')
params.push(color)
}
if (hasFuehrungsstelle !== undefined) {
updates.push('has_fuehrungsstelle = ?')
params.push(hasFuehrungsstelle ? 1 : 0)
}
if (fuehrungsstelleName !== undefined) {
updates.push('fuehrungsstelle_name = ?')
params.push(fuehrungsstelleName)
}
if (parentId !== undefined) {
updates.push('parent_id = ?')
params.push(finalParentId)
}
if (level !== undefined) {
updates.push('level = ?')
params.push(Number(level))
}
if (positionX !== undefined) {
updates.push('position_x = ?')
params.push(Math.round(Number(positionX)))
}
if (positionY !== undefined) {
updates.push('position_y = ?')
params.push(Math.round(Number(positionY)))
}
updates.push('updated_at = ?')
params.push(now)
if (updates.length === 0) {
return res.json({ success: true, message: 'No changes applied' })
}
params.push(req.params.id)
const stmt = db.prepare(`UPDATE organizational_units SET ${updates.join(', ')} WHERE id = ?`)
stmt.run(...params)
res.json({ success: true })
} catch (error) {
logger.error('Error updating organizational unit:', error)
@ -405,7 +577,7 @@ router.post('/assignments',
}
// Validate unit exists and is active
const unit = db.prepare('SELECT id FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId)
const unit = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId) as { id: string; code?: string | null; name?: string | null } | undefined
if (!unit) {
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
}
@ -427,6 +599,23 @@ router.post('/assignments',
SET is_primary = 0, updated_at = ?
WHERE employee_id = ? AND end_date IS NULL
`).run(now, employeeId)
// Keep employees.primary_unit_id in sync for listings
db.prepare(`
UPDATE employees
SET primary_unit_id = ?, updated_at = ?
WHERE id = ?
`).run(unitId, now, employeeId)
// Update department text (backward compatibility for older UIs/exports)
const deptText = (unit.code && String(unit.code).trim().length > 0) ? unit.code : (unit.name || null)
if (deptText) {
db.prepare(`
UPDATE employees
SET department = ?, updated_at = ?
WHERE id = ?
`).run(deptText, now, employeeId)
}
}
db.prepare(`
@ -544,15 +733,39 @@ router.post('/deputies',
const now = new Date().toISOString()
const assignmentId = uuidv4()
// Check for conflicts
let range
try {
range = normalizeAssignmentRange(validFrom, validUntil)
} catch (error: any) {
return res.status(400).json({ success: false, error: { message: error?.message || 'Invalid date range' } })
}
const { startSql, endSql } = range
const conflict = db.prepare(`
SELECT id FROM deputy_assignments
SELECT id, unit_id as unitId, reason as existingReason, can_delegate as existingCanDelegate
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)
AND valid_until >= ?
AND valid_from <= ?
`).get(req.user.employeeId, deputyId, startSql, endSql) as any
if (conflict) {
return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
db.prepare(`
UPDATE deputy_assignments
SET unit_id = ?, valid_from = ?, valid_until = ?, reason = ?, can_delegate = ?, updated_at = ?
WHERE id = ?
`).run(
unitId || conflict.unitId || null,
startSql,
endSql,
reason !== undefined ? (reason || null) : (conflict.existingReason || null),
canDelegate !== undefined ? (canDelegate ? 1 : 0) : (conflict.existingCanDelegate ? 1 : 0),
now,
conflict.id
)
return res.json({ success: true, data: { id: conflict.id, updated: true } })
}
db.prepare(`
@ -563,7 +776,7 @@ router.post('/deputies',
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
assignmentId, req.user.employeeId, deputyId, unitId || null,
validFrom, validUntil, reason || null, canDelegate ? 1 : 0,
startSql, endSql, reason || null, canDelegate ? 1 : 0,
req.user.id, now, now
)
@ -581,7 +794,7 @@ router.post('/deputies/my',
[
body('deputyId').isUUID(),
body('validFrom').isISO8601(),
body('validUntil').optional({ nullable: true }).isISO8601(),
body('validUntil').isISO8601(),
body('unitId').optional({ nullable: true }).isUUID()
],
async (req: AuthRequest, res: any, next: any) => {
@ -599,15 +812,39 @@ router.post('/deputies/my',
const now = new Date().toISOString()
const assignmentId = uuidv4()
// Check for conflicts
let range
try {
range = normalizeAssignmentRange(validFrom, validUntil)
} catch (error: any) {
return res.status(400).json({ success: false, error: { message: error?.message || 'Invalid date range' } })
}
const { startSql, endSql } = range
const conflict = db.prepare(`
SELECT id FROM deputy_assignments
SELECT id, unit_id as unitId, reason as existingReason, can_delegate as existingCanDelegate
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)
AND valid_until >= ?
AND valid_from <= ?
`).get(req.user.employeeId, deputyId, startSql, endSql) as any
if (conflict) {
return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
db.prepare(`
UPDATE deputy_assignments
SET unit_id = ?, valid_from = ?, valid_until = ?, reason = ?, can_delegate = ?, updated_at = ?
WHERE id = ?
`).run(
unitId || conflict.unitId || null,
startSql,
endSql,
reason !== undefined ? (reason || null) : (conflict.existingReason || null),
canDelegate !== undefined ? (canDelegate ? 1 : 0) : (conflict.existingCanDelegate ? 1 : 0),
now,
conflict.id
)
return res.json({ success: true, data: { id: conflict.id, updated: true } })
}
db.prepare(`
@ -618,7 +855,7 @@ router.post('/deputies/my',
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
assignmentId, req.user.employeeId, deputyId, unitId || null,
validFrom, validUntil || validFrom, reason || null, canDelegate ? 1 : 0,
startSql, endSql, reason || null, canDelegate ? 1 : 0,
req.user.id, now, now
)

Datei anzeigen

@ -0,0 +1,242 @@
import { Router, Response, NextFunction } from 'express'
import { body, validationResult } from 'express-validator'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../config/secureDatabase'
import { authenticate, AuthRequest } from '../middleware/auth'
import { requirePermission } from '../middleware/roleAuth'
import { logger } from '../utils/logger'
const router = Router()
interface PositionRow {
id: string
label: string
organization_unit_id: string | null
order_index?: number
is_active?: number | boolean
created_at?: string
updated_at?: string
}
const mapRow = (row: PositionRow) => ({
id: row.id,
label: row.label,
organizationUnitId: row.organization_unit_id || null,
orderIndex: row.order_index ?? 0,
isActive: row.is_active === undefined ? true : Boolean(row.is_active),
createdAt: row.created_at,
updatedAt: row.updated_at
})
const sanitizeUnitId = (value: unknown): string | null => {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed || trimmed.toLowerCase() === 'null' || trimmed.toLowerCase() === 'global') {
return null
}
return trimmed
}
router.get('/', authenticate, (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const unitId = sanitizeUnitId(req.query.unitId)
const fetchGlobal = (): PositionRow[] => db.prepare(
`SELECT id, label, organization_unit_id, order_index, is_active
FROM position_catalog
WHERE is_active = 1 AND organization_unit_id IS NULL
ORDER BY order_index ASC, label COLLATE NOCASE ASC`
).all() as PositionRow[]
const fetchScoped = (orgId: string): PositionRow[] => db.prepare(
`SELECT id, label, organization_unit_id, order_index, is_active
FROM position_catalog
WHERE is_active = 1 AND organization_unit_id = ?
ORDER BY order_index ASC, label COLLATE NOCASE ASC`
).all(orgId) as PositionRow[]
let rows: PositionRow[]
if (unitId) {
const scoped = fetchScoped(unitId)
const global = fetchGlobal()
const seen = new Set<string>()
rows = [...scoped, ...global].filter((row) => {
const key = String(row.label || '').trim().toLowerCase()
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
} else {
rows = fetchGlobal()
}
res.json({
success: true,
data: rows.map((row) => ({
id: row.id,
label: row.label,
organizationUnitId: row.organization_unit_id || null
}))
})
} catch (error) {
next(error)
}
})
router.get('/admin', authenticate, requirePermission('settings:read'), (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const unitId = sanitizeUnitId(req.query.unitId)
const statement = unitId
? db.prepare(`SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE organization_unit_id = ? ORDER BY order_index ASC, label COLLATE NOCASE ASC`)
: db.prepare(`SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE organization_unit_id IS NULL ORDER BY order_index ASC, label COLLATE NOCASE ASC`)
const rows = (unitId ? statement.all(unitId) : statement.all()) as PositionRow[]
res.json({ success: true, data: rows.map(mapRow) })
} catch (error) {
next(error)
}
})
router.post('/',
authenticate,
requirePermission('settings:update'),
[
body('label').trim().notEmpty(),
body('organizationUnitId').optional({ checkFalsy: true }).isString().trim()
],
(req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
}
const { label, organizationUnitId } = req.body as { label: string; organizationUnitId?: string | null }
const unitId = sanitizeUnitId(organizationUnitId)
const now = new Date().toISOString()
const id = uuidv4()
const maxOrder = unitId
? (db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM position_catalog WHERE organization_unit_id = ?').get(unitId) as { maxOrder: number })
: (db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM position_catalog WHERE organization_unit_id IS NULL').get() as { maxOrder: number })
const nextOrder = (maxOrder?.maxOrder ?? -1) + 1
const insert = db.prepare(`
INSERT INTO position_catalog (id, label, organization_unit_id, order_index, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?)
`)
try {
insert.run(id, label.trim(), unitId, nextOrder, now, now)
} catch (error: any) {
if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ success: false, error: { message: 'Position existiert bereits für diese Organisationseinheit.' } })
}
throw error
}
logger.info(`Position "${label}" created by ${req.user?.username}${unitId ? ` for unit ${unitId}` : ''}`)
res.status(201).json({ success: true, data: { id, label: label.trim(), organizationUnitId: unitId, orderIndex: nextOrder, isActive: true } })
} catch (error) {
next(error)
}
}
)
router.put('/:id',
authenticate,
requirePermission('settings:update'),
[
body('label').optional().trim().notEmpty(),
body('isActive').optional().isBoolean(),
body('orderIndex').optional().isInt({ min: 0 }),
body('organizationUnitId').optional({ checkFalsy: true }).isString().trim()
],
(req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
}
const { id } = req.params
const { label, isActive, orderIndex, organizationUnitId } = req.body as { label?: string; isActive?: boolean; orderIndex?: number; organizationUnitId?: string | null }
const unitId = organizationUnitId !== undefined ? sanitizeUnitId(organizationUnitId) : undefined
const now = new Date().toISOString()
const existing = db.prepare('SELECT id FROM position_catalog WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Positionseintrag nicht gefunden.' } })
}
const fields: string[] = []
const values: any[] = []
if (label !== undefined) {
fields.push('label = ?')
values.push(label.trim())
}
if (isActive !== undefined) {
fields.push('is_active = ?')
values.push(isActive ? 1 : 0)
}
if (orderIndex !== undefined) {
fields.push('order_index = ?')
values.push(orderIndex)
}
if (unitId !== undefined) {
fields.push('organization_unit_id = ?')
values.push(unitId)
}
if (fields.length === 0) {
return res.json({ success: true, message: 'Keine Änderungen erforderlich.' })
}
fields.push('updated_at = ?')
values.push(now)
values.push(id)
const update = db.prepare(`
UPDATE position_catalog SET ${fields.join(', ')} WHERE id = ?
`)
try {
update.run(...values)
} catch (error: any) {
if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ success: false, error: { message: 'Position existiert bereits für diese Organisationseinheit.' } })
}
throw error
}
logger.info(`Position ${id} updated by ${req.user?.username}`)
const refreshed = db.prepare('SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE id = ?').get(id) as PositionRow | undefined
res.json({ success: true, data: refreshed ? mapRow(refreshed) : null })
} catch (error) {
next(error)
}
}
)
router.delete('/:id', authenticate, requirePermission('settings:update'), (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const existing = db.prepare('SELECT id FROM position_catalog WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Positionseintrag nicht gefunden.' } })
}
db.prepare('DELETE FROM position_catalog WHERE id = ?').run(id)
logger.info(`Position ${id} deleted by ${req.user?.username}`)
res.json({ success: true })
} catch (error) {
next(error)
}
})
export default router

Datei anzeigen

@ -4,7 +4,7 @@ import bcrypt from 'bcrypt'
import { v4 as uuidv4 } from 'uuid'
import { db, encryptedDb } from '../config/secureDatabase'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { User, UserRole } from '@skillmate/shared'
import { User, UserRole, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService'
import { logger } from '../utils/logger'
@ -15,9 +15,23 @@ const router = Router()
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
FROM users
ORDER BY username
SELECT
u.id,
u.username,
u.email,
u.role,
u.employee_id,
u.last_login,
u.is_active,
u.created_at,
u.updated_at,
u.power_unit_id,
u.power_function,
ou.name AS power_unit_name,
ou.type AS power_unit_type
FROM users u
LEFT JOIN organizational_units ou ON ou.id = u.power_unit_id
ORDER BY u.username
`).all() as any[]
// Decrypt email addresses (handle decryption failures)
@ -35,6 +49,9 @@ router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthR
}
}
const powerDef = POWER_FUNCTIONS.find(def => def.id === user.power_function)
const canManageEmployees = user.role === 'admin' || (user.role === 'superuser' && powerDef?.canManageEmployees)
return {
...user,
email: decryptedEmail,
@ -42,7 +59,12 @@ router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthR
lastLogin: user.last_login ? new Date(user.last_login) : null,
createdAt: new Date(user.created_at),
updatedAt: new Date(user.updated_at),
employeeId: user.employee_id
employeeId: user.employee_id,
powerUnitId: user.power_unit_id || null,
powerUnitName: user.power_unit_name || null,
powerUnitType: user.power_unit_type || null,
powerFunction: user.power_function || null,
canManageEmployees: Boolean(canManageEmployees)
}
})
@ -58,7 +80,9 @@ router.put('/:id/role',
authenticate,
authorize('admin'),
[
body('role').isIn(['admin', 'superuser', 'user'])
body('role').isIn(['admin', 'superuser', 'user']),
body('powerUnitId').optional({ nullable: true }).isUUID(),
body('powerFunction').optional({ nullable: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@ -71,7 +95,7 @@ router.put('/:id/role',
}
const { id } = req.params
const { role } = req.body
const { role, powerUnitId, powerFunction } = req.body as { role: UserRole; powerUnitId?: string | null; powerFunction?: string | null }
// Check if user exists
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
@ -90,11 +114,40 @@ router.put('/:id/role',
})
}
// Update role
let finalPowerUnit: string | null = null
let finalPowerFunction: string | null = null
if (role === 'superuser') {
if (!powerUnitId || !powerFunction) {
return res.status(400).json({
success: false,
error: { message: 'Poweruser requires organizational unit and Funktion' }
})
}
const functionDef = POWER_FUNCTIONS.find(def => def.id === powerFunction)
if (!functionDef) {
return res.status(400).json({ success: false, error: { message: 'Ungültige Poweruser-Funktion' } })
}
const unitRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(powerUnitId) as { id: string; type: OrganizationalUnitType } | undefined
if (!unitRow) {
return res.status(404).json({ success: false, error: { message: 'Organisationseinheit nicht gefunden' } })
}
if (!functionDef.unitTypes.includes(unitRow.type)) {
return res.status(400).json({ success: false, error: { message: `Funktion ${functionDef.label} kann nicht der Einheit vom Typ ${unitRow.type} zugeordnet werden` } })
}
finalPowerUnit = powerUnitId
finalPowerFunction = powerFunction
}
const now = new Date().toISOString()
db.prepare(`
UPDATE users SET role = ?, updated_at = ?
UPDATE users SET role = ?, power_unit_id = ?, power_function = ?, updated_at = ?
WHERE id = ?
`).run(role, new Date().toISOString(), id)
`).run(role, finalPowerUnit, finalPowerFunction, now, id)
logger.info(`User role updated: ${existingUser.username} -> ${role}`)
res.json({ success: true, message: 'Role updated successfully' })
@ -124,6 +177,13 @@ router.post('/bulk-create-from-employees',
}
const { employeeIds, role } = req.body as { employeeIds: string[]; role: UserRole }
if (role === 'superuser') {
return res.status(400).json({
success: false,
error: { message: 'Bulk-Erstellung für Poweruser wird nicht unterstützt. Bitte einzeln mit Organisationszuordnung anlegen.' }
})
}
const results: any[] = []
for (const employeeId of employeeIds) {
@ -155,8 +215,8 @@ router.post('/bulk-create-from-employees',
const userId = uuidv4()
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
finalUsername,
@ -165,6 +225,8 @@ router.post('/bulk-create-from-employees',
hashedPassword,
role,
employeeId,
null,
null,
1,
now,
now
@ -336,7 +398,9 @@ router.post('/create-from-employee',
[
body('employeeId').notEmpty().isString(),
body('username').optional().isString().isLength({ min: 3 }),
body('role').isIn(['admin', 'superuser', 'user'])
body('role').isIn(['admin', 'superuser', 'user']),
body('powerUnitId').optional({ nullable: true }).isUUID(),
body('powerFunction').optional({ nullable: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@ -348,7 +412,7 @@ router.post('/create-from-employee',
})
}
const { employeeId, username, role } = req.body
const { employeeId, username, role, powerUnitId, powerFunction } = req.body as { employeeId: string; username?: string; role: UserRole; powerUnitId?: string | null; powerFunction?: string | null }
// Check employee exists
const employee = encryptedDb.getEmployee(employeeId) as any
@ -381,6 +445,29 @@ router.post('/create-from-employee',
}
}
let resolvedPowerUnit: string | null = null
let resolvedPowerFunction: string | null = null
if (role === 'superuser') {
const powerFunctionId = typeof powerFunction === 'string' ? powerFunction : null
const functionDef = powerFunctionId ? POWER_FUNCTIONS.find(def => def.id === powerFunctionId) : undefined
if (!powerUnitId || !functionDef || !powerFunctionId) {
return res.status(400).json({ success: false, error: { message: 'Poweruser requires Organisationseinheit und Funktion' } })
}
const unitRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(powerUnitId) as { id: string; type: OrganizationalUnitType } | undefined
if (!unitRow) {
return res.status(404).json({ success: false, error: { message: 'Organisationseinheit nicht gefunden' } })
}
if (!functionDef.unitTypes.includes(unitRow.type)) {
return res.status(400).json({ success: false, error: { message: `Funktion ${functionDef.label} kann nicht der Einheit vom Typ ${unitRow.type} zugeordnet werden` } })
}
resolvedPowerUnit = powerUnitId
resolvedPowerFunction = powerFunctionId
}
// Generate temp password
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = await bcrypt.hash(tempPassword, 12)
@ -389,8 +476,8 @@ router.post('/create-from-employee',
// Insert user with encrypted email
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
finalUsername,
@ -399,6 +486,8 @@ router.post('/create-from-employee',
hashedPassword,
role,
employeeId,
resolvedPowerUnit,
resolvedPowerFunction,
1,
now,
now

Datei anzeigen

@ -32,6 +32,7 @@ export class SyncScheduler {
}
private initialize() {
this.ensureSyncSettingsTable()
// Check current sync settings on startup
this.checkAndUpdateInterval()
@ -41,6 +42,44 @@ export class SyncScheduler {
}, 60000)
}
private ensureSyncSettingsTable() {
const now = new Date().toISOString()
db.exec(`
CREATE TABLE IF NOT EXISTS sync_settings (
id TEXT PRIMARY KEY,
auto_sync_interval TEXT,
conflict_resolution TEXT CHECK(conflict_resolution IN ('admin', 'newest', 'manual')),
sync_employees INTEGER DEFAULT 1,
sync_skills INTEGER DEFAULT 1,
sync_users INTEGER DEFAULT 1,
sync_settings INTEGER DEFAULT 0,
bandwidth_limit INTEGER,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL
)
`)
db.prepare(`
INSERT OR IGNORE INTO sync_settings (
id, auto_sync_interval, conflict_resolution,
sync_employees, sync_skills, sync_users, sync_settings,
bandwidth_limit, updated_at, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'default',
'disabled',
'admin',
1,
1,
1,
0,
null,
now,
'system'
)
}
private checkAndUpdateInterval() {
try {
const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any
@ -126,4 +165,4 @@ export class SyncScheduler {
}
}
export const syncScheduler = SyncScheduler.getInstance()
export const syncScheduler = SyncScheduler.getInstance()

Datei anzeigen

@ -0,0 +1,143 @@
import type * as BetterSqlite3 from 'better-sqlite3'
import { decodeHtmlEntities } from './html'
interface DepartmentSource {
department?: string | null
primaryUnitId?: string | null
primaryUnitCode?: string | null
primaryUnitName?: string | null
primaryUnitDescription?: string | null
}
export interface DepartmentInfo {
label: string
description?: string
tasks?: string
}
const decodeValue = (value?: string | null): string | undefined => {
if (value === null || value === undefined) return undefined
const decoded = decodeHtmlEntities(value)
const cleaned = (decoded ?? value ?? '').trim()
return cleaned.length > 0 ? cleaned : undefined
}
type SqliteDatabase = BetterSqlite3.Database
export const createDepartmentResolver = (db: SqliteDatabase) => {
interface UnitRow {
id: string
code?: string | null
name?: string | null
description?: string | null
parent_id?: string | null
}
const selectById = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE id = ?')
const selectByCode = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE code = ? COLLATE NOCASE')
const selectByName = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE name = ? COLLATE NOCASE')
const resolveUnitById = (id?: string | null): UnitRow | undefined => {
if (!id) return undefined
try {
return selectById.get(id) as UnitRow | undefined
} catch {
return undefined
}
}
const buildPath = (unit: UnitRow): string[] => {
const segments: string[] = []
let current: UnitRow | undefined = unit
let guard = 0
while (current && guard < 20) {
const segment = decodeValue(current.code) || decodeValue(current.name)
if (segment) {
segments.unshift(segment)
}
current = current.parent_id ? resolveUnitById(current.parent_id) : undefined
guard += 1
}
return segments
}
const resolve = (source: DepartmentSource): DepartmentInfo => {
const originalDepartment = decodeValue(source.department)
let label = decodeValue(source.primaryUnitCode) || originalDepartment || ''
let description = decodeValue(source.primaryUnitName)
let tasks = decodeValue(source.primaryUnitDescription)
let unitRow: UnitRow | undefined = resolveUnitById(source.primaryUnitId)
const codeCandidates = [
decodeValue(source.primaryUnitCode),
decodeValue(source.department),
].filter(Boolean) as string[]
if (!unitRow) {
for (const candidate of codeCandidates) {
try {
unitRow = selectByCode.get(candidate) as UnitRow | undefined
} catch {
unitRow = undefined
}
if (unitRow) break
}
}
if (!unitRow) {
const nameCandidates = [
decodeValue(source.primaryUnitName),
originalDepartment,
].filter(Boolean) as string[]
for (const candidate of nameCandidates) {
try {
unitRow = selectByName.get(candidate) as UnitRow | undefined
} catch {
unitRow = undefined
}
if (unitRow) break
}
}
if (unitRow) {
const unitCode = decodeValue(unitRow.code)
const unitName = decodeValue(unitRow.name)
const unitDescription = decodeValue(unitRow.description)
const pathSegments = buildPath(unitRow)
if (pathSegments.length > 0) {
label = pathSegments.join(' -> ')
} else if (!label && unitCode) {
label = unitCode
}
if (unitName && unitName !== label) {
description = unitName
}
if (unitDescription) {
tasks = unitDescription
} else if (!tasks && unitName && unitName !== label) {
tasks = unitName
}
}
if (!label) {
label = originalDepartment || ''
}
if (description && description === label) {
description = undefined
}
return {
label,
description,
tasks,
}
}
return resolve
}

53
backend/src/utils/html.ts Normale Datei
Datei anzeigen

@ -0,0 +1,53 @@
const namedEntities: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: '\u00A0',
slash: '/',
sol: '/',
frasl: '/',
}
const decodeSinglePass = (input: string): string => {
return input.replace(/&(#x?[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === '#') {
const isHex = entity[1]?.toLowerCase() === 'x'
const codePoint = isHex
? parseInt(entity.slice(2), 16)
: parseInt(entity.slice(1), 10)
if (!Number.isNaN(codePoint)) {
try {
return String.fromCodePoint(codePoint)
} catch {
return match
}
}
return match
}
const lowered = entity.toLowerCase()
if (namedEntities[lowered]) {
return namedEntities[lowered]
}
return match
})
}
export const decodeHtmlEntities = (value?: string | null): string | undefined => {
if (value === undefined || value === null) {
return undefined
}
let result = value
for (let i = 0; i < 3; i += 1) {
const decoded = decodeSinglePass(result)
if (decoded === result) {
break
}
result = decoded
}
return result
}

Datei anzeigen

@ -1,20 +1,20 @@
import { body } from 'express-validator'
export const createEmployeeValidators = [
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('department').notEmpty().trim().escape(),
body('position').optional().trim().escape(),
body('department').notEmpty().trim(),
body('position').optional().trim(),
body('phone').optional().trim(),
body('employeeNumber').optional().trim(),
]
export const updateEmployeeValidators = [
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('position').optional().trim().escape(),
body('department').notEmpty().trim().escape(),
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('position').optional().trim(),
body('department').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('phone').optional().trim(),
body('employeeNumber').optional().trim(),

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@ -10,11 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^9.88.0",
"@react-three/drei": "^9.112.5",
"@react-three/fiber": "^8.15.0",
"@skillmate/shared": "file:../shared",
"@types/three": "^0.180.0",
"axios": "^1.6.2",
"axios": "^1.7.9",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -31,5 +31,9 @@
"tailwindcss": "^3.3.6",
"typescript": "^5.3.0",
"vite": "^5.0.7"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.52.3",
"@rollup/rollup-win32-x64-msvc": "^4.52.3"
}
}

Datei anzeigen

@ -1,18 +1,28 @@
import { useState, useEffect } from 'react'
import api from '../services/api'
import type { DeputyAssignment, Employee } from '@skillmate/shared'
import type { Employee } from '@skillmate/shared'
import { useAuthStore } from '../stores/authStore'
interface UnitOption {
id: string
name: string
code?: string | null
isPrimary?: boolean
}
export default function DeputyManagement() {
const [asPrincipal, setAsPrincipal] = useState<any[]>([])
const [asDeputy, setAsDeputy] = useState<any[]>([])
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
const [unitOptions, setUnitOptions] = useState<UnitOption[]>([])
const [showAddDialog, setShowAddDialog] = useState(false)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('')
const [formData, setFormData] = useState({
deputyId: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
validUntil: new Date().toISOString().split('T')[0],
reason: '',
canDelegate: true,
unitId: ''
@ -22,6 +32,7 @@ export default function DeputyManagement() {
useEffect(() => {
loadDeputies()
loadEmployees()
loadUnits()
}, [])
const loadDeputies = async () => {
@ -43,28 +54,66 @@ export default function DeputyManagement() {
try {
const response = await api.get('/employees/public')
if (response.data.success) {
setAvailableEmployees(response.data.data)
const sorted = (response.data.data as Employee[])
.filter(emp => emp.id !== user?.employeeId)
.sort((a, b) => `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`))
setAvailableEmployees(sorted)
}
} catch (error) {
console.error('Failed to load employees:', error)
}
}
const handleAddDeputy = async () => {
const loadUnits = 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
})
const response = await api.get('/organization/my-units')
if (response.data.success) {
setUnitOptions(response.data.data || [])
}
} catch (error) {
console.warn('Failed to load organizational units for deputy dialog:', error)
}
}
const handleAddDeputy = async () => {
if (submitting) return
setFormError('')
if (!formData.deputyId) {
setFormError('Bitte wählen Sie eine Vertretung aus.')
return
}
try {
setSubmitting(true)
const payload: any = {
deputyId: formData.deputyId,
validFrom: formData.validFrom,
validUntil: formData.validUntil,
reason: formData.reason || null,
canDelegate: formData.canDelegate
}
if (formData.unitId) {
payload.unitId = formData.unitId
}
const response = await api.post('/organization/deputies/my', payload)
if (response.data.success) {
await loadDeputies()
setShowAddDialog(false)
resetForm()
} else {
setFormError(response.data?.error?.message || 'Vertretung konnte nicht gespeichert werden.')
}
} catch (error) {
} catch (error: any) {
console.error('Failed to add deputy:', error)
const message = error?.response?.data?.error?.message || 'Vertretung konnte nicht gespeichert werden.'
setFormError(message)
} finally {
setSubmitting(false)
}
}
@ -104,14 +153,48 @@ export default function DeputyManagement() {
}
const resetForm = () => {
const today = new Date().toISOString().split('T')[0]
setFormData({
deputyId: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
validFrom: today,
validUntil: today,
reason: '',
canDelegate: true,
unitId: ''
})
setFormError('')
setSubmitting(false)
}
const openAddDialog = () => {
setFormError('')
const today = new Date().toISOString().split('T')[0]
if (asPrincipal.length > 0) {
const existing = asPrincipal[0]
const existingFrom = existing.validFrom ? existing.validFrom.slice(0, 10) : today
const existingUntil = existing.validUntil ? existing.validUntil.slice(0, 10) : today
setFormData({
deputyId: existing.deputyId || existing.id || '',
validFrom: existingFrom,
validUntil: existingUntil < existingFrom ? existingFrom : existingUntil,
reason: existing.reason || '',
canDelegate: existing.canDelegate === 1 || existing.canDelegate === true,
unitId: existing.unitId || ''
})
} else {
setFormData({
deputyId: '',
validFrom: today,
validUntil: today,
reason: '',
canDelegate: true,
unitId: ''
})
}
setShowAddDialog(true)
}
if (loading) {
@ -132,10 +215,10 @@ export default function DeputyManagement() {
<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)}
onClick={openAddDialog}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
+ Vertretung hinzufügen
{asPrincipal.length > 0 ? 'Vertretungszeitraum anpassen' : '+ Vertretung hinzufügen'}
</button>
</div>
@ -213,6 +296,7 @@ export default function DeputyManagement() {
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
disabled={asPrincipal.length > 0}
>
<option value="">Bitte wählen...</option>
{availableEmployees.map(emp => (
@ -221,8 +305,33 @@ export default function DeputyManagement() {
</option>
))}
</select>
{asPrincipal.length > 0 && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Hinweis: Es kann nur eine Vertretung gepflegt werden. Passen Sie die Zeiträume nach Bedarf an.
</p>
)}
</div>
{unitOptions.length > 0 && (
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
Für Organisationseinheit (optional)
</label>
<select
value={formData.unitId}
onChange={(e) => setFormData({ ...formData, unitId: e.target.value })}
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="">Alle Aufgaben / gesamt</option>
{unitOptions.map(unit => (
<option key={unit.id} value={unit.id}>
{unit.name}{unit.code ? ` (${unit.code})` : ''}
</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">
@ -231,20 +340,29 @@ export default function DeputyManagement() {
<input
type="date"
value={formData.validFrom}
onChange={(e) => setFormData({ ...formData, validFrom: e.target.value })}
onChange={(e) => {
const value = e.target.value
setFormData(prev => ({
...prev,
validFrom: value,
validUntil: prev.validUntil < value ? value : prev.validUntil
}))
}}
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)
Bis *
</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"
required
min={formData.validFrom}
/>
</div>
</div>
@ -282,6 +400,12 @@ export default function DeputyManagement() {
</div>
</div>
{formError && (
<div className="mt-4 rounded-input border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500 dark:bg-red-900/30 dark:text-red-200">
{formError}
</div>
)}
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => {
@ -294,9 +418,10 @@ export default function DeputyManagement() {
</button>
<button
onClick={handleAddDeputy}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
disabled={submitting}
className={`px-4 py-2 rounded text-white ${submitting ? 'bg-blue-400 cursor-wait' : 'bg-blue-600 hover:bg-blue-700'}`}
>
Hinzufügen
{submitting ? 'Speichere...' : 'Hinzufügen'}
</button>
</div>
</div>

Datei anzeigen

@ -1,11 +1,13 @@
import type { Employee } from '@skillmate/shared'
import { formatDepartmentWithDescription, normalizeDepartment } from '../utils/text'
interface EmployeeCardProps {
employee: Employee
onClick: () => void
onDeputyNavigate?: (id: string) => void
}
export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
export default function EmployeeCard({ employee, onClick, onDeputyNavigate }: EmployeeCardProps) {
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
const photoSrc = employee.photo && employee.photo.startsWith('/uploads/')
@ -27,9 +29,35 @@ export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
}
const availability = getAvailabilityBadge(employee.availability)
const specializations = employee.specializations || []
const currentDeputies = employee.currentDeputies || []
const represents = employee.represents || []
const isUnavailable = employee.availability && employee.availability !== 'available'
const cardHighlightClasses = isUnavailable
? 'border-red-300 bg-red-50 hover:border-red-400 hover:bg-red-50/90 dark:bg-red-900/20 dark:border-red-700 dark:hover:bg-red-900/30'
: ''
const departmentInfo = formatDepartmentWithDescription(employee.department, employee.departmentDescription)
const departmentDescriptionText = normalizeDepartment(departmentInfo.description)
const departmentTasks = normalizeDepartment(employee.departmentTasks || departmentInfo.tasks)
const showDepartmentDescription = departmentDescriptionText.length > 0 && departmentDescriptionText !== departmentTasks
// Show only the end unit; provide full chain as tooltip. Hide top-level root (e.g. DIR/LKA NRW)
const fullPath = normalizeDepartment(departmentInfo.label)
const splitPath = (fullPath || '').split(' -> ').map(s => s.trim()).filter(Boolean)
const filteredPath = splitPath.length > 0 && (/^dir$/i.test(splitPath[0]) || /^lka\s+nrw$/i.test(splitPath[0]))
? splitPath.slice(1)
: splitPath
const shortDepartmentLabel = filteredPath.length > 0 ? filteredPath[filteredPath.length - 1] : (fullPath || '')
const chainTitle = filteredPath.join(' → ') || fullPath
const handleDeputyClick = (event: React.MouseEvent<HTMLButtonElement>, targetId: string) => {
event.stopPropagation()
if (onDeputyNavigate) {
onDeputyNavigate(targetId)
}
}
return (
<div className="card" onClick={onClick}>
<div className={`card ${cardHighlightClasses}`} onClick={onClick}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 rounded-full bg-bg-accent dark:bg-dark-primary flex items-center justify-center overflow-hidden">
@ -69,27 +97,100 @@ export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
</p>
)}
<p className="text-body text-secondary">
<span className="font-medium">Dienststelle:</span> {employee.department}
<span className="font-medium">Dienststelle:</span>{' '}
<span title={chainTitle}>{shortDepartmentLabel}</span>
</p>
{showDepartmentDescription && (
<p className="text-small text-tertiary ml-4">{departmentDescriptionText}</p>
)}
{departmentTasks && (
<p className="text-small text-secondary">
<span className="font-medium">Aufgaben der Dienststelle:</span> {departmentTasks}
</p>
)}
</div>
{employee.specializations.length > 0 && (
{specializations.length > 0 && (
<div>
<p className="text-small font-medium text-tertiary mb-2">Spezialisierungen:</p>
<div className="flex flex-wrap gap-2">
{employee.specializations.slice(0, 3).map((spec, index) => (
{specializations.slice(0, 3).map((spec, index) => (
<span key={index} className="badge badge-info text-xs">
{spec}
</span>
))}
{employee.specializations.length > 3 && (
{specializations.length > 3 && (
<span className="text-xs text-tertiary">
+{employee.specializations.length - 3} weitere
+{specializations.length - 3} weitere
</span>
)}
</div>
</div>
)}
{represents.length > 0 && (
<div className="mt-2">
<p className="text-small font-medium text-primary">Vertritt</p>
<div className="flex flex-wrap gap-2">
{represents.map(item => {
const label = `${item.firstName || ''} ${item.lastName || ''}`.trim()
const descParts = [
label,
item.position || undefined,
item.availability ? `Status: ${item.availability}` : undefined
].filter(Boolean)
return (
<button
key={item.assignmentId || item.id}
type="button"
title={descParts.join(' · ')}
onClick={(event) => handleDeputyClick(event, item.id)}
className="inline-flex items-center gap-1 rounded-badge bg-blue-100 px-3 py-1 text-sm font-medium text-blue-700 transition hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
{label || 'Unbekannt'}
{item.position && <span className="text-xs text-blue-500 dark:text-blue-300">({item.position})</span>}
</button>
)
})}
</div>
</div>
)}
{isUnavailable && (
<div className="mt-4">
<p className="text-small font-medium text-red-700 dark:text-red-200 mb-2">Vertretung</p>
{currentDeputies.length > 0 ? (
<div className="flex flex-wrap gap-2">
{currentDeputies.map((deputy) => {
const label = `${deputy.firstName || ''} ${deputy.lastName || ''}`.trim()
const titleParts = [
label,
deputy.position || undefined,
deputy.availability ? `Status: ${deputy.availability}` : undefined,
].filter(Boolean)
return (
<button
key={`${deputy.assignmentId || deputy.id}`}
type="button"
title={titleParts.join(' · ')}
onClick={(event) => handleDeputyClick(event, deputy.id)}
className="inline-flex items-center gap-1 rounded-badge bg-red-100 px-3 py-1 text-sm font-medium text-red-700 transition hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400 dark:bg-red-900/40 dark:text-red-200 dark:hover:bg-red-900/60"
>
{label || 'Ohne Namen'}
{deputy.position && <span className="text-xs text-red-500 dark:text-red-300">({deputy.position})</span>}
</button>
)
})}
</div>
) : (
<p className="text-small text-red-600 dark:text-red-300">
Keine Vertretung hinterlegt.
</p>
)}
</div>
)}
</div>
)
}

Datei anzeigen

@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'
import type { OrganizationalUnit, EmployeeUnitAssignment } from '@skillmate/shared'
import api from '../services/api'
import { useAuthStore } from '../stores/authStore'
import { decodeHtmlEntities } from '../utils/text'
interface OrganizationChartProps {
onClose: () => void
@ -113,6 +114,26 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
return colors[level % colors.length]
}
const getUnitDisplayLabel = (unit?: OrganizationalUnit | null) => {
if (!unit) return ''
const code = unit.code?.trim()
if (code) return code
const decoded = decodeHtmlEntities(unit.name) || unit.name || ''
return decoded.trim()
}
const getUnitDescription = (unit?: OrganizationalUnit | null) => {
if (!unit) return ''
const source = unit.description && unit.description.trim().length > 0 ? unit.description : unit.name
const decoded = decodeHtmlEntities(source) || source || ''
const trimmed = decoded.trim()
const label = getUnitDisplayLabel(unit)
if (trimmed && trimmed !== label) {
return trimmed
}
return ''
}
const handleUnitClick = (unit: OrganizationalUnit) => {
setSelectedUnit(unit)
loadUnitDetails(unit.id)
@ -147,7 +168,8 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
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())
const searchable = `${getUnitDisplayLabel(unit)} ${getUnitDescription(unit)}`.toLowerCase()
const isSearchMatch = searchTerm && searchable.includes(searchTerm.toLowerCase())
return (
<div key={unit.id} className="flex flex-col items-center">
@ -167,7 +189,12 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
</div>
</div>
<div className="p-3">
<h4 className="font-semibold text-sm dark:text-white">{unit.name}</h4>
<h4 className="font-semibold text-sm dark:text-white">{getUnitDisplayLabel(unit)}</h4>
{getUnitDescription(unit) && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{getUnitDescription(unit)}
</p>
)}
{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
@ -313,8 +340,13 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
{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>}
<h3 className="text-lg font-semibold">{getUnitDisplayLabel(selectedUnit)}</h3>
{getUnitDescription(selectedUnit) && (
<div className="mt-3 bg-white/10 rounded px-2 py-1">
<p className="text-xs font-semibold uppercase tracking-wide text-white/80">Aufgaben</p>
<p className="text-sm leading-snug text-white">{getUnitDescription(selectedUnit)}</p>
</div>
)}
</div>
{/* Tabs */}
@ -365,13 +397,6 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
)}
</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

Datei anzeigen

@ -4,13 +4,24 @@ import type { 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
interface SelectedUnitDetails {
unit: OrganizationalUnit
storageValue: string
codePath: string
displayPath: string
namesPath: string
descriptionPath?: string
tasks?: string
}
export default function OrganizationSelector({ value, onChange, disabled }: OrganizationSelectorProps) {
interface OrganizationSelectorProps {
value?: string
onChange: (unitId: string | null, formattedValue: string, details?: SelectedUnitDetails) => void
disabled?: boolean
title?: string
}
export default function OrganizationSelector({ value, onChange, disabled, title }: OrganizationSelectorProps) {
const [showModal, setShowModal] = useState(false)
const [hierarchy, setHierarchy] = useState<OrganizationalUnit[]>([])
const [selectedUnit, setSelectedUnit] = useState<OrganizationalUnit | null>(null)
@ -26,14 +37,18 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
}, [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))
const selection = buildSelectionDetails(hierarchy, unit)
setCurrentUnitName(selection.displayPath)
setSelectedUnit(unit)
}
}
if (!value) {
setSelectedUnit(null)
setCurrentUnitName('')
}
}, [value, hierarchy])
const loadOrganization = async () => {
@ -73,25 +88,63 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
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)
const buildSelectionDetails = (units: OrganizationalUnit[], target: OrganizationalUnit): SelectedUnitDetails => {
const pathUnits: OrganizationalUnit[] = []
const findPath = (nodes: OrganizationalUnit[], current: OrganizationalUnit[] = []): boolean => {
for (const node of nodes) {
const extended = [...current, node]
if (node.id === target.id) {
pathUnits.splice(0, pathUnits.length, ...extended)
return true
}
if (unit.children && findPath(unit.children, newPath)) {
if (node.children && findPath(node.children, extended)) {
return true
}
}
return false
}
findPath(units)
return path.join(' → ')
const normalize = (value?: string | null) => (value ?? '').trim()
const codeSegments = pathUnits
.map(unit => normalize(unit.code) || normalize(unit.name))
.filter(Boolean)
const nameSegments = pathUnits
.map(unit => normalize(unit.name))
.filter(Boolean)
const displaySegments = pathUnits
.map(unit => {
const code = normalize(unit.code)
const name = normalize(unit.name)
if (code && name && code !== name) {
return `${code}${name}`
}
return code || name || ''
})
.filter(Boolean)
const codePath = codeSegments.join(' -> ')
const displayPath = displaySegments.join(' → ')
const namesPath = nameSegments.join(' → ')
const descriptionPath = nameSegments.slice(0, -1).join(' → ') || undefined
const rawTask = normalize(target.description) || normalize(target.name)
const tasks = rawTask || undefined
const storageValue = tasks ? (codePath ? `${codePath} -> ${tasks}` : tasks) : codePath
return {
unit: target,
storageValue,
codePath: codePath || namesPath,
displayPath: displayPath || namesPath || tasks || '',
namesPath,
descriptionPath,
tasks
}
}
const toggleExpand = (unitId: string) => {
@ -107,10 +160,10 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
}
const handleSelect = (unit: OrganizationalUnit) => {
const selection = buildSelectionDetails(hierarchy, unit)
setSelectedUnit(unit)
const path = getUnitPath(hierarchy, unit)
setCurrentUnitName(path)
onChange(unit.id, path)
setCurrentUnitName(selection.displayPath)
onChange(unit.id, selection.storageValue, selection)
setShowModal(false)
}
@ -193,9 +246,10 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
type="text"
className="input-field w-full pr-10"
value={currentUnitName}
placeholder="Klicken zum Auswählen..."
placeholder="Organisationseinheit aus dem Organigramm wählen"
readOnly
disabled={disabled}
title={title}
onClick={() => !disabled && setShowModal(true)}
/>
<button

Datei anzeigen

@ -16,6 +16,12 @@ function segmentColor(i: number) {
return 'bg-purple-500 hover:bg-purple-600 dark:bg-purple-700 dark:hover:bg-purple-600'
}
function levelLabel(i: number): string {
if (i <= 3) return 'Anfänger'
if (i <= 7) return 'Fortgeschritten'
return 'Experte'
}
export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disabled = false, showHelp = true }: SkillLevelBarProps) {
const handleKey = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return
@ -36,6 +42,8 @@ export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disa
const current = typeof value === 'number' ? value : 0
const helpTooltip = showHelp ? 'Stufe 1: Anfänger · Stufe 5: Fortgeschritten · Stufe 10: Experte' : undefined
return (
<div className="space-y-2">
<div
@ -46,6 +54,7 @@ export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disa
aria-valuenow={current || undefined}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKey}
title={helpTooltip}
>
{Array.from({ length: max }, (_, idx) => idx + 1).map(i => {
const active = i <= current
@ -58,20 +67,14 @@ export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disa
? segmentColor(i)
: 'bg-border-default hover:bg-bg-gray dark:bg-dark-border/40 dark:hover:bg-dark-border/60'
} ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
aria-label={`Level ${i}`}
aria-label={`Stufe ${i}`}
title={showHelp ? `Stufe ${i}${levelLabel(i)}` : undefined}
onClick={() => !disabled && onChange(i)}
/>
)
})}
<span className="ml-3 text-small text-secondary min-w-[2ch] text-right">{current || ''}</span>
</div>
{showHelp && (
<div className="text-small text-tertiary">
<span className="mr-4"><strong>1</strong> Anfänger</span>
<span className="mr-4"><strong>5</strong> Fortgeschritten</span>
<span><strong>10</strong> Experte</span>
</div>
)}
</div>
)
}

Datei anzeigen

@ -39,6 +39,16 @@ export function usePermissions() {
const hasPermission = (permission: string): boolean => {
if (!isAuthenticated || !user) return false
if (permission === 'employees:create') {
if (user.role === 'admin') return true
return user.role === 'superuser' && Boolean(user.canManageEmployees)
}
if (permission === 'employees:update') {
if (user.role === 'admin') return true
return user.role === 'superuser' && Boolean(user.canManageEmployees)
}
const rolePermissions = ROLE_PERMISSIONS[user.role] || []
return rolePermissions.includes(permission)
}
@ -51,7 +61,8 @@ export function usePermissions() {
}
const canCreateEmployee = (): boolean => {
return hasPermission('employees:create')
if (!isAuthenticated || !user) return false
return user.role === 'superuser' && Boolean(user.canManageEmployees)
}
const canEditEmployee = (employeeId?: string): boolean => {
@ -60,8 +71,8 @@ export function usePermissions() {
// Admins can edit anyone
if (user.role === 'admin') return true
// Superusers can edit anyone
if (user.role === 'superuser') return true
// Superusers can only edit when they are allowed to manage employees
if (user.role === 'superuser' && user.canManageEmployees) return true
// Users can only edit their own profile (if linked)
if (user.role === 'user' && employeeId && user.employeeId === employeeId) {

Datei anzeigen

@ -24,6 +24,10 @@ export const authApi = {
const response = await api.post('/auth/login', { email, password })
return response.data.data
},
forgotPassword: async (email: string) => {
const response = await api.post('/auth/forgot-password', { email })
return response.data
},
logout: async () => {
localStorage.removeItem('token')
}
@ -63,5 +67,31 @@ export const skillsApi = {
}
}
export const officialTitlesApi = {
getAll: async (): Promise<string[]> => {
const response = await api.get('/official-titles')
const list = Array.isArray(response.data?.data) ? response.data.data : []
return list.map((item: any) => item.label).filter((label: any) => typeof label === 'string')
}
}
export const positionCatalogApi = {
getAll: async (unitId?: string | null): Promise<string[]> => {
const params = unitId ? { unitId } : undefined
const response = await api.get('/positions', { params })
const list = Array.isArray(response.data?.data) ? response.data.data : []
const seen = new Set<string>()
return list
.map((item: any) => item?.label)
.filter((label: any) => typeof label === 'string' && label.trim().length > 0)
.filter((label: string) => {
const key = label.trim().toLowerCase()
if (seen.has(key)) return false
seen.add(key)
return true
})
}
}
export { api }
export default api

81
frontend/src/utils/text.ts Normale Datei
Datei anzeigen

@ -0,0 +1,81 @@
const namedEntities: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: '\u00A0',
slash: '/',
sol: '/',
frasl: '/',
}
const decodeOnce = (input: string): string => {
return input.replace(/&(#x?[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, entity) => {
if (!entity) return match
if (entity.startsWith('#')) {
const isHex = entity[1]?.toLowerCase() === 'x'
const num = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(num)) {
try {
return String.fromCodePoint(num)
} catch (err) {
return match
}
}
return match
}
const lowered = entity.toLowerCase()
if (namedEntities[lowered]) {
return namedEntities[lowered]
}
return match
})
}
export const decodeHtmlEntities = (value?: string | null): string | undefined => {
if (value === undefined || value === null) return undefined
let result = value
for (let i = 0; i < 3; i += 1) {
const decoded = decodeOnce(result)
if (decoded === result) break
result = decoded
}
return result
}
export const normalizeDepartment = (value?: string | null): string => {
if (!value) return ''
return (decodeHtmlEntities(value) || value || '').trim()
}
const splitPathAndTask = (value?: string | null): { path: string; task?: string } => {
const normalized = normalizeDepartment(value)
if (!normalized) return { path: '' }
const separator = ' -> '
const lastIndex = normalized.lastIndexOf(separator)
if (lastIndex === -1) {
return { path: normalized }
}
const path = normalized.slice(0, lastIndex)
const task = normalized.slice(lastIndex + separator.length)
return {
path: path || normalized,
task: task || undefined
}
}
export const formatDepartmentWithDescription = (
code?: string | null,
description?: string | null
): { label: string; description?: string; tasks?: string } => {
const { path, task } = splitPathAndTask(code)
const fallbackPath = normalizeDepartment(description)
const label = path || fallbackPath
const desc = fallbackPath && fallbackPath !== label ? fallbackPath : undefined
return {
label,
description: desc,
tasks: task || (desc && desc !== label ? desc : undefined)
}
}

Datei anzeigen

@ -7,6 +7,7 @@ import OfficeMapModal from '../components/OfficeMapModal'
import { employeeApi } from '../services/api'
import { useAuthStore } from '../stores/authStore'
import { isBooleanSkill } from '../utils/skillRules'
import { formatDepartmentWithDescription, normalizeDepartment } from '../utils/text'
export default function EmployeeDetail() {
const { id } = useParams()
@ -90,6 +91,11 @@ export default function EmployeeDetail() {
)
}
const departmentInfo = formatDepartmentWithDescription(employee.department, employee.departmentDescription)
const departmentDescriptionText = normalizeDepartment(departmentInfo.description)
const departmentTasks = normalizeDepartment(employee.departmentTasks || departmentInfo.description)
const showDepartmentDescription = departmentDescriptionText.length > 0 && departmentDescriptionText !== departmentTasks
// Hinweis: Verfügbarkeits-Badge wird im Mitarbeitenden-Detail nicht angezeigt
return (
@ -177,8 +183,17 @@ export default function EmployeeDetail() {
)}
<div>
<span className="text-tertiary">Dienststelle:</span>
<p className="text-secondary font-medium">{employee.department}</p>
<p className="text-secondary font-medium">{departmentInfo.label}</p>
{showDepartmentDescription && (
<p className="text-small text-tertiary mt-1">{departmentDescriptionText}</p>
)}
</div>
{departmentTasks && (
<div>
<span className="text-tertiary">Aufgaben der Dienststelle:</span>
<p className="text-secondary mt-1">{departmentTasks}</p>
</div>
)}
{employee.clearance && (
<div>
<span className="text-tertiary">Sicherheitsüberprüfung:</span>

Datei anzeigen

@ -1,14 +1,25 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { Employee } from '@skillmate/shared'
import { employeeApi } from '../services/api'
import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '../data/skillCategories'
import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '@skillmate/shared'
import { employeeApi, officialTitlesApi, positionCatalogApi } from '../services/api'
import PhotoPreview from '../components/PhotoPreview'
import OrganizationSelector from '../components/OrganizationSelector'
import { isBooleanSkill } from '../utils/skillRules'
import { useAuthStore } from '../stores/authStore'
const DEFAULT_POSITION_OPTIONS = [
'Sachbearbeitung',
'stellvertretende Sachgebietsleitung',
'Sachgebietsleitung',
'Dezernatsleitung',
'Abteilungsleitung',
'Behördenleitung'
]
export default function EmployeeForm() {
const navigate = useNavigate()
const { user } = useAuthStore()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
@ -20,6 +31,8 @@ export default function EmployeeForm() {
position: '',
officialTitle: '',
department: '',
departmentDescription: '',
departmentTasks: '',
email: '',
phone: '',
mobile: '',
@ -31,20 +44,79 @@ export default function EmployeeForm() {
languages: [] as string[],
specializations: [] as string[]
})
const [primaryUnitId, setPrimaryUnitId] = useState<string | null>(null)
const [primaryUnitName, setPrimaryUnitName] = useState<string>('')
const [primaryUnitId, setPrimaryUnitId] = useState<string | null>(user?.powerUnitId || null)
const [primaryUnitName, setPrimaryUnitName] = useState<string>(user?.powerUnitName || '')
const [employeePhoto, setEmployeePhoto] = useState<string | null>(null)
const [photoFile, setPhotoFile] = useState<File | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [expandedSubCategories, setExpandedSubCategories] = useState<Set<string>>(new Set())
const [skillSearchTerm, setSkillSearchTerm] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([])
const [officialTitleOptions, setOfficialTitleOptions] = useState<string[]>([])
const [positionOptions, setPositionOptions] = useState<string[]>(DEFAULT_POSITION_OPTIONS)
useEffect(() => {
if (!user || user.role !== 'superuser' || !user.canManageEmployees) {
navigate('/employees')
}
}, [user, navigate])
useEffect(() => {
setPrimaryUnitId(user?.powerUnitId || null)
setPrimaryUnitName(user?.powerUnitName || '')
}, [user])
useEffect(() => {
if (!primaryUnitName) return
setFormData(prev => {
if (prev.department) return prev
return { ...prev, department: primaryUnitName }
})
}, [primaryUnitName])
useEffect(() => {
const loadOfficialTitles = async () => {
try {
const titles = await officialTitlesApi.getAll()
setOfficialTitleOptions(titles)
} catch (error) {
console.error('Failed to load official titles', error)
setOfficialTitleOptions([])
}
}
loadOfficialTitles()
}, [])
useEffect(() => {
let isActive = true
const loadPositions = async () => {
try {
const options = await positionCatalogApi.getAll(primaryUnitId)
if (!isActive) return
setPositionOptions(options.length ? options : DEFAULT_POSITION_OPTIONS)
} catch (error) {
console.error('Failed to load position options', error)
if (!isActive) return
setPositionOptions(DEFAULT_POSITION_OPTIONS)
}
}
loadPositions()
return () => {
isActive = false
}
}, [primaryUnitId])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
setValidationErrors(prev => {
if (!prev[name]) return prev
const next = { ...prev }
delete next[name]
return next
})
}
const toggleCategory = (categoryId: string) => {
@ -175,17 +247,13 @@ export default function EmployeeForm() {
const validateForm = () => {
const errors: Record<string, string> = {}
if (!formData.firstName.trim()) errors.firstName = 'Vorname ist erforderlich'
if (!formData.lastName.trim()) errors.lastName = 'Nachname ist erforderlich'
if (!formData.employeeNumber.trim()) errors.employeeNumber = 'Personalnummer ist erforderlich'
if (!formData.position.trim()) errors.position = 'Position ist erforderlich'
if (!formData.department.trim()) errors.department = 'Abteilung ist erforderlich'
if (!formData.email.trim()) errors.email = 'E-Mail ist erforderlich'
else if (!/\S+@\S+\.\S+/.test(formData.email)) errors.email = 'Ungültige E-Mail-Adresse'
if (!formData.phone.trim()) errors.phone = 'Telefonnummer ist erforderlich'
if (!primaryUnitId) errors.primaryUnitId = 'Organisatorische Einheit ist erforderlich'
setValidationErrors(errors)
return Object.keys(errors).length === 0
}
@ -196,15 +264,30 @@ export default function EmployeeForm() {
setValidationErrors({})
if (!validateForm()) {
setError('Bitte füllen Sie alle Pflichtfelder aus')
setError('Bitte geben Sie Vorname, Nachname, E-Mail und eine Organisationseinheit an.')
return
}
setLoading(true)
try {
const trimmedFirstName = formData.firstName.trim()
const trimmedLastName = formData.lastName.trim()
const trimmedEmail = formData.email.trim()
const departmentValue = (formData.department || primaryUnitName || '').trim()
const positionValue = formData.position.trim()
const newEmployee: Partial<Employee> = {
...formData,
firstName: trimmedFirstName,
lastName: trimmedLastName,
email: trimmedEmail,
position: positionValue || 'Teammitglied',
department: departmentValue || 'Noch nicht zugewiesen',
employeeNumber: formData.employeeNumber.trim() || undefined,
phone: formData.phone.trim() || undefined,
mobile: formData.mobile.trim() || undefined,
office: formData.office.trim() || undefined,
skills: formData.skills.map((skill, index) => ({
id: skill.skillId || `skill-${index}`,
name: skill.name,
@ -225,16 +308,30 @@ export default function EmployeeForm() {
officialTitle: formData.officialTitle || undefined,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'admin'
createdBy: user?.id ?? 'frontend'
}
const result = await employeeApi.create({ ...newEmployee, primaryUnitId })
const newEmployeeId = result.data.id
// Upload photo if we have one
const payload = {
...newEmployee,
department: newEmployee.department,
employeeNumber: newEmployee.employeeNumber,
phone: newEmployee.phone,
mobile: newEmployee.mobile,
office: newEmployee.office,
createUser: true,
userRole: 'user',
organizationUnitId: primaryUnitId,
organizationRole: 'mitarbeiter',
primaryUnitId
}
const result = await employeeApi.create(payload)
const newEmployeeId = result.data?.id
const temporaryPassword = result.data?.temporaryPassword as string | undefined
if (photoFile && newEmployeeId) {
const formData = new FormData()
formData.append('photo', photoFile)
const uploadForm = new FormData()
uploadForm.append('photo', photoFile)
try {
await fetch(`http://localhost:3001/api/upload/employee-photo/${newEmployeeId}`, {
@ -242,13 +339,17 @@ export default function EmployeeForm() {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
body: uploadForm
})
} catch (uploadError) {
console.error('Failed to upload photo:', uploadError)
}
}
if (temporaryPassword) {
window.alert(`Benutzerkonto erstellt. Temporäres Passwort: ${temporaryPassword}\nBitte teilen Sie das Passwort sicher oder versenden Sie eine E-Mail über die Admin-Verwaltung.`)
}
navigate('/employees')
} catch (err: any) {
if (err.response?.status === 401) {
@ -349,7 +450,7 @@ export default function EmployeeForm() {
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Personalnummer *
Personalnummer (optional)
</label>
<input
type="text"
@ -357,7 +458,7 @@ export default function EmployeeForm() {
value={formData.employeeNumber}
onChange={handleChange}
className={`input-field ${validationErrors.employeeNumber ? 'border-red-500 ring-red-200' : ''}`}
required
placeholder="Kann später ergänzt werden"
/>
{validationErrors.employeeNumber && (
<p className="mt-1 text-sm text-red-600">{validationErrors.employeeNumber}</p>
@ -383,16 +484,22 @@ export default function EmployeeForm() {
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Position *
Position (optional)
</label>
<input
type="text"
<select
name="position"
value={formData.position}
onChange={handleChange}
className={`input-field ${validationErrors.position ? 'border-red-500 ring-red-200' : ''}`}
required
/>
>
<option value="" disabled>Neutrale Funktionsbezeichnung auswählen</option>
{positionOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
{formData.position && !positionOptions.includes(formData.position) && (
<option value={formData.position}>{`${formData.position} (bestehender Wert)`}</option>
)}
</select>
{validationErrors.position && (
<p className="mt-1 text-sm text-red-600">{validationErrors.position}</p>
)}
@ -402,19 +509,25 @@ export default function EmployeeForm() {
<label className="block text-sm font-medium text-secondary mb-2">
Amtsbezeichnung
</label>
<input
type="text"
<select
name="officialTitle"
value={formData.officialTitle}
onChange={handleChange}
className="input-field"
placeholder="z. B. KOK, KHK, EKHK"
/>
>
<option value="">Bitte auswählen</option>
{officialTitleOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
{formData.officialTitle && !officialTitleOptions.includes(formData.officialTitle) && (
<option value={formData.officialTitle}>{`${formData.officialTitle} (bestehender Wert)`}</option>
)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Abteilung *
Abteilung (optional wird aus der Organisationseinheit vorgeschlagen)
</label>
<input
type="text"
@ -422,7 +535,7 @@ export default function EmployeeForm() {
value={formData.department}
onChange={handleChange}
className={`input-field ${validationErrors.department ? 'border-red-500 ring-red-200' : ''}`}
required
placeholder="Wird automatisch nach Auswahl der Einheit befüllt"
/>
{validationErrors.department && (
<p className="mt-1 text-sm text-red-600">{validationErrors.department}</p>
@ -435,10 +548,18 @@ export default function EmployeeForm() {
</label>
<OrganizationSelector
value={primaryUnitId || undefined}
onChange={(unitId, unitName) => {
onChange={(unitId, formattedValue, details) => {
if (user?.canManageEmployees && user?.powerUnitId) return
setPrimaryUnitId(unitId)
setPrimaryUnitName(unitName)
setPrimaryUnitName(details?.displayPath || formattedValue)
setFormData(prev => ({
...prev,
department: formattedValue || '',
departmentDescription: details?.descriptionPath || details?.namesPath || '',
departmentTasks: details?.tasks || ''
}))
}}
disabled={Boolean(user?.canManageEmployees && user?.powerUnitId)}
/>
<p className="text-tertiary text-sm mt-2">{primaryUnitName || 'Bitte auswählen'}</p>
{validationErrors.primaryUnitId && (
@ -448,7 +569,7 @@ export default function EmployeeForm() {
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Telefon *
Telefon (optional)
</label>
<input
type="tel"
@ -456,7 +577,7 @@ export default function EmployeeForm() {
value={formData.phone}
onChange={handleChange}
className={`input-field ${validationErrors.phone ? 'border-red-500 ring-red-200' : ''}`}
required
placeholder="Kann später ergänzt werden"
/>
{validationErrors.phone && (
<p className="mt-1 text-sm text-red-600">{validationErrors.phone}</p>

Datei anzeigen

@ -3,13 +3,12 @@ import { useNavigate } from 'react-router-dom'
import { SearchIcon } from '../components/icons'
import EmployeeCard from '../components/EmployeeCard'
import { employeeApi } from '../services/api'
import { useAuthStore } from '../stores/authStore'
import type { Employee } from '@skillmate/shared'
import { usePermissions } from '../hooks/usePermissions'
import { normalizeDepartment } from '../utils/text'
export default function EmployeeList() {
const navigate = useNavigate()
const { user } = useAuthStore()
const { canCreateEmployee } = usePermissions()
const [employees, setEmployees] = useState<Employee[]>([])
const [searchTerm, setSearchTerm] = useState('')
@ -59,30 +58,48 @@ export default function EmployeeList() {
}
useEffect(() => {
const skillFilter = filters.skills.trim().toLowerCase()
const searchFilter = searchTerm.trim().toLowerCase()
let filtered = employees.filter(emp => {
// Text search
const matchesSearch = searchTerm === '' ||
`${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.department.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.position.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.specializations.some(spec => spec.toLowerCase().includes(searchTerm.toLowerCase()))
// Department filter
const matchesDepartment = filters.department === '' || emp.department === filters.department
// Availability filter
const matchesAvailability = filters.availability === '' || emp.availability === filters.availability
// Skills filter (basic - später erweitern)
const matchesSkills = filters.skills === '' ||
emp.specializations.some(spec => spec.toLowerCase().includes(filters.skills.toLowerCase()))
const departmentLabel = normalizeDepartment(emp.department)
const departmentDescription = normalizeDepartment(emp.departmentDescription)
const departmentTasks = normalizeDepartment(emp.departmentTasks || emp.departmentDescription)
const matchesSkillSearch = (emp.skills || []).some(skill =>
(skill.name || '').toLowerCase().includes(searchFilter) ||
(skill.category || '').toLowerCase().includes(searchFilter)
)
const matchesSpecializationSearch = emp.specializations.some(spec => spec.toLowerCase().includes(searchFilter))
const matchesSearch = searchFilter === '' ||
`${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchFilter) ||
departmentLabel.toLowerCase().includes(searchFilter) ||
departmentDescription.toLowerCase().includes(searchFilter) ||
departmentTasks.toLowerCase().includes(searchFilter) ||
emp.position.toLowerCase().includes(searchFilter) ||
matchesSkillSearch ||
matchesSpecializationSearch
const matchesDepartment = !filters.department || departmentLabel === filters.department
const matchesAvailability = !filters.availability || emp.availability === filters.availability
const matchesSkills = skillFilter === '' ||
(emp.skills || []).some(skill =>
(skill.name || '').toLowerCase().includes(skillFilter) ||
(skill.category || '').toLowerCase().includes(skillFilter)
) ||
emp.specializations.some(spec => spec.toLowerCase().includes(skillFilter))
return matchesSearch && matchesDepartment && matchesAvailability && matchesSkills
})
setFilteredEmployees(filtered)
}, [searchTerm, employees, filters])
const departmentOptions = Array.from(new Set(
employees.map(emp => normalizeDepartment(emp.department)).filter(Boolean)
)).sort()
return (
<div>
<div className="flex justify-between items-center mb-8">
@ -136,11 +153,9 @@ export default function EmployeeList() {
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="">Alle Dienststellen</option>
<option value="Cybercrime">Cybercrime</option>
<option value="Staatsschutz">Staatsschutz</option>
<option value="Kriminalpolizei">Kriminalpolizei</option>
<option value="IT">IT</option>
<option value="Verwaltung">Verwaltung</option>
{departmentOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
@ -183,6 +198,7 @@ export default function EmployeeList() {
key={employee.id}
employee={employee}
onClick={() => navigate(`/employees/${employee.id}`)}
onDeputyNavigate={(id) => navigate(`/employees/${id}`)}
/>
))}
</div>

Datei anzeigen

@ -10,6 +10,10 @@ export default function Login() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [forgotOpen, setForgotOpen] = useState(false)
const [forgotEmail, setForgotEmail] = useState('')
const [forgotMessage, setForgotMessage] = useState('')
const [forgotLoading, setForgotLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@ -28,6 +32,43 @@ export default function Login() {
}
}
const toggleForgotPassword = () => {
setForgotOpen((prev) => {
const next = !prev
if (!prev) {
setForgotEmail((current) => current || email)
setForgotMessage('')
}
if (prev) {
setForgotEmail('')
setForgotMessage('')
}
return next
})
}
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault()
const fallbackMessage = 'Falls die E-Mail im System hinterlegt ist, senden wir zeitnah ein neues Passwort.'
if (!forgotEmail.trim()) {
setForgotMessage(fallbackMessage)
return
}
try {
setForgotLoading(true)
setForgotMessage('')
const response = await authApi.forgotPassword(forgotEmail.trim())
setForgotMessage(response?.message || fallbackMessage)
} catch (err) {
setForgotMessage(fallbackMessage)
} finally {
setForgotLoading(false)
}
}
return (
<div className="min-h-screen bg-primary flex items-center justify-center">
<div className="card max-w-md w-full">
@ -82,10 +123,53 @@ export default function Login() {
</button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={toggleForgotPassword}
className="text-sm text-primary hover:underline"
>
Passwort vergessen?
</button>
</div>
{forgotOpen && (
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4">
<div>
<label htmlFor="forgot-email" className="block text-sm font-medium text-secondary mb-2">
E-Mail-Adresse für Passwort-Reset
</label>
<input
id="forgot-email"
type="email"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
className="input-field"
placeholder="E-Mail-Adresse eingeben"
required
/>
</div>
{forgotMessage && (
<div className="bg-info-bg text-info p-3 rounded-input text-sm">
{forgotMessage}
</div>
)}
<button
type="submit"
disabled={forgotLoading}
className="btn-secondary w-full"
>
{forgotLoading ? 'Wird angefordert...' : 'Neues Passwort anfordern'}
</button>
</form>
)}
<div className="mt-6 text-center text-sm text-tertiary">
<p>Für erste Anmeldung wenden Sie sich an Ihren Administrator</p>
</div>
</div>
</div>
)
}
}

Datei anzeigen

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useAuthStore } from '../stores/authStore'
import { employeeApi } from '../services/api'
import { employeeApi, officialTitlesApi, positionCatalogApi } from '../services/api'
import PhotoUpload from '../components/PhotoUpload'
import SkillLevelBar from '../components/SkillLevelBar'
import DeputyManagement from '../components/DeputyManagement'
@ -9,6 +9,26 @@ import { isBooleanSkill } from '../utils/skillRules'
interface SkillSelection { categoryId: string; subCategoryId: string; skillId: string; name: string; level: string }
const AVAILABILITY_OPTIONS = [
{ value: 'available', label: 'Verfügbar' },
{ value: 'busy', label: 'Beschäftigt' },
{ value: 'away', label: 'Abwesend' },
{ value: 'vacation', label: 'Urlaub' },
{ value: 'sick', label: 'Erkrankt' },
{ value: 'training', label: 'Fortbildung' },
{ value: 'operation', label: 'Im Einsatz' },
{ value: 'unavailable', label: 'Nicht verfügbar' }
]
const DEFAULT_POSITION_OPTIONS = [
'Sachbearbeitung',
'stellvertretende Sachgebietsleitung',
'Sachgebietsleitung',
'Dezernatsleitung',
'Abteilungsleitung',
'Behördenleitung'
]
export default function MyProfile() {
const { user } = useAuthStore()
const employeeId = user?.employeeId
@ -17,23 +37,17 @@ export default function MyProfile() {
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
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[]>([])
const AVAILABILITY_OPTIONS = [
{ value: 'available', label: 'Verfügbar' },
{ value: 'busy', label: 'Beschäftigt' },
{ value: 'away', label: 'Abwesend' },
{ value: 'vacation', label: 'Urlaub' },
{ value: 'sick', label: 'Erkrankt' },
{ value: 'training', label: 'Fortbildung' },
{ value: 'operation', label: 'Im Einsatz' },
{ value: 'parttime', label: 'Teilzeit' },
{ value: 'unavailable', label: 'Nicht verfügbar' }
]
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[]>([])
const [officialTitleOptions, setOfficialTitleOptions] = useState<string[]>([])
const [positionOptions, setPositionOptions] = useState<string[]>(DEFAULT_POSITION_OPTIONS)
const [showAvailabilityHint, setShowAvailabilityHint] = useState(false)
const [highlightDelegations, setHighlightDelegations] = useState(false)
const highlightTimeoutRef = useRef<number | undefined>(undefined)
useEffect(() => {
if (!employeeId) {
@ -43,6 +57,46 @@ const AVAILABILITY_OPTIONS = [
load()
}, [employeeId])
useEffect(() => {
const loadOfficialTitles = async () => {
try {
const titles = await officialTitlesApi.getAll()
setOfficialTitleOptions(titles)
} catch (error) {
console.error('Failed to load official titles', error)
setOfficialTitleOptions([])
}
}
loadOfficialTitles()
}, [])
useEffect(() => {
let isActive = true
const loadPositions = async () => {
try {
const titles = await positionCatalogApi.getAll(currentUnitId)
if (!isActive) return
setPositionOptions(titles.length ? titles : DEFAULT_POSITION_OPTIONS)
} catch (error) {
console.error('Failed to load position options', error)
if (!isActive) return
setPositionOptions(DEFAULT_POSITION_OPTIONS)
}
}
loadPositions()
return () => {
isActive = false
}
}, [currentUnitId])
useEffect(() => {
return () => {
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current)
}
}
}, [])
const load = async () => {
if (!employeeId) return
setLoading(true)
@ -50,6 +104,7 @@ const AVAILABILITY_OPTIONS = [
try {
// Load employee first
const data = await employeeApi.getById(employeeId)
const availability = data.availability || 'available'
setForm({ ...data, email: user?.email || data.email || '' })
const mapped: SkillSelection[] = (data.skills || []).map((s: any) => {
const catStr = s.category || ''
@ -67,6 +122,7 @@ const AVAILABILITY_OPTIONS = [
}
})
setSkills(mapped)
setShowAvailabilityHint(availability !== 'available')
// Load my organizational units
try {
@ -118,17 +174,49 @@ const AVAILABILITY_OPTIONS = [
setSkills(prev => prev.map(s => (s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId) ? { ...s, level } : s))
}
const triggerDelegationHighlight = () => {
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current)
}
setHighlightDelegations(true)
highlightTimeoutRef.current = window.setTimeout(() => {
setHighlightDelegations(false)
highlightTimeoutRef.current = undefined
}, 5000)
}
const handleAvailabilityChange = (value: string) => {
setForm((prev: any) => ({ ...prev, availability: value }))
const shouldHighlight = value !== 'available'
setShowAvailabilityHint(shouldHighlight)
if (shouldHighlight) {
triggerDelegationHighlight()
} else {
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current)
highlightTimeoutRef.current = undefined
}
setHighlightDelegations(false)
}
}
const isSkillSelected = (categoryId: string, subCategoryId: string, skillId: string) =>
skills.some(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)
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) => {
const handleOrganizationChange = async (unitId: string | null, formattedValue: string, details?: { descriptionPath?: string; namesPath: string; tasks?: string } | null) => {
setCurrentUnitId(unitId)
setForm((prev: any) => ({
...prev,
department: formattedValue || '',
departmentDescription: details?.descriptionPath || details?.namesPath || '',
departmentTasks: details?.tasks || ''
}))
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: {
@ -136,14 +224,12 @@ const AVAILABILITY_OPTIONS = [
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
employeeId: employeeId,
unitId: unitId,
employeeId,
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)
}
@ -228,13 +314,17 @@ const AVAILABILITY_OPTIONS = [
<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'}`}
className={`pb-2 px-1 transition-colors ${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'}`}
className={`pb-2 px-1 transition-colors rounded-sm ${
activeTab === 'deputies'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-600 dark:text-gray-400'
} ${highlightDelegations ? 'ring-2 ring-amber-400 ring-offset-1 ring-offset-white dark:ring-offset-gray-900 bg-amber-50 dark:bg-amber-900/30 animate-pulse' : ''}`}
>
Vertretungen
</button>
@ -259,33 +349,45 @@ const AVAILABILITY_OPTIONS = [
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">E-Mail</label>
<input className="input-field w-full disabled:opacity-70" value={user?.email || form.email || ''} disabled readOnly placeholder="wird aus Login übernommen" title="Wird automatisch aus dem Login gesetzt" />
<p className="text-small text-tertiary mt-1">Wird automatisch aus dem Login übernommen. Änderung ggf. im Admin Panel.</p>
<input className="input-field w-full disabled:opacity-70" value={user?.email || form.email || ''} disabled readOnly placeholder="Wird automatisch aus dem Login übernommen" title="Wird automatisch aus dem Login übernommen" />
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Position</label>
<input
<select
className="input-field w-full"
value={form.position || ''}
onChange={(e) => setForm((p: any) => ({ ...p, position: e.target.value }))}
placeholder="z. B. Sachbearbeitung, Teamleitung"
/>
<p className="text-small text-tertiary mt-1">Beispiele: Sachbearbeitung, Teamleitung, Stabsstelle.</p>
title="Neutrale Funktionsbezeichnung auswählen"
>
<option value="" disabled>Neutrale Funktionsbezeichnung auswählen</option>
{positionOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
{form.position && !positionOptions.includes(form.position) && (
<option value={form.position}>{`${form.position} (bestehender Wert)`}</option>
)}
</select>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Amtsbezeichnung</label>
<input
<select
className="input-field w-full"
value={form.officialTitle || ''}
onChange={(e) => setForm((p: any) => ({ ...p, officialTitle: e.target.value }))}
placeholder="z. B. KOK, KHK, EKHK"
/>
<p className="text-small text-tertiary mt-1">Freifeld für Amts- bzw. Dienstbezeichnungen (z. B. KOK, RBe, EKHK).</p>
title="Dienstliche Amtsbezeichnung auswählen"
>
<option value="" disabled>Amtsbezeichnung auswählen</option>
{officialTitleOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
{form.officialTitle && !officialTitleOptions.includes(form.officialTitle) && (
<option value={form.officialTitle}>{`${form.officialTitle} (bestehender Wert)`}</option>
)}
</select>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">NW-Kennung</label>
<input className="input-field w-full" value={form.employeeNumber || ''} onChange={(e) => setForm((p: any) => ({ ...p, employeeNumber: e.target.value }))} placeholder="z. B. NW068111" />
<p className="text-small text-tertiary mt-1">Ihre behördliche Kennung, z. B. NW068111.</p>
<input className="input-field w-full" value={form.employeeNumber || ''} onChange={(e) => setForm((p: any) => ({ ...p, employeeNumber: e.target.value }))} placeholder="z. B. NW068111" title="Behördliche Kennung, z. B. NW068111" />
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Dienststelle</label>
@ -293,41 +395,50 @@ const AVAILABILITY_OPTIONS = [
value={currentUnitId || undefined}
onChange={handleOrganizationChange}
disabled={false}
title="Organisationseinheit aus dem Organigramm auswählen"
/>
<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>
<input className="input-field w-full" value={form.phone || ''} onChange={(e) => setForm((p: any) => ({ ...p, phone: e.target.value }))} placeholder="z. B. +49 30 12345-100" />
<p className="text-small text-tertiary mt-1">Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).</p>
<input className="input-field w-full" value={form.phone || ''} onChange={(e) => setForm((p: any) => ({ ...p, phone: e.target.value }))} placeholder="z. B. +49 30 12345-100" title="Bitte Rufnummer im internationalen Format angeben" />
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Mobil</label>
<input className="input-field w-full" value={form.mobile || ''} onChange={(e) => setForm((p: any) => ({ ...p, mobile: e.target.value }))} placeholder="z. B. +49 171 1234567" />
<p className="text-small text-tertiary mt-1">Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).</p>
<input className="input-field w-full" value={form.mobile || ''} onChange={(e) => setForm((p: any) => ({ ...p, mobile: e.target.value }))} placeholder="z. B. +49 171 1234567" title="Bitte Rufnummer im internationalen Format angeben" />
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Büro</label>
<input className="input-field w-full" value={form.office || ''} onChange={(e) => setForm((p: any) => ({ ...p, office: e.target.value }))} placeholder="z. B. Gebäude A, 3.OG, Raum 3.12" />
<p className="text-small text-tertiary mt-1">Angabe zum Standort, z. B. Gebäude, Etage und Raum.</p>
<input className="input-field w-full" value={form.office || ''} onChange={(e) => setForm((p: any) => ({ ...p, office: e.target.value }))} placeholder="z. B. Gebäude A, 3.OG, Raum 3.12" title="Standort mit Gebäude, Etage und Raum angeben" />
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">Verfügbarkeit</label>
<select
className="input-field w-full"
value={form.availability || 'available'}
onChange={(e) => setForm((p: any) => ({ ...p, availability: e.target.value }))}
onChange={(e) => handleAvailabilityChange(e.target.value)}
title="Status wird in Mitarbeitendenübersicht und Teamplanung angezeigt"
>
{AVAILABILITY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<p className="text-small text-tertiary mt-1">Dieser Status wird in der Mitarbeitendenübersicht und Teamplanung angezeigt.</p>
{showAvailabilityHint && (
<div className="mt-2 rounded-input border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:border-amber-500 dark:bg-amber-900/30 dark:text-amber-200">
Hinweis: Bitte stimmen Sie eine Vertretung im Reiter Vertretungen ab, solange Sie nicht verfügbar sind.
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex justify-end mt-4">
<button onClick={onSave} disabled={saving} className="btn-primary">
{saving ? 'Speichere...' : 'Änderungen speichern'}
</button>
</div>
<div className="card mt-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Kompetenzen</h2>
<div className="space-y-4">
@ -341,26 +452,33 @@ const AVAILABILITY_OPTIONS = [
{sub.skills.map(skill => {
const booleanSkill = isBooleanSkill(category.id, sub.id)
const selected = isSkillSelected(category.id, sub.id, skill.id)
const skillInputId = `skill-${category.id}-${sub.id}-${skill.id}`
return (
<div
key={`${category.id}-${sub.id}-${skill.id}`}
className={`p-2 border rounded-input ${selected ? 'border-primary-blue bg-bg-accent' : 'border-border-default'}`}
>
<label className="flex items-center justify-between">
<span className="text-body text-secondary">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label
htmlFor={skillInputId}
className="flex items-start gap-2 flex-1 min-w-0 text-body text-secondary cursor-pointer"
>
<input
id={skillInputId}
type="checkbox"
className="mr-2"
className="mt-0.5 shrink-0"
checked={selected}
onChange={() => handleSkillToggle(category.id, sub.id, skill.id, skill.name)}
/>
{skill.name}
</span>
<span className="truncate" title={skill.name}>{skill.name}</span>
</label>
{selected && (
booleanSkill ? (
<span className="ml-3 text-small font-medium text-green-700 dark:text-green-400">Ja</span>
<span className="sm:ml-3 text-small font-medium text-green-700 dark:text-green-400">Ja</span>
) : (
<div className="ml-3 flex-1">
<div className="sm:ml-3 w-full sm:w-56 md:w-64">
<SkillLevelBar
value={Number(getSkillLevel(category.id, sub.id, skill.id)) || ''}
onChange={(val) => handleSkillLevelChange(category.id, sub.id, skill.id, String(val))}
@ -368,7 +486,7 @@ const AVAILABILITY_OPTIONS = [
</div>
)
)}
</label>
</div>
</div>
)
})}

Datei anzeigen

@ -4,6 +4,7 @@ import { SearchIcon } from '../components/icons'
import EmployeeCard from '../components/EmployeeCard'
import { useNavigate } from 'react-router-dom'
import { employeeApi } from '../services/api'
import { normalizeDepartment } from '../utils/text'
// Import the skill hierarchy - we'll load it dynamically in useEffect
type SkillWithStats = {
@ -194,8 +195,11 @@ export default function SkillSearch() {
const searchLower = freeSearchTerm.toLowerCase()
const results = allEmployees.filter(employee => {
// Search in name, department, position
const departmentLabel = normalizeDepartment(employee.department)
const departmentDescription = normalizeDepartment(employee.departmentDescription)
if (`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchLower) ||
employee.department?.toLowerCase().includes(searchLower) ||
departmentLabel.toLowerCase().includes(searchLower) ||
departmentDescription.toLowerCase().includes(searchLower) ||
employee.position?.toLowerCase().includes(searchLower)) {
return true
}

Datei anzeigen

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import type { Employee } from '@skillmate/shared'
import { employeeApi } from '../services/api'
import { SearchIcon } from '../components/icons'
import { normalizeDepartment, formatDepartmentWithDescription } from '../utils/text'
type TeamPosition = {
id: string
@ -146,7 +147,9 @@ export default function TeamZusammenstellung() {
}
// Get unique departments and positions
const departments = Array.from(new Set(employees.map(e => e.department).filter(Boolean)))
const departments = Array.from(new Set(
employees.map(e => normalizeDepartment(e.department)).filter(Boolean)
))
const positions = Array.from(new Set(employees.map(e => e.position).filter(Boolean)))
// Toggle category selection
@ -241,7 +244,7 @@ export default function TeamZusammenstellung() {
// Department filter
if (selectedDepartment) {
filtered = filtered.filter(emp => emp.department === selectedDepartment)
filtered = filtered.filter(emp => normalizeDepartment(emp.department) === selectedDepartment)
}
// Position filter
@ -652,7 +655,9 @@ export default function TeamZusammenstellung() {
filteredEmployees.map((employee: any) => {
const isAssigned = teamPositions.some(p => p.assignedEmployeeId === employee.id)
const matchScore = employee.matchScore || 0
const departmentInfo = formatDepartmentWithDescription(employee.department, employee.departmentDescription)
const departmentText = departmentInfo.description ? `${departmentInfo.label}${departmentInfo.description}` : departmentInfo.label
return (
<div
key={employee.id}
@ -690,7 +695,7 @@ export default function TeamZusammenstellung() {
<div className="text-sm text-tertiary">
{employee.position}
{employee.officialTitle ? `${employee.officialTitle}` : ''}
{`${employee.department}`}
{departmentText ? `${departmentText}` : ''}
</div>
</div>

86
install.ps1 Normale Datei
Datei anzeigen

@ -0,0 +1,86 @@
<#
SkillMate installer for Windows PowerShell.
Installs dependencies for backend, frontend, admin panel and creates a backend .env with dev defaults.
#>
param(
[switch]$SkipBuild
)
$ErrorActionPreference = 'Stop'
function New-RandomHex([int]$length) {
if ($length % 2 -ne 0) { throw "Length must be even" }
$bytes = New-Object byte[] ($length / 2)
[System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
return ($bytes | ForEach-Object { $_.ToString('x2') }) -join ''
}
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Write-Host "==> Installing SkillMate dependencies..." -ForegroundColor Cyan
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
throw 'Node.js is required but was not found in PATH. Install Node.js LTS first.'
}
$npmVersion = (& npm --version)
Write-Host " npm version $npmVersion detected." -ForegroundColor DarkGray
$projects = @('backend', 'frontend', 'admin-panel')
foreach ($project in $projects) {
$projectPath = Join-Path $scriptRoot $project
if (-not (Test-Path $projectPath)) {
throw "Project folder '$project' not found."
}
Write-Host "--> Installing dependencies in $project..." -ForegroundColor Cyan
Push-Location $projectPath
try {
if (Test-Path 'package-lock.json') {
npm ci
} else {
npm install
}
} finally {
Pop-Location
}
}
$envPath = Join-Path $scriptRoot 'backend/.env'
if (-not (Test-Path $envPath)) {
Write-Host '--> Creating backend/.env with development defaults...' -ForegroundColor Cyan
$fieldKey = New-RandomHex 64
$dbKey = New-RandomHex 64
@(
'NODE_ENV=development'
'PORT=3004'
"FIELD_ENCRYPTION_KEY=$fieldKey"
"DATABASE_ENCRYPTION_KEY=$dbKey"
'FRONTEND_URL=http://localhost:5173'
'ADMIN_PANEL_URL=http://localhost:5174'
'NODE_TYPE=admin'
) | Set-Content -Path $envPath -Encoding utf8
}
if (-not $SkipBuild) {
Write-Host '==> Building frontend and admin panel...' -ForegroundColor Cyan
foreach ($project in @('frontend', 'admin-panel')) {
Push-Location (Join-Path $scriptRoot $project)
try {
npm run build
} finally {
Pop-Location
}
}
Write-Host '==> Building backend...' -ForegroundColor Cyan
Push-Location (Join-Path $scriptRoot 'backend')
try {
npm run build
} finally {
Pop-Location
}
} else {
Write-Host '!! SkipBuild set: build steps skipped.' -ForegroundColor DarkYellow
}
Write-Host "Installation complete. Use run-dev.cmd or run-prod.cmd to start services." -ForegroundColor Green

Datei anzeigen

@ -216,9 +216,12 @@ class SkillMateStarter:
if admin_dir.exists():
print(f" - Admin: http://localhost:{self.admin_port}")
# Öffne Frontend im Browser
# Öffne Frontend und Admin Panel im Browser
time.sleep(3)
webbrowser.open(f"http://localhost:{self.frontend_port}")
if (self.base_dir / "admin-panel").exists():
time.sleep(1)
webbrowser.open(f"http://localhost:{self.admin_port}")
print("\n⚡ Schließen Sie dieses Fenster, um SkillMate zu beenden")

44
shared/index.d.ts vendored
Datei anzeigen

@ -12,6 +12,11 @@ export interface User {
isActive: boolean
createdAt: Date
updatedAt: Date
powerUnitId?: string | null
powerUnitName?: string | null
powerUnitType?: OrganizationalUnitType | null
powerFunction?: PowerFunction | null
canManageEmployees?: boolean
}
export interface Skill {
@ -39,6 +44,15 @@ export interface Clearance {
issuedDate: Date
}
export type PowerFunction = 'sachgebietsleitung' | 'stellvertretende_sachgebietsleitung' | 'ermittlungskommissionsleitung' | 'dezernatsleitung' | 'abteilungsleitung'
export interface PowerFunctionDefinition {
id: PowerFunction
label: string
unitTypes: OrganizationalUnitType[]
canManageEmployees: boolean
}
export interface Employee {
id: string
firstName: string
@ -48,6 +62,11 @@ export interface Employee {
position: string
officialTitle?: string | null
department: string
departmentDescription?: string | null
departmentTasks?: string | null
primaryUnitId?: string | null
primaryUnitCode?: string | null
primaryUnitName?: string | null
email?: string | null
phone?: string | null
mobile?: string | null
@ -57,12 +76,23 @@ export interface Employee {
languages?: LanguageSkill[]
clearance?: Clearance
specializations?: string[]
currentDeputies?: EmployeeDeputySummary[]
represents?: EmployeeDeputySummary[]
createdAt: Date | string
updatedAt: Date | string
createdBy?: string
updatedBy?: string | null
}
export interface EmployeeDeputySummary {
id: string
firstName: string
lastName: string
availability?: string | null
position?: string | null
assignmentId?: string
}
export interface SkillDefinition {
id: string
name: string
@ -89,6 +119,8 @@ export interface LoginResponse {
export const ROLE_PERMISSIONS: Record<UserRole, string[]>
export const POWER_FUNCTIONS: PowerFunctionDefinition[]
export const DEFAULT_SKILLS: Record<string, string[]>
export const LANGUAGE_LEVELS: string[]
export interface SkillLevel { id: string; name: string; level?: string }
@ -122,7 +154,7 @@ export interface WorkspaceFilter {
}
// Organization
export type OrganizationalUnitType = 'direktion' | 'abteilung' | 'dezernat' | 'sachgebiet' | 'teildezernat' | 'fuehrungsstelle' | 'stabsstelle' | 'sondereinheit'
export type OrganizationalUnitType = 'direktion' | 'abteilung' | 'dezernat' | 'sachgebiet' | 'teildezernat' | 'fuehrungsstelle' | 'stabsstelle' | 'sondereinheit' | 'ermittlungskommission'
export type EmployeeUnitRole = 'leiter' | 'stellvertreter' | 'mitarbeiter' | 'beauftragter'
export interface OrganizationalUnit {
@ -216,3 +248,13 @@ export interface BookingRequest {
endTime: string
notes?: string
}
declare const shared: {
ROLE_PERMISSIONS: typeof ROLE_PERMISSIONS
POWER_FUNCTIONS: typeof POWER_FUNCTIONS
DEFAULT_SKILLS: typeof DEFAULT_SKILLS
LANGUAGE_LEVELS: typeof LANGUAGE_LEVELS
SKILL_HIERARCHY: typeof SKILL_HIERARCHY
}
export default shared

Datei anzeigen

@ -1,5 +1,13 @@
// Runtime constants and helpers shared across projects
const POWER_FUNCTIONS = [
{ id: 'sachgebietsleitung', label: 'Sachgebietsleitung', unitTypes: ['sachgebiet'], canManageEmployees: true },
{ id: 'stellvertretende_sachgebietsleitung', label: 'Stellvertretende Sachgebietsleitung', unitTypes: ['sachgebiet'], canManageEmployees: true },
{ id: 'ermittlungskommissionsleitung', label: 'Ermittlungskommissionsleitung', unitTypes: ['ermittlungskommission'], canManageEmployees: true },
{ id: 'dezernatsleitung', label: 'Dezernatsleitung', unitTypes: ['dezernat'], canManageEmployees: false },
{ id: 'abteilungsleitung', label: 'Abteilungsleitung', unitTypes: ['abteilung'], canManageEmployees: false }
]
const ROLE_PERMISSIONS = {
admin: [
'admin:panel:access',
@ -30,36 +38,41 @@ const ROLE_PERMISSIONS = {
]
}
module.exports = {
ROLE_PERMISSIONS,
DEFAULT_SKILLS: {
general: [
'Teamarbeit',
'Kommunikation',
'Projektmanagement'
],
it: [
'JavaScript',
'TypeScript',
'Node.js',
'SQL'
],
certificates: [
'Erste Hilfe',
'Brandschutzhelfer'
],
weapons: [
'WBK A',
'WBK B'
]
}
const DEFAULT_SKILLS = {
general: [
'Teamarbeit',
'Kommunikation',
'Projektmanagement'
],
it: [
'JavaScript',
'TypeScript',
'Node.js',
'SQL'
],
certificates: [
'Erste Hilfe',
'Brandschutzhelfer'
],
weapons: [
'WBK A',
'WBK B'
]
}
exports.ROLE_PERMISSIONS = ROLE_PERMISSIONS
exports.POWER_FUNCTIONS = POWER_FUNCTIONS
exports.DEFAULT_SKILLS = DEFAULT_SKILLS
// Re-export skill constants
try {
const { LANGUAGE_LEVELS, SKILL_HIERARCHY } = require('./skills')
module.exports.LANGUAGE_LEVELS = LANGUAGE_LEVELS
module.exports.SKILL_HIERARCHY = SKILL_HIERARCHY
exports.LANGUAGE_LEVELS = LANGUAGE_LEVELS
exports.SKILL_HIERARCHY = SKILL_HIERARCHY
} catch (e) {
// no-op if skills.js not present
}
module.exports = exports
exports.__esModule = true
exports.default = exports

240
shared/index.mjs Normale Datei
Datei anzeigen

@ -0,0 +1,240 @@
export const POWER_FUNCTIONS = [
{ id: 'sachgebietsleitung', label: 'Sachgebietsleitung', unitTypes: ['sachgebiet'], canManageEmployees: true },
{ id: 'stellvertretende_sachgebietsleitung', label: 'Stellvertretende Sachgebietsleitung', unitTypes: ['sachgebiet'], canManageEmployees: true },
{ id: 'ermittlungskommissionsleitung', label: 'Ermittlungskommissionsleitung', unitTypes: ['ermittlungskommission'], canManageEmployees: true },
{ id: 'dezernatsleitung', label: 'Dezernatsleitung', unitTypes: ['dezernat'], canManageEmployees: false },
{ id: 'abteilungsleitung', label: 'Abteilungsleitung', unitTypes: ['abteilung'], canManageEmployees: false }
]
export const ROLE_PERMISSIONS = {
admin: [
'admin:panel:access',
'users:create',
'users:read',
'users:update',
'users:delete',
'employees:create',
'settings:read',
'settings:update',
'employees:read',
'employees:update',
'skills:read',
'skills:update'
],
superuser: [
'admin:panel:access',
'users:read',
'employees:create',
'employees:read',
'employees:update',
'skills:read',
'skills:update'
],
user: [
'employees:read',
'skills:read'
]
}
export const DEFAULT_SKILLS = {
general: [
'Teamarbeit',
'Kommunikation',
'Projektmanagement'
],
it: [
'JavaScript',
'TypeScript',
'Node.js',
'SQL'
],
certificates: [
'Erste Hilfe',
'Brandschutzhelfer'
],
weapons: [
'WBK A',
'WBK B'
]
}
export const LANGUAGE_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2', 'Muttersprache']
export const SKILL_HIERARCHY = [
{
id: 'communication',
name: 'Kommunikative Fähigkeiten',
subcategories: [
{
id: 'languages',
name: 'Fremdsprachenkenntnisse',
skills: [
{ id: 'de', name: 'Deutsch' },
{ id: 'en', name: 'Englisch' },
{ id: 'fr', name: 'Französisch' },
{ id: 'es', name: 'Spanisch' },
{ id: 'it', name: 'Italienisch' },
{ id: 'ru', name: 'Russisch' },
{ id: 'ar', name: 'Arabisch' },
{ id: 'tr', name: 'Türkisch' },
{ id: 'pl', name: 'Polnisch' },
{ id: 'zh', name: 'Chinesisch' },
{ id: 'fa', name: 'Farsi/Persisch' }
]
},
{
id: 'interpersonal',
name: 'Zwischenmenschliche Fähigkeiten',
skills: [
{ id: 'negotiation', name: 'Verhandlungsführung' },
{ id: 'presentation', name: 'Präsentationstechnik' },
{ id: 'teamwork', name: 'Teamfähigkeit' },
{ id: 'leadership', name: 'Führungskompetenz' },
{ id: 'conflict', name: 'Konfliktmanagement' }
]
}
]
},
{
id: 'technical',
name: 'Technische Fähigkeiten',
subcategories: [
{
id: 'it_general',
name: 'IT-Grundkenntnisse',
skills: [
{ id: 'office', name: 'MS Office' },
{ id: 'windows', name: 'Windows Administration' },
{ id: 'linux', name: 'Linux Administration' },
{ id: 'networks', name: 'Netzwerktechnik' }
]
},
{
id: 'programming',
name: 'Programmierung',
skills: [
{ id: 'python', name: 'Python' },
{ id: 'java', name: 'Java' },
{ id: 'javascript', name: 'JavaScript' },
{ id: 'sql', name: 'SQL/Datenbanken' },
{ id: 'r', name: 'R' }
]
},
{
id: 'security',
name: 'IT-Sicherheit',
skills: [
{ id: 'forensics', name: 'Digitale Forensik' },
{ id: 'malware', name: 'Malware-Analyse' },
{ id: 'crypto', name: 'Kryptographie' },
{ id: 'pentest', name: 'Penetrationstests' },
{ id: 'siem', name: 'SIEM-Systeme' }
]
}
]
},
{
id: 'operational',
name: 'Operative Fähigkeiten',
subcategories: [
{
id: 'investigation',
name: 'Ermittlungstechniken',
skills: [
{ id: 'surveillance', name: 'Observationstechnik' },
{ id: 'undercover', name: 'Verdeckte Ermittlung' },
{ id: 'interrogation', name: 'Vernehmungsführung' },
{ id: 'evidence', name: 'Spurensicherung' },
{ id: 'scene', name: 'Tatortarbeit' }
]
},
{
id: 'tactical',
name: 'Taktische Fähigkeiten',
skills: [
{ id: 'planning', name: 'Einsatzplanung' },
{ id: 'access', name: 'Zugriffstechniken' },
{ id: 'protection', name: 'Personenschutz' },
{ id: 'crisis', name: 'Krisenmanagement' },
{ id: 'firstaid', name: 'Erste Hilfe' }
]
}
]
},
{
id: 'analytical',
name: 'Analytische Fähigkeiten',
subcategories: [
{
id: 'data_analysis',
name: 'Datenanalyse',
skills: [
{ id: 'statistics', name: 'Statistische Analyse' },
{ id: 'osint', name: 'OSINT-Techniken' },
{ id: 'social_media', name: 'Social Media Analyse' },
{ id: 'financial', name: 'Finanzermittlungen' },
{ id: 'network_analysis', name: 'Netzwerkanalyse' }
]
},
{
id: 'intelligence',
name: 'Nachrichtendienstliche Analyse',
skills: [
{ id: 'threat', name: 'Gefährdungsbewertung' },
{ id: 'profiling', name: 'Profiling' },
{ id: 'pattern', name: 'Mustererkennung' },
{ id: 'risk', name: 'Risikoanalyse' },
{ id: 'forecasting', name: 'Prognosemodelle' }
]
}
]
},
{
id: 'certifications',
name: 'Zertifizierungen & Berechtigungen',
subcategories: [
{
id: 'security_clearance',
name: 'Sicherheitsüberprüfungen',
skills: [
{ id: 'ue1', name: 'Sicherheitsüberprüfung Ü1' },
{ id: 'ue2', name: 'Sicherheitsüberprüfung Ü2' },
{ id: 'ue3', name: 'Sicherheitsüberprüfung Ü3' }
]
},
{
id: 'weapons',
name: 'Waffen & Ausrüstung',
skills: [
{ id: 'weapons_cert', name: 'Waffensachkunde' },
{ id: 'pistol', name: 'Schießausbildung Pistole' },
{ id: 'rifle', name: 'Schießausbildung Gewehr' },
{ id: 'mp', name: 'Schießausbildung MP' },
{ id: 'sniper', name: 'Scharfschützenausbildung' }
]
},
{
id: 'vehicles',
name: 'Fahrzeuge & Transport',
skills: [
{ id: 'car_b', name: 'Führerschein Klasse B' },
{ id: 'car_c', name: 'Führerschein Klasse C' },
{ id: 'car_ce', name: 'Führerschein Klasse CE' },
{ id: 'motorcycle', name: 'Führerschein Klasse A' },
{ id: 'boat', name: 'Bootsführerschein' },
{ id: 'pilot', name: 'Flugschein PPL' }
]
}
]
}
]
const shared = {
ROLE_PERMISSIONS,
POWER_FUNCTIONS,
DEFAULT_SKILLS,
LANGUAGE_LEVELS,
SKILL_HIERARCHY
}
export default shared

Datei anzeigen

@ -4,6 +4,12 @@
"private": true,
"main": "index.js",
"types": "index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.mjs",
"require": "./index.js"
}
},
"license": "UNLICENSED"
}