Fähigkeitauswahl verbessert #41
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
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 { 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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
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) {
|
||||
const sa = a.sort_order != null ? Number(a.sort_order) : 99999
|
||||
|
|
@ -72,6 +81,7 @@ export default function SkillsCatalogAdmin() {
|
|||
status: 'active',
|
||||
sort_order: '',
|
||||
category_id: '',
|
||||
main_category_id: '',
|
||||
karate_relevance: '',
|
||||
relevance_level: ''
|
||||
})
|
||||
|
|
@ -84,7 +94,7 @@ export default function SkillsCatalogAdmin() {
|
|||
const [editDialog, setEditDialog] = useState(null)
|
||||
|
||||
const refreshCategories = useCallback(async (mainId) => {
|
||||
if (mainId == null) {
|
||||
if (mainId == null || mainId === SKILL_MAIN_UNASSIGNED_KEY || typeof mainId !== 'number') {
|
||||
setCategories([])
|
||||
return
|
||||
}
|
||||
|
|
@ -128,12 +138,19 @@ export default function SkillsCatalogAdmin() {
|
|||
|
||||
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 []
|
||||
return catalog
|
||||
.filter((s) => s.category_id === selectedCategoryId)
|
||||
.sort(bySortThenName)
|
||||
}, [catalog, selectedCategoryId])
|
||||
return [...catalogSkillsForSelection(catalog, selectedMainId, selectedCategoryId)].sort(
|
||||
bySortThenName
|
||||
)
|
||||
}, [catalog, selectedMainId, selectedCategoryId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editDialog) return
|
||||
|
|
@ -179,6 +196,7 @@ export default function SkillsCatalogAdmin() {
|
|||
status: s.status || 'active',
|
||||
sort_order: s.sort_order ?? '',
|
||||
category_id: s.category_id ?? '',
|
||||
main_category_id: s.main_category_id ?? '',
|
||||
karate_relevance: s.karate_relevance || '',
|
||||
relevance_level:
|
||||
s.relevance_level != null && s.relevance_level !== ''
|
||||
|
|
@ -199,7 +217,7 @@ export default function SkillsCatalogAdmin() {
|
|||
try {
|
||||
await op()
|
||||
await bootstrap()
|
||||
if (selectedMainId) await refreshCategories(selectedMainId)
|
||||
if (typeof selectedMainId === 'number') await refreshCategories(selectedMainId)
|
||||
setMessage('Gespeichert.')
|
||||
return true
|
||||
} catch (e) {
|
||||
|
|
@ -238,7 +256,7 @@ export default function SkillsCatalogAdmin() {
|
|||
}
|
||||
|
||||
async function handleSwapSkill(rowId, delta) {
|
||||
const sorted = [...skillsInCategory].sort(bySortThenName)
|
||||
const sorted = [...skillsInSelection].sort(bySortThenName)
|
||||
await run(async () => {
|
||||
await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data))
|
||||
})
|
||||
|
|
@ -292,16 +310,18 @@ export default function SkillsCatalogAdmin() {
|
|||
async function handleSaveSkill(e) {
|
||||
e.preventDefault()
|
||||
if (!editDialog || editDialog.type !== 'skill') return
|
||||
let cid =
|
||||
const cid =
|
||||
skillForm.category_id === '' || skillForm.category_id == null
|
||||
? null
|
||||
: Number(skillForm.category_id)
|
||||
const skillRow = catalog.find((s) => s.id === editDialog.id)
|
||||
if (cid == null && skillRow?.category_id) {
|
||||
cid = skillRow.category_id
|
||||
}
|
||||
const mid =
|
||||
cid == null &&
|
||||
skillForm.main_category_id !== '' &&
|
||||
skillForm.main_category_id != null
|
||||
? Number(skillForm.main_category_id)
|
||||
: null
|
||||
const ok = await run(async () => {
|
||||
await api.updateSkill(editDialog.id, {
|
||||
const payload = {
|
||||
name: skillForm.name.trim(),
|
||||
description: skillForm.description || null,
|
||||
category: skillForm.category || null,
|
||||
|
|
@ -319,16 +339,26 @@ export default function SkillsCatalogAdmin() {
|
|||
relevance_level:
|
||||
skillForm.relevance_level === '' || skillForm.relevance_level == 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 (cid != null && cid !== selectedCategoryId) {
|
||||
if (cid != null) {
|
||||
const cat = allCategories.find((c) => c.id === cid)
|
||||
if (cat?.main_category_id) {
|
||||
setSelectedMainId(cat.main_category_id)
|
||||
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()
|
||||
}
|
||||
|
|
@ -347,7 +377,7 @@ export default function SkillsCatalogAdmin() {
|
|||
|
||||
async function handleCreateCategory(e) {
|
||||
e.preventDefault()
|
||||
if (selectedMainId == null) return
|
||||
if (selectedMainId == null || selectedMainId === SKILL_MAIN_UNASSIGNED_KEY) return
|
||||
const name = newCategoryName.trim()
|
||||
if (!name) return
|
||||
await run(async () => {
|
||||
|
|
@ -367,11 +397,17 @@ export default function SkillsCatalogAdmin() {
|
|||
const name = newSkillName.trim()
|
||||
if (!name) return
|
||||
await run(async () => {
|
||||
const created = await api.createSkill({
|
||||
name,
|
||||
category_id: selectedCategoryId,
|
||||
status: 'active'
|
||||
})
|
||||
const body = { name, status: 'active' }
|
||||
if (typeof selectedCategoryId === 'number') {
|
||||
body.category_id = selectedCategoryId
|
||||
} 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('')
|
||||
setSelectedSkillId(created.id)
|
||||
})
|
||||
|
|
@ -429,8 +465,9 @@ export default function SkillsCatalogAdmin() {
|
|||
<div className="skills-catalog-admin">
|
||||
<p className="skills-catalog-admin__intro muted">
|
||||
Struktur: <strong>Hauptkategorie</strong> → <strong>Kategorie</strong> →{' '}
|
||||
<strong>Fähigkeit</strong>. Reihenfolge mit Pfeilen; Details über das Stift-Symbol (öffnet
|
||||
ein Fenster — auf dem iPhone nach unten scrollen, falls nötig).
|
||||
<strong>Fähigkeit</strong>. Fähigkeiten ohne Zuordnung finden Sie unter{' '}
|
||||
<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>
|
||||
|
||||
{error ? (
|
||||
|
|
@ -455,6 +492,26 @@ export default function SkillsCatalogAdmin() {
|
|||
<section className="skills-catalog-column" aria-label="Hauptkategorien">
|
||||
<h3 className="skills-catalog-column__title">Hauptkategorien</h3>
|
||||
<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) => (
|
||||
<li key={m.id}>
|
||||
<div
|
||||
|
|
@ -534,9 +591,57 @@ export default function SkillsCatalogAdmin() {
|
|||
<h3 className="skills-catalog-column__title">Kategorien</h3>
|
||||
{selectedMainId == null ? (
|
||||
<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">
|
||||
{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) => (
|
||||
<li key={c.id}>
|
||||
<div
|
||||
|
|
@ -617,11 +722,13 @@ export default function SkillsCatalogAdmin() {
|
|||
<section className="skills-catalog-column" aria-label="Fähigkeiten">
|
||||
<h3 className="skills-catalog-column__title">Fähigkeiten</h3>
|
||||
{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">
|
||||
{skillsInCategory.map((s) => (
|
||||
{skillsInSelection.map((s) => (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
className={
|
||||
|
|
@ -897,14 +1004,41 @@ export default function SkillsCatalogAdmin() {
|
|||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
required
|
||||
>
|
||||
<option value="">{SKILL_UNASSIGNED_CATEGORY_LABEL}</option>
|
||||
{allCategories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
<input
|
||||
className="form-input"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
buildExerciseMediaDragPayload,
|
||||
} from '../../utils/exerciseInlineMediaRefs'
|
||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||
import SkillTreeSelect from '../SkillTreeSelect'
|
||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
|
|
@ -609,7 +611,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 +1186,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 +1856,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 +1874,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({
|
||||
|
|
|
|||
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