Implement Skill Tree Selection Components and Update API Calls
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s
- Introduced new `SkillTreeSelect` and `SkillTreeMultiSelect` components for enhanced skill selection in various modals and forms. - Updated API calls to use `listSkillsCatalog` instead of `listSkills` for improved data retrieval. - Enhanced CSS styles for skill selection components to improve user experience and visual consistency across the application.
This commit is contained in:
parent
728b37ad5f
commit
3067b2e6a8
|
|
@ -5182,6 +5182,144 @@ html.modal-scroll-locked .app-main {
|
|||
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 {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
splitMnCatalogRules,
|
||||
splitScalarCatalogRules,
|
||||
} from '../constants/exerciseListFilters'
|
||||
import MultiSelectCombo from './MultiSelectCombo'
|
||||
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
||||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||
import CatalogRulePicker from './CatalogRulePicker'
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ export default function ExercisePickerModal({
|
|||
api.listStyleDirections(),
|
||||
api.listTrainingTypes(),
|
||||
api.listTargetGroups(),
|
||||
api.listSkills(),
|
||||
api.listSkillsCatalog(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setCatalogs({
|
||||
|
|
@ -146,10 +146,6 @@ export default function ExercisePickerModal({
|
|||
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
|
||||
[catalogs.targetGroups]
|
||||
)
|
||||
const skillOptions = useMemo(
|
||||
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
|
||||
[catalogs.skills]
|
||||
)
|
||||
const visibilityOptions = useMemo(
|
||||
() => [
|
||||
{ id: 'private', label: 'Privat' },
|
||||
|
|
@ -506,10 +502,10 @@ export default function ExercisePickerModal({
|
|||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label className="form-label">Fähigkeit</label>
|
||||
<MultiSelectCombo
|
||||
<SkillTreeMultiSelect
|
||||
value={filters.skill_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
|
||||
options={skillOptions}
|
||||
skills={catalogs.skills}
|
||||
placeholder="Fähigkeit …"
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
156
frontend/src/components/SkillTreePickerPanel.jsx
Normal file
156
frontend/src/components/SkillTreePickerPanel.jsx
Normal file
|
|
@ -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 <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 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 (
|
||||
<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 { 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() {
|
|||
<div className="admin-matrix-skill-add">
|
||||
<div className="admin-matrix-skill-add__select">
|
||||
<label className="form-label">Fähigkeit hinzufügen</label>
|
||||
<select
|
||||
className="form-input"
|
||||
<SkillTreeSelect
|
||||
value={skillToAdd}
|
||||
onChange={(e) => setSkillToAdd(e.target.value)}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{allSkills.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setSkillToAdd}
|
||||
skills={allSkills}
|
||||
excludeIds={(detail.model_skills || []).map((ms) => ms.skill_id)}
|
||||
placeholder="— wählen —"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import {
|
|||
buildExerciseMediaDragPayload,
|
||||
} from '../../utils/exerciseInlineMediaRefs'
|
||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import SkillTreeSelect from '../SkillTreeSelect'
|
||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
import {
|
||||
|
|
@ -609,7 +610,7 @@ function ExerciseFormPageRoot() {
|
|||
const boot = async () => {
|
||||
try {
|
||||
const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
|
||||
api.listSkills(),
|
||||
api.listSkillsCatalog(),
|
||||
api.listFocusAreas(),
|
||||
api.listTrainingStyles(),
|
||||
api.listTrainingTypes(),
|
||||
|
|
@ -1184,8 +1185,6 @@ function ExerciseFormPageRoot() {
|
|||
}
|
||||
}
|
||||
|
||||
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
|
||||
|
||||
const selectedVariantForEdit =
|
||||
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
|
||||
const selectedVariantIdx = selectedVariantForEdit
|
||||
|
|
@ -1856,20 +1855,14 @@ function ExerciseFormPageRoot() {
|
|||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' }}>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ flex: '1 1 200px' }}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px', alignItems: 'stretch' }}>
|
||||
<SkillTreeSelect
|
||||
value={skillPick}
|
||||
onChange={(e) => setSkillPick(e.target.value)}
|
||||
>
|
||||
<option value="">Fähigkeit wählen…</option>
|
||||
{availableSkills.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.category})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setSkillPick}
|
||||
skills={skillsCatalog}
|
||||
excludeIds={formData.skills.map((s) => s.skill_id)}
|
||||
placeholder="Fähigkeit wählen…"
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
|
|
@ -1880,11 +1873,11 @@ function ExerciseFormPageRoot() {
|
|||
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
|
||||
<div>
|
||||
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
|
||||
{sk?.category && (
|
||||
{sk ? (
|
||||
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
|
||||
{sk.category}
|
||||
{skillCatalogPathLabel(sk)}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
|
||||
import MultiSelectCombo from '../MultiSelectCombo'
|
||||
import SkillTreeMultiSelect from '../SkillTreeMultiSelect'
|
||||
import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker'
|
||||
import CatalogRulePicker from '../CatalogRulePicker'
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ export default function ExerciseListFilterModal({
|
|||
styleOptions,
|
||||
trainingTypeOptions,
|
||||
targetGroupOptions,
|
||||
skillOptions,
|
||||
skillsCatalog = [],
|
||||
visibilityOptions,
|
||||
statusOptions,
|
||||
savingExercisePrefs,
|
||||
|
|
@ -103,10 +103,10 @@ export default function ExerciseListFilterModal({
|
|||
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
|
||||
<div className="exercise-filter-skill-block">
|
||||
<label className="form-label">Fähigkeit</label>
|
||||
<MultiSelectCombo
|
||||
<SkillTreeMultiSelect
|
||||
value={filters.skill_ids}
|
||||
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
||||
options={skillOptions}
|
||||
skills={skillsCatalog}
|
||||
placeholder="Fähigkeit suchen …"
|
||||
/>
|
||||
<p className="exercise-filter-skill-hint">
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import ExercisePeekModal from '../ExercisePeekModal'
|
|||
import NavStateLink from '../NavStateLink'
|
||||
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
||||
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
||||
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
||||
import {
|
||||
|
|
@ -186,7 +187,11 @@ function ExercisesListPageRoot() {
|
|||
[catalogs.targetGroups]
|
||||
)
|
||||
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]
|
||||
)
|
||||
const visibilityOptions = useMemo(
|
||||
|
|
@ -549,7 +554,7 @@ function ExercisesListPageRoot() {
|
|||
styleOptions={styleOptions}
|
||||
trainingTypeOptions={trainingTypeOptions}
|
||||
targetGroupOptions={targetGroupOptions}
|
||||
skillOptions={skillOptions}
|
||||
skillsCatalog={catalogs.skills}
|
||||
visibilityOptions={visibilityOptions}
|
||||
statusOptions={statusOptions}
|
||||
savingExercisePrefs={savingExercisePrefs}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
|
|||
api.listStyleDirections(),
|
||||
api.listTrainingTypes(),
|
||||
api.listTargetGroups(),
|
||||
api.listSkills(),
|
||||
api.listSkillsCatalog(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setCatalogs({
|
||||
|
|
|
|||
163
frontend/src/utils/skillCatalogTree.js
Normal file
163
frontend/src/utils/skillCatalogTree.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/** @param {ReturnType<typeof buildSkillCatalogTree>} tree */
|
||||
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
|
||||
}
|
||||
60
frontend/src/utils/skillCatalogTree.test.js
Normal file
60
frontend/src/utils/skillCatalogTree.test.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSkillCatalogTree,
|
||||
collectSkillLeavesFromTree,
|
||||
filterSkillTreeByQuery,
|
||||
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')
|
||||
expect(tree[0].children[0].children[0].skillId).toBe(2)
|
||||
})
|
||||
|
||||
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')
|
||||
expect(filtered[0].children.some((c) => c.children?.some((s) => s.skillId === 1))).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user