feat: add skill main categories and enhance skills catalog functionality
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m57s

- Introduced endpoints for managing skill main categories, including CRUD operations.
- Enhanced skills catalog endpoint to support hierarchical sorting by main category and category.
- Updated frontend API utility functions to include new skill main category operations.
- Improved admin interface for skills management with new layout and styles.
- Documented changes in the changelog for better tracking of new features and updates.
This commit is contained in:
Lars 2026-04-27 11:53:25 +02:00
parent f1ee1eec7e
commit e8b7e62832
7 changed files with 2231 additions and 806 deletions

View File

@ -3,7 +3,9 @@ Catalog Management Endpoints for Shinkan Jinkendo
Admin-verwaltbare Stammdaten für Übungen, Fokusbereiche, Stile, etc.
"""
import re
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from db import get_db, get_cursor, r2d
@ -12,6 +14,131 @@ from auth import require_auth
router = APIRouter(prefix="/api", tags=["catalogs"])
def _slugify_skill_label(text: str) -> str:
t = (text or "").strip().lower()
t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE)
t = re.sub(r"_+", "_", t).strip("_")
return (t[:48] or "gruppe")
# ════════════════════════════════════════════════════════════════════════
# SKILL MAIN CATEGORIES (Hauptgruppen, z. B. „KARATE Fähigkeiten“)
# ════════════════════════════════════════════════════════════════════════
@router.get("/skill-main-categories")
def list_skill_main_categories(session=Depends(require_auth)):
"""Alle Hauptkategorien für den Fähigkeitskatalog (sortiert)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT * FROM skill_main_categories ORDER BY sort_order NULLS LAST, name"
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/skill-main-categories")
def create_skill_main_category(data: dict, session=Depends(require_auth)):
role = session.get("role")
if role not in ("admin", "superadmin"):
raise HTTPException(403, "Nur Admins dürfen Hauptkategorien anlegen")
name = (data.get("name") or "").strip()
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
slug = (data.get("slug") or "").strip() or _slugify_skill_label(name)
with get_db() as conn:
cur = get_cursor(conn)
try:
cur.execute(
"""
INSERT INTO skill_main_categories (name, slug, description, sort_order)
VALUES (%s, %s, %s, %s)
RETURNING id
""",
(
name,
slug,
data.get("description"),
data.get("sort_order", 99),
),
)
mid = cur.fetchone()["id"]
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(409, "Name oder Slug bereits vergeben") from e
raise
cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (mid,))
return r2d(cur.fetchone())
@router.put("/skill-main-categories/{main_id}")
def update_skill_main_category(main_id: int, data: dict, session=Depends(require_auth)):
role = session.get("role")
if role not in ("admin", "superadmin"):
raise HTTPException(403, "Nur Admins dürfen Hauptkategorien bearbeiten")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM skill_main_categories WHERE id = %s", (main_id,))
if not cur.fetchone():
raise HTTPException(404, "Hauptkategorie nicht gefunden")
sets = []
vals = []
for key in ("name", "slug", "description", "sort_order"):
if key in data:
sets.append(f"{key} = %s")
vals.append(data[key])
if not sets:
cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (main_id,))
return r2d(cur.fetchone())
sets.append("updated_at = NOW()")
vals.append(main_id)
try:
cur.execute(
f"UPDATE skill_main_categories SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
except Exception as e:
if "unique" in str(e).lower():
raise HTTPException(409, "Name oder Slug bereits vergeben") from e
raise
cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (main_id,))
return r2d(cur.fetchone())
@router.delete("/skill-main-categories/{main_id}")
def delete_skill_main_category(main_id: int, session=Depends(require_auth)):
role = session.get("role")
if role != "superadmin":
raise HTTPException(403, "Nur Superadmins dürfen Hauptkategorien löschen")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COUNT(*) AS c FROM skill_categories WHERE main_category_id = %s",
(main_id,),
)
if (cur.fetchone() or {}).get("c", 0) > 0:
raise HTTPException(
409,
"Hauptkategorie hat noch Unterkategorien. Bitte zuerst verschieben oder löschen.",
)
cur.execute(
"SELECT COUNT(*) AS c FROM skills WHERE main_category_id = %s",
(main_id,),
)
if (cur.fetchone() or {}).get("c", 0) > 0:
raise HTTPException(
409,
"Hauptkategorie ist noch Fähigkeiten zugeordnet.",
)
cur.execute(
"DELETE FROM skill_main_categories WHERE id = %s RETURNING id",
(main_id,),
)
if not cur.fetchone():
raise HTTPException(404, "Hauptkategorie nicht gefunden")
return {"ok": True}
# ════════════════════════════════════════════════════════════════════════
# FOCUS AREAS
# ════════════════════════════════════════════════════════════════════════
@ -526,24 +653,35 @@ def delete_training_type(type_id: int, session=Depends(require_auth)):
@router.get("/skill-categories")
def list_skill_categories(
status: Optional[str] = Query(default='active'),
session=Depends(require_auth)
main_category_id: Optional[int] = Query(default=None),
session=Depends(require_auth),
):
"""List all skill categories."""
"""List all skill categories (mit Hauptkategorie)."""
with get_db() as conn:
cur = get_cursor(conn)
query = """
SELECT sc.*, pc.name as parent_category_name
SELECT sc.*, pc.name as parent_category_name,
mc.id as main_category_ref_id, mc.name as main_category_name,
mc.slug as main_category_slug, mc.sort_order as main_category_sort
FROM skill_categories sc
LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id
LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id
"""
params = []
where = []
if status:
query += " WHERE sc.status = %s"
where.append("sc.status = %s")
params.append(status)
if main_category_id is not None:
where.append("sc.main_category_id = %s")
params.append(main_category_id)
query += " ORDER BY sc.sort_order, sc.name"
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, sc.name"
cur.execute(query, params)
rows = cur.fetchall()
@ -557,34 +695,56 @@ def create_skill_category(data: dict, session=Depends(require_auth)):
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Fähigkeitsbereiche erstellen")
name = data.get('name')
name = (data.get("name") or "").strip()
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
main_category_id = data.get("main_category_id")
if main_category_id is not None:
main_category_id = int(main_category_id)
slug = (data.get("slug") or "").strip() or _slugify_skill_label(name)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
INSERT INTO skill_categories (name, description, parent_category_id, sort_order, status)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""", (
name,
data.get('description'),
data.get('parent_category_id'),
data.get('sort_order', 99),
data.get('status', 'active')
))
try:
cur.execute(
"""
INSERT INTO skill_categories (
name, slug, description, parent_category_id, main_category_id,
sort_order, status
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
name,
slug,
data.get("description"),
data.get("parent_category_id"),
main_category_id,
data.get("sort_order", 99),
data.get("status", "active"),
),
)
cat_id = cur.fetchone()["id"]
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(409, "Name oder Slug schon vergeben") from e
raise
cat_id = cur.fetchone()['id']
conn.commit()
cur.execute("""
SELECT sc.*, pc.name as parent_category_name
cur.execute(
"""
SELECT sc.*, pc.name as parent_category_name,
mc.name as main_category_name, mc.slug as main_category_slug
FROM skill_categories sc
LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id
LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id
WHERE sc.id = %s
""", (cat_id,))
""",
(cat_id,),
)
return r2d(cur.fetchone())
@ -598,32 +758,59 @@ def update_skill_category(cat_id: int, data: dict, session=Depends(require_auth)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
UPDATE skill_categories SET
name = %s,
description = %s,
parent_category_id = %s,
sort_order = %s,
status = %s,
updated_at = NOW()
WHERE id = %s
""", (
data.get('name'),
data.get('description'),
data.get('parent_category_id'),
data.get('sort_order'),
data.get('status'),
cat_id
))
cur.execute("SELECT id FROM skill_categories WHERE id = %s", (cat_id,))
if not cur.fetchone():
raise HTTPException(404, "Kategorie nicht gefunden")
conn.commit()
sets: list = []
vals: list = []
for key in (
"name",
"slug",
"description",
"parent_category_id",
"main_category_id",
"sort_order",
"status",
):
if key in data:
sets.append(f"{key} = %s")
vals.append(data[key])
if sets:
sets.append("updated_at = NOW()")
vals.append(cat_id)
try:
cur.execute(
f"UPDATE skill_categories SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
except Exception as e:
if "unique" in str(e).lower():
raise HTTPException(409, "Name oder Slug schon vergeben") from e
raise
cur.execute("""
SELECT sc.*, pc.name as parent_category_name
cur.execute(
"SELECT main_category_id FROM skill_categories WHERE id = %s",
(cat_id,),
)
effective_main = cur.fetchone()["main_category_id"]
if effective_main is not None:
cur.execute(
"UPDATE skills SET main_category_id = %s WHERE category_id = %s",
(effective_main, cat_id),
)
cur.execute(
"""
SELECT sc.*, pc.name as parent_category_name,
mc.name as main_category_name, mc.slug as main_category_slug
FROM skill_categories sc
LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id
LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id
WHERE sc.id = %s
""", (cat_id,))
""",
(cat_id,),
)
return r2d(cur.fetchone())
@ -636,8 +823,21 @@ def delete_skill_category(cat_id: int, session=Depends(require_auth)):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM skill_categories WHERE id = %s", (cat_id,))
conn.commit()
cur.execute("SELECT COUNT(*) AS c FROM skills WHERE category_id = %s", (cat_id,))
if (cur.fetchone() or {}).get("c", 0) > 0:
raise HTTPException(
409,
"Kategorie noch Fähigkeiten zugeordnet bitte zuerst verschieben oder löschen.",
)
cur.execute(
"SELECT COUNT(*) AS c FROM skill_categories WHERE parent_category_id = %s",
(cat_id,),
)
if (cur.fetchone() or {}).get("c", 0) > 0:
raise HTTPException(409, "Kategorie hat Unterkategorien.")
cur.execute("DELETE FROM skill_categories WHERE id = %s RETURNING id", (cat_id,))
if not cur.fetchone():
raise HTTPException(404, "Kategorie nicht gefunden")
return {"ok": True}

View File

@ -5,7 +5,9 @@ Handles CRUD operations for skills and training methods.
Read access for all authenticated users.
Write access for admins only.
"""
from typing import Optional
import json
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from db import get_db, get_cursor, r2d
@ -14,6 +16,17 @@ from auth import require_auth
router = APIRouter(prefix="/api", tags=["skills"])
def _main_id_for_category(cur, category_id: Optional[int]) -> Optional[int]:
if not category_id:
return None
cur.execute(
"SELECT main_category_id FROM skill_categories WHERE id = %s",
(category_id,),
)
row = cur.fetchone()
return row["main_category_id"] if row else None
# ── List Skills ───────────────────────────────────────────────────────
@router.get("/skills")
def list_skills(
@ -56,6 +69,57 @@ def list_skills(
return [r2d(r) for r in rows]
# ── Katalog-Ansicht (Hierarchie-Sortierung, Admin) ───────────────────────
@router.get("/skills/catalog")
def list_skills_catalog(
status: Optional[str] = Query(
default="active",
description="'active', 'inactive' oder 'all' (nur Admin)",
),
session=Depends(require_auth),
):
"""
Fähigkeiten für Admin-Katalog: sortiert nach Hauptgruppe Kategorie Sortierung Name.
status=all nur für admin/superadmin.
"""
role = session.get("role")
admin = role in ("admin", "superadmin")
with get_db() as conn:
cur = get_cursor(conn)
q = """
SELECT s.*,
mc.name AS catalog_main_category_name,
mc.sort_order AS catalog_main_sort,
mc.slug AS catalog_main_slug,
sc.name AS catalog_category_name,
sc.sort_order AS catalog_category_sort,
sc.slug AS catalog_category_slug
FROM skills s
LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
LEFT JOIN skill_categories sc ON s.category_id = sc.id
WHERE 1=1
"""
params: list[Any] = []
if status == "all":
if not admin:
raise HTTPException(403, "Nur Admins dürfen alle Status sehen")
elif status:
q += " AND s.status = %s"
params.append(status)
else:
q += " AND s.status = %s"
params.append("active")
q += """
ORDER BY mc.sort_order NULLS LAST,
sc.sort_order NULLS LAST,
s.sort_order NULLS LAST,
s.name
"""
cur.execute(q, params)
return [r2d(r) for r in cur.fetchall()]
# ── Get Skill ─────────────────────────────────────────────────────────
@router.get("/skills/{skill_id}")
def get_skill(skill_id: int, session=Depends(require_auth)):
@ -84,24 +148,46 @@ def create_skill(data: dict, session=Depends(require_auth)):
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
cat_id = data.get("category_id")
if cat_id is not None:
cat_id = int(cat_id)
main_id = data.get("main_category_id")
if main_id is not None:
main_id = int(main_id)
focus = data.get("focus_areas")
if isinstance(focus, (list, dict)):
focus = json.dumps(focus)
with get_db() as conn:
cur = get_cursor(conn)
if cat_id and main_id is None:
main_id = _main_id_for_category(cur, cat_id)
cur.execute("""
INSERT INTO skills (name, category, description, importance, keywords, status)
VALUES (%s, %s, %s, %s, %s, %s)
cur.execute(
"""
INSERT INTO skills (
name, category, description, importance, keywords, status,
category_id, main_category_id, focus_areas, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s)
RETURNING id
""", (
name,
data.get('category'),
data.get('description'),
data.get('importance'),
data.get('keywords'),
data.get('status', 'active')
))
""",
(
name,
data.get("category"),
data.get("description"),
data.get("importance"),
data.get("keywords"),
data.get("status", "active"),
cat_id,
main_id,
focus if focus is not None else "[]",
data.get("sort_order"),
),
)
skill_id = cur.fetchone()['id']
conn.commit()
skill_id = cur.fetchone()["id"]
return get_skill(skill_id, session)
@ -122,28 +208,44 @@ def update_skill(skill_id: int, data: dict, session=Depends(require_auth)):
if not cur.fetchone():
raise HTTPException(404, "Fähigkeit nicht gefunden")
# Update
cur.execute("""
UPDATE skills SET
name = %s,
category = %s,
description = %s,
importance = %s,
keywords = %s,
status = %s,
updated_at = NOW()
WHERE id = %s
""", (
data.get('name'),
data.get('category'),
data.get('description'),
data.get('importance'),
data.get('keywords'),
data.get('status'),
skill_id
))
sets: list = []
vals: list = []
for key in (
"name",
"category",
"description",
"importance",
"keywords",
"status",
"category_id",
"main_category_id",
"sort_order",
):
if key in data:
sets.append(f"{key} = %s")
vals.append(data[key])
if "focus_areas" in data:
fa = data["focus_areas"]
if isinstance(fa, (list, dict)):
fa = json.dumps(fa)
sets.append("focus_areas = %s::jsonb")
vals.append(fa if fa is not None else "[]")
conn.commit()
if sets:
sets.append("updated_at = NOW()")
vals.append(skill_id)
cur.execute(
f"UPDATE skills SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
if "category_id" in data and "main_category_id" not in data:
mid = _main_id_for_category(cur, data.get("category_id"))
if mid is not None:
cur.execute(
"UPDATE skills SET main_category_id = %s WHERE id = %s",
(mid, skill_id),
)
return get_skill(skill_id, session)

View File

@ -19,7 +19,7 @@
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
--capture-content-max: 800px;
/* Admin: nutzt volle Hauptspalte bis zu dieser Obergrenze (siehe .app-main:has(.admin-shell)) */
--admin-main-max: min(1560px, calc(100vw - 220px));
--admin-main-max: min(1720px, calc(100vw - 200px));
}
@media (prefers-color-scheme: dark) {
:root {
@ -1040,6 +1040,214 @@ a.analysis-split__nav-item {
}
}
.admin-maturity-header {
margin-bottom: 20px;
}
.admin-maturity-header__title {
font-size: 1.35rem;
font-weight: 700;
margin: 0 0 6px;
letter-spacing: -0.02em;
}
.admin-maturity-header__subtitle {
margin: 0;
max-width: 56rem;
}
.admin-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
}
.admin-tabs__tab {
padding: 10px 16px;
border-radius: 10px 10px 0 0;
border: 1px solid transparent;
background: var(--surface2);
color: var(--text2);
font-size: 14px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.admin-tabs__tab:hover {
color: var(--text1);
background: var(--surface);
}
.admin-tabs__tab--active {
background: var(--surface);
color: var(--accent-dark);
border-color: var(--border);
border-bottom-color: var(--surface);
margin-bottom: -13px;
padding-bottom: 11px;
}
@media (prefers-color-scheme: dark) {
.admin-tabs__tab--active {
color: var(--accent);
}
}
.admin-tabs__panel {
min-width: 0;
}
.admin-matrix-panel__intro {
margin: 0 0 16px;
max-width: 56rem;
}
.skills-catalog-admin__intro {
margin: 0 0 16px;
max-width: 56rem;
}
.skills-catalog-admin__error {
padding: 12px 14px;
border-radius: 10px;
background: var(--warn-bg);
color: var(--warn-text);
margin-bottom: 16px;
}
.skills-catalog-admin__msg {
margin: 0 0 12px;
}
.skills-catalog-layout {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: start;
margin-bottom: 24px;
}
@media (max-width: 1023px) {
.skills-catalog-layout {
grid-template-columns: 1fr;
}
}
.skills-catalog-column {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
min-width: 0;
}
.skills-catalog-column__title {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text3);
margin: 0 0 12px;
}
.skills-catalog-list {
list-style: none;
margin: 0 0 14px;
padding: 0;
max-height: min(42vh, 360px);
overflow-y: auto;
}
.skills-catalog-placeholder {
margin: 0 0 14px;
font-size: 14px;
}
.skills-catalog-row {
display: flex;
align-items: center;
gap: 6px;
border-radius: 8px;
margin-bottom: 4px;
border: 1px solid transparent;
}
.skills-catalog-row--active {
border-color: var(--accent);
background: var(--accent-light);
}
.skills-catalog-row__label {
flex: 1;
min-width: 0;
text-align: left;
padding: 8px 10px;
border: none;
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
border-radius: 7px;
}
.skills-catalog-row__label:hover {
background: var(--surface2);
}
.skills-catalog-row--active .skills-catalog-row__label:hover {
background: transparent;
}
.skills-catalog-row__actions {
display: flex;
gap: 2px;
flex-shrink: 0;
padding-right: 4px;
}
.skills-catalog-row__badge {
display: inline-block;
margin-left: 8px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
}
.skills-catalog-quick {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.skills-catalog-quick .form-label {
margin-bottom: 0;
}
.skills-catalog-detail {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px 20px;
max-width: 720px;
}
.skills-catalog-detail__title {
font-size: 15px;
font-weight: 700;
margin: 0 0 14px;
}
.skills-catalog-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.skills-catalog-form__heading {
font-size: 14px;
font-weight: 600;
margin: 0 0 4px;
color: var(--text2);
}
.skills-catalog-form__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.btn-tiny {
padding: 4px 8px;
font-size: 13px;
line-height: 1.2;
min-width: 2rem;
}
.muted { color: var(--text3); font-size: 13px; }
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }

View File

@ -0,0 +1,734 @@
import React, { useEffect, useState } from 'react'
import { useAuth } from '../../context/AuthContext'
import api from '../../utils/api'
function MultiIdSelect({ label, options, valueIds, onChange, hint }) {
return (
<div>
<label className="form-label">{label}</label>
<select
multiple
className="form-input"
style={{ minHeight: 100, width: '100%' }}
value={(valueIds || []).map(String)}
onChange={(e) => {
const v = Array.from(e.target.selectedOptions, (o) => parseInt(o.value, 10))
onChange(v)
}}
>
{options.map((o) => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
{hint ? (
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{hint}</div>
) : null}
</div>
)
}
export default function MaturityModelsAdminPanel() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const [models, setModels] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [detail, setDetail] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [focusAreas, setFocusAreas] = useState([])
const [styles, setStyles] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [allSkills, setAllSkills] = useState([])
const [newModel, setNewModel] = useState({
name: '',
level_count: 5,
focus_area_ids: [],
style_direction_ids: [],
target_group_ids: [],
status: 'draft'
})
const [meta, setMeta] = useState(null)
const [levelCount, setLevelCount] = useState(5)
const [levelsForm, setLevelsForm] = useState([])
const [cellDraft, setCellDraft] = useState({})
const [skillToAdd, setSkillToAdd] = useState('')
useEffect(() => {
let cancelled = false
;(async () => {
try {
const [m, fa, sd, tg, sk] = await Promise.all([
api.listMaturityModels({}),
api.listFocusAreas({}),
api.listStyleDirections({}),
api.listTargetGroups({}),
api.listSkills({ status: 'active' })
])
if (!cancelled) {
setModels(m)
setFocusAreas(fa)
setStyles(sd)
setTargetGroups(tg)
setAllSkills(sk)
}
} catch (e) {
if (!cancelled) setError(e.message)
}
})()
return () => { cancelled = true }
}, [])
async function refreshModels() {
const m = await api.listMaturityModels({})
setModels(m)
}
async function selectModel(id) {
setSelectedId(id)
setLoading(true)
setError('')
try {
const d = await api.getMaturityModel(id)
setDetail(d)
setMeta({
name: d.name,
description: d.description || '',
focus_area_ids: (d.focus_areas || []).map((x) => x.id),
style_direction_ids: (d.style_directions || []).map((x) => x.id),
target_group_ids: (d.target_groups || []).map((x) => x.id),
status: d.status,
version: d.version || '1.0'
})
const lc = parseInt(String(d.level_count), 10)
setLevelCount(lc)
setLevelsForm(
(d.levels || []).map((l) => ({
level_number: l.level_number,
name: l.name,
description: l.description || '',
sort_order: l.sort_order
}))
)
const draft = {}
for (const r of d.skill_levels || []) {
draft[`${r.skill_id}-${r.level_number}`] = {
description: r.description || '',
observable_criteria: r.observable_criteria || ''
}
}
setCellDraft(draft)
} catch (e) {
setError(e.message)
setDetail(null)
setMeta(null)
} finally {
setLoading(false)
}
}
async function handleCreate(e) {
e.preventDefault()
setError('')
setSaving(true)
try {
const payload = {
name: newModel.name.trim(),
level_count: parseInt(String(newModel.level_count), 10),
status: newModel.status,
focus_area_ids: newModel.focus_area_ids,
style_direction_ids: newModel.style_direction_ids,
target_group_ids: newModel.target_group_ids
}
const created = await api.createMaturityModel(payload)
await refreshModels()
await selectModel(created.id)
setNewModel({
name: '',
level_count: 5,
focus_area_ids: [],
style_direction_ids: [],
target_group_ids: [],
status: 'draft'
})
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveMeta() {
if (!selectedId || !meta) return
setError('')
setSaving(true)
try {
await api.updateMaturityModel(selectedId, {
name: meta.name,
description: meta.description || null,
focus_area_ids: meta.focus_area_ids,
style_direction_ids: meta.style_direction_ids,
target_group_ids: meta.target_group_ids,
status: meta.status,
version: meta.version
})
await refreshModels()
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveLevels() {
if (!selectedId || !meta) return
setError('')
setSaving(true)
try {
await api.updateMaturityModel(selectedId, {
level_count: parseInt(String(levelCount), 10),
levels: levelsForm.map((l) => ({
level_number: parseInt(String(l.level_number), 10),
name: l.name,
description: l.description || null,
sort_order: parseInt(String(l.sort_order), 10)
}))
})
await refreshModels()
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveMatrix() {
if (!selectedId || !detail) return
if (!detail.model_skills?.length) {
setError('Matrix: Zuerst Fähigkeiten hinzufügen.')
return
}
setError('')
setSaving(true)
try {
const entries = []
for (const ms of detail.model_skills) {
for (const lev of detail.levels || []) {
const key = `${ms.skill_id}-${lev.level_number}`
const d = cellDraft[key] || { description: '', observable_criteria: '' }
entries.push({
skill_id: ms.skill_id,
level_number: lev.level_number,
description: (d.description || '').trim(),
observable_criteria: (d.observable_criteria || '').trim() || null
})
}
}
await api.upsertMaturityModelSkillLevels(selectedId, { entries })
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleAddSkill() {
if (!selectedId || !skillToAdd) return
setError('')
setSaving(true)
try {
await api.addMaturityModelSkill(selectedId, { skill_id: parseInt(String(skillToAdd), 10) })
setSkillToAdd('')
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleRemoveSkill(skillId) {
if (!selectedId) return
if (!confirm('Fähigkeit aus dem Modell entfernen? Zelltexte werden gelöscht.')) return
setError('')
setSaving(true)
try {
await api.removeMaturityModelSkill(selectedId, skillId)
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleDeleteModel() {
if (!selectedId || !isSuperadmin) return
if (!confirm('Reifegradmodell dauerhaft löschen?')) return
setError('')
setSaving(true)
try {
await api.deleteMaturityModel(selectedId)
setSelectedId(null)
setDetail(null)
setMeta(null)
await refreshModels()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
function onLevelCountChange(raw) {
const next = Math.max(3, Math.min(10, parseInt(String(raw), 10) || 3))
setLevelCount(next)
setLevelsForm((prev) => {
const byNum = Object.fromEntries(prev.map((r) => [r.level_number, { ...r }]))
const rows = []
for (let i = 1; i <= next; i++) {
rows.push(
byNum[i] || {
level_number: i,
name: `Stufe ${i}`,
description: '',
sort_order: i
}
)
}
return rows
})
}
function setCell(skillId, levelNumber, field, value) {
const key = `${skillId}-${levelNumber}`
setCellDraft((prev) => ({
...prev,
[key]: {
description: field === 'description' ? value : (prev[key]?.description ?? ''),
observable_criteria:
field === 'observable_criteria' ? value : (prev[key]?.observable_criteria ?? '')
}
}))
}
return (
<div className="admin-matrix-panel">
<p className="admin-matrix-panel__intro muted">
Reifegradmodelle: Kontext (Fokus / Stil / Zielgruppe), Stufen, Matrix-Zelltexte. Die Fähigkeiten selbst pflegen Sie im Tab Katalog und Hierarchie.
</p>
{error ? (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
{error}
</div>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(240px, 320px) 1fr',
gap: 20,
alignItems: 'start'
}}
>
<div className="card" style={{ padding: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Modelle</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{models.map((m) => (
<li key={m.id} style={{ marginBottom: 8 }}>
<button
type="button"
className={selectedId === m.id ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ width: '100%', textAlign: 'left' }}
onClick={() => selectModel(m.id)}
>
<div style={{ fontWeight: 600 }}>{m.name}</div>
<div style={{ fontSize: 12, opacity: 0.85, lineHeight: 1.35 }}>
{(m.focus_areas || []).length
? `Fokus: ${m.focus_areas.map((f) => f.name).join(', ')}`
: 'Fokus: alle'}
<br />
{(m.style_directions || []).length
? `Stil: ${m.style_directions.map((s) => s.name).join(', ')}`
: 'Stil: alle'}
<br />
{(m.target_groups || []).length
? `Zielgr.: ${m.target_groups.map((t) => t.name).join(', ')}`
: 'Zielgr.: alle'}
</div>
<div style={{ fontSize: 11, opacity: 0.75 }}>{m.status} · {m.level_count} Stufen</div>
</button>
</li>
))}
</ul>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<h3 style={{ fontSize: '1rem' }}>Neues Modell</h3>
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label className="form-label">Name</label>
<input
className="form-input"
value={newModel.name}
onChange={(e) => setNewModel((s) => ({ ...s, name: e.target.value }))}
required
/>
<label className="form-label">Stufenanzahl (310)</label>
<input
className="form-input"
type="number"
min={3}
max={10}
value={newModel.level_count}
onChange={(e) => setNewModel((s) => ({ ...s, level_count: e.target.value }))}
/>
<label className="form-label">Status</label>
<select
className="form-input"
value={newModel.status}
onChange={(e) => setNewModel((s) => ({ ...s, status: e.target.value }))}
>
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
<option value="archived">Archiviert</option>
</select>
<MultiIdSelect
label="Fokusbereiche (leer = alle)"
options={focusAreas}
valueIds={newModel.focus_area_ids}
onChange={(ids) => setNewModel((s) => ({ ...s, focus_area_ids: ids }))}
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
/>
<MultiIdSelect
label="Stilrichtungen (leer = alle)"
options={styles}
valueIds={newModel.style_direction_ids}
onChange={(ids) => setNewModel((s) => ({ ...s, style_direction_ids: ids }))}
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
/>
<MultiIdSelect
label="Zielgruppen (leer = alle)"
options={targetGroups}
valueIds={newModel.target_group_ids}
onChange={(ids) => setNewModel((s) => ({ ...s, target_group_ids: ids }))}
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
/>
<button type="submit" className="btn btn-primary btn-full" disabled={saving}>
Anlegen
</button>
</form>
</div>
<div>
{loading ? <div className="spinner" /> : null}
{!loading && !detail && (
<div className="card" style={{ padding: 24, color: 'var(--text2)' }}>
Modell links wählen oder neu anlegen.
</div>
)}
{!loading && detail && meta && (
<>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Kontext &amp; Metadaten</h2>
<div className="form-row" style={{ display: 'grid', gap: 12 }}>
<div>
<label className="form-label">Name</label>
<input
className="form-input"
value={meta.name}
onChange={(e) => setMeta((m) => ({ ...m, name: e.target.value }))}
/>
</div>
<div>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={2}
value={meta.description}
onChange={(e) => setMeta((m) => ({ ...m, description: e.target.value }))}
/>
</div>
<MultiIdSelect
label="Fokusbereiche (keine Auswahl = alle)"
options={focusAreas}
valueIds={meta.focus_area_ids}
onChange={(ids) => setMeta((m) => ({ ...m, focus_area_ids: ids }))}
hint="Strg/Cmd + Klick für mehrere. Alle abwählen = gilt in jedem Fokusbereich."
/>
<MultiIdSelect
label="Stilrichtungen (keine Auswahl = alle)"
options={styles}
valueIds={meta.style_direction_ids}
onChange={(ids) => setMeta((m) => ({ ...m, style_direction_ids: ids }))}
hint="Strg/Cmd + Klick für mehrere."
/>
<MultiIdSelect
label="Zielgruppen (keine Auswahl = alle)"
options={targetGroups}
valueIds={meta.target_group_ids}
onChange={(ids) => setMeta((m) => ({ ...m, target_group_ids: ids }))}
hint="Strg/Cmd + Klick für mehrere."
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label">Status</label>
<select
className="form-input"
value={meta.status}
onChange={(e) => setMeta((m) => ({ ...m, status: e.target.value }))}
>
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
<option value="archived">Archiviert</option>
</select>
</div>
<div>
<label className="form-label">Version</label>
<input
className="form-input"
value={meta.version}
onChange={(e) => setMeta((m) => ({ ...m, version: e.target.value }))}
/>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary" onClick={handleSaveMeta} disabled={saving}>
Metadaten speichern
</button>
{isSuperadmin ? (
<button type="button" className="btn btn-secondary" onClick={handleDeleteModel} disabled={saving}>
Modell löschen
</button>
) : null}
</div>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Stufen (Bezeichnungen)</h2>
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 0 }}>
Reihenfolge muss lückenlos 1N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
</p>
<div style={{ marginBottom: 12, maxWidth: 200 }}>
<label className="form-label">Stufenanzahl (310)</label>
<input
className="form-input"
type="number"
min={3}
max={10}
value={levelCount}
onChange={(e) => onLevelCountChange(e.target.value)}
/>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8 }}>Nr.</th>
<th style={{ textAlign: 'left', padding: 8 }}>Name</th>
<th style={{ textAlign: 'left', padding: 8 }}>Beschreibung</th>
<th style={{ textAlign: 'left', padding: 8 }}>Sort</th>
</tr>
</thead>
<tbody>
{levelsForm.map((row, idx) => (
<tr key={row.level_number} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: 8 }}>{row.level_number}</td>
<td style={{ padding: 8 }}>
<input
className="form-input"
value={row.name}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, name: e.target.value }
setLevelsForm(next)
}}
/>
</td>
<td style={{ padding: 8 }}>
<input
className="form-input"
value={row.description}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, description: e.target.value }
setLevelsForm(next)
}}
/>
</td>
<td style={{ padding: 8, width: 72 }}>
<input
className="form-input"
type="number"
value={row.sort_order}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, sort_order: e.target.value }
setLevelsForm(next)
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 12 }}
onClick={handleSaveLevels}
disabled={saving}
>
Stufen speichern
</button>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Fähigkeiten im Modell</h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 220px' }}>
<label className="form-label">Fähigkeit hinzufügen</label>
<select
className="form-input"
value={skillToAdd}
onChange={(e) => setSkillToAdd(e.target.value)}
>
<option value=""> wählen </option>
{allSkills.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<button type="button" className="btn btn-primary" onClick={handleAddSkill} disabled={saving || !skillToAdd}>
Hinzufügen
</button>
</div>
<ul style={{ marginTop: 12, paddingLeft: 18 }}>
{(detail.model_skills || []).map((ms) => (
<li key={ms.skill_id} style={{ marginBottom: 10 }}>
<div>
<strong>{ms.skill_name}</strong>
{' '}
<button
type="button"
className="btn btn-secondary"
style={{ padding: '2px 8px', fontSize: 12 }}
onClick={() => handleRemoveSkill(ms.skill_id)}
>
entfernen
</button>
</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 2 }}>
{ms.skill_main_category_name}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
) : null}
</li>
))}
</ul>
</div>
<div className="card" style={{ padding: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Matrix (Zielbild je Stufe)</h2>
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 0 }}>
Leere Zellen werden beim Speichern aus der Datenbank entfernt. Beobachtungskriterien optional in
zweiter Zeile (nach Speichern mit Beschreibung).
</p>
<div style={{ overflow: 'auto', maxHeight: '70vh' }}>
<table style={{ borderCollapse: 'collapse', fontSize: 13, minWidth: 600 }}>
<thead>
<tr>
<th style={{ padding: 8, border: '1px solid var(--border)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
Fähigkeit
</th>
{(detail.levels || []).map((l) => (
<th
key={l.level_number}
style={{ padding: 8, border: '1px solid var(--border)', minWidth: 160, background: 'var(--surface2)' }}
>
{l.level_number}. {l.name}
</th>
))}
</tr>
</thead>
<tbody>
{(detail.model_skills || []).map((ms) => (
<tr key={ms.skill_id}>
<td
style={{
padding: 8,
border: '1px solid var(--border)',
position: 'sticky',
left: 0,
background: 'var(--surface)',
fontWeight: 600,
maxWidth: 220,
verticalAlign: 'top'
}}
>
<div>{ms.skill_name}</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text2)', marginTop: 4, lineHeight: 1.3 }}>
{ms.skill_main_category_name || '—'}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
) : null}
</td>
{(detail.levels || []).map((l) => {
const key = `${ms.skill_id}-${l.level_number}`
const d = cellDraft[key] || { description: '', observable_criteria: '' }
return (
<td key={l.level_number} style={{ padding: 6, border: '1px solid var(--border)', verticalAlign: 'top' }}>
<textarea
className="form-input"
rows={3}
placeholder="Zielbild / Erwartung"
value={d.description}
onChange={(e) => setCell(ms.skill_id, l.level_number, 'description', e.target.value)}
style={{ fontSize: 12, width: '100%', minWidth: 140 }}
/>
<textarea
className="form-input"
rows={2}
placeholder="Beobachtungskriterien (optional)"
value={d.observable_criteria}
onChange={(e) => setCell(ms.skill_id, l.level_number, 'observable_criteria', e.target.value)}
style={{ fontSize: 11, width: '100%', minWidth: 140, marginTop: 4 }}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 12 }}
onClick={handleSaveMatrix}
disabled={saving || !(detail.model_skills || []).length}
>
Matrix speichern
</button>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,838 @@
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>
)
}

View File

@ -1,744 +1,53 @@
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
function MultiIdSelect({ label, options, valueIds, onChange, hint }) {
return (
<div>
<label className="form-label">{label}</label>
<select
multiple
className="form-input"
style={{ minHeight: 100, width: '100%' }}
value={(valueIds || []).map(String)}
onChange={(e) => {
const v = Array.from(e.target.selectedOptions, (o) => parseInt(o.value, 10))
onChange(v)
}}
>
{options.map((o) => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
{hint ? (
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{hint}</div>
) : null}
</div>
)
}
import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
export default function AdminMaturityModelsPage() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const [models, setModels] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [detail, setDetail] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [focusAreas, setFocusAreas] = useState([])
const [styles, setStyles] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [allSkills, setAllSkills] = useState([])
const [newModel, setNewModel] = useState({
name: '',
level_count: 5,
focus_area_ids: [],
style_direction_ids: [],
target_group_ids: [],
status: 'draft'
})
const [meta, setMeta] = useState(null)
const [levelCount, setLevelCount] = useState(5)
const [levelsForm, setLevelsForm] = useState([])
const [cellDraft, setCellDraft] = useState({})
const [skillToAdd, setSkillToAdd] = useState('')
useEffect(() => {
if (!isAdmin) return
let cancelled = false
;(async () => {
try {
const [m, fa, sd, tg, sk] = await Promise.all([
api.listMaturityModels({}),
api.listFocusAreas({}),
api.listStyleDirections({}),
api.listTargetGroups({}),
api.listSkills({ status: 'active' })
])
if (!cancelled) {
setModels(m)
setFocusAreas(fa)
setStyles(sd)
setTargetGroups(tg)
setAllSkills(sk)
}
} catch (e) {
if (!cancelled) setError(e.message)
}
})()
return () => { cancelled = true }
}, [isAdmin])
async function refreshModels() {
const m = await api.listMaturityModels({})
setModels(m)
}
async function selectModel(id) {
setSelectedId(id)
setLoading(true)
setError('')
try {
const d = await api.getMaturityModel(id)
setDetail(d)
setMeta({
name: d.name,
description: d.description || '',
focus_area_ids: (d.focus_areas || []).map((x) => x.id),
style_direction_ids: (d.style_directions || []).map((x) => x.id),
target_group_ids: (d.target_groups || []).map((x) => x.id),
status: d.status,
version: d.version || '1.0'
})
const lc = parseInt(String(d.level_count), 10)
setLevelCount(lc)
setLevelsForm(
(d.levels || []).map((l) => ({
level_number: l.level_number,
name: l.name,
description: l.description || '',
sort_order: l.sort_order
}))
)
const draft = {}
for (const r of d.skill_levels || []) {
draft[`${r.skill_id}-${r.level_number}`] = {
description: r.description || '',
observable_criteria: r.observable_criteria || ''
}
}
setCellDraft(draft)
} catch (e) {
setError(e.message)
setDetail(null)
setMeta(null)
} finally {
setLoading(false)
}
}
async function handleCreate(e) {
e.preventDefault()
setError('')
setSaving(true)
try {
const payload = {
name: newModel.name.trim(),
level_count: parseInt(String(newModel.level_count), 10),
status: newModel.status,
focus_area_ids: newModel.focus_area_ids,
style_direction_ids: newModel.style_direction_ids,
target_group_ids: newModel.target_group_ids
}
const created = await api.createMaturityModel(payload)
await refreshModels()
await selectModel(created.id)
setNewModel({
name: '',
level_count: 5,
focus_area_ids: [],
style_direction_ids: [],
target_group_ids: [],
status: 'draft'
})
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveMeta() {
if (!selectedId || !meta) return
setError('')
setSaving(true)
try {
await api.updateMaturityModel(selectedId, {
name: meta.name,
description: meta.description || null,
focus_area_ids: meta.focus_area_ids,
style_direction_ids: meta.style_direction_ids,
target_group_ids: meta.target_group_ids,
status: meta.status,
version: meta.version
})
await refreshModels()
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveLevels() {
if (!selectedId || !meta) return
setError('')
setSaving(true)
try {
await api.updateMaturityModel(selectedId, {
level_count: parseInt(String(levelCount), 10),
levels: levelsForm.map((l) => ({
level_number: parseInt(String(l.level_number), 10),
name: l.name,
description: l.description || null,
sort_order: parseInt(String(l.sort_order), 10)
}))
})
await refreshModels()
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveMatrix() {
if (!selectedId || !detail) return
if (!detail.model_skills?.length) {
setError('Matrix: Zuerst Fähigkeiten hinzufügen.')
return
}
setError('')
setSaving(true)
try {
const entries = []
for (const ms of detail.model_skills) {
for (const lev of detail.levels || []) {
const key = `${ms.skill_id}-${lev.level_number}`
const d = cellDraft[key] || { description: '', observable_criteria: '' }
entries.push({
skill_id: ms.skill_id,
level_number: lev.level_number,
description: (d.description || '').trim(),
observable_criteria: (d.observable_criteria || '').trim() || null
})
}
}
await api.upsertMaturityModelSkillLevels(selectedId, { entries })
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleAddSkill() {
if (!selectedId || !skillToAdd) return
setError('')
setSaving(true)
try {
await api.addMaturityModelSkill(selectedId, { skill_id: parseInt(String(skillToAdd), 10) })
setSkillToAdd('')
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleRemoveSkill(skillId) {
if (!selectedId) return
if (!confirm('Fähigkeit aus dem Modell entfernen? Zelltexte werden gelöscht.')) return
setError('')
setSaving(true)
try {
await api.removeMaturityModelSkill(selectedId, skillId)
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleDeleteModel() {
if (!selectedId || !isSuperadmin) return
if (!confirm('Reifegradmodell dauerhaft löschen?')) return
setError('')
setSaving(true)
try {
await api.deleteMaturityModel(selectedId)
setSelectedId(null)
setDetail(null)
setMeta(null)
await refreshModels()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
function onLevelCountChange(raw) {
const next = Math.max(3, Math.min(10, parseInt(String(raw), 10) || 3))
setLevelCount(next)
setLevelsForm((prev) => {
const byNum = Object.fromEntries(prev.map((r) => [r.level_number, { ...r }]))
const rows = []
for (let i = 1; i <= next; i++) {
rows.push(
byNum[i] || {
level_number: i,
name: `Stufe ${i}`,
description: '',
sort_order: i
}
)
}
return rows
})
}
function setCell(skillId, levelNumber, field, value) {
const key = `${skillId}-${levelNumber}`
setCellDraft((prev) => ({
...prev,
[key]: {
description: field === 'description' ? value : (prev[key]?.description ?? ''),
observable_criteria:
field === 'observable_criteria' ? value : (prev[key]?.observable_criteria ?? '')
}
}))
}
const [tab, setTab] = useState('catalog')
if (!isAdmin) {
return <Navigate to="/" replace />
}
return (
<div style={{ padding: '20px', maxWidth: 1400, margin: '0 auto' }}>
<div className="admin-shell admin-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Admin: Fähigkeitsmatrix</h1>
<p style={{ color: 'var(--text2)', marginTop: '-8px' }}>
Reifegradmodelle mit Fokusbereich, Stilrichtung und Zielgruppe. Pro Modell: Stufen definieren,
Fähigkeiten zuordnen, Zelltexte pflegen.
</p>
<header className="admin-maturity-header">
<h1 className="admin-maturity-header__title">Admin: Fähigkeitsmatrix und Katalog</h1>
<p className="admin-maturity-header__subtitle muted">
Hierarchie der Fähigkeiten und Reifegradmodelle mit Matrix-Pflege.
</p>
</header>
{error ? (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
{error}
</div>
) : null}
<div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten">
<button
type="button"
role="tab"
aria-selected={tab === 'catalog'}
className={'admin-tabs__tab' + (tab === 'catalog' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('catalog')}
>
Katalog und Hierarchie
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'models'}
className={'admin-tabs__tab' + (tab === 'models' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('models')}
>
Reifegradmodelle
</button>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(240px, 320px) 1fr',
gap: 20,
alignItems: 'start'
}}
>
<div className="card" style={{ padding: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Modelle</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{models.map((m) => (
<li key={m.id} style={{ marginBottom: 8 }}>
<button
type="button"
className={selectedId === m.id ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ width: '100%', textAlign: 'left' }}
onClick={() => selectModel(m.id)}
>
<div style={{ fontWeight: 600 }}>{m.name}</div>
<div style={{ fontSize: 12, opacity: 0.85, lineHeight: 1.35 }}>
{(m.focus_areas || []).length
? `Fokus: ${m.focus_areas.map((f) => f.name).join(', ')}`
: 'Fokus: alle'}
<br />
{(m.style_directions || []).length
? `Stil: ${m.style_directions.map((s) => s.name).join(', ')}`
: 'Stil: alle'}
<br />
{(m.target_groups || []).length
? `Zielgr.: ${m.target_groups.map((t) => t.name).join(', ')}`
: 'Zielgr.: alle'}
</div>
<div style={{ fontSize: 11, opacity: 0.75 }}>{m.status} · {m.level_count} Stufen</div>
</button>
</li>
))}
</ul>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<h3 style={{ fontSize: '1rem' }}>Neues Modell</h3>
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label className="form-label">Name</label>
<input
className="form-input"
value={newModel.name}
onChange={(e) => setNewModel((s) => ({ ...s, name: e.target.value }))}
required
/>
<label className="form-label">Stufenanzahl (310)</label>
<input
className="form-input"
type="number"
min={3}
max={10}
value={newModel.level_count}
onChange={(e) => setNewModel((s) => ({ ...s, level_count: e.target.value }))}
/>
<label className="form-label">Status</label>
<select
className="form-input"
value={newModel.status}
onChange={(e) => setNewModel((s) => ({ ...s, status: e.target.value }))}
>
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
<option value="archived">Archiviert</option>
</select>
<MultiIdSelect
label="Fokusbereiche (leer = alle)"
options={focusAreas}
valueIds={newModel.focus_area_ids}
onChange={(ids) => setNewModel((s) => ({ ...s, focus_area_ids: ids }))}
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
/>
<MultiIdSelect
label="Stilrichtungen (leer = alle)"
options={styles}
valueIds={newModel.style_direction_ids}
onChange={(ids) => setNewModel((s) => ({ ...s, style_direction_ids: ids }))}
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
/>
<MultiIdSelect
label="Zielgruppen (leer = alle)"
options={targetGroups}
valueIds={newModel.target_group_ids}
onChange={(ids) => setNewModel((s) => ({ ...s, target_group_ids: ids }))}
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
/>
<button type="submit" className="btn btn-primary btn-full" disabled={saving}>
Anlegen
</button>
</form>
</div>
<div>
{loading ? <div className="spinner" /> : null}
{!loading && !detail && (
<div className="card" style={{ padding: 24, color: 'var(--text2)' }}>
Modell links wählen oder neu anlegen.
</div>
)}
{!loading && detail && meta && (
<>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Kontext &amp; Metadaten</h2>
<div className="form-row" style={{ display: 'grid', gap: 12 }}>
<div>
<label className="form-label">Name</label>
<input
className="form-input"
value={meta.name}
onChange={(e) => setMeta((m) => ({ ...m, name: e.target.value }))}
/>
</div>
<div>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={2}
value={meta.description}
onChange={(e) => setMeta((m) => ({ ...m, description: e.target.value }))}
/>
</div>
<MultiIdSelect
label="Fokusbereiche (keine Auswahl = alle)"
options={focusAreas}
valueIds={meta.focus_area_ids}
onChange={(ids) => setMeta((m) => ({ ...m, focus_area_ids: ids }))}
hint="Strg/Cmd + Klick für mehrere. Alle abwählen = gilt in jedem Fokusbereich."
/>
<MultiIdSelect
label="Stilrichtungen (keine Auswahl = alle)"
options={styles}
valueIds={meta.style_direction_ids}
onChange={(ids) => setMeta((m) => ({ ...m, style_direction_ids: ids }))}
hint="Strg/Cmd + Klick für mehrere."
/>
<MultiIdSelect
label="Zielgruppen (keine Auswahl = alle)"
options={targetGroups}
valueIds={meta.target_group_ids}
onChange={(ids) => setMeta((m) => ({ ...m, target_group_ids: ids }))}
hint="Strg/Cmd + Klick für mehrere."
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label">Status</label>
<select
className="form-input"
value={meta.status}
onChange={(e) => setMeta((m) => ({ ...m, status: e.target.value }))}
>
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
<option value="archived">Archiviert</option>
</select>
</div>
<div>
<label className="form-label">Version</label>
<input
className="form-input"
value={meta.version}
onChange={(e) => setMeta((m) => ({ ...m, version: e.target.value }))}
/>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary" onClick={handleSaveMeta} disabled={saving}>
Metadaten speichern
</button>
{isSuperadmin ? (
<button type="button" className="btn btn-secondary" onClick={handleDeleteModel} disabled={saving}>
Modell löschen
</button>
) : null}
</div>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Stufen (Bezeichnungen)</h2>
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 0 }}>
Reihenfolge muss lückenlos 1N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
</p>
<div style={{ marginBottom: 12, maxWidth: 200 }}>
<label className="form-label">Stufenanzahl (310)</label>
<input
className="form-input"
type="number"
min={3}
max={10}
value={levelCount}
onChange={(e) => onLevelCountChange(e.target.value)}
/>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8 }}>Nr.</th>
<th style={{ textAlign: 'left', padding: 8 }}>Name</th>
<th style={{ textAlign: 'left', padding: 8 }}>Beschreibung</th>
<th style={{ textAlign: 'left', padding: 8 }}>Sort</th>
</tr>
</thead>
<tbody>
{levelsForm.map((row, idx) => (
<tr key={row.level_number} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: 8 }}>{row.level_number}</td>
<td style={{ padding: 8 }}>
<input
className="form-input"
value={row.name}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, name: e.target.value }
setLevelsForm(next)
}}
/>
</td>
<td style={{ padding: 8 }}>
<input
className="form-input"
value={row.description}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, description: e.target.value }
setLevelsForm(next)
}}
/>
</td>
<td style={{ padding: 8, width: 72 }}>
<input
className="form-input"
type="number"
value={row.sort_order}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, sort_order: e.target.value }
setLevelsForm(next)
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 12 }}
onClick={handleSaveLevels}
disabled={saving}
>
Stufen speichern
</button>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Fähigkeiten im Modell</h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 220px' }}>
<label className="form-label">Fähigkeit hinzufügen</label>
<select
className="form-input"
value={skillToAdd}
onChange={(e) => setSkillToAdd(e.target.value)}
>
<option value=""> wählen </option>
{allSkills.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<button type="button" className="btn btn-primary" onClick={handleAddSkill} disabled={saving || !skillToAdd}>
Hinzufügen
</button>
</div>
<ul style={{ marginTop: 12, paddingLeft: 18 }}>
{(detail.model_skills || []).map((ms) => (
<li key={ms.skill_id} style={{ marginBottom: 10 }}>
<div>
<strong>{ms.skill_name}</strong>
{' '}
<button
type="button"
className="btn btn-secondary"
style={{ padding: '2px 8px', fontSize: 12 }}
onClick={() => handleRemoveSkill(ms.skill_id)}
>
entfernen
</button>
</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 2 }}>
{ms.skill_main_category_name}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
) : null}
</li>
))}
</ul>
</div>
<div className="card" style={{ padding: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Matrix (Zielbild je Stufe)</h2>
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 0 }}>
Leere Zellen werden beim Speichern aus der Datenbank entfernt. Beobachtungskriterien optional in
zweiter Zeile (nach Speichern mit Beschreibung).
</p>
<div style={{ overflow: 'auto', maxHeight: '70vh' }}>
<table style={{ borderCollapse: 'collapse', fontSize: 13, minWidth: 600 }}>
<thead>
<tr>
<th style={{ padding: 8, border: '1px solid var(--border)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
Fähigkeit
</th>
{(detail.levels || []).map((l) => (
<th
key={l.level_number}
style={{ padding: 8, border: '1px solid var(--border)', minWidth: 160, background: 'var(--surface2)' }}
>
{l.level_number}. {l.name}
</th>
))}
</tr>
</thead>
<tbody>
{(detail.model_skills || []).map((ms) => (
<tr key={ms.skill_id}>
<td
style={{
padding: 8,
border: '1px solid var(--border)',
position: 'sticky',
left: 0,
background: 'var(--surface)',
fontWeight: 600,
maxWidth: 220,
verticalAlign: 'top'
}}
>
<div>{ms.skill_name}</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text2)', marginTop: 4, lineHeight: 1.3 }}>
{ms.skill_main_category_name || '—'}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
) : null}
</td>
{(detail.levels || []).map((l) => {
const key = `${ms.skill_id}-${l.level_number}`
const d = cellDraft[key] || { description: '', observable_criteria: '' }
return (
<td key={l.level_number} style={{ padding: 6, border: '1px solid var(--border)', verticalAlign: 'top' }}>
<textarea
className="form-input"
rows={3}
placeholder="Zielbild / Erwartung"
value={d.description}
onChange={(e) => setCell(ms.skill_id, l.level_number, 'description', e.target.value)}
style={{ fontSize: 12, width: '100%', minWidth: 140 }}
/>
<textarea
className="form-input"
rows={2}
placeholder="Beobachtungskriterien (optional)"
value={d.observable_criteria}
onChange={(e) => setCell(ms.skill_id, l.level_number, 'observable_criteria', e.target.value)}
style={{ fontSize: 11, width: '100%', minWidth: 140, marginTop: 4 }}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 12 }}
onClick={handleSaveMatrix}
disabled={saving || !(detail.model_skills || []).length}
>
Matrix speichern
</button>
</div>
</>
)}
</div>
<div className="admin-tabs__panel" role="tabpanel">
{tab === 'catalog' ? <SkillsCatalogAdmin /> : <MaturityModelsAdminPanel />}
</div>
</div>
)

View File

@ -149,6 +149,12 @@ export async function listSkills(filters = {}) {
return request(`/api/skills${query ? '?' + query : ''}`)
}
/** Admin: Fähigkeiten hierarchisch sortiert (Hauptkategorie → Kategorie → Sortierung) */
export async function listSkillsCatalog(filters = {}) {
const query = new URLSearchParams(filters).toString()
return request(`/api/skills/catalog${query ? '?' + query : ''}`)
}
export async function getSkill(id) {
return request(`/api/skills/${id}`)
}
@ -395,6 +401,29 @@ export async function deleteTrainingType(id) {
return request(`/api/training-types/${id}`, { method: 'DELETE' })
}
// Skill main categories (Hauptgruppen im Fähigkeitskatalog)
export async function listSkillMainCategories() {
return request('/api/skill-main-categories')
}
export async function createSkillMainCategory(data) {
return request('/api/skill-main-categories', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function updateSkillMainCategory(id, data) {
return request(`/api/skill-main-categories/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteSkillMainCategory(id) {
return request(`/api/skill-main-categories/${id}`, { method: 'DELETE' })
}
// Skill Categories
export async function listSkillCategories(filters = {}) {
const query = new URLSearchParams(filters).toString()
@ -587,6 +616,7 @@ export const api = {
// Skills & Methods
listSkills,
listSkillsCatalog,
getSkill,
createSkill,
updateSkill,
@ -630,6 +660,10 @@ export const api = {
createTrainingType,
updateTrainingType,
deleteTrainingType,
listSkillMainCategories,
createSkillMainCategory,
updateSkillMainCategory,
deleteSkillMainCategory,
listSkillCategories,
createSkillCategory,
updateSkillCategory,