Test
Dieser Commit ist enthalten in:
279
admin-panel/src/components/OrganizationSelector.tsx
Normale Datei
279
admin-panel/src/components/OrganizationSelector.tsx
Normale Datei
@ -0,0 +1,279 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { OrganizationalUnit } from '@skillmate/shared'
|
||||
import { ChevronRight, Building2, Search, X } from 'lucide-react'
|
||||
import { api } from '../services/api'
|
||||
|
||||
interface OrganizationSelectorProps {
|
||||
value?: string | null
|
||||
onChange: (unitId: string | null, unitName: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function OrganizationSelector({ value, onChange, disabled }: OrganizationSelectorProps) {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [hierarchy, setHierarchy] = useState<OrganizationalUnit[]>([])
|
||||
const [selectedUnit, setSelectedUnit] = useState<OrganizationalUnit | null>(null)
|
||||
const [currentUnitName, setCurrentUnitName] = useState('')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (showModal) {
|
||||
loadOrganization()
|
||||
}
|
||||
}, [showModal])
|
||||
|
||||
useEffect(() => {
|
||||
if (value && hierarchy.length > 0) {
|
||||
const unit = findUnitById(hierarchy, value)
|
||||
if (unit) {
|
||||
const path = getUnitPath(hierarchy, unit)
|
||||
setSelectedUnit(unit)
|
||||
setCurrentUnitName(path)
|
||||
}
|
||||
}
|
||||
if (!value) {
|
||||
setSelectedUnit(null)
|
||||
setCurrentUnitName('')
|
||||
}
|
||||
}, [value, hierarchy])
|
||||
|
||||
const loadOrganization = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await api.get('/organization/hierarchy')
|
||||
if (response.data.success) {
|
||||
const units = response.data.data as OrganizationalUnit[]
|
||||
setHierarchy(units)
|
||||
const defaults = new Set<string>()
|
||||
const expandToLevel = (nodes: OrganizationalUnit[], level = 0) => {
|
||||
if (level > 1) return
|
||||
nodes.forEach(node => {
|
||||
defaults.add(node.id)
|
||||
if (node.children) expandToLevel(node.children, level + 1)
|
||||
})
|
||||
}
|
||||
expandToLevel(units)
|
||||
setExpandedNodes(defaults)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load organization:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const findUnitById = (units: OrganizationalUnit[], id: string): OrganizationalUnit | null => {
|
||||
for (const unit of units) {
|
||||
if (unit.id === id) return unit
|
||||
if (unit.children) {
|
||||
const result = findUnitById(unit.children, id)
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getUnitPath = (units: OrganizationalUnit[], target: OrganizationalUnit): string => {
|
||||
const path: string[] = []
|
||||
const traverse = (nodes: OrganizationalUnit[], trail: string[] = []): boolean => {
|
||||
for (const node of nodes) {
|
||||
const currentTrail = [...trail, node.name]
|
||||
if (node.id === target.id) {
|
||||
path.push(...currentTrail)
|
||||
return true
|
||||
}
|
||||
if (node.children && traverse(node.children, currentTrail)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
traverse(units)
|
||||
return path.join(' → ')
|
||||
}
|
||||
|
||||
const toggleExpand = (unitId: string) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(unitId)) {
|
||||
next.delete(unitId)
|
||||
} else {
|
||||
next.add(unitId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelect = (unit: OrganizationalUnit | null) => {
|
||||
if (!unit) {
|
||||
setSelectedUnit(null)
|
||||
setCurrentUnitName('')
|
||||
onChange(null, '')
|
||||
return
|
||||
}
|
||||
setSelectedUnit(unit)
|
||||
const path = getUnitPath(hierarchy, unit)
|
||||
setCurrentUnitName(path)
|
||||
onChange(unit.id, path)
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
const typeBadgeStyle = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
direktion: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
|
||||
abteilung: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200',
|
||||
dezernat: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
|
||||
sachgebiet: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200',
|
||||
teildezernat: 'bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-200',
|
||||
stabsstelle: 'bg-gray-100 text-gray-800 dark:bg-gray-900/40 dark:text-gray-200',
|
||||
sonderenheit: 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-200'
|
||||
}
|
||||
return colors[type] || 'bg-gray-100 text-gray-800 dark:bg-gray-800/40 dark:text-gray-200'
|
||||
}
|
||||
|
||||
const matchesSearch = (unit: OrganizationalUnit): boolean => {
|
||||
if (!searchTerm) return true
|
||||
const term = searchTerm.toLowerCase()
|
||||
return unit.name.toLowerCase().includes(term) || unit.code?.toLowerCase().includes(term) || false
|
||||
}
|
||||
|
||||
const hasMatchingDescendant = (unit: OrganizationalUnit): boolean => {
|
||||
if (!unit.children) return false
|
||||
for (const child of unit.children) {
|
||||
if (matchesSearch(child) || hasMatchingDescendant(child)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const renderUnit = (unit: OrganizationalUnit, level = 0) => {
|
||||
const isVisible = matchesSearch(unit) || hasMatchingDescendant(unit)
|
||||
if (!isVisible) return null
|
||||
const hasChildren = unit.children && unit.children.length > 0
|
||||
const expanded = expandedNodes.has(unit.id)
|
||||
|
||||
return (
|
||||
<div key={unit.id} className={level ? 'ml-4' : ''}>
|
||||
<div
|
||||
className={`flex items-center gap-2 p-2 rounded transition-colors ${
|
||||
selectedUnit?.id === unit.id
|
||||
? 'bg-blue-100 dark:bg-blue-800/40'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
toggleExpand(unit.id)
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left"
|
||||
onClick={() => handleSelect(unit)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="w-5 h-5 text-tertiary" />
|
||||
<div>
|
||||
<div className="font-medium text-secondary">{unit.name}</div>
|
||||
<div className="text-xs text-tertiary flex items-center gap-2 mt-0.5">
|
||||
{unit.code && <span>{unit.code}</span>}
|
||||
<span className={`px-2 py-0.5 rounded-full ${typeBadgeStyle(unit.type)}`}>
|
||||
{unit.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div className="mt-1">
|
||||
{unit.children!.map(child => renderUnit(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className={`input-field flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`} onClick={() => !disabled && setShowModal(true)}>
|
||||
<span className={currentUnitName ? 'text-secondary' : 'text-tertiary'}>
|
||||
{currentUnitName || 'Organisationseinheit auswählen'}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
{currentUnitName && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-tertiary hover:text-error flex items-center gap-1"
|
||||
onClick={() => handleSelect(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border-default px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-primary">Organisationseinheit auswählen</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="text-tertiary hover:text-secondary"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b border-border-default">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-tertiary absolute left-3 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={event => setSearchTerm(event.target.value)}
|
||||
className="input-field pl-9"
|
||||
placeholder="Suche nach Name oder Code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-tertiary">Lade Organisation...</div>
|
||||
) : (
|
||||
hierarchy.map(unit => renderUnit(unit))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border-default flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -3,4 +3,5 @@ export { default as UsersIcon } from './UsersIcon'
|
||||
export { default as SearchIcon } from './SearchIcon'
|
||||
export { default as SettingsIcon } from './SettingsIcon'
|
||||
export { default as SunIcon } from './SunIcon'
|
||||
export { default as MoonIcon } from './MoonIcon'
|
||||
export { default as MoonIcon } from './MoonIcon'
|
||||
export { default as OrganizationSelector } from './OrganizationSelector'
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../services/api'
|
||||
import type { UserRole } from '@skillmate/shared'
|
||||
import type { EmployeeUnitRole, UserRole } from '@skillmate/shared'
|
||||
import { OrganizationSelector } from '../components'
|
||||
|
||||
interface CreateEmployeeData {
|
||||
firstName: string
|
||||
@ -18,10 +19,12 @@ export default function CreateEmployee() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [createdUser, setCreatedUser] = useState<{password?: string}>({})
|
||||
const [creationDone, setCreationDone] = useState(false)
|
||||
const [createdUser, setCreatedUser] = useState<{ password?: string }>({})
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null)
|
||||
const [selectedUnitName, setSelectedUnitName] = useState('')
|
||||
const [selectedUnitRole, setSelectedUnitRole] = useState<EmployeeUnitRole>('mitarbeiter')
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, watch } = useForm<CreateEmployeeData>({
|
||||
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm<CreateEmployeeData>({
|
||||
defaultValues: {
|
||||
userRole: 'user',
|
||||
createUser: true
|
||||
@ -30,6 +33,25 @@ export default function CreateEmployee() {
|
||||
|
||||
const watchCreateUser = watch('createUser')
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUnitName) {
|
||||
setValue('department', selectedUnitName)
|
||||
}
|
||||
}, [selectedUnitName, setValue])
|
||||
|
||||
const getUnitRoleLabel = (role: EmployeeUnitRole): string => {
|
||||
switch (role) {
|
||||
case 'leiter':
|
||||
return 'Leitung'
|
||||
case 'stellvertreter':
|
||||
return 'Stellvertretung'
|
||||
case 'beauftragter':
|
||||
return 'Beauftragte:r'
|
||||
default:
|
||||
return 'Mitarbeiter:in'
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: CreateEmployeeData) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@ -42,7 +64,9 @@ export default function CreateEmployee() {
|
||||
email: data.email,
|
||||
department: data.department,
|
||||
userRole: data.createUser ? data.userRole : undefined,
|
||||
createUser: data.createUser
|
||||
createUser: data.createUser,
|
||||
organizationUnitId: selectedUnitId || undefined,
|
||||
organizationRole: selectedUnitId ? selectedUnitRole : undefined
|
||||
}
|
||||
|
||||
const response = await api.post('/employees', payload)
|
||||
@ -53,8 +77,6 @@ export default function CreateEmployee() {
|
||||
} else {
|
||||
setSuccess('Mitarbeiter erfolgreich erstellt!')
|
||||
}
|
||||
// Manuelles Weiterklicken statt Auto-Navigation, damit Passwort kopiert werden kann
|
||||
setCreationDone(true)
|
||||
} catch (err: any) {
|
||||
console.error('Error:', err.response?.data)
|
||||
const errorMessage = err.response?.data?.error?.message || 'Speichern fehlgeschlagen'
|
||||
@ -107,6 +129,11 @@ export default function CreateEmployee() {
|
||||
{success && (
|
||||
<div className="bg-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
|
||||
{success}
|
||||
{selectedUnitName && (
|
||||
<div className="mt-2 text-green-700">
|
||||
Zugeordnete Einheit: <strong>{selectedUnitName}</strong> · {getUnitRoleLabel(selectedUnitRole)}
|
||||
</div>
|
||||
)}
|
||||
{createdUser.password && (
|
||||
<div className="mt-4 p-4 bg-white rounded border border-green-300">
|
||||
<h4 className="font-semibold mb-2">🔑 Temporäres Passwort:</h4>
|
||||
@ -192,8 +219,9 @@ export default function CreateEmployee() {
|
||||
</label>
|
||||
<input
|
||||
{...register('department', { required: 'Abteilung ist erforderlich' })}
|
||||
className="input-field w-full"
|
||||
className={`input-field w-full ${selectedUnitId ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||||
placeholder="IT, Personal, Marketing, etc."
|
||||
readOnly={Boolean(selectedUnitId)}
|
||||
/>
|
||||
{errors.department && (
|
||||
<p className="text-error text-sm mt-1">{errors.department.message}</p>
|
||||
@ -202,6 +230,54 @@ export default function CreateEmployee() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
|
||||
Organisation & Rolle
|
||||
</h2>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Organisationseinheit
|
||||
</label>
|
||||
<OrganizationSelector
|
||||
value={selectedUnitId}
|
||||
onChange={(unitId, unitPath) => {
|
||||
setSelectedUnitId(unitId)
|
||||
setSelectedUnitName(unitPath)
|
||||
if (!unitId) {
|
||||
setSelectedUnitRole('mitarbeiter')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-secondary-light mt-2">
|
||||
Wählen Sie die primäre Organisationseinheit für den neuen Mitarbeiter. Die Abteilung wird automatisch anhand der Auswahl gesetzt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedUnitId && (
|
||||
<div>
|
||||
<label className="block text-body font-medium text-secondary mb-2">
|
||||
Rolle innerhalb der Einheit
|
||||
</label>
|
||||
<select
|
||||
value={selectedUnitRole}
|
||||
onChange={event => setSelectedUnitRole(event.target.value as EmployeeUnitRole)}
|
||||
className="input-field w-full"
|
||||
>
|
||||
<option value="leiter">Leiter:in</option>
|
||||
<option value="stellvertreter">Stellvertretung</option>
|
||||
<option value="mitarbeiter">Mitarbeiter:in</option>
|
||||
<option value="beauftragter">Beauftragte:r</option>
|
||||
</select>
|
||||
<p className="text-sm text-secondary-light mt-2">
|
||||
Diese Rolle wird für Vertretungs- und Organigramm-Funktionen verwendet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mb-6">
|
||||
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
|
||||
Benutzerkonto
|
||||
@ -253,6 +329,7 @@ export default function CreateEmployee() {
|
||||
<ul className="space-y-2 text-body text-secondary">
|
||||
<li>• Der Mitarbeiter wird mit Grunddaten angelegt (Position: "Mitarbeiter", Telefon: "Nicht angegeben")</li>
|
||||
<li>• {watchCreateUser ? 'Ein Benutzerkonto wird erstellt und ein temporäres Passwort generiert' : 'Kein Benutzerkonto wird erstellt'}</li>
|
||||
<li>• {selectedUnitId ? `Die Organisationseinheit ${selectedUnitName} (${getUnitRoleLabel(selectedUnitRole)}) wird als primäre Zuordnung hinterlegt` : 'Organisationseinheit kann später im Organigramm zugewiesen werden'}</li>
|
||||
<li>• Der Mitarbeiter kann später im Frontend seine Profildaten vervollständigen</li>
|
||||
<li>• Alle Daten werden verschlüsselt in der Datenbank gespeichert</li>
|
||||
</ul>
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren