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.
|
Admin-verwaltbare Stammdaten für Übungen, Fokusbereiche, Stile, etc.
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
|
|
@ -12,6 +14,131 @@ from auth import require_auth
|
||||||
router = APIRouter(prefix="/api", tags=["catalogs"])
|
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
|
# FOCUS AREAS
|
||||||
# ════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -526,24 +653,35 @@ def delete_training_type(type_id: int, session=Depends(require_auth)):
|
||||||
@router.get("/skill-categories")
|
@router.get("/skill-categories")
|
||||||
def list_skill_categories(
|
def list_skill_categories(
|
||||||
status: Optional[str] = Query(default='active'),
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
query = """
|
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
|
FROM skill_categories sc
|
||||||
LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id
|
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 = []
|
params = []
|
||||||
|
where = []
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
query += " WHERE sc.status = %s"
|
where.append("sc.status = %s")
|
||||||
params.append(status)
|
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)
|
cur.execute(query, params)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
@ -557,34 +695,56 @@ def create_skill_category(data: dict, session=Depends(require_auth)):
|
||||||
if role not in ['admin', 'superadmin']:
|
if role not in ['admin', 'superadmin']:
|
||||||
raise HTTPException(403, "Nur Admins dürfen Fähigkeitsbereiche erstellen")
|
raise HTTPException(403, "Nur Admins dürfen Fähigkeitsbereiche erstellen")
|
||||||
|
|
||||||
name = data.get('name')
|
name = (data.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(400, "Name ist Pflichtfeld")
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
cur.execute("""
|
try:
|
||||||
INSERT INTO skill_categories (name, description, parent_category_id, sort_order, status)
|
cur.execute(
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
"""
|
||||||
RETURNING id
|
INSERT INTO skill_categories (
|
||||||
""", (
|
name, slug, description, parent_category_id, main_category_id,
|
||||||
name,
|
sort_order, status
|
||||||
data.get('description'),
|
)
|
||||||
data.get('parent_category_id'),
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
data.get('sort_order', 99),
|
RETURNING id
|
||||||
data.get('status', 'active')
|
""",
|
||||||
))
|
(
|
||||||
|
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']
|
cur.execute(
|
||||||
conn.commit()
|
"""
|
||||||
|
SELECT sc.*, pc.name as parent_category_name,
|
||||||
cur.execute("""
|
mc.name as main_category_name, mc.slug as main_category_slug
|
||||||
SELECT sc.*, pc.name as parent_category_name
|
|
||||||
FROM skill_categories sc
|
FROM skill_categories sc
|
||||||
LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id
|
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
|
WHERE sc.id = %s
|
||||||
""", (cat_id,))
|
""",
|
||||||
|
(cat_id,),
|
||||||
|
)
|
||||||
return r2d(cur.fetchone())
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("SELECT id FROM skill_categories WHERE id = %s", (cat_id,))
|
||||||
UPDATE skill_categories SET
|
if not cur.fetchone():
|
||||||
name = %s,
|
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||||
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
|
|
||||||
))
|
|
||||||
|
|
||||||
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("""
|
cur.execute(
|
||||||
SELECT sc.*, pc.name as parent_category_name
|
"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
|
FROM skill_categories sc
|
||||||
LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id
|
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
|
WHERE sc.id = %s
|
||||||
""", (cat_id,))
|
""",
|
||||||
|
(cat_id,),
|
||||||
|
)
|
||||||
return r2d(cur.fetchone())
|
return r2d(cur.fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -636,8 +823,21 @@ def delete_skill_category(cat_id: int, session=Depends(require_auth)):
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("DELETE FROM skill_categories WHERE id = %s", (cat_id,))
|
cur.execute("SELECT COUNT(*) AS c FROM skills WHERE category_id = %s", (cat_id,))
|
||||||
conn.commit()
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ Handles CRUD operations for skills and training methods.
|
||||||
Read access for all authenticated users.
|
Read access for all authenticated users.
|
||||||
Write access for admins only.
|
Write access for admins only.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
|
|
@ -14,6 +16,17 @@ from auth import require_auth
|
||||||
router = APIRouter(prefix="/api", tags=["skills"])
|
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 ───────────────────────────────────────────────────────
|
# ── List Skills ───────────────────────────────────────────────────────
|
||||||
@router.get("/skills")
|
@router.get("/skills")
|
||||||
def list_skills(
|
def list_skills(
|
||||||
|
|
@ -56,6 +69,57 @@ def list_skills(
|
||||||
return [r2d(r) for r in rows]
|
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 ─────────────────────────────────────────────────────────
|
# ── Get Skill ─────────────────────────────────────────────────────────
|
||||||
@router.get("/skills/{skill_id}")
|
@router.get("/skills/{skill_id}")
|
||||||
def get_skill(skill_id: int, session=Depends(require_auth)):
|
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:
|
if not name:
|
||||||
raise HTTPException(400, "Name ist Pflichtfeld")
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
if cat_id and main_id is None:
|
||||||
|
main_id = _main_id_for_category(cur, cat_id)
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute(
|
||||||
INSERT INTO skills (name, category, description, importance, keywords, status)
|
"""
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
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
|
RETURNING id
|
||||||
""", (
|
""",
|
||||||
name,
|
(
|
||||||
data.get('category'),
|
name,
|
||||||
data.get('description'),
|
data.get("category"),
|
||||||
data.get('importance'),
|
data.get("description"),
|
||||||
data.get('keywords'),
|
data.get("importance"),
|
||||||
data.get('status', 'active')
|
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']
|
skill_id = cur.fetchone()["id"]
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return get_skill(skill_id, session)
|
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():
|
if not cur.fetchone():
|
||||||
raise HTTPException(404, "Fähigkeit nicht gefunden")
|
raise HTTPException(404, "Fähigkeit nicht gefunden")
|
||||||
|
|
||||||
# Update
|
sets: list = []
|
||||||
cur.execute("""
|
vals: list = []
|
||||||
UPDATE skills SET
|
for key in (
|
||||||
name = %s,
|
"name",
|
||||||
category = %s,
|
"category",
|
||||||
description = %s,
|
"description",
|
||||||
importance = %s,
|
"importance",
|
||||||
keywords = %s,
|
"keywords",
|
||||||
status = %s,
|
"status",
|
||||||
updated_at = NOW()
|
"category_id",
|
||||||
WHERE id = %s
|
"main_category_id",
|
||||||
""", (
|
"sort_order",
|
||||||
data.get('name'),
|
):
|
||||||
data.get('category'),
|
if key in data:
|
||||||
data.get('description'),
|
sets.append(f"{key} = %s")
|
||||||
data.get('importance'),
|
vals.append(data[key])
|
||||||
data.get('keywords'),
|
if "focus_areas" in data:
|
||||||
data.get('status'),
|
fa = data["focus_areas"]
|
||||||
skill_id
|
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)
|
return get_skill(skill_id, session)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
--capture-content-max: 800px;
|
--capture-content-max: 800px;
|
||||||
/* Admin: nutzt volle Hauptspalte bis zu dieser Obergrenze (siehe .app-main:has(.admin-shell)) */
|
/* 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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
: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; }
|
.muted { color: var(--text3); font-size: 13px; }
|
||||||
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
|
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
|
||||||
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }
|
.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 { Navigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../utils/api'
|
|
||||||
import AdminPageNav from '../components/AdminPageNav'
|
import AdminPageNav from '../components/AdminPageNav'
|
||||||
|
import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
|
||||||
function MultiIdSelect({ label, options, valueIds, onChange, hint }) {
|
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
|
||||||
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 AdminMaturityModelsPage() {
|
export default function AdminMaturityModelsPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||||
const isSuperadmin = user?.role === 'superadmin'
|
const [tab, setTab] = useState('catalog')
|
||||||
|
|
||||||
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 ?? '')
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return <Navigate to="/" replace />
|
return <Navigate to="/" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px', maxWidth: 1400, margin: '0 auto' }}>
|
<div className="admin-shell admin-page">
|
||||||
<AdminPageNav />
|
<AdminPageNav />
|
||||||
|
|
||||||
<h1 style={{ marginTop: 0 }}>Admin: Fähigkeitsmatrix</h1>
|
<header className="admin-maturity-header">
|
||||||
<p style={{ color: 'var(--text2)', marginTop: '-8px' }}>
|
<h1 className="admin-maturity-header__title">Admin: Fähigkeitsmatrix und Katalog</h1>
|
||||||
Reifegradmodelle mit Fokusbereich, Stilrichtung und Zielgruppe. Pro Modell: Stufen definieren,
|
<p className="admin-maturity-header__subtitle muted">
|
||||||
Fähigkeiten zuordnen, Zelltexte pflegen.
|
Hierarchie der Fähigkeiten und Reifegradmodelle mit Matrix-Pflege.
|
||||||
</p>
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
{error ? (
|
<div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten">
|
||||||
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
|
<button
|
||||||
{error}
|
type="button"
|
||||||
</div>
|
role="tab"
|
||||||
) : null}
|
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
|
<div className="admin-tabs__panel" role="tabpanel">
|
||||||
style={{
|
{tab === 'catalog' ? <SkillsCatalogAdmin /> : <MaturityModelsAdminPanel />}
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,12 @@ export async function listSkills(filters = {}) {
|
||||||
return request(`/api/skills${query ? '?' + query : ''}`)
|
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) {
|
export async function getSkill(id) {
|
||||||
return request(`/api/skills/${id}`)
|
return request(`/api/skills/${id}`)
|
||||||
}
|
}
|
||||||
|
|
@ -395,6 +401,29 @@ export async function deleteTrainingType(id) {
|
||||||
return request(`/api/training-types/${id}`, { method: 'DELETE' })
|
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
|
// Skill Categories
|
||||||
export async function listSkillCategories(filters = {}) {
|
export async function listSkillCategories(filters = {}) {
|
||||||
const query = new URLSearchParams(filters).toString()
|
const query = new URLSearchParams(filters).toString()
|
||||||
|
|
@ -587,6 +616,7 @@ export const api = {
|
||||||
|
|
||||||
// Skills & Methods
|
// Skills & Methods
|
||||||
listSkills,
|
listSkills,
|
||||||
|
listSkillsCatalog,
|
||||||
getSkill,
|
getSkill,
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
|
|
@ -630,6 +660,10 @@ export const api = {
|
||||||
createTrainingType,
|
createTrainingType,
|
||||||
updateTrainingType,
|
updateTrainingType,
|
||||||
deleteTrainingType,
|
deleteTrainingType,
|
||||||
|
listSkillMainCategories,
|
||||||
|
createSkillMainCategory,
|
||||||
|
updateSkillMainCategory,
|
||||||
|
deleteSkillMainCategory,
|
||||||
listSkillCategories,
|
listSkillCategories,
|
||||||
createSkillCategory,
|
createSkillCategory,
|
||||||
updateSkillCategory,
|
updateSkillCategory,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user