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 const sb = b.sort_order != null ? Number(b.sort_order) : 99999 if (sa !== sb) return sa - sb return String(a.name || '').localeCompare(String(b.name || ''), 'de') } /** Tauscht sort_order mit dem direkten Nachbarn in der aktuellen Sortierung (stabil per ID). */ async function swapNeighborSortById(sortedList, rowId, delta, updateId) { const sorted = [...sortedList].sort(bySortThenName) const i = sorted.findIndex((x) => x.id === rowId) const j = i + delta if (i < 0 || j < 0 || j >= sorted.length) return const a = sorted[i] const b = sorted[j] let oa = a.sort_order let ob = b.sort_order if (oa == null || oa === '') oa = (i + 1) * 10 else oa = Number(oa) if (ob == null || ob === '') ob = (j + 1) * 10 else ob = Number(ob) await Promise.all([ updateId(a.id, { sort_order: ob }), updateId(b.id, { sort_order: oa }) ]) } function stop(e) { e.preventDefault() e.stopPropagation() } export default function SkillsCatalogAdmin() { const { user } = useAuth() const isSuperadmin = user?.role === 'superadmin' const [mains, setMains] = useState([]) const [categories, setCategories] = useState([]) const [allCategories, setAllCategories] = useState([]) const [catalog, setCatalog] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [busy, setBusy] = useState(false) const [message, setMessage] = useState('') const [selectedMainId, setSelectedMainId] = useState(null) const [selectedCategoryId, setSelectedCategoryId] = useState(null) const [selectedSkillId, setSelectedSkillId] = useState(null) const [mainForm, setMainForm] = useState({ name: '', slug: '', description: '', sort_order: '' }) const [categoryForm, setCategoryForm] = useState({ name: '', slug: '', description: '', main_category_id: '', sort_order: '' }) const [skillForm, setSkillForm] = useState({ name: '', description: '', category: '', keywords: '', status: 'active', sort_order: '', category_id: '', main_category_id: '', karate_relevance: '', relevance_level: '' }) const [newMainName, setNewMainName] = useState('') const [newCategoryName, setNewCategoryName] = useState('') const [newSkillName, setNewSkillName] = useState('') /** Aktives Bearbeiten-Modal: Typ + Entitäts-ID */ const [editDialog, setEditDialog] = useState(null) const refreshCategories = useCallback(async (mainId) => { if (mainId == null || mainId === SKILL_MAIN_UNASSIGNED_KEY || typeof mainId !== 'number') { setCategories([]) return } const c = await api.listSkillCategories({ main_category_id: mainId }) setCategories([...c].sort(bySortThenName)) }, []) const bootstrap = useCallback(async () => { setError('') try { const [m, s, ac] = await Promise.all([ api.listSkillMainCategories(), api.listSkillsCatalog({ status: 'all' }), api.listSkillCategories({}) ]) setMains([...m].sort(bySortThenName)) setCatalog(s) setAllCategories([...ac].sort(bySortThenName)) } catch (e) { setError(e.message || String(e)) } }, []) useEffect(() => { let cancelled = false ;(async () => { setLoading(true) await bootstrap() if (!cancelled) setLoading(false) })() return () => { cancelled = true } }, [bootstrap]) useEffect(() => { refreshCategories(selectedMainId) }, [selectedMainId, refreshCategories]) const sortedMains = useMemo(() => [...mains].sort(bySortThenName), [mains]) const sortedCategories = useMemo(() => [...categories].sort(bySortThenName), [categories]) const unassignedMainCount = useMemo(() => countCatalogSkillsWithoutMain(catalog), [catalog]) const unassignedCategoryCount = useMemo( () => countCatalogSkillsWithoutCategory(catalog, selectedMainId), [catalog, selectedMainId] ) const skillsInSelection = useMemo(() => { if (selectedCategoryId == null) return [] return [...catalogSkillsForSelection(catalog, selectedMainId, selectedCategoryId)].sort( bySortThenName ) }, [catalog, selectedMainId, selectedCategoryId]) useEffect(() => { if (!editDialog) return const onKey = (ev) => { if (ev.key === 'Escape') setEditDialog(null) } document.addEventListener('keydown', onKey) const prev = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', onKey) document.body.style.overflow = prev } }, [editDialog]) function openEditMain(m) { setMainForm({ name: m.name || '', slug: m.slug || '', description: m.description || '', sort_order: m.sort_order ?? '' }) setEditDialog({ type: 'main', id: m.id }) } function openEditCategory(c) { setCategoryForm({ name: c.name || '', slug: c.slug || '', description: c.description || '', main_category_id: c.main_category_id ?? '', sort_order: c.sort_order ?? '' }) setEditDialog({ type: 'category', id: c.id }) } function openEditSkill(s) { setSkillForm({ name: s.name || '', description: s.description || '', category: s.category || '', keywords: s.keywords || '', 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 !== '' ? String(s.relevance_level) : '' }) setEditDialog({ type: 'skill', id: s.id }) } function closeEditDialog() { setEditDialog(null) } /** @returns {Promise} true bei Erfolg */ async function run(op) { setBusy(true) setMessage('') try { await op() await bootstrap() if (typeof selectedMainId === 'number') await refreshCategories(selectedMainId) setMessage('Gespeichert.') return true } catch (e) { setError(e.message || String(e)) return false } finally { setBusy(false) } } function selectMain(id) { setSelectedMainId(id) setSelectedCategoryId(null) setSelectedSkillId(null) } function selectCategory(id) { setSelectedCategoryId(id) setSelectedSkillId(null) } async function handleSwapMain(rowId, delta) { await run(async () => { await swapNeighborSortById(sortedMains, rowId, delta, (id, data) => api.updateSkillMainCategory(id, data) ) }) } async function handleSwapCategory(rowId, delta) { await run(async () => { await swapNeighborSortById(sortedCategories, rowId, delta, (id, data) => api.updateSkillCategory(id, data) ) }) } async function handleSwapSkill(rowId, delta) { const sorted = [...skillsInSelection].sort(bySortThenName) await run(async () => { await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data)) }) } async function handleSaveMain(e) { e.preventDefault() if (!editDialog || editDialog.type !== 'main') return const ok = await run(async () => { await api.updateSkillMainCategory(editDialog.id, { name: mainForm.name.trim(), slug: (mainForm.slug || '').trim() || undefined, description: mainForm.description || null, sort_order: mainForm.sort_order === '' || mainForm.sort_order == null ? null : Number(mainForm.sort_order) }) }) if (ok) closeEditDialog() } async function handleSaveCategory(e) { e.preventDefault() if (!editDialog || editDialog.type !== 'category') return const mid = categoryForm.main_category_id === '' || categoryForm.main_category_id == null ? null : Number(categoryForm.main_category_id) const ok = await run(async () => { await api.updateSkillCategory(editDialog.id, { name: categoryForm.name.trim(), slug: (categoryForm.slug || '').trim() || undefined, description: categoryForm.description || null, main_category_id: mid, sort_order: categoryForm.sort_order === '' || categoryForm.sort_order == null ? null : Number(categoryForm.sort_order) }) }) if (ok) { if (mid != null && mid !== selectedMainId) { setSelectedMainId(mid) setSelectedSkillId(null) } closeEditDialog() } } async function handleSaveSkill(e) { e.preventDefault() if (!editDialog || editDialog.type !== 'skill') return const cid = skillForm.category_id === '' || skillForm.category_id == null ? null : Number(skillForm.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 () => { const payload = { name: skillForm.name.trim(), description: skillForm.description || null, category: skillForm.category || null, keywords: skillForm.keywords || null, status: skillForm.status, sort_order: skillForm.sort_order === '' || skillForm.sort_order == null ? null : Number(skillForm.sort_order), category_id: cid, karate_relevance: typeof skillForm.karate_relevance === 'string' && skillForm.karate_relevance.trim() ? skillForm.karate_relevance.trim() : null, relevance_level: skillForm.relevance_level === '' || skillForm.relevance_level == null ? null : Number(skillForm.relevance_level), } if (cid == null) { payload.main_category_id = mid } await api.updateSkill(editDialog.id, payload) }) if (ok) { 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() } } async function handleCreateMain(e) { e.preventDefault() const name = newMainName.trim() if (!name) return await run(async () => { const created = await api.createSkillMainCategory({ name }) setNewMainName('') selectMain(created.id) }) } async function handleCreateCategory(e) { e.preventDefault() if (selectedMainId == null || selectedMainId === SKILL_MAIN_UNASSIGNED_KEY) return const name = newCategoryName.trim() if (!name) return await run(async () => { const created = await api.createSkillCategory({ name, main_category_id: selectedMainId }) setNewCategoryName('') setSelectedCategoryId(created.id) setSelectedSkillId(null) }) } async function handleCreateSkill(e) { e.preventDefault() if (selectedCategoryId == null) return const name = newSkillName.trim() if (!name) return await run(async () => { 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) }) } async function handleDeleteMain() { if (!editDialog || editDialog.type !== 'main' || !isSuperadmin) return if (!window.confirm('Hauptkategorie wirklich löschen?')) return const id = editDialog.id const ok = await run(async () => { await api.deleteSkillMainCategory(id) if (selectedMainId === id) { setSelectedMainId(null) setSelectedCategoryId(null) setSelectedSkillId(null) } }) if (ok) closeEditDialog() } async function handleDeleteCategory() { if (!editDialog || editDialog.type !== 'category' || !isSuperadmin) return if (!window.confirm('Kategorie wirklich löschen?')) return const id = editDialog.id const ok = await run(async () => { await api.deleteSkillCategory(id) if (selectedCategoryId === id) { setSelectedCategoryId(null) setSelectedSkillId(null) } }) if (ok) closeEditDialog() } async function handleDeleteSkill() { if (!editDialog || editDialog.type !== 'skill' || !isSuperadmin) return if (!window.confirm('Fähigkeit wirklich löschen?')) return const id = editDialog.id const ok = await run(async () => { await api.deleteSkill(id) if (selectedSkillId === id) setSelectedSkillId(null) }) if (ok) closeEditDialog() } if (loading) { return (

Lade Katalog…

) } return (

Struktur: HauptkategorieKategorie →{' '} 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 ? (
{error}
) : null} {message ?

{message}

: null}

Hauptkategorien

    {unassignedMainCount > 0 ? (
  • ) : null} {sortedMains.map((m) => (
  • ))}
setNewMainName(e.target.value)} placeholder="Name" disabled={busy} />

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) => (
  • ))}
setNewCategoryName(e.target.value)} placeholder="Name" disabled={busy} />
)}

Fähigkeiten

{selectedCategoryId == null ? (

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

) : ( <>
    {skillsInSelection.map((s) => (
  • ))}
setNewSkillName(e.target.value)} placeholder="Name" disabled={busy} />
)}
{editDialog ? (
e.stopPropagation()} >

{editDialog.type === 'main' ? 'Hauptkategorie bearbeiten' : editDialog.type === 'category' ? 'Kategorie bearbeiten' : 'Fähigkeit bearbeiten'}

{editDialog.type === 'main' ? (
setMainForm((f) => ({ ...f, name: e.target.value }))} disabled={busy} autoComplete="off" /> setMainForm((f) => ({ ...f, slug: e.target.value }))} disabled={busy} />