Enhance Skills Catalog Admin with Unassigned Skill Handling and Improved Selection Logic
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 41s
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) Has been cancelled

- Introduced utility functions to count skills without main categories and categories, enhancing data management in the Skills Catalog Admin.
- Updated the SkillsCatalogAdmin component to handle unassigned main and category IDs, improving user experience when managing skills.
- Refactored skill selection logic to utilize new utility functions, ensuring accurate filtering of skills based on selected categories and main categories.
- Enhanced the UI to display unassigned skills clearly, improving overall usability and clarity in skill management.
This commit is contained in:
Lars 2026-05-20 11:16:49 +02:00
parent 9020e5eb16
commit 39b1fd04f0
3 changed files with 233 additions and 28 deletions

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

@ -195,3 +195,51 @@ export function defaultExpandedKeysForSkillTree(tree) {
walk(tree)
return keys
}
/** 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

@ -1,9 +1,14 @@
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'
@ -70,4 +75,22 @@ describe('skillCatalogTree', () => {
const group = kihon?.children?.[0]
expect(group?.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)
})
})