diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py
index 521ded2..5f4f2c9 100644
--- a/backend/routers/catalogs.py
+++ b/backend/routers/catalogs.py
@@ -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}
diff --git a/backend/routers/skills.py b/backend/routers/skills.py
index b5b09d1..64582dd 100644
--- a/backend/routers/skills.py
+++ b/backend/routers/skills.py
@@ -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)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index a7c112c..475b3fd 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -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; }
diff --git a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx
new file mode 100644
index 0000000..3fc2863
--- /dev/null
+++ b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx
@@ -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 (
+
+
{label}
+
{
+ const v = Array.from(e.target.selectedOptions, (o) => parseInt(o.value, 10))
+ onChange(v)
+ }}
+ >
+ {options.map((o) => (
+ {o.name}
+ ))}
+
+ {hint ? (
+
{hint}
+ ) : null}
+
+ )
+}
+
+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 (
+
+
+ Reifegradmodelle: Kontext (Fokus / Stil / Zielgruppe), Stufen, Matrix-Zelltexte. Die Fähigkeiten selbst pflegen Sie im Tab „Katalog und Hierarchie“.
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
Modelle
+
+ {models.map((m) => (
+
+ selectModel(m.id)}
+ >
+ {m.name}
+
+ {(m.focus_areas || []).length
+ ? `Fokus: ${m.focus_areas.map((f) => f.name).join(', ')}`
+ : 'Fokus: alle'}
+
+ {(m.style_directions || []).length
+ ? `Stil: ${m.style_directions.map((s) => s.name).join(', ')}`
+ : 'Stil: alle'}
+
+ {(m.target_groups || []).length
+ ? `Zielgr.: ${m.target_groups.map((t) => t.name).join(', ')}`
+ : 'Zielgr.: alle'}
+
+ {m.status} · {m.level_count} Stufen
+
+
+ ))}
+
+
+
+
+
Neues Modell
+
+
+
+
+ {loading ?
: null}
+
+ {!loading && !detail && (
+
+ Modell links wählen oder neu anlegen.
+
+ )}
+
+ {!loading && detail && meta && (
+ <>
+
+
Kontext & Metadaten
+
+
+ Name
+ setMeta((m) => ({ ...m, name: e.target.value }))}
+ />
+
+
+ Beschreibung
+
+
setMeta((m) => ({ ...m, focus_area_ids: ids }))}
+ hint="Strg/Cmd + Klick für mehrere. Alle abwählen = gilt in jedem Fokusbereich."
+ />
+ setMeta((m) => ({ ...m, style_direction_ids: ids }))}
+ hint="Strg/Cmd + Klick für mehrere."
+ />
+ setMeta((m) => ({ ...m, target_group_ids: ids }))}
+ hint="Strg/Cmd + Klick für mehrere."
+ />
+
+
+ Status
+ setMeta((m) => ({ ...m, status: e.target.value }))}
+ >
+ Entwurf
+ Aktiv
+ Archiviert
+
+
+
+ Version
+ setMeta((m) => ({ ...m, version: e.target.value }))}
+ />
+
+
+
+
+
+ Metadaten speichern
+
+ {isSuperadmin ? (
+
+ Modell löschen
+
+ ) : null}
+
+
+
+
+
Stufen (Bezeichnungen)
+
+ Reihenfolge muss lückenlos 1…N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
+
+
+ Stufenanzahl (3–10)
+ onLevelCountChange(e.target.value)}
+ />
+
+
+
+ Stufen speichern
+
+
+
+
+
Fähigkeiten im Modell
+
+
+ Fähigkeit hinzufügen
+ setSkillToAdd(e.target.value)}
+ >
+ — wählen —
+ {allSkills.map((s) => (
+ {s.name}
+ ))}
+
+
+
+ Hinzufügen
+
+
+
+
+
+
+
Matrix (Zielbild je Stufe)
+
+ Leere Zellen werden beim Speichern aus der Datenbank entfernt. Beobachtungskriterien optional in
+ zweiter Zeile (nach Speichern mit Beschreibung).
+
+
+
+
+
+
+ Fähigkeit
+
+ {(detail.levels || []).map((l) => (
+
+ {l.level_number}. {l.name}
+
+ ))}
+
+
+
+ {(detail.model_skills || []).map((ms) => (
+
+
+ {ms.skill_name}
+ {(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
+
+ {ms.skill_main_category_name || '—'}
+ {ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''}
+
+ ) : null}
+
+ {(detail.levels || []).map((l) => {
+ const key = `${ms.skill_id}-${l.level_number}`
+ const d = cellDraft[key] || { description: '', observable_criteria: '' }
+ return (
+
+
+ )
+ })}
+
+ ))}
+
+
+
+
+ Matrix speichern
+
+
+ >
+ )}
+
+
+
+ )
+}
+
diff --git a/frontend/src/components/admin/SkillsCatalogAdmin.jsx b/frontend/src/components/admin/SkillsCatalogAdmin.jsx
new file mode 100644
index 0000000..8fb2a0c
--- /dev/null
+++ b/frontend/src/components/admin/SkillsCatalogAdmin.jsx
@@ -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 (
+
+ )
+ }
+
+ return (
+
+
+ Struktur: Hauptkategorie → Kategorie →{' '}
+ Fähigkeit . Reihenfolge mit den Pfeiltasten; Zuordnungen und Texte im
+ Bereich „Bearbeiten“.
+
+
+ {error ? (
+
+ {error}
+ {
+ setError('')
+ bootstrap()
+ }}
+ >
+ Erneut laden
+
+
+ ) : null}
+ {message ?
{message}
: null}
+
+
+
+
+ Bearbeiten
+ {detailMode === 'none' ? (
+
+ Wählen Sie eine Hauptkategorie, Kategorie oder Fähigkeit in den Spalten oben.
+
+ ) : null}
+
+ {detailMode === 'main' ? (
+
+ Hauptkategorie
+ Name
+ setMainForm((f) => ({ ...f, name: e.target.value }))}
+ disabled={busy}
+ />
+ Slug
+ setMainForm((f) => ({ ...f, slug: e.target.value }))}
+ disabled={busy}
+ />
+ Beschreibung
+ setMainForm((f) => ({ ...f, description: e.target.value }))}
+ disabled={busy}
+ />
+ Sortierung (Zahl, optional)
+ setMainForm((f) => ({ ...f, sort_order: e.target.value }))}
+ disabled={busy}
+ />
+
+
+ Speichern
+
+ {isSuperadmin ? (
+
+ Löschen
+
+ ) : null}
+
+
+ ) : null}
+
+ {detailMode === 'category' ? (
+
+ Kategorie
+ Name
+ setCategoryForm((f) => ({ ...f, name: e.target.value }))}
+ disabled={busy}
+ />
+ Slug
+ setCategoryForm((f) => ({ ...f, slug: e.target.value }))}
+ disabled={busy}
+ />
+ Hauptkategorie (verschieben)
+
+ setCategoryForm((f) => ({
+ ...f,
+ main_category_id: e.target.value === '' ? '' : e.target.value
+ }))
+ }
+ disabled={busy}
+ >
+ — keine —
+ {sortedMains.map((m) => (
+
+ {m.name}
+
+ ))}
+
+ Beschreibung
+ setCategoryForm((f) => ({ ...f, description: e.target.value }))}
+ disabled={busy}
+ />
+ Sortierung (optional)
+ setCategoryForm((f) => ({ ...f, sort_order: e.target.value }))}
+ disabled={busy}
+ />
+
+
+ Speichern
+
+ {isSuperadmin ? (
+
+ Löschen
+
+ ) : null}
+
+
+ ) : null}
+
+ {detailMode === 'skill' ? (
+
+ Fähigkeit
+ Name
+ setSkillForm((f) => ({ ...f, name: e.target.value }))}
+ disabled={busy}
+ />
+ Kategorie (verschieben)
+
+ setSkillForm((f) => ({
+ ...f,
+ category_id: e.target.value === '' ? '' : e.target.value
+ }))
+ }
+ disabled={busy}
+ required
+ >
+ {allCategories.map((c) => (
+
+ {(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
+
+ ))}
+
+ Legacy-Kurzlabel „category“ (optional)
+ setSkillForm((f) => ({ ...f, category: e.target.value }))}
+ disabled={busy}
+ />
+ Stichwörter
+ setSkillForm((f) => ({ ...f, keywords: e.target.value }))}
+ disabled={busy}
+ />
+ Status
+ setSkillForm((f) => ({ ...f, status: e.target.value }))}
+ disabled={busy}
+ >
+ active
+ inactive
+
+ Beschreibung
+ setSkillForm((f) => ({ ...f, description: e.target.value }))}
+ disabled={busy}
+ />
+ Sortierung (optional)
+ setSkillForm((f) => ({ ...f, sort_order: e.target.value }))}
+ disabled={busy}
+ />
+
+
+ Speichern
+
+ {isSuperadmin ? (
+
+ Löschen
+
+ ) : null}
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/src/pages/AdminMaturityModelsPage.jsx b/frontend/src/pages/AdminMaturityModelsPage.jsx
index 5856f40..a2b9769 100644
--- a/frontend/src/pages/AdminMaturityModelsPage.jsx
+++ b/frontend/src/pages/AdminMaturityModelsPage.jsx
@@ -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 (
-
-
{label}
-
{
- const v = Array.from(e.target.selectedOptions, (o) => parseInt(o.value, 10))
- onChange(v)
- }}
- >
- {options.map((o) => (
- {o.name}
- ))}
-
- {hint ? (
-
{hint}
- ) : null}
-
- )
-}
+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
}
return (
-
+
-
Admin: Fähigkeitsmatrix
-
- Reifegradmodelle mit Fokusbereich, Stilrichtung und Zielgruppe. Pro Modell: Stufen definieren,
- Fähigkeiten zuordnen, Zelltexte pflegen.
-
+
- {error ? (
-
- {error}
-
- ) : null}
+
+ setTab('catalog')}
+ >
+ Katalog und Hierarchie
+
+ setTab('models')}
+ >
+ Reifegradmodelle
+
+
-
-
-
Modelle
-
- {models.map((m) => (
-
- selectModel(m.id)}
- >
- {m.name}
-
- {(m.focus_areas || []).length
- ? `Fokus: ${m.focus_areas.map((f) => f.name).join(', ')}`
- : 'Fokus: alle'}
-
- {(m.style_directions || []).length
- ? `Stil: ${m.style_directions.map((s) => s.name).join(', ')}`
- : 'Stil: alle'}
-
- {(m.target_groups || []).length
- ? `Zielgr.: ${m.target_groups.map((t) => t.name).join(', ')}`
- : 'Zielgr.: alle'}
-
- {m.status} · {m.level_count} Stufen
-
-
- ))}
-
-
-
-
-
Neues Modell
-
- Name
- setNewModel((s) => ({ ...s, name: e.target.value }))}
- required
- />
- Stufenanzahl (3–10)
- setNewModel((s) => ({ ...s, level_count: e.target.value }))}
- />
- Status
- setNewModel((s) => ({ ...s, status: e.target.value }))}
- >
- Entwurf
- Aktiv
- Archiviert
-
- setNewModel((s) => ({ ...s, focus_area_ids: ids }))}
- hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
- />
- setNewModel((s) => ({ ...s, style_direction_ids: ids }))}
- hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
- />
- setNewModel((s) => ({ ...s, target_group_ids: ids }))}
- hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
- />
-
- Anlegen
-
-
-
-
-
- {loading ?
: null}
-
- {!loading && !detail && (
-
- Modell links wählen oder neu anlegen.
-
- )}
-
- {!loading && detail && meta && (
- <>
-
-
Kontext & Metadaten
-
-
- Name
- setMeta((m) => ({ ...m, name: e.target.value }))}
- />
-
-
- Beschreibung
- setMeta((m) => ({ ...m, description: e.target.value }))}
- />
-
-
setMeta((m) => ({ ...m, focus_area_ids: ids }))}
- hint="Strg/Cmd + Klick für mehrere. Alle abwählen = gilt in jedem Fokusbereich."
- />
- setMeta((m) => ({ ...m, style_direction_ids: ids }))}
- hint="Strg/Cmd + Klick für mehrere."
- />
- setMeta((m) => ({ ...m, target_group_ids: ids }))}
- hint="Strg/Cmd + Klick für mehrere."
- />
-
-
- Status
- setMeta((m) => ({ ...m, status: e.target.value }))}
- >
- Entwurf
- Aktiv
- Archiviert
-
-
-
- Version
- setMeta((m) => ({ ...m, version: e.target.value }))}
- />
-
-
-
-
-
- Metadaten speichern
-
- {isSuperadmin ? (
-
- Modell löschen
-
- ) : null}
-
-
-
-
-
Stufen (Bezeichnungen)
-
- Reihenfolge muss lückenlos 1…N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
-
-
- Stufenanzahl (3–10)
- onLevelCountChange(e.target.value)}
- />
-
-
-
- Stufen speichern
-
-
-
-
-
Fähigkeiten im Modell
-
-
- Fähigkeit hinzufügen
- setSkillToAdd(e.target.value)}
- >
- — wählen —
- {allSkills.map((s) => (
- {s.name}
- ))}
-
-
-
- Hinzufügen
-
-
-
-
-
-
-
Matrix (Zielbild je Stufe)
-
- Leere Zellen werden beim Speichern aus der Datenbank entfernt. Beobachtungskriterien optional in
- zweiter Zeile (nach Speichern mit Beschreibung).
-
-
-
-
-
-
- Fähigkeit
-
- {(detail.levels || []).map((l) => (
-
- {l.level_number}. {l.name}
-
- ))}
-
-
-
- {(detail.model_skills || []).map((ms) => (
-
-
- {ms.skill_name}
- {(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
-
- {ms.skill_main_category_name || '—'}
- {ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''}
-
- ) : null}
-
- {(detail.levels || []).map((l) => {
- const key = `${ms.skill_id}-${l.level_number}`
- const d = cellDraft[key] || { description: '', observable_criteria: '' }
- return (
-
- setCell(ms.skill_id, l.level_number, 'description', e.target.value)}
- style={{ fontSize: 12, width: '100%', minWidth: 140 }}
- />
- setCell(ms.skill_id, l.level_number, 'observable_criteria', e.target.value)}
- style={{ fontSize: 11, width: '100%', minWidth: 140, marginTop: 4 }}
- />
-
- )
- })}
-
- ))}
-
-
-
-
- Matrix speichern
-
-
- >
- )}
-
+
+ {tab === 'catalog' ? : }
)
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index bf137d6..8a33049 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -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,