2026-04-27 11:53:25 +02:00
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
|
|
|
import { useAuth } from '../../context/AuthContext'
|
|
|
|
|
|
import api from '../../utils/api'
|
2026-05-20 11:16:49 +02:00
|
|
|
|
import {
|
|
|
|
|
|
catalogSkillsForSelection,
|
|
|
|
|
|
countCatalogSkillsWithoutCategory,
|
|
|
|
|
|
countCatalogSkillsWithoutMain,
|
|
|
|
|
|
SKILL_CATEGORY_UNASSIGNED_KEY,
|
|
|
|
|
|
SKILL_MAIN_UNASSIGNED_KEY,
|
|
|
|
|
|
SKILL_UNASSIGNED_CATEGORY_LABEL,
|
|
|
|
|
|
SKILL_UNASSIGNED_MAIN_LABEL,
|
|
|
|
|
|
} from '../../utils/skillCatalogTree'
|
2026-04-27 11:53:25 +02:00
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
/** 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)
|
2026-04-27 11:53:25 +02:00
|
|
|
|
const j = i + delta
|
|
|
|
|
|
if (i < 0 || j < 0 || j >= sorted.length) return
|
|
|
|
|
|
const a = sorted[i]
|
|
|
|
|
|
const b = sorted[j]
|
2026-04-27 12:19:22 +02:00
|
|
|
|
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()
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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: '',
|
2026-05-16 11:05:15 +02:00
|
|
|
|
category_id: '',
|
2026-05-20 11:16:49 +02:00
|
|
|
|
main_category_id: '',
|
2026-05-16 11:05:15 +02:00
|
|
|
|
karate_relevance: '',
|
|
|
|
|
|
relevance_level: ''
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const [newMainName, setNewMainName] = useState('')
|
|
|
|
|
|
const [newCategoryName, setNewCategoryName] = useState('')
|
|
|
|
|
|
const [newSkillName, setNewSkillName] = useState('')
|
|
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
/** Aktives Bearbeiten-Modal: Typ + Entitäts-ID */
|
|
|
|
|
|
const [editDialog, setEditDialog] = useState(null)
|
|
|
|
|
|
|
2026-04-27 11:53:25 +02:00
|
|
|
|
const refreshCategories = useCallback(async (mainId) => {
|
2026-05-20 11:16:49 +02:00
|
|
|
|
if (mainId == null || mainId === SKILL_MAIN_UNASSIGNED_KEY || typeof mainId !== 'number') {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-05-20 11:16:49 +02:00
|
|
|
|
const unassignedMainCount = useMemo(() => countCatalogSkillsWithoutMain(catalog), [catalog])
|
|
|
|
|
|
|
|
|
|
|
|
const unassignedCategoryCount = useMemo(
|
|
|
|
|
|
() => countCatalogSkillsWithoutCategory(catalog, selectedMainId),
|
|
|
|
|
|
[catalog, selectedMainId]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const skillsInSelection = useMemo(() => {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
if (selectedCategoryId == null) return []
|
2026-05-20 11:16:49 +02:00
|
|
|
|
return [...catalogSkillsForSelection(catalog, selectedMainId, selectedCategoryId)].sort(
|
|
|
|
|
|
bySortThenName
|
|
|
|
|
|
)
|
|
|
|
|
|
}, [catalog, selectedMainId, selectedCategoryId])
|
2026-04-27 11:53:25 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (!editDialog) return
|
|
|
|
|
|
const onKey = (ev) => {
|
|
|
|
|
|
if (ev.key === 'Escape') setEditDialog(null)
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
2026-04-27 12:19:22 +02:00
|
|
|
|
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) {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
setMainForm({
|
2026-04-27 12:19:22 +02:00
|
|
|
|
name: m.name || '',
|
|
|
|
|
|
slug: m.slug || '',
|
|
|
|
|
|
description: m.description || '',
|
|
|
|
|
|
sort_order: m.sort_order ?? ''
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
setEditDialog({ type: 'main', id: m.id })
|
|
|
|
|
|
}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
function openEditCategory(c) {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
setCategoryForm({
|
2026-04-27 12:19:22 +02:00
|
|
|
|
name: c.name || '',
|
|
|
|
|
|
slug: c.slug || '',
|
|
|
|
|
|
description: c.description || '',
|
|
|
|
|
|
main_category_id: c.main_category_id ?? '',
|
|
|
|
|
|
sort_order: c.sort_order ?? ''
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
setEditDialog({ type: 'category', id: c.id })
|
|
|
|
|
|
}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
function openEditSkill(s) {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
setSkillForm({
|
2026-04-27 12:19:22 +02:00
|
|
|
|
name: s.name || '',
|
|
|
|
|
|
description: s.description || '',
|
|
|
|
|
|
category: s.category || '',
|
|
|
|
|
|
keywords: s.keywords || '',
|
|
|
|
|
|
status: s.status || 'active',
|
|
|
|
|
|
sort_order: s.sort_order ?? '',
|
2026-05-16 11:05:15 +02:00
|
|
|
|
category_id: s.category_id ?? '',
|
2026-05-20 11:16:49 +02:00
|
|
|
|
main_category_id: s.main_category_id ?? '',
|
2026-05-16 11:05:15 +02:00
|
|
|
|
karate_relevance: s.karate_relevance || '',
|
|
|
|
|
|
relevance_level:
|
|
|
|
|
|
s.relevance_level != null && s.relevance_level !== ''
|
|
|
|
|
|
? String(s.relevance_level)
|
|
|
|
|
|
: ''
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
setEditDialog({ type: 'skill', id: s.id })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeEditDialog() {
|
|
|
|
|
|
setEditDialog(null)
|
|
|
|
|
|
}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
/** @returns {Promise<boolean>} true bei Erfolg */
|
2026-04-27 11:53:25 +02:00
|
|
|
|
async function run(op) {
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
setMessage('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
await op()
|
|
|
|
|
|
await bootstrap()
|
2026-05-20 11:16:49 +02:00
|
|
|
|
if (typeof selectedMainId === 'number') await refreshCategories(selectedMainId)
|
2026-04-27 11:53:25 +02:00
|
|
|
|
setMessage('Gespeichert.')
|
2026-04-27 12:19:22 +02:00
|
|
|
|
return true
|
2026-04-27 11:53:25 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError(e.message || String(e))
|
2026-04-27 12:19:22 +02:00
|
|
|
|
return false
|
2026-04-27 11:53:25 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectMain(id) {
|
|
|
|
|
|
setSelectedMainId(id)
|
|
|
|
|
|
setSelectedCategoryId(null)
|
|
|
|
|
|
setSelectedSkillId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectCategory(id) {
|
|
|
|
|
|
setSelectedCategoryId(id)
|
|
|
|
|
|
setSelectedSkillId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
async function handleSwapMain(rowId, delta) {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
await run(async () => {
|
2026-04-27 12:19:22 +02:00
|
|
|
|
await swapNeighborSortById(sortedMains, rowId, delta, (id, data) =>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
api.updateSkillMainCategory(id, data)
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
async function handleSwapCategory(rowId, delta) {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
await run(async () => {
|
2026-04-27 12:19:22 +02:00
|
|
|
|
await swapNeighborSortById(sortedCategories, rowId, delta, (id, data) =>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
api.updateSkillCategory(id, data)
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
async function handleSwapSkill(rowId, delta) {
|
2026-05-20 11:16:49 +02:00
|
|
|
|
const sorted = [...skillsInSelection].sort(bySortThenName)
|
2026-04-27 11:53:25 +02:00
|
|
|
|
await run(async () => {
|
2026-04-27 12:19:22 +02:00
|
|
|
|
await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data))
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSaveMain(e) {
|
|
|
|
|
|
e.preventDefault()
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (!editDialog || editDialog.type !== 'main') return
|
|
|
|
|
|
const ok = await run(async () => {
|
|
|
|
|
|
await api.updateSkillMainCategory(editDialog.id, {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (ok) closeEditDialog()
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSaveCategory(e) {
|
|
|
|
|
|
e.preventDefault()
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (!editDialog || editDialog.type !== 'category') return
|
2026-04-27 11:53:25 +02:00
|
|
|
|
const mid =
|
|
|
|
|
|
categoryForm.main_category_id === '' || categoryForm.main_category_id == null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: Number(categoryForm.main_category_id)
|
2026-04-27 12:19:22 +02:00
|
|
|
|
const ok = await run(async () => {
|
|
|
|
|
|
await api.updateSkillCategory(editDialog.id, {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (ok) {
|
|
|
|
|
|
if (mid != null && mid !== selectedMainId) {
|
|
|
|
|
|
setSelectedMainId(mid)
|
|
|
|
|
|
setSelectedSkillId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
closeEditDialog()
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSaveSkill(e) {
|
|
|
|
|
|
e.preventDefault()
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (!editDialog || editDialog.type !== 'skill') return
|
2026-05-20 11:16:49 +02:00
|
|
|
|
const cid =
|
2026-04-27 11:53:25 +02:00
|
|
|
|
skillForm.category_id === '' || skillForm.category_id == null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: Number(skillForm.category_id)
|
2026-05-20 11:16:49 +02:00
|
|
|
|
const mid =
|
|
|
|
|
|
cid == null &&
|
|
|
|
|
|
skillForm.main_category_id !== '' &&
|
|
|
|
|
|
skillForm.main_category_id != null
|
|
|
|
|
|
? Number(skillForm.main_category_id)
|
|
|
|
|
|
: null
|
2026-04-27 12:19:22 +02:00
|
|
|
|
const ok = await run(async () => {
|
2026-05-20 11:16:49 +02:00
|
|
|
|
const payload = {
|
2026-04-27 11:53:25 +02:00
|
|
|
|
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),
|
2026-05-16 11:05:15 +02:00
|
|
|
|
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
|
2026-05-20 11:16:49 +02:00
|
|
|
|
: Number(skillForm.relevance_level),
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cid == null) {
|
|
|
|
|
|
payload.main_category_id = mid
|
|
|
|
|
|
}
|
|
|
|
|
|
await api.updateSkill(editDialog.id, payload)
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (ok) {
|
2026-05-20 11:16:49 +02:00
|
|
|
|
if (cid != null) {
|
2026-04-27 12:19:22 +02:00
|
|
|
|
const cat = allCategories.find((c) => c.id === cid)
|
|
|
|
|
|
if (cat?.main_category_id) {
|
|
|
|
|
|
setSelectedMainId(cat.main_category_id)
|
|
|
|
|
|
setSelectedCategoryId(cid)
|
|
|
|
|
|
}
|
2026-05-20 11:16:49 +02:00
|
|
|
|
} else if (mid == null) {
|
|
|
|
|
|
setSelectedMainId(SKILL_MAIN_UNASSIGNED_KEY)
|
|
|
|
|
|
setSelectedCategoryId(SKILL_CATEGORY_UNASSIGNED_KEY)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSelectedMainId(mid)
|
|
|
|
|
|
setSelectedCategoryId(SKILL_CATEGORY_UNASSIGNED_KEY)
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
2026-04-27 12:19:22 +02:00
|
|
|
|
closeEditDialog()
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-05-20 11:16:49 +02:00
|
|
|
|
if (selectedMainId == null || selectedMainId === SKILL_MAIN_UNASSIGNED_KEY) return
|
2026-04-27 11:53:25 +02:00
|
|
|
|
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 () => {
|
2026-05-20 11:16:49 +02:00
|
|
|
|
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)
|
2026-04-27 11:53:25 +02:00
|
|
|
|
setNewSkillName('')
|
|
|
|
|
|
setSelectedSkillId(created.id)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDeleteMain() {
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (!editDialog || editDialog.type !== 'main' || !isSuperadmin) return
|
2026-04-27 11:53:25 +02:00
|
|
|
|
if (!window.confirm('Hauptkategorie wirklich löschen?')) return
|
2026-04-27 12:19:22 +02:00
|
|
|
|
const id = editDialog.id
|
|
|
|
|
|
const ok = await run(async () => {
|
|
|
|
|
|
await api.deleteSkillMainCategory(id)
|
|
|
|
|
|
if (selectedMainId === id) {
|
|
|
|
|
|
setSelectedMainId(null)
|
|
|
|
|
|
setSelectedCategoryId(null)
|
|
|
|
|
|
setSelectedSkillId(null)
|
|
|
|
|
|
}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (ok) closeEditDialog()
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDeleteCategory() {
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (!editDialog || editDialog.type !== 'category' || !isSuperadmin) return
|
2026-04-27 11:53:25 +02:00
|
|
|
|
if (!window.confirm('Kategorie wirklich löschen?')) return
|
2026-04-27 12:19:22 +02:00
|
|
|
|
const id = editDialog.id
|
|
|
|
|
|
const ok = await run(async () => {
|
|
|
|
|
|
await api.deleteSkillCategory(id)
|
|
|
|
|
|
if (selectedCategoryId === id) {
|
|
|
|
|
|
setSelectedCategoryId(null)
|
|
|
|
|
|
setSelectedSkillId(null)
|
|
|
|
|
|
}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (ok) closeEditDialog()
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDeleteSkill() {
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (!editDialog || editDialog.type !== 'skill' || !isSuperadmin) return
|
2026-04-27 11:53:25 +02:00
|
|
|
|
if (!window.confirm('Fähigkeit wirklich löschen?')) return
|
2026-04-27 12:19:22 +02:00
|
|
|
|
const id = editDialog.id
|
|
|
|
|
|
const ok = await run(async () => {
|
|
|
|
|
|
await api.deleteSkill(id)
|
|
|
|
|
|
if (selectedSkillId === id) setSelectedSkillId(null)
|
2026-04-27 11:53:25 +02:00
|
|
|
|
})
|
2026-04-27 12:19:22 +02:00
|
|
|
|
if (ok) closeEditDialog()
|
2026-04-27 11:53:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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> →{' '}
|
2026-05-20 11:16:49 +02:00
|
|
|
|
<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.
|
2026-04-27 11:53:25 +02:00
|
|
|
|
</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">
|
2026-05-20 11:16:49 +02:00
|
|
|
|
{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}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
{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"
|
2026-04-27 12:19:22 +02:00
|
|
|
|
aria-label="Nach oben sortieren"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
stop(e)
|
|
|
|
|
|
handleSwapMain(m.id, -1)
|
|
|
|
|
|
}}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
>
|
|
|
|
|
|
↑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary btn-tiny"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
title="Nach unten"
|
2026-04-27 12:19:22 +02:00
|
|
|
|
aria-label="Nach unten sortieren"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
stop(e)
|
|
|
|
|
|
handleSwapMain(m.id, 1)
|
|
|
|
|
|
}}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
>
|
|
|
|
|
|
↓
|
|
|
|
|
|
</button>
|
2026-04-27 12:19:22 +02:00
|
|
|
|
<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>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
</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>
|
2026-05-20 11:16:49 +02:00
|
|
|
|
) : 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>
|
|
|
|
|
|
</>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<ul className="skills-catalog-list">
|
2026-05-20 11:16:49 +02:00
|
|
|
|
{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}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
{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"
|
2026-04-27 12:19:22 +02:00
|
|
|
|
aria-label="Nach oben sortieren"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
stop(e)
|
|
|
|
|
|
handleSwapCategory(c.id, -1)
|
|
|
|
|
|
}}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
>
|
|
|
|
|
|
↑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary btn-tiny"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
title="Nach unten"
|
2026-04-27 12:19:22 +02:00
|
|
|
|
aria-label="Nach unten sortieren"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
stop(e)
|
|
|
|
|
|
handleSwapCategory(c.id, 1)
|
|
|
|
|
|
}}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
>
|
|
|
|
|
|
↓
|
|
|
|
|
|
</button>
|
2026-04-27 12:19:22 +02:00
|
|
|
|
<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>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
</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 ? (
|
2026-05-20 11:16:49 +02:00
|
|
|
|
<p className="muted skills-catalog-placeholder">
|
|
|
|
|
|
Zuerst eine Kategorie oder „{SKILL_UNASSIGNED_CATEGORY_LABEL}“ wählen.
|
|
|
|
|
|
</p>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<ul className="skills-catalog-list">
|
2026-05-20 11:16:49 +02:00
|
|
|
|
{skillsInSelection.map((s) => (
|
2026-04-27 11:53:25 +02:00
|
|
|
|
<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"
|
2026-04-27 12:19:22 +02:00
|
|
|
|
aria-label="Nach oben sortieren"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
stop(e)
|
|
|
|
|
|
handleSwapSkill(s.id, -1)
|
|
|
|
|
|
}}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
>
|
|
|
|
|
|
↑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary btn-tiny"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
title="Nach unten"
|
2026-04-27 12:19:22 +02:00
|
|
|
|
aria-label="Nach unten sortieren"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
stop(e)
|
|
|
|
|
|
handleSwapSkill(s.id, 1)
|
|
|
|
|
|
}}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
>
|
|
|
|
|
|
↓
|
|
|
|
|
|
</button>
|
2026-04-27 12:19:22 +02:00
|
|
|
|
<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>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
{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"
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
2026-04-27 11:53:25 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-04-27 12:19:22 +02:00
|
|
|
|
<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}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
{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>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-04-27 12:19:22 +02:00
|
|
|
|
{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}
|
|
|
|
|
|
>
|
2026-05-20 11:16:49 +02:00
|
|
|
|
<option value="">{SKILL_UNASSIGNED_CATEGORY_LABEL}</option>
|
2026-04-27 12:19:22 +02:00
|
|
|
|
{allCategories.map((c) => (
|
|
|
|
|
|
<option key={c.id} value={c.id}>
|
|
|
|
|
|
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
2026-05-20 11:16:49 +02:00
|
|
|
|
{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}
|
2026-04-27 12:19:22 +02:00
|
|
|
|
<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}
|
|
|
|
|
|
/>
|
2026-05-16 11:05:15 +02:00
|
|
|
|
<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>
|
2026-04-27 12:19:22 +02:00
|
|
|
|
<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>
|
2026-04-27 11:53:25 +02:00
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
2026-04-27 12:19:22 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-04-27 11:53:25 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|