Files
SkillMate/frontend/src/views/EmployeeForm.tsx
2025-09-29 00:35:31 +02:00

706 Zeilen
27 KiB
TypeScript

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import type { Employee } from '@skillmate/shared'
import { employeeApi } from '../services/api'
import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '../data/skillCategories'
import PhotoPreview from '../components/PhotoPreview'
import OrganizationSelector from '../components/OrganizationSelector'
export default function EmployeeForm() {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
employeeNumber: '',
position: '',
department: '',
email: '',
phone: '',
mobile: '',
office: '',
clearance: '',
availability: 'available' as 'available' | 'parttime' | 'unavailable',
partTimeHours: '',
skills: [] as any[],
languages: [] as string[],
specializations: [] as string[]
})
const [primaryUnitId, setPrimaryUnitId] = useState<string | null>(null)
const [primaryUnitName, setPrimaryUnitName] = useState<string>('')
const [employeePhoto, setEmployeePhoto] = useState<string | null>(null)
const [photoFile, setPhotoFile] = useState<File | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [expandedSubCategories, setExpandedSubCategories] = useState<Set<string>>(new Set())
const [skillSearchTerm, setSkillSearchTerm] = useState('')
const [searchResults, setSearchResults] = useState<any[]>([])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(categoryId)) {
newSet.delete(categoryId)
} else {
newSet.add(categoryId)
}
return newSet
})
}
const toggleSubCategory = (subCategoryId: string) => {
setExpandedSubCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(subCategoryId)) {
newSet.delete(subCategoryId)
} else {
newSet.add(subCategoryId)
}
return newSet
})
}
const handleSkillToggle = (categoryId: string, subCategoryId: string, skillId: string, skillName: string) => {
setFormData(prev => {
const skills = [...prev.skills]
const existingIndex = skills.findIndex(s =>
s.categoryId === categoryId &&
s.subCategoryId === subCategoryId &&
s.skillId === skillId
)
if (existingIndex > -1) {
skills.splice(existingIndex, 1)
} else {
skills.push({
categoryId,
subCategoryId,
skillId,
name: skillName,
level: ''
})
}
return { ...prev, skills }
})
}
const handleSkillLevelChange = (categoryId: string, subCategoryId: string, skillId: string, level: string) => {
setFormData(prev => {
const skills = [...prev.skills]
const skill = skills.find(s =>
s.categoryId === categoryId &&
s.subCategoryId === subCategoryId &&
s.skillId === skillId
)
if (skill) {
skill.level = level
}
return { ...prev, skills }
})
}
const isSkillSelected = (categoryId: string, subCategoryId: string, skillId: string) => {
return formData.skills.some(s =>
s.categoryId === categoryId &&
s.subCategoryId === subCategoryId &&
s.skillId === skillId
)
}
const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) => {
const skill = formData.skills.find(s =>
s.categoryId === categoryId &&
s.subCategoryId === subCategoryId &&
s.skillId === skillId
)
return skill?.level || ''
}
// Skill-Suche
const handleSkillSearch = (searchTerm: string) => {
setSkillSearchTerm(searchTerm)
if (searchTerm.length < 2) {
setSearchResults([])
return
}
const results: any[] = []
const lowerSearch = searchTerm.toLowerCase()
SKILL_HIERARCHY.forEach(category => {
category.subcategories.forEach(subCategory => {
subCategory.skills.forEach(skill => {
if (skill.name.toLowerCase().includes(lowerSearch)) {
results.push({
categoryId: category.id,
categoryName: category.name,
subCategoryId: subCategory.id,
subCategoryName: subCategory.name,
skillId: skill.id,
skillName: skill.name
})
}
})
})
})
setSearchResults(results)
}
const handleSearchResultClick = (result: any) => {
// Öffne die entsprechenden Kategorien
setExpandedCategories(prev => new Set([...prev, result.categoryId]))
setExpandedSubCategories(prev => new Set([...prev, `${result.categoryId}-${result.subCategoryId}`]))
// Wähle den Skill aus
handleSkillToggle(result.categoryId, result.subCategoryId, result.skillId, result.skillName)
// Lösche die Suche
setSkillSearchTerm('')
setSearchResults([])
}
const validateForm = () => {
const errors: Record<string, string> = {}
if (!formData.firstName.trim()) errors.firstName = 'Vorname ist erforderlich'
if (!formData.lastName.trim()) errors.lastName = 'Nachname ist erforderlich'
if (!formData.employeeNumber.trim()) errors.employeeNumber = 'Personalnummer ist erforderlich'
if (!formData.position.trim()) errors.position = 'Position ist erforderlich'
if (!formData.department.trim()) errors.department = 'Abteilung ist erforderlich'
if (!formData.email.trim()) errors.email = 'E-Mail ist erforderlich'
else if (!/\S+@\S+\.\S+/.test(formData.email)) errors.email = 'Ungültige E-Mail-Adresse'
if (!formData.phone.trim()) errors.phone = 'Telefonnummer ist erforderlich'
if (!primaryUnitId) errors.primaryUnitId = 'Organisatorische Einheit ist erforderlich'
setValidationErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setValidationErrors({})
if (!validateForm()) {
setError('Bitte füllen Sie alle Pflichtfelder aus')
return
}
setLoading(true)
try {
const newEmployee: Partial<Employee> = {
...formData,
skills: formData.skills.map((skill, index) => ({
id: `skill-${index}`,
name: skill.name,
category: skill.categoryId,
level: skill.level || 3
})),
languages: formData.skills
.filter(s => s.subCategoryId === 'languages')
.map(s => ({
language: s.name,
proficiency: s.level || 'B1'
})),
clearance: formData.clearance ? {
level: formData.clearance as 'Ü2' | 'Ü3',
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 5)),
issuedDate: new Date()
} : undefined,
createdAt: new Date(),
updatedAt: new Date(),
createdBy: 'admin'
}
const result = await employeeApi.create({ ...newEmployee, primaryUnitId })
const newEmployeeId = result.data.id
// Upload photo if we have one
if (photoFile && newEmployeeId) {
const formData = new FormData()
formData.append('photo', photoFile)
try {
await fetch(`http://localhost:3001/api/upload/employee-photo/${newEmployeeId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
})
} catch (uploadError) {
console.error('Failed to upload photo:', uploadError)
}
}
navigate('/employees')
} catch (err: any) {
if (err.response?.status === 401) {
setError('Ihre Session ist abgelaufen. Bitte melden Sie sich erneut an.')
} else {
setError(err.response?.data?.message || 'Fehler beim Erstellen des Profils')
}
} finally {
setLoading(false)
}
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary">
Neues Mitarbeitendenprofil
</h1>
<button
onClick={() => navigate('/employees')}
className="btn-secondary"
>
Abbrechen
</button>
</div>
{error && (
<div className="bg-error-bg text-error p-4 rounded-card mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-8">
{/* Persönliche Informationen */}
<div className="form-card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Persönliche Informationen
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2 flex justify-center mb-6">
<div>
<label className="block text-sm font-medium text-secondary mb-2 text-center">
Profilfoto
</label>
<PhotoPreview
currentPhoto={employeePhoto || undefined}
onPhotoSelect={(file) => {
setPhotoFile(file)
// Create preview URL
const reader = new FileReader()
reader.onloadend = () => {
setEmployeePhoto(reader.result as string)
}
reader.readAsDataURL(file)
}}
onPhotoRemove={() => {
setPhotoFile(null)
setEmployeePhoto(null)
}}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Vorname *
</label>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleChange}
className={`input-field ${validationErrors.firstName ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.firstName && (
<p className="mt-1 text-sm text-red-600">{validationErrors.firstName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Nachname *
</label>
<input
type="text"
name="lastName"
value={formData.lastName}
onChange={handleChange}
className={`input-field ${validationErrors.lastName ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.lastName && (
<p className="mt-1 text-sm text-red-600">{validationErrors.lastName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Personalnummer *
</label>
<input
type="text"
name="employeeNumber"
value={formData.employeeNumber}
onChange={handleChange}
className={`input-field ${validationErrors.employeeNumber ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.employeeNumber && (
<p className="mt-1 text-sm text-red-600">{validationErrors.employeeNumber}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
E-Mail *
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className={`input-field ${validationErrors.email ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.email && (
<p className="mt-1 text-sm text-red-600">{validationErrors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Position *
</label>
<input
type="text"
name="position"
value={formData.position}
onChange={handleChange}
className={`input-field ${validationErrors.position ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.position && (
<p className="mt-1 text-sm text-red-600">{validationErrors.position}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Abteilung *
</label>
<input
type="text"
name="department"
value={formData.department}
onChange={handleChange}
className={`input-field ${validationErrors.department ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.department && (
<p className="mt-1 text-sm text-red-600">{validationErrors.department}</p>
)}
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-secondary mb-2">
Organisatorische Einheit (Primär) *
</label>
<OrganizationSelector
value={primaryUnitId || undefined}
onChange={(unitId, unitName) => {
setPrimaryUnitId(unitId)
setPrimaryUnitName(unitName)
}}
/>
<p className="text-tertiary text-sm mt-2">{primaryUnitName || 'Bitte auswählen'}</p>
{validationErrors.primaryUnitId && (
<p className="mt-1 text-sm text-red-600">{validationErrors.primaryUnitId}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Telefon *
</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
className={`input-field ${validationErrors.phone ? 'border-red-500 ring-red-200' : ''}`}
required
/>
{validationErrors.phone && (
<p className="mt-1 text-sm text-red-600">{validationErrors.phone}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Mobil
</label>
<input
type="tel"
name="mobile"
value={formData.mobile}
onChange={handleChange}
className="input-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Büro
</label>
<input
type="text"
name="office"
value={formData.office}
onChange={handleChange}
className="input-field"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Sicherheitsfreigabe
</label>
<select
name="clearance"
value={formData.clearance}
onChange={handleChange}
className="input-field"
>
<option value="">Keine</option>
<option value="Ü2">Ü2</option>
<option value="Ü3">Ü3</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Verfügbarkeit
</label>
<select
name="availability"
value={formData.availability}
onChange={handleChange}
className="input-field"
>
<option value="available">Verfügbar</option>
<option value="parttime">Teilzeit</option>
<option value="unavailable">Nicht verfügbar</option>
</select>
</div>
{formData.availability === 'parttime' && (
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Stunden pro Woche
</label>
<input
type="text"
name="partTimeHours"
value={formData.partTimeHours}
onChange={handleChange}
placeholder="z.B. 20 Stunden"
className="input-field"
/>
</div>
)}
</div>
</div>
{/* Skills */}
<div className="form-card">
<div className="flex items-center justify-between mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary">
Fähigkeiten und Qualifikationen
</h2>
{/* Skill-Suche */}
<div className="relative w-64">
<input
type="text"
value={skillSearchTerm}
onChange={(e) => handleSkillSearch(e.target.value)}
placeholder="Skills suchen..."
className="input-field w-full pl-10 pr-3"
/>
<svg
className="absolute left-3 top-4 w-4 h-4 text-text-placeholder pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{/* Suchergebnisse */}
{searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-64 overflow-y-auto z-10">
{searchResults.map((result, index) => (
<button
key={index}
type="button"
onClick={() => handleSearchResultClick(result)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border-b border-gray-200 dark:border-gray-600 last:border-b-0 text-gray-900 dark:text-white"
>
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900 dark:text-white">{result.skillName}</span>
{isSkillSelected(result.categoryId, result.subCategoryId, result.skillId) && (
<svg className="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{result.categoryName} {result.subCategoryName}
</div>
</button>
))}
</div>
)}
{skillSearchTerm.length >= 2 && searchResults.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-4">
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">Keine Skills gefunden</p>
</div>
)}
</div>
</div>
<div className="space-y-4">
{SKILL_HIERARCHY.map(category => (
<div key={category.id} className="border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800">
{/* Oberste Kategorie */}
<button
type="button"
onClick={() => toggleCategory(category.id)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-900 dark:text-white"
>
<span className="font-semibold text-gray-900 dark:text-white">{category.name}</span>
<svg
className={`w-5 h-5 text-gray-600 dark:text-gray-400 transition-transform ${
expandedCategories.has(category.id) ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Unterkategorien */}
{expandedCategories.has(category.id) && (
<div className="border-t border-gray-300 dark:border-gray-600">
{category.subcategories.map(subCategory => (
<div key={subCategory.id} className="border-b border-gray-200 dark:border-gray-700 last:border-b-0">
{/* Mittlere Kategorie */}
<button
type="button"
onClick={() => toggleSubCategory(`${category.id}-${subCategory.id}`)}
className="w-full px-6 py-2 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-800 dark:text-gray-200"
>
<span className="text-gray-700 dark:text-gray-300">{subCategory.name}</span>
<svg
className={`w-4 h-4 text-gray-600 dark:text-gray-400 transition-transform ${
expandedSubCategories.has(`${category.id}-${subCategory.id}`) ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Skills */}
{expandedSubCategories.has(`${category.id}-${subCategory.id}`) && (
<div className="px-8 py-3 bg-gray-50 dark:bg-gray-900 space-y-2">
{subCategory.skills.map(skill => (
<div key={skill.id} className="flex items-center space-x-3">
<label className="flex items-center space-x-2 flex-1">
<input
type="checkbox"
checked={isSkillSelected(category.id, subCategory.id, skill.id)}
onChange={() => handleSkillToggle(category.id, subCategory.id, skill.id, skill.name)}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800 dark:text-blue-400"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{skill.name}</span>
</label>
{/* Niveauauswahl */}
{isSkillSelected(category.id, subCategory.id, skill.id) && (
subCategory.id === 'languages' ? (
<select
value={getSkillLevel(category.id, subCategory.id, skill.id)}
onChange={(e) => handleSkillLevelChange(category.id, subCategory.id, skill.id, e.target.value)}
className="text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">Niveau wählen</option>
{LANGUAGE_LEVELS.map(level => (
<option key={level} value={level}>{level}</option>
))}
</select>
) : (
<select
value={getSkillLevel(category.id, subCategory.id, skill.id)}
onChange={(e) => handleSkillLevelChange(category.id, subCategory.id, skill.id, e.target.value)}
className="text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<option value="">Niveau wählen</option>
<option value="Beginner">Anfänger</option>
<option value="Basic">Grundkenntnisse</option>
<option value="Intermediate">Fortgeschritten</option>
<option value="Advanced">Sehr fortgeschritten</option>
<option value="Expert">Experte</option>
</select>
)
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
{/* Submit */}
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => navigate('/employees')}
className="btn-secondary"
>
Abbrechen
</button>
<button
type="submit"
disabled={loading}
className="btn-primary"
>
{loading ? 'Wird gespeichert...' : 'Profil erstellen'}
</button>
</div>
</form>
</div>
)
}