Merge pull request 'Fähigkeitauswahl verbessert' (#41) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m21s

Reviewed-on: #41
This commit is contained in:
Lars 2026-05-20 11:24:38 +02:00
commit f9df2d31db
13 changed files with 1051 additions and 73 deletions

View File

@ -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;

View File

@ -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 }}>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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">

View File

@ -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}

View File

@ -29,7 +29,7 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
api.listStyleDirections(),
api.listTrainingTypes(),
api.listTargetGroups(),
api.listSkills(),
api.listSkillsCatalog(),
])
if (!cancelled) {
setCatalogs({

View 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
}

View 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)
})
})