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

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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