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.
1140 lines
41 KiB
JavaScript
1140 lines
41 KiB
JavaScript
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<boolean>} 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 (
|
||
<div className="skills-catalog-admin">
|
||
<p className="muted">Lade Katalog…</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="skills-catalog-admin">
|
||
<p className="skills-catalog-admin__intro muted">
|
||
Struktur: <strong>Hauptkategorie</strong> → <strong>Kategorie</strong> →{' '}
|
||
<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 ? (
|
||
<div className="skills-catalog-admin__error" role="alert">
|
||
{error}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ marginLeft: 12 }}
|
||
onClick={() => {
|
||
setError('')
|
||
bootstrap()
|
||
}}
|
||
>
|
||
Erneut laden
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{message ? <p className="muted skills-catalog-admin__msg">{message}</p> : null}
|
||
|
||
<div className="skills-catalog-layout">
|
||
<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
|
||
className={
|
||
'skills-catalog-row' +
|
||
(selectedMainId === m.id ? ' skills-catalog-row--active' : '')
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="skills-catalog-row__label"
|
||
onClick={() => selectMain(m.id)}
|
||
>
|
||
{m.name}
|
||
</button>
|
||
<span className="skills-catalog-row__actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-tiny"
|
||
disabled={busy}
|
||
title="Nach oben"
|
||
aria-label="Nach oben sortieren"
|
||
onClick={(e) => {
|
||
stop(e)
|
||
handleSwapMain(m.id, -1)
|
||
}}
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-tiny"
|
||
disabled={busy}
|
||
title="Nach unten"
|
||
aria-label="Nach unten sortieren"
|
||
onClick={(e) => {
|
||
stop(e)
|
||
handleSwapMain(m.id, 1)
|
||
}}
|
||
>
|
||
↓
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost btn-icon-touch"
|
||
disabled={busy}
|
||
title="Bearbeiten"
|
||
aria-label={`Hauptkategorie ${m.name} bearbeiten`}
|
||
onClick={(e) => {
|
||
stop(e)
|
||
openEditMain(m)
|
||
}}
|
||
>
|
||
✎
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<form className="skills-catalog-quick" onSubmit={handleCreateMain}>
|
||
<label className="form-label">Neue Hauptkategorie</label>
|
||
<input
|
||
className="form-input"
|
||
value={newMainName}
|
||
onChange={(e) => setNewMainName(e.target.value)}
|
||
placeholder="Name"
|
||
disabled={busy}
|
||
/>
|
||
<button type="submit" className="btn btn-primary btn-full" disabled={busy}>
|
||
Anlegen
|
||
</button>
|
||
</form>
|
||
</section>
|
||
|
||
<section className="skills-catalog-column" aria-label="Kategorien">
|
||
<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
|
||
className={
|
||
'skills-catalog-row' +
|
||
(selectedCategoryId === c.id ? ' skills-catalog-row--active' : '')
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="skills-catalog-row__label"
|
||
onClick={() => selectCategory(c.id)}
|
||
>
|
||
{c.name}
|
||
</button>
|
||
<span className="skills-catalog-row__actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-tiny"
|
||
disabled={busy}
|
||
title="Nach oben"
|
||
aria-label="Nach oben sortieren"
|
||
onClick={(e) => {
|
||
stop(e)
|
||
handleSwapCategory(c.id, -1)
|
||
}}
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-tiny"
|
||
disabled={busy}
|
||
title="Nach unten"
|
||
aria-label="Nach unten sortieren"
|
||
onClick={(e) => {
|
||
stop(e)
|
||
handleSwapCategory(c.id, 1)
|
||
}}
|
||
>
|
||
↓
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost btn-icon-touch"
|
||
disabled={busy}
|
||
title="Bearbeiten"
|
||
aria-label={`Kategorie ${c.name} bearbeiten`}
|
||
onClick={(e) => {
|
||
stop(e)
|
||
openEditCategory(c)
|
||
}}
|
||
>
|
||
✎
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<form className="skills-catalog-quick" onSubmit={handleCreateCategory}>
|
||
<label className="form-label">Neue Kategorie</label>
|
||
<input
|
||
className="form-input"
|
||
value={newCategoryName}
|
||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||
placeholder="Name"
|
||
disabled={busy}
|
||
/>
|
||
<button type="submit" className="btn btn-primary btn-full" disabled={busy}>
|
||
Anlegen
|
||
</button>
|
||
</form>
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
<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 oder „{SKILL_UNASSIGNED_CATEGORY_LABEL}“ wählen.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<ul className="skills-catalog-list">
|
||
{skillsInSelection.map((s) => (
|
||
<li key={s.id}>
|
||
<div
|
||
className={
|
||
'skills-catalog-row' +
|
||
(selectedSkillId === s.id ? ' skills-catalog-row--active' : '')
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="skills-catalog-row__label"
|
||
onClick={() => setSelectedSkillId(s.id)}
|
||
>
|
||
{s.name}
|
||
{s.status && s.status !== 'active' ? (
|
||
<span className="skills-catalog-row__badge">{s.status}</span>
|
||
) : null}
|
||
</button>
|
||
<span className="skills-catalog-row__actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-tiny"
|
||
disabled={busy}
|
||
title="Nach oben"
|
||
aria-label="Nach oben sortieren"
|
||
onClick={(e) => {
|
||
stop(e)
|
||
handleSwapSkill(s.id, -1)
|
||
}}
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary btn-tiny"
|
||
disabled={busy}
|
||
title="Nach unten"
|
||
aria-label="Nach unten sortieren"
|
||
onClick={(e) => {
|
||
stop(e)
|
||
handleSwapSkill(s.id, 1)
|
||
}}
|
||
>
|
||
↓
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost btn-icon-touch"
|
||
disabled={busy}
|
||
title="Bearbeiten"
|
||
aria-label={`Fähigkeit ${s.name} bearbeiten`}
|
||
onClick={(e) => {
|
||
stop(e)
|
||
openEditSkill(s)
|
||
}}
|
||
>
|
||
✎
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<form className="skills-catalog-quick" onSubmit={handleCreateSkill}>
|
||
<label className="form-label">Neue Fähigkeit</label>
|
||
<input
|
||
className="form-input"
|
||
value={newSkillName}
|
||
onChange={(e) => setNewSkillName(e.target.value)}
|
||
placeholder="Name"
|
||
disabled={busy}
|
||
/>
|
||
<button type="submit" className="btn btn-primary btn-full" disabled={busy}>
|
||
Anlegen
|
||
</button>
|
||
</form>
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
{editDialog ? (
|
||
<div
|
||
className="admin-modal-backdrop"
|
||
role="presentation"
|
||
onClick={closeEditDialog}
|
||
>
|
||
<div
|
||
className="admin-modal-sheet"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="skills-catalog-edit-title"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="admin-modal-sheet__header">
|
||
<h3 id="skills-catalog-edit-title" className="admin-modal-sheet__title">
|
||
{editDialog.type === 'main'
|
||
? 'Hauptkategorie bearbeiten'
|
||
: editDialog.type === 'category'
|
||
? 'Kategorie bearbeiten'
|
||
: 'Fähigkeit bearbeiten'}
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary admin-modal-sheet__close"
|
||
onClick={closeEditDialog}
|
||
aria-label="Schließen"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="admin-modal-sheet__body">
|
||
{editDialog.type === 'main' ? (
|
||
<form className="skills-catalog-form" onSubmit={handleSaveMain}>
|
||
<label className="form-label">Name</label>
|
||
<input
|
||
className="form-input"
|
||
value={mainForm.name}
|
||
onChange={(e) => setMainForm((f) => ({ ...f, name: e.target.value }))}
|
||
disabled={busy}
|
||
autoComplete="off"
|
||
/>
|
||
<label className="form-label">Slug</label>
|
||
<input
|
||
className="form-input"
|
||
value={mainForm.slug}
|
||
onChange={(e) => setMainForm((f) => ({ ...f, slug: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Beschreibung</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={3}
|
||
value={mainForm.description}
|
||
onChange={(e) => setMainForm((f) => ({ ...f, description: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Sortierung (Zahl, optional)</label>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
inputMode="numeric"
|
||
value={mainForm.sort_order}
|
||
onChange={(e) => setMainForm((f) => ({ ...f, sort_order: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<div className="skills-catalog-form__actions">
|
||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||
Speichern
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy}
|
||
onClick={closeEditDialog}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
{isSuperadmin ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy}
|
||
onClick={handleDeleteMain}
|
||
>
|
||
Löschen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{editDialog.type === 'category' ? (
|
||
<form className="skills-catalog-form" onSubmit={handleSaveCategory}>
|
||
<label className="form-label">Name</label>
|
||
<input
|
||
className="form-input"
|
||
value={categoryForm.name}
|
||
onChange={(e) => setCategoryForm((f) => ({ ...f, name: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Slug</label>
|
||
<input
|
||
className="form-input"
|
||
value={categoryForm.slug}
|
||
onChange={(e) => setCategoryForm((f) => ({ ...f, slug: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Hauptkategorie (verschieben)</label>
|
||
<select
|
||
className="form-input"
|
||
value={
|
||
categoryForm.main_category_id === '' ? '' : String(categoryForm.main_category_id)
|
||
}
|
||
onChange={(e) =>
|
||
setCategoryForm((f) => ({
|
||
...f,
|
||
main_category_id: e.target.value === '' ? '' : e.target.value
|
||
}))
|
||
}
|
||
disabled={busy}
|
||
>
|
||
<option value="">— keine —</option>
|
||
{sortedMains.map((m) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<label className="form-label">Beschreibung</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={3}
|
||
value={categoryForm.description}
|
||
onChange={(e) => setCategoryForm((f) => ({ ...f, description: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Sortierung (optional)</label>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
inputMode="numeric"
|
||
value={categoryForm.sort_order}
|
||
onChange={(e) => setCategoryForm((f) => ({ ...f, sort_order: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<div className="skills-catalog-form__actions">
|
||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||
Speichern
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy}
|
||
onClick={closeEditDialog}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
{isSuperadmin ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy}
|
||
onClick={handleDeleteCategory}
|
||
>
|
||
Löschen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{editDialog.type === 'skill' ? (
|
||
<form className="skills-catalog-form" onSubmit={handleSaveSkill}>
|
||
<label className="form-label">Name</label>
|
||
<input
|
||
className="form-input"
|
||
value={skillForm.name}
|
||
onChange={(e) => setSkillForm((f) => ({ ...f, name: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Kategorie (verschieben)</label>
|
||
<select
|
||
className="form-input"
|
||
value={
|
||
skillForm.category_id === '' || skillForm.category_id == null
|
||
? ''
|
||
: String(skillForm.category_id)
|
||
}
|
||
onChange={(e) =>
|
||
setSkillForm((f) => ({
|
||
...f,
|
||
category_id: e.target.value === '' ? '' : e.target.value
|
||
}))
|
||
}
|
||
disabled={busy}
|
||
>
|
||
<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"
|
||
value={skillForm.category}
|
||
onChange={(e) => setSkillForm((f) => ({ ...f, category: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Stichwörter</label>
|
||
<input
|
||
className="form-input"
|
||
value={skillForm.keywords}
|
||
onChange={(e) => setSkillForm((f) => ({ ...f, keywords: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Status</label>
|
||
<select
|
||
className="form-input"
|
||
value={skillForm.status}
|
||
onChange={(e) => setSkillForm((f) => ({ ...f, status: e.target.value }))}
|
||
disabled={busy}
|
||
>
|
||
<option value="active">active</option>
|
||
<option value="inactive">inactive</option>
|
||
</select>
|
||
<label className="form-label">Beschreibung</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={4}
|
||
value={skillForm.description}
|
||
onChange={(e) => setSkillForm((f) => ({ ...f, description: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<label className="form-label">Karate-Relevanz (Wiki)</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={3}
|
||
value={skillForm.karate_relevance}
|
||
onChange={(e) =>
|
||
setSkillForm((f) => ({ ...f, karate_relevance: e.target.value }))
|
||
}
|
||
disabled={busy}
|
||
placeholder="Freitext aus Wiki / eigene Erläuterung"
|
||
/>
|
||
<label className="form-label">Relevanzgrad (Wiki, 1–3)</label>
|
||
<select
|
||
className="form-input"
|
||
value={skillForm.relevance_level === '' ? '' : String(skillForm.relevance_level)}
|
||
onChange={(e) =>
|
||
setSkillForm((f) => ({ ...f, relevance_level: e.target.value }))
|
||
}
|
||
disabled={busy}
|
||
>
|
||
<option value="">– nicht gesetzt –</option>
|
||
<option value="1">1</option>
|
||
<option value="2">2</option>
|
||
<option value="3">3</option>
|
||
</select>
|
||
<label className="form-label">Sortierung (optional)</label>
|
||
<input
|
||
className="form-input"
|
||
type="number"
|
||
inputMode="numeric"
|
||
value={skillForm.sort_order}
|
||
onChange={(e) => setSkillForm((f) => ({ ...f, sort_order: e.target.value }))}
|
||
disabled={busy}
|
||
/>
|
||
<div className="skills-catalog-form__actions">
|
||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||
Speichern
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy}
|
||
onClick={closeEditDialog}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
{isSuperadmin ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
disabled={busy}
|
||
onClick={handleDeleteSkill}
|
||
>
|
||
Löschen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|