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),
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren