feat: add skill main categories and enhance skills catalog functionality
- 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:
parent
f1ee1eec7e
commit
e8b7e62832
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
734
frontend/src/components/admin/MaturityModelsAdminPanel.jsx
Normal file
734
frontend/src/components/admin/MaturityModelsAdminPanel.jsx
Normal 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 (3–10)</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 & 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 1…N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
|
||||
</p>
|
||||
<div style={{ marginBottom: 12, maxWidth: 200 }}>
|
||||
<label className="form-label">Stufenanzahl (3–10)</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>
|
||||
)
|
||||
}
|
||||
|
||||
838
frontend/src/components/admin/SkillsCatalogAdmin.jsx
Normal file
838
frontend/src/components/admin/SkillsCatalogAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (3–10)</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 & 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 1…N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
|
||||
</p>
|
||||
<div style={{ marginBottom: 12, maxWidth: 200 }}>
|
||||
<label className="form-label">Stufenanzahl (3–10)</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user