Update changes
Dieser Commit ist enthalten in:
976
admin-panel/package-lock.json
generiert
976
admin-panel/package-lock.json
generiert
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -27,6 +27,10 @@
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.7"
|
||||
"vite": "^7.1.8"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.52.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "^4.52.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ import UserManagement from './views/UserManagement'
|
||||
import EmailSettings from './views/EmailSettings'
|
||||
import SyncSettings from './views/SyncSettings'
|
||||
import OrganizationEditor from './views/OrganizationEditor'
|
||||
import OfficialTitles from './views/OfficialTitles'
|
||||
import Positions from './views/Positions'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
function App() {
|
||||
@ -40,6 +42,8 @@ function App() {
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
<Route path="/users/create-employee" element={<CreateEmployee />} />
|
||||
<Route path="/email-settings" element={<EmailSettings />} />
|
||||
<Route path="/positions" element={<Positions />} />
|
||||
<Route path="/official-titles" element={<OfficialTitles />} />
|
||||
<Route path="/sync" element={<SyncSettings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
@ -48,4 +52,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { ComponentType, ReactNode, SVGProps, useEffect, useState } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import {
|
||||
HomeIcon,
|
||||
@ -7,24 +7,68 @@ import {
|
||||
SettingsIcon,
|
||||
MailIcon
|
||||
} from './icons'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { Building2, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
type NavItem = {
|
||||
name: string
|
||||
href: string
|
||||
icon: IconComponent
|
||||
}
|
||||
|
||||
type NavGroup = {
|
||||
name: string
|
||||
icon: IconComponent
|
||||
children: NavItem[]
|
||||
}
|
||||
|
||||
type NavigationEntry = NavItem | NavGroup
|
||||
|
||||
const navigation: NavigationEntry[] = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Organigramm', href: '/organization', icon: Building2 },
|
||||
{ name: 'Benutzerverwaltung', href: '/users', icon: UsersIcon },
|
||||
{ name: 'Skills verwalten', href: '/skills', icon: SettingsIcon },
|
||||
{ name: 'Skillverwaltung', href: '/skills', icon: SettingsIcon },
|
||||
{
|
||||
name: 'Frontend-Daten',
|
||||
icon: SettingsIcon,
|
||||
children: [
|
||||
{ name: 'Organigramm', href: '/organization', icon: Building2 },
|
||||
{ name: 'Positionen', href: '/positions', icon: SettingsIcon },
|
||||
{ name: 'Amtsbezeichnungen', href: '/official-titles', icon: SettingsIcon }
|
||||
]
|
||||
},
|
||||
{ name: 'E-Mail-Einstellungen', href: '/email-settings', icon: MailIcon },
|
||||
{ name: 'Synchronisation', href: '/sync', icon: SettingsIcon },
|
||||
{ name: 'Synchronisation', href: '/sync', icon: SettingsIcon }
|
||||
]
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const findGroupForPath = (pathname: string) => navigation.find((entry): entry is NavGroup => 'children' in entry && entry.children.some(child => child.href === pathname))
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<string[]>(() => {
|
||||
const activeGroup = findGroupForPath(location.pathname)
|
||||
return activeGroup ? [activeGroup.name] : []
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const activeGroup = findGroupForPath(location.pathname)
|
||||
if (activeGroup) {
|
||||
setOpenGroups((prev) => (prev.includes(activeGroup.name) ? prev : [...prev, activeGroup.name]))
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
const toggleGroup = (name: string) => {
|
||||
setOpenGroups((prev) => (prev.includes(name) ? prev.filter(item => item !== name) : [...prev, name]))
|
||||
}
|
||||
|
||||
const isGroupOpen = (name: string) => openGroups.includes(name)
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
@ -41,18 +85,57 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
|
||||
<nav className="px-5 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`sidebar-item ${isActive ? 'sidebar-item-active' : ''}`
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5 mr-3 flex-shrink-0" />
|
||||
<span className="font-poppins font-medium">{item.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{navigation.map((entry) => {
|
||||
if ('children' in entry) {
|
||||
const group = entry as NavGroup
|
||||
const open = isGroupOpen(group.name)
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
className={`sidebar-item w-full flex items-center justify-between ${open ? 'sidebar-item-active' : ''}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<group.icon className="w-5 h-5 mr-3 flex-shrink-0" />
|
||||
<span className="font-poppins font-medium">{group.name}</span>
|
||||
</div>
|
||||
<ChevronRight className={`w-4 h-4 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-1 ml-8 space-y-1">
|
||||
{group.children.map((child) => (
|
||||
<NavLink
|
||||
key={child.name}
|
||||
to={child.href}
|
||||
className={({ isActive }) =>
|
||||
`sidebar-item ${isActive ? 'sidebar-item-active' : ''}`
|
||||
}
|
||||
>
|
||||
<child.icon className="w-4 h-4 mr-3 flex-shrink-0" />
|
||||
<span className="font-poppins text-sm">{child.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const item = entry as NavItem
|
||||
return (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`sidebar-item ${isActive ? 'sidebar-item-active' : ''}`
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5 mr-3 flex-shrink-0" />
|
||||
<span className="font-poppins font-medium">{item.name}</span>
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-5 border-t border-border-default">
|
||||
|
||||
@ -3,9 +3,19 @@ import type { OrganizationalUnit } from '@skillmate/shared'
|
||||
import { ChevronRight, Building2, Search, X } from 'lucide-react'
|
||||
import { api } from '../services/api'
|
||||
|
||||
interface SelectedUnitDetails {
|
||||
unit: OrganizationalUnit
|
||||
storageValue: string
|
||||
codePath: string
|
||||
displayPath: string
|
||||
namesPath: string
|
||||
descriptionPath?: string
|
||||
tasks?: string
|
||||
}
|
||||
|
||||
interface OrganizationSelectorProps {
|
||||
value?: string | null
|
||||
onChange: (unitId: string | null, unitName: string) => void
|
||||
onChange: (unitId: string | null, formattedValue: string, details?: SelectedUnitDetails | null) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@ -28,9 +38,9 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
|
||||
if (value && hierarchy.length > 0) {
|
||||
const unit = findUnitById(hierarchy, value)
|
||||
if (unit) {
|
||||
const path = getUnitPath(hierarchy, unit)
|
||||
const selection = buildSelectionDetails(hierarchy, unit)
|
||||
setSelectedUnit(unit)
|
||||
setCurrentUnitName(path)
|
||||
setCurrentUnitName(selection.displayPath)
|
||||
}
|
||||
}
|
||||
if (!value) {
|
||||
@ -75,23 +85,63 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
|
||||
return null
|
||||
}
|
||||
|
||||
const getUnitPath = (units: OrganizationalUnit[], target: OrganizationalUnit): string => {
|
||||
const path: string[] = []
|
||||
const traverse = (nodes: OrganizationalUnit[], trail: string[] = []): boolean => {
|
||||
const buildSelectionDetails = (units: OrganizationalUnit[], target: OrganizationalUnit): SelectedUnitDetails => {
|
||||
const pathUnits: OrganizationalUnit[] = []
|
||||
|
||||
const traverse = (nodes: OrganizationalUnit[], trail: OrganizationalUnit[] = []): boolean => {
|
||||
for (const node of nodes) {
|
||||
const currentTrail = [...trail, node.name]
|
||||
const extended = [...trail, node]
|
||||
if (node.id === target.id) {
|
||||
path.push(...currentTrail)
|
||||
pathUnits.splice(0, pathUnits.length, ...extended)
|
||||
return true
|
||||
}
|
||||
if (node.children && traverse(node.children, currentTrail)) {
|
||||
if (node.children && traverse(node.children, extended)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
traverse(units)
|
||||
return path.join(' → ')
|
||||
|
||||
const normalize = (value?: string | null) => (value ?? '').trim()
|
||||
|
||||
const codeSegments = pathUnits
|
||||
.map(unit => normalize(unit.code) || normalize(unit.name))
|
||||
.filter(Boolean)
|
||||
|
||||
const nameSegments = pathUnits
|
||||
.map(unit => normalize(unit.name))
|
||||
.filter(Boolean)
|
||||
|
||||
const displaySegments = pathUnits
|
||||
.map(unit => {
|
||||
const code = normalize(unit.code)
|
||||
const name = normalize(unit.name)
|
||||
if (code && name && code !== name) {
|
||||
return `${code} – ${name}`
|
||||
}
|
||||
return code || name || ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const codePath = codeSegments.join(' -> ')
|
||||
const displayPath = displaySegments.join(' → ')
|
||||
const namesPath = nameSegments.join(' → ')
|
||||
const descriptionPath = nameSegments.slice(0, -1).join(' → ') || undefined
|
||||
const rawTask = normalize(target.description) || normalize(target.name)
|
||||
const tasks = rawTask || undefined
|
||||
const storageValue = tasks ? (codePath ? `${codePath} -> ${tasks}` : tasks) : codePath
|
||||
|
||||
return {
|
||||
unit: target,
|
||||
storageValue,
|
||||
codePath: codePath || namesPath,
|
||||
displayPath: displayPath || namesPath || tasks || '',
|
||||
namesPath,
|
||||
descriptionPath,
|
||||
tasks
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (unitId: string) => {
|
||||
@ -110,13 +160,13 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
|
||||
if (!unit) {
|
||||
setSelectedUnit(null)
|
||||
setCurrentUnitName('')
|
||||
onChange(null, '')
|
||||
onChange(null, '', null)
|
||||
return
|
||||
}
|
||||
const selection = buildSelectionDetails(hierarchy, unit)
|
||||
setSelectedUnit(unit)
|
||||
const path = getUnitPath(hierarchy, unit)
|
||||
setCurrentUnitName(path)
|
||||
onChange(unit.id, path)
|
||||
setCurrentUnitName(selection.displayPath)
|
||||
onChange(unit.id, selection.storageValue, selection)
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
|
||||
@ -36,4 +36,36 @@ api.interceptors.response.use(
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
export default api
|
||||
|
||||
export const positionsApi = {
|
||||
getLabels: async (unitId?: string | null): Promise<string[]> => {
|
||||
const params = unitId ? { unitId } : undefined
|
||||
const response = await api.get('/positions', { params })
|
||||
const list = Array.isArray(response.data?.data) ? response.data.data : []
|
||||
const seen = new Set<string>()
|
||||
return list
|
||||
.map((item: any) => item?.label)
|
||||
.filter((label: any) => typeof label === 'string' && label.trim().length > 0)
|
||||
.filter((label: string) => {
|
||||
const key = label.trim().toLowerCase()
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
},
|
||||
listAdmin: async (unitId?: string | null) => {
|
||||
const params = unitId ? { unitId } : undefined
|
||||
const response = await api.get('/positions/admin', { params })
|
||||
return Array.isArray(response.data?.data) ? response.data.data : []
|
||||
},
|
||||
create: async (payload: { label: string; organizationUnitId?: string | null }) => {
|
||||
return api.post('/positions', payload)
|
||||
},
|
||||
update: async (id: string, payload: { label?: string; isActive?: boolean; orderIndex?: number; organizationUnitId?: string | null }) => {
|
||||
return api.put(`/positions/${id}`, payload)
|
||||
},
|
||||
remove: async (id: string) => {
|
||||
return api.delete(`/positions/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../services/api'
|
||||
import type { EmployeeUnitRole, UserRole } from '@skillmate/shared'
|
||||
import { POWER_FUNCTIONS } from '@skillmate/shared'
|
||||
import type { EmployeeUnitRole, UserRole, OrganizationalUnitType, PowerFunctionDefinition } from '@skillmate/shared'
|
||||
import { OrganizationSelector } from '../components'
|
||||
|
||||
interface CreateEmployeeData {
|
||||
@ -12,6 +13,7 @@ interface CreateEmployeeData {
|
||||
department: string
|
||||
userRole: 'admin' | 'superuser' | 'user'
|
||||
createUser: boolean
|
||||
powerFunction: string
|
||||
}
|
||||
|
||||
export default function CreateEmployee() {
|
||||
@ -22,22 +24,57 @@ export default function CreateEmployee() {
|
||||
const [createdUser, setCreatedUser] = useState<{ password?: string }>({})
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null)
|
||||
const [selectedUnitName, setSelectedUnitName] = useState('')
|
||||
const [selectedUnitType, setSelectedUnitType] = useState<OrganizationalUnitType | null>(null)
|
||||
const [selectedUnitRole, setSelectedUnitRole] = useState<EmployeeUnitRole>('mitarbeiter')
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm<CreateEmployeeData>({
|
||||
const { register, handleSubmit, formState: { errors }, watch, setValue, setError: setFormError, clearErrors } = useForm<CreateEmployeeData>({
|
||||
defaultValues: {
|
||||
userRole: 'user',
|
||||
createUser: true
|
||||
createUser: true,
|
||||
powerFunction: ''
|
||||
}
|
||||
})
|
||||
|
||||
const watchCreateUser = watch('createUser')
|
||||
const watchUserRole = watch('userRole')
|
||||
const watchPowerFunction = watch('powerFunction')
|
||||
|
||||
const availablePowerFunctions = useMemo<PowerFunctionDefinition[]>(() => {
|
||||
if (!selectedUnitType) return POWER_FUNCTIONS
|
||||
return POWER_FUNCTIONS.filter(def => def.unitTypes.includes(selectedUnitType))
|
||||
}, [selectedUnitType])
|
||||
|
||||
const selectedPowerFunctionDef = useMemo(() => {
|
||||
if (!watchPowerFunction) return null
|
||||
return availablePowerFunctions.find(def => def.id === watchPowerFunction) || null
|
||||
}, [availablePowerFunctions, watchPowerFunction])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUnitName) {
|
||||
setValue('department', selectedUnitName)
|
||||
clearErrors('department')
|
||||
}
|
||||
}, [selectedUnitName, setValue])
|
||||
}, [selectedUnitName, setValue, clearErrors])
|
||||
|
||||
useEffect(() => {
|
||||
if (!watchCreateUser || watchUserRole !== 'superuser') {
|
||||
setValue('powerFunction', '')
|
||||
clearErrors('powerFunction')
|
||||
}
|
||||
}, [watchCreateUser, watchUserRole, setValue, clearErrors])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUnitType) {
|
||||
return
|
||||
}
|
||||
|
||||
if (watchPowerFunction) {
|
||||
const stillValid = availablePowerFunctions.some(def => def.id === watchPowerFunction)
|
||||
if (!stillValid) {
|
||||
setValue('powerFunction', '')
|
||||
}
|
||||
}
|
||||
}, [selectedUnitType, availablePowerFunctions, watchPowerFunction, setValue])
|
||||
|
||||
const getUnitRoleLabel = (role: EmployeeUnitRole): string => {
|
||||
switch (role) {
|
||||
@ -53,10 +90,34 @@ export default function CreateEmployee() {
|
||||
}
|
||||
|
||||
const onSubmit = async (data: CreateEmployeeData) => {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
const requiresPowerMetadata = data.createUser && data.userRole === 'superuser'
|
||||
|
||||
if (requiresPowerMetadata && !selectedUnitId) {
|
||||
setFormError('department', { type: 'manual', message: 'Bitte wählen Sie eine Organisationseinheit für den Poweruser aus' })
|
||||
setError('Poweruser benötigen eine zugeordnete Organisationseinheit.')
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresPowerMetadata && !data.powerFunction) {
|
||||
setFormError('powerFunction', { type: 'manual', message: 'Bitte wählen Sie eine Funktion aus' })
|
||||
setError('Poweruser benötigen eine Funktion (z. B. Sachgebietsleitung).')
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresPowerMetadata && data.powerFunction) {
|
||||
const definition = POWER_FUNCTIONS.find(def => def.id === data.powerFunction)
|
||||
if (definition && selectedUnitType && !definition.unitTypes.includes(selectedUnitType)) {
|
||||
setFormError('powerFunction', { type: 'manual', message: `Die Funktion ${definition.label} passt nicht zur gewählten Einheit` })
|
||||
setError('Die ausgewählte Funktion ist für diesen Einheitstyp nicht zulässig.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
const payload = {
|
||||
firstName: data.firstName,
|
||||
@ -65,8 +126,10 @@ export default function CreateEmployee() {
|
||||
department: data.department,
|
||||
userRole: data.createUser ? data.userRole : undefined,
|
||||
createUser: data.createUser,
|
||||
organizationUnitId: selectedUnitId || undefined,
|
||||
organizationRole: selectedUnitId ? selectedUnitRole : undefined
|
||||
// employees API expects primaryUnitId + assignmentRole
|
||||
primaryUnitId: selectedUnitId || undefined,
|
||||
assignmentRole: selectedUnitId ? selectedUnitRole : undefined,
|
||||
powerFunction: requiresPowerMetadata ? data.powerFunction : undefined
|
||||
}
|
||||
|
||||
const response = await api.post('/employees', payload)
|
||||
@ -96,7 +159,7 @@ export default function CreateEmployee() {
|
||||
const getRoleDescription = (role: UserRole): string => {
|
||||
const descriptions = {
|
||||
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung',
|
||||
superuser: 'Kann Mitarbeitende anlegen und verwalten, aber kein Zugriff auf Admin Panel',
|
||||
superuser: 'Kann Mitarbeitende im eigenen Bereich anlegen und verwalten (abhängig von der zugewiesenen Funktion), kein Zugriff auf Admin Panel',
|
||||
user: 'Kann nur das eigene Profil bearbeiten und Mitarbeitende durchsuchen'
|
||||
}
|
||||
return descriptions[role]
|
||||
@ -223,9 +286,6 @@ export default function CreateEmployee() {
|
||||
placeholder="IT, Personal, Marketing, etc."
|
||||
readOnly={Boolean(selectedUnitId)}
|
||||
/>
|
||||
{errors.department && (
|
||||
<p className="text-error text-sm mt-1">{errors.department.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -242,17 +302,29 @@ export default function CreateEmployee() {
|
||||
</label>
|
||||
<OrganizationSelector
|
||||
value={selectedUnitId}
|
||||
onChange={(unitId, unitPath) => {
|
||||
onChange={(unitId, formattedValue, details) => {
|
||||
setSelectedUnitId(unitId)
|
||||
setSelectedUnitName(unitPath)
|
||||
setSelectedUnitName(details?.displayPath || formattedValue)
|
||||
setSelectedUnitType(details?.unit.type || null)
|
||||
if (!unitId) {
|
||||
setSelectedUnitRole('mitarbeiter')
|
||||
setValue('powerFunction', '')
|
||||
clearErrors('powerFunction')
|
||||
} else {
|
||||
clearErrors('department')
|
||||
clearErrors('powerFunction')
|
||||
if (formattedValue) {
|
||||
setValue('department', formattedValue)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-secondary-light mt-2">
|
||||
Wählen Sie die primäre Organisationseinheit für die neue Person. Die Abteilung wird automatisch anhand der Auswahl gesetzt.
|
||||
</p>
|
||||
{errors.department && (
|
||||
<p className="text-error text-sm mt-1">{errors.department.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUnitId && (
|
||||
@ -315,10 +387,51 @@ export default function CreateEmployee() {
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<p className="text-sm text-secondary-light mt-2">
|
||||
{getRoleDescription(watch('userRole'))}
|
||||
{getRoleDescription(watchUserRole)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{watchCreateUser && watchUserRole === 'superuser' && (
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Poweruser-Funktion *
|
||||
</label>
|
||||
<select
|
||||
{...register('powerFunction', {
|
||||
validate: value => {
|
||||
if (!watchCreateUser || watchUserRole !== 'superuser') return true
|
||||
return value ? true : 'Bitte wählen Sie eine Funktion aus'
|
||||
}
|
||||
})}
|
||||
className="input-field w-full"
|
||||
disabled={!selectedUnitId || availablePowerFunctions.length === 0}
|
||||
>
|
||||
<option value="">Bitte auswählen…</option>
|
||||
{availablePowerFunctions.map(fn => (
|
||||
<option key={fn.id} value={fn.id}>{fn.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.powerFunction && (
|
||||
<p className="text-error text-sm mt-1">{errors.powerFunction.message}</p>
|
||||
)}
|
||||
{!selectedUnitId && (
|
||||
<p className="text-sm text-secondary-light mt-2">
|
||||
Wählen Sie zuerst eine Organisationseinheit, um verfügbare Funktionen zu sehen.
|
||||
</p>
|
||||
)}
|
||||
{selectedUnitId && availablePowerFunctions.length === 0 && (
|
||||
<p className="text-sm text-warning mt-2">
|
||||
Für den ausgewählten Einheitstyp sind derzeit keine Poweruser-Funktionen definiert.
|
||||
</p>
|
||||
)}
|
||||
{selectedPowerFunctionDef && !selectedPowerFunctionDef.canManageEmployees && (
|
||||
<p className="text-sm text-secondary-light mt-2">
|
||||
Hinweis: Diese Funktion dient der Übersicht. Mitarbeitende können damit nicht angelegt werden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../services/api'
|
||||
import { api, positionsApi } from '../services/api'
|
||||
import type { UserRole } from '@skillmate/shared'
|
||||
|
||||
const DEFAULT_POSITION_OPTIONS = [
|
||||
'Sachbearbeitung',
|
||||
'stellvertretende Sachgebietsleitung',
|
||||
'Sachgebietsleitung',
|
||||
'Dezernatsleitung',
|
||||
'Abteilungsleitung',
|
||||
'Behördenleitung'
|
||||
]
|
||||
|
||||
interface EmployeeFormData {
|
||||
firstName: string
|
||||
lastName: string
|
||||
@ -28,6 +37,7 @@ export default function EmployeeFormComplete() {
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [createdUser, setCreatedUser] = useState<{password?: string}>({})
|
||||
const [positionOptions, setPositionOptions] = useState<string[]>(DEFAULT_POSITION_OPTIONS)
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, reset, watch } = useForm<EmployeeFormData>({
|
||||
defaultValues: {
|
||||
@ -38,6 +48,20 @@ export default function EmployeeFormComplete() {
|
||||
})
|
||||
|
||||
const watchCreateUser = watch('createUser')
|
||||
const selectedPosition = watch('position')
|
||||
|
||||
useEffect(() => {
|
||||
const loadPositions = async () => {
|
||||
try {
|
||||
const options = await positionsApi.getLabels()
|
||||
setPositionOptions(options.length ? options : DEFAULT_POSITION_OPTIONS)
|
||||
} catch (error) {
|
||||
console.error('Failed to load position options', error)
|
||||
setPositionOptions(DEFAULT_POSITION_OPTIONS)
|
||||
}
|
||||
}
|
||||
loadPositions()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
@ -277,11 +301,18 @@ export default function EmployeeFormComplete() {
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Position *
|
||||
</label>
|
||||
<input
|
||||
<select
|
||||
{...register('position', { required: 'Position ist erforderlich' })}
|
||||
className="input-field w-full"
|
||||
placeholder="Software Developer"
|
||||
/>
|
||||
>
|
||||
<option value="">Neutrale Funktionsbezeichnung auswählen…</option>
|
||||
{positionOptions.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
{selectedPosition && !positionOptions.includes(selectedPosition) && (
|
||||
<option value={selectedPosition}>{`${selectedPosition} (bestehender Wert)`}</option>
|
||||
)}
|
||||
</select>
|
||||
{errors.position && (
|
||||
<p className="text-error text-sm mt-1">{errors.position.message}</p>
|
||||
)}
|
||||
|
||||
182
admin-panel/src/views/OfficialTitles.tsx
Normale Datei
182
admin-panel/src/views/OfficialTitles.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useCallback, useEffect, useState, useRef, useMemo } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
@ -15,7 +16,7 @@ import ReactFlow, {
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { api } from '../services/api'
|
||||
import { OrganizationalUnit, OrganizationalUnitType } from '@skillmate/shared'
|
||||
import { OrganizationalUnitType } from '@skillmate/shared'
|
||||
import { Upload } from 'lucide-react'
|
||||
|
||||
// Custom Node Component (vorheriger Look, aber mit soliden dezenten Farben)
|
||||
@ -27,6 +28,7 @@ const OrganizationNode = ({ data }: { data: any }) => {
|
||||
dezernat: '📁',
|
||||
sachgebiet: '📋',
|
||||
teildezernat: '🔧',
|
||||
ermittlungskommission: '🕵️',
|
||||
fuehrungsstelle: '⭐',
|
||||
stabsstelle: '🎯',
|
||||
sondereinheit: '🛡️'
|
||||
@ -41,6 +43,7 @@ const OrganizationNode = ({ data }: { data: any }) => {
|
||||
case 'dezernat': return '#1D4ED8' // blue-700
|
||||
case 'sachgebiet': return '#64748B' // slate-500
|
||||
case 'teildezernat': return '#64748B' // slate-500
|
||||
case 'ermittlungskommission': return '#6366f1' // indigo-500
|
||||
case 'stabsstelle': return '#374151' // gray-700
|
||||
case 'sondereinheit': return '#475569' // slate-600
|
||||
default: return '#334155'
|
||||
@ -85,6 +88,41 @@ const nodeTypes: NodeTypes = {
|
||||
organization: OrganizationNode
|
||||
}
|
||||
|
||||
const UNIT_TYPE_OPTIONS: { value: OrganizationalUnitType; label: string }[] = [
|
||||
{ value: 'direktion', label: 'Direktion' },
|
||||
{ value: 'abteilung', label: 'Abteilung' },
|
||||
{ value: 'dezernat', label: 'Dezernat' },
|
||||
{ value: 'sachgebiet', label: 'Sachgebiet' },
|
||||
{ value: 'teildezernat', label: 'Teildezernat' },
|
||||
{ value: 'ermittlungskommission', label: 'Ermittlungskommission' },
|
||||
{ value: 'stabsstelle', label: 'Stabsstelle' },
|
||||
{ value: 'sondereinheit', label: 'Sondereinheit' },
|
||||
{ value: 'fuehrungsstelle', label: 'Führungsstelle' }
|
||||
]
|
||||
|
||||
const PARENT_RULES: Record<OrganizationalUnitType, OrganizationalUnitType[] | null> = {
|
||||
direktion: null,
|
||||
abteilung: ['direktion'],
|
||||
dezernat: ['abteilung'],
|
||||
sachgebiet: ['dezernat', 'teildezernat'],
|
||||
teildezernat: ['dezernat'],
|
||||
ermittlungskommission: ['dezernat'],
|
||||
fuehrungsstelle: ['abteilung'],
|
||||
stabsstelle: ['direktion'],
|
||||
sondereinheit: ['direktion', 'abteilung']
|
||||
}
|
||||
|
||||
type EditFormState = {
|
||||
code: string
|
||||
name: string
|
||||
type: OrganizationalUnitType
|
||||
level: number
|
||||
description: string
|
||||
hasFuehrungsstelle: boolean
|
||||
fuehrungsstelleName: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
export default function OrganizationEditor() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
@ -109,6 +147,79 @@ export default function OrganizationEditor() {
|
||||
parentId: '',
|
||||
description: ''
|
||||
})
|
||||
const [editDialog, setEditDialog] = useState<{ open: boolean; nodeId: string | null }>({ open: false, nodeId: null })
|
||||
const [editForm, setEditForm] = useState<EditFormState>(() => ({
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'dezernat',
|
||||
level: 0,
|
||||
description: '',
|
||||
hasFuehrungsstelle: false,
|
||||
fuehrungsstelleName: '',
|
||||
parentId: null
|
||||
}))
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
|
||||
const nodesById = useMemo(() => {
|
||||
const map: Record<string, Node> = {}
|
||||
nodes.forEach(node => { map[node.id] = node })
|
||||
return map
|
||||
}, [nodes])
|
||||
|
||||
const descendantIds = useMemo(() => {
|
||||
if (!editDialog.nodeId) return new Set<string>()
|
||||
const adjacency = new Map<string, string[]>()
|
||||
edges.forEach(edge => {
|
||||
if (!adjacency.has(edge.source)) adjacency.set(edge.source, [])
|
||||
adjacency.get(edge.source)!.push(edge.target)
|
||||
})
|
||||
const collected = new Set<string>()
|
||||
const stack = [...(adjacency.get(editDialog.nodeId) || [])]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!
|
||||
if (collected.has(current)) continue
|
||||
collected.add(current)
|
||||
const children = adjacency.get(current)
|
||||
if (children) stack.push(...children)
|
||||
}
|
||||
return collected
|
||||
}, [edges, editDialog.nodeId])
|
||||
|
||||
const allowedParentTypes = PARENT_RULES[editForm.type] ?? null
|
||||
const allowRootSelection = allowedParentTypes === null
|
||||
|
||||
const parentOptions = useMemo(() => {
|
||||
if (!editDialog.nodeId) return []
|
||||
const options = nodes
|
||||
.filter(node => {
|
||||
if (node.id === editDialog.nodeId) return false
|
||||
if (descendantIds.has(node.id)) return false
|
||||
const nodeType = node.data?.type as OrganizationalUnitType | undefined
|
||||
if (allowedParentTypes && allowedParentTypes.length > 0) {
|
||||
return nodeType ? allowedParentTypes.includes(nodeType) : false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(node => ({
|
||||
value: node.id,
|
||||
label: `${node.data?.code ? `${node.data.code} – ` : ''}${node.data?.name || ''}`.trim() || node.id,
|
||||
type: node.data?.type as OrganizationalUnitType | undefined
|
||||
}))
|
||||
|
||||
if (editForm.parentId && !options.some(option => option.value === editForm.parentId)) {
|
||||
const currentParent = nodesById[editForm.parentId]
|
||||
if (currentParent) {
|
||||
options.push({
|
||||
value: currentParent.id,
|
||||
label: `${currentParent.data?.code ? `${currentParent.data.code} – ` : ''}${currentParent.data?.name || ''}` || currentParent.id,
|
||||
type: currentParent.data?.type as OrganizationalUnitType | undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label, 'de', { numeric: true, sensitivity: 'base' }))
|
||||
}, [nodes, editDialog.nodeId, allowedParentTypes, descendantIds, editForm.parentId, nodesById])
|
||||
|
||||
// Load organizational units
|
||||
useEffect(() => {
|
||||
@ -126,12 +237,14 @@ export default function OrganizationEditor() {
|
||||
setNodes(flowNodes)
|
||||
setEdges(flowEdges)
|
||||
computeIssues(flowNodes)
|
||||
return { nodes: flowNodes, edges: flowEdges }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load organization:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const convertToFlowElements = (units: any[], parentPosition = { x: 0, y: 0 }, level = 0): { nodes: Node[], edges: Edge[] } => {
|
||||
@ -351,6 +464,104 @@ export default function OrganizationEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const resetEditForm = () => {
|
||||
setEditForm({
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'dezernat',
|
||||
level: 0,
|
||||
description: '',
|
||||
hasFuehrungsstelle: false,
|
||||
fuehrungsstelleName: '',
|
||||
parentId: null
|
||||
})
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditDialog({ open: false, nodeId: null })
|
||||
setEditError(null)
|
||||
resetEditForm()
|
||||
}
|
||||
|
||||
const openEditModal = useCallback((node: Node) => {
|
||||
setSelectedNode(node)
|
||||
const nodeData: any = node.data || {}
|
||||
const typeCandidate = nodeData.type
|
||||
const allowedType = UNIT_TYPE_OPTIONS.some(option => option.value === typeCandidate)
|
||||
? typeCandidate
|
||||
: 'dezernat'
|
||||
const levelCandidate = typeof nodeData.level === 'number'
|
||||
? nodeData.level
|
||||
: (nodeData.level != null && !isNaN(Number(nodeData.level)) ? Number(nodeData.level) : 0)
|
||||
|
||||
setEditForm({
|
||||
code: nodeData.code || '',
|
||||
name: nodeData.name || '',
|
||||
type: allowedType,
|
||||
level: levelCandidate,
|
||||
description: nodeData.description || '',
|
||||
hasFuehrungsstelle: !!nodeData.hasFuehrungsstelle,
|
||||
fuehrungsstelleName: nodeData.fuehrungsstelleName || '',
|
||||
parentId: nodeData.parentId || null
|
||||
})
|
||||
setEditError(null)
|
||||
setEditDialog({ open: true, nodeId: node.id })
|
||||
}, [])
|
||||
|
||||
const handleNodeDoubleClick = useCallback((_: any, node: Node) => {
|
||||
openEditModal(node)
|
||||
}, [openEditModal])
|
||||
|
||||
const handleUpdateUnit = async () => {
|
||||
if (!editDialog.nodeId) return
|
||||
const trimmedName = editForm.name.trim()
|
||||
const trimmedCode = editForm.code.trim()
|
||||
if (!trimmedName || !trimmedCode) {
|
||||
setEditError('Name und Code dürfen nicht leer sein.')
|
||||
return
|
||||
}
|
||||
|
||||
setEditSaving(true)
|
||||
setEditError(null)
|
||||
try {
|
||||
const normalizedLevel = Number.isFinite(editForm.level)
|
||||
? Math.max(0, Math.min(10, Math.round(editForm.level)))
|
||||
: 0
|
||||
|
||||
const payload: any = {
|
||||
name: trimmedName,
|
||||
code: trimmedCode,
|
||||
type: editForm.type,
|
||||
level: normalizedLevel,
|
||||
description: editForm.description,
|
||||
hasFuehrungsstelle: editForm.hasFuehrungsstelle
|
||||
}
|
||||
|
||||
if (editForm.hasFuehrungsstelle && editForm.fuehrungsstelleName.trim()) {
|
||||
payload.fuehrungsstelleName = editForm.fuehrungsstelleName.trim()
|
||||
}
|
||||
|
||||
payload.parentId = editForm.parentId ?? null
|
||||
|
||||
const response = await api.put(`/organization/units/${editDialog.nodeId}`, payload)
|
||||
if (response.data?.success) {
|
||||
const updated = await loadOrganization()
|
||||
if (updated?.nodes) {
|
||||
const refreshed = updated.nodes.find((n: Node) => n.id === editDialog.nodeId)
|
||||
if (refreshed) {
|
||||
setSelectedNode(refreshed)
|
||||
}
|
||||
}
|
||||
closeEditModal()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Update unit failed:', error)
|
||||
setEditError(error?.response?.data?.error?.message || 'Aktualisierung fehlgeschlagen')
|
||||
} finally {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute simple validation issues (orphans)
|
||||
const computeIssues = (flowNodes: Node[]) => {
|
||||
const ids = new Set(flowNodes.map(n => n.id))
|
||||
@ -590,6 +801,7 @@ export default function OrganizationEditor() {
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
@ -611,7 +823,7 @@ export default function OrganizationEditor() {
|
||||
return (type && map[type]) || '#94a3b8'
|
||||
}}
|
||||
/>
|
||||
<Background variant="dots" gap={12} size={1} />
|
||||
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
||||
|
||||
<Panel position="top-left" className="bg-white p-4 rounded-lg shadow-lg">
|
||||
<h2 className="text-xl font-bold mb-4">LKA NRW Organigramm</h2>
|
||||
@ -654,6 +866,12 @@ export default function OrganizationEditor() {
|
||||
{selectedNode.data.description && (
|
||||
<p className="text-sm mt-2">{selectedNode.data.description}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEditModal(selectedNode)}
|
||||
className="mt-3 w-full px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
|
||||
>
|
||||
Einheit bearbeiten
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-500">Klicken Sie auf eine Einheit für Details</p>
|
||||
@ -812,6 +1030,158 @@ export default function OrganizationEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Unit Dialog */}
|
||||
{editDialog.open && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold mb-4">Einheit bearbeiten</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.code}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, code: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Typ *</label>
|
||||
<select
|
||||
value={editForm.type}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value as OrganizationalUnitType
|
||||
setEditForm(prev => {
|
||||
let nextParentId = prev.parentId
|
||||
const allowed = PARENT_RULES[nextType] ?? null
|
||||
if (allowed && allowed.length > 0) {
|
||||
const currentParentType = nextParentId ? nodesById[nextParentId]?.data?.type as OrganizationalUnitType | undefined : undefined
|
||||
if (!currentParentType || !allowed.includes(currentParentType)) {
|
||||
const candidate = nodes.find(node => {
|
||||
if (node.id === editDialog.nodeId) return false
|
||||
if (descendantIds.has(node.id)) return false
|
||||
const nodeType = node.data?.type as OrganizationalUnitType | undefined
|
||||
return nodeType ? allowed.includes(nodeType) : false
|
||||
})
|
||||
nextParentId = candidate ? candidate.id : null
|
||||
}
|
||||
}
|
||||
return { ...prev, type: nextType, parentId: nextParentId }
|
||||
})
|
||||
}}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
{UNIT_TYPE_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Übergeordnete Einheit</label>
|
||||
<select
|
||||
value={editForm.parentId ?? ''}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, parentId: e.target.value === '' ? null : e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
disabled={!allowRootSelection && parentOptions.length === 0}
|
||||
>
|
||||
{allowRootSelection && <option value="">(Oberste Ebene)</option>}
|
||||
{parentOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{allowRootSelection
|
||||
? 'Optional: Kann auf oberster Ebene stehen oder einer übergeordneten Einheit zugeordnet werden.'
|
||||
: 'Wählen Sie eine passende übergeordnete Einheit gemäß der Hierarchie.'}
|
||||
</p>
|
||||
{!allowRootSelection && parentOptions.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-1">Keine passende übergeordnete Einheit verfügbar.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Ebene</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.level}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, level: Number(e.target.value) || 0 }))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
min="0"
|
||||
max="10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.hasFuehrungsstelle}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hasFuehrungsstelle: e.target.checked }))}
|
||||
/>
|
||||
Führungsstelle vorhanden
|
||||
</label>
|
||||
{editForm.hasFuehrungsstelle && (
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.fuehrungsstelleName}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, fuehrungsstelleName: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="Bezeichnung der Führungsstelle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded text-sm">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={closeEditModal}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
disabled={editSaving}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateUnit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-70"
|
||||
disabled={editSaving}
|
||||
>
|
||||
{editSaving ? 'Speichert...' : 'Änderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Unit Dialog */}
|
||||
{showAddDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
|
||||
266
admin-panel/src/views/Positions.tsx
Normale Datei
266
admin-panel/src/views/Positions.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, DragEvent } from 'react'
|
||||
import { useState, useEffect, useMemo, DragEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../services/api'
|
||||
import { User, UserRole } from '@skillmate/shared'
|
||||
import type { User, UserRole, PowerFunctionDefinition, OrganizationalUnitType } from '@skillmate/shared'
|
||||
import { POWER_FUNCTIONS } from '@skillmate/shared'
|
||||
import { TrashIcon, ShieldIcon, KeyIcon } from '../components/icons'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { OrganizationSelector } from '../components'
|
||||
|
||||
interface UserWithEmployee extends User {
|
||||
employeeName?: string
|
||||
@ -17,6 +19,11 @@ export default function UserManagement() {
|
||||
const [error, setError] = useState('')
|
||||
const [editingUser, setEditingUser] = useState<string | null>(null)
|
||||
const [editRole, setEditRole] = useState<UserRole>('user')
|
||||
const [editPowerUnitId, setEditPowerUnitId] = useState<string | null>(null)
|
||||
const [editPowerUnitName, setEditPowerUnitName] = useState('')
|
||||
const [editPowerUnitType, setEditPowerUnitType] = useState<OrganizationalUnitType | null>(null)
|
||||
const [editPowerFunction, setEditPowerFunction] = useState('')
|
||||
const [roleValidationError, setRoleValidationError] = useState('')
|
||||
const [resetPasswordUser, setResetPasswordUser] = useState<string | null>(null)
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
|
||||
@ -45,7 +52,6 @@ export default function UserManagement() {
|
||||
})
|
||||
|
||||
setUsers(enrichedUsers)
|
||||
setEmployees(employeesData)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch users:', err)
|
||||
setError('Benutzer konnten nicht geladen werden')
|
||||
@ -54,8 +60,6 @@ export default function UserManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const [employees, setEmployees] = useState<any[]>([])
|
||||
|
||||
// Import state
|
||||
type ImportRow = { firstName: string; lastName: string; email: string; department: string }
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
@ -76,13 +80,70 @@ export default function UserManagement() {
|
||||
// Store temporary passwords per user to show + email
|
||||
const [tempPasswords, setTempPasswords] = useState<Record<string, { password: string }>>({})
|
||||
|
||||
const availableEditFunctions = useMemo<PowerFunctionDefinition[]>(() => {
|
||||
if (!editPowerUnitType) return POWER_FUNCTIONS
|
||||
return POWER_FUNCTIONS.filter(def => def.unitTypes.includes(editPowerUnitType))
|
||||
}, [editPowerUnitType])
|
||||
|
||||
const selectedEditPowerFunction = useMemo(() => {
|
||||
if (!editPowerFunction) return null
|
||||
return availableEditFunctions.find(def => def.id === editPowerFunction) || null
|
||||
}, [availableEditFunctions, editPowerFunction])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editPowerFunction) return
|
||||
const stillValid = availableEditFunctions.some(def => def.id === editPowerFunction)
|
||||
if (!stillValid) {
|
||||
setEditPowerFunction('')
|
||||
}
|
||||
}, [availableEditFunctions, editPowerFunction])
|
||||
|
||||
const powerFunctionLabelMap = useMemo(() => {
|
||||
return POWER_FUNCTIONS.reduce<Record<string, PowerFunctionDefinition>>((acc, def) => {
|
||||
acc[def.id] = def
|
||||
return acc
|
||||
}, {})
|
||||
}, [])
|
||||
|
||||
const getPowerFunctionLabel = (id?: string | null) => {
|
||||
if (!id) return undefined
|
||||
return powerFunctionLabelMap[id]?.label || undefined
|
||||
}
|
||||
|
||||
// removed legacy creation helpers for employees without user accounts
|
||||
|
||||
const handleRoleChange = async (userId: string) => {
|
||||
setRoleValidationError('')
|
||||
|
||||
if (editRole === 'superuser') {
|
||||
if (!editPowerUnitId) {
|
||||
setRoleValidationError('Bitte wählen Sie eine Organisationseinheit für den Poweruser aus.')
|
||||
return
|
||||
}
|
||||
if (!editPowerFunction) {
|
||||
setRoleValidationError('Bitte wählen Sie eine Poweruser-Funktion aus.')
|
||||
return
|
||||
}
|
||||
|
||||
const def = POWER_FUNCTIONS.find(fn => fn.id === editPowerFunction)
|
||||
if (def && editPowerUnitType && !def.unitTypes.includes(editPowerUnitType)) {
|
||||
setRoleValidationError(`Die Funktion ${def.label} ist für den gewählten Einheitstyp nicht zulässig.`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/role`, { role: editRole })
|
||||
await api.put(`/admin/users/${userId}/role`, {
|
||||
role: editRole,
|
||||
powerUnitId: editRole === 'superuser' ? editPowerUnitId : null,
|
||||
powerFunction: editRole === 'superuser' ? editPowerFunction : null
|
||||
})
|
||||
await fetchUsers()
|
||||
setEditingUser(null)
|
||||
setEditPowerUnitId(null)
|
||||
setEditPowerUnitName('')
|
||||
setEditPowerUnitType(null)
|
||||
setEditPowerFunction('')
|
||||
} catch (err: any) {
|
||||
setError('Rolle konnte nicht geändert werden')
|
||||
}
|
||||
@ -339,35 +400,132 @@ export default function UserManagement() {
|
||||
<span className="text-tertiary italic">Nicht verknüpft</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<td className="py-4 px-4 align-top">
|
||||
{editingUser === user.id ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value as UserRole)}
|
||||
className="input-field py-1 text-sm"
|
||||
>
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="superuser">Poweruser</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRoleChange(user.id)}
|
||||
className="text-primary-blue hover:text-primary-blue-hover"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
className="text-error hover:text-red-700"
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={(e) => {
|
||||
const nextRole = e.target.value as UserRole
|
||||
setEditRole(nextRole)
|
||||
setRoleValidationError('')
|
||||
if (nextRole !== 'superuser') {
|
||||
setEditPowerFunction('')
|
||||
}
|
||||
}}
|
||||
className="input-field py-1 text-sm"
|
||||
>
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="superuser">Poweruser</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRoleChange(user.id)}
|
||||
className="text-primary-blue hover:text-primary-blue-hover"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingUser(null)
|
||||
setEditPowerUnitId(null)
|
||||
setEditPowerUnitName('')
|
||||
setEditPowerUnitType(null)
|
||||
setEditPowerFunction('')
|
||||
setRoleValidationError('')
|
||||
}}
|
||||
className="text-error hover:text-red-700"
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editRole === 'superuser' && (
|
||||
<div className="space-y-3 rounded-lg border border-border-light bg-gray-50 p-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-secondary mb-1">
|
||||
Organisationseinheit *
|
||||
</label>
|
||||
<OrganizationSelector
|
||||
value={editPowerUnitId}
|
||||
onChange={(unitId, formattedValue, details) => {
|
||||
setEditPowerUnitId(unitId)
|
||||
setEditPowerUnitName(details?.displayPath || formattedValue)
|
||||
setEditPowerUnitType(details?.unit.type || null)
|
||||
setRoleValidationError('')
|
||||
if (!unitId) {
|
||||
setEditPowerFunction('')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-secondary mt-1">
|
||||
{editPowerUnitName || 'Keine Einheit ausgewählt'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-secondary mb-1">
|
||||
Funktion *
|
||||
</label>
|
||||
<select
|
||||
value={editPowerFunction}
|
||||
onChange={(e) => {
|
||||
setEditPowerFunction(e.target.value)
|
||||
setRoleValidationError('')
|
||||
}}
|
||||
className="input-field py-1 text-sm"
|
||||
disabled={!editPowerUnitId || availableEditFunctions.length === 0}
|
||||
>
|
||||
<option value="">Bitte auswählen…</option>
|
||||
{availableEditFunctions.map(fn => (
|
||||
<option key={fn.id} value={fn.id}>{fn.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{!editPowerUnitId && (
|
||||
<p className="text-xs text-secondary mt-1">
|
||||
Bitte wählen Sie zuerst eine Organisationseinheit aus.
|
||||
</p>
|
||||
)}
|
||||
{editPowerUnitId && availableEditFunctions.length === 0 && (
|
||||
<p className="text-xs text-warning mt-1">
|
||||
Für den gewählten Einheitstyp sind keine Poweruser-Funktionen definiert.
|
||||
</p>
|
||||
)}
|
||||
{selectedEditPowerFunction && !selectedEditPowerFunction.canManageEmployees && (
|
||||
<p className="text-xs text-secondary mt-1">
|
||||
Hinweis: Diese Funktion berechtigt nicht zum Anlegen neuer Mitarbeitender.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{roleValidationError && (
|
||||
<p className="text-xs text-error">{roleValidationError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
|
||||
{getRoleLabel(user.role)}
|
||||
</span>
|
||||
<div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
|
||||
{getRoleLabel(user.role)}
|
||||
</span>
|
||||
{user.role === 'superuser' && (
|
||||
<div className="text-xs text-secondary mt-1 space-y-0.5">
|
||||
{user.powerUnitName ? (
|
||||
<div>{user.powerUnitName}</div>
|
||||
) : (
|
||||
<div className="text-tertiary">Keine Einheit zugewiesen</div>
|
||||
)}
|
||||
<div>
|
||||
{user.powerFunction ? getPowerFunctionLabel(user.powerFunction) || 'Keine Funktion' : 'Keine Funktion'}
|
||||
{user.powerFunction && (user.canManageEmployees === false || powerFunctionLabelMap[user.powerFunction]?.canManageEmployees === false) && (
|
||||
<span className="ml-1 text-tertiary">(keine Mitarbeitendenanlage)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
@ -389,6 +547,11 @@ export default function UserManagement() {
|
||||
onClick={() => {
|
||||
setEditingUser(user.id)
|
||||
setEditRole(user.role)
|
||||
setEditPowerUnitId(user.powerUnitId || null)
|
||||
setEditPowerUnitName(user.powerUnitName || '')
|
||||
setEditPowerUnitType(user.powerUnitType || null)
|
||||
setEditPowerFunction(user.powerFunction || '')
|
||||
setRoleValidationError('')
|
||||
}}
|
||||
className="p-1 text-secondary hover:text-primary-blue transition-colors"
|
||||
title="Rolle bearbeiten"
|
||||
@ -483,7 +646,7 @@ export default function UserManagement() {
|
||||
</h3>
|
||||
<ul className="space-y-2 text-body text-secondary">
|
||||
<li>• <strong>Administrator:</strong> Vollzugriff auf alle Funktionen und Einstellungen</li>
|
||||
<li>• <strong>Poweruser:</strong> Kann Mitarbeitende und Skills verwalten, aber keine Systemeinstellungen ändern</li>
|
||||
<li>• <strong>Poweruser:</strong> Kann Mitarbeitende und die Skillverwaltung nutzen, aber keine Systemeinstellungen ändern</li>
|
||||
<li>• <strong>Benutzer:</strong> Kann nur eigenes Profil bearbeiten und Daten einsehen</li>
|
||||
<li>• Neue Benutzer können über den Import oder die Mitarbeitendenverwaltung angelegt werden</li>
|
||||
<li>• Der Admin-Benutzer kann nicht gelöscht werden</li>
|
||||
|
||||
@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
// Use relative asset paths so the build works when deployed under a subdirectory
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: Number(process.env.VITE_PORT || 5174),
|
||||
|
||||
@ -3,32 +3,43 @@
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
function parseFrontendHierarchy() {
|
||||
function loadHierarchy() {
|
||||
const sharedPath = path.join(process.cwd(), '..', 'shared', 'skills.js')
|
||||
if (fs.existsSync(sharedPath)) {
|
||||
const sharedModule = require(sharedPath)
|
||||
if (Array.isArray(sharedModule?.SKILL_HIERARCHY)) {
|
||||
return sharedModule.SKILL_HIERARCHY
|
||||
}
|
||||
throw new Error('SKILL_HIERARCHY missing or invalid in shared/skills.js')
|
||||
}
|
||||
|
||||
const tsPath = path.join(process.cwd(), '..', 'frontend', 'src', 'data', 'skillCategories.ts')
|
||||
if (!fs.existsSync(tsPath)) {
|
||||
throw new Error('No skill hierarchy definition found in shared/skills.js or frontend/src/data/skillCategories.ts')
|
||||
}
|
||||
|
||||
const src = fs.readFileSync(tsPath, 'utf8')
|
||||
// Remove interface declarations and LANGUAGE_LEVELS export, keep the array literal
|
||||
let code = src
|
||||
.replace(/export interface[\s\S]*?\n\}/g, '')
|
||||
.replace(/export const LANGUAGE_LEVELS[\s\S]*?\n\n/, '')
|
||||
.replace(/export const SKILL_HIERARCHY:[^=]*=/, 'module.exports =')
|
||||
|
||||
const sandbox = { module: {}, exports: {} }
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(code, sandbox)
|
||||
return sandbox.module.exports || sandbox.exports
|
||||
require('vm').runInNewContext(code, sandbox)
|
||||
const hierarchy = sandbox.module?.exports || sandbox.exports
|
||||
if (!Array.isArray(hierarchy)) {
|
||||
throw new Error('Parsed hierarchy is not an array')
|
||||
}
|
||||
return hierarchy
|
||||
}
|
||||
|
||||
function main() {
|
||||
const dbPath = path.join(process.cwd(), 'skillmate.dev.encrypted.db')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
const hierarchy = parseFrontendHierarchy()
|
||||
if (!Array.isArray(hierarchy)) {
|
||||
throw new Error('Parsed hierarchy is not an array')
|
||||
}
|
||||
const hierarchy = loadHierarchy()
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT OR IGNORE INTO skills (id, name, category, description, expires_after)
|
||||
|
||||
@ -207,6 +207,86 @@ export function initializeSecureDatabase() {
|
||||
CREATE INDEX IF NOT EXISTS idx_employees_phone_hash ON employees(phone_hash);
|
||||
`)
|
||||
|
||||
// Official titles catalog managed via admin panel
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS official_titles (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
order_index INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_official_titles_label ON official_titles(label COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_official_titles_order ON official_titles(order_index);
|
||||
`)
|
||||
|
||||
try {
|
||||
const existingTitles = db.prepare('SELECT COUNT(*) as count FROM official_titles').get() as { count: number }
|
||||
if (!existingTitles || existingTitles.count === 0) {
|
||||
const defaults = [
|
||||
'Sachbearbeitung',
|
||||
'stellvertretende Sachgebietsleitung',
|
||||
'Sachgebietsleitung',
|
||||
'Dezernatsleitung',
|
||||
'Abteilungsleitung',
|
||||
'Behördenleitung'
|
||||
]
|
||||
const now = new Date().toISOString()
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO official_titles (id, label, order_index, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?)
|
||||
`)
|
||||
defaults.forEach((label, index) => {
|
||||
insert.run(uuidv4(), label, index, now, now)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to seed official titles:', error)
|
||||
}
|
||||
|
||||
// Position catalog managed via admin panel (optionally scoped per organisationseinheit)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS position_catalog (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
organization_unit_id TEXT,
|
||||
order_index INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_position_catalog_unit ON position_catalog(organization_unit_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_position_catalog_order ON position_catalog(order_index);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_position_catalog_unique ON position_catalog(label COLLATE NOCASE, IFNULL(organization_unit_id, 'GLOBAL'));
|
||||
`)
|
||||
|
||||
try {
|
||||
const existingPositions = db.prepare('SELECT COUNT(*) as count FROM position_catalog WHERE organization_unit_id IS NULL').get() as { count: number }
|
||||
if (!existingPositions || existingPositions.count === 0) {
|
||||
const defaults = [
|
||||
'Sachbearbeitung',
|
||||
'stellvertretende Sachgebietsleitung',
|
||||
'Sachgebietsleitung',
|
||||
'Dezernatsleitung',
|
||||
'Abteilungsleitung',
|
||||
'Behördenleitung'
|
||||
]
|
||||
const now = new Date().toISOString()
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO position_catalog (id, label, organization_unit_id, order_index, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, NULL, ?, 1, ?, ?)
|
||||
`)
|
||||
defaults.forEach((label, index) => {
|
||||
insert.run(uuidv4(), label, index, now, now)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to seed position catalog:', error)
|
||||
}
|
||||
|
||||
// Users table with encrypted email
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@ -217,19 +297,37 @@ export function initializeSecureDatabase() {
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
|
||||
employee_id TEXT,
|
||||
power_unit_id TEXT,
|
||||
power_function TEXT,
|
||||
last_login TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(email_hash)
|
||||
UNIQUE(email_hash),
|
||||
FOREIGN KEY(power_unit_id) REFERENCES organizational_units(id)
|
||||
)
|
||||
`)
|
||||
|
||||
|
||||
// Create index for email hash
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_hash ON users(email_hash);
|
||||
`)
|
||||
|
||||
// Ensure new power user columns exist (legacy migrations)
|
||||
try {
|
||||
const userCols: any[] = db.prepare(`PRAGMA table_info(users)`).all() as any
|
||||
const hasPowerUnit = userCols.some(c => c.name === 'power_unit_id')
|
||||
const hasPowerFunction = userCols.some(c => c.name === 'power_function')
|
||||
if (!hasPowerUnit) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN power_unit_id TEXT`)
|
||||
}
|
||||
if (!hasPowerFunction) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN power_function TEXT`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure power user columns:', error)
|
||||
}
|
||||
|
||||
// Skills table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
|
||||
@ -16,6 +16,8 @@ import workspaceRoutes from './routes/workspaces'
|
||||
import userRoutes from './routes/users'
|
||||
import userAdminRoutes from './routes/usersAdmin'
|
||||
import settingsRoutes from './routes/settings'
|
||||
import officialTitlesRoutes from './routes/officialTitles'
|
||||
import positionsRoutes from './routes/positions'
|
||||
import organizationRoutes from './routes/organization'
|
||||
import organizationImportRoutes from './routes/organizationImport'
|
||||
import employeeOrganizationRoutes from './routes/employeeOrganization'
|
||||
@ -65,6 +67,8 @@ app.use('/api/workspaces', workspaceRoutes)
|
||||
app.use('/api/users', userRoutes)
|
||||
app.use('/api/admin/users', userAdminRoutes)
|
||||
app.use('/api/admin/settings', settingsRoutes)
|
||||
app.use('/api/positions', positionsRoutes)
|
||||
app.use('/api/official-titles', officialTitlesRoutes)
|
||||
app.use('/api/organization', organizationRoutes)
|
||||
app.use('/api/organization', organizationImportRoutes)
|
||||
app.use('/api', employeeOrganizationRoutes)
|
||||
|
||||
@ -2,10 +2,11 @@ import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import { db } from '../config/secureDatabase'
|
||||
import { User, LoginRequest, LoginResponse } from '@skillmate/shared'
|
||||
import { db, encryptedDb } from '../config/secureDatabase'
|
||||
import { User, LoginRequest, LoginResponse, POWER_FUNCTIONS } from '@skillmate/shared'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
import { logger } from '../utils/logger'
|
||||
import { emailService } from '../services/emailService'
|
||||
|
||||
const router = Router()
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
@ -77,7 +78,20 @@ router.post('/login',
|
||||
const now = new Date().toISOString()
|
||||
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id)
|
||||
|
||||
// Enrich with power user meta (unit + function)
|
||||
const power = db.prepare(`
|
||||
SELECT u.power_unit_id as powerUnitId,
|
||||
u.power_function as powerFunction,
|
||||
ou.name as powerUnitName,
|
||||
ou.type as powerUnitType
|
||||
FROM users u
|
||||
LEFT JOIN organizational_units ou ON ou.id = u.power_unit_id
|
||||
WHERE u.id = ?
|
||||
`).get(userRow.id) as any
|
||||
|
||||
// Create user object without password (decrypt email)
|
||||
const powerFunctionId = power?.powerFunction || null
|
||||
const powerDefinition = powerFunctionId ? POWER_FUNCTIONS.find(def => def.id === powerFunctionId) : undefined
|
||||
const user: User = {
|
||||
id: userRow.id,
|
||||
username: userRow.username,
|
||||
@ -87,7 +101,12 @@ router.post('/login',
|
||||
lastLogin: new Date(now),
|
||||
isActive: Boolean(userRow.is_active),
|
||||
createdAt: new Date(userRow.created_at),
|
||||
updatedAt: new Date(userRow.updated_at)
|
||||
updatedAt: new Date(userRow.updated_at),
|
||||
powerUnitId: power?.powerUnitId || null,
|
||||
powerUnitName: power?.powerUnitName || null,
|
||||
powerUnitType: power?.powerUnitType || null,
|
||||
powerFunction: powerFunctionId,
|
||||
canManageEmployees: userRow.role === 'admin' || (userRow.role === 'superuser' && Boolean(powerDefinition?.canManageEmployees))
|
||||
}
|
||||
|
||||
// Generate token
|
||||
@ -114,8 +133,87 @@ router.post('/login',
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/forgot-password',
|
||||
[
|
||||
body('email').isEmail().normalizeEmail()
|
||||
],
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const genericMessage = 'Falls die angegebene E-Mail im System hinterlegt ist, erhalten Sie in Kürze ein neues Passwort.'
|
||||
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
// Immer generische Antwort senden, um keine Information preiszugeben
|
||||
logger.warn('Forgot password request received with invalid email input')
|
||||
return res.json({ success: true, message: genericMessage })
|
||||
}
|
||||
|
||||
const { email } = req.body as { email: string }
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
const emailHash = FieldEncryption.hash(normalizedEmail)
|
||||
|
||||
const userRow = db.prepare(`
|
||||
SELECT id, username, email, employee_id, is_active
|
||||
FROM users
|
||||
WHERE email_hash = ?
|
||||
`).get(emailHash) as any
|
||||
|
||||
if (!userRow || !userRow.is_active) {
|
||||
logger.info('Forgot password request for non-existing or inactive account')
|
||||
return res.json({ success: true, message: genericMessage })
|
||||
}
|
||||
|
||||
const decryptedEmail = FieldEncryption.decrypt(userRow.email) || normalizedEmail
|
||||
|
||||
let firstName: string | undefined
|
||||
if (userRow.employee_id) {
|
||||
try {
|
||||
const employee = encryptedDb.getEmployee(userRow.employee_id)
|
||||
if (employee?.first_name) {
|
||||
firstName = employee.first_name
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to resolve employee for password reset: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
|
||||
const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
|
||||
const canSendEmail = emailNotificationsEnabled && emailService.isServiceEnabled()
|
||||
|
||||
if (!canSendEmail) {
|
||||
logger.warn('Password reset requested but email notifications are disabled or email service unavailable')
|
||||
return res.json({ success: true, message: genericMessage })
|
||||
}
|
||||
|
||||
const temporaryPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
|
||||
const sent = await emailService.sendInitialPassword(decryptedEmail, temporaryPassword, firstName)
|
||||
|
||||
if (!sent) {
|
||||
logger.warn(`Password reset email could not be sent to ${decryptedEmail}`)
|
||||
return res.json({ success: true, message: genericMessage })
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(temporaryPassword, 12)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET password = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(hashedPassword, now, userRow.id)
|
||||
|
||||
logger.info(`Password reset processed for user ${userRow.username}`)
|
||||
return res.json({ success: true, message: genericMessage })
|
||||
} catch (error) {
|
||||
logger.error('Error processing forgot password request:', error)
|
||||
return res.json({ success: true, message: genericMessage })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
res.json({ success: true, message: 'Logged out successfully' })
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
||||
@ -82,17 +82,24 @@ router.put('/employee/:employeeId/organization', authenticate, async (req: AuthR
|
||||
assignmentId, employeeId, unitId, 'mitarbeiter',
|
||||
now, 1, now, now
|
||||
)
|
||||
|
||||
// Keep employees.primary_unit_id in sync for listings
|
||||
db.prepare(`
|
||||
UPDATE employees
|
||||
SET primary_unit_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(unitId, now, employeeId)
|
||||
}
|
||||
|
||||
// Update employee's department field for backward compatibility
|
||||
if (unitId) {
|
||||
const unitInfo = db.prepare('SELECT name FROM organizational_units WHERE id = ?').get(unitId) as any
|
||||
const unitInfo = db.prepare('SELECT code, name FROM organizational_units WHERE id = ?').get(unitId) as any
|
||||
if (unitInfo) {
|
||||
db.prepare(`
|
||||
UPDATE employees
|
||||
SET department = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(unitInfo.name, now, employeeId)
|
||||
`).run(unitInfo.code || unitInfo.name, now, employeeId)
|
||||
}
|
||||
} else {
|
||||
// Clear department if no unit
|
||||
@ -163,4 +170,4 @@ router.get('/unit/:unitId/employees', authenticate, async (req: AuthRequest, res
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
||||
@ -5,12 +5,99 @@ import bcrypt from 'bcrypt'
|
||||
import { db } from '../config/database'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
|
||||
import { Employee, LanguageSkill, Skill, UserRole, EmployeeUnitRole } from '@skillmate/shared'
|
||||
import { Employee, LanguageSkill, Skill, UserRole, EmployeeUnitRole, EmployeeDeputySummary, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
|
||||
import { syncService } from '../services/syncService'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
import { decodeHtmlEntities } from '../utils/html'
|
||||
import { createDepartmentResolver } from '../utils/department'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function toSqlDateTime(date: Date): string {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||
const year = date.getUTCFullYear()
|
||||
const month = pad(date.getUTCMonth() + 1)
|
||||
const day = pad(date.getUTCDate())
|
||||
const hours = pad(date.getUTCHours())
|
||||
const minutes = pad(date.getUTCMinutes())
|
||||
const seconds = pad(date.getUTCSeconds())
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function clearActiveDeputiesForPrincipal(principalId: string) {
|
||||
const now = new Date()
|
||||
const past = new Date(now.getTime() - 1000)
|
||||
const pastSql = toSqlDateTime(past)
|
||||
const nowSql = toSqlDateTime(now)
|
||||
db.prepare(`
|
||||
UPDATE deputy_assignments
|
||||
SET valid_until = ?, updated_at = ?
|
||||
WHERE principal_id = ?
|
||||
AND valid_until >= datetime('now')
|
||||
`).run(pastSql, nowSql, principalId)
|
||||
db.prepare(`
|
||||
DELETE FROM deputy_assignments
|
||||
WHERE principal_id = ?
|
||||
AND valid_from > datetime('now')
|
||||
`).run(principalId)
|
||||
}
|
||||
|
||||
const resolveDepartmentInfo = createDepartmentResolver(db)
|
||||
|
||||
function getActiveDeputies(principalId: string): EmployeeDeputySummary[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
da.id as assignmentId,
|
||||
da.deputy_id as deputyId,
|
||||
e.first_name as firstName,
|
||||
e.last_name as lastName,
|
||||
e.availability as availability,
|
||||
e.position as position
|
||||
FROM deputy_assignments da
|
||||
JOIN employees e ON e.id = da.deputy_id
|
||||
WHERE da.principal_id = ?
|
||||
AND da.valid_from <= datetime('now')
|
||||
AND da.valid_until >= datetime('now')
|
||||
ORDER BY e.last_name, e.first_name
|
||||
`).all(principalId) as any[]
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.deputyId,
|
||||
assignmentId: row.assignmentId,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
availability: row.availability,
|
||||
position: row.position || undefined
|
||||
}))
|
||||
}
|
||||
|
||||
function getActivePrincipals(deputyId: string): EmployeeDeputySummary[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
da.id as assignmentId,
|
||||
da.principal_id as principalId,
|
||||
e.first_name as firstName,
|
||||
e.last_name as lastName,
|
||||
e.availability as availability,
|
||||
e.position as position
|
||||
FROM deputy_assignments da
|
||||
JOIN employees e ON e.id = da.principal_id
|
||||
WHERE da.deputy_id = ?
|
||||
AND da.valid_from <= datetime('now')
|
||||
AND da.valid_until >= datetime('now')
|
||||
ORDER BY e.last_name, e.first_name
|
||||
`).all(deputyId) as any[]
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.principalId,
|
||||
assignmentId: row.assignmentId,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
availability: row.availability,
|
||||
position: row.position || undefined
|
||||
}))
|
||||
}
|
||||
|
||||
// Helper function to map old proficiency to new level
|
||||
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
|
||||
const mapping: Record<string, 'basic' | 'fluent' | 'native' | 'business'> = {
|
||||
@ -34,15 +121,65 @@ function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'nativ
|
||||
router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employees = db.prepare(`
|
||||
SELECT id, first_name, last_name, employee_number, photo, position, official_title,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance_level, clearance_valid_until, clearance_issued_date,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
FROM employees
|
||||
ORDER BY last_name, first_name
|
||||
SELECT
|
||||
e.id,
|
||||
e.first_name,
|
||||
e.last_name,
|
||||
e.employee_number,
|
||||
e.photo,
|
||||
e.position,
|
||||
e.official_title,
|
||||
e.department,
|
||||
e.email,
|
||||
e.phone,
|
||||
e.mobile,
|
||||
e.office,
|
||||
e.availability,
|
||||
e.primary_unit_id as primaryUnitId,
|
||||
ou.code as primaryUnitCode,
|
||||
ou.name as primaryUnitName,
|
||||
ou.description as primaryUnitDescription,
|
||||
e.clearance_level,
|
||||
e.clearance_valid_until,
|
||||
e.clearance_issued_date,
|
||||
e.created_at,
|
||||
e.updated_at,
|
||||
e.created_by,
|
||||
e.updated_by
|
||||
FROM employees e
|
||||
LEFT JOIN organizational_units ou ON ou.id = e.primary_unit_id
|
||||
ORDER BY e.last_name, e.first_name
|
||||
`).all()
|
||||
|
||||
const employeesWithDetails = employees.map((emp: any) => {
|
||||
const decodeValue = (val: string | null) => {
|
||||
if (val === null || val === undefined) return undefined
|
||||
return decodeHtmlEntities(val) ?? val
|
||||
}
|
||||
|
||||
const decodedFirstName = decodeValue(emp.first_name) || emp.first_name
|
||||
const decodedLastName = decodeValue(emp.last_name) || emp.last_name
|
||||
const decodedPosition = decodeValue(emp.position) || emp.position
|
||||
const decodedOfficialTitle = decodeValue(emp.official_title)
|
||||
const decodedDepartmentRaw = decodeValue(emp.department)
|
||||
const decodedEmail = decodeValue(emp.email) || emp.email
|
||||
const decodedPhone = decodeValue(emp.phone) || emp.phone
|
||||
const decodedMobile = decodeValue(emp.mobile)
|
||||
const decodedOffice = decodeValue(emp.office)
|
||||
const decodedPrimaryUnitCode = decodeValue(emp.primaryUnitCode) || undefined
|
||||
const decodedPrimaryUnitName = decodeValue(emp.primaryUnitName) || undefined
|
||||
const decodedPrimaryUnitDescription = decodeValue(emp.primaryUnitDescription) || undefined
|
||||
const departmentInfo = resolveDepartmentInfo({
|
||||
department: emp.department,
|
||||
primaryUnitId: emp.primaryUnitId,
|
||||
primaryUnitCode: emp.primaryUnitCode,
|
||||
primaryUnitName: emp.primaryUnitName,
|
||||
primaryUnitDescription: emp.primaryUnitDescription,
|
||||
})
|
||||
const departmentLabel = departmentInfo.label || (decodedDepartmentRaw || '')
|
||||
const departmentDescription = departmentInfo.description
|
||||
const departmentTasks = departmentInfo.tasks || decodedPrimaryUnitDescription
|
||||
|
||||
// Get skills
|
||||
const skills = db.prepare(`
|
||||
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
|
||||
@ -65,17 +202,22 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
lastName: emp.last_name,
|
||||
firstName: decodedFirstName,
|
||||
lastName: decodedLastName,
|
||||
employeeNumber: emp.employee_number,
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
officialTitle: emp.official_title || undefined,
|
||||
department: emp.department,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
office: emp.office,
|
||||
position: decodedPosition,
|
||||
officialTitle: decodedOfficialTitle,
|
||||
department: departmentLabel,
|
||||
departmentDescription,
|
||||
departmentTasks,
|
||||
primaryUnitId: emp.primaryUnitId || undefined,
|
||||
primaryUnitCode: decodedPrimaryUnitCode,
|
||||
primaryUnitName: decodedPrimaryUnitName,
|
||||
email: decodedEmail,
|
||||
phone: decodedPhone,
|
||||
mobile: decodedMobile,
|
||||
office: decodedOffice,
|
||||
availability: emp.availability,
|
||||
skills: skills.map((s: any) => ({
|
||||
id: s.id,
|
||||
@ -99,7 +241,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
updatedBy: emp.updated_by,
|
||||
currentDeputies: getActiveDeputies(emp.id),
|
||||
represents: getActivePrincipals(emp.id)
|
||||
}
|
||||
|
||||
return employee
|
||||
@ -117,12 +261,34 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
|
||||
const { id } = req.params
|
||||
|
||||
const emp = db.prepare(`
|
||||
SELECT id, first_name, last_name, employee_number, photo, position, official_title,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance_level, clearance_valid_until, clearance_issued_date,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
FROM employees
|
||||
WHERE id = ?
|
||||
SELECT
|
||||
e.id,
|
||||
e.first_name,
|
||||
e.last_name,
|
||||
e.employee_number,
|
||||
e.photo,
|
||||
e.position,
|
||||
e.official_title,
|
||||
e.department,
|
||||
e.email,
|
||||
e.phone,
|
||||
e.mobile,
|
||||
e.office,
|
||||
e.availability,
|
||||
e.primary_unit_id as primaryUnitId,
|
||||
ou.code as primaryUnitCode,
|
||||
ou.name as primaryUnitName,
|
||||
ou.description as primaryUnitDescription,
|
||||
e.clearance_level,
|
||||
e.clearance_valid_until,
|
||||
e.clearance_issued_date,
|
||||
e.created_at,
|
||||
e.updated_at,
|
||||
e.created_by,
|
||||
e.updated_by
|
||||
FROM employees e
|
||||
LEFT JOIN organizational_units ou ON ou.id = e.primary_unit_id
|
||||
WHERE e.id = ?
|
||||
`).get(id) as any
|
||||
|
||||
if (!emp) {
|
||||
@ -152,19 +318,52 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
|
||||
SELECT name FROM specializations WHERE employee_id = ?
|
||||
`).all(emp.id).map((s: any) => s.name)
|
||||
|
||||
const decodeValue = (val: string | null) => {
|
||||
if (val === null || val === undefined) return undefined
|
||||
return decodeHtmlEntities(val) ?? val
|
||||
}
|
||||
|
||||
const decodedFirstName = decodeValue(emp.first_name) || emp.first_name
|
||||
const decodedLastName = decodeValue(emp.last_name) || emp.last_name
|
||||
const decodedPosition = decodeValue(emp.position) || emp.position
|
||||
const decodedOfficialTitle = decodeValue(emp.official_title)
|
||||
const decodedDepartmentRaw = decodeValue(emp.department)
|
||||
const decodedEmail = decodeValue(emp.email) || emp.email
|
||||
const decodedPhone = decodeValue(emp.phone) || emp.phone
|
||||
const decodedMobile = decodeValue(emp.mobile)
|
||||
const decodedOffice = decodeValue(emp.office)
|
||||
const decodedPrimaryUnitCode = decodeValue(emp.primaryUnitCode) || undefined
|
||||
const decodedPrimaryUnitName = decodeValue(emp.primaryUnitName) || undefined
|
||||
const decodedPrimaryUnitDescription = decodeValue(emp.primaryUnitDescription) || undefined
|
||||
const departmentInfo = resolveDepartmentInfo({
|
||||
department: emp.department,
|
||||
primaryUnitId: emp.primaryUnitId,
|
||||
primaryUnitCode: emp.primaryUnitCode,
|
||||
primaryUnitName: emp.primaryUnitName,
|
||||
primaryUnitDescription: emp.primaryUnitDescription,
|
||||
})
|
||||
const departmentLabel = departmentInfo.label || (decodedDepartmentRaw || '')
|
||||
const departmentDescription = departmentInfo.description
|
||||
const departmentTasks = departmentInfo.tasks || decodedPrimaryUnitDescription
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
lastName: emp.last_name,
|
||||
firstName: decodedFirstName,
|
||||
lastName: decodedLastName,
|
||||
employeeNumber: emp.employee_number,
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
officialTitle: emp.official_title || undefined,
|
||||
department: emp.department,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
office: emp.office,
|
||||
position: decodedPosition,
|
||||
officialTitle: decodedOfficialTitle,
|
||||
department: departmentLabel,
|
||||
departmentDescription,
|
||||
departmentTasks,
|
||||
primaryUnitId: emp.primaryUnitId || undefined,
|
||||
primaryUnitCode: decodedPrimaryUnitCode,
|
||||
primaryUnitName: decodedPrimaryUnitName,
|
||||
email: decodedEmail,
|
||||
phone: decodedPhone,
|
||||
mobile: decodedMobile,
|
||||
office: decodedOffice,
|
||||
availability: emp.availability,
|
||||
skills: skills.map((s: any) => ({
|
||||
id: s.id,
|
||||
@ -188,7 +387,9 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
updatedBy: emp.updated_by,
|
||||
currentDeputies: getActiveDeputies(emp.id),
|
||||
represents: getActivePrincipals(emp.id)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: employee })
|
||||
@ -204,11 +405,17 @@ router.post('/',
|
||||
[
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('employeeNumber').optional({ checkFalsy: true }).trim(),
|
||||
body('position').optional({ checkFalsy: true }).trim(),
|
||||
body('officialTitle').optional().trim(),
|
||||
body('email').isEmail(),
|
||||
body('department').notEmpty().trim(),
|
||||
body('organizationUnitId').optional({ checkFalsy: true }).isUUID(),
|
||||
body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter'])
|
||||
body('department').optional({ checkFalsy: true }).trim(),
|
||||
body('phone').optional({ checkFalsy: true }).trim(),
|
||||
body('mobile').optional({ checkFalsy: true }).trim(),
|
||||
body('office').optional({ checkFalsy: true }).trim(),
|
||||
body('organizationUnitId').notEmpty().isUUID(),
|
||||
body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']),
|
||||
body('powerFunction').optional({ checkFalsy: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@ -227,9 +434,16 @@ router.post('/',
|
||||
firstName, lastName, employeeNumber, photo, position, officialTitle,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance, skills, languages, specializations,
|
||||
userRole, createUser, organizationUnitId, organizationRole
|
||||
userRole, createUser, organizationUnitId: organizationUnitIdRaw, organizationRole, powerFunction
|
||||
} = req.body
|
||||
|
||||
const organizationUnitId = typeof organizationUnitIdRaw === 'string' ? organizationUnitIdRaw : ''
|
||||
const normalizedDepartment = typeof department === 'string' ? department.trim() : ''
|
||||
|
||||
if (!organizationUnitId) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Organisatorische Einheit ist erforderlich' } })
|
||||
}
|
||||
|
||||
if (organizationRole && !organizationUnitId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@ -237,20 +451,66 @@ router.post('/',
|
||||
})
|
||||
}
|
||||
|
||||
let resolvedDepartment = department
|
||||
const requestedPowerFunction = typeof powerFunction === 'string' && powerFunction.length > 0 ? powerFunction : null
|
||||
const powerFunctionDef = requestedPowerFunction ? POWER_FUNCTIONS.find(def => def.id === requestedPowerFunction) : undefined
|
||||
|
||||
if (userRole === 'superuser' && !requestedPowerFunction) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert die Auswahl einer Funktion' } })
|
||||
}
|
||||
|
||||
if (userRole === 'superuser' && !organizationUnitId && req.user?.role !== 'superuser') {
|
||||
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert eine Organisationseinheit' } })
|
||||
}
|
||||
|
||||
let resolvedDepartment = normalizedDepartment
|
||||
let resolvedUnitId: string | null = null
|
||||
let resolvedUnitRole: EmployeeUnitRole = 'mitarbeiter'
|
||||
|
||||
if (organizationUnitId) {
|
||||
const unitRow = db.prepare('SELECT id, name FROM organizational_units WHERE id = ? AND is_active = 1').get(organizationUnitId) as { id: string; name: string } | undefined
|
||||
const unitRow = db.prepare('SELECT id, code, name, type FROM organizational_units WHERE id = ? AND is_active = 1').get(organizationUnitId) as { id: string; code: string | null; name: string; type: OrganizationalUnitType } | undefined
|
||||
if (!unitRow) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Organization unit not found' } })
|
||||
}
|
||||
resolvedUnitId = unitRow.id
|
||||
resolvedDepartment = unitRow.name
|
||||
resolvedDepartment = unitRow.code || unitRow.name
|
||||
if (organizationRole) {
|
||||
resolvedUnitRole = organizationRole as EmployeeUnitRole
|
||||
}
|
||||
|
||||
if (requestedPowerFunction && powerFunctionDef && !powerFunctionDef.unitTypes.includes(unitRow.type)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: `Funktion ${powerFunctionDef.label} kann nicht einer Einheit vom Typ ${unitRow.type} zugeordnet werden` }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (req.user?.role === 'superuser') {
|
||||
const canManage = req.user.canManageEmployees
|
||||
if (!canManage) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Keine Berechtigung zum Anlegen von Mitarbeitenden' } })
|
||||
}
|
||||
|
||||
if (!req.user.powerUnitId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Poweruser hat keine Organisationseinheit zugewiesen' } })
|
||||
}
|
||||
|
||||
if (organizationUnitId && organizationUnitId !== req.user.powerUnitId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Mitarbeitende dürfen nur im eigenen Bereich angelegt werden' } })
|
||||
}
|
||||
|
||||
const unitRow = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ?').get(req.user.powerUnitId) as { id: string; code: string | null; name: string } | undefined
|
||||
if (!unitRow) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Zugeordnete Organisationseinheit nicht gefunden' } })
|
||||
}
|
||||
|
||||
resolvedUnitId = unitRow.id
|
||||
resolvedDepartment = unitRow.code || unitRow.name
|
||||
resolvedUnitRole = 'mitarbeiter'
|
||||
}
|
||||
|
||||
if (!resolvedDepartment) {
|
||||
resolvedDepartment = 'Noch nicht zugewiesen'
|
||||
}
|
||||
|
||||
// Insert employee with default values for missing fields
|
||||
@ -373,12 +633,35 @@ router.post('/',
|
||||
|
||||
// Encrypt email for user table storage
|
||||
const encryptedEmail = FieldEncryption.encrypt(email)
|
||||
const emailHash = FieldEncryption.hash(email)
|
||||
|
||||
let powerUnitForUser: string | null = null
|
||||
let powerFunctionForUser: string | null = null
|
||||
|
||||
if (userRole === 'superuser') {
|
||||
if (!resolvedUnitId || !requestedPowerFunction) {
|
||||
throw new Error('Poweruser benötigt Organisationseinheit und Funktion')
|
||||
}
|
||||
powerUnitForUser = resolvedUnitId
|
||||
powerFunctionForUser = requestedPowerFunction
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId, email, encryptedEmail, hashedPassword, userRole, employeeId, 1, now, now
|
||||
userId,
|
||||
email,
|
||||
encryptedEmail,
|
||||
emailHash,
|
||||
hashedPassword,
|
||||
userRole,
|
||||
employeeId,
|
||||
powerUnitForUser,
|
||||
powerFunctionForUser,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
console.log(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
|
||||
@ -438,7 +721,7 @@ router.put('/:id',
|
||||
body('department').notEmpty().trim(),
|
||||
body('email').isEmail(),
|
||||
body('phone').notEmpty().trim(),
|
||||
body('availability').isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
|
||||
body('availability').isIn(['available', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@ -459,7 +742,7 @@ router.put('/:id',
|
||||
} = req.body
|
||||
|
||||
// Check if employee exists
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@ -482,6 +765,10 @@ router.put('/:id',
|
||||
now, req.user!.id, id
|
||||
)
|
||||
|
||||
if (availability === 'available') {
|
||||
clearActiveDeputiesForPrincipal(id)
|
||||
}
|
||||
|
||||
// Update skills
|
||||
if (skills !== undefined) {
|
||||
// Delete existing skills
|
||||
@ -530,6 +817,10 @@ router.put('/:id',
|
||||
|
||||
await syncService.queueSync('employees', 'update', updatedEmployee)
|
||||
|
||||
if (availability === 'available') {
|
||||
clearActiveDeputiesForPrincipal(id)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Employee updated successfully'
|
||||
@ -549,7 +840,7 @@ router.delete('/:id',
|
||||
const { id } = req.params
|
||||
|
||||
// Check if employee exists
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
|
||||
@ -5,13 +5,99 @@ import bcrypt from 'bcrypt'
|
||||
import { db, encryptedDb } from '../config/secureDatabase'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
|
||||
import { Employee, LanguageSkill, Skill, UserRole } from '@skillmate/shared'
|
||||
import { Employee, LanguageSkill, Skill, UserRole, EmployeeDeputySummary, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
|
||||
import { syncService } from '../services/syncService'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
import { emailService } from '../services/emailService'
|
||||
import { logger } from '../utils/logger'
|
||||
import { createDepartmentResolver } from '../utils/department'
|
||||
|
||||
|
||||
const router = Router()
|
||||
const resolveDepartmentInfo = createDepartmentResolver(db)
|
||||
|
||||
function toSqlDateTime(date: Date): string {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||
const year = date.getUTCFullYear()
|
||||
const month = pad(date.getUTCMonth() + 1)
|
||||
const day = pad(date.getUTCDate())
|
||||
const hours = pad(date.getUTCHours())
|
||||
const minutes = pad(date.getUTCMinutes())
|
||||
const seconds = pad(date.getUTCSeconds())
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function clearActiveDeputiesForPrincipal(principalId: string) {
|
||||
const now = new Date()
|
||||
const past = new Date(now.getTime() - 1000)
|
||||
const pastSql = toSqlDateTime(past)
|
||||
const nowSql = toSqlDateTime(now)
|
||||
db.prepare(`
|
||||
UPDATE deputy_assignments
|
||||
SET valid_until = ?, updated_at = ?
|
||||
WHERE principal_id = ?
|
||||
AND valid_until >= datetime('now')
|
||||
`).run(pastSql, nowSql, principalId)
|
||||
db.prepare(`
|
||||
DELETE FROM deputy_assignments
|
||||
WHERE principal_id = ?
|
||||
AND valid_from > datetime('now')
|
||||
`).run(principalId)
|
||||
}
|
||||
|
||||
function getActiveDeputies(principalId: string): EmployeeDeputySummary[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
da.id as assignmentId,
|
||||
da.deputy_id as deputyId,
|
||||
e.first_name as firstName,
|
||||
e.last_name as lastName,
|
||||
e.availability as availability,
|
||||
e.position as position
|
||||
FROM deputy_assignments da
|
||||
JOIN employees e ON e.id = da.deputy_id
|
||||
WHERE da.principal_id = ?
|
||||
AND da.valid_from <= datetime('now')
|
||||
AND da.valid_until >= datetime('now')
|
||||
ORDER BY e.last_name, e.first_name
|
||||
`).all(principalId) as any[]
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.deputyId,
|
||||
assignmentId: row.assignmentId,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
availability: row.availability,
|
||||
position: row.position || undefined
|
||||
}))
|
||||
}
|
||||
|
||||
function getActivePrincipals(deputyId: string): EmployeeDeputySummary[] {
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
da.id as assignmentId,
|
||||
da.principal_id as principalId,
|
||||
e.first_name as firstName,
|
||||
e.last_name as lastName,
|
||||
e.availability as availability,
|
||||
e.position as position
|
||||
FROM deputy_assignments da
|
||||
JOIN employees e ON e.id = da.principal_id
|
||||
WHERE da.deputy_id = ?
|
||||
AND da.valid_from <= datetime('now')
|
||||
AND da.valid_until >= datetime('now')
|
||||
ORDER BY e.last_name, e.first_name
|
||||
`).all(deputyId) as any[]
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.principalId,
|
||||
assignmentId: row.assignmentId,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
availability: row.availability,
|
||||
position: row.position || undefined
|
||||
}))
|
||||
}
|
||||
|
||||
// Helper function to map old proficiency to new level
|
||||
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
|
||||
@ -114,6 +200,11 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
|
||||
SELECT name FROM specializations WHERE employee_id = ?
|
||||
`).all(emp.id).map((s: any) => s.name)
|
||||
|
||||
const departmentInfo = resolveDepartmentInfo({
|
||||
department: emp.department,
|
||||
primaryUnitId: emp.primary_unit_id,
|
||||
})
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
@ -122,7 +213,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
officialTitle: emp.official_title || undefined,
|
||||
department: emp.department,
|
||||
department: departmentInfo.label || emp.department,
|
||||
departmentDescription: departmentInfo.description,
|
||||
departmentTasks: departmentInfo.tasks,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
@ -150,7 +243,10 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
updatedBy: emp.updated_by,
|
||||
primaryUnitId: emp.primary_unit_id || undefined,
|
||||
currentDeputies: getActiveDeputies(emp.id),
|
||||
represents: getActivePrincipals(emp.id)
|
||||
}
|
||||
|
||||
return employee
|
||||
@ -197,6 +293,11 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
|
||||
SELECT name FROM specializations WHERE employee_id = ?
|
||||
`).all(emp.id).map((s: any) => s.name)
|
||||
|
||||
const departmentInfo = resolveDepartmentInfo({
|
||||
department: emp.department,
|
||||
primaryUnitId: emp.primary_unit_id,
|
||||
})
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
@ -205,7 +306,9 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
officialTitle: emp.official_title || undefined,
|
||||
department: emp.department,
|
||||
department: departmentInfo.label || emp.department,
|
||||
departmentDescription: departmentInfo.description,
|
||||
departmentTasks: departmentInfo.tasks,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
@ -233,7 +336,10 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
updatedBy: emp.updated_by,
|
||||
primaryUnitId: emp.primary_unit_id || undefined,
|
||||
currentDeputies: getActiveDeputies(emp.id),
|
||||
represents: getActivePrincipals(emp.id)
|
||||
}
|
||||
|
||||
return employee
|
||||
@ -334,13 +440,16 @@ router.post('/',
|
||||
authenticate,
|
||||
requirePermission('employees:create'),
|
||||
[
|
||||
body('firstName').notEmpty().trim().escape(),
|
||||
body('lastName').notEmpty().trim().escape(),
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('department').notEmpty().trim().escape(),
|
||||
body('position').optional().trim().escape(), // Optional
|
||||
body('department').notEmpty().trim(),
|
||||
body('position').optional().trim(), // Optional
|
||||
body('phone').optional().trim(), // Optional - kann später ergänzt werden
|
||||
body('employeeNumber').optional().trim() // Optional - wird automatisch generiert wenn leer
|
||||
body('employeeNumber').optional().trim(), // Optional - wird automatisch generiert wenn leer
|
||||
body('primaryUnitId').optional({ checkFalsy: true }).isUUID(),
|
||||
body('assignmentRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']),
|
||||
body('powerFunction').optional({ checkFalsy: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction = db.transaction(() => {
|
||||
@ -358,10 +467,21 @@ router.post('/',
|
||||
|
||||
const {
|
||||
firstName, lastName, employeeNumber, photo, position = 'Teammitglied', officialTitle,
|
||||
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
|
||||
clearance, skills = [], languages = [], specializations = [], userRole, createUser,
|
||||
primaryUnitId, assignmentRole
|
||||
} = req.body
|
||||
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
|
||||
clearance, skills = [], languages = [], specializations = [], userRole, createUser,
|
||||
primaryUnitId, assignmentRole, powerFunction
|
||||
} = req.body
|
||||
|
||||
const requestedPowerFunction = typeof powerFunction === 'string' && powerFunction.length > 0 ? powerFunction : null
|
||||
const powerFunctionDef = requestedPowerFunction ? POWER_FUNCTIONS.find(def => def.id === requestedPowerFunction) : undefined
|
||||
|
||||
if (userRole === 'superuser' && !requestedPowerFunction) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert die Auswahl einer Funktion' } })
|
||||
}
|
||||
|
||||
if (userRole === 'superuser' && !primaryUnitId && req.user?.role !== 'superuser') {
|
||||
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert eine Organisationseinheit' } })
|
||||
}
|
||||
|
||||
// Generate employee number if not provided
|
||||
const finalEmployeeNumber = employeeNumber || `EMP${Date.now()}`
|
||||
@ -375,6 +495,48 @@ router.post('/',
|
||||
})
|
||||
}
|
||||
|
||||
let resolvedDepartment = department
|
||||
let resolvedPrimaryUnitId: string | null = primaryUnitId || null
|
||||
let resolvedAssignmentRole = assignmentRole || 'mitarbeiter'
|
||||
|
||||
if (primaryUnitId) {
|
||||
const unit = db.prepare('SELECT id, type, code, name FROM organizational_units WHERE id = ? AND is_active = 1').get(primaryUnitId) as { id: string; type: OrganizationalUnitType; code: string | null; name: string } | undefined
|
||||
if (!unit) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Invalid primary unit' } })
|
||||
}
|
||||
|
||||
if (requestedPowerFunction && powerFunctionDef && !powerFunctionDef.unitTypes.includes(unit.type)) {
|
||||
return res.status(400).json({ success: false, error: { message: `Funktion ${powerFunctionDef.label} kann nicht einer Einheit vom Typ ${unit.type} zugeordnet werden` } })
|
||||
}
|
||||
|
||||
resolvedDepartment = unit.code || unit.name
|
||||
resolvedPrimaryUnitId = unit.id
|
||||
}
|
||||
|
||||
if (req.user?.role === 'superuser') {
|
||||
const canManage = req.user.canManageEmployees
|
||||
if (!canManage) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Keine Berechtigung zum Anlegen von Mitarbeitenden' } })
|
||||
}
|
||||
|
||||
if (!req.user.powerUnitId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Poweruser hat keine Organisationseinheit zugewiesen' } })
|
||||
}
|
||||
|
||||
if (primaryUnitId && primaryUnitId !== req.user.powerUnitId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Mitarbeitende dürfen nur im eigenen Bereich angelegt werden' } })
|
||||
}
|
||||
|
||||
const unitRow = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ?').get(req.user.powerUnitId) as { id: string; code: string | null; name: string } | undefined
|
||||
if (!unitRow) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Zugeordnete Organisationseinheit nicht gefunden' } })
|
||||
}
|
||||
|
||||
resolvedPrimaryUnitId = unitRow.id
|
||||
resolvedDepartment = unitRow.code || unitRow.name
|
||||
resolvedAssignmentRole = 'mitarbeiter'
|
||||
}
|
||||
|
||||
// Insert employee with encrypted fields
|
||||
encryptedDb.insertEmployee({
|
||||
id: employeeId,
|
||||
@ -384,7 +546,7 @@ router.post('/',
|
||||
photo: photo || null,
|
||||
position,
|
||||
official_title: officialTitle || null,
|
||||
department,
|
||||
department: resolvedDepartment,
|
||||
email,
|
||||
phone,
|
||||
mobile: mobile || null,
|
||||
@ -393,24 +555,20 @@ router.post('/',
|
||||
clearance_level: clearance?.level || null,
|
||||
clearance_valid_until: clearance?.validUntil || null,
|
||||
clearance_issued_date: clearance?.issuedDate || null,
|
||||
primary_unit_id: primaryUnitId || null,
|
||||
primary_unit_id: resolvedPrimaryUnitId,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created_by: req.user!.id
|
||||
})
|
||||
|
||||
// Create primary assignment if provided
|
||||
if (primaryUnitId) {
|
||||
const unit = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(primaryUnitId)
|
||||
if (!unit) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Invalid primary unit' } })
|
||||
}
|
||||
if (resolvedPrimaryUnitId) {
|
||||
db.prepare(`
|
||||
INSERT INTO employee_unit_assignments (
|
||||
id, employee_id, unit_id, role, start_date, end_date, is_primary, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
uuidv4(), employeeId, primaryUnitId, assignmentRole || 'mitarbeiter',
|
||||
uuidv4(), employeeId, resolvedPrimaryUnitId, resolvedAssignmentRole,
|
||||
now, null, 1, now, now
|
||||
)
|
||||
}
|
||||
@ -486,10 +644,20 @@ router.post('/',
|
||||
const hashedPassword = bcrypt.hashSync(tempPassword, 12)
|
||||
// Enforce role policy: only admins may assign roles; others default to 'user'
|
||||
const assignedRole = req.user?.role === 'admin' && userRole ? userRole : 'user'
|
||||
|
||||
|
||||
let powerUnitForUser: string | null = null
|
||||
let powerFunctionForUser: string | null = null
|
||||
if (assignedRole === 'superuser') {
|
||||
if (!resolvedPrimaryUnitId || !requestedPowerFunction) {
|
||||
throw new Error('Poweruser benötigt Organisationseinheit und Funktion')
|
||||
}
|
||||
powerUnitForUser = resolvedPrimaryUnitId
|
||||
powerFunctionForUser = requestedPowerFunction
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
email,
|
||||
@ -498,6 +666,8 @@ router.post('/',
|
||||
hashedPassword,
|
||||
assignedRole,
|
||||
employeeId,
|
||||
powerUnitForUser,
|
||||
powerFunctionForUser,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
@ -593,15 +763,15 @@ router.put('/:id',
|
||||
authenticate,
|
||||
requireEditPermission(req => req.params.id),
|
||||
[
|
||||
body('firstName').notEmpty().trim().escape(),
|
||||
body('lastName').notEmpty().trim().escape(),
|
||||
body('position').optional().trim().escape(),
|
||||
body('officialTitle').optional().trim().escape(),
|
||||
body('department').notEmpty().trim().escape(),
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('position').optional().trim(),
|
||||
body('officialTitle').optional().trim(),
|
||||
body('department').notEmpty().trim(),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('phone').optional().trim(),
|
||||
body('employeeNumber').optional().trim(),
|
||||
body('availability').optional().isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
|
||||
body('availability').optional().isIn(['available', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction = db.transaction(() => {
|
||||
@ -618,13 +788,13 @@ router.put('/:id',
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const {
|
||||
firstName, lastName, position = 'Teammitglied', officialTitle, department, email, phone = 'Nicht angegeben',
|
||||
firstName, lastName, position = 'Teammitglied', officialTitle, department, email, phone = 'Nicht angegeben',
|
||||
mobile, office, availability = 'available', clearance, skills, languages, specializations,
|
||||
employeeNumber
|
||||
} = req.body
|
||||
|
||||
// Check if employee exists
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
@ -727,6 +897,10 @@ router.put('/:id',
|
||||
logger.error('Failed to queue sync:', err)
|
||||
})
|
||||
|
||||
if (availability === 'available') {
|
||||
clearActiveDeputiesForPrincipal(id)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Employee updated successfully'
|
||||
@ -762,7 +936,7 @@ router.delete('/:id',
|
||||
const { id } = req.params
|
||||
|
||||
// Check if employee exists
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
|
||||
158
backend/src/routes/officialTitles.ts
Normale Datei
158
backend/src/routes/officialTitles.ts
Normale Datei
@ -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
|
||||
@ -5,15 +5,72 @@ import { db } from '../config/secureDatabase'
|
||||
import { authenticate, AuthRequest } from '../middleware/auth'
|
||||
import {
|
||||
OrganizationalUnit,
|
||||
OrganizationalUnitType,
|
||||
EmployeeUnitAssignment,
|
||||
SpecialPosition,
|
||||
DeputyAssignment,
|
||||
DeputyDelegation
|
||||
} from '@skillmate/shared'
|
||||
import { logger } from '../utils/logger'
|
||||
import { decodeHtmlEntities } from '../utils/html'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function toSqlDateTime(date: Date): string {
|
||||
const pad = (value: number) => value.toString().padStart(2, '0')
|
||||
const year = date.getUTCFullYear()
|
||||
const month = pad(date.getUTCMonth() + 1)
|
||||
const day = pad(date.getUTCDate())
|
||||
const hours = pad(date.getUTCHours())
|
||||
const minutes = pad(date.getUTCMinutes())
|
||||
const seconds = pad(date.getUTCSeconds())
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function normalizeAssignmentRange(validFrom: string, validUntil?: string | null) {
|
||||
if (!validFrom) {
|
||||
throw new Error('Startdatum ist erforderlich')
|
||||
}
|
||||
if (!validUntil) {
|
||||
throw new Error('Enddatum ist erforderlich')
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(validFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(validUntil)) {
|
||||
throw new Error('Datumsangaben müssen im Format JJJJ-MM-TT erfolgen')
|
||||
}
|
||||
|
||||
const startDate = new Date(`${validFrom}T00:00:00Z`)
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
throw new Error('Ungültiges Startdatum')
|
||||
}
|
||||
|
||||
const endDate = new Date(`${validUntil}T23:59:59Z`)
|
||||
if (Number.isNaN(endDate.getTime())) {
|
||||
throw new Error('Ungültiges Enddatum')
|
||||
}
|
||||
|
||||
if (endDate.getTime() < startDate.getTime()) {
|
||||
throw new Error('Enddatum muss nach dem Startdatum liegen')
|
||||
}
|
||||
|
||||
const startSql = toSqlDateTime(startDate)
|
||||
const endSql = toSqlDateTime(endDate)
|
||||
|
||||
return { startSql, endSql }
|
||||
}
|
||||
|
||||
const PARENT_RULES: Record<OrganizationalUnitType, OrganizationalUnitType[] | null> = {
|
||||
direktion: null,
|
||||
abteilung: ['direktion'],
|
||||
dezernat: ['abteilung'],
|
||||
sachgebiet: ['dezernat', 'teildezernat'],
|
||||
teildezernat: ['dezernat'],
|
||||
ermittlungskommission: ['dezernat'],
|
||||
fuehrungsstelle: ['abteilung'],
|
||||
stabsstelle: ['direktion'],
|
||||
sondereinheit: ['direktion', 'abteilung']
|
||||
}
|
||||
|
||||
// Get all organizational units
|
||||
router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
@ -32,7 +89,14 @@ router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
|
||||
ORDER BY level, order_index, name
|
||||
`).all()
|
||||
|
||||
res.json({ success: true, data: units })
|
||||
const decodedUnits = units.map((unit: any) => ({
|
||||
...unit,
|
||||
code: decodeHtmlEntities(unit.code) ?? unit.code,
|
||||
name: decodeHtmlEntities(unit.name) ?? unit.name,
|
||||
description: decodeHtmlEntities(unit.description) ?? (decodeHtmlEntities(unit.name) ?? unit.name)
|
||||
}))
|
||||
|
||||
res.json({ success: true, data: decodedUnits })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organizational units:', error)
|
||||
next(error)
|
||||
@ -171,6 +235,18 @@ router.get('/hierarchy', authenticate, async (req: AuthRequest, res, next) => {
|
||||
// Apply sorting from the top
|
||||
rootUnits.forEach(sortTree)
|
||||
|
||||
const decodeTree = (node: any) => {
|
||||
if (!node) return
|
||||
node.code = decodeHtmlEntities(node.code) ?? node.code
|
||||
node.name = decodeHtmlEntities(node.name) ?? node.name
|
||||
node.description = decodeHtmlEntities(node.description) ?? (decodeHtmlEntities(node.name) ?? node.name)
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(decodeTree)
|
||||
}
|
||||
}
|
||||
|
||||
rootUnits.forEach(decodeTree)
|
||||
|
||||
res.json({ success: true, data: rootUnits })
|
||||
} catch (error) {
|
||||
logger.error('Error building organizational hierarchy:', error)
|
||||
@ -218,11 +294,28 @@ router.get('/units/:id', authenticate, async (req: AuthRequest, res, next) => {
|
||||
e.last_name, e.first_name
|
||||
`).all(req.params.id)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
const decodedUnit = {
|
||||
...unit,
|
||||
code: decodeHtmlEntities(unit.code) ?? unit.code,
|
||||
name: decodeHtmlEntities(unit.name) ?? unit.name,
|
||||
description: decodeHtmlEntities(unit.description) ?? (decodeHtmlEntities(unit.name) ?? unit.name)
|
||||
}
|
||||
|
||||
const decodedEmployees = employees.map((emp: any) => ({
|
||||
...emp,
|
||||
firstName: decodeHtmlEntities(emp.firstName) ?? emp.firstName,
|
||||
lastName: decodeHtmlEntities(emp.lastName) ?? emp.lastName,
|
||||
position: decodeHtmlEntities(emp.position) ?? emp.position,
|
||||
department: decodeHtmlEntities(emp.department) ?? emp.department,
|
||||
email: decodeHtmlEntities(emp.email) ?? emp.email,
|
||||
phone: decodeHtmlEntities(emp.phone) ?? emp.phone
|
||||
}))
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...unit,
|
||||
employees
|
||||
...decodedUnit,
|
||||
employees: decodedEmployees
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
@ -237,7 +330,7 @@ router.post('/units',
|
||||
[
|
||||
body('code').notEmpty().trim(),
|
||||
body('name').notEmpty().trim(),
|
||||
body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit']),
|
||||
body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
|
||||
body('level').isInt({ min: 0, max: 10 }),
|
||||
body('parentId').optional({ checkFalsy: true }).isUUID()
|
||||
],
|
||||
@ -263,6 +356,22 @@ router.post('/units',
|
||||
return res.status(400).json({ success: false, error: { message: 'Unit code already exists' } })
|
||||
}
|
||||
|
||||
const unitType = type as OrganizationalUnitType
|
||||
const requiredParents = PARENT_RULES[unitType] ?? null
|
||||
let parentType: OrganizationalUnitType | null = null
|
||||
if (parentId) {
|
||||
const parentRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ? AND is_active = 1').get(parentId) as { id: string; type: OrganizationalUnitType } | undefined
|
||||
if (!parentRow) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
|
||||
}
|
||||
parentType = parentRow.type
|
||||
if (requiredParents && requiredParents.length > 0 && !requiredParents.includes(parentType)) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Ungültige übergeordnete Einheit für diesen Typ' } })
|
||||
}
|
||||
} else if (requiredParents && requiredParents.length > 0) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Dieser Einheitstyp benötigt eine übergeordnete Einheit' } })
|
||||
}
|
||||
|
||||
// Get max order index for this level
|
||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM organizational_units WHERE level = ?').get(level) as any
|
||||
const orderIndex = (maxOrder?.max || 0) + 1
|
||||
@ -294,9 +403,13 @@ router.put('/units/:id',
|
||||
authenticate,
|
||||
[
|
||||
param('id').isUUID(),
|
||||
body('code').optional().isString().trim().notEmpty(),
|
||||
body('name').optional().notEmpty().trim(),
|
||||
body('type').optional().isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
|
||||
body('description').optional(),
|
||||
body('color').optional(),
|
||||
body('hasFuehrungsstelle').optional().isBoolean().toBoolean(),
|
||||
body('fuehrungsstelleName').optional().isString().trim(),
|
||||
body('parentId').optional({ checkFalsy: true }).isUUID(),
|
||||
body('level').optional().isInt({ min: 0, max: 10 }),
|
||||
// allow updating persisted canvas positions from admin editor
|
||||
@ -305,67 +418,126 @@ router.put('/units/:id',
|
||||
],
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
|
||||
}
|
||||
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
|
||||
}
|
||||
|
||||
const { name, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
|
||||
const { code, name, type, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Optional: validate parentId and avoid cycles
|
||||
let newParentId: string | null | undefined = undefined
|
||||
const existingUnit = db.prepare('SELECT id, type, parent_id as parentId FROM organizational_units WHERE id = ?').get(req.params.id) as { id: string; type: OrganizationalUnitType; parentId: string | null } | undefined
|
||||
if (!existingUnit) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
|
||||
}
|
||||
|
||||
// Resolve target type
|
||||
const targetType: OrganizationalUnitType = (type as OrganizationalUnitType) || existingUnit.type
|
||||
|
||||
// Determine final parent
|
||||
let finalParentId: string | null = existingUnit.parentId || null
|
||||
let finalParentType: OrganizationalUnitType | null = null
|
||||
|
||||
if (parentId !== undefined) {
|
||||
if (parentId === null || parentId === '' ) {
|
||||
newParentId = null
|
||||
if (parentId === null || parentId === '') {
|
||||
finalParentId = null
|
||||
} else {
|
||||
const parent = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(parentId)
|
||||
if (!parent) {
|
||||
const parentRow = db.prepare('SELECT id, parent_id as parentId, type FROM organizational_units WHERE id = ? AND is_active = 1').get(parentId) as { id: string; parentId: string | null; type: OrganizationalUnitType } | undefined
|
||||
if (!parentRow) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
|
||||
}
|
||||
// cycle check: walk up from parent to root; id must not appear
|
||||
// cycle check
|
||||
const targetId = req.params.id
|
||||
let cursor: any = parent
|
||||
let cursor: any = parentRow
|
||||
while (cursor && cursor.parentId) {
|
||||
if (cursor.parentId === targetId) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Cyclic parent assignment is not allowed' } })
|
||||
}
|
||||
cursor = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(cursor.parentId)
|
||||
}
|
||||
newParentId = parentId
|
||||
finalParentId = parentRow.id
|
||||
finalParentType = parentRow.type
|
||||
}
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE organizational_units
|
||||
SET name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
color = COALESCE(?, color),
|
||||
has_fuehrungsstelle = COALESCE(?, has_fuehrungsstelle),
|
||||
fuehrungsstelle_name = COALESCE(?, fuehrungsstelle_name),
|
||||
parent_id = COALESCE(?, parent_id),
|
||||
level = COALESCE(?, level),
|
||||
position_x = COALESCE(?, position_x),
|
||||
position_y = COALESCE(?, position_y),
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name || null,
|
||||
description !== undefined ? description : null,
|
||||
color || null,
|
||||
hasFuehrungsstelle !== undefined ? (hasFuehrungsstelle ? 1 : 0) : null,
|
||||
fuehrungsstelleName || null,
|
||||
newParentId !== undefined ? newParentId : null,
|
||||
level !== undefined ? Number(level) : null,
|
||||
positionX !== undefined ? Math.round(Number(positionX)) : null,
|
||||
positionY !== undefined ? Math.round(Number(positionY)) : null,
|
||||
now,
|
||||
req.params.id
|
||||
)
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
|
||||
if (finalParentId && !finalParentType) {
|
||||
const parentRow = db.prepare('SELECT type FROM organizational_units WHERE id = ?').get(finalParentId) as { type: OrganizationalUnitType } | undefined
|
||||
finalParentType = parentRow?.type ?? null
|
||||
}
|
||||
|
||||
const allowedParents = PARENT_RULES[targetType] ?? null
|
||||
if (allowedParents && allowedParents.length > 0) {
|
||||
if (!finalParentId) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Dieser Einheitstyp benötigt eine übergeordnete Einheit' } })
|
||||
}
|
||||
if (!finalParentType || !allowedParents.includes(finalParentType)) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Ungültige übergeordnete Einheit für diesen Typ' } })
|
||||
}
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
|
||||
if (code !== undefined) {
|
||||
updates.push('code = ?')
|
||||
params.push(code)
|
||||
}
|
||||
if (name !== undefined) {
|
||||
updates.push('name = ?')
|
||||
params.push(name)
|
||||
}
|
||||
if (type !== undefined) {
|
||||
updates.push('type = ?')
|
||||
params.push(type)
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updates.push('description = ?')
|
||||
params.push(description)
|
||||
}
|
||||
if (color !== undefined) {
|
||||
updates.push('color = ?')
|
||||
params.push(color)
|
||||
}
|
||||
if (hasFuehrungsstelle !== undefined) {
|
||||
updates.push('has_fuehrungsstelle = ?')
|
||||
params.push(hasFuehrungsstelle ? 1 : 0)
|
||||
}
|
||||
if (fuehrungsstelleName !== undefined) {
|
||||
updates.push('fuehrungsstelle_name = ?')
|
||||
params.push(fuehrungsstelleName)
|
||||
}
|
||||
if (parentId !== undefined) {
|
||||
updates.push('parent_id = ?')
|
||||
params.push(finalParentId)
|
||||
}
|
||||
if (level !== undefined) {
|
||||
updates.push('level = ?')
|
||||
params.push(Number(level))
|
||||
}
|
||||
if (positionX !== undefined) {
|
||||
updates.push('position_x = ?')
|
||||
params.push(Math.round(Number(positionX)))
|
||||
}
|
||||
if (positionY !== undefined) {
|
||||
updates.push('position_y = ?')
|
||||
params.push(Math.round(Number(positionY)))
|
||||
}
|
||||
|
||||
updates.push('updated_at = ?')
|
||||
params.push(now)
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.json({ success: true, message: 'No changes applied' })
|
||||
}
|
||||
|
||||
params.push(req.params.id)
|
||||
const stmt = db.prepare(`UPDATE organizational_units SET ${updates.join(', ')} WHERE id = ?`)
|
||||
stmt.run(...params)
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error updating organizational unit:', error)
|
||||
@ -405,7 +577,7 @@ router.post('/assignments',
|
||||
}
|
||||
|
||||
// Validate unit exists and is active
|
||||
const unit = db.prepare('SELECT id FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId)
|
||||
const unit = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId) as { id: string; code?: string | null; name?: string | null } | undefined
|
||||
if (!unit) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
|
||||
}
|
||||
@ -427,6 +599,23 @@ router.post('/assignments',
|
||||
SET is_primary = 0, updated_at = ?
|
||||
WHERE employee_id = ? AND end_date IS NULL
|
||||
`).run(now, employeeId)
|
||||
|
||||
// Keep employees.primary_unit_id in sync for listings
|
||||
db.prepare(`
|
||||
UPDATE employees
|
||||
SET primary_unit_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(unitId, now, employeeId)
|
||||
|
||||
// Update department text (backward compatibility for older UIs/exports)
|
||||
const deptText = (unit.code && String(unit.code).trim().length > 0) ? unit.code : (unit.name || null)
|
||||
if (deptText) {
|
||||
db.prepare(`
|
||||
UPDATE employees
|
||||
SET department = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(deptText, now, employeeId)
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
@ -544,15 +733,39 @@ router.post('/deputies',
|
||||
const now = new Date().toISOString()
|
||||
const assignmentId = uuidv4()
|
||||
|
||||
// Check for conflicts
|
||||
let range
|
||||
try {
|
||||
range = normalizeAssignmentRange(validFrom, validUntil)
|
||||
} catch (error: any) {
|
||||
return res.status(400).json({ success: false, error: { message: error?.message || 'Invalid date range' } })
|
||||
}
|
||||
|
||||
const { startSql, endSql } = range
|
||||
|
||||
const conflict = db.prepare(`
|
||||
SELECT id FROM deputy_assignments
|
||||
SELECT id, unit_id as unitId, reason as existingReason, can_delegate as existingCanDelegate
|
||||
FROM deputy_assignments
|
||||
WHERE principal_id = ? AND deputy_id = ?
|
||||
AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?))
|
||||
`).get(req.user.employeeId, deputyId, validFrom, validUntil, validFrom, validUntil)
|
||||
AND valid_until >= ?
|
||||
AND valid_from <= ?
|
||||
`).get(req.user.employeeId, deputyId, startSql, endSql) as any
|
||||
|
||||
if (conflict) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
|
||||
db.prepare(`
|
||||
UPDATE deputy_assignments
|
||||
SET unit_id = ?, valid_from = ?, valid_until = ?, reason = ?, can_delegate = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
unitId || conflict.unitId || null,
|
||||
startSql,
|
||||
endSql,
|
||||
reason !== undefined ? (reason || null) : (conflict.existingReason || null),
|
||||
canDelegate !== undefined ? (canDelegate ? 1 : 0) : (conflict.existingCanDelegate ? 1 : 0),
|
||||
now,
|
||||
conflict.id
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: { id: conflict.id, updated: true } })
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
@ -563,7 +776,7 @@ router.post('/deputies',
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
assignmentId, req.user.employeeId, deputyId, unitId || null,
|
||||
validFrom, validUntil, reason || null, canDelegate ? 1 : 0,
|
||||
startSql, endSql, reason || null, canDelegate ? 1 : 0,
|
||||
req.user.id, now, now
|
||||
)
|
||||
|
||||
@ -581,7 +794,7 @@ router.post('/deputies/my',
|
||||
[
|
||||
body('deputyId').isUUID(),
|
||||
body('validFrom').isISO8601(),
|
||||
body('validUntil').optional({ nullable: true }).isISO8601(),
|
||||
body('validUntil').isISO8601(),
|
||||
body('unitId').optional({ nullable: true }).isUUID()
|
||||
],
|
||||
async (req: AuthRequest, res: any, next: any) => {
|
||||
@ -599,15 +812,39 @@ router.post('/deputies/my',
|
||||
const now = new Date().toISOString()
|
||||
const assignmentId = uuidv4()
|
||||
|
||||
// Check for conflicts
|
||||
let range
|
||||
try {
|
||||
range = normalizeAssignmentRange(validFrom, validUntil)
|
||||
} catch (error: any) {
|
||||
return res.status(400).json({ success: false, error: { message: error?.message || 'Invalid date range' } })
|
||||
}
|
||||
|
||||
const { startSql, endSql } = range
|
||||
|
||||
const conflict = db.prepare(`
|
||||
SELECT id FROM deputy_assignments
|
||||
SELECT id, unit_id as unitId, reason as existingReason, can_delegate as existingCanDelegate
|
||||
FROM deputy_assignments
|
||||
WHERE principal_id = ? AND deputy_id = ?
|
||||
AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?))
|
||||
`).get(req.user.employeeId, deputyId, validFrom, validUntil || validFrom, validFrom, validUntil || validFrom)
|
||||
AND valid_until >= ?
|
||||
AND valid_from <= ?
|
||||
`).get(req.user.employeeId, deputyId, startSql, endSql) as any
|
||||
|
||||
if (conflict) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
|
||||
db.prepare(`
|
||||
UPDATE deputy_assignments
|
||||
SET unit_id = ?, valid_from = ?, valid_until = ?, reason = ?, can_delegate = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
unitId || conflict.unitId || null,
|
||||
startSql,
|
||||
endSql,
|
||||
reason !== undefined ? (reason || null) : (conflict.existingReason || null),
|
||||
canDelegate !== undefined ? (canDelegate ? 1 : 0) : (conflict.existingCanDelegate ? 1 : 0),
|
||||
now,
|
||||
conflict.id
|
||||
)
|
||||
|
||||
return res.json({ success: true, data: { id: conflict.id, updated: true } })
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
@ -618,7 +855,7 @@ router.post('/deputies/my',
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
assignmentId, req.user.employeeId, deputyId, unitId || null,
|
||||
validFrom, validUntil || validFrom, reason || null, canDelegate ? 1 : 0,
|
||||
startSql, endSql, reason || null, canDelegate ? 1 : 0,
|
||||
req.user.id, now, now
|
||||
)
|
||||
|
||||
|
||||
242
backend/src/routes/positions.ts
Normale Datei
242
backend/src/routes/positions.ts
Normale Datei
@ -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
|
||||
@ -4,7 +4,7 @@ import bcrypt from 'bcrypt'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { db, encryptedDb } from '../config/secureDatabase'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { User, UserRole } from '@skillmate/shared'
|
||||
import { User, UserRole, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
import { emailService } from '../services/emailService'
|
||||
import { logger } from '../utils/logger'
|
||||
@ -15,9 +15,23 @@ const router = Router()
|
||||
router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const users = db.prepare(`
|
||||
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY username
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.email,
|
||||
u.role,
|
||||
u.employee_id,
|
||||
u.last_login,
|
||||
u.is_active,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.power_unit_id,
|
||||
u.power_function,
|
||||
ou.name AS power_unit_name,
|
||||
ou.type AS power_unit_type
|
||||
FROM users u
|
||||
LEFT JOIN organizational_units ou ON ou.id = u.power_unit_id
|
||||
ORDER BY u.username
|
||||
`).all() as any[]
|
||||
|
||||
// Decrypt email addresses (handle decryption failures)
|
||||
@ -35,6 +49,9 @@ router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthR
|
||||
}
|
||||
}
|
||||
|
||||
const powerDef = POWER_FUNCTIONS.find(def => def.id === user.power_function)
|
||||
const canManageEmployees = user.role === 'admin' || (user.role === 'superuser' && powerDef?.canManageEmployees)
|
||||
|
||||
return {
|
||||
...user,
|
||||
email: decryptedEmail,
|
||||
@ -42,7 +59,12 @@ router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthR
|
||||
lastLogin: user.last_login ? new Date(user.last_login) : null,
|
||||
createdAt: new Date(user.created_at),
|
||||
updatedAt: new Date(user.updated_at),
|
||||
employeeId: user.employee_id
|
||||
employeeId: user.employee_id,
|
||||
powerUnitId: user.power_unit_id || null,
|
||||
powerUnitName: user.power_unit_name || null,
|
||||
powerUnitType: user.power_unit_type || null,
|
||||
powerFunction: user.power_function || null,
|
||||
canManageEmployees: Boolean(canManageEmployees)
|
||||
}
|
||||
})
|
||||
|
||||
@ -58,7 +80,9 @@ router.put('/:id/role',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
[
|
||||
body('role').isIn(['admin', 'superuser', 'user'])
|
||||
body('role').isIn(['admin', 'superuser', 'user']),
|
||||
body('powerUnitId').optional({ nullable: true }).isUUID(),
|
||||
body('powerFunction').optional({ nullable: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@ -71,7 +95,7 @@ router.put('/:id/role',
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const { role } = req.body
|
||||
const { role, powerUnitId, powerFunction } = req.body as { role: UserRole; powerUnitId?: string | null; powerFunction?: string | null }
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
||||
@ -90,11 +114,40 @@ router.put('/:id/role',
|
||||
})
|
||||
}
|
||||
|
||||
// Update role
|
||||
let finalPowerUnit: string | null = null
|
||||
let finalPowerFunction: string | null = null
|
||||
|
||||
if (role === 'superuser') {
|
||||
if (!powerUnitId || !powerFunction) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Poweruser requires organizational unit and Funktion' }
|
||||
})
|
||||
}
|
||||
|
||||
const functionDef = POWER_FUNCTIONS.find(def => def.id === powerFunction)
|
||||
if (!functionDef) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Ungültige Poweruser-Funktion' } })
|
||||
}
|
||||
|
||||
const unitRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(powerUnitId) as { id: string; type: OrganizationalUnitType } | undefined
|
||||
if (!unitRow) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Organisationseinheit nicht gefunden' } })
|
||||
}
|
||||
|
||||
if (!functionDef.unitTypes.includes(unitRow.type)) {
|
||||
return res.status(400).json({ success: false, error: { message: `Funktion ${functionDef.label} kann nicht der Einheit vom Typ ${unitRow.type} zugeordnet werden` } })
|
||||
}
|
||||
|
||||
finalPowerUnit = powerUnitId
|
||||
finalPowerFunction = powerFunction
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
db.prepare(`
|
||||
UPDATE users SET role = ?, updated_at = ?
|
||||
UPDATE users SET role = ?, power_unit_id = ?, power_function = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(role, new Date().toISOString(), id)
|
||||
`).run(role, finalPowerUnit, finalPowerFunction, now, id)
|
||||
|
||||
logger.info(`User role updated: ${existingUser.username} -> ${role}`)
|
||||
res.json({ success: true, message: 'Role updated successfully' })
|
||||
@ -124,6 +177,13 @@ router.post('/bulk-create-from-employees',
|
||||
}
|
||||
|
||||
const { employeeIds, role } = req.body as { employeeIds: string[]; role: UserRole }
|
||||
|
||||
if (role === 'superuser') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Bulk-Erstellung für Poweruser wird nicht unterstützt. Bitte einzeln mit Organisationszuordnung anlegen.' }
|
||||
})
|
||||
}
|
||||
const results: any[] = []
|
||||
|
||||
for (const employeeId of employeeIds) {
|
||||
@ -155,8 +215,8 @@ router.post('/bulk-create-from-employees',
|
||||
const userId = uuidv4()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
finalUsername,
|
||||
@ -165,6 +225,8 @@ router.post('/bulk-create-from-employees',
|
||||
hashedPassword,
|
||||
role,
|
||||
employeeId,
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
@ -336,7 +398,9 @@ router.post('/create-from-employee',
|
||||
[
|
||||
body('employeeId').notEmpty().isString(),
|
||||
body('username').optional().isString().isLength({ min: 3 }),
|
||||
body('role').isIn(['admin', 'superuser', 'user'])
|
||||
body('role').isIn(['admin', 'superuser', 'user']),
|
||||
body('powerUnitId').optional({ nullable: true }).isUUID(),
|
||||
body('powerFunction').optional({ nullable: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@ -348,7 +412,7 @@ router.post('/create-from-employee',
|
||||
})
|
||||
}
|
||||
|
||||
const { employeeId, username, role } = req.body
|
||||
const { employeeId, username, role, powerUnitId, powerFunction } = req.body as { employeeId: string; username?: string; role: UserRole; powerUnitId?: string | null; powerFunction?: string | null }
|
||||
|
||||
// Check employee exists
|
||||
const employee = encryptedDb.getEmployee(employeeId) as any
|
||||
@ -381,6 +445,29 @@ router.post('/create-from-employee',
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedPowerUnit: string | null = null
|
||||
let resolvedPowerFunction: string | null = null
|
||||
|
||||
if (role === 'superuser') {
|
||||
const powerFunctionId = typeof powerFunction === 'string' ? powerFunction : null
|
||||
const functionDef = powerFunctionId ? POWER_FUNCTIONS.find(def => def.id === powerFunctionId) : undefined
|
||||
if (!powerUnitId || !functionDef || !powerFunctionId) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Poweruser requires Organisationseinheit und Funktion' } })
|
||||
}
|
||||
|
||||
const unitRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(powerUnitId) as { id: string; type: OrganizationalUnitType } | undefined
|
||||
if (!unitRow) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Organisationseinheit nicht gefunden' } })
|
||||
}
|
||||
|
||||
if (!functionDef.unitTypes.includes(unitRow.type)) {
|
||||
return res.status(400).json({ success: false, error: { message: `Funktion ${functionDef.label} kann nicht der Einheit vom Typ ${unitRow.type} zugeordnet werden` } })
|
||||
}
|
||||
|
||||
resolvedPowerUnit = powerUnitId
|
||||
resolvedPowerFunction = powerFunctionId
|
||||
}
|
||||
|
||||
// Generate temp password
|
||||
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 12)
|
||||
@ -389,8 +476,8 @@ router.post('/create-from-employee',
|
||||
|
||||
// Insert user with encrypted email
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
finalUsername,
|
||||
@ -399,6 +486,8 @@ router.post('/create-from-employee',
|
||||
hashedPassword,
|
||||
role,
|
||||
employeeId,
|
||||
resolvedPowerUnit,
|
||||
resolvedPowerFunction,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
|
||||
@ -32,6 +32,7 @@ export class SyncScheduler {
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.ensureSyncSettingsTable()
|
||||
// Check current sync settings on startup
|
||||
this.checkAndUpdateInterval()
|
||||
|
||||
@ -41,6 +42,44 @@ export class SyncScheduler {
|
||||
}, 60000)
|
||||
}
|
||||
|
||||
private ensureSyncSettingsTable() {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
auto_sync_interval TEXT,
|
||||
conflict_resolution TEXT CHECK(conflict_resolution IN ('admin', 'newest', 'manual')),
|
||||
sync_employees INTEGER DEFAULT 1,
|
||||
sync_skills INTEGER DEFAULT 1,
|
||||
sync_users INTEGER DEFAULT 1,
|
||||
sync_settings INTEGER DEFAULT 0,
|
||||
bandwidth_limit INTEGER,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO sync_settings (
|
||||
id, auto_sync_interval, conflict_resolution,
|
||||
sync_employees, sync_skills, sync_users, sync_settings,
|
||||
bandwidth_limit, updated_at, updated_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'default',
|
||||
'disabled',
|
||||
'admin',
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
null,
|
||||
now,
|
||||
'system'
|
||||
)
|
||||
}
|
||||
|
||||
private checkAndUpdateInterval() {
|
||||
try {
|
||||
const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any
|
||||
@ -126,4 +165,4 @@ export class SyncScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
export const syncScheduler = SyncScheduler.getInstance()
|
||||
export const syncScheduler = SyncScheduler.getInstance()
|
||||
|
||||
143
backend/src/utils/department.ts
Normale Datei
143
backend/src/utils/department.ts
Normale Datei
@ -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
53
backend/src/utils/html.ts
Normale Datei
@ -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
|
||||
}
|
||||
@ -1,20 +1,20 @@
|
||||
import { body } from 'express-validator'
|
||||
|
||||
export const createEmployeeValidators = [
|
||||
body('firstName').notEmpty().trim().escape(),
|
||||
body('lastName').notEmpty().trim().escape(),
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('department').notEmpty().trim().escape(),
|
||||
body('position').optional().trim().escape(),
|
||||
body('department').notEmpty().trim(),
|
||||
body('position').optional().trim(),
|
||||
body('phone').optional().trim(),
|
||||
body('employeeNumber').optional().trim(),
|
||||
]
|
||||
|
||||
export const updateEmployeeValidators = [
|
||||
body('firstName').notEmpty().trim().escape(),
|
||||
body('lastName').notEmpty().trim().escape(),
|
||||
body('position').optional().trim().escape(),
|
||||
body('department').notEmpty().trim().escape(),
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('position').optional().trim(),
|
||||
body('department').notEmpty().trim(),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('phone').optional().trim(),
|
||||
body('employeeNumber').optional().trim(),
|
||||
|
||||
1317
frontend/package-lock.json
generiert
1317
frontend/package-lock.json
generiert
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
@ -10,11 +10,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.88.0",
|
||||
"@react-three/drei": "^9.112.5",
|
||||
"@react-three/fiber": "^8.15.0",
|
||||
"@skillmate/shared": "file:../shared",
|
||||
"@types/three": "^0.180.0",
|
||||
"axios": "^1.6.2",
|
||||
"axios": "^1.7.9",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@ -31,5 +31,9 @@
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.52.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "^4.52.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,28 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import api from '../services/api'
|
||||
import type { DeputyAssignment, Employee } from '@skillmate/shared'
|
||||
import type { Employee } from '@skillmate/shared'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
interface UnitOption {
|
||||
id: string
|
||||
name: string
|
||||
code?: string | null
|
||||
isPrimary?: boolean
|
||||
}
|
||||
|
||||
export default function DeputyManagement() {
|
||||
const [asPrincipal, setAsPrincipal] = useState<any[]>([])
|
||||
const [asDeputy, setAsDeputy] = useState<any[]>([])
|
||||
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
|
||||
const [unitOptions, setUnitOptions] = useState<UnitOption[]>([])
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState('')
|
||||
const [formData, setFormData] = useState({
|
||||
deputyId: '',
|
||||
validFrom: new Date().toISOString().split('T')[0],
|
||||
validUntil: '',
|
||||
validUntil: new Date().toISOString().split('T')[0],
|
||||
reason: '',
|
||||
canDelegate: true,
|
||||
unitId: ''
|
||||
@ -22,6 +32,7 @@ export default function DeputyManagement() {
|
||||
useEffect(() => {
|
||||
loadDeputies()
|
||||
loadEmployees()
|
||||
loadUnits()
|
||||
}, [])
|
||||
|
||||
const loadDeputies = async () => {
|
||||
@ -43,28 +54,66 @@ export default function DeputyManagement() {
|
||||
try {
|
||||
const response = await api.get('/employees/public')
|
||||
if (response.data.success) {
|
||||
setAvailableEmployees(response.data.data)
|
||||
const sorted = (response.data.data as Employee[])
|
||||
.filter(emp => emp.id !== user?.employeeId)
|
||||
.sort((a, b) => `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`))
|
||||
setAvailableEmployees(sorted)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load employees:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDeputy = async () => {
|
||||
const loadUnits = async () => {
|
||||
try {
|
||||
const response = await api.post('/organization/deputies/my', {
|
||||
...formData,
|
||||
validFrom: new Date(formData.validFrom).toISOString(),
|
||||
validUntil: formData.validUntil ? new Date(formData.validUntil).toISOString() : null
|
||||
})
|
||||
const response = await api.get('/organization/my-units')
|
||||
if (response.data.success) {
|
||||
setUnitOptions(response.data.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load organizational units for deputy dialog:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDeputy = async () => {
|
||||
if (submitting) return
|
||||
setFormError('')
|
||||
|
||||
if (!formData.deputyId) {
|
||||
setFormError('Bitte wählen Sie eine Vertretung aus.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
|
||||
const payload: any = {
|
||||
deputyId: formData.deputyId,
|
||||
validFrom: formData.validFrom,
|
||||
validUntil: formData.validUntil,
|
||||
reason: formData.reason || null,
|
||||
canDelegate: formData.canDelegate
|
||||
}
|
||||
|
||||
if (formData.unitId) {
|
||||
payload.unitId = formData.unitId
|
||||
}
|
||||
|
||||
const response = await api.post('/organization/deputies/my', payload)
|
||||
|
||||
if (response.data.success) {
|
||||
await loadDeputies()
|
||||
setShowAddDialog(false)
|
||||
resetForm()
|
||||
} else {
|
||||
setFormError(response.data?.error?.message || 'Vertretung konnte nicht gespeichert werden.')
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Failed to add deputy:', error)
|
||||
const message = error?.response?.data?.error?.message || 'Vertretung konnte nicht gespeichert werden.'
|
||||
setFormError(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,14 +153,48 @@ export default function DeputyManagement() {
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
setFormData({
|
||||
deputyId: '',
|
||||
validFrom: new Date().toISOString().split('T')[0],
|
||||
validUntil: '',
|
||||
validFrom: today,
|
||||
validUntil: today,
|
||||
reason: '',
|
||||
canDelegate: true,
|
||||
unitId: ''
|
||||
})
|
||||
setFormError('')
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
setFormError('')
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
if (asPrincipal.length > 0) {
|
||||
const existing = asPrincipal[0]
|
||||
const existingFrom = existing.validFrom ? existing.validFrom.slice(0, 10) : today
|
||||
const existingUntil = existing.validUntil ? existing.validUntil.slice(0, 10) : today
|
||||
|
||||
setFormData({
|
||||
deputyId: existing.deputyId || existing.id || '',
|
||||
validFrom: existingFrom,
|
||||
validUntil: existingUntil < existingFrom ? existingFrom : existingUntil,
|
||||
reason: existing.reason || '',
|
||||
canDelegate: existing.canDelegate === 1 || existing.canDelegate === true,
|
||||
unitId: existing.unitId || ''
|
||||
})
|
||||
} else {
|
||||
setFormData({
|
||||
deputyId: '',
|
||||
validFrom: today,
|
||||
validUntil: today,
|
||||
reason: '',
|
||||
canDelegate: true,
|
||||
unitId: ''
|
||||
})
|
||||
}
|
||||
|
||||
setShowAddDialog(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@ -132,10 +215,10 @@ export default function DeputyManagement() {
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold dark:text-white">Aktuelle Vertretungen</h3>
|
||||
<button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
onClick={openAddDialog}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
+ Vertretung hinzufügen
|
||||
{asPrincipal.length > 0 ? 'Vertretungszeitraum anpassen' : '+ Vertretung hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -213,6 +296,7 @@ export default function DeputyManagement() {
|
||||
onChange={(e) => setFormData({ ...formData, deputyId: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
required
|
||||
disabled={asPrincipal.length > 0}
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{availableEmployees.map(emp => (
|
||||
@ -221,8 +305,33 @@ export default function DeputyManagement() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{asPrincipal.length > 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Hinweis: Es kann nur eine Vertretung gepflegt werden. Passen Sie die Zeiträume nach Bedarf an.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unitOptions.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Für Organisationseinheit (optional)
|
||||
</label>
|
||||
<select
|
||||
value={formData.unitId}
|
||||
onChange={(e) => setFormData({ ...formData, unitId: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="">Alle Aufgaben / gesamt</option>
|
||||
{unitOptions.map(unit => (
|
||||
<option key={unit.id} value={unit.id}>
|
||||
{unit.name}{unit.code ? ` (${unit.code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
@ -231,20 +340,29 @@ export default function DeputyManagement() {
|
||||
<input
|
||||
type="date"
|
||||
value={formData.validFrom}
|
||||
onChange={(e) => setFormData({ ...formData, validFrom: e.target.value })}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
validFrom: value,
|
||||
validUntil: prev.validUntil < value ? value : prev.validUntil
|
||||
}))
|
||||
}}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-300">
|
||||
Bis (optional)
|
||||
Bis *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.validUntil}
|
||||
onChange={(e) => setFormData({ ...formData, validUntil: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600"
|
||||
required
|
||||
min={formData.validFrom}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -282,6 +400,12 @@ export default function DeputyManagement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div className="mt-4 rounded-input border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500 dark:bg-red-900/30 dark:text-red-200">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -294,9 +418,10 @@ export default function DeputyManagement() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddDeputy}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
disabled={submitting}
|
||||
className={`px-4 py-2 rounded text-white ${submitting ? 'bg-blue-400 cursor-wait' : 'bg-blue-600 hover:bg-blue-700'}`}
|
||||
>
|
||||
Hinzufügen
|
||||
{submitting ? 'Speichere...' : 'Hinzufügen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { Employee } from '@skillmate/shared'
|
||||
import { formatDepartmentWithDescription, normalizeDepartment } from '../utils/text'
|
||||
|
||||
interface EmployeeCardProps {
|
||||
employee: Employee
|
||||
onClick: () => void
|
||||
onDeputyNavigate?: (id: string) => void
|
||||
}
|
||||
|
||||
export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
|
||||
export default function EmployeeCard({ employee, onClick, onDeputyNavigate }: EmployeeCardProps) {
|
||||
const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
|
||||
const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '')
|
||||
const photoSrc = employee.photo && employee.photo.startsWith('/uploads/')
|
||||
@ -27,9 +29,35 @@ export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
|
||||
}
|
||||
|
||||
const availability = getAvailabilityBadge(employee.availability)
|
||||
const specializations = employee.specializations || []
|
||||
const currentDeputies = employee.currentDeputies || []
|
||||
const represents = employee.represents || []
|
||||
const isUnavailable = employee.availability && employee.availability !== 'available'
|
||||
const cardHighlightClasses = isUnavailable
|
||||
? 'border-red-300 bg-red-50 hover:border-red-400 hover:bg-red-50/90 dark:bg-red-900/20 dark:border-red-700 dark:hover:bg-red-900/30'
|
||||
: ''
|
||||
const departmentInfo = formatDepartmentWithDescription(employee.department, employee.departmentDescription)
|
||||
const departmentDescriptionText = normalizeDepartment(departmentInfo.description)
|
||||
const departmentTasks = normalizeDepartment(employee.departmentTasks || departmentInfo.tasks)
|
||||
const showDepartmentDescription = departmentDescriptionText.length > 0 && departmentDescriptionText !== departmentTasks
|
||||
|
||||
// Show only the end unit; provide full chain as tooltip. Hide top-level root (e.g. DIR/LKA NRW)
|
||||
const fullPath = normalizeDepartment(departmentInfo.label)
|
||||
const splitPath = (fullPath || '').split(' -> ').map(s => s.trim()).filter(Boolean)
|
||||
const filteredPath = splitPath.length > 0 && (/^dir$/i.test(splitPath[0]) || /^lka\s+nrw$/i.test(splitPath[0]))
|
||||
? splitPath.slice(1)
|
||||
: splitPath
|
||||
const shortDepartmentLabel = filteredPath.length > 0 ? filteredPath[filteredPath.length - 1] : (fullPath || '')
|
||||
const chainTitle = filteredPath.join(' → ') || fullPath
|
||||
const handleDeputyClick = (event: React.MouseEvent<HTMLButtonElement>, targetId: string) => {
|
||||
event.stopPropagation()
|
||||
if (onDeputyNavigate) {
|
||||
onDeputyNavigate(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" onClick={onClick}>
|
||||
<div className={`card ${cardHighlightClasses}`} onClick={onClick}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-16 h-16 rounded-full bg-bg-accent dark:bg-dark-primary flex items-center justify-center overflow-hidden">
|
||||
@ -69,27 +97,100 @@ export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) {
|
||||
</p>
|
||||
)}
|
||||
<p className="text-body text-secondary">
|
||||
<span className="font-medium">Dienststelle:</span> {employee.department}
|
||||
<span className="font-medium">Dienststelle:</span>{' '}
|
||||
<span title={chainTitle}>{shortDepartmentLabel}</span>
|
||||
</p>
|
||||
{showDepartmentDescription && (
|
||||
<p className="text-small text-tertiary ml-4">{departmentDescriptionText}</p>
|
||||
)}
|
||||
{departmentTasks && (
|
||||
<p className="text-small text-secondary">
|
||||
<span className="font-medium">Aufgaben der Dienststelle:</span> {departmentTasks}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{employee.specializations.length > 0 && (
|
||||
{specializations.length > 0 && (
|
||||
<div>
|
||||
<p className="text-small font-medium text-tertiary mb-2">Spezialisierungen:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{employee.specializations.slice(0, 3).map((spec, index) => (
|
||||
{specializations.slice(0, 3).map((spec, index) => (
|
||||
<span key={index} className="badge badge-info text-xs">
|
||||
{spec}
|
||||
</span>
|
||||
))}
|
||||
{employee.specializations.length > 3 && (
|
||||
{specializations.length > 3 && (
|
||||
<span className="text-xs text-tertiary">
|
||||
+{employee.specializations.length - 3} weitere
|
||||
+{specializations.length - 3} weitere
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{represents.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-small font-medium text-primary">Vertritt</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{represents.map(item => {
|
||||
const label = `${item.firstName || ''} ${item.lastName || ''}`.trim()
|
||||
const descParts = [
|
||||
label,
|
||||
item.position || undefined,
|
||||
item.availability ? `Status: ${item.availability}` : undefined
|
||||
].filter(Boolean)
|
||||
return (
|
||||
<button
|
||||
key={item.assignmentId || item.id}
|
||||
type="button"
|
||||
title={descParts.join(' · ')}
|
||||
onClick={(event) => handleDeputyClick(event, item.id)}
|
||||
className="inline-flex items-center gap-1 rounded-badge bg-blue-100 px-3 py-1 text-sm font-medium text-blue-700 transition hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
|
||||
>
|
||||
{label || 'Unbekannt'}
|
||||
{item.position && <span className="text-xs text-blue-500 dark:text-blue-300">({item.position})</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnavailable && (
|
||||
<div className="mt-4">
|
||||
<p className="text-small font-medium text-red-700 dark:text-red-200 mb-2">Vertretung</p>
|
||||
{currentDeputies.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentDeputies.map((deputy) => {
|
||||
const label = `${deputy.firstName || ''} ${deputy.lastName || ''}`.trim()
|
||||
const titleParts = [
|
||||
label,
|
||||
deputy.position || undefined,
|
||||
deputy.availability ? `Status: ${deputy.availability}` : undefined,
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${deputy.assignmentId || deputy.id}`}
|
||||
type="button"
|
||||
title={titleParts.join(' · ')}
|
||||
onClick={(event) => handleDeputyClick(event, deputy.id)}
|
||||
className="inline-flex items-center gap-1 rounded-badge bg-red-100 px-3 py-1 text-sm font-medium text-red-700 transition hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400 dark:bg-red-900/40 dark:text-red-200 dark:hover:bg-red-900/60"
|
||||
>
|
||||
{label || 'Ohne Namen'}
|
||||
{deputy.position && <span className="text-xs text-red-500 dark:text-red-300">({deputy.position})</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-small text-red-600 dark:text-red-300">
|
||||
Keine Vertretung hinterlegt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
import type { OrganizationalUnit, EmployeeUnitAssignment } from '@skillmate/shared'
|
||||
import api from '../services/api'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { decodeHtmlEntities } from '../utils/text'
|
||||
|
||||
interface OrganizationChartProps {
|
||||
onClose: () => void
|
||||
@ -113,6 +114,26 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
|
||||
return colors[level % colors.length]
|
||||
}
|
||||
|
||||
const getUnitDisplayLabel = (unit?: OrganizationalUnit | null) => {
|
||||
if (!unit) return ''
|
||||
const code = unit.code?.trim()
|
||||
if (code) return code
|
||||
const decoded = decodeHtmlEntities(unit.name) || unit.name || ''
|
||||
return decoded.trim()
|
||||
}
|
||||
|
||||
const getUnitDescription = (unit?: OrganizationalUnit | null) => {
|
||||
if (!unit) return ''
|
||||
const source = unit.description && unit.description.trim().length > 0 ? unit.description : unit.name
|
||||
const decoded = decodeHtmlEntities(source) || source || ''
|
||||
const trimmed = decoded.trim()
|
||||
const label = getUnitDisplayLabel(unit)
|
||||
if (trimmed && trimmed !== label) {
|
||||
return trimmed
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleUnitClick = (unit: OrganizationalUnit) => {
|
||||
setSelectedUnit(unit)
|
||||
loadUnitDetails(unit.id)
|
||||
@ -147,7 +168,8 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
|
||||
|
||||
const renderUnit = (unit: any, level: number = 0) => {
|
||||
const isMyUnit = myUnits.some(u => u.id === unit.id)
|
||||
const isSearchMatch = searchTerm && unit.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const searchable = `${getUnitDisplayLabel(unit)} ${getUnitDescription(unit)}`.toLowerCase()
|
||||
const isSearchMatch = searchTerm && searchable.includes(searchTerm.toLowerCase())
|
||||
|
||||
return (
|
||||
<div key={unit.id} className="flex flex-col items-center">
|
||||
@ -167,7 +189,12 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h4 className="font-semibold text-sm dark:text-white">{unit.name}</h4>
|
||||
<h4 className="font-semibold text-sm dark:text-white">{getUnitDisplayLabel(unit)}</h4>
|
||||
{getUnitDescription(unit) && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
{getUnitDescription(unit)}
|
||||
</p>
|
||||
)}
|
||||
{unit.hasFuehrungsstelle && (
|
||||
<span className="inline-block px-2 py-1 mt-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">
|
||||
FüSt
|
||||
@ -313,8 +340,13 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
|
||||
{selectedUnit && (
|
||||
<div className="w-80 bg-gray-50 dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 p-4 overflow-y-auto">
|
||||
<div className={`p-3 text-white rounded-lg mb-4 ${getUnitColor(selectedUnit.level || 0)}`}>
|
||||
<h3 className="text-lg font-semibold">{selectedUnit.name}</h3>
|
||||
{selectedUnit.code && <p className="text-sm opacity-90">{selectedUnit.code}</p>}
|
||||
<h3 className="text-lg font-semibold">{getUnitDisplayLabel(selectedUnit)}</h3>
|
||||
{getUnitDescription(selectedUnit) && (
|
||||
<div className="mt-3 bg-white/10 rounded px-2 py-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-white/80">Aufgaben</p>
|
||||
<p className="text-sm leading-snug text-white">{getUnitDescription(selectedUnit)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@ -365,13 +397,6 @@ export default function OrganizationChart({ onClose }: OrganizationChartProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUnit.description && (
|
||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded">
|
||||
<h4 className="font-semibold mb-1 dark:text-white">Beschreibung</h4>
|
||||
<p className="text-sm dark:text-gray-300">{selectedUnit.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user?.employeeId && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
|
||||
@ -4,13 +4,24 @@ import type { OrganizationalUnit } from '@skillmate/shared'
|
||||
import api from '../services/api'
|
||||
import { ChevronRight, Building2, Search, X } from 'lucide-react'
|
||||
|
||||
interface OrganizationSelectorProps {
|
||||
value?: string
|
||||
onChange: (unitId: string | null, unitName: string) => void
|
||||
disabled?: boolean
|
||||
interface SelectedUnitDetails {
|
||||
unit: OrganizationalUnit
|
||||
storageValue: string
|
||||
codePath: string
|
||||
displayPath: string
|
||||
namesPath: string
|
||||
descriptionPath?: string
|
||||
tasks?: string
|
||||
}
|
||||
|
||||
export default function OrganizationSelector({ value, onChange, disabled }: OrganizationSelectorProps) {
|
||||
interface OrganizationSelectorProps {
|
||||
value?: string
|
||||
onChange: (unitId: string | null, formattedValue: string, details?: SelectedUnitDetails) => void
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function OrganizationSelector({ value, onChange, disabled, title }: OrganizationSelectorProps) {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [hierarchy, setHierarchy] = useState<OrganizationalUnit[]>([])
|
||||
const [selectedUnit, setSelectedUnit] = useState<OrganizationalUnit | null>(null)
|
||||
@ -26,14 +37,18 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
|
||||
}, [showModal])
|
||||
|
||||
useEffect(() => {
|
||||
// Load current unit name if value exists
|
||||
if (value && hierarchy.length > 0) {
|
||||
const unit = findUnitById(hierarchy, value)
|
||||
if (unit) {
|
||||
setCurrentUnitName(getUnitPath(hierarchy, unit))
|
||||
const selection = buildSelectionDetails(hierarchy, unit)
|
||||
setCurrentUnitName(selection.displayPath)
|
||||
setSelectedUnit(unit)
|
||||
}
|
||||
}
|
||||
if (!value) {
|
||||
setSelectedUnit(null)
|
||||
setCurrentUnitName('')
|
||||
}
|
||||
}, [value, hierarchy])
|
||||
|
||||
const loadOrganization = async () => {
|
||||
@ -73,25 +88,63 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
|
||||
return null
|
||||
}
|
||||
|
||||
const getUnitPath = (units: OrganizationalUnit[], target: OrganizationalUnit): string => {
|
||||
const path: string[] = []
|
||||
|
||||
const findPath = (units: OrganizationalUnit[], current: string[] = []): boolean => {
|
||||
for (const unit of units) {
|
||||
const newPath = [...current, unit.name]
|
||||
if (unit.id === target.id) {
|
||||
path.push(...newPath)
|
||||
const buildSelectionDetails = (units: OrganizationalUnit[], target: OrganizationalUnit): SelectedUnitDetails => {
|
||||
const pathUnits: OrganizationalUnit[] = []
|
||||
|
||||
const findPath = (nodes: OrganizationalUnit[], current: OrganizationalUnit[] = []): boolean => {
|
||||
for (const node of nodes) {
|
||||
const extended = [...current, node]
|
||||
if (node.id === target.id) {
|
||||
pathUnits.splice(0, pathUnits.length, ...extended)
|
||||
return true
|
||||
}
|
||||
if (unit.children && findPath(unit.children, newPath)) {
|
||||
if (node.children && findPath(node.children, extended)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
findPath(units)
|
||||
return path.join(' → ')
|
||||
|
||||
const normalize = (value?: string | null) => (value ?? '').trim()
|
||||
|
||||
const codeSegments = pathUnits
|
||||
.map(unit => normalize(unit.code) || normalize(unit.name))
|
||||
.filter(Boolean)
|
||||
|
||||
const nameSegments = pathUnits
|
||||
.map(unit => normalize(unit.name))
|
||||
.filter(Boolean)
|
||||
|
||||
const displaySegments = pathUnits
|
||||
.map(unit => {
|
||||
const code = normalize(unit.code)
|
||||
const name = normalize(unit.name)
|
||||
if (code && name && code !== name) {
|
||||
return `${code} – ${name}`
|
||||
}
|
||||
return code || name || ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const codePath = codeSegments.join(' -> ')
|
||||
const displayPath = displaySegments.join(' → ')
|
||||
const namesPath = nameSegments.join(' → ')
|
||||
const descriptionPath = nameSegments.slice(0, -1).join(' → ') || undefined
|
||||
const rawTask = normalize(target.description) || normalize(target.name)
|
||||
const tasks = rawTask || undefined
|
||||
const storageValue = tasks ? (codePath ? `${codePath} -> ${tasks}` : tasks) : codePath
|
||||
|
||||
return {
|
||||
unit: target,
|
||||
storageValue,
|
||||
codePath: codePath || namesPath,
|
||||
displayPath: displayPath || namesPath || tasks || '',
|
||||
namesPath,
|
||||
descriptionPath,
|
||||
tasks
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (unitId: string) => {
|
||||
@ -107,10 +160,10 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
|
||||
}
|
||||
|
||||
const handleSelect = (unit: OrganizationalUnit) => {
|
||||
const selection = buildSelectionDetails(hierarchy, unit)
|
||||
setSelectedUnit(unit)
|
||||
const path = getUnitPath(hierarchy, unit)
|
||||
setCurrentUnitName(path)
|
||||
onChange(unit.id, path)
|
||||
setCurrentUnitName(selection.displayPath)
|
||||
onChange(unit.id, selection.storageValue, selection)
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
@ -193,9 +246,10 @@ export default function OrganizationSelector({ value, onChange, disabled }: Orga
|
||||
type="text"
|
||||
className="input-field w-full pr-10"
|
||||
value={currentUnitName}
|
||||
placeholder="Klicken zum Auswählen..."
|
||||
placeholder="Organisationseinheit aus dem Organigramm wählen"
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => !disabled && setShowModal(true)}
|
||||
/>
|
||||
<button
|
||||
|
||||
@ -16,6 +16,12 @@ function segmentColor(i: number) {
|
||||
return 'bg-purple-500 hover:bg-purple-600 dark:bg-purple-700 dark:hover:bg-purple-600'
|
||||
}
|
||||
|
||||
function levelLabel(i: number): string {
|
||||
if (i <= 3) return 'Anfänger'
|
||||
if (i <= 7) return 'Fortgeschritten'
|
||||
return 'Experte'
|
||||
}
|
||||
|
||||
export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disabled = false, showHelp = true }: SkillLevelBarProps) {
|
||||
const handleKey = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (disabled) return
|
||||
@ -36,6 +42,8 @@ export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disa
|
||||
|
||||
const current = typeof value === 'number' ? value : 0
|
||||
|
||||
const helpTooltip = showHelp ? 'Stufe 1: Anfänger · Stufe 5: Fortgeschritten · Stufe 10: Experte' : undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
@ -46,6 +54,7 @@ export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disa
|
||||
aria-valuenow={current || undefined}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKey}
|
||||
title={helpTooltip}
|
||||
>
|
||||
{Array.from({ length: max }, (_, idx) => idx + 1).map(i => {
|
||||
const active = i <= current
|
||||
@ -58,20 +67,14 @@ export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disa
|
||||
? segmentColor(i)
|
||||
: 'bg-border-default hover:bg-bg-gray dark:bg-dark-border/40 dark:hover:bg-dark-border/60'
|
||||
} ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
aria-label={`Level ${i}`}
|
||||
aria-label={`Stufe ${i}`}
|
||||
title={showHelp ? `Stufe ${i} – ${levelLabel(i)}` : undefined}
|
||||
onClick={() => !disabled && onChange(i)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<span className="ml-3 text-small text-secondary min-w-[2ch] text-right">{current || ''}</span>
|
||||
</div>
|
||||
{showHelp && (
|
||||
<div className="text-small text-tertiary">
|
||||
<span className="mr-4"><strong>1</strong> Anfänger</span>
|
||||
<span className="mr-4"><strong>5</strong> Fortgeschritten</span>
|
||||
<span><strong>10</strong> Experte</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -39,6 +39,16 @@ export function usePermissions() {
|
||||
const hasPermission = (permission: string): boolean => {
|
||||
if (!isAuthenticated || !user) return false
|
||||
|
||||
if (permission === 'employees:create') {
|
||||
if (user.role === 'admin') return true
|
||||
return user.role === 'superuser' && Boolean(user.canManageEmployees)
|
||||
}
|
||||
|
||||
if (permission === 'employees:update') {
|
||||
if (user.role === 'admin') return true
|
||||
return user.role === 'superuser' && Boolean(user.canManageEmployees)
|
||||
}
|
||||
|
||||
const rolePermissions = ROLE_PERMISSIONS[user.role] || []
|
||||
return rolePermissions.includes(permission)
|
||||
}
|
||||
@ -51,7 +61,8 @@ export function usePermissions() {
|
||||
}
|
||||
|
||||
const canCreateEmployee = (): boolean => {
|
||||
return hasPermission('employees:create')
|
||||
if (!isAuthenticated || !user) return false
|
||||
return user.role === 'superuser' && Boolean(user.canManageEmployees)
|
||||
}
|
||||
|
||||
const canEditEmployee = (employeeId?: string): boolean => {
|
||||
@ -60,8 +71,8 @@ export function usePermissions() {
|
||||
// Admins can edit anyone
|
||||
if (user.role === 'admin') return true
|
||||
|
||||
// Superusers can edit anyone
|
||||
if (user.role === 'superuser') return true
|
||||
// Superusers can only edit when they are allowed to manage employees
|
||||
if (user.role === 'superuser' && user.canManageEmployees) return true
|
||||
|
||||
// Users can only edit their own profile (if linked)
|
||||
if (user.role === 'user' && employeeId && user.employeeId === employeeId) {
|
||||
|
||||
@ -24,6 +24,10 @@ export const authApi = {
|
||||
const response = await api.post('/auth/login', { email, password })
|
||||
return response.data.data
|
||||
},
|
||||
forgotPassword: async (email: string) => {
|
||||
const response = await api.post('/auth/forgot-password', { email })
|
||||
return response.data
|
||||
},
|
||||
logout: async () => {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
@ -63,5 +67,31 @@ export const skillsApi = {
|
||||
}
|
||||
}
|
||||
|
||||
export const officialTitlesApi = {
|
||||
getAll: async (): Promise<string[]> => {
|
||||
const response = await api.get('/official-titles')
|
||||
const list = Array.isArray(response.data?.data) ? response.data.data : []
|
||||
return list.map((item: any) => item.label).filter((label: any) => typeof label === 'string')
|
||||
}
|
||||
}
|
||||
|
||||
export const positionCatalogApi = {
|
||||
getAll: async (unitId?: string | null): Promise<string[]> => {
|
||||
const params = unitId ? { unitId } : undefined
|
||||
const response = await api.get('/positions', { params })
|
||||
const list = Array.isArray(response.data?.data) ? response.data.data : []
|
||||
const seen = new Set<string>()
|
||||
return list
|
||||
.map((item: any) => item?.label)
|
||||
.filter((label: any) => typeof label === 'string' && label.trim().length > 0)
|
||||
.filter((label: string) => {
|
||||
const key = label.trim().toLowerCase()
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { api }
|
||||
export default api
|
||||
|
||||
81
frontend/src/utils/text.ts
Normale Datei
81
frontend/src/utils/text.ts
Normale Datei
@ -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)
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import OfficeMapModal from '../components/OfficeMapModal'
|
||||
import { employeeApi } from '../services/api'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { isBooleanSkill } from '../utils/skillRules'
|
||||
import { formatDepartmentWithDescription, normalizeDepartment } from '../utils/text'
|
||||
|
||||
export default function EmployeeDetail() {
|
||||
const { id } = useParams()
|
||||
@ -90,6 +91,11 @@ export default function EmployeeDetail() {
|
||||
)
|
||||
}
|
||||
|
||||
const departmentInfo = formatDepartmentWithDescription(employee.department, employee.departmentDescription)
|
||||
const departmentDescriptionText = normalizeDepartment(departmentInfo.description)
|
||||
const departmentTasks = normalizeDepartment(employee.departmentTasks || departmentInfo.description)
|
||||
const showDepartmentDescription = departmentDescriptionText.length > 0 && departmentDescriptionText !== departmentTasks
|
||||
|
||||
// Hinweis: Verfügbarkeits-Badge wird im Mitarbeitenden-Detail nicht angezeigt
|
||||
|
||||
return (
|
||||
@ -177,8 +183,17 @@ export default function EmployeeDetail() {
|
||||
)}
|
||||
<div>
|
||||
<span className="text-tertiary">Dienststelle:</span>
|
||||
<p className="text-secondary font-medium">{employee.department}</p>
|
||||
<p className="text-secondary font-medium">{departmentInfo.label}</p>
|
||||
{showDepartmentDescription && (
|
||||
<p className="text-small text-tertiary mt-1">{departmentDescriptionText}</p>
|
||||
)}
|
||||
</div>
|
||||
{departmentTasks && (
|
||||
<div>
|
||||
<span className="text-tertiary">Aufgaben der Dienststelle:</span>
|
||||
<p className="text-secondary mt-1">{departmentTasks}</p>
|
||||
</div>
|
||||
)}
|
||||
{employee.clearance && (
|
||||
<div>
|
||||
<span className="text-tertiary">Sicherheitsüberprüfung:</span>
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { Employee } from '@skillmate/shared'
|
||||
import { employeeApi } from '../services/api'
|
||||
import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '../data/skillCategories'
|
||||
import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '@skillmate/shared'
|
||||
import { employeeApi, officialTitlesApi, positionCatalogApi } from '../services/api'
|
||||
import PhotoPreview from '../components/PhotoPreview'
|
||||
import OrganizationSelector from '../components/OrganizationSelector'
|
||||
import { isBooleanSkill } from '../utils/skillRules'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const DEFAULT_POSITION_OPTIONS = [
|
||||
'Sachbearbeitung',
|
||||
'stellvertretende Sachgebietsleitung',
|
||||
'Sachgebietsleitung',
|
||||
'Dezernatsleitung',
|
||||
'Abteilungsleitung',
|
||||
'Behördenleitung'
|
||||
]
|
||||
|
||||
export default function EmployeeForm() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuthStore()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
|
||||
@ -20,6 +31,8 @@ export default function EmployeeForm() {
|
||||
position: '',
|
||||
officialTitle: '',
|
||||
department: '',
|
||||
departmentDescription: '',
|
||||
departmentTasks: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
@ -31,20 +44,79 @@ export default function EmployeeForm() {
|
||||
languages: [] as string[],
|
||||
specializations: [] as string[]
|
||||
})
|
||||
const [primaryUnitId, setPrimaryUnitId] = useState<string | null>(null)
|
||||
const [primaryUnitName, setPrimaryUnitName] = useState<string>('')
|
||||
const [primaryUnitId, setPrimaryUnitId] = useState<string | null>(user?.powerUnitId || null)
|
||||
const [primaryUnitName, setPrimaryUnitName] = useState<string>(user?.powerUnitName || '')
|
||||
|
||||
const [employeePhoto, setEmployeePhoto] = useState<string | null>(null)
|
||||
const [photoFile, setPhotoFile] = useState<File | null>(null)
|
||||
|
||||
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
const [expandedSubCategories, setExpandedSubCategories] = useState<Set<string>>(new Set())
|
||||
const [skillSearchTerm, setSkillSearchTerm] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [officialTitleOptions, setOfficialTitleOptions] = useState<string[]>([])
|
||||
const [positionOptions, setPositionOptions] = useState<string[]>(DEFAULT_POSITION_OPTIONS)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== 'superuser' || !user.canManageEmployees) {
|
||||
navigate('/employees')
|
||||
}
|
||||
}, [user, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
setPrimaryUnitId(user?.powerUnitId || null)
|
||||
setPrimaryUnitName(user?.powerUnitName || '')
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!primaryUnitName) return
|
||||
setFormData(prev => {
|
||||
if (prev.department) return prev
|
||||
return { ...prev, department: primaryUnitName }
|
||||
})
|
||||
}, [primaryUnitName])
|
||||
|
||||
useEffect(() => {
|
||||
const loadOfficialTitles = async () => {
|
||||
try {
|
||||
const titles = await officialTitlesApi.getAll()
|
||||
setOfficialTitleOptions(titles)
|
||||
} catch (error) {
|
||||
console.error('Failed to load official titles', error)
|
||||
setOfficialTitleOptions([])
|
||||
}
|
||||
}
|
||||
loadOfficialTitles()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
const loadPositions = async () => {
|
||||
try {
|
||||
const options = await positionCatalogApi.getAll(primaryUnitId)
|
||||
if (!isActive) return
|
||||
setPositionOptions(options.length ? options : DEFAULT_POSITION_OPTIONS)
|
||||
} catch (error) {
|
||||
console.error('Failed to load position options', error)
|
||||
if (!isActive) return
|
||||
setPositionOptions(DEFAULT_POSITION_OPTIONS)
|
||||
}
|
||||
}
|
||||
loadPositions()
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [primaryUnitId])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({ ...prev, [name]: value }))
|
||||
setValidationErrors(prev => {
|
||||
if (!prev[name]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[name]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
@ -175,17 +247,13 @@ export default function EmployeeForm() {
|
||||
|
||||
const validateForm = () => {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
|
||||
if (!formData.firstName.trim()) errors.firstName = 'Vorname ist erforderlich'
|
||||
if (!formData.lastName.trim()) errors.lastName = 'Nachname ist erforderlich'
|
||||
if (!formData.employeeNumber.trim()) errors.employeeNumber = 'Personalnummer ist erforderlich'
|
||||
if (!formData.position.trim()) errors.position = 'Position ist erforderlich'
|
||||
if (!formData.department.trim()) errors.department = 'Abteilung ist erforderlich'
|
||||
if (!formData.email.trim()) errors.email = 'E-Mail ist erforderlich'
|
||||
else if (!/\S+@\S+\.\S+/.test(formData.email)) errors.email = 'Ungültige E-Mail-Adresse'
|
||||
if (!formData.phone.trim()) errors.phone = 'Telefonnummer ist erforderlich'
|
||||
if (!primaryUnitId) errors.primaryUnitId = 'Organisatorische Einheit ist erforderlich'
|
||||
|
||||
|
||||
setValidationErrors(errors)
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
@ -196,15 +264,30 @@ export default function EmployeeForm() {
|
||||
setValidationErrors({})
|
||||
|
||||
if (!validateForm()) {
|
||||
setError('Bitte füllen Sie alle Pflichtfelder aus')
|
||||
setError('Bitte geben Sie Vorname, Nachname, E-Mail und eine Organisationseinheit an.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const trimmedFirstName = formData.firstName.trim()
|
||||
const trimmedLastName = formData.lastName.trim()
|
||||
const trimmedEmail = formData.email.trim()
|
||||
const departmentValue = (formData.department || primaryUnitName || '').trim()
|
||||
const positionValue = formData.position.trim()
|
||||
|
||||
const newEmployee: Partial<Employee> = {
|
||||
...formData,
|
||||
firstName: trimmedFirstName,
|
||||
lastName: trimmedLastName,
|
||||
email: trimmedEmail,
|
||||
position: positionValue || 'Teammitglied',
|
||||
department: departmentValue || 'Noch nicht zugewiesen',
|
||||
employeeNumber: formData.employeeNumber.trim() || undefined,
|
||||
phone: formData.phone.trim() || undefined,
|
||||
mobile: formData.mobile.trim() || undefined,
|
||||
office: formData.office.trim() || undefined,
|
||||
skills: formData.skills.map((skill, index) => ({
|
||||
id: skill.skillId || `skill-${index}`,
|
||||
name: skill.name,
|
||||
@ -225,16 +308,30 @@ export default function EmployeeForm() {
|
||||
officialTitle: formData.officialTitle || undefined,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: 'admin'
|
||||
createdBy: user?.id ?? 'frontend'
|
||||
}
|
||||
|
||||
const result = await employeeApi.create({ ...newEmployee, primaryUnitId })
|
||||
const newEmployeeId = result.data.id
|
||||
|
||||
// Upload photo if we have one
|
||||
const payload = {
|
||||
...newEmployee,
|
||||
department: newEmployee.department,
|
||||
employeeNumber: newEmployee.employeeNumber,
|
||||
phone: newEmployee.phone,
|
||||
mobile: newEmployee.mobile,
|
||||
office: newEmployee.office,
|
||||
createUser: true,
|
||||
userRole: 'user',
|
||||
organizationUnitId: primaryUnitId,
|
||||
organizationRole: 'mitarbeiter',
|
||||
primaryUnitId
|
||||
}
|
||||
|
||||
const result = await employeeApi.create(payload)
|
||||
const newEmployeeId = result.data?.id
|
||||
const temporaryPassword = result.data?.temporaryPassword as string | undefined
|
||||
|
||||
if (photoFile && newEmployeeId) {
|
||||
const formData = new FormData()
|
||||
formData.append('photo', photoFile)
|
||||
const uploadForm = new FormData()
|
||||
uploadForm.append('photo', photoFile)
|
||||
|
||||
try {
|
||||
await fetch(`http://localhost:3001/api/upload/employee-photo/${newEmployeeId}`, {
|
||||
@ -242,13 +339,17 @@ export default function EmployeeForm() {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
body: uploadForm
|
||||
})
|
||||
} catch (uploadError) {
|
||||
console.error('Failed to upload photo:', uploadError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (temporaryPassword) {
|
||||
window.alert(`Benutzerkonto erstellt. Temporäres Passwort: ${temporaryPassword}\nBitte teilen Sie das Passwort sicher oder versenden Sie eine E-Mail über die Admin-Verwaltung.`)
|
||||
}
|
||||
|
||||
navigate('/employees')
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
@ -349,7 +450,7 @@ export default function EmployeeForm() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Personalnummer *
|
||||
Personalnummer (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -357,7 +458,7 @@ export default function EmployeeForm() {
|
||||
value={formData.employeeNumber}
|
||||
onChange={handleChange}
|
||||
className={`input-field ${validationErrors.employeeNumber ? 'border-red-500 ring-red-200' : ''}`}
|
||||
required
|
||||
placeholder="Kann später ergänzt werden"
|
||||
/>
|
||||
{validationErrors.employeeNumber && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationErrors.employeeNumber}</p>
|
||||
@ -383,16 +484,22 @@ export default function EmployeeForm() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Position *
|
||||
Position (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
name="position"
|
||||
value={formData.position}
|
||||
onChange={handleChange}
|
||||
className={`input-field ${validationErrors.position ? 'border-red-500 ring-red-200' : ''}`}
|
||||
required
|
||||
/>
|
||||
>
|
||||
<option value="" disabled>Neutrale Funktionsbezeichnung auswählen…</option>
|
||||
{positionOptions.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
{formData.position && !positionOptions.includes(formData.position) && (
|
||||
<option value={formData.position}>{`${formData.position} (bestehender Wert)`}</option>
|
||||
)}
|
||||
</select>
|
||||
{validationErrors.position && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationErrors.position}</p>
|
||||
)}
|
||||
@ -402,19 +509,25 @@ export default function EmployeeForm() {
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Amtsbezeichnung
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
name="officialTitle"
|
||||
value={formData.officialTitle}
|
||||
onChange={handleChange}
|
||||
className="input-field"
|
||||
placeholder="z. B. KOK, KHK, EKHK"
|
||||
/>
|
||||
>
|
||||
<option value="">Bitte auswählen…</option>
|
||||
{officialTitleOptions.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
{formData.officialTitle && !officialTitleOptions.includes(formData.officialTitle) && (
|
||||
<option value={formData.officialTitle}>{`${formData.officialTitle} (bestehender Wert)`}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Abteilung *
|
||||
Abteilung (optional – wird aus der Organisationseinheit vorgeschlagen)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -422,7 +535,7 @@ export default function EmployeeForm() {
|
||||
value={formData.department}
|
||||
onChange={handleChange}
|
||||
className={`input-field ${validationErrors.department ? 'border-red-500 ring-red-200' : ''}`}
|
||||
required
|
||||
placeholder="Wird automatisch nach Auswahl der Einheit befüllt"
|
||||
/>
|
||||
{validationErrors.department && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationErrors.department}</p>
|
||||
@ -435,10 +548,18 @@ export default function EmployeeForm() {
|
||||
</label>
|
||||
<OrganizationSelector
|
||||
value={primaryUnitId || undefined}
|
||||
onChange={(unitId, unitName) => {
|
||||
onChange={(unitId, formattedValue, details) => {
|
||||
if (user?.canManageEmployees && user?.powerUnitId) return
|
||||
setPrimaryUnitId(unitId)
|
||||
setPrimaryUnitName(unitName)
|
||||
setPrimaryUnitName(details?.displayPath || formattedValue)
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
department: formattedValue || '',
|
||||
departmentDescription: details?.descriptionPath || details?.namesPath || '',
|
||||
departmentTasks: details?.tasks || ''
|
||||
}))
|
||||
}}
|
||||
disabled={Boolean(user?.canManageEmployees && user?.powerUnitId)}
|
||||
/>
|
||||
<p className="text-tertiary text-sm mt-2">{primaryUnitName || 'Bitte auswählen'}</p>
|
||||
{validationErrors.primaryUnitId && (
|
||||
@ -448,7 +569,7 @@ export default function EmployeeForm() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary mb-2">
|
||||
Telefon *
|
||||
Telefon (optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
@ -456,7 +577,7 @@ export default function EmployeeForm() {
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className={`input-field ${validationErrors.phone ? 'border-red-500 ring-red-200' : ''}`}
|
||||
required
|
||||
placeholder="Kann später ergänzt werden"
|
||||
/>
|
||||
{validationErrors.phone && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationErrors.phone}</p>
|
||||
|
||||
@ -3,13 +3,12 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { SearchIcon } from '../components/icons'
|
||||
import EmployeeCard from '../components/EmployeeCard'
|
||||
import { employeeApi } from '../services/api'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import type { Employee } from '@skillmate/shared'
|
||||
import { usePermissions } from '../hooks/usePermissions'
|
||||
import { normalizeDepartment } from '../utils/text'
|
||||
|
||||
export default function EmployeeList() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuthStore()
|
||||
const { canCreateEmployee } = usePermissions()
|
||||
const [employees, setEmployees] = useState<Employee[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@ -59,30 +58,48 @@ export default function EmployeeList() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const skillFilter = filters.skills.trim().toLowerCase()
|
||||
const searchFilter = searchTerm.trim().toLowerCase()
|
||||
|
||||
let filtered = employees.filter(emp => {
|
||||
// Text search
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
`${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
emp.department.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
emp.position.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
emp.specializations.some(spec => spec.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
|
||||
// Department filter
|
||||
const matchesDepartment = filters.department === '' || emp.department === filters.department
|
||||
|
||||
// Availability filter
|
||||
const matchesAvailability = filters.availability === '' || emp.availability === filters.availability
|
||||
|
||||
// Skills filter (basic - später erweitern)
|
||||
const matchesSkills = filters.skills === '' ||
|
||||
emp.specializations.some(spec => spec.toLowerCase().includes(filters.skills.toLowerCase()))
|
||||
|
||||
const departmentLabel = normalizeDepartment(emp.department)
|
||||
const departmentDescription = normalizeDepartment(emp.departmentDescription)
|
||||
const departmentTasks = normalizeDepartment(emp.departmentTasks || emp.departmentDescription)
|
||||
|
||||
const matchesSkillSearch = (emp.skills || []).some(skill =>
|
||||
(skill.name || '').toLowerCase().includes(searchFilter) ||
|
||||
(skill.category || '').toLowerCase().includes(searchFilter)
|
||||
)
|
||||
const matchesSpecializationSearch = emp.specializations.some(spec => spec.toLowerCase().includes(searchFilter))
|
||||
const matchesSearch = searchFilter === '' ||
|
||||
`${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchFilter) ||
|
||||
departmentLabel.toLowerCase().includes(searchFilter) ||
|
||||
departmentDescription.toLowerCase().includes(searchFilter) ||
|
||||
departmentTasks.toLowerCase().includes(searchFilter) ||
|
||||
emp.position.toLowerCase().includes(searchFilter) ||
|
||||
matchesSkillSearch ||
|
||||
matchesSpecializationSearch
|
||||
|
||||
const matchesDepartment = !filters.department || departmentLabel === filters.department
|
||||
const matchesAvailability = !filters.availability || emp.availability === filters.availability
|
||||
|
||||
const matchesSkills = skillFilter === '' ||
|
||||
(emp.skills || []).some(skill =>
|
||||
(skill.name || '').toLowerCase().includes(skillFilter) ||
|
||||
(skill.category || '').toLowerCase().includes(skillFilter)
|
||||
) ||
|
||||
emp.specializations.some(spec => spec.toLowerCase().includes(skillFilter))
|
||||
|
||||
return matchesSearch && matchesDepartment && matchesAvailability && matchesSkills
|
||||
})
|
||||
|
||||
setFilteredEmployees(filtered)
|
||||
}, [searchTerm, employees, filters])
|
||||
|
||||
const departmentOptions = Array.from(new Set(
|
||||
employees.map(emp => normalizeDepartment(emp.department)).filter(Boolean)
|
||||
)).sort()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@ -136,11 +153,9 @@ export default function EmployeeList() {
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Dienststellen</option>
|
||||
<option value="Cybercrime">Cybercrime</option>
|
||||
<option value="Staatsschutz">Staatsschutz</option>
|
||||
<option value="Kriminalpolizei">Kriminalpolizei</option>
|
||||
<option value="IT">IT</option>
|
||||
<option value="Verwaltung">Verwaltung</option>
|
||||
{departmentOptions.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -183,6 +198,7 @@ export default function EmployeeList() {
|
||||
key={employee.id}
|
||||
employee={employee}
|
||||
onClick={() => navigate(`/employees/${employee.id}`)}
|
||||
onDeputyNavigate={(id) => navigate(`/employees/${id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -10,6 +10,10 @@ export default function Login() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [forgotOpen, setForgotOpen] = useState(false)
|
||||
const [forgotEmail, setForgotEmail] = useState('')
|
||||
const [forgotMessage, setForgotMessage] = useState('')
|
||||
const [forgotLoading, setForgotLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@ -28,6 +32,43 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleForgotPassword = () => {
|
||||
setForgotOpen((prev) => {
|
||||
const next = !prev
|
||||
if (!prev) {
|
||||
setForgotEmail((current) => current || email)
|
||||
setForgotMessage('')
|
||||
}
|
||||
if (prev) {
|
||||
setForgotEmail('')
|
||||
setForgotMessage('')
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const fallbackMessage = 'Falls die E-Mail im System hinterlegt ist, senden wir zeitnah ein neues Passwort.'
|
||||
|
||||
if (!forgotEmail.trim()) {
|
||||
setForgotMessage(fallbackMessage)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setForgotLoading(true)
|
||||
setForgotMessage('')
|
||||
const response = await authApi.forgotPassword(forgotEmail.trim())
|
||||
setForgotMessage(response?.message || fallbackMessage)
|
||||
} catch (err) {
|
||||
setForgotMessage(fallbackMessage)
|
||||
} finally {
|
||||
setForgotLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-primary flex items-center justify-center">
|
||||
<div className="card max-w-md w-full">
|
||||
@ -82,10 +123,53 @@ export default function Login() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleForgotPassword}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Passwort vergessen?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{forgotOpen && (
|
||||
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="forgot-email" className="block text-sm font-medium text-secondary mb-2">
|
||||
E-Mail-Adresse für Passwort-Reset
|
||||
</label>
|
||||
<input
|
||||
id="forgot-email"
|
||||
type="email"
|
||||
value={forgotEmail}
|
||||
onChange={(e) => setForgotEmail(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder="E-Mail-Adresse eingeben"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{forgotMessage && (
|
||||
<div className="bg-info-bg text-info p-3 rounded-input text-sm">
|
||||
{forgotMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotLoading}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
{forgotLoading ? 'Wird angefordert...' : 'Neues Passwort anfordern'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm text-tertiary">
|
||||
<p>Für erste Anmeldung wenden Sie sich an Ihren Administrator</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { employeeApi } from '../services/api'
|
||||
import { employeeApi, officialTitlesApi, positionCatalogApi } from '../services/api'
|
||||
import PhotoUpload from '../components/PhotoUpload'
|
||||
import SkillLevelBar from '../components/SkillLevelBar'
|
||||
import DeputyManagement from '../components/DeputyManagement'
|
||||
@ -9,6 +9,26 @@ import { isBooleanSkill } from '../utils/skillRules'
|
||||
|
||||
interface SkillSelection { categoryId: string; subCategoryId: string; skillId: string; name: string; level: string }
|
||||
|
||||
const AVAILABILITY_OPTIONS = [
|
||||
{ value: 'available', label: 'Verfügbar' },
|
||||
{ value: 'busy', label: 'Beschäftigt' },
|
||||
{ value: 'away', label: 'Abwesend' },
|
||||
{ value: 'vacation', label: 'Urlaub' },
|
||||
{ value: 'sick', label: 'Erkrankt' },
|
||||
{ value: 'training', label: 'Fortbildung' },
|
||||
{ value: 'operation', label: 'Im Einsatz' },
|
||||
{ value: 'unavailable', label: 'Nicht verfügbar' }
|
||||
]
|
||||
|
||||
const DEFAULT_POSITION_OPTIONS = [
|
||||
'Sachbearbeitung',
|
||||
'stellvertretende Sachgebietsleitung',
|
||||
'Sachgebietsleitung',
|
||||
'Dezernatsleitung',
|
||||
'Abteilungsleitung',
|
||||
'Behördenleitung'
|
||||
]
|
||||
|
||||
export default function MyProfile() {
|
||||
const { user } = useAuthStore()
|
||||
const employeeId = user?.employeeId
|
||||
@ -17,23 +37,17 @@ export default function MyProfile() {
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const [form, setForm] = useState<any | null>(null)
|
||||
const [catalog, setCatalog] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
|
||||
const [skills, setSkills] = useState<SkillSelection[]>([])
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'deputies'>('profile')
|
||||
const [currentUnitId, setCurrentUnitId] = useState<string | null>(null)
|
||||
const [myUnits, setMyUnits] = useState<any[]>([])
|
||||
const AVAILABILITY_OPTIONS = [
|
||||
{ value: 'available', label: 'Verfügbar' },
|
||||
{ value: 'busy', label: 'Beschäftigt' },
|
||||
{ value: 'away', label: 'Abwesend' },
|
||||
{ value: 'vacation', label: 'Urlaub' },
|
||||
{ value: 'sick', label: 'Erkrankt' },
|
||||
{ value: 'training', label: 'Fortbildung' },
|
||||
{ value: 'operation', label: 'Im Einsatz' },
|
||||
{ value: 'parttime', label: 'Teilzeit' },
|
||||
{ value: 'unavailable', label: 'Nicht verfügbar' }
|
||||
]
|
||||
const [form, setForm] = useState<any | null>(null)
|
||||
const [catalog, setCatalog] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([])
|
||||
const [skills, setSkills] = useState<SkillSelection[]>([])
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'deputies'>('profile')
|
||||
const [currentUnitId, setCurrentUnitId] = useState<string | null>(null)
|
||||
const [myUnits, setMyUnits] = useState<any[]>([])
|
||||
const [officialTitleOptions, setOfficialTitleOptions] = useState<string[]>([])
|
||||
const [positionOptions, setPositionOptions] = useState<string[]>(DEFAULT_POSITION_OPTIONS)
|
||||
const [showAvailabilityHint, setShowAvailabilityHint] = useState(false)
|
||||
const [highlightDelegations, setHighlightDelegations] = useState(false)
|
||||
const highlightTimeoutRef = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!employeeId) {
|
||||
@ -43,6 +57,46 @@ const AVAILABILITY_OPTIONS = [
|
||||
load()
|
||||
}, [employeeId])
|
||||
|
||||
useEffect(() => {
|
||||
const loadOfficialTitles = async () => {
|
||||
try {
|
||||
const titles = await officialTitlesApi.getAll()
|
||||
setOfficialTitleOptions(titles)
|
||||
} catch (error) {
|
||||
console.error('Failed to load official titles', error)
|
||||
setOfficialTitleOptions([])
|
||||
}
|
||||
}
|
||||
loadOfficialTitles()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
const loadPositions = async () => {
|
||||
try {
|
||||
const titles = await positionCatalogApi.getAll(currentUnitId)
|
||||
if (!isActive) return
|
||||
setPositionOptions(titles.length ? titles : DEFAULT_POSITION_OPTIONS)
|
||||
} catch (error) {
|
||||
console.error('Failed to load position options', error)
|
||||
if (!isActive) return
|
||||
setPositionOptions(DEFAULT_POSITION_OPTIONS)
|
||||
}
|
||||
}
|
||||
loadPositions()
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [currentUnitId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimeoutRef.current) {
|
||||
window.clearTimeout(highlightTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = async () => {
|
||||
if (!employeeId) return
|
||||
setLoading(true)
|
||||
@ -50,6 +104,7 @@ const AVAILABILITY_OPTIONS = [
|
||||
try {
|
||||
// Load employee first
|
||||
const data = await employeeApi.getById(employeeId)
|
||||
const availability = data.availability || 'available'
|
||||
setForm({ ...data, email: user?.email || data.email || '' })
|
||||
const mapped: SkillSelection[] = (data.skills || []).map((s: any) => {
|
||||
const catStr = s.category || ''
|
||||
@ -67,6 +122,7 @@ const AVAILABILITY_OPTIONS = [
|
||||
}
|
||||
})
|
||||
setSkills(mapped)
|
||||
setShowAvailabilityHint(availability !== 'available')
|
||||
|
||||
// Load my organizational units
|
||||
try {
|
||||
@ -118,17 +174,49 @@ const AVAILABILITY_OPTIONS = [
|
||||
setSkills(prev => prev.map(s => (s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId) ? { ...s, level } : s))
|
||||
}
|
||||
|
||||
const triggerDelegationHighlight = () => {
|
||||
if (highlightTimeoutRef.current) {
|
||||
window.clearTimeout(highlightTimeoutRef.current)
|
||||
}
|
||||
setHighlightDelegations(true)
|
||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||
setHighlightDelegations(false)
|
||||
highlightTimeoutRef.current = undefined
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const handleAvailabilityChange = (value: string) => {
|
||||
setForm((prev: any) => ({ ...prev, availability: value }))
|
||||
const shouldHighlight = value !== 'available'
|
||||
setShowAvailabilityHint(shouldHighlight)
|
||||
if (shouldHighlight) {
|
||||
triggerDelegationHighlight()
|
||||
} else {
|
||||
if (highlightTimeoutRef.current) {
|
||||
window.clearTimeout(highlightTimeoutRef.current)
|
||||
highlightTimeoutRef.current = undefined
|
||||
}
|
||||
setHighlightDelegations(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isSkillSelected = (categoryId: string, subCategoryId: string, skillId: string) =>
|
||||
skills.some(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)
|
||||
|
||||
const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) =>
|
||||
(skills.find(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)?.level) || ''
|
||||
|
||||
const handleOrganizationChange = async (unitId: string | null, unitName: string) => {
|
||||
const handleOrganizationChange = async (unitId: string | null, formattedValue: string, details?: { descriptionPath?: string; namesPath: string; tasks?: string } | null) => {
|
||||
setCurrentUnitId(unitId)
|
||||
setForm((prev: any) => ({
|
||||
...prev,
|
||||
department: formattedValue || '',
|
||||
departmentDescription: details?.descriptionPath || details?.namesPath || '',
|
||||
departmentTasks: details?.tasks || ''
|
||||
}))
|
||||
|
||||
if (unitId && employeeId) {
|
||||
try {
|
||||
// Save organization assignment
|
||||
await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/organization/assignments', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -136,14 +224,12 @@ const AVAILABILITY_OPTIONS = [
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
employeeId: employeeId,
|
||||
unitId: unitId,
|
||||
employeeId,
|
||||
unitId,
|
||||
role: 'mitarbeiter',
|
||||
isPrimary: true
|
||||
})
|
||||
})
|
||||
// Update department field with unit name for backward compatibility
|
||||
setForm((prev: any) => ({ ...prev, department: unitName }))
|
||||
} catch (error) {
|
||||
console.error('Failed to assign unit:', error)
|
||||
}
|
||||
@ -228,13 +314,17 @@ const AVAILABILITY_OPTIONS = [
|
||||
<div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`pb-2 px-1 ${activeTab === 'profile' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-600 dark:text-gray-400'}`}
|
||||
className={`pb-2 px-1 transition-colors ${activeTab === 'profile' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-600 dark:text-gray-400'}`}
|
||||
>
|
||||
Profildaten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('deputies')}
|
||||
className={`pb-2 px-1 ${activeTab === 'deputies' ? 'border-b-2 border-blue-600 text-blue-600' : 'text-gray-600 dark:text-gray-400'}`}
|
||||
className={`pb-2 px-1 transition-colors rounded-sm ${
|
||||
activeTab === 'deputies'
|
||||
? 'border-b-2 border-blue-600 text-blue-600'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
} ${highlightDelegations ? 'ring-2 ring-amber-400 ring-offset-1 ring-offset-white dark:ring-offset-gray-900 bg-amber-50 dark:bg-amber-900/30 animate-pulse' : ''}`}
|
||||
>
|
||||
Vertretungen
|
||||
</button>
|
||||
@ -259,33 +349,45 @@ const AVAILABILITY_OPTIONS = [
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">E-Mail</label>
|
||||
<input className="input-field w-full disabled:opacity-70" value={user?.email || form.email || ''} disabled readOnly placeholder="wird aus Login übernommen" title="Wird automatisch aus dem Login gesetzt" />
|
||||
<p className="text-small text-tertiary mt-1">Wird automatisch aus dem Login übernommen. Änderung ggf. im Admin Panel.</p>
|
||||
<input className="input-field w-full disabled:opacity-70" value={user?.email || form.email || ''} disabled readOnly placeholder="Wird automatisch aus dem Login übernommen" title="Wird automatisch aus dem Login übernommen" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Position</label>
|
||||
<input
|
||||
<select
|
||||
className="input-field w-full"
|
||||
value={form.position || ''}
|
||||
onChange={(e) => setForm((p: any) => ({ ...p, position: e.target.value }))}
|
||||
placeholder="z. B. Sachbearbeitung, Teamleitung"
|
||||
/>
|
||||
<p className="text-small text-tertiary mt-1">Beispiele: Sachbearbeitung, Teamleitung, Stabsstelle.</p>
|
||||
title="Neutrale Funktionsbezeichnung auswählen"
|
||||
>
|
||||
<option value="" disabled>Neutrale Funktionsbezeichnung auswählen…</option>
|
||||
{positionOptions.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
{form.position && !positionOptions.includes(form.position) && (
|
||||
<option value={form.position}>{`${form.position} (bestehender Wert)`}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Amtsbezeichnung</label>
|
||||
<input
|
||||
<select
|
||||
className="input-field w-full"
|
||||
value={form.officialTitle || ''}
|
||||
onChange={(e) => setForm((p: any) => ({ ...p, officialTitle: e.target.value }))}
|
||||
placeholder="z. B. KOK, KHK, EKHK"
|
||||
/>
|
||||
<p className="text-small text-tertiary mt-1">Freifeld für Amts- bzw. Dienstbezeichnungen (z. B. KOK, RBe, EKHK).</p>
|
||||
title="Dienstliche Amtsbezeichnung auswählen"
|
||||
>
|
||||
<option value="" disabled>Amtsbezeichnung auswählen…</option>
|
||||
{officialTitleOptions.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
{form.officialTitle && !officialTitleOptions.includes(form.officialTitle) && (
|
||||
<option value={form.officialTitle}>{`${form.officialTitle} (bestehender Wert)`}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">NW-Kennung</label>
|
||||
<input className="input-field w-full" value={form.employeeNumber || ''} onChange={(e) => setForm((p: any) => ({ ...p, employeeNumber: e.target.value }))} placeholder="z. B. NW068111" />
|
||||
<p className="text-small text-tertiary mt-1">Ihre behördliche Kennung, z. B. NW068111.</p>
|
||||
<input className="input-field w-full" value={form.employeeNumber || ''} onChange={(e) => setForm((p: any) => ({ ...p, employeeNumber: e.target.value }))} placeholder="z. B. NW068111" title="Behördliche Kennung, z. B. NW068111" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Dienststelle</label>
|
||||
@ -293,41 +395,50 @@ const AVAILABILITY_OPTIONS = [
|
||||
value={currentUnitId || undefined}
|
||||
onChange={handleOrganizationChange}
|
||||
disabled={false}
|
||||
title="Organisationseinheit aus dem Organigramm auswählen"
|
||||
/>
|
||||
<p className="text-small text-tertiary mt-1">Wählen Sie Ihre Organisationseinheit aus dem Organigramm.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Telefon</label>
|
||||
<input className="input-field w-full" value={form.phone || ''} onChange={(e) => setForm((p: any) => ({ ...p, phone: e.target.value }))} placeholder="z. B. +49 30 12345-100" />
|
||||
<p className="text-small text-tertiary mt-1">Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).</p>
|
||||
<input className="input-field w-full" value={form.phone || ''} onChange={(e) => setForm((p: any) => ({ ...p, phone: e.target.value }))} placeholder="z. B. +49 30 12345-100" title="Bitte Rufnummer im internationalen Format angeben" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Mobil</label>
|
||||
<input className="input-field w-full" value={form.mobile || ''} onChange={(e) => setForm((p: any) => ({ ...p, mobile: e.target.value }))} placeholder="z. B. +49 171 1234567" />
|
||||
<p className="text-small text-tertiary mt-1">Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).</p>
|
||||
<input className="input-field w-full" value={form.mobile || ''} onChange={(e) => setForm((p: any) => ({ ...p, mobile: e.target.value }))} placeholder="z. B. +49 171 1234567" title="Bitte Rufnummer im internationalen Format angeben" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Büro</label>
|
||||
<input className="input-field w-full" value={form.office || ''} onChange={(e) => setForm((p: any) => ({ ...p, office: e.target.value }))} placeholder="z. B. Gebäude A, 3.OG, Raum 3.12" />
|
||||
<p className="text-small text-tertiary mt-1">Angabe zum Standort, z. B. Gebäude, Etage und Raum.</p>
|
||||
<input className="input-field w-full" value={form.office || ''} onChange={(e) => setForm((p: any) => ({ ...p, office: e.target.value }))} placeholder="z. B. Gebäude A, 3.OG, Raum 3.12" title="Standort mit Gebäude, Etage und Raum angeben" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">Verfügbarkeit</label>
|
||||
<select
|
||||
className="input-field w-full"
|
||||
value={form.availability || 'available'}
|
||||
onChange={(e) => setForm((p: any) => ({ ...p, availability: e.target.value }))}
|
||||
onChange={(e) => handleAvailabilityChange(e.target.value)}
|
||||
title="Status wird in Mitarbeitendenübersicht und Teamplanung angezeigt"
|
||||
>
|
||||
{AVAILABILITY_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-small text-tertiary mt-1">Dieser Status wird in der Mitarbeitendenübersicht und Teamplanung angezeigt.</p>
|
||||
|
||||
{showAvailabilityHint && (
|
||||
<div className="mt-2 rounded-input border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:border-amber-500 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
Hinweis: Bitte stimmen Sie eine Vertretung im Reiter „Vertretungen“ ab, solange Sie nicht verfügbar sind.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button onClick={onSave} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Speichere...' : 'Änderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">Kompetenzen</h2>
|
||||
<div className="space-y-4">
|
||||
@ -341,26 +452,33 @@ const AVAILABILITY_OPTIONS = [
|
||||
{sub.skills.map(skill => {
|
||||
const booleanSkill = isBooleanSkill(category.id, sub.id)
|
||||
const selected = isSkillSelected(category.id, sub.id, skill.id)
|
||||
const skillInputId = `skill-${category.id}-${sub.id}-${skill.id}`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${category.id}-${sub.id}-${skill.id}`}
|
||||
className={`p-2 border rounded-input ${selected ? 'border-primary-blue bg-bg-accent' : 'border-border-default'}`}
|
||||
>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-body text-secondary">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label
|
||||
htmlFor={skillInputId}
|
||||
className="flex items-start gap-2 flex-1 min-w-0 text-body text-secondary cursor-pointer"
|
||||
>
|
||||
<input
|
||||
id={skillInputId}
|
||||
type="checkbox"
|
||||
className="mr-2"
|
||||
className="mt-0.5 shrink-0"
|
||||
checked={selected}
|
||||
onChange={() => handleSkillToggle(category.id, sub.id, skill.id, skill.name)}
|
||||
/>
|
||||
{skill.name}
|
||||
</span>
|
||||
<span className="truncate" title={skill.name}>{skill.name}</span>
|
||||
</label>
|
||||
|
||||
{selected && (
|
||||
booleanSkill ? (
|
||||
<span className="ml-3 text-small font-medium text-green-700 dark:text-green-400">Ja</span>
|
||||
<span className="sm:ml-3 text-small font-medium text-green-700 dark:text-green-400">Ja</span>
|
||||
) : (
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="sm:ml-3 w-full sm:w-56 md:w-64">
|
||||
<SkillLevelBar
|
||||
value={Number(getSkillLevel(category.id, sub.id, skill.id)) || ''}
|
||||
onChange={(val) => handleSkillLevelChange(category.id, sub.id, skill.id, String(val))}
|
||||
@ -368,7 +486,7 @@ const AVAILABILITY_OPTIONS = [
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -4,6 +4,7 @@ import { SearchIcon } from '../components/icons'
|
||||
import EmployeeCard from '../components/EmployeeCard'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { employeeApi } from '../services/api'
|
||||
import { normalizeDepartment } from '../utils/text'
|
||||
// Import the skill hierarchy - we'll load it dynamically in useEffect
|
||||
|
||||
type SkillWithStats = {
|
||||
@ -194,8 +195,11 @@ export default function SkillSearch() {
|
||||
const searchLower = freeSearchTerm.toLowerCase()
|
||||
const results = allEmployees.filter(employee => {
|
||||
// Search in name, department, position
|
||||
const departmentLabel = normalizeDepartment(employee.department)
|
||||
const departmentDescription = normalizeDepartment(employee.departmentDescription)
|
||||
if (`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchLower) ||
|
||||
employee.department?.toLowerCase().includes(searchLower) ||
|
||||
departmentLabel.toLowerCase().includes(searchLower) ||
|
||||
departmentDescription.toLowerCase().includes(searchLower) ||
|
||||
employee.position?.toLowerCase().includes(searchLower)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import type { Employee } from '@skillmate/shared'
|
||||
import { employeeApi } from '../services/api'
|
||||
import { SearchIcon } from '../components/icons'
|
||||
import { normalizeDepartment, formatDepartmentWithDescription } from '../utils/text'
|
||||
|
||||
type TeamPosition = {
|
||||
id: string
|
||||
@ -146,7 +147,9 @@ export default function TeamZusammenstellung() {
|
||||
}
|
||||
|
||||
// Get unique departments and positions
|
||||
const departments = Array.from(new Set(employees.map(e => e.department).filter(Boolean)))
|
||||
const departments = Array.from(new Set(
|
||||
employees.map(e => normalizeDepartment(e.department)).filter(Boolean)
|
||||
))
|
||||
const positions = Array.from(new Set(employees.map(e => e.position).filter(Boolean)))
|
||||
|
||||
// Toggle category selection
|
||||
@ -241,7 +244,7 @@ export default function TeamZusammenstellung() {
|
||||
|
||||
// Department filter
|
||||
if (selectedDepartment) {
|
||||
filtered = filtered.filter(emp => emp.department === selectedDepartment)
|
||||
filtered = filtered.filter(emp => normalizeDepartment(emp.department) === selectedDepartment)
|
||||
}
|
||||
|
||||
// Position filter
|
||||
@ -652,7 +655,9 @@ export default function TeamZusammenstellung() {
|
||||
filteredEmployees.map((employee: any) => {
|
||||
const isAssigned = teamPositions.some(p => p.assignedEmployeeId === employee.id)
|
||||
const matchScore = employee.matchScore || 0
|
||||
|
||||
const departmentInfo = formatDepartmentWithDescription(employee.department, employee.departmentDescription)
|
||||
const departmentText = departmentInfo.description ? `${departmentInfo.label} – ${departmentInfo.description}` : departmentInfo.label
|
||||
|
||||
return (
|
||||
<div
|
||||
key={employee.id}
|
||||
@ -690,7 +695,7 @@ export default function TeamZusammenstellung() {
|
||||
<div className="text-sm text-tertiary">
|
||||
{employee.position}
|
||||
{employee.officialTitle ? ` • ${employee.officialTitle}` : ''}
|
||||
{` • ${employee.department}`}
|
||||
{departmentText ? ` • ${departmentText}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
86
install.ps1
Normale Datei
86
install.ps1
Normale Datei
@ -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
|
||||
5
main.py
5
main.py
@ -216,9 +216,12 @@ class SkillMateStarter:
|
||||
if admin_dir.exists():
|
||||
print(f" - Admin: http://localhost:{self.admin_port}")
|
||||
|
||||
# Öffne Frontend im Browser
|
||||
# Öffne Frontend und Admin Panel im Browser
|
||||
time.sleep(3)
|
||||
webbrowser.open(f"http://localhost:{self.frontend_port}")
|
||||
if (self.base_dir / "admin-panel").exists():
|
||||
time.sleep(1)
|
||||
webbrowser.open(f"http://localhost:{self.admin_port}")
|
||||
|
||||
print("\n⚡ Schließen Sie dieses Fenster, um SkillMate zu beenden")
|
||||
|
||||
|
||||
44
shared/index.d.ts
vendored
44
shared/index.d.ts
vendored
@ -12,6 +12,11 @@ export interface User {
|
||||
isActive: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
powerUnitId?: string | null
|
||||
powerUnitName?: string | null
|
||||
powerUnitType?: OrganizationalUnitType | null
|
||||
powerFunction?: PowerFunction | null
|
||||
canManageEmployees?: boolean
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
@ -39,6 +44,15 @@ export interface Clearance {
|
||||
issuedDate: Date
|
||||
}
|
||||
|
||||
export type PowerFunction = 'sachgebietsleitung' | 'stellvertretende_sachgebietsleitung' | 'ermittlungskommissionsleitung' | 'dezernatsleitung' | 'abteilungsleitung'
|
||||
|
||||
export interface PowerFunctionDefinition {
|
||||
id: PowerFunction
|
||||
label: string
|
||||
unitTypes: OrganizationalUnitType[]
|
||||
canManageEmployees: boolean
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string
|
||||
firstName: string
|
||||
@ -48,6 +62,11 @@ export interface Employee {
|
||||
position: string
|
||||
officialTitle?: string | null
|
||||
department: string
|
||||
departmentDescription?: string | null
|
||||
departmentTasks?: string | null
|
||||
primaryUnitId?: string | null
|
||||
primaryUnitCode?: string | null
|
||||
primaryUnitName?: string | null
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
mobile?: string | null
|
||||
@ -57,12 +76,23 @@ export interface Employee {
|
||||
languages?: LanguageSkill[]
|
||||
clearance?: Clearance
|
||||
specializations?: string[]
|
||||
currentDeputies?: EmployeeDeputySummary[]
|
||||
represents?: EmployeeDeputySummary[]
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
createdBy?: string
|
||||
updatedBy?: string | null
|
||||
}
|
||||
|
||||
export interface EmployeeDeputySummary {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
availability?: string | null
|
||||
position?: string | null
|
||||
assignmentId?: string
|
||||
}
|
||||
|
||||
export interface SkillDefinition {
|
||||
id: string
|
||||
name: string
|
||||
@ -89,6 +119,8 @@ export interface LoginResponse {
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<UserRole, string[]>
|
||||
|
||||
export const POWER_FUNCTIONS: PowerFunctionDefinition[]
|
||||
|
||||
export const DEFAULT_SKILLS: Record<string, string[]>
|
||||
export const LANGUAGE_LEVELS: string[]
|
||||
export interface SkillLevel { id: string; name: string; level?: string }
|
||||
@ -122,7 +154,7 @@ export interface WorkspaceFilter {
|
||||
}
|
||||
|
||||
// Organization
|
||||
export type OrganizationalUnitType = 'direktion' | 'abteilung' | 'dezernat' | 'sachgebiet' | 'teildezernat' | 'fuehrungsstelle' | 'stabsstelle' | 'sondereinheit'
|
||||
export type OrganizationalUnitType = 'direktion' | 'abteilung' | 'dezernat' | 'sachgebiet' | 'teildezernat' | 'fuehrungsstelle' | 'stabsstelle' | 'sondereinheit' | 'ermittlungskommission'
|
||||
export type EmployeeUnitRole = 'leiter' | 'stellvertreter' | 'mitarbeiter' | 'beauftragter'
|
||||
|
||||
export interface OrganizationalUnit {
|
||||
@ -216,3 +248,13 @@ export interface BookingRequest {
|
||||
endTime: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
declare const shared: {
|
||||
ROLE_PERMISSIONS: typeof ROLE_PERMISSIONS
|
||||
POWER_FUNCTIONS: typeof POWER_FUNCTIONS
|
||||
DEFAULT_SKILLS: typeof DEFAULT_SKILLS
|
||||
LANGUAGE_LEVELS: typeof LANGUAGE_LEVELS
|
||||
SKILL_HIERARCHY: typeof SKILL_HIERARCHY
|
||||
}
|
||||
|
||||
export default shared
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
// Runtime constants and helpers shared across projects
|
||||
|
||||
const POWER_FUNCTIONS = [
|
||||
{ id: 'sachgebietsleitung', label: 'Sachgebietsleitung', unitTypes: ['sachgebiet'], canManageEmployees: true },
|
||||
{ id: 'stellvertretende_sachgebietsleitung', label: 'Stellvertretende Sachgebietsleitung', unitTypes: ['sachgebiet'], canManageEmployees: true },
|
||||
{ id: 'ermittlungskommissionsleitung', label: 'Ermittlungskommissionsleitung', unitTypes: ['ermittlungskommission'], canManageEmployees: true },
|
||||
{ id: 'dezernatsleitung', label: 'Dezernatsleitung', unitTypes: ['dezernat'], canManageEmployees: false },
|
||||
{ id: 'abteilungsleitung', label: 'Abteilungsleitung', unitTypes: ['abteilung'], canManageEmployees: false }
|
||||
]
|
||||
|
||||
const ROLE_PERMISSIONS = {
|
||||
admin: [
|
||||
'admin:panel:access',
|
||||
@ -30,36 +38,41 @@ const ROLE_PERMISSIONS = {
|
||||
]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ROLE_PERMISSIONS,
|
||||
DEFAULT_SKILLS: {
|
||||
general: [
|
||||
'Teamarbeit',
|
||||
'Kommunikation',
|
||||
'Projektmanagement'
|
||||
],
|
||||
it: [
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Node.js',
|
||||
'SQL'
|
||||
],
|
||||
certificates: [
|
||||
'Erste Hilfe',
|
||||
'Brandschutzhelfer'
|
||||
],
|
||||
weapons: [
|
||||
'WBK A',
|
||||
'WBK B'
|
||||
]
|
||||
}
|
||||
const DEFAULT_SKILLS = {
|
||||
general: [
|
||||
'Teamarbeit',
|
||||
'Kommunikation',
|
||||
'Projektmanagement'
|
||||
],
|
||||
it: [
|
||||
'JavaScript',
|
||||
'TypeScript',
|
||||
'Node.js',
|
||||
'SQL'
|
||||
],
|
||||
certificates: [
|
||||
'Erste Hilfe',
|
||||
'Brandschutzhelfer'
|
||||
],
|
||||
weapons: [
|
||||
'WBK A',
|
||||
'WBK B'
|
||||
]
|
||||
}
|
||||
|
||||
exports.ROLE_PERMISSIONS = ROLE_PERMISSIONS
|
||||
exports.POWER_FUNCTIONS = POWER_FUNCTIONS
|
||||
exports.DEFAULT_SKILLS = DEFAULT_SKILLS
|
||||
|
||||
// Re-export skill constants
|
||||
try {
|
||||
const { LANGUAGE_LEVELS, SKILL_HIERARCHY } = require('./skills')
|
||||
module.exports.LANGUAGE_LEVELS = LANGUAGE_LEVELS
|
||||
module.exports.SKILL_HIERARCHY = SKILL_HIERARCHY
|
||||
exports.LANGUAGE_LEVELS = LANGUAGE_LEVELS
|
||||
exports.SKILL_HIERARCHY = SKILL_HIERARCHY
|
||||
} catch (e) {
|
||||
// no-op if skills.js not present
|
||||
}
|
||||
|
||||
module.exports = exports
|
||||
exports.__esModule = true
|
||||
exports.default = exports
|
||||
|
||||
240
shared/index.mjs
Normale Datei
240
shared/index.mjs
Normale Datei
@ -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
|
||||
@ -4,6 +4,12 @@
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.mjs",
|
||||
"require": "./index.js"
|
||||
}
|
||||
},
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren