Sub Kategorie doch erstellt

Dieser Commit ist enthalten in:
Claude Project Manager
2025-09-28 00:41:52 +02:00
Ursprung 2689cd2d32
Commit f8098ab136
2 geänderte Dateien mit 331 neuen und 38 gelöschten Zeilen

Datei anzeigen

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { api } from '../services/api' import { api } from '../services/api'
type Skill = { type Skill = {
@ -9,12 +9,15 @@ type Skill = {
userCount?: number userCount?: number
tags?: string[] tags?: string[]
levelDistribution?: { levelDistribution?: {
beginner: number // Level 1-3 beginner: number
intermediate: number // Level 4-6 intermediate: number
expert: number // Level 7-10 expert: number
} }
requires_certification?: boolean requires_certification?: boolean
certification_months?: number certification_months?: number
typeKey?: string
typeName?: string
typeIcon?: string
} }
type Category = { type Category = {
@ -25,6 +28,16 @@ type Category = {
skillCount?: number skillCount?: number
} }
type SkillType = {
key: string
id: string
name: string
categoryId: string
categoryName: string
icon: string
color: string
}
type ViewMode = 'grid' | 'category' type ViewMode = 'grid' | 'category'
export default function SkillManagement() { export default function SkillManagement() {
@ -39,6 +52,11 @@ export default function SkillManagement() {
const [sortBy, setSortBy] = useState<'name' | 'users' | 'category'>('name') const [sortBy, setSortBy] = useState<'name' | 'users' | 'category'>('name')
const [employeeCounts, setEmployeeCounts] = useState<Record<string, number>>({}) const [employeeCounts, setEmployeeCounts] = useState<Record<string, number>>({})
const [levelDistributions, setLevelDistributions] = useState<Record<string, { beginner: number, intermediate: number, expert: number }>>({}) const [levelDistributions, setLevelDistributions] = useState<Record<string, { beginner: number, intermediate: number, expert: number }>>({})
const [skillTypes, setSkillTypes] = useState<SkillType[]>([])
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set())
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set())
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
const lastExpansionRef = useRef<{ key: string; prevTop: number | null } | null>(null)
// Modal states // Modal states
const [showSkillModal, setShowSkillModal] = useState(false) const [showSkillModal, setShowSkillModal] = useState(false)
@ -50,6 +68,7 @@ export default function SkillManagement() {
name: '', name: '',
description: '', description: '',
category: '', category: '',
typeKey: '',
tags: [] as string[], tags: [] as string[],
requires_certification: false, requires_certification: false,
certification_months: 0 certification_months: 0
@ -64,6 +83,17 @@ export default function SkillManagement() {
'certifications': { icon: '📜', color: 'bg-yellow-100 text-yellow-800' }, 'certifications': { icon: '📜', color: 'bg-yellow-100 text-yellow-800' },
} }
const typeIconFallback = '📁'
const typeIconMap: Record<string, string> = {
languages: '🌐',
interpersonal: '🤝',
presentation: '🗣️',
moderation: '🎙️',
negotiation: '⚖️',
security: '🛡️',
}
// Predefined tags // Predefined tags
const availableTags = [ const availableTags = [
'Programmierung', 'Datenanalyse', 'Forensik', 'Machine Learning', 'Programmierung', 'Datenanalyse', 'Forensik', 'Machine Learning',
@ -75,6 +105,12 @@ export default function SkillManagement() {
loadData() loadData()
}, []) }, [])
useEffect(() => {
if (!editingSkill && !skillForm.category && categories.length > 0) {
setSkillForm(prev => ({ ...prev, category: categories[0].id }))
}
}, [categories, editingSkill, skillForm.category])
async function loadData() { async function loadData() {
// First fetch employee counts, then skills with those counts // First fetch employee counts, then skills with those counts
const { counts, distributions } = await fetchEmployeeCountsAndDistributions() const { counts, distributions } = await fetchEmployeeCountsAndDistributions()
@ -95,6 +131,8 @@ export default function SkillManagement() {
const extractedCategories: Category[] = [] const extractedCategories: Category[] = []
const flatSkills: Skill[] = [] const flatSkills: Skill[] = []
const seenCategories = new Set<string>() const seenCategories = new Set<string>()
const collectedTypes: SkillType[] = []
const seenTypeKeys = new Set<string>()
hierarchy.forEach((cat: any) => { hierarchy.forEach((cat: any) => {
// Add category if not seen // Add category if not seen
@ -110,11 +148,32 @@ export default function SkillManagement() {
}) })
} }
const catName = cat.name
cat.subcategories?.forEach((sub: any) => { 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) => { sub.skills?.forEach((skill: any) => {
flatSkills.push({ flatSkills.push({
...skill, ...skill,
category: cat.id, category: cat.id,
typeKey,
typeName: sub.name,
typeIcon: typeIconMap[sub.id] || typeIconFallback,
tags: [cat.name, sub.name], tags: [cat.name, sub.name],
userCount: skillCounts[skill.id] || 0, userCount: skillCounts[skill.id] || 0,
levelDistribution: skillDistributions[skill.id] || { beginner: 0, intermediate: 0, expert: 0 } levelDistribution: skillDistributions[skill.id] || { beginner: 0, intermediate: 0, expert: 0 }
@ -130,6 +189,7 @@ export default function SkillManagement() {
setCategories(extractedCategories) setCategories(extractedCategories)
setSkills(flatSkills) setSkills(flatSkills)
setSkillTypes(collectedTypes.sort((a, b) => a.name.localeCompare(b.name)))
} catch (e) { } catch (e) {
setError('Skills konnten nicht geladen werden') setError('Skills konnten nicht geladen werden')
} finally { } finally {
@ -180,7 +240,8 @@ export default function SkillManagement() {
const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) || const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
skill.description?.toLowerCase().includes(searchQuery.toLowerCase()) skill.description?.toLowerCase().includes(searchQuery.toLowerCase())
const matchesCategory = selectedCategories.size === 0 || selectedCategories.has(skill.category || '') 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) => { .sort((a, b) => {
switch (sortBy) { switch (sortBy) {
@ -203,13 +264,150 @@ export default function SkillManagement() {
newThisWeek: Math.floor(skills.length * 0.15) 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<Map<string, Skill[]>>((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 (
<div
key={typeKey}
ref={el => {
if (el) {
sectionRefs.current[typeKey] = el
} else {
delete sectionRefs.current[typeKey]
}
}}
className="mb-8"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className="text-xl" aria-hidden>{typeMeta.icon}</span>
<div>
<h3 className="text-lg font-semibold text-primary">{typeMeta.name}</h3>
<p className="text-xs text-secondary">{typeMeta.categoryName}</p>
</div>
</div>
<span className="text-sm text-tertiary">{skillsForType.length} Skills</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{visibleSkills.map(skill => (
<SkillCard key={skill.id} skill={skill} />
))}
</div>
{hiddenCount > 0 && (
<div className="mt-3">
<button
type="button"
onClick={() => toggleTypeExpansion(typeKey)}
className="text-sm text-blue-600 hover:text-blue-800"
>
{isExpanded ? 'Weniger anzeigen' : `Mehr anzeigen (+${hiddenCount})`}
</button>
</div>
)}
</div>
)
}
function openSkillModal(skill?: Skill) { function openSkillModal(skill?: Skill) {
if (skill) { if (skill) {
setEditingSkill(skill) setEditingSkill(skill)
const categoryId = skill.category || ''
const typeKey = skill.typeKey || ''
setSkillForm({ setSkillForm({
name: skill.name, name: skill.name,
description: skill.description || '', description: skill.description || '',
category: skill.category || '', category: categoryId,
typeKey,
tags: skill.tags || [], tags: skill.tags || [],
requires_certification: skill.requires_certification || false, requires_certification: skill.requires_certification || false,
certification_months: skill.certification_months || 0 certification_months: skill.certification_months || 0
@ -220,6 +418,7 @@ export default function SkillManagement() {
name: '', name: '',
description: '', description: '',
category: '', category: '',
typeKey: '',
tags: [], tags: [],
requires_certification: false, requires_certification: false,
certification_months: 0 certification_months: 0
@ -230,18 +429,21 @@ export default function SkillManagement() {
async function saveSkill() { async function saveSkill() {
try { 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) { if (editingSkill) {
await api.put(`/skills/${editingSkill.id}`, { await api.put(`/skills/${editingSkill.id}`, payload)
name: skillForm.name,
description: skillForm.description,
category: skillForm.category
})
} else { } else {
await api.post('/skills', { await api.post('/skills', payload)
name: skillForm.name,
description: skillForm.description,
category: skillForm.category || 'technical.general'
})
} }
setShowSkillModal(false) setShowSkillModal(false)
loadData() loadData()
@ -287,7 +489,8 @@ export default function SkillManagement() {
const data = filteredAndSortedSkills.map(s => ({ const data = filteredAndSortedSkills.map(s => ({
Name: s.name, Name: s.name,
Beschreibung: s.description, Beschreibung: s.description,
Kategorie: s.category, Kategorie: categories.find(c => c.id === s.category)?.name || s.category,
Typ: s.typeName,
Tags: s.tags?.join(', ') Tags: s.tags?.join(', ')
})) }))
const csv = [ const csv = [
@ -351,10 +554,18 @@ export default function SkillManagement() {
)} )}
<div className="flex items-start justify-between gap-2 text-sm"> <div className="flex items-start justify-between gap-2 text-sm">
<span className={`inline-flex items-start px-2 py-1 rounded-full text-xs font-medium ${category?.color || 'bg-gray-100'}`}> <div className="space-y-1">
<span className="flex-shrink-0">{category?.icon}</span> <span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${category?.color || 'bg-gray-100'}`}>
<span className="ml-1">{category?.name}</span> <span className="flex-shrink-0">{category?.icon}</span>
</span> <span className="ml-1">{category?.name}</span>
</span>
{skill.typeName && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
<span className="mr-1">{skill.typeIcon || typeIconFallback}</span>
{skill.typeName}
</span>
)}
</div>
<span className="text-tertiary flex-shrink-0">👥 {employeeCounts[skill.id] || skill.userCount || 0}</span> <span className="text-tertiary flex-shrink-0">👥 {employeeCounts[skill.id] || skill.userCount || 0}</span>
</div> </div>
@ -376,11 +587,29 @@ export default function SkillManagement() {
) )
} }
const categorySkillsMap = categories.reduce<Map<string, Skill[]>>((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() { function CategoryView() {
if (visibleCategories.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-8 text-center text-secondary">
Keine Kategorien mit passenden Skills gefunden. Filter zurücksetzen?
</div>
)
}
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{categories.map(category => { {visibleCategories.map(category => {
const categorySkills = filteredAndSortedSkills.filter(s => s.category === category.id) const categorySkills = categorySkillsMap.get(category.id) || []
return ( return (
<div key={category.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6"> <div key={category.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@ -411,7 +640,7 @@ export default function SkillManagement() {
<button <button
onClick={() => { onClick={() => {
setSkillForm(prev => ({ ...prev, category: category.id })) setSkillForm(prev => ({ ...prev, category: category.id, typeKey: '' }))
openSkillModal() openSkillModal()
}} }}
className="w-full mt-4 text-sm text-blue-600 hover:text-blue-800" className="w-full mt-4 text-sm text-blue-600 hover:text-blue-800"
@ -553,6 +782,43 @@ export default function SkillManagement() {
)} )}
</div> </div>
{displayedTypeChips.length > 0 && (
<div className="mt-4 space-y-2">
<h3 className="text-sm font-medium text-secondary">Skill-Typen</h3>
<div className="flex flex-wrap gap-2">
{displayedTypeChips.map(type => {
const isActive = selectedTypes.has(type.key)
const count = groupedSkills.get(type.key)?.length || 0
if (count === 0) return null
return (
<button
key={type.key}
onClick={() => toggleTypeFilter(type.key)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-1 ${
isActive
? 'bg-blue-600 text-white shadow'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600'
}`}
>
<span>{type.icon}</span>
<span>{type.name}</span>
<span className="text-xs opacity-75">({count})</span>
</button>
)
})}
{selectedTypes.size > 0 && (
<button
onClick={() => setSelectedTypes(new Set())}
className="px-3 py-1.5 rounded-full text-sm font-medium bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-300 dark:hover:bg-gray-500"
>
Alle Typen
</button>
)}
</div>
</div>
)}
</div> </div>
{error && ( {error && (
@ -570,9 +836,9 @@ export default function SkillManagement() {
) : ( ) : (
<> <>
{viewMode === 'grid' && ( {viewMode === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div>
{filteredAndSortedSkills.map(skill => ( {sortedTypeKeys.map(typeKey => (
<SkillCard key={skill.id} skill={skill} /> <TypeSection key={typeKey} typeKey={typeKey} />
))} ))}
</div> </div>
)} )}
@ -625,7 +891,14 @@ export default function SkillManagement() {
<label className="block text-sm font-medium mb-2">Hauptkategorie</label> <label className="block text-sm font-medium mb-2">Hauptkategorie</label>
<select <select
value={skillForm.category} value={skillForm.category}
onChange={(e) => setSkillForm(prev => ({ ...prev, category: e.target.value }))} onChange={(e) => {
const newCategory = e.target.value
setSkillForm(prev => ({
...prev,
category: newCategory,
typeKey: prev.typeKey.startsWith(`${newCategory}.`) ? prev.typeKey : ''
}))
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
> >
<option value="">Kategorie wählen...</option> <option value="">Kategorie wählen...</option>
@ -635,6 +908,25 @@ export default function SkillManagement() {
</select> </select>
</div> </div>
<div>
<label className="block text-sm font-medium mb-2">Skill-Typ</label>
<select
value={skillForm.typeKey}
onChange={(e) => setSkillForm(prev => ({ ...prev, typeKey: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600"
disabled={!skillForm.category}
>
<option value="">{skillForm.category ? 'Skill-Typ wählen...' : 'Bitte zuerst eine Kategorie wählen'}</option>
{skillTypes
.filter(type => type.categoryId === skillForm.category)
.map(type => (
<option key={type.key} value={type.key}>
{type.name}
</option>
))}
</select>
</div>
<div> <div>
<label className="block text-sm font-medium mb-2">Tags (Mehrfachzuordnung)</label> <label className="block text-sm font-medium mb-2">Tags (Mehrfachzuordnung)</label>
<div className="border border-gray-300 rounded-lg p-3 dark:border-gray-600"> <div className="border border-gray-300 rounded-lg p-3 dark:border-gray-600">

Datei anzeigen

@ -5,7 +5,8 @@ import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 3006, port: Number(process.env.VITE_PORT || 5174),
strictPort: true,
}, },
resolve: { resolve: {
alias: { alias: {