2026-05-20 10:59:17 +02:00
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { ChevronDown, ChevronRight } from 'lucide-react'
|
|
|
|
|
import {
|
|
|
|
|
allExpandableKeys,
|
|
|
|
|
buildSkillCatalogTree,
|
|
|
|
|
collectSkillLeavesFromTree,
|
2026-05-20 11:13:34 +02:00
|
|
|
defaultExpandedKeysForSkillTree,
|
2026-05-20 10:59:17 +02:00
|
|
|
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 <li className="skill-tree__empty">Keine Fähigkeiten</li>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nodes.map((node) => {
|
|
|
|
|
if (node.type === 'skill') {
|
|
|
|
|
if (exclude.has(node.skillId)) return null
|
|
|
|
|
return (
|
|
|
|
|
<li key={node.key} className="skill-tree__leaf" style={{ paddingLeft: `${8 + depth * 14}px` }}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="skill-tree__pick"
|
|
|
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
|
|
|
onClick={() => onPickSkill(node.skillId, node.skill)}
|
|
|
|
|
>
|
|
|
|
|
{node.label}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasKids = node.children?.length > 0
|
|
|
|
|
const isOpen = expanded.has(node.key)
|
|
|
|
|
return (
|
|
|
|
|
<li key={node.key} className="skill-tree__branch">
|
|
|
|
|
<div className="skill-tree__branch-head" style={{ paddingLeft: `${4 + depth * 14}px` }}>
|
|
|
|
|
{hasKids ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="skill-tree__toggle"
|
|
|
|
|
aria-expanded={isOpen}
|
|
|
|
|
onClick={() => onToggle(node.key)}
|
|
|
|
|
>
|
|
|
|
|
{isOpen ? <ChevronDown size={16} aria-hidden /> : <ChevronRight size={16} aria-hidden />}
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="skill-tree__toggle-spacer" aria-hidden />
|
|
|
|
|
)}
|
2026-05-20 11:13:34 +02:00
|
|
|
<span
|
|
|
|
|
className={`skill-tree__group-label skill-tree__group-label--${node.type}${
|
|
|
|
|
node.type === 'skillGroup' ? ' skill-tree__group-label--skill-group' : ''
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{node.label}
|
|
|
|
|
</span>
|
2026-05-20 10:59:17 +02:00
|
|
|
</div>
|
|
|
|
|
{hasKids && isOpen ? (
|
|
|
|
|
<ul className="skill-tree__children" role={pickMode === 'multi' ? 'group' : 'tree'}>
|
|
|
|
|
<SkillTreeNodes
|
|
|
|
|
nodes={node.children}
|
|
|
|
|
depth={depth + 1}
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
exclude={exclude}
|
|
|
|
|
onToggle={onToggle}
|
|
|
|
|
onPickSkill={onPickSkill}
|
|
|
|
|
pickMode={pickMode}
|
|
|
|
|
/>
|
|
|
|
|
</ul>
|
|
|
|
|
) : null}
|
|
|
|
|
</li>
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-20 11:13:34 +02:00
|
|
|
* Ausklappbare Baumliste: Hauptgruppe → Kategorie (beide standard offen) → Fähigkeiten-Gruppe (standard zu).
|
2026-05-20 10:59:17 +02:00
|
|
|
*/
|
|
|
|
|
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)))
|
2026-05-20 11:13:34 +02:00
|
|
|
} else {
|
|
|
|
|
setExpanded(new Set(defaultExpandedKeysForSkillTree(displayTree)))
|
2026-05-20 10:59:17 +02:00
|
|
|
}
|
2026-05-20 11:13:34 +02:00
|
|
|
}, [searchQuery, displayTree])
|
2026-05-20 10:59:17 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-20 11:13:34 +02:00
|
|
|
if (!searchQuery.trim()) {
|
|
|
|
|
setExpanded(new Set(defaultExpandedKeysForSkillTree(fullTree)))
|
2026-05-20 11:05:27 +02:00
|
|
|
}
|
2026-05-20 11:13:34 +02:00
|
|
|
}, [fullTree, searchQuery])
|
2026-05-20 10:59:17 +02:00
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<ul className={`skill-tree skill-tree--flat-hits ${className}`.trim()} role="listbox">
|
|
|
|
|
{flatSearchHits.map((leaf) => (
|
|
|
|
|
<li key={leaf.id}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="skill-tree__pick skill-tree__pick--path"
|
|
|
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
|
|
|
onClick={() => onPickSkill(leaf.id, leaf.skill)}
|
|
|
|
|
>
|
|
|
|
|
<span className="skill-tree__pick-name">{leaf.label}</span>
|
|
|
|
|
<span className="skill-tree__pick-path">{leaf.pathLabel}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ul className={`skill-tree ${className}`.trim()} role="tree">
|
|
|
|
|
<SkillTreeNodes
|
|
|
|
|
nodes={displayTree}
|
|
|
|
|
depth={0}
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
exclude={exclude}
|
|
|
|
|
onToggle={onToggle}
|
|
|
|
|
onPickSkill={onPickSkill}
|
|
|
|
|
pickMode={pickMode}
|
|
|
|
|
/>
|
|
|
|
|
</ul>
|
|
|
|
|
)
|
|
|
|
|
}
|