diff --git a/frontend/src/components/SkillTreeMultiSelect.jsx b/frontend/src/components/SkillTreeMultiSelect.jsx
new file mode 100644
index 0000000..98dc4c0
--- /dev/null
+++ b/frontend/src/components/SkillTreeMultiSelect.jsx
@@ -0,0 +1,154 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree'
+import SkillTreePickerPanel from './SkillTreePickerPanel'
+
+function normId(id) {
+ return String(id)
+}
+
+/**
+ * Mehrfachauswahl Fähigkeiten mit hierarchischer Treeview („Alle“) und Pfad-Suche.
+ */
+export default function SkillTreeMultiSelect({
+ value = [],
+ onChange,
+ skills = [],
+ placeholder = 'Fähigkeit suchen …',
+ browseLabel = '▼ Katalog',
+ emptyHint = 'Keine Treffer',
+ className = '',
+}) {
+ const [query, setQuery] = useState('')
+ const [open, setOpen] = useState(false)
+ const [browseTree, setBrowseTree] = useState(false)
+ const rootRef = useRef(null)
+
+ const tree = useMemo(() => buildSkillCatalogTree(skills), [skills])
+ const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
+
+ const leaves = useMemo(() => collectSkillLeavesFromTree(tree, value), [tree, value])
+
+ const selectedLabels = useMemo(() => {
+ return value.map((id) => {
+ const leaf = leaves.find((l) => normId(l.id) === normId(id)) || leaves.find(() => false)
+ const fromSkills = skills.find((s) => normId(s.id) === normId(id))
+ return leaf?.pathLabel || fromSkills?.name || `#${id}`
+ })
+ }, [value, leaves, skills])
+
+ const addId = useCallback(
+ (id) => {
+ const sid = normId(id)
+ if (selectedSet.has(sid)) return
+ onChange([...value, id])
+ setQuery('')
+ setBrowseTree(false)
+ },
+ [value, onChange, selectedSet]
+ )
+
+ const removeAt = useCallback(
+ (idx) => {
+ onChange(value.filter((_, i) => i !== idx))
+ },
+ [value, onChange]
+ )
+
+ useEffect(() => {
+ const onDoc = (e) => {
+ if (!rootRef.current?.contains(e.target)) {
+ setOpen(false)
+ setBrowseTree(false)
+ }
+ }
+ document.addEventListener('mousedown', onDoc)
+ return () => document.removeEventListener('mousedown', onDoc)
+ }, [])
+
+ const showTree = browseTree || !query.trim()
+
+ return (
+
+
+ {value.map((id, idx) => (
+
+ ))}
+
+
+ {
+ setQuery(e.target.value)
+ setOpen(true)
+ setBrowseTree(false)
+ }}
+ onFocus={() => setOpen(true)}
+ autoComplete="off"
+ aria-expanded={open}
+ />
+
+
+ {open ? (
+
+ {showTree ? (
+
addId(id)}
+ pickMode="multi"
+ />
+ ) : (
+
+ {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length ===
+ 0 ? (
+ - {emptyHint}
+ ) : (
+ leaves
+ .filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
+ .map((l) => (
+ -
+
+
+ ))
+ )}
+
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/components/SkillTreePickerPanel.jsx b/frontend/src/components/SkillTreePickerPanel.jsx
new file mode 100644
index 0000000..b1b0b8d
--- /dev/null
+++ b/frontend/src/components/SkillTreePickerPanel.jsx
@@ -0,0 +1,156 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { ChevronDown, ChevronRight } from 'lucide-react'
+import {
+ allExpandableKeys,
+ buildSkillCatalogTree,
+ collectSkillLeavesFromTree,
+ filterSkillTreeByQuery,
+} from '../utils/skillCatalogTree'
+
+function normExclude(excludeIds) {
+ return new Set(
+ (excludeIds instanceof Set ? [...excludeIds] : excludeIds || [])
+ .map((x) => Number(x))
+ .filter((n) => Number.isFinite(n))
+ )
+}
+
+function SkillTreeNodes({ nodes, depth, expanded, exclude, onToggle, onPickSkill, pickMode }) {
+ if (!nodes?.length) {
+ return
Keine Fähigkeiten
+ }
+
+ return nodes.map((node) => {
+ if (node.type === 'skill') {
+ if (exclude.has(node.skillId)) return null
+ return (
+
+
+
+ )
+ }
+
+ const hasKids = node.children?.length > 0
+ const isOpen = expanded.has(node.key)
+ return (
+
+
+ {hasKids ? (
+
+ ) : (
+
+ )}
+ {node.label}
+
+ {hasKids && isOpen ? (
+
+ ) : null}
+
+ )
+ })
+}
+
+/**
+ * Ausklappbare Baumliste für Fähigkeiten (Hauptgruppe → Kategorie → Fähigkeit).
+ */
+export default function SkillTreePickerPanel({
+ skills = [],
+ excludeIds,
+ searchQuery = '',
+ onPickSkill,
+ pickMode = 'single',
+ className = '',
+}) {
+ const exclude = useMemo(() => normExclude(excludeIds), [excludeIds])
+ const fullTree = useMemo(() => buildSkillCatalogTree(skills), [skills])
+ const displayTree = useMemo(
+ () => filterSkillTreeByQuery(fullTree, searchQuery),
+ [fullTree, searchQuery]
+ )
+ const [expanded, setExpanded] = useState(() => new Set())
+
+ useEffect(() => {
+ if (searchQuery.trim()) {
+ setExpanded(new Set(allExpandableKeys(displayTree)))
+ }
+ }, [searchQuery, displayTree])
+
+ useEffect(() => {
+ setExpanded(new Set(allExpandableKeys(fullTree)))
+ }, [fullTree])
+
+ const onToggle = useCallback((key) => {
+ setExpanded((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ return next
+ })
+ }, [])
+
+ const flatSearchHits = useMemo(() => {
+ const q = searchQuery.trim()
+ if (!q) return []
+ return collectSkillLeavesFromTree(fullTree, exclude).filter((leaf) =>
+ leaf.pathLabel.toLowerCase().includes(q.toLowerCase())
+ )
+ }, [fullTree, exclude, searchQuery])
+
+ if (searchQuery.trim() && flatSearchHits.length > 0) {
+ return (
+
+ {flatSearchHits.map((leaf) => (
+ -
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/SkillTreeSelect.jsx b/frontend/src/components/SkillTreeSelect.jsx
new file mode 100644
index 0000000..9ef6d1c
--- /dev/null
+++ b/frontend/src/components/SkillTreeSelect.jsx
@@ -0,0 +1,84 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react'
+import { skillCatalogPathLabel } from '../utils/skillCatalogTree'
+import SkillTreePickerPanel from './SkillTreePickerPanel'
+
+/**
+ * Einzelauswahl einer Fähigkeit als hierarchische Treeview.
+ */
+export default function SkillTreeSelect({
+ value = '',
+ onChange,
+ skills = [],
+ excludeIds,
+ placeholder = 'Fähigkeit wählen…',
+ disabled = false,
+ className = '',
+ searchPlaceholder = 'Fähigkeit suchen…',
+}) {
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const rootRef = useRef(null)
+
+ const selected = useMemo(() => {
+ const id = value ? Number(value) : NaN
+ if (!Number.isFinite(id)) return null
+ return skills.find((s) => Number(s.id) === id) || null
+ }, [value, skills])
+
+ const displayLabel = selected ? skillCatalogPathLabel(selected) : ''
+
+ useEffect(() => {
+ const onDoc = (e) => {
+ if (!rootRef.current?.contains(e.target)) {
+ setOpen(false)
+ setQuery('')
+ }
+ }
+ document.addEventListener('mousedown', onDoc)
+ return () => document.removeEventListener('mousedown', onDoc)
+ }, [])
+
+ const pick = (skillId) => {
+ onChange(String(skillId))
+ setOpen(false)
+ setQuery('')
+ }
+
+ return (
+
+
+ {open ? (
+
+
setQuery(e.target.value)}
+ autoComplete="off"
+ />
+
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx
index dba4e4c..82df068 100644
--- a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx
+++ b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import { useAuth } from '../../context/AuthContext'
import api from '../../utils/api'
+import SkillTreeSelect from '../SkillTreeSelect'
export default function MaturityModelsAdminPanel() {
const { user } = useAuth()
@@ -39,7 +40,7 @@ export default function MaturityModelsAdminPanel() {
try {
const [m, sk] = await Promise.all([
api.listMaturityModels({}),
- api.listSkills({ status: 'active' })
+ api.listSkillsCatalog({ status: 'active' })
])
if (!cancelled) {
setModels(m)
@@ -267,7 +268,7 @@ export default function MaturityModelsAdminPanel() {
? null
: Number(skillWikiForm.relevance_level)
})
- const sk = await api.listSkills({ status: 'active' })
+ const sk = await api.listSkillsCatalog({ status: 'active' })
setAllSkills(sk)
closeSkillWikiModal()
} catch (e) {
@@ -585,16 +586,13 @@ export default function MaturityModelsAdminPanel() {
-
+ onChange={setSkillToAdd}
+ skills={allSkills}
+ excludeIds={(detail.model_skills || []).map((ms) => ms.skill_id)}
+ placeholder="— wählen —"
+ />