diff --git a/admin-panel/src/views/SkillManagement.tsx b/admin-panel/src/views/SkillManagement.tsx index dcb18b9..04d4ab5 100644 --- a/admin-panel/src/views/SkillManagement.tsx +++ b/admin-panel/src/views/SkillManagement.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { api } from '../services/api' -type Skill = { +type Skill = { id: string name: string category?: string @@ -9,15 +9,18 @@ type Skill = { userCount?: number tags?: string[] levelDistribution?: { - beginner: number // Level 1-3 - intermediate: number // Level 4-6 - expert: number // Level 7-10 + beginner: number + intermediate: number + expert: number } requires_certification?: boolean certification_months?: number + typeKey?: string + typeName?: string + typeIcon?: string } -type Category = { +type Category = { id: string name: string icon?: string @@ -25,6 +28,16 @@ type Category = { skillCount?: number } +type SkillType = { + key: string + id: string + name: string + categoryId: string + categoryName: string + icon: string + color: string +} + type ViewMode = 'grid' | 'category' export default function SkillManagement() { @@ -39,6 +52,11 @@ export default function SkillManagement() { const [sortBy, setSortBy] = useState<'name' | 'users' | 'category'>('name') const [employeeCounts, setEmployeeCounts] = useState>({}) const [levelDistributions, setLevelDistributions] = useState>({}) + const [skillTypes, setSkillTypes] = useState([]) + const [selectedTypes, setSelectedTypes] = useState>(new Set()) + const [expandedTypes, setExpandedTypes] = useState>(new Set()) + const sectionRefs = useRef>({}) + const lastExpansionRef = useRef<{ key: string; prevTop: number | null } | null>(null) // Modal states const [showSkillModal, setShowSkillModal] = useState(false) @@ -50,6 +68,7 @@ export default function SkillManagement() { name: '', description: '', category: '', + typeKey: '', tags: [] as string[], requires_certification: false, certification_months: 0 @@ -64,6 +83,17 @@ export default function SkillManagement() { 'certifications': { icon: '📜', color: 'bg-yellow-100 text-yellow-800' }, } + const typeIconFallback = '📁' + + const typeIconMap: Record = { + languages: '🌐', + interpersonal: '🤝', + presentation: '🗣️', + moderation: '🎙️', + negotiation: '⚖️', + security: '🛡️', + } + // Predefined tags const availableTags = [ 'Programmierung', 'Datenanalyse', 'Forensik', 'Machine Learning', @@ -75,6 +105,12 @@ export default function SkillManagement() { loadData() }, []) + useEffect(() => { + if (!editingSkill && !skillForm.category && categories.length > 0) { + setSkillForm(prev => ({ ...prev, category: categories[0].id })) + } + }, [categories, editingSkill, skillForm.category]) + async function loadData() { // First fetch employee counts, then skills with those counts const { counts, distributions } = await fetchEmployeeCountsAndDistributions() @@ -95,6 +131,8 @@ export default function SkillManagement() { const extractedCategories: Category[] = [] const flatSkills: Skill[] = [] const seenCategories = new Set() + const collectedTypes: SkillType[] = [] + const seenTypeKeys = new Set() hierarchy.forEach((cat: any) => { // Add category if not seen @@ -110,11 +148,32 @@ export default function SkillManagement() { }) } + const catName = cat.name + cat.subcategories?.forEach((sub: any) => { + const typeKey = `${cat.id}.${sub.id}` + if (!seenTypeKeys.has(typeKey)) { + seenTypeKeys.add(typeKey) + const typeIcon = typeIconMap[sub.id] || typeIconFallback + const typeColor = categoryConfig[cat.id]?.color || 'bg-gray-100 text-gray-800' + collectedTypes.push({ + key: typeKey, + id: sub.id, + name: sub.name, + categoryId: cat.id, + categoryName: catName, + icon: typeIcon, + color: typeColor + }) + } + sub.skills?.forEach((skill: any) => { flatSkills.push({ ...skill, category: cat.id, + typeKey, + typeName: sub.name, + typeIcon: typeIconMap[sub.id] || typeIconFallback, tags: [cat.name, sub.name], userCount: skillCounts[skill.id] || 0, levelDistribution: skillDistributions[skill.id] || { beginner: 0, intermediate: 0, expert: 0 } @@ -130,6 +189,7 @@ export default function SkillManagement() { setCategories(extractedCategories) setSkills(flatSkills) + setSkillTypes(collectedTypes.sort((a, b) => a.name.localeCompare(b.name))) } catch (e) { setError('Skills konnten nicht geladen werden') } finally { @@ -180,7 +240,8 @@ export default function SkillManagement() { const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) || skill.description?.toLowerCase().includes(searchQuery.toLowerCase()) const matchesCategory = selectedCategories.size === 0 || selectedCategories.has(skill.category || '') - return matchesSearch && matchesCategory + const matchesType = selectedTypes.size === 0 || (skill.typeKey && selectedTypes.has(skill.typeKey)) + return matchesSearch && matchesCategory && matchesType }) .sort((a, b) => { switch (sortBy) { @@ -203,13 +264,150 @@ export default function SkillManagement() { newThisWeek: Math.floor(skills.length * 0.15) } + const typeMetaMap = new Map(skillTypes.map(type => [type.key, type])) + const fallbackTypeMeta: SkillType = { + key: '__other', + id: 'other', + name: 'Weitere Skills', + categoryId: 'other', + categoryName: 'Weitere', + icon: typeIconFallback, + color: 'bg-gray-100 text-gray-800' + } + + const groupedSkills = filteredAndSortedSkills.reduce>((acc, skill) => { + const key = skill.typeKey && typeMetaMap.has(skill.typeKey) ? skill.typeKey : '__other' + if (!acc.has(key)) { + acc.set(key, []) + } + acc.get(key)!.push(skill) + return acc + }, new Map()) + + const typeKeysInResults = new Set(groupedSkills.keys()) + + const filteredSkillTypes = skillTypes.filter(type => { + const categoryMatch = selectedCategories.size === 0 || selectedCategories.has(type.categoryId) + return categoryMatch && typeKeysInResults.has(type.key) + }) + + const displayedTypeChips = filteredSkillTypes.length > 0 ? filteredSkillTypes : skillTypes.filter(type => typeKeysInResults.has(type.key)) + + const toggleTypeFilter = (key: string) => { + const updated = new Set(selectedTypes) + if (updated.has(key)) { + updated.delete(key) + } else { + updated.add(key) + } + setSelectedTypes(updated) + } + + const toggleTypeExpansion = (key: string) => { + const section = sectionRefs.current[key] + const prevTop = section ? section.getBoundingClientRect().top : null + lastExpansionRef.current = { key, prevTop } + setExpandedTypes(prev => { + const updated = new Set(prev) + if (updated.has(key)) { + updated.delete(key) + } else { + updated.add(key) + } + return updated + }) + } + + useLayoutEffect(() => { + if (!lastExpansionRef.current) return + const { key, prevTop } = lastExpansionRef.current + lastExpansionRef.current = null + if (prevTop === null) return + const section = sectionRefs.current[key] + if (!section) return + const newTop = section.getBoundingClientRect().top + const delta = newTop - prevTop + if (delta > 1) { + window.scrollBy({ top: delta, behavior: 'auto' }) + } + }, [expandedTypes]) + + const getTypeMeta = (key: string): SkillType => { + if (key === '__other') return fallbackTypeMeta + return typeMetaMap.get(key) || fallbackTypeMeta + } + + const sortedTypeKeys = Array.from(groupedSkills.keys()).sort((a, b) => { + const metaA = getTypeMeta(a) + const metaB = getTypeMeta(b) + if (metaA.categoryName !== metaB.categoryName) { + return metaA.categoryName.localeCompare(metaB.categoryName) + } + return metaA.name.localeCompare(metaB.name) + }) + + function TypeSection({ typeKey }: { typeKey: string }) { + const skillsForType = groupedSkills.get(typeKey) || [] + if (skillsForType.length === 0) return null + const typeMeta = getTypeMeta(typeKey) + const isExpanded = expandedTypes.has(typeKey) + const visibleSkills = isExpanded ? skillsForType : skillsForType.slice(0, 3) + const hiddenCount = skillsForType.length - visibleSkills.length + + return ( +
{ + if (el) { + sectionRefs.current[typeKey] = el + } else { + delete sectionRefs.current[typeKey] + } + }} + className="mb-8" + > +
+
+ {typeMeta.icon} +
+

{typeMeta.name}

+

{typeMeta.categoryName}

+
+
+ {skillsForType.length} Skills +
+ +
+ {visibleSkills.map(skill => ( + + ))} +
+ + {hiddenCount > 0 && ( +
+ +
+ )} +
+ ) + } + function openSkillModal(skill?: Skill) { if (skill) { setEditingSkill(skill) + const categoryId = skill.category || '' + const typeKey = skill.typeKey || '' setSkillForm({ name: skill.name, description: skill.description || '', - category: skill.category || '', + category: categoryId, + typeKey, tags: skill.tags || [], requires_certification: skill.requires_certification || false, certification_months: skill.certification_months || 0 @@ -220,6 +418,7 @@ export default function SkillManagement() { name: '', description: '', category: '', + typeKey: '', tags: [], requires_certification: false, certification_months: 0 @@ -230,18 +429,21 @@ export default function SkillManagement() { async function saveSkill() { try { + const payload = { + name: skillForm.name, + description: skillForm.description, + category: skillForm.typeKey || skillForm.category + } + + if (!payload.category || payload.category.indexOf('.') === -1) { + setError('Bitte einen Skill-Typ auswählen') + return + } + if (editingSkill) { - await api.put(`/skills/${editingSkill.id}`, { - name: skillForm.name, - description: skillForm.description, - category: skillForm.category - }) + await api.put(`/skills/${editingSkill.id}`, payload) } else { - await api.post('/skills', { - name: skillForm.name, - description: skillForm.description, - category: skillForm.category || 'technical.general' - }) + await api.post('/skills', payload) } setShowSkillModal(false) loadData() @@ -287,7 +489,8 @@ export default function SkillManagement() { const data = filteredAndSortedSkills.map(s => ({ Name: s.name, Beschreibung: s.description, - Kategorie: s.category, + Kategorie: categories.find(c => c.id === s.category)?.name || s.category, + Typ: s.typeName, Tags: s.tags?.join(', ') })) const csv = [ @@ -306,7 +509,7 @@ export default function SkillManagement() { function SkillCard({ skill }: { skill: Skill }) { const category = categories.find(c => c.id === skill.category) const dist = skill.levelDistribution || { beginner: 0, intermediate: 0, expert: 0 } - + return (
@@ -351,10 +554,18 @@ export default function SkillManagement() { )}
- - {category?.icon} - {category?.name} - +
+ + {category?.icon} + {category?.name} + + {skill.typeName && ( + + {skill.typeIcon || typeIconFallback} + {skill.typeName} + + )} +
👥 {employeeCounts[skill.id] || skill.userCount || 0}
@@ -376,11 +587,29 @@ export default function SkillManagement() { ) } + const categorySkillsMap = categories.reduce>((acc, category) => { + const skillsForCategory = filteredAndSortedSkills.filter(skill => skill.category === category.id) + if (skillsForCategory.length > 0) { + acc.set(category.id, skillsForCategory) + } + return acc + }, new Map()) + + const visibleCategories = categories.filter(category => categorySkillsMap.has(category.id)) + function CategoryView() { + if (visibleCategories.length === 0) { + return ( +
+ Keine Kategorien mit passenden Skills gefunden. Filter zurücksetzen? +
+ ) + } + return (
- {categories.map(category => { - const categorySkills = filteredAndSortedSkills.filter(s => s.category === category.id) + {visibleCategories.map(category => { + const categorySkills = categorySkillsMap.get(category.id) || [] return (
@@ -390,11 +619,11 @@ export default function SkillManagement() { {categorySkills.length} Skills
- +
{categorySkills.slice(0, 10).map(skill => ( -
openSkillModal(skill)} > @@ -408,10 +637,10 @@ export default function SkillManagement() {
)}
- +
+ {displayedTypeChips.length > 0 && ( +
+

Skill-Typen

+
+ {displayedTypeChips.map(type => { + const isActive = selectedTypes.has(type.key) + const count = groupedSkills.get(type.key)?.length || 0 + if (count === 0) return null + return ( + + ) + })} + + {selectedTypes.size > 0 && ( + + )} +
+
+ )} +
{error && ( @@ -570,9 +836,9 @@ export default function SkillManagement() { ) : ( <> {viewMode === 'grid' && ( -
- {filteredAndSortedSkills.map(skill => ( - +
+ {sortedTypeKeys.map(typeKey => ( + ))}
)} @@ -625,7 +891,14 @@ export default function SkillManagement() {
+
+ + +
+
@@ -766,4 +1058,4 @@ export default function SkillManagement() { )}
) -} \ No newline at end of file +} diff --git a/admin-panel/vite.config.ts b/admin-panel/vite.config.ts index 2439ec4..a199c95 100644 --- a/admin-panel/vite.config.ts +++ b/admin-panel/vite.config.ts @@ -5,7 +5,8 @@ import path from 'path' export default defineConfig({ plugins: [react()], server: { - port: 3006, + port: Number(process.env.VITE_PORT || 5174), + strictPort: true, }, resolve: { alias: { @@ -16,4 +17,4 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, }, -}) \ No newline at end of file +})