Dieser Commit ist enthalten in:
Claude Project Manager
2025-09-28 20:00:33 +02:00
Ursprung f8098ab136
Commit b462f69281
6 geänderte Dateien mit 889 neuen und 35 gelöschten Zeilen

Datei anzeigen

@ -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>
)
}

Datei anzeigen

@ -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'

Datei anzeigen

@ -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>