Fähigkeitauswahl verbessert #41
|
|
@ -5182,6 +5182,144 @@ html.modal-scroll-locked .app-main {
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fähigkeiten-Katalog: Treeview-Auswahl */
|
||||||
|
.skill-tree-select {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 200px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.skill-tree-select__trigger {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.skill-tree-select__placeholder {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
.skill-tree-select__panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 120;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
max-height: min(360px, 55vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.skill-tree-select__search {
|
||||||
|
margin: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.skill-tree-select__tree-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0 4px 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.skill-tree-multiselect__panel {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 120;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
max-height: min(360px, 55vh);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.skill-tree {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.skill-tree__empty {
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.skill-tree__branch-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
.skill-tree__toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.skill-tree__toggle:hover {
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.skill-tree__toggle-spacer {
|
||||||
|
width: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.skill-tree__group-label {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.skill-tree__group-label--main {
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.skill-tree__children {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.skill-tree__pick {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
.skill-tree__pick:hover,
|
||||||
|
.skill-tree__pick:focus-visible {
|
||||||
|
background: var(--surface2);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.skill-tree__pick--path {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.skill-tree__pick-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.skill-tree__pick-path {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text3);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.multi-assoc-block {
|
.multi-assoc-block {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
splitMnCatalogRules,
|
splitMnCatalogRules,
|
||||||
splitScalarCatalogRules,
|
splitScalarCatalogRules,
|
||||||
} from '../constants/exerciseListFilters'
|
} from '../constants/exerciseListFilters'
|
||||||
import MultiSelectCombo from './MultiSelectCombo'
|
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
||||||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||||
import CatalogRulePicker from './CatalogRulePicker'
|
import CatalogRulePicker from './CatalogRulePicker'
|
||||||
|
|
||||||
|
|
@ -88,7 +88,7 @@ export default function ExercisePickerModal({
|
||||||
api.listStyleDirections(),
|
api.listStyleDirections(),
|
||||||
api.listTrainingTypes(),
|
api.listTrainingTypes(),
|
||||||
api.listTargetGroups(),
|
api.listTargetGroups(),
|
||||||
api.listSkills(),
|
api.listSkillsCatalog(),
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCatalogs({
|
setCatalogs({
|
||||||
|
|
@ -146,10 +146,6 @@ export default function ExercisePickerModal({
|
||||||
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
|
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
|
||||||
[catalogs.targetGroups]
|
[catalogs.targetGroups]
|
||||||
)
|
)
|
||||||
const skillOptions = useMemo(
|
|
||||||
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
|
|
||||||
[catalogs.skills]
|
|
||||||
)
|
|
||||||
const visibilityOptions = useMemo(
|
const visibilityOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ id: 'private', label: 'Privat' },
|
{ id: 'private', label: 'Privat' },
|
||||||
|
|
@ -506,10 +502,10 @@ export default function ExercisePickerModal({
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<label className="form-label">Fähigkeit</label>
|
<label className="form-label">Fähigkeit</label>
|
||||||
<MultiSelectCombo
|
<SkillTreeMultiSelect
|
||||||
value={filters.skill_ids}
|
value={filters.skill_ids}
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
|
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
|
||||||
options={skillOptions}
|
skills={catalogs.skills}
|
||||||
placeholder="Fähigkeit …"
|
placeholder="Fähigkeit …"
|
||||||
/>
|
/>
|
||||||
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
|
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
|
||||||
|
|
|
||||||
154
frontend/src/components/SkillTreeMultiSelect.jsx
Normal file
154
frontend/src/components/SkillTreeMultiSelect.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={`multiselect-combo skill-tree-multiselect ${className}`.trim()} ref={rootRef}>
|
||||||
|
<div className="multiselect-combo__chips">
|
||||||
|
{value.map((id, idx) => (
|
||||||
|
<button
|
||||||
|
key={`${normId(id)}-${idx}`}
|
||||||
|
type="button"
|
||||||
|
className="multiselect-combo__chip"
|
||||||
|
onClick={() => removeAt(idx)}
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
<span>{selectedLabels[idx]}</span>
|
||||||
|
<span className="multiselect-combo__chip-x" aria-hidden>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="multiselect-combo__field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input multiselect-combo__input"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
setBrowseTree(false)
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-expanded={open}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn multiselect-combo__browse"
|
||||||
|
title="Katalog als Baum"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true)
|
||||||
|
setBrowseTree(true)
|
||||||
|
setQuery('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{browseLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<div className="skill-tree-multiselect__panel">
|
||||||
|
{showTree ? (
|
||||||
|
<SkillTreePickerPanel
|
||||||
|
skills={skills}
|
||||||
|
excludeIds={value}
|
||||||
|
searchQuery={query}
|
||||||
|
onPickSkill={(id) => addId(id)}
|
||||||
|
pickMode="multi"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="multiselect-combo__list" role="listbox">
|
||||||
|
{leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length ===
|
||||||
|
0 ? (
|
||||||
|
<li className="multiselect-combo__empty">{emptyHint}</li>
|
||||||
|
) : (
|
||||||
|
leaves
|
||||||
|
.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
|
||||||
|
.map((l) => (
|
||||||
|
<li key={normId(l.id)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="multiselect-combo__opt skill-tree__pick--path"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => addId(l.id)}
|
||||||
|
>
|
||||||
|
<span className="skill-tree__pick-name">{l.label}</span>
|
||||||
|
<span className="skill-tree__pick-path">{l.pathLabel}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
frontend/src/components/SkillTreePickerPanel.jsx
Normal file
163
frontend/src/components/SkillTreePickerPanel.jsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
allExpandableKeys,
|
||||||
|
buildSkillCatalogTree,
|
||||||
|
collectSkillLeavesFromTree,
|
||||||
|
defaultExpandedKeysForSkillTree,
|
||||||
|
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 />
|
||||||
|
)}
|
||||||
|
<span className={`skill-tree__group-label skill-tree__group-label--${node.type}`}>
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausklappbare Baumliste: Hauptgruppe → Kategorie → Fähigkeit (nur Hauptgruppe standard offen).
|
||||||
|
*/
|
||||||
|
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)))
|
||||||
|
} else {
|
||||||
|
setExpanded(new Set(defaultExpandedKeysForSkillTree(displayTree)))
|
||||||
|
}
|
||||||
|
}, [searchQuery, displayTree])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setExpanded(new Set(defaultExpandedKeysForSkillTree(fullTree)))
|
||||||
|
}
|
||||||
|
}, [fullTree, searchQuery])
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
frontend/src/components/SkillTreeSelect.jsx
Normal file
84
frontend/src/components/SkillTreeSelect.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={`skill-tree-select ${className}`.trim()} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-input skill-tree-select__trigger"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => !disabled && setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<span className={displayLabel ? '' : 'skill-tree-select__placeholder'}>
|
||||||
|
{displayLabel || placeholder}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="skill-tree-select__panel">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-input skill-tree-select__search"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div className="skill-tree-select__tree-wrap">
|
||||||
|
<SkillTreePickerPanel
|
||||||
|
skills={skills}
|
||||||
|
excludeIds={excludeIds}
|
||||||
|
searchQuery={query}
|
||||||
|
onPickSkill={pick}
|
||||||
|
pickMode="single"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
|
import SkillTreeSelect from '../SkillTreeSelect'
|
||||||
|
|
||||||
export default function MaturityModelsAdminPanel() {
|
export default function MaturityModelsAdminPanel() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -39,7 +40,7 @@ export default function MaturityModelsAdminPanel() {
|
||||||
try {
|
try {
|
||||||
const [m, sk] = await Promise.all([
|
const [m, sk] = await Promise.all([
|
||||||
api.listMaturityModels({}),
|
api.listMaturityModels({}),
|
||||||
api.listSkills({ status: 'active' })
|
api.listSkillsCatalog({ status: 'active' })
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setModels(m)
|
setModels(m)
|
||||||
|
|
@ -267,7 +268,7 @@ export default function MaturityModelsAdminPanel() {
|
||||||
? null
|
? null
|
||||||
: Number(skillWikiForm.relevance_level)
|
: Number(skillWikiForm.relevance_level)
|
||||||
})
|
})
|
||||||
const sk = await api.listSkills({ status: 'active' })
|
const sk = await api.listSkillsCatalog({ status: 'active' })
|
||||||
setAllSkills(sk)
|
setAllSkills(sk)
|
||||||
closeSkillWikiModal()
|
closeSkillWikiModal()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -585,16 +586,13 @@ export default function MaturityModelsAdminPanel() {
|
||||||
<div className="admin-matrix-skill-add">
|
<div className="admin-matrix-skill-add">
|
||||||
<div className="admin-matrix-skill-add__select">
|
<div className="admin-matrix-skill-add__select">
|
||||||
<label className="form-label">Fähigkeit hinzufügen</label>
|
<label className="form-label">Fähigkeit hinzufügen</label>
|
||||||
<select
|
<SkillTreeSelect
|
||||||
className="form-input"
|
|
||||||
value={skillToAdd}
|
value={skillToAdd}
|
||||||
onChange={(e) => setSkillToAdd(e.target.value)}
|
onChange={setSkillToAdd}
|
||||||
>
|
skills={allSkills}
|
||||||
<option value="">— wählen —</option>
|
excludeIds={(detail.model_skills || []).map((ms) => ms.skill_id)}
|
||||||
{allSkills.map((s) => (
|
placeholder="— wählen —"
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
/>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
|
import {
|
||||||
|
catalogSkillsForSelection,
|
||||||
|
countCatalogSkillsWithoutCategory,
|
||||||
|
countCatalogSkillsWithoutMain,
|
||||||
|
SKILL_CATEGORY_UNASSIGNED_KEY,
|
||||||
|
SKILL_MAIN_UNASSIGNED_KEY,
|
||||||
|
SKILL_UNASSIGNED_CATEGORY_LABEL,
|
||||||
|
SKILL_UNASSIGNED_MAIN_LABEL,
|
||||||
|
} from '../../utils/skillCatalogTree'
|
||||||
|
|
||||||
function bySortThenName(a, b) {
|
function bySortThenName(a, b) {
|
||||||
const sa = a.sort_order != null ? Number(a.sort_order) : 99999
|
const sa = a.sort_order != null ? Number(a.sort_order) : 99999
|
||||||
|
|
@ -72,6 +81,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
sort_order: '',
|
sort_order: '',
|
||||||
category_id: '',
|
category_id: '',
|
||||||
|
main_category_id: '',
|
||||||
karate_relevance: '',
|
karate_relevance: '',
|
||||||
relevance_level: ''
|
relevance_level: ''
|
||||||
})
|
})
|
||||||
|
|
@ -84,7 +94,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
const [editDialog, setEditDialog] = useState(null)
|
const [editDialog, setEditDialog] = useState(null)
|
||||||
|
|
||||||
const refreshCategories = useCallback(async (mainId) => {
|
const refreshCategories = useCallback(async (mainId) => {
|
||||||
if (mainId == null) {
|
if (mainId == null || mainId === SKILL_MAIN_UNASSIGNED_KEY || typeof mainId !== 'number') {
|
||||||
setCategories([])
|
setCategories([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -128,12 +138,19 @@ export default function SkillsCatalogAdmin() {
|
||||||
|
|
||||||
const sortedCategories = useMemo(() => [...categories].sort(bySortThenName), [categories])
|
const sortedCategories = useMemo(() => [...categories].sort(bySortThenName), [categories])
|
||||||
|
|
||||||
const skillsInCategory = useMemo(() => {
|
const unassignedMainCount = useMemo(() => countCatalogSkillsWithoutMain(catalog), [catalog])
|
||||||
|
|
||||||
|
const unassignedCategoryCount = useMemo(
|
||||||
|
() => countCatalogSkillsWithoutCategory(catalog, selectedMainId),
|
||||||
|
[catalog, selectedMainId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const skillsInSelection = useMemo(() => {
|
||||||
if (selectedCategoryId == null) return []
|
if (selectedCategoryId == null) return []
|
||||||
return catalog
|
return [...catalogSkillsForSelection(catalog, selectedMainId, selectedCategoryId)].sort(
|
||||||
.filter((s) => s.category_id === selectedCategoryId)
|
bySortThenName
|
||||||
.sort(bySortThenName)
|
)
|
||||||
}, [catalog, selectedCategoryId])
|
}, [catalog, selectedMainId, selectedCategoryId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editDialog) return
|
if (!editDialog) return
|
||||||
|
|
@ -179,6 +196,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
status: s.status || 'active',
|
status: s.status || 'active',
|
||||||
sort_order: s.sort_order ?? '',
|
sort_order: s.sort_order ?? '',
|
||||||
category_id: s.category_id ?? '',
|
category_id: s.category_id ?? '',
|
||||||
|
main_category_id: s.main_category_id ?? '',
|
||||||
karate_relevance: s.karate_relevance || '',
|
karate_relevance: s.karate_relevance || '',
|
||||||
relevance_level:
|
relevance_level:
|
||||||
s.relevance_level != null && s.relevance_level !== ''
|
s.relevance_level != null && s.relevance_level !== ''
|
||||||
|
|
@ -199,7 +217,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
try {
|
try {
|
||||||
await op()
|
await op()
|
||||||
await bootstrap()
|
await bootstrap()
|
||||||
if (selectedMainId) await refreshCategories(selectedMainId)
|
if (typeof selectedMainId === 'number') await refreshCategories(selectedMainId)
|
||||||
setMessage('Gespeichert.')
|
setMessage('Gespeichert.')
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -238,7 +256,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSwapSkill(rowId, delta) {
|
async function handleSwapSkill(rowId, delta) {
|
||||||
const sorted = [...skillsInCategory].sort(bySortThenName)
|
const sorted = [...skillsInSelection].sort(bySortThenName)
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data))
|
await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data))
|
||||||
})
|
})
|
||||||
|
|
@ -292,16 +310,18 @@ export default function SkillsCatalogAdmin() {
|
||||||
async function handleSaveSkill(e) {
|
async function handleSaveSkill(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!editDialog || editDialog.type !== 'skill') return
|
if (!editDialog || editDialog.type !== 'skill') return
|
||||||
let cid =
|
const cid =
|
||||||
skillForm.category_id === '' || skillForm.category_id == null
|
skillForm.category_id === '' || skillForm.category_id == null
|
||||||
? null
|
? null
|
||||||
: Number(skillForm.category_id)
|
: Number(skillForm.category_id)
|
||||||
const skillRow = catalog.find((s) => s.id === editDialog.id)
|
const mid =
|
||||||
if (cid == null && skillRow?.category_id) {
|
cid == null &&
|
||||||
cid = skillRow.category_id
|
skillForm.main_category_id !== '' &&
|
||||||
}
|
skillForm.main_category_id != null
|
||||||
|
? Number(skillForm.main_category_id)
|
||||||
|
: null
|
||||||
const ok = await run(async () => {
|
const ok = await run(async () => {
|
||||||
await api.updateSkill(editDialog.id, {
|
const payload = {
|
||||||
name: skillForm.name.trim(),
|
name: skillForm.name.trim(),
|
||||||
description: skillForm.description || null,
|
description: skillForm.description || null,
|
||||||
category: skillForm.category || null,
|
category: skillForm.category || null,
|
||||||
|
|
@ -319,16 +339,26 @@ export default function SkillsCatalogAdmin() {
|
||||||
relevance_level:
|
relevance_level:
|
||||||
skillForm.relevance_level === '' || skillForm.relevance_level == null
|
skillForm.relevance_level === '' || skillForm.relevance_level == null
|
||||||
? null
|
? null
|
||||||
: Number(skillForm.relevance_level)
|
: Number(skillForm.relevance_level),
|
||||||
})
|
}
|
||||||
|
if (cid == null) {
|
||||||
|
payload.main_category_id = mid
|
||||||
|
}
|
||||||
|
await api.updateSkill(editDialog.id, payload)
|
||||||
})
|
})
|
||||||
if (ok) {
|
if (ok) {
|
||||||
if (cid != null && cid !== selectedCategoryId) {
|
if (cid != null) {
|
||||||
const cat = allCategories.find((c) => c.id === cid)
|
const cat = allCategories.find((c) => c.id === cid)
|
||||||
if (cat?.main_category_id) {
|
if (cat?.main_category_id) {
|
||||||
setSelectedMainId(cat.main_category_id)
|
setSelectedMainId(cat.main_category_id)
|
||||||
setSelectedCategoryId(cid)
|
setSelectedCategoryId(cid)
|
||||||
}
|
}
|
||||||
|
} else if (mid == null) {
|
||||||
|
setSelectedMainId(SKILL_MAIN_UNASSIGNED_KEY)
|
||||||
|
setSelectedCategoryId(SKILL_CATEGORY_UNASSIGNED_KEY)
|
||||||
|
} else {
|
||||||
|
setSelectedMainId(mid)
|
||||||
|
setSelectedCategoryId(SKILL_CATEGORY_UNASSIGNED_KEY)
|
||||||
}
|
}
|
||||||
closeEditDialog()
|
closeEditDialog()
|
||||||
}
|
}
|
||||||
|
|
@ -347,7 +377,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
|
|
||||||
async function handleCreateCategory(e) {
|
async function handleCreateCategory(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (selectedMainId == null) return
|
if (selectedMainId == null || selectedMainId === SKILL_MAIN_UNASSIGNED_KEY) return
|
||||||
const name = newCategoryName.trim()
|
const name = newCategoryName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
|
|
@ -367,11 +397,17 @@ export default function SkillsCatalogAdmin() {
|
||||||
const name = newSkillName.trim()
|
const name = newSkillName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const created = await api.createSkill({
|
const body = { name, status: 'active' }
|
||||||
name,
|
if (typeof selectedCategoryId === 'number') {
|
||||||
category_id: selectedCategoryId,
|
body.category_id = selectedCategoryId
|
||||||
status: 'active'
|
} else if (selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY) {
|
||||||
})
|
body.category_id = null
|
||||||
|
body.main_category_id =
|
||||||
|
typeof selectedMainId === 'number' ? selectedMainId : null
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const created = await api.createSkill(body)
|
||||||
setNewSkillName('')
|
setNewSkillName('')
|
||||||
setSelectedSkillId(created.id)
|
setSelectedSkillId(created.id)
|
||||||
})
|
})
|
||||||
|
|
@ -429,8 +465,9 @@ export default function SkillsCatalogAdmin() {
|
||||||
<div className="skills-catalog-admin">
|
<div className="skills-catalog-admin">
|
||||||
<p className="skills-catalog-admin__intro muted">
|
<p className="skills-catalog-admin__intro muted">
|
||||||
Struktur: <strong>Hauptkategorie</strong> → <strong>Kategorie</strong> →{' '}
|
Struktur: <strong>Hauptkategorie</strong> → <strong>Kategorie</strong> →{' '}
|
||||||
<strong>Fähigkeit</strong>. Reihenfolge mit Pfeilen; Details über das Stift-Symbol (öffnet
|
<strong>Fähigkeit</strong>. Fähigkeiten ohne Zuordnung finden Sie unter{' '}
|
||||||
ein Fenster — auf dem iPhone nach unten scrollen, falls nötig).
|
<strong>{SKILL_UNASSIGNED_MAIN_LABEL}</strong> bzw. <strong>{SKILL_UNASSIGNED_CATEGORY_LABEL}</strong>{' '}
|
||||||
|
(wie in der Auswahlbox). Reihenfolge mit Pfeilen; Details über das Stift-Symbol.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -455,6 +492,26 @@ export default function SkillsCatalogAdmin() {
|
||||||
<section className="skills-catalog-column" aria-label="Hauptkategorien">
|
<section className="skills-catalog-column" aria-label="Hauptkategorien">
|
||||||
<h3 className="skills-catalog-column__title">Hauptkategorien</h3>
|
<h3 className="skills-catalog-column__title">Hauptkategorien</h3>
|
||||||
<ul className="skills-catalog-list">
|
<ul className="skills-catalog-list">
|
||||||
|
{unassignedMainCount > 0 ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'skills-catalog-row' +
|
||||||
|
(selectedMainId === SKILL_MAIN_UNASSIGNED_KEY
|
||||||
|
? ' skills-catalog-row--active'
|
||||||
|
: '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="skills-catalog-row__label"
|
||||||
|
onClick={() => selectMain(SKILL_MAIN_UNASSIGNED_KEY)}
|
||||||
|
>
|
||||||
|
{SKILL_UNASSIGNED_MAIN_LABEL} ({unassignedMainCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
{sortedMains.map((m) => (
|
{sortedMains.map((m) => (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -534,9 +591,57 @@ export default function SkillsCatalogAdmin() {
|
||||||
<h3 className="skills-catalog-column__title">Kategorien</h3>
|
<h3 className="skills-catalog-column__title">Kategorien</h3>
|
||||||
{selectedMainId == null ? (
|
{selectedMainId == null ? (
|
||||||
<p className="muted skills-catalog-placeholder">Zuerst eine Hauptkategorie wählen.</p>
|
<p className="muted skills-catalog-placeholder">Zuerst eine Hauptkategorie wählen.</p>
|
||||||
|
) : selectedMainId === SKILL_MAIN_UNASSIGNED_KEY ? (
|
||||||
|
<>
|
||||||
|
<ul className="skills-catalog-list">
|
||||||
|
{unassignedCategoryCount > 0 ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'skills-catalog-row' +
|
||||||
|
(selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY
|
||||||
|
? ' skills-catalog-row--active'
|
||||||
|
: '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="skills-catalog-row__label"
|
||||||
|
onClick={() => selectCategory(SKILL_CATEGORY_UNASSIGNED_KEY)}
|
||||||
|
>
|
||||||
|
{SKILL_UNASSIGNED_CATEGORY_LABEL} ({unassignedCategoryCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
<p className="muted skills-catalog-placeholder">
|
||||||
|
Unter {SKILL_UNASSIGNED_MAIN_LABEL} gibt es keine Unterkategorien.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className="skills-catalog-list">
|
<ul className="skills-catalog-list">
|
||||||
|
{unassignedCategoryCount > 0 ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'skills-catalog-row' +
|
||||||
|
(selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY
|
||||||
|
? ' skills-catalog-row--active'
|
||||||
|
: '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="skills-catalog-row__label"
|
||||||
|
onClick={() => selectCategory(SKILL_CATEGORY_UNASSIGNED_KEY)}
|
||||||
|
>
|
||||||
|
{SKILL_UNASSIGNED_CATEGORY_LABEL} ({unassignedCategoryCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
{sortedCategories.map((c) => (
|
{sortedCategories.map((c) => (
|
||||||
<li key={c.id}>
|
<li key={c.id}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -617,11 +722,13 @@ export default function SkillsCatalogAdmin() {
|
||||||
<section className="skills-catalog-column" aria-label="Fähigkeiten">
|
<section className="skills-catalog-column" aria-label="Fähigkeiten">
|
||||||
<h3 className="skills-catalog-column__title">Fähigkeiten</h3>
|
<h3 className="skills-catalog-column__title">Fähigkeiten</h3>
|
||||||
{selectedCategoryId == null ? (
|
{selectedCategoryId == null ? (
|
||||||
<p className="muted skills-catalog-placeholder">Zuerst eine Kategorie wählen.</p>
|
<p className="muted skills-catalog-placeholder">
|
||||||
|
Zuerst eine Kategorie oder „{SKILL_UNASSIGNED_CATEGORY_LABEL}“ wählen.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className="skills-catalog-list">
|
<ul className="skills-catalog-list">
|
||||||
{skillsInCategory.map((s) => (
|
{skillsInSelection.map((s) => (
|
||||||
<li key={s.id}>
|
<li key={s.id}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|
@ -897,14 +1004,41 @@ export default function SkillsCatalogAdmin() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
required
|
|
||||||
>
|
>
|
||||||
|
<option value="">{SKILL_UNASSIGNED_CATEGORY_LABEL}</option>
|
||||||
{allCategories.map((c) => (
|
{allCategories.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
|
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{skillForm.category_id === '' || skillForm.category_id == null ? (
|
||||||
|
<>
|
||||||
|
<label className="form-label">Hauptkategorie</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={
|
||||||
|
skillForm.main_category_id === '' || skillForm.main_category_id == null
|
||||||
|
? ''
|
||||||
|
: String(skillForm.main_category_id)
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSkillForm((f) => ({
|
||||||
|
...f,
|
||||||
|
main_category_id: e.target.value === '' ? '' : e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<option value="">{SKILL_UNASSIGNED_MAIN_LABEL}</option>
|
||||||
|
{sortedMains.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<label className="form-label">Legacy-Kurzlabel „category“ (optional)</label>
|
<label className="form-label">Legacy-Kurzlabel „category“ (optional)</label>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
buildExerciseMediaDragPayload,
|
buildExerciseMediaDragPayload,
|
||||||
} from '../../utils/exerciseInlineMediaRefs'
|
} from '../../utils/exerciseInlineMediaRefs'
|
||||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||||
|
import SkillTreeSelect from '../SkillTreeSelect'
|
||||||
|
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { useToast } from '../../context/ToastContext'
|
import { useToast } from '../../context/ToastContext'
|
||||||
|
|
@ -609,7 +611,7 @@ function ExerciseFormPageRoot() {
|
||||||
const boot = async () => {
|
const boot = async () => {
|
||||||
try {
|
try {
|
||||||
const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
|
const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
|
||||||
api.listSkills(),
|
api.listSkillsCatalog(),
|
||||||
api.listFocusAreas(),
|
api.listFocusAreas(),
|
||||||
api.listTrainingStyles(),
|
api.listTrainingStyles(),
|
||||||
api.listTrainingTypes(),
|
api.listTrainingTypes(),
|
||||||
|
|
@ -1184,8 +1186,6 @@ function ExerciseFormPageRoot() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
|
|
||||||
|
|
||||||
const selectedVariantForEdit =
|
const selectedVariantForEdit =
|
||||||
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
|
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
|
||||||
const selectedVariantIdx = selectedVariantForEdit
|
const selectedVariantIdx = selectedVariantForEdit
|
||||||
|
|
@ -1856,20 +1856,14 @@ function ExerciseFormPageRoot() {
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
|
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px', alignItems: 'stretch' }}>
|
||||||
<select
|
<SkillTreeSelect
|
||||||
className="form-input"
|
|
||||||
style={{ flex: '1 1 200px' }}
|
|
||||||
value={skillPick}
|
value={skillPick}
|
||||||
onChange={(e) => setSkillPick(e.target.value)}
|
onChange={setSkillPick}
|
||||||
>
|
skills={skillsCatalog}
|
||||||
<option value="">Fähigkeit wählen…</option>
|
excludeIds={formData.skills.map((s) => s.skill_id)}
|
||||||
{availableSkills.map((s) => (
|
placeholder="Fähigkeit wählen…"
|
||||||
<option key={s.id} value={s.id}>
|
/>
|
||||||
{s.name} ({s.category})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
|
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
|
||||||
Hinzufügen
|
Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1880,11 +1874,11 @@ function ExerciseFormPageRoot() {
|
||||||
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
|
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
|
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
|
||||||
{sk?.category && (
|
{sk ? (
|
||||||
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
|
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
|
||||||
{sk.category}
|
{skillCatalogPathLabel(sk)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
|
||||||
import MultiSelectCombo from '../MultiSelectCombo'
|
import SkillTreeMultiSelect from '../SkillTreeMultiSelect'
|
||||||
import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker'
|
||||||
import CatalogRulePicker from '../CatalogRulePicker'
|
import CatalogRulePicker from '../CatalogRulePicker'
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ export default function ExerciseListFilterModal({
|
||||||
styleOptions,
|
styleOptions,
|
||||||
trainingTypeOptions,
|
trainingTypeOptions,
|
||||||
targetGroupOptions,
|
targetGroupOptions,
|
||||||
skillOptions,
|
skillsCatalog = [],
|
||||||
visibilityOptions,
|
visibilityOptions,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
savingExercisePrefs,
|
savingExercisePrefs,
|
||||||
|
|
@ -103,10 +103,10 @@ export default function ExerciseListFilterModal({
|
||||||
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
|
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
|
||||||
<div className="exercise-filter-skill-block">
|
<div className="exercise-filter-skill-block">
|
||||||
<label className="form-label">Fähigkeit</label>
|
<label className="form-label">Fähigkeit</label>
|
||||||
<MultiSelectCombo
|
<SkillTreeMultiSelect
|
||||||
value={filters.skill_ids}
|
value={filters.skill_ids}
|
||||||
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
||||||
options={skillOptions}
|
skills={skillsCatalog}
|
||||||
placeholder="Fähigkeit suchen …"
|
placeholder="Fähigkeit suchen …"
|
||||||
/>
|
/>
|
||||||
<p className="exercise-filter-skill-hint">
|
<p className="exercise-filter-skill-hint">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import ExercisePeekModal from '../ExercisePeekModal'
|
||||||
import NavStateLink from '../NavStateLink'
|
import NavStateLink from '../NavStateLink'
|
||||||
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
||||||
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
||||||
|
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||||
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
||||||
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
||||||
import {
|
import {
|
||||||
|
|
@ -186,7 +187,11 @@ function ExercisesListPageRoot() {
|
||||||
[catalogs.targetGroups]
|
[catalogs.targetGroups]
|
||||||
)
|
)
|
||||||
const skillOptions = useMemo(
|
const skillOptions = useMemo(
|
||||||
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
|
() =>
|
||||||
|
catalogs.skills.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: skillCatalogPathLabel(s) || s.name || String(s.id),
|
||||||
|
})),
|
||||||
[catalogs.skills]
|
[catalogs.skills]
|
||||||
)
|
)
|
||||||
const visibilityOptions = useMemo(
|
const visibilityOptions = useMemo(
|
||||||
|
|
@ -549,7 +554,7 @@ function ExercisesListPageRoot() {
|
||||||
styleOptions={styleOptions}
|
styleOptions={styleOptions}
|
||||||
trainingTypeOptions={trainingTypeOptions}
|
trainingTypeOptions={trainingTypeOptions}
|
||||||
targetGroupOptions={targetGroupOptions}
|
targetGroupOptions={targetGroupOptions}
|
||||||
skillOptions={skillOptions}
|
skillsCatalog={catalogs.skills}
|
||||||
visibilityOptions={visibilityOptions}
|
visibilityOptions={visibilityOptions}
|
||||||
statusOptions={statusOptions}
|
statusOptions={statusOptions}
|
||||||
savingExercisePrefs={savingExercisePrefs}
|
savingExercisePrefs={savingExercisePrefs}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
|
||||||
api.listStyleDirections(),
|
api.listStyleDirections(),
|
||||||
api.listTrainingTypes(),
|
api.listTrainingTypes(),
|
||||||
api.listTargetGroups(),
|
api.listTargetGroups(),
|
||||||
api.listSkills(),
|
api.listSkillsCatalog(),
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCatalogs({
|
setCatalogs({
|
||||||
|
|
|
||||||
217
frontend/src/utils/skillCatalogTree.js
Normal file
217
frontend/src/utils/skillCatalogTree.js
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
/**
|
||||||
|
* Hierarchie Fähigkeiten-Katalog: Hauptgruppe → Kategorie → Fähigkeit.
|
||||||
|
* Datenbasis: GET /api/skills/catalog (catalog_* Felder).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function bySortThenName(a, b) {
|
||||||
|
const sa = a.sort_order != null ? Number(a.sort_order) : 99999
|
||||||
|
const sb = b.sort_order != null ? Number(b.sort_order) : 99999
|
||||||
|
if (sa !== sb) return sa - sb
|
||||||
|
return String(a.name || a.label || '').localeCompare(String(b.name || b.label || ''), 'de')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {object} skill */
|
||||||
|
export function skillCatalogPathLabel(skill) {
|
||||||
|
if (!skill || typeof skill !== 'object') return ''
|
||||||
|
const parts = []
|
||||||
|
const main = (skill.catalog_main_category_name || '').trim()
|
||||||
|
const cat = (skill.catalog_category_name || '').trim()
|
||||||
|
if (main) parts.push(main)
|
||||||
|
if (cat) parts.push(cat)
|
||||||
|
parts.push((skill.name || '').trim() || `#${skill.id}`)
|
||||||
|
return parts.join(' › ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object[]} skills
|
||||||
|
* @returns {Array<{ key: string, type: string, label: string, skillId?: number, skill?: object, children?: object[] }>}
|
||||||
|
*/
|
||||||
|
export function buildSkillCatalogTree(skills) {
|
||||||
|
if (!Array.isArray(skills) || skills.length === 0) return []
|
||||||
|
|
||||||
|
/** @type {Map<string, { key: string, type: string, label: string, sortOrder: number, children: Map<string, object> }>} */
|
||||||
|
const mainMap = new Map()
|
||||||
|
|
||||||
|
for (const skill of skills) {
|
||||||
|
const mainId = skill.main_category_id ?? null
|
||||||
|
const mainKey = mainId != null ? `m-${mainId}` : 'm-none'
|
||||||
|
const mainLabel = (skill.catalog_main_category_name || '').trim() || 'Ohne Hauptgruppe'
|
||||||
|
const mainSort = Number(skill.catalog_main_sort ?? 99999)
|
||||||
|
|
||||||
|
if (!mainMap.has(mainKey)) {
|
||||||
|
mainMap.set(mainKey, {
|
||||||
|
key: mainKey,
|
||||||
|
type: 'main',
|
||||||
|
label: mainLabel,
|
||||||
|
sortOrder: mainSort,
|
||||||
|
children: new Map(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const mainNode = mainMap.get(mainKey)
|
||||||
|
|
||||||
|
const catId = skill.category_id ?? null
|
||||||
|
const catKey = catId != null ? `c-${catId}` : 'c-none'
|
||||||
|
const catLabel = (skill.catalog_category_name || '').trim() || 'Ohne Kategorie'
|
||||||
|
const catSort = Number(skill.catalog_category_sort ?? 99999)
|
||||||
|
|
||||||
|
if (!mainNode.children.has(catKey)) {
|
||||||
|
mainNode.children.set(catKey, {
|
||||||
|
key: `${mainKey}-${catKey}`,
|
||||||
|
type: 'category',
|
||||||
|
label: catLabel,
|
||||||
|
sortOrder: catSort,
|
||||||
|
skills: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mainNode.children.get(catKey).skills.push(skill)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...mainMap.values()]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||||
|
return a.label.localeCompare(b.label, 'de')
|
||||||
|
})
|
||||||
|
.map((main) => ({
|
||||||
|
key: main.key,
|
||||||
|
type: main.type,
|
||||||
|
label: main.label,
|
||||||
|
children: [...main.children.values()]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||||||
|
return a.label.localeCompare(b.label, 'de')
|
||||||
|
})
|
||||||
|
.map((cat) => ({
|
||||||
|
key: cat.key,
|
||||||
|
type: cat.type,
|
||||||
|
label: cat.label,
|
||||||
|
children: [...cat.skills].sort(bySortThenName).map((skill) => ({
|
||||||
|
key: `s-${skill.id}`,
|
||||||
|
type: 'skill',
|
||||||
|
label: (skill.name || '').trim() || `#${skill.id}`,
|
||||||
|
skillId: Number(skill.id),
|
||||||
|
skill,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle wählbaren Blätter mit Pfad-Label (für Suche / Chips).
|
||||||
|
* @param {ReturnType<typeof buildSkillCatalogTree>} tree
|
||||||
|
* @param {Set<number>|number[]} [excludeIds]
|
||||||
|
*/
|
||||||
|
export function collectSkillLeavesFromTree(tree, excludeIds) {
|
||||||
|
const exclude = new Set(
|
||||||
|
(excludeIds instanceof Set ? [...excludeIds] : excludeIds || [])
|
||||||
|
.map((x) => Number(x))
|
||||||
|
.filter((n) => Number.isFinite(n))
|
||||||
|
)
|
||||||
|
/** @type {Array<{ id: number, label: string, pathLabel: string, skill: object }>} */
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
const walk = (nodes, pathParts) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type === 'skill' && n.skillId != null) {
|
||||||
|
if (!exclude.has(n.skillId)) {
|
||||||
|
const pathLabel = [...pathParts, n.label].join(' › ')
|
||||||
|
out.push({ id: n.skillId, label: n.label, pathLabel, skill: n.skill })
|
||||||
|
}
|
||||||
|
} else if (n.children?.length) {
|
||||||
|
walk(n.children, [...pathParts, n.label])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(tree, [])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {ReturnType<typeof buildSkillCatalogTree>} tree */
|
||||||
|
export function filterSkillTreeByQuery(tree, query) {
|
||||||
|
const q = String(query || '').trim().toLowerCase()
|
||||||
|
if (!q) return tree
|
||||||
|
|
||||||
|
const filterNodes = (nodes) => {
|
||||||
|
/** @type {typeof nodes} */
|
||||||
|
const result = []
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type === 'skill') {
|
||||||
|
const hay = `${n.label} ${skillCatalogPathLabel(n.skill)}`.toLowerCase()
|
||||||
|
if (hay.includes(q)) result.push(n)
|
||||||
|
} else {
|
||||||
|
const kids = filterNodes(n.children || [])
|
||||||
|
if (kids.length) result.push({ ...n, children: kids })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return filterNodes(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle aufklappbaren Knoten (Hauptgruppe und Kategorie). */
|
||||||
|
export function allExpandableKeys(tree) {
|
||||||
|
const keys = []
|
||||||
|
const walk = (nodes) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type !== 'skill' && n.children?.length) {
|
||||||
|
keys.push(n.key)
|
||||||
|
walk(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(tree)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard: nur Hauptgruppe aufgeklappt; Kategorien und Fähigkeiten zugeklappt. */
|
||||||
|
export function defaultExpandedKeysForSkillTree(tree) {
|
||||||
|
if (!Array.isArray(tree)) return []
|
||||||
|
return tree.filter((n) => n.type === 'main').map((n) => n.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sentinel für Katalog-Admin: keine Hauptgruppe gewählt. */
|
||||||
|
export const SKILL_MAIN_UNASSIGNED_KEY = '__unassigned_main__'
|
||||||
|
/** Sentinel für Katalog-Admin: keine Kategorie gewählt. */
|
||||||
|
export const SKILL_CATEGORY_UNASSIGNED_KEY = '__unassigned_category__'
|
||||||
|
export const SKILL_UNASSIGNED_MAIN_LABEL = 'Ohne Hauptgruppe'
|
||||||
|
export const SKILL_UNASSIGNED_CATEGORY_LABEL = 'Ohne Kategorie'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fähigkeiten für die Admin-Katalog-Auswahl (Hauptgruppe + Kategorie-Slot).
|
||||||
|
* @param {object[]} skills
|
||||||
|
* @param {number|string|null} mainId
|
||||||
|
* @param {number|string|null} categoryId
|
||||||
|
*/
|
||||||
|
export function catalogSkillsForSelection(skills, mainId, categoryId) {
|
||||||
|
if (!Array.isArray(skills) || categoryId == null) return []
|
||||||
|
if (categoryId === SKILL_CATEGORY_UNASSIGNED_KEY) {
|
||||||
|
if (mainId === SKILL_MAIN_UNASSIGNED_KEY) {
|
||||||
|
return skills.filter((s) => s.main_category_id == null && s.category_id == null)
|
||||||
|
}
|
||||||
|
if (typeof mainId === 'number') {
|
||||||
|
return skills.filter((s) => s.main_category_id === mainId && s.category_id == null)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (typeof categoryId === 'number') {
|
||||||
|
return skills.filter((s) => s.category_id === categoryId)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {object[]} skills */
|
||||||
|
export function countCatalogSkillsWithoutMain(skills) {
|
||||||
|
if (!Array.isArray(skills)) return 0
|
||||||
|
return skills.filter((s) => s.main_category_id == null).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {object[]} skills @param {number|string|null} mainId */
|
||||||
|
export function countCatalogSkillsWithoutCategory(skills, mainId) {
|
||||||
|
if (!Array.isArray(skills)) return 0
|
||||||
|
if (mainId === SKILL_MAIN_UNASSIGNED_KEY) {
|
||||||
|
return skills.filter((s) => s.main_category_id == null && s.category_id == null).length
|
||||||
|
}
|
||||||
|
if (typeof mainId === 'number') {
|
||||||
|
return skills.filter((s) => s.main_category_id === mainId && s.category_id == null).length
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
95
frontend/src/utils/skillCatalogTree.test.js
Normal file
95
frontend/src/utils/skillCatalogTree.test.js
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildSkillCatalogTree,
|
||||||
|
catalogSkillsForSelection,
|
||||||
|
collectSkillLeavesFromTree,
|
||||||
|
countCatalogSkillsWithoutCategory,
|
||||||
|
countCatalogSkillsWithoutMain,
|
||||||
|
defaultExpandedKeysForSkillTree,
|
||||||
|
filterSkillTreeByQuery,
|
||||||
|
SKILL_CATEGORY_UNASSIGNED_KEY,
|
||||||
|
SKILL_MAIN_UNASSIGNED_KEY,
|
||||||
|
skillCatalogPathLabel,
|
||||||
|
} from './skillCatalogTree.js'
|
||||||
|
|
||||||
|
const sample = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Dachi Waza',
|
||||||
|
main_category_id: 10,
|
||||||
|
category_id: 20,
|
||||||
|
catalog_main_category_name: 'Karate',
|
||||||
|
catalog_main_sort: 1,
|
||||||
|
catalog_category_name: 'Kihon',
|
||||||
|
catalog_category_sort: 2,
|
||||||
|
sort_order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Kata Ablauf',
|
||||||
|
main_category_id: 10,
|
||||||
|
category_id: 21,
|
||||||
|
catalog_main_category_name: 'Karate',
|
||||||
|
catalog_main_sort: 1,
|
||||||
|
catalog_category_name: 'Kata',
|
||||||
|
catalog_category_sort: 1,
|
||||||
|
sort_order: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('skillCatalogTree', () => {
|
||||||
|
it('buildSkillCatalogTree groups by main and category', () => {
|
||||||
|
const tree = buildSkillCatalogTree(sample)
|
||||||
|
expect(tree).toHaveLength(1)
|
||||||
|
expect(tree[0].label).toBe('Karate')
|
||||||
|
expect(tree[0].children).toHaveLength(2)
|
||||||
|
expect(tree[0].children[0].label).toBe('Kata')
|
||||||
|
const kataSkill = tree[0].children[0].children[0]
|
||||||
|
expect(kataSkill.type).toBe('skill')
|
||||||
|
expect(kataSkill.skillId).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaultExpandedKeysForSkillTree opens main groups only', () => {
|
||||||
|
const tree = buildSkillCatalogTree(sample)
|
||||||
|
const keys = defaultExpandedKeysForSkillTree(tree)
|
||||||
|
expect(keys).toEqual(['m-10'])
|
||||||
|
expect(keys.some((k) => k.includes('c-'))).toBe(false)
|
||||||
|
expect(keys.some((k) => k.startsWith('s-'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skillCatalogPathLabel', () => {
|
||||||
|
expect(skillCatalogPathLabel(sample[0])).toBe('Karate › Kihon › Dachi Waza')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collectSkillLeavesFromTree respects exclude', () => {
|
||||||
|
const tree = buildSkillCatalogTree(sample)
|
||||||
|
const leaves = collectSkillLeavesFromTree(tree, [1])
|
||||||
|
expect(leaves).toHaveLength(1)
|
||||||
|
expect(leaves[0].id).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filterSkillTreeByQuery', () => {
|
||||||
|
const tree = buildSkillCatalogTree(sample)
|
||||||
|
const filtered = filterSkillTreeByQuery(tree, 'dachi')
|
||||||
|
const kihon = filtered[0].children.find((c) => c.label === 'Kihon')
|
||||||
|
expect(kihon?.children?.some((s) => s.skillId === 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('catalogSkillsForSelection lists unassigned buckets', () => {
|
||||||
|
const skills = [
|
||||||
|
{ id: 1, main_category_id: null, category_id: null },
|
||||||
|
{ id: 2, main_category_id: 10, category_id: null },
|
||||||
|
{ id: 3, main_category_id: 10, category_id: 20 },
|
||||||
|
]
|
||||||
|
expect(
|
||||||
|
catalogSkillsForSelection(skills, SKILL_MAIN_UNASSIGNED_KEY, SKILL_CATEGORY_UNASSIGNED_KEY).map(
|
||||||
|
(s) => s.id
|
||||||
|
)
|
||||||
|
).toEqual([1])
|
||||||
|
expect(catalogSkillsForSelection(skills, 10, SKILL_CATEGORY_UNASSIGNED_KEY).map((s) => s.id)).toEqual([
|
||||||
|
2,
|
||||||
|
])
|
||||||
|
expect(countCatalogSkillsWithoutMain(skills)).toBe(1)
|
||||||
|
expect(countCatalogSkillsWithoutCategory(skills, 10)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user