Compare commits
No commits in common. "f9df2d31dbae32b9238ff86fb5605a97f1075dc7" and "1e2fdeeb0f11ba85dc8c898951da9a47a22d7aeb" have entirely different histories.
f9df2d31db
...
1e2fdeeb0f
|
|
@ -5182,144 +5182,6 @@ html.modal-scroll-locked .app-main {
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fähigkeiten-Katalog: Treeview-Auswahl */
|
|
||||||
.skill-tree-select {
|
|
||||||
position: relative;
|
|
||||||
flex: 1 1 200px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.skill-tree-select__trigger {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
||||||
.skill-tree-select__placeholder {
|
|
||||||
color: var(--text3);
|
|
||||||
}
|
|
||||||
.skill-tree-select__panel {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 120;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: calc(100% + 4px);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
max-height: min(360px, 55vh);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.skill-tree-select__search {
|
|
||||||
margin: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.skill-tree-select__tree-wrap {
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0 4px 8px;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.skill-tree-multiselect__panel {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 120;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: calc(100% + 4px);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
max-height: min(360px, 55vh);
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.skill-tree {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.skill-tree__empty {
|
|
||||||
padding: 10px 12px;
|
|
||||||
color: var(--text3);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
.skill-tree__branch-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
min-height: 30px;
|
|
||||||
}
|
|
||||||
.skill-tree__toggle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text2);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.skill-tree__toggle:hover {
|
|
||||||
background: var(--surface2);
|
|
||||||
}
|
|
||||||
.skill-tree__toggle-spacer {
|
|
||||||
width: 28px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.skill-tree__group-label {
|
|
||||||
font-size: 0.86rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text2);
|
|
||||||
}
|
|
||||||
.skill-tree__group-label--main {
|
|
||||||
color: var(--accent-dark);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.skill-tree__children {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.skill-tree__pick {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text1);
|
|
||||||
}
|
|
||||||
.skill-tree__pick:hover,
|
|
||||||
.skill-tree__pick:focus-visible {
|
|
||||||
background: var(--surface2);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.skill-tree__pick--path {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.skill-tree__pick-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.skill-tree__pick-path {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--text3);
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.multi-assoc-block {
|
.multi-assoc-block {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
splitMnCatalogRules,
|
splitMnCatalogRules,
|
||||||
splitScalarCatalogRules,
|
splitScalarCatalogRules,
|
||||||
} from '../constants/exerciseListFilters'
|
} from '../constants/exerciseListFilters'
|
||||||
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
import MultiSelectCombo from './MultiSelectCombo'
|
||||||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||||
import CatalogRulePicker from './CatalogRulePicker'
|
import CatalogRulePicker from './CatalogRulePicker'
|
||||||
|
|
||||||
|
|
@ -88,7 +88,7 @@ export default function ExercisePickerModal({
|
||||||
api.listStyleDirections(),
|
api.listStyleDirections(),
|
||||||
api.listTrainingTypes(),
|
api.listTrainingTypes(),
|
||||||
api.listTargetGroups(),
|
api.listTargetGroups(),
|
||||||
api.listSkillsCatalog(),
|
api.listSkills(),
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCatalogs({
|
setCatalogs({
|
||||||
|
|
@ -146,6 +146,10 @@ export default function ExercisePickerModal({
|
||||||
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
|
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
|
||||||
[catalogs.targetGroups]
|
[catalogs.targetGroups]
|
||||||
)
|
)
|
||||||
|
const skillOptions = useMemo(
|
||||||
|
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
|
||||||
|
[catalogs.skills]
|
||||||
|
)
|
||||||
const visibilityOptions = useMemo(
|
const visibilityOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ id: 'private', label: 'Privat' },
|
{ id: 'private', label: 'Privat' },
|
||||||
|
|
@ -502,10 +506,10 @@ export default function ExercisePickerModal({
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<label className="form-label">Fähigkeit</label>
|
<label className="form-label">Fähigkeit</label>
|
||||||
<SkillTreeMultiSelect
|
<MultiSelectCombo
|
||||||
value={filters.skill_ids}
|
value={filters.skill_ids}
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
|
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
|
||||||
skills={catalogs.skills}
|
options={skillOptions}
|
||||||
placeholder="Fähigkeit …"
|
placeholder="Fähigkeit …"
|
||||||
/>
|
/>
|
||||||
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
|
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
import SkillTreeSelect from '../SkillTreeSelect'
|
|
||||||
|
|
||||||
export default function MaturityModelsAdminPanel() {
|
export default function MaturityModelsAdminPanel() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -40,7 +39,7 @@ export default function MaturityModelsAdminPanel() {
|
||||||
try {
|
try {
|
||||||
const [m, sk] = await Promise.all([
|
const [m, sk] = await Promise.all([
|
||||||
api.listMaturityModels({}),
|
api.listMaturityModels({}),
|
||||||
api.listSkillsCatalog({ status: 'active' })
|
api.listSkills({ status: 'active' })
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setModels(m)
|
setModels(m)
|
||||||
|
|
@ -268,7 +267,7 @@ export default function MaturityModelsAdminPanel() {
|
||||||
? null
|
? null
|
||||||
: Number(skillWikiForm.relevance_level)
|
: Number(skillWikiForm.relevance_level)
|
||||||
})
|
})
|
||||||
const sk = await api.listSkillsCatalog({ status: 'active' })
|
const sk = await api.listSkills({ status: 'active' })
|
||||||
setAllSkills(sk)
|
setAllSkills(sk)
|
||||||
closeSkillWikiModal()
|
closeSkillWikiModal()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -586,13 +585,16 @@ export default function MaturityModelsAdminPanel() {
|
||||||
<div className="admin-matrix-skill-add">
|
<div className="admin-matrix-skill-add">
|
||||||
<div className="admin-matrix-skill-add__select">
|
<div className="admin-matrix-skill-add__select">
|
||||||
<label className="form-label">Fähigkeit hinzufügen</label>
|
<label className="form-label">Fähigkeit hinzufügen</label>
|
||||||
<SkillTreeSelect
|
<select
|
||||||
|
className="form-input"
|
||||||
value={skillToAdd}
|
value={skillToAdd}
|
||||||
onChange={setSkillToAdd}
|
onChange={(e) => setSkillToAdd(e.target.value)}
|
||||||
skills={allSkills}
|
>
|
||||||
excludeIds={(detail.model_skills || []).map((ms) => ms.skill_id)}
|
<option value="">— wählen —</option>
|
||||||
placeholder="— wählen —"
|
{allSkills.map((s) => (
|
||||||
/>
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,6 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
import {
|
|
||||||
catalogSkillsForSelection,
|
|
||||||
countCatalogSkillsWithoutCategory,
|
|
||||||
countCatalogSkillsWithoutMain,
|
|
||||||
SKILL_CATEGORY_UNASSIGNED_KEY,
|
|
||||||
SKILL_MAIN_UNASSIGNED_KEY,
|
|
||||||
SKILL_UNASSIGNED_CATEGORY_LABEL,
|
|
||||||
SKILL_UNASSIGNED_MAIN_LABEL,
|
|
||||||
} from '../../utils/skillCatalogTree'
|
|
||||||
|
|
||||||
function bySortThenName(a, b) {
|
function bySortThenName(a, b) {
|
||||||
const sa = a.sort_order != null ? Number(a.sort_order) : 99999
|
const sa = a.sort_order != null ? Number(a.sort_order) : 99999
|
||||||
|
|
@ -81,7 +72,6 @@ export default function SkillsCatalogAdmin() {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
sort_order: '',
|
sort_order: '',
|
||||||
category_id: '',
|
category_id: '',
|
||||||
main_category_id: '',
|
|
||||||
karate_relevance: '',
|
karate_relevance: '',
|
||||||
relevance_level: ''
|
relevance_level: ''
|
||||||
})
|
})
|
||||||
|
|
@ -94,7 +84,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
const [editDialog, setEditDialog] = useState(null)
|
const [editDialog, setEditDialog] = useState(null)
|
||||||
|
|
||||||
const refreshCategories = useCallback(async (mainId) => {
|
const refreshCategories = useCallback(async (mainId) => {
|
||||||
if (mainId == null || mainId === SKILL_MAIN_UNASSIGNED_KEY || typeof mainId !== 'number') {
|
if (mainId == null) {
|
||||||
setCategories([])
|
setCategories([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -138,19 +128,12 @@ export default function SkillsCatalogAdmin() {
|
||||||
|
|
||||||
const sortedCategories = useMemo(() => [...categories].sort(bySortThenName), [categories])
|
const sortedCategories = useMemo(() => [...categories].sort(bySortThenName), [categories])
|
||||||
|
|
||||||
const unassignedMainCount = useMemo(() => countCatalogSkillsWithoutMain(catalog), [catalog])
|
const skillsInCategory = useMemo(() => {
|
||||||
|
|
||||||
const unassignedCategoryCount = useMemo(
|
|
||||||
() => countCatalogSkillsWithoutCategory(catalog, selectedMainId),
|
|
||||||
[catalog, selectedMainId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const skillsInSelection = useMemo(() => {
|
|
||||||
if (selectedCategoryId == null) return []
|
if (selectedCategoryId == null) return []
|
||||||
return [...catalogSkillsForSelection(catalog, selectedMainId, selectedCategoryId)].sort(
|
return catalog
|
||||||
bySortThenName
|
.filter((s) => s.category_id === selectedCategoryId)
|
||||||
)
|
.sort(bySortThenName)
|
||||||
}, [catalog, selectedMainId, selectedCategoryId])
|
}, [catalog, selectedCategoryId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editDialog) return
|
if (!editDialog) return
|
||||||
|
|
@ -196,7 +179,6 @@ export default function SkillsCatalogAdmin() {
|
||||||
status: s.status || 'active',
|
status: s.status || 'active',
|
||||||
sort_order: s.sort_order ?? '',
|
sort_order: s.sort_order ?? '',
|
||||||
category_id: s.category_id ?? '',
|
category_id: s.category_id ?? '',
|
||||||
main_category_id: s.main_category_id ?? '',
|
|
||||||
karate_relevance: s.karate_relevance || '',
|
karate_relevance: s.karate_relevance || '',
|
||||||
relevance_level:
|
relevance_level:
|
||||||
s.relevance_level != null && s.relevance_level !== ''
|
s.relevance_level != null && s.relevance_level !== ''
|
||||||
|
|
@ -217,7 +199,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
try {
|
try {
|
||||||
await op()
|
await op()
|
||||||
await bootstrap()
|
await bootstrap()
|
||||||
if (typeof selectedMainId === 'number') await refreshCategories(selectedMainId)
|
if (selectedMainId) await refreshCategories(selectedMainId)
|
||||||
setMessage('Gespeichert.')
|
setMessage('Gespeichert.')
|
||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -256,7 +238,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSwapSkill(rowId, delta) {
|
async function handleSwapSkill(rowId, delta) {
|
||||||
const sorted = [...skillsInSelection].sort(bySortThenName)
|
const sorted = [...skillsInCategory].sort(bySortThenName)
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data))
|
await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data))
|
||||||
})
|
})
|
||||||
|
|
@ -310,18 +292,16 @@ export default function SkillsCatalogAdmin() {
|
||||||
async function handleSaveSkill(e) {
|
async function handleSaveSkill(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!editDialog || editDialog.type !== 'skill') return
|
if (!editDialog || editDialog.type !== 'skill') return
|
||||||
const cid =
|
let cid =
|
||||||
skillForm.category_id === '' || skillForm.category_id == null
|
skillForm.category_id === '' || skillForm.category_id == null
|
||||||
? null
|
? null
|
||||||
: Number(skillForm.category_id)
|
: Number(skillForm.category_id)
|
||||||
const mid =
|
const skillRow = catalog.find((s) => s.id === editDialog.id)
|
||||||
cid == null &&
|
if (cid == null && skillRow?.category_id) {
|
||||||
skillForm.main_category_id !== '' &&
|
cid = skillRow.category_id
|
||||||
skillForm.main_category_id != null
|
}
|
||||||
? Number(skillForm.main_category_id)
|
|
||||||
: null
|
|
||||||
const ok = await run(async () => {
|
const ok = await run(async () => {
|
||||||
const payload = {
|
await api.updateSkill(editDialog.id, {
|
||||||
name: skillForm.name.trim(),
|
name: skillForm.name.trim(),
|
||||||
description: skillForm.description || null,
|
description: skillForm.description || null,
|
||||||
category: skillForm.category || null,
|
category: skillForm.category || null,
|
||||||
|
|
@ -339,26 +319,16 @@ export default function SkillsCatalogAdmin() {
|
||||||
relevance_level:
|
relevance_level:
|
||||||
skillForm.relevance_level === '' || skillForm.relevance_level == null
|
skillForm.relevance_level === '' || skillForm.relevance_level == null
|
||||||
? null
|
? null
|
||||||
: Number(skillForm.relevance_level),
|
: Number(skillForm.relevance_level)
|
||||||
}
|
})
|
||||||
if (cid == null) {
|
|
||||||
payload.main_category_id = mid
|
|
||||||
}
|
|
||||||
await api.updateSkill(editDialog.id, payload)
|
|
||||||
})
|
})
|
||||||
if (ok) {
|
if (ok) {
|
||||||
if (cid != null) {
|
if (cid != null && cid !== selectedCategoryId) {
|
||||||
const cat = allCategories.find((c) => c.id === cid)
|
const cat = allCategories.find((c) => c.id === cid)
|
||||||
if (cat?.main_category_id) {
|
if (cat?.main_category_id) {
|
||||||
setSelectedMainId(cat.main_category_id)
|
setSelectedMainId(cat.main_category_id)
|
||||||
setSelectedCategoryId(cid)
|
setSelectedCategoryId(cid)
|
||||||
}
|
}
|
||||||
} else if (mid == null) {
|
|
||||||
setSelectedMainId(SKILL_MAIN_UNASSIGNED_KEY)
|
|
||||||
setSelectedCategoryId(SKILL_CATEGORY_UNASSIGNED_KEY)
|
|
||||||
} else {
|
|
||||||
setSelectedMainId(mid)
|
|
||||||
setSelectedCategoryId(SKILL_CATEGORY_UNASSIGNED_KEY)
|
|
||||||
}
|
}
|
||||||
closeEditDialog()
|
closeEditDialog()
|
||||||
}
|
}
|
||||||
|
|
@ -377,7 +347,7 @@ export default function SkillsCatalogAdmin() {
|
||||||
|
|
||||||
async function handleCreateCategory(e) {
|
async function handleCreateCategory(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (selectedMainId == null || selectedMainId === SKILL_MAIN_UNASSIGNED_KEY) return
|
if (selectedMainId == null) return
|
||||||
const name = newCategoryName.trim()
|
const name = newCategoryName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
|
|
@ -397,17 +367,11 @@ export default function SkillsCatalogAdmin() {
|
||||||
const name = newSkillName.trim()
|
const name = newSkillName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const body = { name, status: 'active' }
|
const created = await api.createSkill({
|
||||||
if (typeof selectedCategoryId === 'number') {
|
name,
|
||||||
body.category_id = selectedCategoryId
|
category_id: selectedCategoryId,
|
||||||
} else if (selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY) {
|
status: 'active'
|
||||||
body.category_id = null
|
})
|
||||||
body.main_category_id =
|
|
||||||
typeof selectedMainId === 'number' ? selectedMainId : null
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const created = await api.createSkill(body)
|
|
||||||
setNewSkillName('')
|
setNewSkillName('')
|
||||||
setSelectedSkillId(created.id)
|
setSelectedSkillId(created.id)
|
||||||
})
|
})
|
||||||
|
|
@ -465,9 +429,8 @@ export default function SkillsCatalogAdmin() {
|
||||||
<div className="skills-catalog-admin">
|
<div className="skills-catalog-admin">
|
||||||
<p className="skills-catalog-admin__intro muted">
|
<p className="skills-catalog-admin__intro muted">
|
||||||
Struktur: <strong>Hauptkategorie</strong> → <strong>Kategorie</strong> →{' '}
|
Struktur: <strong>Hauptkategorie</strong> → <strong>Kategorie</strong> →{' '}
|
||||||
<strong>Fähigkeit</strong>. Fähigkeiten ohne Zuordnung finden Sie unter{' '}
|
<strong>Fähigkeit</strong>. Reihenfolge mit Pfeilen; Details über das Stift-Symbol (öffnet
|
||||||
<strong>{SKILL_UNASSIGNED_MAIN_LABEL}</strong> bzw. <strong>{SKILL_UNASSIGNED_CATEGORY_LABEL}</strong>{' '}
|
ein Fenster — auf dem iPhone nach unten scrollen, falls nötig).
|
||||||
(wie in der Auswahlbox). Reihenfolge mit Pfeilen; Details über das Stift-Symbol.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -492,26 +455,6 @@ export default function SkillsCatalogAdmin() {
|
||||||
<section className="skills-catalog-column" aria-label="Hauptkategorien">
|
<section className="skills-catalog-column" aria-label="Hauptkategorien">
|
||||||
<h3 className="skills-catalog-column__title">Hauptkategorien</h3>
|
<h3 className="skills-catalog-column__title">Hauptkategorien</h3>
|
||||||
<ul className="skills-catalog-list">
|
<ul className="skills-catalog-list">
|
||||||
{unassignedMainCount > 0 ? (
|
|
||||||
<li>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'skills-catalog-row' +
|
|
||||||
(selectedMainId === SKILL_MAIN_UNASSIGNED_KEY
|
|
||||||
? ' skills-catalog-row--active'
|
|
||||||
: '')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="skills-catalog-row__label"
|
|
||||||
onClick={() => selectMain(SKILL_MAIN_UNASSIGNED_KEY)}
|
|
||||||
>
|
|
||||||
{SKILL_UNASSIGNED_MAIN_LABEL} ({unassignedMainCount})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
{sortedMains.map((m) => (
|
{sortedMains.map((m) => (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -591,57 +534,9 @@ export default function SkillsCatalogAdmin() {
|
||||||
<h3 className="skills-catalog-column__title">Kategorien</h3>
|
<h3 className="skills-catalog-column__title">Kategorien</h3>
|
||||||
{selectedMainId == null ? (
|
{selectedMainId == null ? (
|
||||||
<p className="muted skills-catalog-placeholder">Zuerst eine Hauptkategorie wählen.</p>
|
<p className="muted skills-catalog-placeholder">Zuerst eine Hauptkategorie wählen.</p>
|
||||||
) : selectedMainId === SKILL_MAIN_UNASSIGNED_KEY ? (
|
|
||||||
<>
|
|
||||||
<ul className="skills-catalog-list">
|
|
||||||
{unassignedCategoryCount > 0 ? (
|
|
||||||
<li>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'skills-catalog-row' +
|
|
||||||
(selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY
|
|
||||||
? ' skills-catalog-row--active'
|
|
||||||
: '')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="skills-catalog-row__label"
|
|
||||||
onClick={() => selectCategory(SKILL_CATEGORY_UNASSIGNED_KEY)}
|
|
||||||
>
|
|
||||||
{SKILL_UNASSIGNED_CATEGORY_LABEL} ({unassignedCategoryCount})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
<p className="muted skills-catalog-placeholder">
|
|
||||||
Unter {SKILL_UNASSIGNED_MAIN_LABEL} gibt es keine Unterkategorien.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className="skills-catalog-list">
|
<ul className="skills-catalog-list">
|
||||||
{unassignedCategoryCount > 0 ? (
|
|
||||||
<li>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'skills-catalog-row' +
|
|
||||||
(selectedCategoryId === SKILL_CATEGORY_UNASSIGNED_KEY
|
|
||||||
? ' skills-catalog-row--active'
|
|
||||||
: '')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="skills-catalog-row__label"
|
|
||||||
onClick={() => selectCategory(SKILL_CATEGORY_UNASSIGNED_KEY)}
|
|
||||||
>
|
|
||||||
{SKILL_UNASSIGNED_CATEGORY_LABEL} ({unassignedCategoryCount})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
{sortedCategories.map((c) => (
|
{sortedCategories.map((c) => (
|
||||||
<li key={c.id}>
|
<li key={c.id}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -722,13 +617,11 @@ export default function SkillsCatalogAdmin() {
|
||||||
<section className="skills-catalog-column" aria-label="Fähigkeiten">
|
<section className="skills-catalog-column" aria-label="Fähigkeiten">
|
||||||
<h3 className="skills-catalog-column__title">Fähigkeiten</h3>
|
<h3 className="skills-catalog-column__title">Fähigkeiten</h3>
|
||||||
{selectedCategoryId == null ? (
|
{selectedCategoryId == null ? (
|
||||||
<p className="muted skills-catalog-placeholder">
|
<p className="muted skills-catalog-placeholder">Zuerst eine Kategorie wählen.</p>
|
||||||
Zuerst eine Kategorie oder „{SKILL_UNASSIGNED_CATEGORY_LABEL}“ wählen.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className="skills-catalog-list">
|
<ul className="skills-catalog-list">
|
||||||
{skillsInSelection.map((s) => (
|
{skillsInCategory.map((s) => (
|
||||||
<li key={s.id}>
|
<li key={s.id}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|
@ -1004,41 +897,14 @@ export default function SkillsCatalogAdmin() {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<option value="">{SKILL_UNASSIGNED_CATEGORY_LABEL}</option>
|
|
||||||
{allCategories.map((c) => (
|
{allCategories.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
|
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{skillForm.category_id === '' || skillForm.category_id == null ? (
|
|
||||||
<>
|
|
||||||
<label className="form-label">Hauptkategorie</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={
|
|
||||||
skillForm.main_category_id === '' || skillForm.main_category_id == null
|
|
||||||
? ''
|
|
||||||
: String(skillForm.main_category_id)
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSkillForm((f) => ({
|
|
||||||
...f,
|
|
||||||
main_category_id: e.target.value === '' ? '' : e.target.value
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
<option value="">{SKILL_UNASSIGNED_MAIN_LABEL}</option>
|
|
||||||
{sortedMains.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<label className="form-label">Legacy-Kurzlabel „category“ (optional)</label>
|
<label className="form-label">Legacy-Kurzlabel „category“ (optional)</label>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ import {
|
||||||
buildExerciseMediaDragPayload,
|
buildExerciseMediaDragPayload,
|
||||||
} from '../../utils/exerciseInlineMediaRefs'
|
} from '../../utils/exerciseInlineMediaRefs'
|
||||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||||
import SkillTreeSelect from '../SkillTreeSelect'
|
|
||||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
|
||||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { useToast } from '../../context/ToastContext'
|
import { useToast } from '../../context/ToastContext'
|
||||||
|
|
@ -611,7 +609,7 @@ function ExerciseFormPageRoot() {
|
||||||
const boot = async () => {
|
const boot = async () => {
|
||||||
try {
|
try {
|
||||||
const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
|
const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
|
||||||
api.listSkillsCatalog(),
|
api.listSkills(),
|
||||||
api.listFocusAreas(),
|
api.listFocusAreas(),
|
||||||
api.listTrainingStyles(),
|
api.listTrainingStyles(),
|
||||||
api.listTrainingTypes(),
|
api.listTrainingTypes(),
|
||||||
|
|
@ -1186,6 +1184,8 @@ function ExerciseFormPageRoot() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
|
||||||
|
|
||||||
const selectedVariantForEdit =
|
const selectedVariantForEdit =
|
||||||
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
|
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
|
||||||
const selectedVariantIdx = selectedVariantForEdit
|
const selectedVariantIdx = selectedVariantForEdit
|
||||||
|
|
@ -1856,14 +1856,20 @@ function ExerciseFormPageRoot() {
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
|
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px', alignItems: 'stretch' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' }}>
|
||||||
<SkillTreeSelect
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: '1 1 200px' }}
|
||||||
value={skillPick}
|
value={skillPick}
|
||||||
onChange={setSkillPick}
|
onChange={(e) => setSkillPick(e.target.value)}
|
||||||
skills={skillsCatalog}
|
>
|
||||||
excludeIds={formData.skills.map((s) => s.skill_id)}
|
<option value="">Fähigkeit wählen…</option>
|
||||||
placeholder="Fähigkeit wählen…"
|
{availableSkills.map((s) => (
|
||||||
/>
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({s.category})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
|
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
|
||||||
Hinzufügen
|
Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1874,11 +1880,11 @@ function ExerciseFormPageRoot() {
|
||||||
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
|
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
|
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
|
||||||
{sk ? (
|
{sk?.category && (
|
||||||
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
|
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
|
||||||
{skillCatalogPathLabel(sk)}
|
{sk.category}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
|
||||||
import SkillTreeMultiSelect from '../SkillTreeMultiSelect'
|
import MultiSelectCombo from '../MultiSelectCombo'
|
||||||
import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker'
|
||||||
import CatalogRulePicker from '../CatalogRulePicker'
|
import CatalogRulePicker from '../CatalogRulePicker'
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ export default function ExerciseListFilterModal({
|
||||||
styleOptions,
|
styleOptions,
|
||||||
trainingTypeOptions,
|
trainingTypeOptions,
|
||||||
targetGroupOptions,
|
targetGroupOptions,
|
||||||
skillsCatalog = [],
|
skillOptions,
|
||||||
visibilityOptions,
|
visibilityOptions,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
savingExercisePrefs,
|
savingExercisePrefs,
|
||||||
|
|
@ -103,10 +103,10 @@ export default function ExerciseListFilterModal({
|
||||||
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
|
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
|
||||||
<div className="exercise-filter-skill-block">
|
<div className="exercise-filter-skill-block">
|
||||||
<label className="form-label">Fähigkeit</label>
|
<label className="form-label">Fähigkeit</label>
|
||||||
<SkillTreeMultiSelect
|
<MultiSelectCombo
|
||||||
value={filters.skill_ids}
|
value={filters.skill_ids}
|
||||||
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
||||||
skills={skillsCatalog}
|
options={skillOptions}
|
||||||
placeholder="Fähigkeit suchen …"
|
placeholder="Fähigkeit suchen …"
|
||||||
/>
|
/>
|
||||||
<p className="exercise-filter-skill-hint">
|
<p className="exercise-filter-skill-hint">
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import ExercisePeekModal from '../ExercisePeekModal'
|
||||||
import NavStateLink from '../NavStateLink'
|
import NavStateLink from '../NavStateLink'
|
||||||
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
||||||
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
||||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
|
||||||
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
||||||
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
||||||
import {
|
import {
|
||||||
|
|
@ -187,11 +186,7 @@ function ExercisesListPageRoot() {
|
||||||
[catalogs.targetGroups]
|
[catalogs.targetGroups]
|
||||||
)
|
)
|
||||||
const skillOptions = useMemo(
|
const skillOptions = useMemo(
|
||||||
() =>
|
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
|
||||||
catalogs.skills.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
label: skillCatalogPathLabel(s) || s.name || String(s.id),
|
|
||||||
})),
|
|
||||||
[catalogs.skills]
|
[catalogs.skills]
|
||||||
)
|
)
|
||||||
const visibilityOptions = useMemo(
|
const visibilityOptions = useMemo(
|
||||||
|
|
@ -554,7 +549,7 @@ function ExercisesListPageRoot() {
|
||||||
styleOptions={styleOptions}
|
styleOptions={styleOptions}
|
||||||
trainingTypeOptions={trainingTypeOptions}
|
trainingTypeOptions={trainingTypeOptions}
|
||||||
targetGroupOptions={targetGroupOptions}
|
targetGroupOptions={targetGroupOptions}
|
||||||
skillsCatalog={catalogs.skills}
|
skillOptions={skillOptions}
|
||||||
visibilityOptions={visibilityOptions}
|
visibilityOptions={visibilityOptions}
|
||||||
statusOptions={statusOptions}
|
statusOptions={statusOptions}
|
||||||
savingExercisePrefs={savingExercisePrefs}
|
savingExercisePrefs={savingExercisePrefs}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
|
||||||
api.listStyleDirections(),
|
api.listStyleDirections(),
|
||||||
api.listTrainingTypes(),
|
api.listTrainingTypes(),
|
||||||
api.listTargetGroups(),
|
api.listTargetGroups(),
|
||||||
api.listSkillsCatalog(),
|
api.listSkills(),
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCatalogs({
|
setCatalogs({
|
||||||
|
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
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