From 39b1fd04f0bc92ff994a9bc78d1deb1a083a9a0d Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 11:16:49 +0200 Subject: [PATCH] Enhance Skills Catalog Admin with Unassigned Skill Handling and Improved Selection Logic - 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. --- .../components/admin/SkillsCatalogAdmin.jsx | 190 +++++++++++++++--- frontend/src/utils/skillCatalogTree.js | 48 +++++ frontend/src/utils/skillCatalogTree.test.js | 23 +++ 3 files changed, 233 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/admin/SkillsCatalogAdmin.jsx b/frontend/src/components/admin/SkillsCatalogAdmin.jsx index 83cca0a..3225d01 100644 --- a/frontend/src/components/admin/SkillsCatalogAdmin.jsx +++ b/frontend/src/components/admin/SkillsCatalogAdmin.jsx @@ -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() {

Struktur: HauptkategorieKategorie →{' '} - Fähigkeit. Reihenfolge mit Pfeilen; Details über das Stift-Symbol (öffnet - ein Fenster — auf dem iPhone nach unten scrollen, falls nötig). + Fähigkeit. Fähigkeiten ohne Zuordnung finden Sie unter{' '} + {SKILL_UNASSIGNED_MAIN_LABEL} bzw. {SKILL_UNASSIGNED_CATEGORY_LABEL}{' '} + (wie in der Auswahlbox). Reihenfolge mit Pfeilen; Details über das Stift-Symbol.

{error ? ( @@ -455,6 +492,26 @@ export default function SkillsCatalogAdmin() {

Hauptkategorien

    + {unassignedMainCount > 0 ? ( +
  • +
    + +
    +
  • + ) : null} {sortedMains.map((m) => (
  • Kategorien {selectedMainId == null ? (

    Zuerst eine Hauptkategorie wählen.

    + ) : selectedMainId === SKILL_MAIN_UNASSIGNED_KEY ? ( + <> +
      + {unassignedCategoryCount > 0 ? ( +
    • +
      + +
      +
    • + ) : null} +
    +

    + Unter {SKILL_UNASSIGNED_MAIN_LABEL} gibt es keine Unterkategorien. +

    + ) : ( <>
      + {unassignedCategoryCount > 0 ? ( +
    • +
      + +
      +
    • + ) : null} {sortedCategories.map((c) => (
    • Fähigkeiten

      {selectedCategoryId == null ? ( -

      Zuerst eine Kategorie wählen.

      +

      + Zuerst eine Kategorie oder „{SKILL_UNASSIGNED_CATEGORY_LABEL}“ wählen. +

      ) : ( <>
        - {skillsInCategory.map((s) => ( + {skillsInSelection.map((s) => (
      • + {allCategories.map((c) => ( ))} + {skillForm.category_id === '' || skillForm.category_id == null ? ( + <> + + + + ) : null} 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 +} diff --git a/frontend/src/utils/skillCatalogTree.test.js b/frontend/src/utils/skillCatalogTree.test.js index c6477d7..723c3eb 100644 --- a/frontend/src/utils/skillCatalogTree.test.js +++ b/frontend/src/utils/skillCatalogTree.test.js @@ -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) + }) })