839 lines
28 KiB
React
839 lines
28 KiB
React
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||
|
|
import { useAuth } from '../../context/AuthContext'
|
||
|
|
import api from '../../utils/api'
|
||
|
|
|
||
|
|
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')
|
||
|
|
}
|
||
|
|
|
||
|
|
async function swapNeighborSort(list, index, delta, updateId) {
|
||
|
|
const sorted = [...list].sort(bySortThenName)
|
||
|
|
const i = sorted.findIndex((x) => x.id === list[index]?.id)
|
||
|
|
const j = i + delta
|
||
|
|
if (i < 0 || j < 0 || j >= sorted.length) return
|
||
|
|
const a = sorted[i]
|
||
|
|
const b = sorted[j]
|
||
|
|
const oa = a.sort_order != null ? Number(a.sort_order) : (i + 1) * 10
|
||
|
|
const ob = b.sort_order != null ? Number(b.sort_order) : (j + 1) * 10
|
||
|
|
await updateId(a.id, { sort_order: ob })
|
||
|
|
await updateId(b.id, { sort_order: oa })
|
||
|
|
}
|
||
|
|
|
||
|
|
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: ''
|
||
|
|
})
|
||
|
|
|
||
|
|
const [newMainName, setNewMainName] = useState('')
|
||
|
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||
|
|
const [newSkillName, setNewSkillName] = useState('')
|
||
|
|
|
||
|
|
const refreshCategories = useCallback(async (mainId) => {
|
||
|
|
if (mainId == null) {
|
||
|
|
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 skillsInCategory = useMemo(() => {
|
||
|
|
if (selectedCategoryId == null) return []
|
||
|
|
return catalog
|
||
|
|
.filter((s) => s.category_id === selectedCategoryId)
|
||
|
|
.sort(bySortThenName)
|
||
|
|
}, [catalog, selectedCategoryId])
|
||
|
|
|
||
|
|
const selectedMain = useMemo(
|
||
|
|
() => mains.find((m) => m.id === selectedMainId) || null,
|
||
|
|
[mains, selectedMainId]
|
||
|
|
)
|
||
|
|
const selectedCategory = useMemo(
|
||
|
|
() => categories.find((c) => c.id === selectedCategoryId) || null,
|
||
|
|
[categories, selectedCategoryId]
|
||
|
|
)
|
||
|
|
const selectedSkill = useMemo(
|
||
|
|
() => catalog.find((s) => s.id === selectedSkillId) || null,
|
||
|
|
[catalog, selectedSkillId]
|
||
|
|
)
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!selectedMain) {
|
||
|
|
setMainForm({ name: '', slug: '', description: '', sort_order: '' })
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setMainForm({
|
||
|
|
name: selectedMain.name || '',
|
||
|
|
slug: selectedMain.slug || '',
|
||
|
|
description: selectedMain.description || '',
|
||
|
|
sort_order: selectedMain.sort_order ?? ''
|
||
|
|
})
|
||
|
|
}, [selectedMain])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!selectedCategory) {
|
||
|
|
setCategoryForm({
|
||
|
|
name: '',
|
||
|
|
slug: '',
|
||
|
|
description: '',
|
||
|
|
main_category_id: '',
|
||
|
|
sort_order: ''
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setCategoryForm({
|
||
|
|
name: selectedCategory.name || '',
|
||
|
|
slug: selectedCategory.slug || '',
|
||
|
|
description: selectedCategory.description || '',
|
||
|
|
main_category_id: selectedCategory.main_category_id ?? '',
|
||
|
|
sort_order: selectedCategory.sort_order ?? ''
|
||
|
|
})
|
||
|
|
}, [selectedCategory])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!selectedSkill) {
|
||
|
|
setSkillForm({
|
||
|
|
name: '',
|
||
|
|
description: '',
|
||
|
|
category: '',
|
||
|
|
keywords: '',
|
||
|
|
status: 'active',
|
||
|
|
sort_order: '',
|
||
|
|
category_id: ''
|
||
|
|
})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setSkillForm({
|
||
|
|
name: selectedSkill.name || '',
|
||
|
|
description: selectedSkill.description || '',
|
||
|
|
category: selectedSkill.category || '',
|
||
|
|
keywords: selectedSkill.keywords || '',
|
||
|
|
status: selectedSkill.status || 'active',
|
||
|
|
sort_order: selectedSkill.sort_order ?? '',
|
||
|
|
category_id: selectedSkill.category_id ?? ''
|
||
|
|
})
|
||
|
|
}, [selectedSkill])
|
||
|
|
|
||
|
|
async function run(op) {
|
||
|
|
setBusy(true)
|
||
|
|
setMessage('')
|
||
|
|
try {
|
||
|
|
await op()
|
||
|
|
await bootstrap()
|
||
|
|
if (selectedMainId) await refreshCategories(selectedMainId)
|
||
|
|
setMessage('Gespeichert.')
|
||
|
|
} catch (e) {
|
||
|
|
setError(e.message || String(e))
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectMain(id) {
|
||
|
|
setSelectedMainId(id)
|
||
|
|
setSelectedCategoryId(null)
|
||
|
|
setSelectedSkillId(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
function selectCategory(id) {
|
||
|
|
setSelectedCategoryId(id)
|
||
|
|
setSelectedSkillId(null)
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSwapMain(row, delta) {
|
||
|
|
const idx = sortedMains.findIndex((m) => m.id === row.id)
|
||
|
|
await run(async () => {
|
||
|
|
await swapNeighborSort(sortedMains, idx, delta, (id, data) =>
|
||
|
|
api.updateSkillMainCategory(id, data)
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSwapCategory(row, delta) {
|
||
|
|
const idx = sortedCategories.findIndex((c) => c.id === row.id)
|
||
|
|
await run(async () => {
|
||
|
|
await swapNeighborSort(sortedCategories, idx, delta, (id, data) =>
|
||
|
|
api.updateSkillCategory(id, data)
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSwapSkill(row, delta) {
|
||
|
|
const sorted = [...skillsInCategory].sort(bySortThenName)
|
||
|
|
const idx = sorted.findIndex((s) => s.id === row.id)
|
||
|
|
await run(async () => {
|
||
|
|
await swapNeighborSort(sorted, idx, delta, (id, data) => api.updateSkill(id, data))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSaveMain(e) {
|
||
|
|
e.preventDefault()
|
||
|
|
if (!selectedMainId) return
|
||
|
|
await run(async () => {
|
||
|
|
await api.updateSkillMainCategory(selectedMainId, {
|
||
|
|
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)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSaveCategory(e) {
|
||
|
|
e.preventDefault()
|
||
|
|
if (!selectedCategoryId) return
|
||
|
|
const mid =
|
||
|
|
categoryForm.main_category_id === '' || categoryForm.main_category_id == null
|
||
|
|
? null
|
||
|
|
: Number(categoryForm.main_category_id)
|
||
|
|
await run(async () => {
|
||
|
|
await api.updateSkillCategory(selectedCategoryId, {
|
||
|
|
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 (mid != null && mid !== selectedMainId) {
|
||
|
|
setSelectedMainId(mid)
|
||
|
|
setSelectedSkillId(null)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleSaveSkill(e) {
|
||
|
|
e.preventDefault()
|
||
|
|
if (!selectedSkillId) return
|
||
|
|
let cid =
|
||
|
|
skillForm.category_id === '' || skillForm.category_id == null
|
||
|
|
? null
|
||
|
|
: Number(skillForm.category_id)
|
||
|
|
if (cid == null && selectedSkill?.category_id) {
|
||
|
|
cid = selectedSkill.category_id
|
||
|
|
}
|
||
|
|
await run(async () => {
|
||
|
|
await api.updateSkill(selectedSkillId, {
|
||
|
|
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
|
||
|
|
})
|
||
|
|
})
|
||
|
|
if (cid != null && cid !== selectedCategoryId) {
|
||
|
|
const cat = allCategories.find((c) => c.id === cid)
|
||
|
|
if (cat?.main_category_id) {
|
||
|
|
setSelectedMainId(cat.main_category_id)
|
||
|
|
setSelectedCategoryId(cid)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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) 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 created = await api.createSkill({
|
||
|
|
name,
|
||
|
|
category_id: selectedCategoryId,
|
||
|
|
status: 'active'
|
||
|
|
})
|
||
|
|
setNewSkillName('')
|
||
|
|
setSelectedSkillId(created.id)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleDeleteMain() {
|
||
|
|
if (!selectedMainId || !isSuperadmin) return
|
||
|
|
if (!window.confirm('Hauptkategorie wirklich löschen?')) return
|
||
|
|
await run(async () => {
|
||
|
|
await api.deleteSkillMainCategory(selectedMainId)
|
||
|
|
setSelectedMainId(null)
|
||
|
|
setSelectedCategoryId(null)
|
||
|
|
setSelectedSkillId(null)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleDeleteCategory() {
|
||
|
|
if (!selectedCategoryId || !isSuperadmin) return
|
||
|
|
if (!window.confirm('Kategorie wirklich löschen?')) return
|
||
|
|
await run(async () => {
|
||
|
|
await api.deleteSkillCategory(selectedCategoryId)
|
||
|
|
setSelectedCategoryId(null)
|
||
|
|
setSelectedSkillId(null)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleDeleteSkill() {
|
||
|
|
if (!selectedSkillId || !isSuperadmin) return
|
||
|
|
if (!window.confirm('Fähigkeit wirklich löschen?')) return
|
||
|
|
await run(async () => {
|
||
|
|
await api.deleteSkill(selectedSkillId)
|
||
|
|
setSelectedSkillId(null)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const detailMode = selectedSkillId
|
||
|
|
? 'skill'
|
||
|
|
: selectedCategoryId
|
||
|
|
? 'category'
|
||
|
|
: selectedMainId
|
||
|
|
? 'main'
|
||
|
|
: 'none'
|
||
|
|
|
||
|
|
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>. Reihenfolge mit den Pfeiltasten; Zuordnungen und Texte im
|
||
|
|
Bereich „Bearbeiten“.
|
||
|
|
</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">
|
||
|
|
{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"
|
||
|
|
onClick={() => handleSwapMain(m, -1)}
|
||
|
|
>
|
||
|
|
↑
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary btn-tiny"
|
||
|
|
disabled={busy}
|
||
|
|
title="Nach unten"
|
||
|
|
onClick={() => handleSwapMain(m, 1)}
|
||
|
|
>
|
||
|
|
↓
|
||
|
|
</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>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<ul className="skills-catalog-list">
|
||
|
|
{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"
|
||
|
|
onClick={() => handleSwapCategory(c, -1)}
|
||
|
|
>
|
||
|
|
↑
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary btn-tiny"
|
||
|
|
disabled={busy}
|
||
|
|
title="Nach unten"
|
||
|
|
onClick={() => handleSwapCategory(c, 1)}
|
||
|
|
>
|
||
|
|
↓
|
||
|
|
</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 wählen.</p>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<ul className="skills-catalog-list">
|
||
|
|
{skillsInCategory.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"
|
||
|
|
onClick={() => handleSwapSkill(s, -1)}
|
||
|
|
>
|
||
|
|
↑
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary btn-tiny"
|
||
|
|
disabled={busy}
|
||
|
|
title="Nach unten"
|
||
|
|
onClick={() => handleSwapSkill(s, 1)}
|
||
|
|
>
|
||
|
|
↓
|
||
|
|
</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>
|
||
|
|
|
||
|
|
<section className="skills-catalog-detail" aria-label="Bearbeiten">
|
||
|
|
<h3 className="skills-catalog-detail__title">Bearbeiten</h3>
|
||
|
|
{detailMode === 'none' ? (
|
||
|
|
<p className="muted">
|
||
|
|
Wählen Sie eine Hauptkategorie, Kategorie oder Fähigkeit in den Spalten oben.
|
||
|
|
</p>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{detailMode === 'main' ? (
|
||
|
|
<form className="skills-catalog-form" onSubmit={handleSaveMain}>
|
||
|
|
<h4 className="skills-catalog-form__heading">Hauptkategorie</h4>
|
||
|
|
<label className="form-label">Name</label>
|
||
|
|
<input
|
||
|
|
className="form-input"
|
||
|
|
value={mainForm.name}
|
||
|
|
onChange={(e) => setMainForm((f) => ({ ...f, name: e.target.value }))}
|
||
|
|
disabled={busy}
|
||
|
|
/>
|
||
|
|
<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"
|
||
|
|
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>
|
||
|
|
{isSuperadmin ? (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
disabled={busy}
|
||
|
|
onClick={handleDeleteMain}
|
||
|
|
>
|
||
|
|
Löschen
|
||
|
|
</button>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{detailMode === 'category' ? (
|
||
|
|
<form className="skills-catalog-form" onSubmit={handleSaveCategory}>
|
||
|
|
<h4 className="skills-catalog-form__heading">Kategorie</h4>
|
||
|
|
<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"
|
||
|
|
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>
|
||
|
|
{isSuperadmin ? (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
disabled={busy}
|
||
|
|
onClick={handleDeleteCategory}
|
||
|
|
>
|
||
|
|
Löschen
|
||
|
|
</button>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
{detailMode === 'skill' ? (
|
||
|
|
<form className="skills-catalog-form" onSubmit={handleSaveSkill}>
|
||
|
|
<h4 className="skills-catalog-form__heading">Fähigkeit</h4>
|
||
|
|
<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}
|
||
|
|
required
|
||
|
|
>
|
||
|
|
{allCategories.map((c) => (
|
||
|
|
<option key={c.id} value={c.id}>
|
||
|
|
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
<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">Sortierung (optional)</label>
|
||
|
|
<input
|
||
|
|
className="form-input"
|
||
|
|
type="number"
|
||
|
|
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>
|
||
|
|
{isSuperadmin ? (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
disabled={busy}
|
||
|
|
onClick={handleDeleteSkill}
|
||
|
|
>
|
||
|
|
Löschen
|
||
|
|
</button>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
) : null}
|
||
|
|
</section>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|