shinkan-jinkendo/frontend/src/components/admin/SkillsCatalogAdmin.jsx

839 lines
28 KiB
React
Raw Normal View History

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>
)
}