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", "postcss": "^8.4.32",
"tailwindcss": "^3.3.6", "tailwindcss": "^3.3.6",
"typescript": "^5.3.0", "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 EmailSettings from './views/EmailSettings'
import SyncSettings from './views/SyncSettings' import SyncSettings from './views/SyncSettings'
import OrganizationEditor from './views/OrganizationEditor' import OrganizationEditor from './views/OrganizationEditor'
import OfficialTitles from './views/OfficialTitles'
import Positions from './views/Positions'
import { useEffect } from 'react' import { useEffect } from 'react'
function App() { function App() {
@ -40,6 +42,8 @@ function App() {
<Route path="/users" element={<UserManagement />} /> <Route path="/users" element={<UserManagement />} />
<Route path="/users/create-employee" element={<CreateEmployee />} /> <Route path="/users/create-employee" element={<CreateEmployee />} />
<Route path="/email-settings" element={<EmailSettings />} /> <Route path="/email-settings" element={<EmailSettings />} />
<Route path="/positions" element={<Positions />} />
<Route path="/official-titles" element={<OfficialTitles />} />
<Route path="/sync" element={<SyncSettings />} /> <Route path="/sync" element={<SyncSettings />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

Datei anzeigen

@ -1,5 +1,5 @@
import { ReactNode } from 'react' import { ComponentType, ReactNode, SVGProps, useEffect, useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom' import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
import { import {
HomeIcon, HomeIcon,
@ -7,24 +7,68 @@ import {
SettingsIcon, SettingsIcon,
MailIcon MailIcon
} from './icons' } from './icons'
import { Building2 } from 'lucide-react' import { Building2, ChevronRight } from 'lucide-react'
interface LayoutProps { interface LayoutProps {
children: ReactNode 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: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Organigramm', href: '/organization', icon: Building2 },
{ name: 'Benutzerverwaltung', href: '/users', icon: UsersIcon }, { 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: '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) { export default function Layout({ children }: LayoutProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuthStore() 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 = () => { const handleLogout = () => {
logout() logout()
@ -41,7 +85,45 @@ export default function Layout({ children }: LayoutProps) {
</div> </div>
<nav className="px-5 space-y-1"> <nav className="px-5 space-y-1">
{navigation.map((item) => ( {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 <NavLink
key={item.name} key={item.name}
to={item.href} to={item.href}
@ -52,7 +134,8 @@ export default function Layout({ children }: LayoutProps) {
<item.icon className="w-5 h-5 mr-3 flex-shrink-0" /> <item.icon className="w-5 h-5 mr-3 flex-shrink-0" />
<span className="font-poppins font-medium">{item.name}</span> <span className="font-poppins font-medium">{item.name}</span>
</NavLink> </NavLink>
))} )
})}
</nav> </nav>
<div className="absolute bottom-0 left-0 right-0 p-5 border-t border-border-default"> <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 { ChevronRight, Building2, Search, X } from 'lucide-react'
import { api } from '../services/api' import { api } from '../services/api'
interface SelectedUnitDetails {
unit: OrganizationalUnit
storageValue: string
codePath: string
displayPath: string
namesPath: string
descriptionPath?: string
tasks?: string
}
interface OrganizationSelectorProps { interface OrganizationSelectorProps {
value?: string | null value?: string | null
onChange: (unitId: string | null, unitName: string) => void onChange: (unitId: string | null, formattedValue: string, details?: SelectedUnitDetails | null) => void
disabled?: boolean disabled?: boolean
} }
@ -28,9 +38,9 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
if (value && hierarchy.length > 0) { if (value && hierarchy.length > 0) {
const unit = findUnitById(hierarchy, value) const unit = findUnitById(hierarchy, value)
if (unit) { if (unit) {
const path = getUnitPath(hierarchy, unit) const selection = buildSelectionDetails(hierarchy, unit)
setSelectedUnit(unit) setSelectedUnit(unit)
setCurrentUnitName(path) setCurrentUnitName(selection.displayPath)
} }
} }
if (!value) { if (!value) {
@ -75,23 +85,63 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
return null return null
} }
const getUnitPath = (units: OrganizationalUnit[], target: OrganizationalUnit): string => { const buildSelectionDetails = (units: OrganizationalUnit[], target: OrganizationalUnit): SelectedUnitDetails => {
const path: string[] = [] const pathUnits: OrganizationalUnit[] = []
const traverse = (nodes: OrganizationalUnit[], trail: string[] = []): boolean => {
const traverse = (nodes: OrganizationalUnit[], trail: OrganizationalUnit[] = []): boolean => {
for (const node of nodes) { for (const node of nodes) {
const currentTrail = [...trail, node.name] const extended = [...trail, node]
if (node.id === target.id) { if (node.id === target.id) {
path.push(...currentTrail) pathUnits.splice(0, pathUnits.length, ...extended)
return true return true
} }
if (node.children && traverse(node.children, currentTrail)) { if (node.children && traverse(node.children, extended)) {
return true return true
} }
} }
return false return false
} }
traverse(units) 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) => { const toggleExpand = (unitId: string) => {
@ -110,13 +160,13 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
if (!unit) { if (!unit) {
setSelectedUnit(null) setSelectedUnit(null)
setCurrentUnitName('') setCurrentUnitName('')
onChange(null, '') onChange(null, '', null)
return return
} }
const selection = buildSelectionDetails(hierarchy, unit)
setSelectedUnit(unit) setSelectedUnit(unit)
const path = getUnitPath(hierarchy, unit) setCurrentUnitName(selection.displayPath)
setCurrentUnitName(path) onChange(unit.id, selection.storageValue, selection)
onChange(unit.id, path)
setShowModal(false) setShowModal(false)
} }

Datei anzeigen

@ -37,3 +37,35 @@ 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 { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { api } from '../services/api' 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' import { OrganizationSelector } from '../components'
interface CreateEmployeeData { interface CreateEmployeeData {
@ -12,6 +13,7 @@ interface CreateEmployeeData {
department: string department: string
userRole: 'admin' | 'superuser' | 'user' userRole: 'admin' | 'superuser' | 'user'
createUser: boolean createUser: boolean
powerFunction: string
} }
export default function CreateEmployee() { export default function CreateEmployee() {
@ -22,22 +24,57 @@ export default function CreateEmployee() {
const [createdUser, setCreatedUser] = useState<{ password?: string }>({}) const [createdUser, setCreatedUser] = useState<{ password?: string }>({})
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null) const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null)
const [selectedUnitName, setSelectedUnitName] = useState('') const [selectedUnitName, setSelectedUnitName] = useState('')
const [selectedUnitType, setSelectedUnitType] = useState<OrganizationalUnitType | null>(null)
const [selectedUnitRole, setSelectedUnitRole] = useState<EmployeeUnitRole>('mitarbeiter') 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: { defaultValues: {
userRole: 'user', userRole: 'user',
createUser: true createUser: true,
powerFunction: ''
} }
}) })
const watchCreateUser = watch('createUser') 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(() => { useEffect(() => {
if (selectedUnitName) { if (selectedUnitName) {
setValue('department', 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 => { const getUnitRoleLabel = (role: EmployeeUnitRole): string => {
switch (role) { switch (role) {
@ -53,11 +90,35 @@ export default function CreateEmployee() {
} }
const onSubmit = async (data: CreateEmployeeData) => { const onSubmit = async (data: CreateEmployeeData) => {
try {
setLoading(true)
setError('') setError('')
setSuccess('') 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)
const payload = { const payload = {
firstName: data.firstName, firstName: data.firstName,
lastName: data.lastName, lastName: data.lastName,
@ -65,8 +126,10 @@ export default function CreateEmployee() {
department: data.department, department: data.department,
userRole: data.createUser ? data.userRole : undefined, userRole: data.createUser ? data.userRole : undefined,
createUser: data.createUser, createUser: data.createUser,
organizationUnitId: selectedUnitId || undefined, // employees API expects primaryUnitId + assignmentRole
organizationRole: selectedUnitId ? selectedUnitRole : undefined primaryUnitId: selectedUnitId || undefined,
assignmentRole: selectedUnitId ? selectedUnitRole : undefined,
powerFunction: requiresPowerMetadata ? data.powerFunction : undefined
} }
const response = await api.post('/employees', payload) const response = await api.post('/employees', payload)
@ -96,7 +159,7 @@ export default function CreateEmployee() {
const getRoleDescription = (role: UserRole): string => { const getRoleDescription = (role: UserRole): string => {
const descriptions = { const descriptions = {
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung', 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' user: 'Kann nur das eigene Profil bearbeiten und Mitarbeitende durchsuchen'
} }
return descriptions[role] return descriptions[role]
@ -223,9 +286,6 @@ export default function CreateEmployee() {
placeholder="IT, Personal, Marketing, etc." placeholder="IT, Personal, Marketing, etc."
readOnly={Boolean(selectedUnitId)} readOnly={Boolean(selectedUnitId)}
/> />
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div> </div>
</div> </div>
</div> </div>
@ -242,17 +302,29 @@ export default function CreateEmployee() {
</label> </label>
<OrganizationSelector <OrganizationSelector
value={selectedUnitId} value={selectedUnitId}
onChange={(unitId, unitPath) => { onChange={(unitId, formattedValue, details) => {
setSelectedUnitId(unitId) setSelectedUnitId(unitId)
setSelectedUnitName(unitPath) setSelectedUnitName(details?.displayPath || formattedValue)
setSelectedUnitType(details?.unit.type || null)
if (!unitId) { if (!unitId) {
setSelectedUnitRole('mitarbeiter') 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"> <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. Wählen Sie die primäre Organisationseinheit für die neue Person. Die Abteilung wird automatisch anhand der Auswahl gesetzt.
</p> </p>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div> </div>
{selectedUnitId && ( {selectedUnitId && (
@ -315,10 +387,51 @@ export default function CreateEmployee() {
<option value="admin">Administrator</option> <option value="admin">Administrator</option>
</select> </select>
<p className="text-sm text-secondary-light mt-2"> <p className="text-sm text-secondary-light mt-2">
{getRoleDescription(watch('userRole'))} {getRoleDescription(watchUserRole)}
</p> </p>
</div> </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>
</div> </div>

Datei anzeigen

@ -1,9 +1,18 @@
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { api } from '../services/api' import { api, positionsApi } from '../services/api'
import type { UserRole } from '@skillmate/shared' import type { UserRole } from '@skillmate/shared'
const DEFAULT_POSITION_OPTIONS = [
'Sachbearbeitung',
'stellvertretende Sachgebietsleitung',
'Sachgebietsleitung',
'Dezernatsleitung',
'Abteilungsleitung',
'Behördenleitung'
]
interface EmployeeFormData { interface EmployeeFormData {
firstName: string firstName: string
lastName: string lastName: string
@ -28,6 +37,7 @@ export default function EmployeeFormComplete() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const [createdUser, setCreatedUser] = useState<{password?: string}>({}) const [createdUser, setCreatedUser] = useState<{password?: string}>({})
const [positionOptions, setPositionOptions] = useState<string[]>(DEFAULT_POSITION_OPTIONS)
const { register, handleSubmit, formState: { errors }, reset, watch } = useForm<EmployeeFormData>({ const { register, handleSubmit, formState: { errors }, reset, watch } = useForm<EmployeeFormData>({
defaultValues: { defaultValues: {
@ -38,6 +48,20 @@ export default function EmployeeFormComplete() {
}) })
const watchCreateUser = watch('createUser') 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(() => { useEffect(() => {
if (isEdit) { if (isEdit) {
@ -277,11 +301,18 @@ export default function EmployeeFormComplete() {
<label className="block text-body font-medium text-secondary mb-2"> <label className="block text-body font-medium text-secondary mb-2">
Position * Position *
</label> </label>
<input <select
{...register('position', { required: 'Position ist erforderlich' })} {...register('position', { required: 'Position ist erforderlich' })}
className="input-field w-full" 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 && ( {errors.position && (
<p className="text-error text-sm mt-1">{errors.position.message}</p> <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, { import ReactFlow, {
Node, Node,
Edge, Edge,
Controls, Controls,
Background, Background,
BackgroundVariant,
MiniMap, MiniMap,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
@ -15,7 +16,7 @@ import ReactFlow, {
} from 'reactflow' } from 'reactflow'
import 'reactflow/dist/style.css' import 'reactflow/dist/style.css'
import { api } from '../services/api' import { api } from '../services/api'
import { OrganizationalUnit, OrganizationalUnitType } from '@skillmate/shared' import { OrganizationalUnitType } from '@skillmate/shared'
import { Upload } from 'lucide-react' import { Upload } from 'lucide-react'
// Custom Node Component (vorheriger Look, aber mit soliden dezenten Farben) // Custom Node Component (vorheriger Look, aber mit soliden dezenten Farben)
@ -27,6 +28,7 @@ const OrganizationNode = ({ data }: { data: any }) => {
dezernat: '📁', dezernat: '📁',
sachgebiet: '📋', sachgebiet: '📋',
teildezernat: '🔧', teildezernat: '🔧',
ermittlungskommission: '🕵️',
fuehrungsstelle: '⭐', fuehrungsstelle: '⭐',
stabsstelle: '🎯', stabsstelle: '🎯',
sondereinheit: '🛡️' sondereinheit: '🛡️'
@ -41,6 +43,7 @@ const OrganizationNode = ({ data }: { data: any }) => {
case 'dezernat': return '#1D4ED8' // blue-700 case 'dezernat': return '#1D4ED8' // blue-700
case 'sachgebiet': return '#64748B' // slate-500 case 'sachgebiet': return '#64748B' // slate-500
case 'teildezernat': return '#64748B' // slate-500 case 'teildezernat': return '#64748B' // slate-500
case 'ermittlungskommission': return '#6366f1' // indigo-500
case 'stabsstelle': return '#374151' // gray-700 case 'stabsstelle': return '#374151' // gray-700
case 'sondereinheit': return '#475569' // slate-600 case 'sondereinheit': return '#475569' // slate-600
default: return '#334155' default: return '#334155'
@ -85,6 +88,41 @@ const nodeTypes: NodeTypes = {
organization: OrganizationNode 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() { export default function OrganizationEditor() {
const [nodes, setNodes, onNodesChange] = useNodesState([]) const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([]) const [edges, setEdges, onEdgesChange] = useEdgesState([])
@ -109,6 +147,79 @@ export default function OrganizationEditor() {
parentId: '', parentId: '',
description: '' 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 // Load organizational units
useEffect(() => { useEffect(() => {
@ -126,12 +237,14 @@ export default function OrganizationEditor() {
setNodes(flowNodes) setNodes(flowNodes)
setEdges(flowEdges) setEdges(flowEdges)
computeIssues(flowNodes) computeIssues(flowNodes)
return { nodes: flowNodes, edges: flowEdges }
} }
} catch (error) { } catch (error) {
console.error('Failed to load organization:', error) console.error('Failed to load organization:', error)
} finally { } finally {
setLoading(false) setLoading(false)
} }
return null
} }
const convertToFlowElements = (units: any[], parentPosition = { x: 0, y: 0 }, level = 0): { nodes: Node[], edges: Edge[] } => { 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) // Compute simple validation issues (orphans)
const computeIssues = (flowNodes: Node[]) => { const computeIssues = (flowNodes: Node[]) => {
const ids = new Set(flowNodes.map(n => n.id)) const ids = new Set(flowNodes.map(n => n.id))
@ -590,6 +801,7 @@ export default function OrganizationEditor() {
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect} onConnect={onConnect}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStop={onNodeDragStop} onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
@ -611,7 +823,7 @@ export default function OrganizationEditor() {
return (type && map[type]) || '#94a3b8' 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"> <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> <h2 className="text-xl font-bold mb-4">LKA NRW Organigramm</h2>
@ -654,6 +866,12 @@ export default function OrganizationEditor() {
{selectedNode.data.description && ( {selectedNode.data.description && (
<p className="text-sm mt-2">{selectedNode.data.description}</p> <p className="text-sm mt-2">{selectedNode.data.description}</p>
)} )}
<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> <p className="text-gray-500">Klicken Sie auf eine Einheit für Details</p>
@ -812,6 +1030,158 @@ export default function OrganizationEditor() {
</div> </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 */} {/* Add Unit Dialog */}
{showAddDialog && ( {showAddDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">

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 { useNavigate } from 'react-router-dom'
import { api } from '../services/api' 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 { TrashIcon, ShieldIcon, KeyIcon } from '../components/icons'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
import { OrganizationSelector } from '../components'
interface UserWithEmployee extends User { interface UserWithEmployee extends User {
employeeName?: string employeeName?: string
@ -17,6 +19,11 @@ export default function UserManagement() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [editingUser, setEditingUser] = useState<string | null>(null) const [editingUser, setEditingUser] = useState<string | null>(null)
const [editRole, setEditRole] = useState<UserRole>('user') 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 [resetPasswordUser, setResetPasswordUser] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@ -45,7 +52,6 @@ export default function UserManagement() {
}) })
setUsers(enrichedUsers) setUsers(enrichedUsers)
setEmployees(employeesData)
} catch (err: any) { } catch (err: any) {
console.error('Failed to fetch users:', err) console.error('Failed to fetch users:', err)
setError('Benutzer konnten nicht geladen werden') setError('Benutzer konnten nicht geladen werden')
@ -54,8 +60,6 @@ export default function UserManagement() {
} }
} }
const [employees, setEmployees] = useState<any[]>([])
// Import state // Import state
type ImportRow = { firstName: string; lastName: string; email: string; department: string } type ImportRow = { firstName: string; lastName: string; email: string; department: string }
const [dragActive, setDragActive] = useState(false) const [dragActive, setDragActive] = useState(false)
@ -76,13 +80,70 @@ export default function UserManagement() {
// Store temporary passwords per user to show + email // Store temporary passwords per user to show + email
const [tempPasswords, setTempPasswords] = useState<Record<string, { password: string }>>({}) 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 // removed legacy creation helpers for employees without user accounts
const handleRoleChange = async (userId: string) => { 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 { 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() await fetchUsers()
setEditingUser(null) setEditingUser(null)
setEditPowerUnitId(null)
setEditPowerUnitName('')
setEditPowerUnitType(null)
setEditPowerFunction('')
} catch (err: any) { } catch (err: any) {
setError('Rolle konnte nicht geändert werden') setError('Rolle konnte nicht geändert werden')
} }
@ -339,12 +400,20 @@ export default function UserManagement() {
<span className="text-tertiary italic">Nicht verknüpft</span> <span className="text-tertiary italic">Nicht verknüpft</span>
)} )}
</td> </td>
<td className="py-4 px-4"> <td className="py-4 px-4 align-top">
{editingUser === user.id ? ( {editingUser === user.id ? (
<div className="space-y-3">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<select <select
value={editRole} value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)} onChange={(e) => {
const nextRole = e.target.value as UserRole
setEditRole(nextRole)
setRoleValidationError('')
if (nextRole !== 'superuser') {
setEditPowerFunction('')
}
}}
className="input-field py-1 text-sm" className="input-field py-1 text-sm"
> >
<option value="user">Benutzer</option> <option value="user">Benutzer</option>
@ -358,16 +427,105 @@ export default function UserManagement() {
</button> </button>
<button <button
onClick={() => setEditingUser(null)} onClick={() => {
setEditingUser(null)
setEditPowerUnitId(null)
setEditPowerUnitName('')
setEditPowerUnitType(null)
setEditPowerFunction('')
setRoleValidationError('')
}}
className="text-error hover:text-red-700" className="text-error hover:text-red-700"
> >
</button> </button>
</div> </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>
) : ( ) : (
<div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{getRoleLabel(user.role)} {getRoleLabel(user.role)}
</span> </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>
<td className="py-4 px-4"> <td className="py-4 px-4">
@ -389,6 +547,11 @@ export default function UserManagement() {
onClick={() => { onClick={() => {
setEditingUser(user.id) setEditingUser(user.id)
setEditRole(user.role) 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" className="p-1 text-secondary hover:text-primary-blue transition-colors"
title="Rolle bearbeiten" title="Rolle bearbeiten"
@ -483,7 +646,7 @@ export default function UserManagement() {
</h3> </h3>
<ul className="space-y-2 text-body text-secondary"> <ul className="space-y-2 text-body text-secondary">
<li> <strong>Administrator:</strong> Vollzugriff auf alle Funktionen und Einstellungen</li> <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> <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> Neue Benutzer können über den Import oder die Mitarbeitendenverwaltung angelegt werden</li>
<li> Der Admin-Benutzer kann nicht gelöscht 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' import path from 'path'
export default defineConfig({ export default defineConfig({
// Use relative asset paths so the build works when deployed under a subdirectory
base: './',
plugins: [react()], plugins: [react()],
server: { server: {
port: Number(process.env.VITE_PORT || 5174), port: Number(process.env.VITE_PORT || 5174),

Datei anzeigen

@ -3,32 +3,43 @@
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const vm = require('vm')
const Database = require('better-sqlite3') 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') 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') const src = fs.readFileSync(tsPath, 'utf8')
// Remove interface declarations and LANGUAGE_LEVELS export, keep the array literal
let code = src let code = src
.replace(/export interface[\s\S]*?\n\}/g, '') .replace(/export interface[\s\S]*?\n\}/g, '')
.replace(/export const LANGUAGE_LEVELS[\s\S]*?\n\n/, '') .replace(/export const LANGUAGE_LEVELS[\s\S]*?\n\n/, '')
.replace(/export const SKILL_HIERARCHY:[^=]*=/, 'module.exports =') .replace(/export const SKILL_HIERARCHY:[^=]*=/, 'module.exports =')
const sandbox = { module: {}, exports: {} } const sandbox = { module: {}, exports: {} }
vm.createContext(sandbox) require('vm').runInNewContext(code, sandbox)
vm.runInContext(code, sandbox) const hierarchy = sandbox.module?.exports || sandbox.exports
return sandbox.module.exports || sandbox.exports if (!Array.isArray(hierarchy)) {
throw new Error('Parsed hierarchy is not an array')
}
return hierarchy
} }
function main() { function main() {
const dbPath = path.join(process.cwd(), 'skillmate.dev.encrypted.db') const dbPath = path.join(process.cwd(), 'skillmate.dev.encrypted.db')
const db = new Database(dbPath) const db = new Database(dbPath)
try { try {
const hierarchy = parseFrontendHierarchy() const hierarchy = loadHierarchy()
if (!Array.isArray(hierarchy)) {
throw new Error('Parsed hierarchy is not an array')
}
const insert = db.prepare(` const insert = db.prepare(`
INSERT OR IGNORE INTO skills (id, name, category, description, expires_after) 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); 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 // Users table with encrypted email
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@ -217,11 +297,14 @@ export function initializeSecureDatabase() {
password TEXT NOT NULL, password TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')), role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
employee_id TEXT, employee_id TEXT,
power_unit_id TEXT,
power_function TEXT,
last_login TEXT, last_login TEXT,
is_active INTEGER DEFAULT 1, is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE(email_hash) UNIQUE(email_hash),
FOREIGN KEY(power_unit_id) REFERENCES organizational_units(id)
) )
`) `)
@ -230,6 +313,21 @@ export function initializeSecureDatabase() {
CREATE INDEX IF NOT EXISTS idx_users_email_hash ON users(email_hash); 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 // Skills table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS skills ( CREATE TABLE IF NOT EXISTS skills (

Datei anzeigen

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

Datei anzeigen

@ -2,10 +2,11 @@ import { Router, Request, Response, NextFunction } from 'express'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { body, validationResult } from 'express-validator' import { body, validationResult } from 'express-validator'
import { db } from '../config/secureDatabase' import { db, encryptedDb } from '../config/secureDatabase'
import { User, LoginRequest, LoginResponse } from '@skillmate/shared' import { User, LoginRequest, LoginResponse, POWER_FUNCTIONS } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption' import { FieldEncryption } from '../services/encryption'
import { logger } from '../utils/logger' import { logger } from '../utils/logger'
import { emailService } from '../services/emailService'
const router = Router() const router = Router()
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production' 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() const now = new Date().toISOString()
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id) 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) // 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 = { const user: User = {
id: userRow.id, id: userRow.id,
username: userRow.username, username: userRow.username,
@ -87,7 +101,12 @@ router.post('/login',
lastLogin: new Date(now), lastLogin: new Date(now),
isActive: Boolean(userRow.is_active), isActive: Boolean(userRow.is_active),
createdAt: new Date(userRow.created_at), 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 // Generate token
@ -114,6 +133,85 @@ 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) => { router.post('/logout', (req, res) => {
res.json({ success: true, message: 'Logged out successfully' }) res.json({ success: true, message: 'Logged out successfully' })
}) })

Datei anzeigen

@ -82,17 +82,24 @@ router.put('/employee/:employeeId/organization', authenticate, async (req: AuthR
assignmentId, employeeId, unitId, 'mitarbeiter', assignmentId, employeeId, unitId, 'mitarbeiter',
now, 1, now, now 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 // Update employee's department field for backward compatibility
if (unitId) { 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) { if (unitInfo) {
db.prepare(` db.prepare(`
UPDATE employees UPDATE employees
SET department = ?, updated_at = ? SET department = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`).run(unitInfo.name, now, employeeId) `).run(unitInfo.code || unitInfo.name, now, employeeId)
} }
} else { } else {
// Clear department if no unit // Clear department if no unit

Datei anzeigen

@ -5,12 +5,99 @@ import bcrypt from 'bcrypt'
import { db } from '../config/database' import { db } from '../config/database'
import { authenticate, authorize, AuthRequest } from '../middleware/auth' import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { requirePermission, requireEditPermission } from '../middleware/roleAuth' 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 { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption' import { FieldEncryption } from '../services/encryption'
import { decodeHtmlEntities } from '../utils/html'
import { createDepartmentResolver } from '../utils/department'
const router = Router() 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 // Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' { function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
const mapping: Record<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) => { router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try { try {
const employees = db.prepare(` const employees = db.prepare(`
SELECT id, first_name, last_name, employee_number, photo, position, official_title, SELECT
department, email, phone, mobile, office, availability, e.id,
clearance_level, clearance_valid_until, clearance_issued_date, e.first_name,
created_at, updated_at, created_by, updated_by e.last_name,
FROM employees e.employee_number,
ORDER BY last_name, first_name 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() `).all()
const employeesWithDetails = employees.map((emp: any) => { 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 // Get skills
const skills = db.prepare(` const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date 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 = { const employee: Employee = {
id: emp.id, id: emp.id,
firstName: emp.first_name, firstName: decodedFirstName,
lastName: emp.last_name, lastName: decodedLastName,
employeeNumber: emp.employee_number, employeeNumber: emp.employee_number,
photo: emp.photo, photo: emp.photo,
position: emp.position, position: decodedPosition,
officialTitle: emp.official_title || undefined, officialTitle: decodedOfficialTitle,
department: emp.department, department: departmentLabel,
email: emp.email, departmentDescription,
phone: emp.phone, departmentTasks,
mobile: emp.mobile, primaryUnitId: emp.primaryUnitId || undefined,
office: emp.office, primaryUnitCode: decodedPrimaryUnitCode,
primaryUnitName: decodedPrimaryUnitName,
email: decodedEmail,
phone: decodedPhone,
mobile: decodedMobile,
office: decodedOffice,
availability: emp.availability, availability: emp.availability,
skills: skills.map((s: any) => ({ skills: skills.map((s: any) => ({
id: s.id, id: s.id,
@ -99,7 +241,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
createdAt: new Date(emp.created_at), createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at), updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by, createdBy: emp.created_by,
updatedBy: emp.updated_by updatedBy: emp.updated_by,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
} }
return employee return employee
@ -117,12 +261,34 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
const { id } = req.params const { id } = req.params
const emp = db.prepare(` const emp = db.prepare(`
SELECT id, first_name, last_name, employee_number, photo, position, official_title, SELECT
department, email, phone, mobile, office, availability, e.id,
clearance_level, clearance_valid_until, clearance_issued_date, e.first_name,
created_at, updated_at, created_by, updated_by e.last_name,
FROM employees e.employee_number,
WHERE id = ? 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 `).get(id) as any
if (!emp) { if (!emp) {
@ -152,19 +318,52 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
SELECT name FROM specializations WHERE employee_id = ? SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name) `).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 = { const employee: Employee = {
id: emp.id, id: emp.id,
firstName: emp.first_name, firstName: decodedFirstName,
lastName: emp.last_name, lastName: decodedLastName,
employeeNumber: emp.employee_number, employeeNumber: emp.employee_number,
photo: emp.photo, photo: emp.photo,
position: emp.position, position: decodedPosition,
officialTitle: emp.official_title || undefined, officialTitle: decodedOfficialTitle,
department: emp.department, department: departmentLabel,
email: emp.email, departmentDescription,
phone: emp.phone, departmentTasks,
mobile: emp.mobile, primaryUnitId: emp.primaryUnitId || undefined,
office: emp.office, primaryUnitCode: decodedPrimaryUnitCode,
primaryUnitName: decodedPrimaryUnitName,
email: decodedEmail,
phone: decodedPhone,
mobile: decodedMobile,
office: decodedOffice,
availability: emp.availability, availability: emp.availability,
skills: skills.map((s: any) => ({ skills: skills.map((s: any) => ({
id: s.id, id: s.id,
@ -188,7 +387,9 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
createdAt: new Date(emp.created_at), createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at), updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by, 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 }) res.json({ success: true, data: employee })
@ -204,11 +405,17 @@ router.post('/',
[ [
body('firstName').notEmpty().trim(), body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(), body('lastName').notEmpty().trim(),
body('employeeNumber').optional({ checkFalsy: true }).trim(),
body('position').optional({ checkFalsy: true }).trim(),
body('officialTitle').optional().trim(), body('officialTitle').optional().trim(),
body('email').isEmail(), body('email').isEmail(),
body('department').notEmpty().trim(), body('department').optional({ checkFalsy: true }).trim(),
body('organizationUnitId').optional({ checkFalsy: true }).isUUID(), body('phone').optional({ checkFalsy: true }).trim(),
body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']) 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) => { async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
@ -227,9 +434,16 @@ router.post('/',
firstName, lastName, employeeNumber, photo, position, officialTitle, firstName, lastName, employeeNumber, photo, position, officialTitle,
department, email, phone, mobile, office, availability, department, email, phone, mobile, office, availability,
clearance, skills, languages, specializations, clearance, skills, languages, specializations,
userRole, createUser, organizationUnitId, organizationRole userRole, createUser, organizationUnitId: organizationUnitIdRaw, organizationRole, powerFunction
} = req.body } = 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) { if (organizationRole && !organizationUnitId) {
return res.status(400).json({ return res.status(400).json({
success: false, 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 resolvedUnitId: string | null = null
let resolvedUnitRole: EmployeeUnitRole = 'mitarbeiter' let resolvedUnitRole: EmployeeUnitRole = 'mitarbeiter'
if (organizationUnitId) { 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) { if (!unitRow) {
return res.status(404).json({ success: false, error: { message: 'Organization unit not found' } }) return res.status(404).json({ success: false, error: { message: 'Organization unit not found' } })
} }
resolvedUnitId = unitRow.id resolvedUnitId = unitRow.id
resolvedDepartment = unitRow.name resolvedDepartment = unitRow.code || unitRow.name
if (organizationRole) { if (organizationRole) {
resolvedUnitRole = organizationRole as EmployeeUnitRole 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 // Insert employee with default values for missing fields
@ -373,12 +633,35 @@ router.post('/',
// Encrypt email for user table storage // Encrypt email for user table storage
const encryptedEmail = FieldEncryption.encrypt(email) 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(` db.prepare(`
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at) INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).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}`) console.log(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
@ -438,7 +721,7 @@ router.put('/:id',
body('department').notEmpty().trim(), body('department').notEmpty().trim(),
body('email').isEmail(), body('email').isEmail(),
body('phone').notEmpty().trim(), 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) => { async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
@ -459,7 +742,7 @@ router.put('/:id',
} = req.body } = req.body
// Check if employee exists // 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) { if (!existing) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
@ -482,6 +765,10 @@ router.put('/:id',
now, req.user!.id, id now, req.user!.id, id
) )
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
// Update skills // Update skills
if (skills !== undefined) { if (skills !== undefined) {
// Delete existing skills // Delete existing skills
@ -530,6 +817,10 @@ router.put('/:id',
await syncService.queueSync('employees', 'update', updatedEmployee) await syncService.queueSync('employees', 'update', updatedEmployee)
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
res.json({ res.json({
success: true, success: true,
message: 'Employee updated successfully' message: 'Employee updated successfully'
@ -549,7 +840,7 @@ router.delete('/:id',
const { id } = req.params const { id } = req.params
// Check if employee exists // 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) { if (!existing) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,

Datei anzeigen

@ -5,13 +5,99 @@ import bcrypt from 'bcrypt'
import { db, encryptedDb } from '../config/secureDatabase' import { db, encryptedDb } from '../config/secureDatabase'
import { authenticate, authorize, AuthRequest } from '../middleware/auth' import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { requirePermission, requireEditPermission } from '../middleware/roleAuth' 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 { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption' import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService' import { emailService } from '../services/emailService'
import { logger } from '../utils/logger' import { logger } from '../utils/logger'
import { createDepartmentResolver } from '../utils/department'
const router = Router() 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 // Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' { 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 = ? SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name) `).all(emp.id).map((s: any) => s.name)
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primary_unit_id,
})
const employee: Employee = { const employee: Employee = {
id: emp.id, id: emp.id,
firstName: emp.first_name, firstName: emp.first_name,
@ -122,7 +213,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
photo: emp.photo, photo: emp.photo,
position: emp.position, position: emp.position,
officialTitle: emp.official_title || undefined, officialTitle: emp.official_title || undefined,
department: emp.department, department: departmentInfo.label || emp.department,
departmentDescription: departmentInfo.description,
departmentTasks: departmentInfo.tasks,
email: emp.email, email: emp.email,
phone: emp.phone, phone: emp.phone,
mobile: emp.mobile, mobile: emp.mobile,
@ -150,7 +243,10 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
createdAt: new Date(emp.created_at), createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at), updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by, 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 return employee
@ -197,6 +293,11 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
SELECT name FROM specializations WHERE employee_id = ? SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name) `).all(emp.id).map((s: any) => s.name)
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primary_unit_id,
})
const employee: Employee = { const employee: Employee = {
id: emp.id, id: emp.id,
firstName: emp.first_name, firstName: emp.first_name,
@ -205,7 +306,9 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
photo: emp.photo, photo: emp.photo,
position: emp.position, position: emp.position,
officialTitle: emp.official_title || undefined, officialTitle: emp.official_title || undefined,
department: emp.department, department: departmentInfo.label || emp.department,
departmentDescription: departmentInfo.description,
departmentTasks: departmentInfo.tasks,
email: emp.email, email: emp.email,
phone: emp.phone, phone: emp.phone,
mobile: emp.mobile, mobile: emp.mobile,
@ -233,7 +336,10 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
createdAt: new Date(emp.created_at), createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at), updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by, 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 return employee
@ -334,13 +440,16 @@ router.post('/',
authenticate, authenticate,
requirePermission('employees:create'), requirePermission('employees:create'),
[ [
body('firstName').notEmpty().trim().escape(), body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim().escape(), body('lastName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(), body('email').isEmail().normalizeEmail(),
body('department').notEmpty().trim().escape(), body('department').notEmpty().trim(),
body('position').optional().trim().escape(), // Optional body('position').optional().trim(), // Optional
body('phone').optional().trim(), // Optional - kann später ergänzt werden 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) => { async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
@ -360,9 +469,20 @@ router.post('/',
firstName, lastName, employeeNumber, photo, position = 'Teammitglied', officialTitle, firstName, lastName, employeeNumber, photo, position = 'Teammitglied', officialTitle,
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available', department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
clearance, skills = [], languages = [], specializations = [], userRole, createUser, clearance, skills = [], languages = [], specializations = [], userRole, createUser,
primaryUnitId, assignmentRole primaryUnitId, assignmentRole, powerFunction
} = req.body } = 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 // Generate employee number if not provided
const finalEmployeeNumber = employeeNumber || `EMP${Date.now()}` 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 // Insert employee with encrypted fields
encryptedDb.insertEmployee({ encryptedDb.insertEmployee({
id: employeeId, id: employeeId,
@ -384,7 +546,7 @@ router.post('/',
photo: photo || null, photo: photo || null,
position, position,
official_title: officialTitle || null, official_title: officialTitle || null,
department, department: resolvedDepartment,
email, email,
phone, phone,
mobile: mobile || null, mobile: mobile || null,
@ -393,24 +555,20 @@ router.post('/',
clearance_level: clearance?.level || null, clearance_level: clearance?.level || null,
clearance_valid_until: clearance?.validUntil || null, clearance_valid_until: clearance?.validUntil || null,
clearance_issued_date: clearance?.issuedDate || null, clearance_issued_date: clearance?.issuedDate || null,
primary_unit_id: primaryUnitId || null, primary_unit_id: resolvedPrimaryUnitId,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
created_by: req.user!.id created_by: req.user!.id
}) })
// Create primary assignment if provided // Create primary assignment if provided
if (primaryUnitId) { if (resolvedPrimaryUnitId) {
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' } })
}
db.prepare(` db.prepare(`
INSERT INTO employee_unit_assignments ( INSERT INTO employee_unit_assignments (
id, employee_id, unit_id, role, start_date, end_date, is_primary, created_at, updated_at id, employee_id, unit_id, role, start_date, end_date, is_primary, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
uuidv4(), employeeId, primaryUnitId, assignmentRole || 'mitarbeiter', uuidv4(), employeeId, resolvedPrimaryUnitId, resolvedAssignmentRole,
now, null, 1, now, now now, null, 1, now, now
) )
} }
@ -487,9 +645,19 @@ router.post('/',
// Enforce role policy: only admins may assign roles; others default to 'user' // Enforce role policy: only admins may assign roles; others default to 'user'
const assignedRole = req.user?.role === 'admin' && userRole ? userRole : '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(` db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at) INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
userId, userId,
email, email,
@ -498,6 +666,8 @@ router.post('/',
hashedPassword, hashedPassword,
assignedRole, assignedRole,
employeeId, employeeId,
powerUnitForUser,
powerFunctionForUser,
1, 1,
now, now,
now now
@ -593,15 +763,15 @@ router.put('/:id',
authenticate, authenticate,
requireEditPermission(req => req.params.id), requireEditPermission(req => req.params.id),
[ [
body('firstName').notEmpty().trim().escape(), body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim().escape(), body('lastName').notEmpty().trim(),
body('position').optional().trim().escape(), body('position').optional().trim(),
body('officialTitle').optional().trim().escape(), body('officialTitle').optional().trim(),
body('department').notEmpty().trim().escape(), body('department').notEmpty().trim(),
body('email').isEmail().normalizeEmail(), body('email').isEmail().normalizeEmail(),
body('phone').optional().trim(), body('phone').optional().trim(),
body('employeeNumber').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) => { async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
@ -624,7 +794,7 @@ router.put('/:id',
} = req.body } = req.body
// Check if employee exists // 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) { if (!existing) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
@ -727,6 +897,10 @@ router.put('/:id',
logger.error('Failed to queue sync:', err) logger.error('Failed to queue sync:', err)
}) })
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
return res.json({ return res.json({
success: true, success: true,
message: 'Employee updated successfully' message: 'Employee updated successfully'
@ -762,7 +936,7 @@ router.delete('/:id',
const { id } = req.params const { id } = req.params
// Check if employee exists // 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) { if (!existing) {
return res.status(404).json({ return res.status(404).json({
success: false, 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 { authenticate, AuthRequest } from '../middleware/auth'
import { import {
OrganizationalUnit, OrganizationalUnit,
OrganizationalUnitType,
EmployeeUnitAssignment, EmployeeUnitAssignment,
SpecialPosition, SpecialPosition,
DeputyAssignment, DeputyAssignment,
DeputyDelegation DeputyDelegation
} from '@skillmate/shared' } from '@skillmate/shared'
import { logger } from '../utils/logger' import { logger } from '../utils/logger'
import { decodeHtmlEntities } from '../utils/html'
const router = Router() 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 // Get all organizational units
router.get('/units', authenticate, async (req: AuthRequest, res, next) => { router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
try { try {
@ -32,7 +89,14 @@ router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
ORDER BY level, order_index, name ORDER BY level, order_index, name
`).all() `).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) { } catch (error) {
logger.error('Error fetching organizational units:', error) logger.error('Error fetching organizational units:', error)
next(error) next(error)
@ -171,6 +235,18 @@ router.get('/hierarchy', authenticate, async (req: AuthRequest, res, next) => {
// Apply sorting from the top // Apply sorting from the top
rootUnits.forEach(sortTree) 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 }) res.json({ success: true, data: rootUnits })
} catch (error) { } catch (error) {
logger.error('Error building organizational hierarchy:', 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 e.last_name, e.first_name
`).all(req.params.id) `).all(req.params.id)
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({ res.json({
success: true, success: true,
data: { data: {
...unit, ...decodedUnit,
employees employees: decodedEmployees
} }
}) })
} catch (error) { } catch (error) {
@ -237,7 +330,7 @@ router.post('/units',
[ [
body('code').notEmpty().trim(), body('code').notEmpty().trim(),
body('name').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('level').isInt({ min: 0, max: 10 }),
body('parentId').optional({ checkFalsy: true }).isUUID() 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' } }) 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 // 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 maxOrder = db.prepare('SELECT MAX(order_index) as max FROM organizational_units WHERE level = ?').get(level) as any
const orderIndex = (maxOrder?.max || 0) + 1 const orderIndex = (maxOrder?.max || 0) + 1
@ -294,9 +403,13 @@ router.put('/units/:id',
authenticate, authenticate,
[ [
param('id').isUUID(), param('id').isUUID(),
body('code').optional().isString().trim().notEmpty(),
body('name').optional().notEmpty().trim(), body('name').optional().notEmpty().trim(),
body('type').optional().isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
body('description').optional(), body('description').optional(),
body('color').optional(), body('color').optional(),
body('hasFuehrungsstelle').optional().isBoolean().toBoolean(),
body('fuehrungsstelleName').optional().isString().trim(),
body('parentId').optional({ checkFalsy: true }).isUUID(), body('parentId').optional({ checkFalsy: true }).isUUID(),
body('level').optional().isInt({ min: 0, max: 10 }), body('level').optional().isInt({ min: 0, max: 10 }),
// allow updating persisted canvas positions from admin editor // allow updating persisted canvas positions from admin editor
@ -305,67 +418,126 @@ router.put('/units/:id',
], ],
async (req: AuthRequest, res: any, next: any) => { async (req: AuthRequest, res: any, next: any) => {
try { 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') { if (req.user?.role !== 'admin') {
return res.status(403).json({ success: false, error: { message: 'Admin access required' } }) return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
} }
const { name, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body const { code, name, type, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
const now = new Date().toISOString() const now = new Date().toISOString()
// Optional: validate parentId and avoid cycles 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
let newParentId: string | null | undefined = 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 !== undefined) {
if (parentId === null || parentId === '' ) { if (parentId === null || parentId === '') {
newParentId = null finalParentId = null
} else { } else {
const parent = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(parentId) 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 (!parent) { if (!parentRow) {
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } }) 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 const targetId = req.params.id
let cursor: any = parent let cursor: any = parentRow
while (cursor && cursor.parentId) { while (cursor && cursor.parentId) {
if (cursor.parentId === targetId) { if (cursor.parentId === targetId) {
return res.status(400).json({ success: false, error: { message: 'Cyclic parent assignment is not allowed' } }) 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) 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(` if (finalParentId && !finalParentType) {
UPDATE organizational_units const parentRow = db.prepare('SELECT type FROM organizational_units WHERE id = ?').get(finalParentId) as { type: OrganizationalUnitType } | undefined
SET name = COALESCE(?, name), finalParentType = parentRow?.type ?? null
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' } })
} }
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 }) res.json({ success: true })
} catch (error) { } catch (error) {
logger.error('Error updating organizational unit:', error) logger.error('Error updating organizational unit:', error)
@ -405,7 +577,7 @@ router.post('/assignments',
} }
// Validate unit exists and is active // 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) { if (!unit) {
return res.status(404).json({ success: false, error: { message: 'Unit not found' } }) 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 = ? SET is_primary = 0, updated_at = ?
WHERE employee_id = ? AND end_date IS NULL WHERE employee_id = ? AND end_date IS NULL
`).run(now, employeeId) `).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(` db.prepare(`
@ -544,15 +733,39 @@ router.post('/deputies',
const now = new Date().toISOString() const now = new Date().toISOString()
const assignmentId = uuidv4() 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(` 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 = ? WHERE principal_id = ? AND deputy_id = ?
AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?)) AND valid_until >= ?
`).get(req.user.employeeId, deputyId, validFrom, validUntil, validFrom, validUntil) AND valid_from <= ?
`).get(req.user.employeeId, deputyId, startSql, endSql) as any
if (conflict) { 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(` db.prepare(`
@ -563,7 +776,7 @@ router.post('/deputies',
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
assignmentId, req.user.employeeId, deputyId, unitId || null, 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 req.user.id, now, now
) )
@ -581,7 +794,7 @@ router.post('/deputies/my',
[ [
body('deputyId').isUUID(), body('deputyId').isUUID(),
body('validFrom').isISO8601(), body('validFrom').isISO8601(),
body('validUntil').optional({ nullable: true }).isISO8601(), body('validUntil').isISO8601(),
body('unitId').optional({ nullable: true }).isUUID() body('unitId').optional({ nullable: true }).isUUID()
], ],
async (req: AuthRequest, res: any, next: any) => { async (req: AuthRequest, res: any, next: any) => {
@ -599,15 +812,39 @@ router.post('/deputies/my',
const now = new Date().toISOString() const now = new Date().toISOString()
const assignmentId = uuidv4() 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(` 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 = ? WHERE principal_id = ? AND deputy_id = ?
AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?)) AND valid_until >= ?
`).get(req.user.employeeId, deputyId, validFrom, validUntil || validFrom, validFrom, validUntil || validFrom) AND valid_from <= ?
`).get(req.user.employeeId, deputyId, startSql, endSql) as any
if (conflict) { 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(` db.prepare(`
@ -618,7 +855,7 @@ router.post('/deputies/my',
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
assignmentId, req.user.employeeId, deputyId, unitId || null, 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 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 { v4 as uuidv4 } from 'uuid'
import { db, encryptedDb } from '../config/secureDatabase' import { db, encryptedDb } from '../config/secureDatabase'
import { authenticate, authorize, AuthRequest } from '../middleware/auth' 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 { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService' import { emailService } from '../services/emailService'
import { logger } from '../utils/logger' import { logger } from '../utils/logger'
@ -15,9 +15,23 @@ const router = Router()
router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthRequest, res, next) => { router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthRequest, res, next) => {
try { try {
const users = db.prepare(` const users = db.prepare(`
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at SELECT
FROM users u.id,
ORDER BY username 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[] `).all() as any[]
// Decrypt email addresses (handle decryption failures) // 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 { return {
...user, ...user,
email: decryptedEmail, 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, lastLogin: user.last_login ? new Date(user.last_login) : null,
createdAt: new Date(user.created_at), createdAt: new Date(user.created_at),
updatedAt: new Date(user.updated_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, authenticate,
authorize('admin'), 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) => { async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
@ -71,7 +95,7 @@ router.put('/:id/role',
} }
const { id } = req.params 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 // Check if user exists
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any 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(` db.prepare(`
UPDATE users SET role = ?, updated_at = ? UPDATE users SET role = ?, power_unit_id = ?, power_function = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`).run(role, new Date().toISOString(), id) `).run(role, finalPowerUnit, finalPowerFunction, now, id)
logger.info(`User role updated: ${existingUser.username} -> ${role}`) logger.info(`User role updated: ${existingUser.username} -> ${role}`)
res.json({ success: true, message: 'Role updated successfully' }) 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 } 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[] = [] const results: any[] = []
for (const employeeId of employeeIds) { for (const employeeId of employeeIds) {
@ -155,8 +215,8 @@ router.post('/bulk-create-from-employees',
const userId = uuidv4() const userId = uuidv4()
db.prepare(` db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at) INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
userId, userId,
finalUsername, finalUsername,
@ -165,6 +225,8 @@ router.post('/bulk-create-from-employees',
hashedPassword, hashedPassword,
role, role,
employeeId, employeeId,
null,
null,
1, 1,
now, now,
now now
@ -336,7 +398,9 @@ router.post('/create-from-employee',
[ [
body('employeeId').notEmpty().isString(), body('employeeId').notEmpty().isString(),
body('username').optional().isString().isLength({ min: 3 }), 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) => { async (req: AuthRequest, res: Response, next: NextFunction) => {
try { 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 // Check employee exists
const employee = encryptedDb.getEmployee(employeeId) as any 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 // Generate temp password
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#` const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = await bcrypt.hash(tempPassword, 12) const hashedPassword = await bcrypt.hash(tempPassword, 12)
@ -389,8 +476,8 @@ router.post('/create-from-employee',
// Insert user with encrypted email // Insert user with encrypted email
db.prepare(` db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at) INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
userId, userId,
finalUsername, finalUsername,
@ -399,6 +486,8 @@ router.post('/create-from-employee',
hashedPassword, hashedPassword,
role, role,
employeeId, employeeId,
resolvedPowerUnit,
resolvedPowerFunction,
1, 1,
now, now,
now now

Datei anzeigen

@ -32,6 +32,7 @@ export class SyncScheduler {
} }
private initialize() { private initialize() {
this.ensureSyncSettingsTable()
// Check current sync settings on startup // Check current sync settings on startup
this.checkAndUpdateInterval() this.checkAndUpdateInterval()
@ -41,6 +42,44 @@ export class SyncScheduler {
}, 60000) }, 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() { private checkAndUpdateInterval() {
try { try {
const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any

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' import { body } from 'express-validator'
export const createEmployeeValidators = [ export const createEmployeeValidators = [
body('firstName').notEmpty().trim().escape(), body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim().escape(), body('lastName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(), body('email').isEmail().normalizeEmail(),
body('department').notEmpty().trim().escape(), body('department').notEmpty().trim(),
body('position').optional().trim().escape(), body('position').optional().trim(),
body('phone').optional().trim(), body('phone').optional().trim(),
body('employeeNumber').optional().trim(), body('employeeNumber').optional().trim(),
] ]
export const updateEmployeeValidators = [ export const updateEmployeeValidators = [
body('firstName').notEmpty().trim().escape(), body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim().escape(), body('lastName').notEmpty().trim(),
body('position').optional().trim().escape(), body('position').optional().trim(),
body('department').notEmpty().trim().escape(), body('department').notEmpty().trim(),
body('email').isEmail().normalizeEmail(), body('email').isEmail().normalizeEmail(),
body('phone').optional().trim(), body('phone').optional().trim(),
body('employeeNumber').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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@react-three/drei": "^9.88.0", "@react-three/drei": "^9.112.5",
"@react-three/fiber": "^8.15.0", "@react-three/fiber": "^8.15.0",
"@skillmate/shared": "file:../shared", "@skillmate/shared": "file:../shared",
"@types/three": "^0.180.0", "@types/three": "^0.180.0",
"axios": "^1.6.2", "axios": "^1.7.9",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -31,5 +31,9 @@
"tailwindcss": "^3.3.6", "tailwindcss": "^3.3.6",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.7" "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 { useState, useEffect } from 'react'
import api from '../services/api' import api from '../services/api'
import type { DeputyAssignment, Employee } from '@skillmate/shared' import type { Employee } from '@skillmate/shared'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
interface UnitOption {
id: string
name: string
code?: string | null
isPrimary?: boolean
}
export default function DeputyManagement() { export default function DeputyManagement() {
const [asPrincipal, setAsPrincipal] = useState<any[]>([]) const [asPrincipal, setAsPrincipal] = useState<any[]>([])
const [asDeputy, setAsDeputy] = useState<any[]>([]) const [asDeputy, setAsDeputy] = useState<any[]>([])
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([]) const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
const [unitOptions, setUnitOptions] = useState<UnitOption[]>([])
const [showAddDialog, setShowAddDialog] = useState(false) const [showAddDialog, setShowAddDialog] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState('')
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
deputyId: '', deputyId: '',
validFrom: new Date().toISOString().split('T')[0], validFrom: new Date().toISOString().split('T')[0],
validUntil: '', validUntil: new Date().toISOString().split('T')[0],
reason: '', reason: '',
canDelegate: true, canDelegate: true,
unitId: '' unitId: ''
@ -22,6 +32,7 @@ export default function DeputyManagement() {
useEffect(() => { useEffect(() => {
loadDeputies() loadDeputies()
loadEmployees() loadEmployees()
loadUnits()
}, []) }, [])
const loadDeputies = async () => { const loadDeputies = async () => {
@ -43,28 +54,66 @@ export default function DeputyManagement() {
try { try {
const response = await api.get('/employees/public') const response = await api.get('/employees/public')
if (response.data.success) { 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) { } catch (error) {
console.error('Failed to load employees:', error) console.error('Failed to load employees:', error)
} }
} }
const handleAddDeputy = async () => { const loadUnits = async () => {
try { try {
const response = await api.post('/organization/deputies/my', { const response = await api.get('/organization/my-units')
...formData, if (response.data.success) {
validFrom: new Date(formData.validFrom).toISOString(), setUnitOptions(response.data.data || [])
validUntil: formData.validUntil ? new Date(formData.validUntil).toISOString() : null }
}) } 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) { if (response.data.success) {
await loadDeputies() await loadDeputies()
setShowAddDialog(false) setShowAddDialog(false)
resetForm() resetForm()
} else {
setFormError(response.data?.error?.message || 'Vertretung konnte nicht gespeichert werden.')
} }
} catch (error) { } catch (error: any) {
console.error('Failed to add deputy:', error) 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 resetForm = () => {
const today = new Date().toISOString().split('T')[0]
setFormData({ setFormData({
deputyId: '', deputyId: '',
validFrom: new Date().toISOString().split('T')[0], validFrom: today,
validUntil: '', validUntil: today,
reason: '', reason: '',
canDelegate: true, canDelegate: true,
unitId: '' 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) { if (loading) {
@ -132,10 +215,10 @@ export default function DeputyManagement() {
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold dark:text-white">Aktuelle Vertretungen</h3> <h3 className="text-lg font-semibold dark:text-white">Aktuelle Vertretungen</h3>
<button <button
onClick={() => setShowAddDialog(true)} onClick={openAddDialog}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" 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> </button>
</div> </div>
@ -213,6 +296,7 @@ export default function DeputyManagement() {
onChange={(e) => setFormData({ ...formData, deputyId: e.target.value })} 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" className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
required required
disabled={asPrincipal.length > 0}
> >
<option value="">Bitte wählen...</option> <option value="">Bitte wählen...</option>
{availableEmployees.map(emp => ( {availableEmployees.map(emp => (
@ -221,8 +305,33 @@ export default function DeputyManagement() {
</option> </option>
))} ))}
</select> </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> </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 className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300"> <label className="block text-sm font-medium mb-1 dark:text-gray-300">
@ -231,20 +340,29 @@ export default function DeputyManagement() {
<input <input
type="date" type="date"
value={formData.validFrom} 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" className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1 dark:text-gray-300"> <label className="block text-sm font-medium mb-1 dark:text-gray-300">
Bis (optional) Bis *
</label> </label>
<input <input
type="date" type="date"
value={formData.validUntil} value={formData.validUntil}
onChange={(e) => setFormData({ ...formData, validUntil: e.target.value })} 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" className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
required
min={formData.validFrom}
/> />
</div> </div>
</div> </div>
@ -282,6 +400,12 @@ export default function DeputyManagement() {
</div> </div>
</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"> <div className="flex justify-end gap-2 mt-6">
<button <button
onClick={() => { onClick={() => {
@ -294,9 +418,10 @@ export default function DeputyManagement() {
</button> </button>
<button <button
onClick={handleAddDeputy} 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> </button>
</div> </div>
</div> </div>

Datei anzeigen

@ -1,11 +1,13 @@
import type { Employee } from '@skillmate/shared' import type { Employee } from '@skillmate/shared'
import { formatDepartmentWithDescription, normalizeDepartment } from '../utils/text'
interface EmployeeCardProps { interface EmployeeCardProps {
employee: Employee employee: Employee
onClick: () => void 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 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 PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
const photoSrc = employee.photo && employee.photo.startsWith('/uploads/') 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 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 ( 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-start justify-between mb-4">
<div className="flex items-center space-x-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"> <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>
)} )}
<p className="text-body text-secondary"> <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> </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> </div>
{employee.specializations.length > 0 && ( {specializations.length > 0 && (
<div> <div>
<p className="text-small font-medium text-tertiary mb-2">Spezialisierungen:</p> <p className="text-small font-medium text-tertiary mb-2">Spezialisierungen:</p>
<div className="flex flex-wrap gap-2"> <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"> <span key={index} className="badge badge-info text-xs">
{spec} {spec}
</span> </span>
))} ))}
{employee.specializations.length > 3 && ( {specializations.length > 3 && (
<span className="text-xs text-tertiary"> <span className="text-xs text-tertiary">
+{employee.specializations.length - 3} weitere +{specializations.length - 3} weitere
</span> </span>
)} )}
</div> </div>
</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> </div>
) )
} }

Datei anzeigen

@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'
import type { OrganizationalUnit, EmployeeUnitAssignment } from '@skillmate/shared' import type { OrganizationalUnit, EmployeeUnitAssignment } from '@skillmate/shared'
import api from '../services/api' import api from '../services/api'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
import { decodeHtmlEntities } from '../utils/text'
interface OrganizationChartProps { interface OrganizationChartProps {
onClose: () => void onClose: () => void
@ -113,6 +114,26 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
return colors[level % colors.length] 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) => { const handleUnitClick = (unit: OrganizationalUnit) => {
setSelectedUnit(unit) setSelectedUnit(unit)
loadUnitDetails(unit.id) loadUnitDetails(unit.id)
@ -147,7 +168,8 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
const renderUnit = (unit: any, level: number = 0) => { const renderUnit = (unit: any, level: number = 0) => {
const isMyUnit = myUnits.some(u => u.id === unit.id) 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 ( return (
<div key={unit.id} className="flex flex-col items-center"> <div key={unit.id} className="flex flex-col items-center">
@ -167,7 +189,12 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
</div> </div>
</div> </div>
<div className="p-3"> <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 && ( {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"> <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 FüSt
@ -313,8 +340,13 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
{selectedUnit && ( {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="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)}`}> <div className={`p-3 text-white rounded-lg mb-4 ${getUnitColor(selectedUnit.level || 0)}`}>
<h3 className="text-lg font-semibold">{selectedUnit.name}</h3> <h3 className="text-lg font-semibold">{getUnitDisplayLabel(selectedUnit)}</h3>
{selectedUnit.code && <p className="text-sm opacity-90">{selectedUnit.code}</p>} {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> </div>
{/* Tabs */} {/* Tabs */}
@ -365,13 +397,6 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
)} )}
</div> </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 && ( {user?.employeeId && (
<div className="mt-4"> <div className="mt-4">
<button <button

Datei anzeigen

@ -4,13 +4,24 @@ import type { OrganizationalUnit } from '@skillmate/shared'
import api from '../services/api' import api from '../services/api'
import { ChevronRight, Building2, Search, X } from 'lucide-react' import { ChevronRight, Building2, Search, X } from 'lucide-react'
interface OrganizationSelectorProps { interface SelectedUnitDetails {
value?: string unit: OrganizationalUnit
onChange: (unitId: string | null, unitName: string) => void storageValue: string
disabled?: boolean 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 [showModal, setShowModal] = useState(false)
const [hierarchy, setHierarchy] = useState<OrganizationalUnit[]>([]) const [hierarchy, setHierarchy] = useState<OrganizationalUnit[]>([])
const [selectedUnit, setSelectedUnit] = useState<OrganizationalUnit | null>(null) const [selectedUnit, setSelectedUnit] = useState<OrganizationalUnit | null>(null)
@ -26,14 +37,18 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
}, [showModal]) }, [showModal])
useEffect(() => { useEffect(() => {
// Load current unit name if value exists
if (value && hierarchy.length > 0) { if (value && hierarchy.length > 0) {
const unit = findUnitById(hierarchy, value) const unit = findUnitById(hierarchy, value)
if (unit) { if (unit) {
setCurrentUnitName(getUnitPath(hierarchy, unit)) const selection = buildSelectionDetails(hierarchy, unit)
setCurrentUnitName(selection.displayPath)
setSelectedUnit(unit) setSelectedUnit(unit)
} }
} }
if (!value) {
setSelectedUnit(null)
setCurrentUnitName('')
}
}, [value, hierarchy]) }, [value, hierarchy])
const loadOrganization = async () => { const loadOrganization = async () => {
@ -73,17 +88,17 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
return null return null
} }
const getUnitPath = (units: OrganizationalUnit[], target: OrganizationalUnit): string => { const buildSelectionDetails = (units: OrganizationalUnit[], target: OrganizationalUnit): SelectedUnitDetails => {
const path: string[] = [] const pathUnits: OrganizationalUnit[] = []
const findPath = (units: OrganizationalUnit[], current: string[] = []): boolean => { const findPath = (nodes: OrganizationalUnit[], current: OrganizationalUnit[] = []): boolean => {
for (const unit of units) { for (const node of nodes) {
const newPath = [...current, unit.name] const extended = [...current, node]
if (unit.id === target.id) { if (node.id === target.id) {
path.push(...newPath) pathUnits.splice(0, pathUnits.length, ...extended)
return true return true
} }
if (unit.children && findPath(unit.children, newPath)) { if (node.children && findPath(node.children, extended)) {
return true return true
} }
} }
@ -91,7 +106,45 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
} }
findPath(units) 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) => { const toggleExpand = (unitId: string) => {
@ -107,10 +160,10 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
} }
const handleSelect = (unit: OrganizationalUnit) => { const handleSelect = (unit: OrganizationalUnit) => {
const selection = buildSelectionDetails(hierarchy, unit)
setSelectedUnit(unit) setSelectedUnit(unit)
const path = getUnitPath(hierarchy, unit) setCurrentUnitName(selection.displayPath)
setCurrentUnitName(path) onChange(unit.id, selection.storageValue, selection)
onChange(unit.id, path)
setShowModal(false) setShowModal(false)
} }
@ -193,9 +246,10 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
type="text" type="text"
className="input-field w-full pr-10" className="input-field w-full pr-10"
value={currentUnitName} value={currentUnitName}
placeholder="Klicken zum Auswählen..." placeholder="Organisationseinheit aus dem Organigramm wählen"
readOnly readOnly
disabled={disabled} disabled={disabled}
title={title}
onClick={() => !disabled && setShowModal(true)} onClick={() => !disabled && setShowModal(true)}
/> />
<button <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' 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) { export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disabled = false, showHelp = true }: SkillLevelBarProps) {
const handleKey = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => { const handleKey = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return 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 current = typeof value === 'number' ? value : 0
const helpTooltip = showHelp ? 'Stufe 1: Anfänger · Stufe 5: Fortgeschritten · Stufe 10: Experte' : undefined
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div <div
@ -46,6 +54,7 @@ export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disa
aria-valuenow={current || undefined} aria-valuenow={current || undefined}
tabIndex={disabled ? -1 : 0} tabIndex={disabled ? -1 : 0}
onKeyDown={handleKey} onKeyDown={handleKey}
title={helpTooltip}
> >
{Array.from({ length: max }, (_, idx) => idx + 1).map(i => { {Array.from({ length: max }, (_, idx) => idx + 1).map(i => {
const active = i <= current const active = i <= current
@ -58,20 +67,14 @@ export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disa
? segmentColor(i) ? segmentColor(i)
: 'bg-border-default hover:bg-bg-gray dark:bg-dark-border/40 dark:hover:bg-dark-border/60' : 'bg-border-default hover:bg-bg-gray dark:bg-dark-border/40 dark:hover:bg-dark-border/60'
} ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`} } ${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)} onClick={() => !disabled && onChange(i)}
/> />
) )
})} })}
<span className="ml-3 text-small text-secondary min-w-[2ch] text-right">{current || ''}</span> <span className="ml-3 text-small text-secondary min-w-[2ch] text-right">{current || ''}</span>
</div> </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> </div>
) )
} }

Datei anzeigen

@ -39,6 +39,16 @@ export function usePermissions() {
const hasPermission = (permission: string): boolean => { const hasPermission = (permission: string): boolean => {
if (!isAuthenticated || !user) return false 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] || [] const rolePermissions = ROLE_PERMISSIONS[user.role] || []
return rolePermissions.includes(permission) return rolePermissions.includes(permission)
} }
@ -51,7 +61,8 @@ export function usePermissions() {
} }
const canCreateEmployee = (): boolean => { const canCreateEmployee = (): boolean => {
return hasPermission('employees:create') if (!isAuthenticated || !user) return false
return user.role === 'superuser' && Boolean(user.canManageEmployees)
} }
const canEditEmployee = (employeeId?: string): boolean => { const canEditEmployee = (employeeId?: string): boolean => {
@ -60,8 +71,8 @@ export function usePermissions() {
// Admins can edit anyone // Admins can edit anyone
if (user.role === 'admin') return true if (user.role === 'admin') return true
// Superusers can edit anyone // Superusers can only edit when they are allowed to manage employees
if (user.role === 'superuser') return true if (user.role === 'superuser' && user.canManageEmployees) return true
// Users can only edit their own profile (if linked) // Users can only edit their own profile (if linked)
if (user.role === 'user' && employeeId && user.employeeId === employeeId) { 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 }) const response = await api.post('/auth/login', { email, password })
return response.data.data return response.data.data
}, },
forgotPassword: async (email: string) => {
const response = await api.post('/auth/forgot-password', { email })
return response.data
},
logout: async () => { logout: async () => {
localStorage.removeItem('token') 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 { api }
export default 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 { employeeApi } from '../services/api'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
import { isBooleanSkill } from '../utils/skillRules' import { isBooleanSkill } from '../utils/skillRules'
import { formatDepartmentWithDescription, normalizeDepartment } from '../utils/text'
export default function EmployeeDetail() { export default function EmployeeDetail() {
const { id } = useParams() 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 // Hinweis: Verfügbarkeits-Badge wird im Mitarbeitenden-Detail nicht angezeigt
return ( return (
@ -177,8 +183,17 @@ export default function EmployeeDetail() {
)} )}
<div> <div>
<span className="text-tertiary">Dienststelle:</span> <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> </div>
{departmentTasks && (
<div>
<span className="text-tertiary">Aufgaben der Dienststelle:</span>
<p className="text-secondary mt-1">{departmentTasks}</p>
</div>
)}
{employee.clearance && ( {employee.clearance && (
<div> <div>
<span className="text-tertiary">Sicherheitsüberprüfung:</span> <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 { useNavigate } from 'react-router-dom'
import type { Employee } from '@skillmate/shared' import type { Employee } from '@skillmate/shared'
import { employeeApi } from '../services/api' import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '@skillmate/shared'
import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '../data/skillCategories' import { employeeApi, officialTitlesApi, positionCatalogApi } from '../services/api'
import PhotoPreview from '../components/PhotoPreview' import PhotoPreview from '../components/PhotoPreview'
import OrganizationSelector from '../components/OrganizationSelector' import OrganizationSelector from '../components/OrganizationSelector'
import { isBooleanSkill } from '../utils/skillRules' 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() { export default function EmployeeForm() {
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuthStore()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}) const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
@ -20,6 +31,8 @@ export default function EmployeeForm() {
position: '', position: '',
officialTitle: '', officialTitle: '',
department: '', department: '',
departmentDescription: '',
departmentTasks: '',
email: '', email: '',
phone: '', phone: '',
mobile: '', mobile: '',
@ -31,8 +44,8 @@ export default function EmployeeForm() {
languages: [] as string[], languages: [] as string[],
specializations: [] as string[] specializations: [] as string[]
}) })
const [primaryUnitId, setPrimaryUnitId] = useState<string | null>(null) const [primaryUnitId, setPrimaryUnitId] = useState<string | null>(user?.powerUnitId || null)
const [primaryUnitName, setPrimaryUnitName] = useState<string>('') const [primaryUnitName, setPrimaryUnitName] = useState<string>(user?.powerUnitName || '')
const [employeePhoto, setEmployeePhoto] = useState<string | null>(null) const [employeePhoto, setEmployeePhoto] = useState<string | null>(null)
const [photoFile, setPhotoFile] = useState<File | null>(null) const [photoFile, setPhotoFile] = useState<File | null>(null)
@ -41,10 +54,69 @@ export default function EmployeeForm() {
const [expandedSubCategories, setExpandedSubCategories] = useState<Set<string>>(new Set()) const [expandedSubCategories, setExpandedSubCategories] = useState<Set<string>>(new Set())
const [skillSearchTerm, setSkillSearchTerm] = useState('') const [skillSearchTerm, setSkillSearchTerm] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([]) 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 handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value })) setFormData(prev => ({ ...prev, [name]: value }))
setValidationErrors(prev => {
if (!prev[name]) return prev
const next = { ...prev }
delete next[name]
return next
})
} }
const toggleCategory = (categoryId: string) => { const toggleCategory = (categoryId: string) => {
@ -178,12 +250,8 @@ export default function EmployeeForm() {
if (!formData.firstName.trim()) errors.firstName = 'Vorname ist erforderlich' if (!formData.firstName.trim()) errors.firstName = 'Vorname ist erforderlich'
if (!formData.lastName.trim()) errors.lastName = 'Nachname 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' 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' 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' if (!primaryUnitId) errors.primaryUnitId = 'Organisatorische Einheit ist erforderlich'
setValidationErrors(errors) setValidationErrors(errors)
@ -196,15 +264,30 @@ export default function EmployeeForm() {
setValidationErrors({}) setValidationErrors({})
if (!validateForm()) { if (!validateForm()) {
setError('Bitte füllen Sie alle Pflichtfelder aus') setError('Bitte geben Sie Vorname, Nachname, E-Mail und eine Organisationseinheit an.')
return return
} }
setLoading(true) setLoading(true)
try { 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> = { const newEmployee: Partial<Employee> = {
...formData, ...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) => ({ skills: formData.skills.map((skill, index) => ({
id: skill.skillId || `skill-${index}`, id: skill.skillId || `skill-${index}`,
name: skill.name, name: skill.name,
@ -225,16 +308,30 @@ export default function EmployeeForm() {
officialTitle: formData.officialTitle || undefined, officialTitle: formData.officialTitle || undefined,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
createdBy: 'admin' createdBy: user?.id ?? 'frontend'
} }
const result = await employeeApi.create({ ...newEmployee, primaryUnitId }) const payload = {
const newEmployeeId = result.data.id ...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
// Upload photo if we have one
if (photoFile && newEmployeeId) { if (photoFile && newEmployeeId) {
const formData = new FormData() const uploadForm = new FormData()
formData.append('photo', photoFile) uploadForm.append('photo', photoFile)
try { try {
await fetch(`http://localhost:3001/api/upload/employee-photo/${newEmployeeId}`, { await fetch(`http://localhost:3001/api/upload/employee-photo/${newEmployeeId}`, {
@ -242,13 +339,17 @@ export default function EmployeeForm() {
headers: { headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token')}`
}, },
body: formData body: uploadForm
}) })
} catch (uploadError) { } catch (uploadError) {
console.error('Failed to upload photo:', 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') navigate('/employees')
} catch (err: any) { } catch (err: any) {
if (err.response?.status === 401) { if (err.response?.status === 401) {
@ -349,7 +450,7 @@ export default function EmployeeForm() {
<div> <div>
<label className="block text-sm font-medium text-secondary mb-2"> <label className="block text-sm font-medium text-secondary mb-2">
Personalnummer * Personalnummer (optional)
</label> </label>
<input <input
type="text" type="text"
@ -357,7 +458,7 @@ export default function EmployeeForm() {
value={formData.employeeNumber} value={formData.employeeNumber}
onChange={handleChange} onChange={handleChange}
className={`input-field ${validationErrors.employeeNumber ? 'border-red-500 ring-red-200' : ''}`} className={`input-field ${validationErrors.employeeNumber ? 'border-red-500 ring-red-200' : ''}`}
required placeholder="Kann später ergänzt werden"
/> />
{validationErrors.employeeNumber && ( {validationErrors.employeeNumber && (
<p className="mt-1 text-sm text-red-600">{validationErrors.employeeNumber}</p> <p className="mt-1 text-sm text-red-600">{validationErrors.employeeNumber}</p>
@ -383,16 +484,22 @@ export default function EmployeeForm() {
<div> <div>
<label className="block text-sm font-medium text-secondary mb-2"> <label className="block text-sm font-medium text-secondary mb-2">
Position * Position (optional)
</label> </label>
<input <select
type="text"
name="position" name="position"
value={formData.position} value={formData.position}
onChange={handleChange} onChange={handleChange}
className={`input-field ${validationErrors.position ? 'border-red-500 ring-red-200' : ''}`} 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 && ( {validationErrors.position && (
<p className="mt-1 text-sm text-red-600">{validationErrors.position}</p> <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"> <label className="block text-sm font-medium text-secondary mb-2">
Amtsbezeichnung Amtsbezeichnung
</label> </label>
<input <select
type="text"
name="officialTitle" name="officialTitle"
value={formData.officialTitle} value={formData.officialTitle}
onChange={handleChange} onChange={handleChange}
className="input-field" 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>
<div> <div>
<label className="block text-sm font-medium text-secondary mb-2"> <label className="block text-sm font-medium text-secondary mb-2">
Abteilung * Abteilung (optional wird aus der Organisationseinheit vorgeschlagen)
</label> </label>
<input <input
type="text" type="text"
@ -422,7 +535,7 @@ export default function EmployeeForm() {
value={formData.department} value={formData.department}
onChange={handleChange} onChange={handleChange}
className={`input-field ${validationErrors.department ? 'border-red-500 ring-red-200' : ''}`} className={`input-field ${validationErrors.department ? 'border-red-500 ring-red-200' : ''}`}
required placeholder="Wird automatisch nach Auswahl der Einheit befüllt"
/> />
{validationErrors.department && ( {validationErrors.department && (
<p className="mt-1 text-sm text-red-600">{validationErrors.department}</p> <p className="mt-1 text-sm text-red-600">{validationErrors.department}</p>
@ -435,10 +548,18 @@ export default function EmployeeForm() {
</label> </label>
<OrganizationSelector <OrganizationSelector
value={primaryUnitId || undefined} value={primaryUnitId || undefined}
onChange={(unitId, unitName) => { onChange={(unitId, formattedValue, details) => {
if (user?.canManageEmployees && user?.powerUnitId) return
setPrimaryUnitId(unitId) 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> <p className="text-tertiary text-sm mt-2">{primaryUnitName || 'Bitte auswählen'}</p>
{validationErrors.primaryUnitId && ( {validationErrors.primaryUnitId && (
@ -448,7 +569,7 @@ export default function EmployeeForm() {
<div> <div>
<label className="block text-sm font-medium text-secondary mb-2"> <label className="block text-sm font-medium text-secondary mb-2">
Telefon * Telefon (optional)
</label> </label>
<input <input
type="tel" type="tel"
@ -456,7 +577,7 @@ export default function EmployeeForm() {
value={formData.phone} value={formData.phone}
onChange={handleChange} onChange={handleChange}
className={`input-field ${validationErrors.phone ? 'border-red-500 ring-red-200' : ''}`} className={`input-field ${validationErrors.phone ? 'border-red-500 ring-red-200' : ''}`}
required placeholder="Kann später ergänzt werden"
/> />
{validationErrors.phone && ( {validationErrors.phone && (
<p className="mt-1 text-sm text-red-600">{validationErrors.phone}</p> <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 { SearchIcon } from '../components/icons'
import EmployeeCard from '../components/EmployeeCard' import EmployeeCard from '../components/EmployeeCard'
import { employeeApi } from '../services/api' import { employeeApi } from '../services/api'
import { useAuthStore } from '../stores/authStore'
import type { Employee } from '@skillmate/shared' import type { Employee } from '@skillmate/shared'
import { usePermissions } from '../hooks/usePermissions' import { usePermissions } from '../hooks/usePermissions'
import { normalizeDepartment } from '../utils/text'
export default function EmployeeList() { export default function EmployeeList() {
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuthStore()
const { canCreateEmployee } = usePermissions() const { canCreateEmployee } = usePermissions()
const [employees, setEmployees] = useState<Employee[]>([]) const [employees, setEmployees] = useState<Employee[]>([])
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@ -59,23 +58,37 @@ export default function EmployeeList() {
} }
useEffect(() => { useEffect(() => {
const skillFilter = filters.skills.trim().toLowerCase()
const searchFilter = searchTerm.trim().toLowerCase()
let filtered = employees.filter(emp => { let filtered = employees.filter(emp => {
// Text search const departmentLabel = normalizeDepartment(emp.department)
const matchesSearch = searchTerm === '' || const departmentDescription = normalizeDepartment(emp.departmentDescription)
`${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchTerm.toLowerCase()) || const departmentTasks = normalizeDepartment(emp.departmentTasks || emp.departmentDescription)
emp.department.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.position.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.specializations.some(spec => spec.toLowerCase().includes(searchTerm.toLowerCase()))
// Department filter const matchesSkillSearch = (emp.skills || []).some(skill =>
const matchesDepartment = filters.department === '' || emp.department === filters.department (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
// Availability filter const matchesDepartment = !filters.department || departmentLabel === filters.department
const matchesAvailability = filters.availability === '' || emp.availability === filters.availability const matchesAvailability = !filters.availability || emp.availability === filters.availability
// Skills filter (basic - später erweitern) const matchesSkills = skillFilter === '' ||
const matchesSkills = filters.skills === '' || (emp.skills || []).some(skill =>
emp.specializations.some(spec => spec.toLowerCase().includes(filters.skills.toLowerCase())) (skill.name || '').toLowerCase().includes(skillFilter) ||
(skill.category || '').toLowerCase().includes(skillFilter)
) ||
emp.specializations.some(spec => spec.toLowerCase().includes(skillFilter))
return matchesSearch && matchesDepartment && matchesAvailability && matchesSkills return matchesSearch && matchesDepartment && matchesAvailability && matchesSkills
}) })
@ -83,6 +96,10 @@ export default function EmployeeList() {
setFilteredEmployees(filtered) setFilteredEmployees(filtered)
}, [searchTerm, employees, filters]) }, [searchTerm, employees, filters])
const departmentOptions = Array.from(new Set(
employees.map(emp => normalizeDepartment(emp.department)).filter(Boolean)
)).sort()
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-8"> <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" 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="">Alle Dienststellen</option>
<option value="Cybercrime">Cybercrime</option> {departmentOptions.map(option => (
<option value="Staatsschutz">Staatsschutz</option> <option key={option} value={option}>{option}</option>
<option value="Kriminalpolizei">Kriminalpolizei</option> ))}
<option value="IT">IT</option>
<option value="Verwaltung">Verwaltung</option>
</select> </select>
</div> </div>
@ -183,6 +198,7 @@ export default function EmployeeList() {
key={employee.id} key={employee.id}
employee={employee} employee={employee}
onClick={() => navigate(`/employees/${employee.id}`)} onClick={() => navigate(`/employees/${employee.id}`)}
onDeputyNavigate={(id) => navigate(`/employees/${id}`)}
/> />
))} ))}
</div> </div>

Datei anzeigen

@ -10,6 +10,10 @@ export default function Login() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() 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 ( return (
<div className="min-h-screen bg-primary flex items-center justify-center"> <div className="min-h-screen bg-primary flex items-center justify-center">
<div className="card max-w-md w-full"> <div className="card max-w-md w-full">
@ -82,6 +123,49 @@ export default function Login() {
</button> </button>
</form> </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"> <div className="mt-6 text-center text-sm text-tertiary">
<p>Für erste Anmeldung wenden Sie sich an Ihren Administrator</p> <p>Für erste Anmeldung wenden Sie sich an Ihren Administrator</p>
</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 { useAuthStore } from '../stores/authStore'
import { employeeApi } from '../services/api' import { employeeApi, officialTitlesApi, positionCatalogApi } from '../services/api'
import PhotoUpload from '../components/PhotoUpload' import PhotoUpload from '../components/PhotoUpload'
import SkillLevelBar from '../components/SkillLevelBar' import SkillLevelBar from '../components/SkillLevelBar'
import DeputyManagement from '../components/DeputyManagement' 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 } 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() { export default function MyProfile() {
const { user } = useAuthStore() const { user } = useAuthStore()
const employeeId = user?.employeeId const employeeId = user?.employeeId
@ -17,23 +37,17 @@ export default function MyProfile() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const [form, setForm] = useState<any | null>(null) 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 [catalog, setCatalog] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
const [skills, setSkills] = useState<SkillSelection[]>([]) const [skills, setSkills] = useState<SkillSelection[]>([])
const [activeTab, setActiveTab] = useState<'profile' | 'deputies'>('profile') const [activeTab, setActiveTab] = useState<'profile' | 'deputies'>('profile')
const [currentUnitId, setCurrentUnitId] = useState<string | null>(null) const [currentUnitId, setCurrentUnitId] = useState<string | null>(null)
const [myUnits, setMyUnits] = useState<any[]>([]) const [myUnits, setMyUnits] = useState<any[]>([])
const AVAILABILITY_OPTIONS = [ const [officialTitleOptions, setOfficialTitleOptions] = useState<string[]>([])
{ value: 'available', label: 'Verfügbar' }, const [positionOptions, setPositionOptions] = useState<string[]>(DEFAULT_POSITION_OPTIONS)
{ value: 'busy', label: 'Beschäftigt' }, const [showAvailabilityHint, setShowAvailabilityHint] = useState(false)
{ value: 'away', label: 'Abwesend' }, const [highlightDelegations, setHighlightDelegations] = useState(false)
{ value: 'vacation', label: 'Urlaub' }, const highlightTimeoutRef = useRef<number | undefined>(undefined)
{ value: 'sick', label: 'Erkrankt' },
{ value: 'training', label: 'Fortbildung' },
{ value: 'operation', label: 'Im Einsatz' },
{ value: 'parttime', label: 'Teilzeit' },
{ value: 'unavailable', label: 'Nicht verfügbar' }
]
useEffect(() => { useEffect(() => {
if (!employeeId) { if (!employeeId) {
@ -43,6 +57,46 @@ const AVAILABILITY_OPTIONS = [
load() load()
}, [employeeId]) }, [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 () => { const load = async () => {
if (!employeeId) return if (!employeeId) return
setLoading(true) setLoading(true)
@ -50,6 +104,7 @@ const AVAILABILITY_OPTIONS = [
try { try {
// Load employee first // Load employee first
const data = await employeeApi.getById(employeeId) const data = await employeeApi.getById(employeeId)
const availability = data.availability || 'available'
setForm({ ...data, email: user?.email || data.email || '' }) setForm({ ...data, email: user?.email || data.email || '' })
const mapped: SkillSelection[] = (data.skills || []).map((s: any) => { const mapped: SkillSelection[] = (data.skills || []).map((s: any) => {
const catStr = s.category || '' const catStr = s.category || ''
@ -67,6 +122,7 @@ const AVAILABILITY_OPTIONS = [
} }
}) })
setSkills(mapped) setSkills(mapped)
setShowAvailabilityHint(availability !== 'available')
// Load my organizational units // Load my organizational units
try { 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)) 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) => const isSkillSelected = (categoryId: string, subCategoryId: string, skillId: string) =>
skills.some(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId) skills.some(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)
const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) => const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) =>
(skills.find(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)?.level) || '' (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) setCurrentUnitId(unitId)
setForm((prev: any) => ({
...prev,
department: formattedValue || '',
departmentDescription: details?.descriptionPath || details?.namesPath || '',
departmentTasks: details?.tasks || ''
}))
if (unitId && employeeId) { if (unitId && employeeId) {
try { try {
// Save organization assignment
await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/organization/assignments', { await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/organization/assignments', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -136,14 +224,12 @@ const AVAILABILITY_OPTIONS = [
'Authorization': `Bearer ${localStorage.getItem('token')}` 'Authorization': `Bearer ${localStorage.getItem('token')}`
}, },
body: JSON.stringify({ body: JSON.stringify({
employeeId: employeeId, employeeId,
unitId: unitId, unitId,
role: 'mitarbeiter', role: 'mitarbeiter',
isPrimary: true isPrimary: true
}) })
}) })
// Update department field with unit name for backward compatibility
setForm((prev: any) => ({ ...prev, department: unitName }))
} catch (error) { } catch (error) {
console.error('Failed to assign unit:', 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"> <div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
<button <button
onClick={() => setActiveTab('profile')} 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 Profildaten
</button> </button>
<button <button
onClick={() => setActiveTab('deputies')} 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 Vertretungen
</button> </button>
@ -259,33 +349,45 @@ const AVAILABILITY_OPTIONS = [
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">E-Mail</label> <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" /> <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" />
<p className="text-small text-tertiary mt-1">Wird automatisch aus dem Login übernommen. Änderung ggf. im Admin Panel.</p>
</div> </div>
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">Position</label> <label className="block text-body font-medium text-secondary mb-2">Position</label>
<input <select
className="input-field w-full" className="input-field w-full"
value={form.position || ''} value={form.position || ''}
onChange={(e) => setForm((p: any) => ({ ...p, position: e.target.value }))} onChange={(e) => setForm((p: any) => ({ ...p, position: e.target.value }))}
placeholder="z. B. Sachbearbeitung, Teamleitung" title="Neutrale Funktionsbezeichnung auswählen"
/> >
<p className="text-small text-tertiary mt-1">Beispiele: Sachbearbeitung, Teamleitung, Stabsstelle.</p> <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>
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">Amtsbezeichnung</label> <label className="block text-body font-medium text-secondary mb-2">Amtsbezeichnung</label>
<input <select
className="input-field w-full" className="input-field w-full"
value={form.officialTitle || ''} value={form.officialTitle || ''}
onChange={(e) => setForm((p: any) => ({ ...p, officialTitle: e.target.value }))} onChange={(e) => setForm((p: any) => ({ ...p, officialTitle: e.target.value }))}
placeholder="z. B. KOK, KHK, EKHK" title="Dienstliche Amtsbezeichnung auswählen"
/> >
<p className="text-small text-tertiary mt-1">Freifeld für Amts- bzw. Dienstbezeichnungen (z. B. KOK, RBe, EKHK).</p> <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>
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">NW-Kennung</label> <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" /> <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" />
<p className="text-small text-tertiary mt-1">Ihre behördliche Kennung, z. B. NW068111.</p>
</div> </div>
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">Dienststelle</label> <label className="block text-body font-medium text-secondary mb-2">Dienststelle</label>
@ -293,41 +395,50 @@ const AVAILABILITY_OPTIONS = [
value={currentUnitId || undefined} value={currentUnitId || undefined}
onChange={handleOrganizationChange} onChange={handleOrganizationChange}
disabled={false} 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>
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">Telefon</label> <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" /> <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" />
<p className="text-small text-tertiary mt-1">Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).</p>
</div> </div>
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">Mobil</label> <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" /> <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" />
<p className="text-small text-tertiary mt-1">Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).</p>
</div> </div>
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">Büro</label> <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" /> <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" />
<p className="text-small text-tertiary mt-1">Angabe zum Standort, z. B. Gebäude, Etage und Raum.</p>
</div> </div>
<div> <div>
<label className="block text-body font-medium text-secondary mb-2">Verfügbarkeit</label> <label className="block text-body font-medium text-secondary mb-2">Verfügbarkeit</label>
<select <select
className="input-field w-full" className="input-field w-full"
value={form.availability || 'available'} 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 => ( {AVAILABILITY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option> <option key={option.value} value={option.value}>{option.label}</option>
))} ))}
</select> </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> </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"> <div className="card mt-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Kompetenzen</h2> <h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Kompetenzen</h2>
<div className="space-y-4"> <div className="space-y-4">
@ -341,26 +452,33 @@ const AVAILABILITY_OPTIONS = [
{sub.skills.map(skill => { {sub.skills.map(skill => {
const booleanSkill = isBooleanSkill(category.id, sub.id) const booleanSkill = isBooleanSkill(category.id, sub.id)
const selected = isSkillSelected(category.id, sub.id, skill.id) const selected = isSkillSelected(category.id, sub.id, skill.id)
const skillInputId = `skill-${category.id}-${sub.id}-${skill.id}`
return ( return (
<div <div
key={`${category.id}-${sub.id}-${skill.id}`} key={`${category.id}-${sub.id}-${skill.id}`}
className={`p-2 border rounded-input ${selected ? 'border-primary-blue bg-bg-accent' : 'border-border-default'}`} className={`p-2 border rounded-input ${selected ? 'border-primary-blue bg-bg-accent' : 'border-border-default'}`}
> >
<label className="flex items-center justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-body text-secondary"> <label
htmlFor={skillInputId}
className="flex items-start gap-2 flex-1 min-w-0 text-body text-secondary cursor-pointer"
>
<input <input
id={skillInputId}
type="checkbox" type="checkbox"
className="mr-2" className="mt-0.5 shrink-0"
checked={selected} checked={selected}
onChange={() => handleSkillToggle(category.id, sub.id, skill.id, skill.name)} onChange={() => handleSkillToggle(category.id, sub.id, skill.id, skill.name)}
/> />
{skill.name} <span className="truncate" title={skill.name}>{skill.name}</span>
</span> </label>
{selected && ( {selected && (
booleanSkill ? ( 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 <SkillLevelBar
value={Number(getSkillLevel(category.id, sub.id, skill.id)) || ''} value={Number(getSkillLevel(category.id, sub.id, skill.id)) || ''}
onChange={(val) => handleSkillLevelChange(category.id, sub.id, skill.id, String(val))} onChange={(val) => handleSkillLevelChange(category.id, sub.id, skill.id, String(val))}
@ -368,7 +486,7 @@ const AVAILABILITY_OPTIONS = [
</div> </div>
) )
)} )}
</label> </div>
</div> </div>
) )
})} })}

Datei anzeigen

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

Datei anzeigen

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import type { Employee } from '@skillmate/shared' import type { Employee } from '@skillmate/shared'
import { employeeApi } from '../services/api' import { employeeApi } from '../services/api'
import { SearchIcon } from '../components/icons' import { SearchIcon } from '../components/icons'
import { normalizeDepartment, formatDepartmentWithDescription } from '../utils/text'
type TeamPosition = { type TeamPosition = {
id: string id: string
@ -146,7 +147,9 @@ export default function TeamZusammenstellung() {
} }
// Get unique departments and positions // 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))) const positions = Array.from(new Set(employees.map(e => e.position).filter(Boolean)))
// Toggle category selection // Toggle category selection
@ -241,7 +244,7 @@ export default function TeamZusammenstellung() {
// Department filter // Department filter
if (selectedDepartment) { if (selectedDepartment) {
filtered = filtered.filter(emp => emp.department === selectedDepartment) filtered = filtered.filter(emp => normalizeDepartment(emp.department) === selectedDepartment)
} }
// Position filter // Position filter
@ -652,6 +655,8 @@ export default function TeamZusammenstellung() {
filteredEmployees.map((employee: any) => { filteredEmployees.map((employee: any) => {
const isAssigned = teamPositions.some(p => p.assignedEmployeeId === employee.id) const isAssigned = teamPositions.some(p => p.assignedEmployeeId === employee.id)
const matchScore = employee.matchScore || 0 const matchScore = employee.matchScore || 0
const departmentInfo = formatDepartmentWithDescription(employee.department, employee.departmentDescription)
const departmentText = departmentInfo.description ? `${departmentInfo.label}${departmentInfo.description}` : departmentInfo.label
return ( return (
<div <div
@ -690,7 +695,7 @@ export default function TeamZusammenstellung() {
<div className="text-sm text-tertiary"> <div className="text-sm text-tertiary">
{employee.position} {employee.position}
{employee.officialTitle ? `${employee.officialTitle}` : ''} {employee.officialTitle ? `${employee.officialTitle}` : ''}
{`${employee.department}`} {departmentText ? `${departmentText}` : ''}
</div> </div>
</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(): if admin_dir.exists():
print(f" - Admin: http://localhost:{self.admin_port}") print(f" - Admin: http://localhost:{self.admin_port}")
# Öffne Frontend im Browser # Öffne Frontend und Admin Panel im Browser
time.sleep(3) time.sleep(3)
webbrowser.open(f"http://localhost:{self.frontend_port}") 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") 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 isActive: boolean
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
powerUnitId?: string | null
powerUnitName?: string | null
powerUnitType?: OrganizationalUnitType | null
powerFunction?: PowerFunction | null
canManageEmployees?: boolean
} }
export interface Skill { export interface Skill {
@ -39,6 +44,15 @@ export interface Clearance {
issuedDate: Date 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 { export interface Employee {
id: string id: string
firstName: string firstName: string
@ -48,6 +62,11 @@ export interface Employee {
position: string position: string
officialTitle?: string | null officialTitle?: string | null
department: string department: string
departmentDescription?: string | null
departmentTasks?: string | null
primaryUnitId?: string | null
primaryUnitCode?: string | null
primaryUnitName?: string | null
email?: string | null email?: string | null
phone?: string | null phone?: string | null
mobile?: string | null mobile?: string | null
@ -57,12 +76,23 @@ export interface Employee {
languages?: LanguageSkill[] languages?: LanguageSkill[]
clearance?: Clearance clearance?: Clearance
specializations?: string[] specializations?: string[]
currentDeputies?: EmployeeDeputySummary[]
represents?: EmployeeDeputySummary[]
createdAt: Date | string createdAt: Date | string
updatedAt: Date | string updatedAt: Date | string
createdBy?: string createdBy?: string
updatedBy?: string | null updatedBy?: string | null
} }
export interface EmployeeDeputySummary {
id: string
firstName: string
lastName: string
availability?: string | null
position?: string | null
assignmentId?: string
}
export interface SkillDefinition { export interface SkillDefinition {
id: string id: string
name: string name: string
@ -89,6 +119,8 @@ export interface LoginResponse {
export const ROLE_PERMISSIONS: Record<UserRole, string[]> export const ROLE_PERMISSIONS: Record<UserRole, string[]>
export const POWER_FUNCTIONS: PowerFunctionDefinition[]
export const DEFAULT_SKILLS: Record<string, string[]> export const DEFAULT_SKILLS: Record<string, string[]>
export const LANGUAGE_LEVELS: string[] export const LANGUAGE_LEVELS: string[]
export interface SkillLevel { id: string; name: string; level?: string } export interface SkillLevel { id: string; name: string; level?: string }
@ -122,7 +154,7 @@ export interface WorkspaceFilter {
} }
// Organization // 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 type EmployeeUnitRole = 'leiter' | 'stellvertreter' | 'mitarbeiter' | 'beauftragter'
export interface OrganizationalUnit { export interface OrganizationalUnit {
@ -216,3 +248,13 @@ export interface BookingRequest {
endTime: string endTime: string
notes?: 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 // 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 = { const ROLE_PERMISSIONS = {
admin: [ admin: [
'admin:panel:access', 'admin:panel:access',
@ -30,9 +38,7 @@ const ROLE_PERMISSIONS = {
] ]
} }
module.exports = { const DEFAULT_SKILLS = {
ROLE_PERMISSIONS,
DEFAULT_SKILLS: {
general: [ general: [
'Teamarbeit', 'Teamarbeit',
'Kommunikation', 'Kommunikation',
@ -52,14 +58,21 @@ module.exports = {
'WBK A', 'WBK A',
'WBK B' 'WBK B'
] ]
}
} }
exports.ROLE_PERMISSIONS = ROLE_PERMISSIONS
exports.POWER_FUNCTIONS = POWER_FUNCTIONS
exports.DEFAULT_SKILLS = DEFAULT_SKILLS
// Re-export skill constants // Re-export skill constants
try { try {
const { LANGUAGE_LEVELS, SKILL_HIERARCHY } = require('./skills') const { LANGUAGE_LEVELS, SKILL_HIERARCHY } = require('./skills')
module.exports.LANGUAGE_LEVELS = LANGUAGE_LEVELS exports.LANGUAGE_LEVELS = LANGUAGE_LEVELS
module.exports.SKILL_HIERARCHY = SKILL_HIERARCHY exports.SKILL_HIERARCHY = SKILL_HIERARCHY
} catch (e) { } catch (e) {
// no-op if skills.js not present // 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, "private": true,
"main": "index.js", "main": "index.js",
"types": "index.d.ts", "types": "index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.mjs",
"require": "./index.js"
}
},
"license": "UNLICENSED" "license": "UNLICENSED"
} }