Sub Kategorie doch erstellt
Dieser Commit ist enthalten in:
@ -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={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${category?.color || 'bg-gray-100'}`}>
|
||||||
<span className="flex-shrink-0">{category?.icon}</span>
|
<span className="flex-shrink-0">{category?.icon}</span>
|
||||||
<span className="ml-1">{category?.name}</span>
|
<span className="ml-1">{category?.name}</span>
|
||||||
</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">
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren