diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py index 521ded2..5f4f2c9 100644 --- a/backend/routers/catalogs.py +++ b/backend/routers/catalogs.py @@ -3,7 +3,9 @@ Catalog Management Endpoints for Shinkan Jinkendo Admin-verwaltbare Stammdaten für Übungen, Fokusbereiche, Stile, etc. """ +import re from typing import Optional + from fastapi import APIRouter, HTTPException, Depends, Query from db import get_db, get_cursor, r2d @@ -12,6 +14,131 @@ from auth import require_auth router = APIRouter(prefix="/api", tags=["catalogs"]) +def _slugify_skill_label(text: str) -> str: + t = (text or "").strip().lower() + t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE) + t = re.sub(r"_+", "_", t).strip("_") + return (t[:48] or "gruppe") + + +# ════════════════════════════════════════════════════════════════════════ +# SKILL MAIN CATEGORIES (Hauptgruppen, z. B. „KARATE Fähigkeiten“) +# ════════════════════════════════════════════════════════════════════════ + + +@router.get("/skill-main-categories") +def list_skill_main_categories(session=Depends(require_auth)): + """Alle Hauptkategorien für den Fähigkeitskatalog (sortiert).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT * FROM skill_main_categories ORDER BY sort_order NULLS LAST, name" + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/skill-main-categories") +def create_skill_main_category(data: dict, session=Depends(require_auth)): + role = session.get("role") + if role not in ("admin", "superadmin"): + raise HTTPException(403, "Nur Admins dürfen Hauptkategorien anlegen") + name = (data.get("name") or "").strip() + if not name: + raise HTTPException(400, "Name ist Pflichtfeld") + slug = (data.get("slug") or "").strip() or _slugify_skill_label(name) + with get_db() as conn: + cur = get_cursor(conn) + try: + cur.execute( + """ + INSERT INTO skill_main_categories (name, slug, description, sort_order) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + ( + name, + slug, + data.get("description"), + data.get("sort_order", 99), + ), + ) + mid = cur.fetchone()["id"] + except Exception as e: + if "unique" in str(e).lower() or "duplicate" in str(e).lower(): + raise HTTPException(409, "Name oder Slug bereits vergeben") from e + raise + cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (mid,)) + return r2d(cur.fetchone()) + + +@router.put("/skill-main-categories/{main_id}") +def update_skill_main_category(main_id: int, data: dict, session=Depends(require_auth)): + role = session.get("role") + if role not in ("admin", "superadmin"): + raise HTTPException(403, "Nur Admins dürfen Hauptkategorien bearbeiten") + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM skill_main_categories WHERE id = %s", (main_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Hauptkategorie nicht gefunden") + sets = [] + vals = [] + for key in ("name", "slug", "description", "sort_order"): + if key in data: + sets.append(f"{key} = %s") + vals.append(data[key]) + if not sets: + cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (main_id,)) + return r2d(cur.fetchone()) + sets.append("updated_at = NOW()") + vals.append(main_id) + try: + cur.execute( + f"UPDATE skill_main_categories SET {', '.join(sets)} WHERE id = %s", + tuple(vals), + ) + except Exception as e: + if "unique" in str(e).lower(): + raise HTTPException(409, "Name oder Slug bereits vergeben") from e + raise + cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (main_id,)) + return r2d(cur.fetchone()) + + +@router.delete("/skill-main-categories/{main_id}") +def delete_skill_main_category(main_id: int, session=Depends(require_auth)): + role = session.get("role") + if role != "superadmin": + raise HTTPException(403, "Nur Superadmins dürfen Hauptkategorien löschen") + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT COUNT(*) AS c FROM skill_categories WHERE main_category_id = %s", + (main_id,), + ) + if (cur.fetchone() or {}).get("c", 0) > 0: + raise HTTPException( + 409, + "Hauptkategorie hat noch Unterkategorien. Bitte zuerst verschieben oder löschen.", + ) + cur.execute( + "SELECT COUNT(*) AS c FROM skills WHERE main_category_id = %s", + (main_id,), + ) + if (cur.fetchone() or {}).get("c", 0) > 0: + raise HTTPException( + 409, + "Hauptkategorie ist noch Fähigkeiten zugeordnet.", + ) + cur.execute( + "DELETE FROM skill_main_categories WHERE id = %s RETURNING id", + (main_id,), + ) + if not cur.fetchone(): + raise HTTPException(404, "Hauptkategorie nicht gefunden") + return {"ok": True} + + # ════════════════════════════════════════════════════════════════════════ # FOCUS AREAS # ════════════════════════════════════════════════════════════════════════ @@ -526,24 +653,35 @@ def delete_training_type(type_id: int, session=Depends(require_auth)): @router.get("/skill-categories") def list_skill_categories( status: Optional[str] = Query(default='active'), - session=Depends(require_auth) + main_category_id: Optional[int] = Query(default=None), + session=Depends(require_auth), ): - """List all skill categories.""" + """List all skill categories (mit Hauptkategorie).""" with get_db() as conn: cur = get_cursor(conn) query = """ - SELECT sc.*, pc.name as parent_category_name + SELECT sc.*, pc.name as parent_category_name, + mc.id as main_category_ref_id, mc.name as main_category_name, + mc.slug as main_category_slug, mc.sort_order as main_category_sort FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id + LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id """ params = [] + where = [] if status: - query += " WHERE sc.status = %s" + where.append("sc.status = %s") params.append(status) + if main_category_id is not None: + where.append("sc.main_category_id = %s") + params.append(main_category_id) - query += " ORDER BY sc.sort_order, sc.name" + if where: + query += " WHERE " + " AND ".join(where) + + query += " ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, sc.name" cur.execute(query, params) rows = cur.fetchall() @@ -557,34 +695,56 @@ def create_skill_category(data: dict, session=Depends(require_auth)): if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Fähigkeitsbereiche erstellen") - name = data.get('name') + name = (data.get("name") or "").strip() if not name: raise HTTPException(400, "Name ist Pflichtfeld") + main_category_id = data.get("main_category_id") + if main_category_id is not None: + main_category_id = int(main_category_id) + + slug = (data.get("slug") or "").strip() or _slugify_skill_label(name) + with get_db() as conn: cur = get_cursor(conn) - cur.execute(""" - INSERT INTO skill_categories (name, description, parent_category_id, sort_order, status) - VALUES (%s, %s, %s, %s, %s) - RETURNING id - """, ( - name, - data.get('description'), - data.get('parent_category_id'), - data.get('sort_order', 99), - data.get('status', 'active') - )) + try: + cur.execute( + """ + INSERT INTO skill_categories ( + name, slug, description, parent_category_id, main_category_id, + sort_order, status + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + name, + slug, + data.get("description"), + data.get("parent_category_id"), + main_category_id, + data.get("sort_order", 99), + data.get("status", "active"), + ), + ) + cat_id = cur.fetchone()["id"] + except Exception as e: + if "unique" in str(e).lower() or "duplicate" in str(e).lower(): + raise HTTPException(409, "Name oder Slug schon vergeben") from e + raise - cat_id = cur.fetchone()['id'] - conn.commit() - - cur.execute(""" - SELECT sc.*, pc.name as parent_category_name + cur.execute( + """ + SELECT sc.*, pc.name as parent_category_name, + mc.name as main_category_name, mc.slug as main_category_slug FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id + LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id WHERE sc.id = %s - """, (cat_id,)) + """, + (cat_id,), + ) return r2d(cur.fetchone()) @@ -598,32 +758,59 @@ def update_skill_category(cat_id: int, data: dict, session=Depends(require_auth) with get_db() as conn: cur = get_cursor(conn) - cur.execute(""" - UPDATE skill_categories SET - name = %s, - description = %s, - parent_category_id = %s, - sort_order = %s, - status = %s, - updated_at = NOW() - WHERE id = %s - """, ( - data.get('name'), - data.get('description'), - data.get('parent_category_id'), - data.get('sort_order'), - data.get('status'), - cat_id - )) + cur.execute("SELECT id FROM skill_categories WHERE id = %s", (cat_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Kategorie nicht gefunden") - conn.commit() + sets: list = [] + vals: list = [] + for key in ( + "name", + "slug", + "description", + "parent_category_id", + "main_category_id", + "sort_order", + "status", + ): + if key in data: + sets.append(f"{key} = %s") + vals.append(data[key]) + if sets: + sets.append("updated_at = NOW()") + vals.append(cat_id) + try: + cur.execute( + f"UPDATE skill_categories SET {', '.join(sets)} WHERE id = %s", + tuple(vals), + ) + except Exception as e: + if "unique" in str(e).lower(): + raise HTTPException(409, "Name oder Slug schon vergeben") from e + raise - cur.execute(""" - SELECT sc.*, pc.name as parent_category_name + cur.execute( + "SELECT main_category_id FROM skill_categories WHERE id = %s", + (cat_id,), + ) + effective_main = cur.fetchone()["main_category_id"] + if effective_main is not None: + cur.execute( + "UPDATE skills SET main_category_id = %s WHERE category_id = %s", + (effective_main, cat_id), + ) + + cur.execute( + """ + SELECT sc.*, pc.name as parent_category_name, + mc.name as main_category_name, mc.slug as main_category_slug FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id + LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id WHERE sc.id = %s - """, (cat_id,)) + """, + (cat_id,), + ) return r2d(cur.fetchone()) @@ -636,8 +823,21 @@ def delete_skill_category(cat_id: int, session=Depends(require_auth)): with get_db() as conn: cur = get_cursor(conn) - cur.execute("DELETE FROM skill_categories WHERE id = %s", (cat_id,)) - conn.commit() + cur.execute("SELECT COUNT(*) AS c FROM skills WHERE category_id = %s", (cat_id,)) + if (cur.fetchone() or {}).get("c", 0) > 0: + raise HTTPException( + 409, + "Kategorie noch Fähigkeiten zugeordnet – bitte zuerst verschieben oder löschen.", + ) + cur.execute( + "SELECT COUNT(*) AS c FROM skill_categories WHERE parent_category_id = %s", + (cat_id,), + ) + if (cur.fetchone() or {}).get("c", 0) > 0: + raise HTTPException(409, "Kategorie hat Unterkategorien.") + cur.execute("DELETE FROM skill_categories WHERE id = %s RETURNING id", (cat_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Kategorie nicht gefunden") return {"ok": True} diff --git a/backend/routers/skills.py b/backend/routers/skills.py index b5b09d1..64582dd 100644 --- a/backend/routers/skills.py +++ b/backend/routers/skills.py @@ -5,7 +5,9 @@ Handles CRUD operations for skills and training methods. Read access for all authenticated users. Write access for admins only. """ -from typing import Optional +import json +from typing import Any, Optional + from fastapi import APIRouter, HTTPException, Depends, Query from db import get_db, get_cursor, r2d @@ -14,6 +16,17 @@ from auth import require_auth router = APIRouter(prefix="/api", tags=["skills"]) +def _main_id_for_category(cur, category_id: Optional[int]) -> Optional[int]: + if not category_id: + return None + cur.execute( + "SELECT main_category_id FROM skill_categories WHERE id = %s", + (category_id,), + ) + row = cur.fetchone() + return row["main_category_id"] if row else None + + # ── List Skills ─────────────────────────────────────────────────────── @router.get("/skills") def list_skills( @@ -56,6 +69,57 @@ def list_skills( return [r2d(r) for r in rows] +# ── Katalog-Ansicht (Hierarchie-Sortierung, Admin) ─────────────────────── +@router.get("/skills/catalog") +def list_skills_catalog( + status: Optional[str] = Query( + default="active", + description="'active', 'inactive' oder 'all' (nur Admin)", + ), + session=Depends(require_auth), +): + """ + Fähigkeiten für Admin-Katalog: sortiert nach Hauptgruppe → Kategorie → Sortierung → Name. + status=all nur für admin/superadmin. + """ + role = session.get("role") + admin = role in ("admin", "superadmin") + + with get_db() as conn: + cur = get_cursor(conn) + q = """ + SELECT s.*, + mc.name AS catalog_main_category_name, + mc.sort_order AS catalog_main_sort, + mc.slug AS catalog_main_slug, + sc.name AS catalog_category_name, + sc.sort_order AS catalog_category_sort, + sc.slug AS catalog_category_slug + FROM skills s + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE 1=1 + """ + params: list[Any] = [] + if status == "all": + if not admin: + raise HTTPException(403, "Nur Admins dürfen alle Status sehen") + elif status: + q += " AND s.status = %s" + params.append(status) + else: + q += " AND s.status = %s" + params.append("active") + q += """ + ORDER BY mc.sort_order NULLS LAST, + sc.sort_order NULLS LAST, + s.sort_order NULLS LAST, + s.name + """ + cur.execute(q, params) + return [r2d(r) for r in cur.fetchall()] + + # ── Get Skill ───────────────────────────────────────────────────────── @router.get("/skills/{skill_id}") def get_skill(skill_id: int, session=Depends(require_auth)): @@ -84,24 +148,46 @@ def create_skill(data: dict, session=Depends(require_auth)): if not name: raise HTTPException(400, "Name ist Pflichtfeld") + cat_id = data.get("category_id") + if cat_id is not None: + cat_id = int(cat_id) + main_id = data.get("main_category_id") + if main_id is not None: + main_id = int(main_id) + + focus = data.get("focus_areas") + if isinstance(focus, (list, dict)): + focus = json.dumps(focus) + with get_db() as conn: cur = get_cursor(conn) + if cat_id and main_id is None: + main_id = _main_id_for_category(cur, cat_id) - cur.execute(""" - INSERT INTO skills (name, category, description, importance, keywords, status) - VALUES (%s, %s, %s, %s, %s, %s) + cur.execute( + """ + INSERT INTO skills ( + name, category, description, importance, keywords, status, + category_id, main_category_id, focus_areas, sort_order + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s) RETURNING id - """, ( - name, - data.get('category'), - data.get('description'), - data.get('importance'), - data.get('keywords'), - data.get('status', 'active') - )) + """, + ( + name, + data.get("category"), + data.get("description"), + data.get("importance"), + data.get("keywords"), + data.get("status", "active"), + cat_id, + main_id, + focus if focus is not None else "[]", + data.get("sort_order"), + ), + ) - skill_id = cur.fetchone()['id'] - conn.commit() + skill_id = cur.fetchone()["id"] return get_skill(skill_id, session) @@ -122,28 +208,44 @@ def update_skill(skill_id: int, data: dict, session=Depends(require_auth)): if not cur.fetchone(): raise HTTPException(404, "Fähigkeit nicht gefunden") - # Update - cur.execute(""" - UPDATE skills SET - name = %s, - category = %s, - description = %s, - importance = %s, - keywords = %s, - status = %s, - updated_at = NOW() - WHERE id = %s - """, ( - data.get('name'), - data.get('category'), - data.get('description'), - data.get('importance'), - data.get('keywords'), - data.get('status'), - skill_id - )) + sets: list = [] + vals: list = [] + for key in ( + "name", + "category", + "description", + "importance", + "keywords", + "status", + "category_id", + "main_category_id", + "sort_order", + ): + if key in data: + sets.append(f"{key} = %s") + vals.append(data[key]) + if "focus_areas" in data: + fa = data["focus_areas"] + if isinstance(fa, (list, dict)): + fa = json.dumps(fa) + sets.append("focus_areas = %s::jsonb") + vals.append(fa if fa is not None else "[]") - conn.commit() + if sets: + sets.append("updated_at = NOW()") + vals.append(skill_id) + cur.execute( + f"UPDATE skills SET {', '.join(sets)} WHERE id = %s", + tuple(vals), + ) + + if "category_id" in data and "main_category_id" not in data: + mid = _main_id_for_category(cur, data.get("category_id")) + if mid is not None: + cur.execute( + "UPDATE skills SET main_category_id = %s WHERE id = %s", + (mid, skill_id), + ) return get_skill(skill_id, session) diff --git a/frontend/src/app.css b/frontend/src/app.css index a7c112c..475b3fd 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -19,7 +19,7 @@ --font: system-ui, -apple-system, 'Segoe UI', sans-serif; --capture-content-max: 800px; /* Admin: nutzt volle Hauptspalte bis zu dieser Obergrenze (siehe .app-main:has(.admin-shell)) */ - --admin-main-max: min(1560px, calc(100vw - 220px)); + --admin-main-max: min(1720px, calc(100vw - 200px)); } @media (prefers-color-scheme: dark) { :root { @@ -1040,6 +1040,214 @@ a.analysis-split__nav-item { } } +.admin-maturity-header { + margin-bottom: 20px; +} +.admin-maturity-header__title { + font-size: 1.35rem; + font-weight: 700; + margin: 0 0 6px; + letter-spacing: -0.02em; +} +.admin-maturity-header__subtitle { + margin: 0; + max-width: 56rem; +} + +.admin-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; + border-bottom: 1px solid var(--border); + padding-bottom: 12px; +} +.admin-tabs__tab { + padding: 10px 16px; + border-radius: 10px 10px 0 0; + border: 1px solid transparent; + background: var(--surface2); + color: var(--text2); + font-size: 14px; + font-weight: 600; + cursor: pointer; + font-family: inherit; +} +.admin-tabs__tab:hover { + color: var(--text1); + background: var(--surface); +} +.admin-tabs__tab--active { + background: var(--surface); + color: var(--accent-dark); + border-color: var(--border); + border-bottom-color: var(--surface); + margin-bottom: -13px; + padding-bottom: 11px; +} +@media (prefers-color-scheme: dark) { + .admin-tabs__tab--active { + color: var(--accent); + } +} +.admin-tabs__panel { + min-width: 0; +} + +.admin-matrix-panel__intro { + margin: 0 0 16px; + max-width: 56rem; +} + +.skills-catalog-admin__intro { + margin: 0 0 16px; + max-width: 56rem; +} +.skills-catalog-admin__error { + padding: 12px 14px; + border-radius: 10px; + background: var(--warn-bg); + color: var(--warn-text); + margin-bottom: 16px; +} +.skills-catalog-admin__msg { + margin: 0 0 12px; +} + +.skills-catalog-layout { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + align-items: start; + margin-bottom: 24px; +} +@media (max-width: 1023px) { + .skills-catalog-layout { + grid-template-columns: 1fr; + } +} + +.skills-catalog-column { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px; + min-width: 0; +} +.skills-catalog-column__title { + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); + margin: 0 0 12px; +} + +.skills-catalog-list { + list-style: none; + margin: 0 0 14px; + padding: 0; + max-height: min(42vh, 360px); + overflow-y: auto; +} +.skills-catalog-placeholder { + margin: 0 0 14px; + font-size: 14px; +} + +.skills-catalog-row { + display: flex; + align-items: center; + gap: 6px; + border-radius: 8px; + margin-bottom: 4px; + border: 1px solid transparent; +} +.skills-catalog-row--active { + border-color: var(--accent); + background: var(--accent-light); +} +.skills-catalog-row__label { + flex: 1; + min-width: 0; + text-align: left; + padding: 8px 10px; + border: none; + background: transparent; + font: inherit; + color: inherit; + cursor: pointer; + border-radius: 7px; +} +.skills-catalog-row__label:hover { + background: var(--surface2); +} +.skills-catalog-row--active .skills-catalog-row__label:hover { + background: transparent; +} +.skills-catalog-row__actions { + display: flex; + gap: 2px; + flex-shrink: 0; + padding-right: 4px; +} +.skills-catalog-row__badge { + display: inline-block; + margin-left: 8px; + font-size: 11px; + font-weight: 600; + color: var(--text3); + text-transform: uppercase; +} + +.skills-catalog-quick { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--border); +} +.skills-catalog-quick .form-label { + margin-bottom: 0; +} + +.skills-catalog-detail { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px 20px; + max-width: 720px; +} +.skills-catalog-detail__title { + font-size: 15px; + font-weight: 700; + margin: 0 0 14px; +} +.skills-catalog-form { + display: flex; + flex-direction: column; + gap: 10px; +} +.skills-catalog-form__heading { + font-size: 14px; + font-weight: 600; + margin: 0 0 4px; + color: var(--text2); +} +.skills-catalog-form__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 8px; +} + +.btn-tiny { + padding: 4px 8px; + font-size: 13px; + line-height: 1.2; + min-width: 2rem; +} + .muted { color: var(--text3); font-size: 13px; } .empty-state { text-align: center; padding: 48px 16px; color: var(--text3); } .empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; } diff --git a/frontend/src/components/admin/MaturityModelsAdminPanel.jsx b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx new file mode 100644 index 0000000..3fc2863 --- /dev/null +++ b/frontend/src/components/admin/MaturityModelsAdminPanel.jsx @@ -0,0 +1,734 @@ +import React, { useEffect, useState } from 'react' +import { useAuth } from '../../context/AuthContext' +import api from '../../utils/api' + +function MultiIdSelect({ label, options, valueIds, onChange, hint }) { + return ( +
+ + + {hint ? ( +
{hint}
+ ) : null} +
+ ) +} + +export default function MaturityModelsAdminPanel() { + const { user } = useAuth() + const isSuperadmin = user?.role === 'superadmin' + + const [models, setModels] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [focusAreas, setFocusAreas] = useState([]) + const [styles, setStyles] = useState([]) + const [targetGroups, setTargetGroups] = useState([]) + const [allSkills, setAllSkills] = useState([]) + + const [newModel, setNewModel] = useState({ + name: '', + level_count: 5, + focus_area_ids: [], + style_direction_ids: [], + target_group_ids: [], + status: 'draft' + }) + + const [meta, setMeta] = useState(null) + const [levelCount, setLevelCount] = useState(5) + const [levelsForm, setLevelsForm] = useState([]) + const [cellDraft, setCellDraft] = useState({}) + const [skillToAdd, setSkillToAdd] = useState('') + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const [m, fa, sd, tg, sk] = await Promise.all([ + api.listMaturityModels({}), + api.listFocusAreas({}), + api.listStyleDirections({}), + api.listTargetGroups({}), + api.listSkills({ status: 'active' }) + ]) + if (!cancelled) { + setModels(m) + setFocusAreas(fa) + setStyles(sd) + setTargetGroups(tg) + setAllSkills(sk) + } + } catch (e) { + if (!cancelled) setError(e.message) + } + })() + return () => { cancelled = true } + }, []) + + async function refreshModels() { + const m = await api.listMaturityModels({}) + setModels(m) + } + + async function selectModel(id) { + setSelectedId(id) + setLoading(true) + setError('') + try { + const d = await api.getMaturityModel(id) + setDetail(d) + setMeta({ + name: d.name, + description: d.description || '', + focus_area_ids: (d.focus_areas || []).map((x) => x.id), + style_direction_ids: (d.style_directions || []).map((x) => x.id), + target_group_ids: (d.target_groups || []).map((x) => x.id), + status: d.status, + version: d.version || '1.0' + }) + const lc = parseInt(String(d.level_count), 10) + setLevelCount(lc) + setLevelsForm( + (d.levels || []).map((l) => ({ + level_number: l.level_number, + name: l.name, + description: l.description || '', + sort_order: l.sort_order + })) + ) + const draft = {} + for (const r of d.skill_levels || []) { + draft[`${r.skill_id}-${r.level_number}`] = { + description: r.description || '', + observable_criteria: r.observable_criteria || '' + } + } + setCellDraft(draft) + } catch (e) { + setError(e.message) + setDetail(null) + setMeta(null) + } finally { + setLoading(false) + } + } + + async function handleCreate(e) { + e.preventDefault() + setError('') + setSaving(true) + try { + const payload = { + name: newModel.name.trim(), + level_count: parseInt(String(newModel.level_count), 10), + status: newModel.status, + focus_area_ids: newModel.focus_area_ids, + style_direction_ids: newModel.style_direction_ids, + target_group_ids: newModel.target_group_ids + } + const created = await api.createMaturityModel(payload) + await refreshModels() + await selectModel(created.id) + setNewModel({ + name: '', + level_count: 5, + focus_area_ids: [], + style_direction_ids: [], + target_group_ids: [], + status: 'draft' + }) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleSaveMeta() { + if (!selectedId || !meta) return + setError('') + setSaving(true) + try { + await api.updateMaturityModel(selectedId, { + name: meta.name, + description: meta.description || null, + focus_area_ids: meta.focus_area_ids, + style_direction_ids: meta.style_direction_ids, + target_group_ids: meta.target_group_ids, + status: meta.status, + version: meta.version + }) + await refreshModels() + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleSaveLevels() { + if (!selectedId || !meta) return + setError('') + setSaving(true) + try { + await api.updateMaturityModel(selectedId, { + level_count: parseInt(String(levelCount), 10), + levels: levelsForm.map((l) => ({ + level_number: parseInt(String(l.level_number), 10), + name: l.name, + description: l.description || null, + sort_order: parseInt(String(l.sort_order), 10) + })) + }) + await refreshModels() + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleSaveMatrix() { + if (!selectedId || !detail) return + if (!detail.model_skills?.length) { + setError('Matrix: Zuerst Fähigkeiten hinzufügen.') + return + } + setError('') + setSaving(true) + try { + const entries = [] + for (const ms of detail.model_skills) { + for (const lev of detail.levels || []) { + const key = `${ms.skill_id}-${lev.level_number}` + const d = cellDraft[key] || { description: '', observable_criteria: '' } + entries.push({ + skill_id: ms.skill_id, + level_number: lev.level_number, + description: (d.description || '').trim(), + observable_criteria: (d.observable_criteria || '').trim() || null + }) + } + } + await api.upsertMaturityModelSkillLevels(selectedId, { entries }) + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleAddSkill() { + if (!selectedId || !skillToAdd) return + setError('') + setSaving(true) + try { + await api.addMaturityModelSkill(selectedId, { skill_id: parseInt(String(skillToAdd), 10) }) + setSkillToAdd('') + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleRemoveSkill(skillId) { + if (!selectedId) return + if (!confirm('Fähigkeit aus dem Modell entfernen? Zelltexte werden gelöscht.')) return + setError('') + setSaving(true) + try { + await api.removeMaturityModelSkill(selectedId, skillId) + await selectModel(selectedId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function handleDeleteModel() { + if (!selectedId || !isSuperadmin) return + if (!confirm('Reifegradmodell dauerhaft löschen?')) return + setError('') + setSaving(true) + try { + await api.deleteMaturityModel(selectedId) + setSelectedId(null) + setDetail(null) + setMeta(null) + await refreshModels() + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + function onLevelCountChange(raw) { + const next = Math.max(3, Math.min(10, parseInt(String(raw), 10) || 3)) + setLevelCount(next) + setLevelsForm((prev) => { + const byNum = Object.fromEntries(prev.map((r) => [r.level_number, { ...r }])) + const rows = [] + for (let i = 1; i <= next; i++) { + rows.push( + byNum[i] || { + level_number: i, + name: `Stufe ${i}`, + description: '', + sort_order: i + } + ) + } + return rows + }) + } + + function setCell(skillId, levelNumber, field, value) { + const key = `${skillId}-${levelNumber}` + setCellDraft((prev) => ({ + ...prev, + [key]: { + description: field === 'description' ? value : (prev[key]?.description ?? ''), + observable_criteria: + field === 'observable_criteria' ? value : (prev[key]?.observable_criteria ?? '') + } + })) + } + + return ( +
+

+ Reifegradmodelle: Kontext (Fokus / Stil / Zielgruppe), Stufen, Matrix-Zelltexte. Die Fähigkeiten selbst pflegen Sie im Tab „Katalog und Hierarchie“. +

+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+

Modelle

+
    + {models.map((m) => ( +
  • + +
  • + ))} +
+ +
+ +

Neues Modell

+
+ + setNewModel((s) => ({ ...s, name: e.target.value }))} + required + /> + + setNewModel((s) => ({ ...s, level_count: e.target.value }))} + /> + + + setNewModel((s) => ({ ...s, focus_area_ids: ids }))} + hint="Strg/Cmd gedrückt halten für Mehrfachauswahl." + /> + setNewModel((s) => ({ ...s, style_direction_ids: ids }))} + hint="Strg/Cmd gedrückt halten für Mehrfachauswahl." + /> + setNewModel((s) => ({ ...s, target_group_ids: ids }))} + hint="Strg/Cmd gedrückt halten für Mehrfachauswahl." + /> + + +
+ +
+ {loading ?
: null} + + {!loading && !detail && ( +
+ Modell links wählen oder neu anlegen. +
+ )} + + {!loading && detail && meta && ( + <> +
+

Kontext & Metadaten

+
+
+ + setMeta((m) => ({ ...m, name: e.target.value }))} + /> +
+
+ +