diff --git a/backend/migrations/025_maturity_model_context_mn.sql b/backend/migrations/025_maturity_model_context_mn.sql new file mode 100644 index 0000000..89086d3 --- /dev/null +++ b/backend/migrations/025_maturity_model_context_mn.sql @@ -0,0 +1,160 @@ +-- Migration 025: Reifegradmodell-Kontext als M:N (Fokusbereich, Stilrichtung, Zielgruppe) +-- + Bootstrap: ein Modell aus allen importierten Skills (Wiki / Migration 023) +-- Datum: 2026-04-27 + +-- ============================================================================ +-- JUNCTION TABLES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS maturity_model_focus_areas ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (maturity_model_id, focus_area_id) +); + +CREATE INDEX IF NOT EXISTS idx_mmfa_model ON maturity_model_focus_areas(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_mmfa_focus ON maturity_model_focus_areas(focus_area_id); + +CREATE TABLE IF NOT EXISTS maturity_model_style_directions ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + style_direction_id INT NOT NULL REFERENCES style_directions(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (maturity_model_id, style_direction_id) +); + +CREATE INDEX IF NOT EXISTS idx_mmsd_model ON maturity_model_style_directions(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_mmsd_style ON maturity_model_style_directions(style_direction_id); + +CREATE TABLE IF NOT EXISTS maturity_model_target_groups ( + id SERIAL PRIMARY KEY, + maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE, + target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE (maturity_model_id, target_group_id) +); + +CREATE INDEX IF NOT EXISTS idx_mmtg_model ON maturity_model_target_groups(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_mmtg_tg ON maturity_model_target_groups(target_group_id); + +-- ============================================================================ +-- Daten aus 1:1-FKs übernehmen (falls Spalten noch existieren), dann FK-Spalten entfernen +-- ============================================================================ + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'focus_area_id' + ) THEN + INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary) + SELECT id, focus_area_id, true + FROM maturity_models + WHERE focus_area_id IS NOT NULL + ON CONFLICT (maturity_model_id, focus_area_id) DO NOTHING; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'style_direction_id' + ) THEN + INSERT INTO maturity_model_style_directions (maturity_model_id, style_direction_id, is_primary) + SELECT id, style_direction_id, true + FROM maturity_models + WHERE style_direction_id IS NOT NULL + ON CONFLICT (maturity_model_id, style_direction_id) DO NOTHING; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'target_group_id' + ) THEN + INSERT INTO maturity_model_target_groups (maturity_model_id, target_group_id, is_primary) + SELECT id, target_group_id, true + FROM maturity_models + WHERE target_group_id IS NOT NULL + ON CONFLICT (maturity_model_id, target_group_id) DO NOTHING; + END IF; +END $$; + +ALTER TABLE maturity_models DROP CONSTRAINT IF EXISTS maturity_models_context_name_unique; + +ALTER TABLE maturity_models DROP COLUMN IF EXISTS focus_area_id; +ALTER TABLE maturity_models DROP COLUMN IF EXISTS style_direction_id; +ALTER TABLE maturity_models DROP COLUMN IF EXISTS target_group_id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_maturity_models_wiki_import + ON maturity_models(import_source, import_id) + WHERE import_id IS NOT NULL; + +-- ============================================================================ +-- Bootstrap: Standard-Modell für alle Skills aus der Wiki-Matrix (Migration 023) +-- ============================================================================ + +DO $$ +DECLARE + mid INT; + n_skills INT; +BEGIN + SELECT COUNT(*) INTO n_skills FROM skills; + IF n_skills = 0 THEN + RAISE NOTICE 'Bootstrap: keine Skills – übersprungen'; + RETURN; + END IF; + + SELECT id INTO mid FROM maturity_models WHERE import_id = 'faehigkeitsmatrix_karatetrainer' LIMIT 1; + IF mid IS NOT NULL THEN + RAISE NOTICE 'Bootstrap: Modell bereits vorhanden (id=%)', mid; + RETURN; + END IF; + + INSERT INTO maturity_models (name, description, level_count, status, import_source, import_id, version) + VALUES ( + 'Fähigkeitsmatrix (Wiki Import)', + 'Alle Fähigkeiten aus der Wiki-Matrix (Karatetrainer). Haupt- und Untergruppen kommen aus skill_main_categories / skill_categories.', + 5, + 'active', + 'wiki_matrix', + 'faehigkeitsmatrix_karatetrainer', + '1.0' + ) + RETURNING id INTO mid; + + INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary) + SELECT mid, fa.id, (fa.name = 'Karate') + FROM focus_areas fa + WHERE fa.name IN ('Karate', 'Selbstverteidigung', 'Gewaltschutz') + ON CONFLICT (maturity_model_id, focus_area_id) DO NOTHING; + + INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order) + VALUES + (mid, 1, 'Einsteiger', NULL, 1), + (mid, 2, 'Grundlagen', NULL, 2), + (mid, 3, 'Aufbau', NULL, 3), + (mid, 4, 'Fortgeschritten', NULL, 4), + (mid, 5, 'Experte', NULL, 5); + + INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance) + SELECT + mid, + s.id, + ROW_NUMBER() OVER ( + ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.name + ), + NULL + FROM skills s + LEFT JOIN skill_categories sc ON s.category_id = sc.id + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id; + + INSERT INTO model_skill_levels (maturity_model_id, skill_id, level_number, description, observable_criteria) + SELECT mid, sld.skill_id, sld.level, sld.description, NULL + FROM skill_level_definitions sld + ON CONFLICT (maturity_model_id, skill_id, level_number) DO NOTHING; + + RAISE NOTICE 'Bootstrap: Reifegradmodell id=% angelegt (% Skills)', mid, n_skills; +END $$; diff --git a/backend/routers/maturity_models.py b/backend/routers/maturity_models.py index c07cd07..63d8466 100644 --- a/backend/routers/maturity_models.py +++ b/backend/routers/maturity_models.py @@ -1,10 +1,12 @@ """ Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen) +Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt überall). + Lesen: alle authentifizierten Nutzer. Schreiben: admin, superadmin. """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, HTTPException, Query @@ -20,29 +22,123 @@ def _require_admin(session: dict) -> None: raise HTTPException(403, "Nur Administratoren dürfen Reifegradmodelle verwalten") -def _row_maturity_model(cur, model_id: int) -> Optional[Dict[str, Any]]: - cur.execute( - """ - SELECT mm.*, - fa.name AS focus_area_name, - sd.name AS style_direction_name, - tg.name AS target_group_name - FROM maturity_models mm - LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id - LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id - LEFT JOIN target_groups tg ON mm.target_group_id = tg.id - WHERE mm.id = %s - """, - (model_id,), - ) +def _base_maturity_model(cur, model_id: int) -> Optional[Dict[str, Any]]: + cur.execute("SELECT * FROM maturity_models WHERE id = %s", (model_id,)) row = cur.fetchone() return r2d(row) if row else None +def _attach_context(cur, m: Dict[str, Any]) -> Dict[str, Any]: + mid = m["id"] + cur.execute( + """ + SELECT fa.id, fa.name, fa.abbreviation, mfa.is_primary + FROM maturity_model_focus_areas mfa + JOIN focus_areas fa ON fa.id = mfa.focus_area_id + WHERE mfa.maturity_model_id = %s + ORDER BY mfa.is_primary DESC NULLS LAST, fa.sort_order, fa.name + """, + (mid,), + ) + m["focus_areas"] = [r2d(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT sd.id, sd.name, msd.is_primary + FROM maturity_model_style_directions msd + JOIN style_directions sd ON sd.id = msd.style_direction_id + WHERE msd.maturity_model_id = %s + ORDER BY msd.is_primary DESC NULLS LAST, sd.name + """, + (mid,), + ) + m["style_directions"] = [r2d(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT tg.id, tg.name, mtg.is_primary + FROM maturity_model_target_groups mtg + JOIN target_groups tg ON tg.id = mtg.target_group_id + WHERE mtg.maturity_model_id = %s + ORDER BY mtg.is_primary DESC NULLS LAST, tg.name + """, + (mid,), + ) + m["target_groups"] = [r2d(r) for r in cur.fetchall()] + + m["focus_area_name"] = m["focus_areas"][0]["name"] if m["focus_areas"] else None + m["style_direction_name"] = m["style_directions"][0]["name"] if m["style_directions"] else None + m["target_group_name"] = m["target_groups"][0]["name"] if m["target_groups"] else None + return m + + +def _normalize_id_list(raw: Any) -> Optional[List[int]]: + if raw is None: + return None + if isinstance(raw, (str, bytes)): + raise HTTPException(400, "Erwarte Liste von IDs") + if not isinstance(raw, Sequence): + raise HTTPException(400, "Erwarte Liste von IDs") + out: List[int] = [] + for x in raw: + if x is None: + continue + out.append(int(x)) + return out + + +def _write_context_junctions(cur, model_id: int, data: Dict[str, Any]) -> None: + fa = data.get("focus_area_ids") + if fa is None and data.get("focus_area_id") is not None: + fa = [data.get("focus_area_id")] + if fa is not None: + fa = _normalize_id_list(fa) + cur.execute("DELETE FROM maturity_model_focus_areas WHERE maturity_model_id = %s", (model_id,)) + for i, fid in enumerate(fa or []): + cur.execute( + """ + INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary) + VALUES (%s, %s, %s) + """, + (model_id, fid, i == 0), + ) + + sd = data.get("style_direction_ids") + if sd is None and data.get("style_direction_id") is not None: + sd = [data.get("style_direction_id")] + if sd is not None: + sd = _normalize_id_list(sd) + cur.execute("DELETE FROM maturity_model_style_directions WHERE maturity_model_id = %s", (model_id,)) + for i, sid in enumerate(sd or []): + cur.execute( + """ + INSERT INTO maturity_model_style_directions (maturity_model_id, style_direction_id, is_primary) + VALUES (%s, %s, %s) + """, + (model_id, sid, i == 0), + ) + + tg = data.get("target_group_ids") + if tg is None and data.get("target_group_id") is not None: + tg = [data.get("target_group_id")] + if tg is not None: + tg = _normalize_id_list(tg) + cur.execute("DELETE FROM maturity_model_target_groups WHERE maturity_model_id = %s", (model_id,)) + for i, tid in enumerate(tg or []): + cur.execute( + """ + INSERT INTO maturity_model_target_groups (maturity_model_id, target_group_id, is_primary) + VALUES (%s, %s, %s) + """, + (model_id, tid, i == 0), + ) + + def _load_full_model(cur, model_id: int) -> Dict[str, Any]: - base = _row_maturity_model(cur, model_id) + base = _base_maturity_model(cur, model_id) if not base: raise HTTPException(404, "Reifegradmodell nicht gefunden") + _attach_context(cur, base) cur.execute( """ @@ -56,11 +152,15 @@ def _load_full_model(cur, model_id: int) -> Dict[str, Any]: cur.execute( """ - SELECT ms.*, s.name AS skill_name, s.status AS skill_status + SELECT ms.*, s.name AS skill_name, s.status AS skill_status, + sc.name AS skill_subcategory_name, sc.slug AS skill_subcategory_slug, + mc.name AS skill_main_category_name, mc.slug AS skill_main_category_slug FROM model_skills ms JOIN skills s ON s.id = ms.skill_id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id WHERE ms.maturity_model_id = %s - ORDER BY ms.sort_order ASC, s.name ASC + ORDER BY ms.sort_order ASC, mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.name ASC """, (model_id,), ) @@ -131,6 +231,22 @@ def _replace_levels(cur, model_id: int, level_count: int, levels: List[Dict[str, ) +def _dim_matches(items: List[Dict[str, Any]], query_id: Optional[int]) -> bool: + if not items: + return True + if query_id is None: + return True + return any(int(x["id"]) == int(query_id) for x in items) + + +def _dim_score(items: List[Dict[str, Any]], query_id: Optional[int]) -> int: + if not items: + return 0 + if query_id is not None and any(int(x["id"]) == int(query_id) for x in items): + return 1 + return 0 + + @router.get("/maturity-models/resolve") def resolve_maturity_model( focus_area_id: Optional[int] = Query(default=None), @@ -140,48 +256,37 @@ def resolve_maturity_model( ): """ Wählt das spezifischste aktive Modell, das zum Kontext passt. - Dimensionen mit NULL im Modell gelten als Wildcard. + Leere M:N-Zuordnung = Wildcard für diese Dimension. """ with get_db() as conn: cur = get_cursor(conn) - cur.execute( - """ - SELECT mm.*, - fa.name AS focus_area_name, - sd.name AS style_direction_name, - tg.name AS target_group_name - FROM maturity_models mm - LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id - LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id - LEFT JOIN target_groups tg ON mm.target_group_id = tg.id - WHERE mm.status = 'active' - """ - ) + cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id") rows = [r2d(r) for r in cur.fetchall()] - def matches(m: Dict[str, Any]) -> bool: - if focus_area_id is not None: - if m.get("focus_area_id") is not None and m["focus_area_id"] != focus_area_id: - return False - if style_direction_id is not None: - if m.get("style_direction_id") is not None and m["style_direction_id"] != style_direction_id: - return False - if target_group_id is not None: - if m.get("target_group_id") is not None and m["target_group_id"] != target_group_id: - return False + enriched: List[Dict[str, Any]] = [] + with get_db() as conn: + cur = get_cursor(conn) + for m in rows: + _attach_context(cur, m) + enriched.append(m) + + def ok(m: Dict[str, Any]) -> bool: + if not _dim_matches(m.get("focus_areas") or [], focus_area_id): + return False + if not _dim_matches(m.get("style_directions") or [], style_direction_id): + return False + if not _dim_matches(m.get("target_groups") or [], target_group_id): + return False return True def score(m: Dict[str, Any]) -> int: - s = 0 - if focus_area_id is not None and m.get("focus_area_id") == focus_area_id: - s += 1 - if style_direction_id is not None and m.get("style_direction_id") == style_direction_id: - s += 1 - if target_group_id is not None and m.get("target_group_id") == target_group_id: - s += 1 - return s + return ( + _dim_score(m.get("focus_areas") or [], focus_area_id) + + _dim_score(m.get("style_directions") or [], style_direction_id) + + _dim_score(m.get("target_groups") or [], target_group_id) + ) - candidates = [m for m in rows if matches(m)] + candidates = [m for m in enriched if ok(m)] if not candidates: return None best = max(candidates, key=lambda m: (score(m), m.get("id") or 0)) @@ -199,27 +304,30 @@ def list_maturity_models( ): with get_db() as conn: cur = get_cursor(conn) - q = """ - SELECT mm.*, - fa.name AS focus_area_name, - sd.name AS style_direction_name, - tg.name AS target_group_name - FROM maturity_models mm - LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id - LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id - LEFT JOIN target_groups tg ON mm.target_group_id = tg.id - WHERE 1=1 - """ + q = "SELECT * FROM maturity_models mm WHERE 1=1" params: List[Any] = [] if status: q += " AND mm.status = %s" params.append(status) if focus_area_id is not None: - q += " AND mm.focus_area_id = %s" + q += """ AND ( + EXISTS ( + SELECT 1 FROM maturity_model_focus_areas mfa + WHERE mfa.maturity_model_id = mm.id + AND mfa.focus_area_id = %s + ) + OR NOT EXISTS ( + SELECT 1 FROM maturity_model_focus_areas mfa2 + WHERE mfa2.maturity_model_id = mm.id + ) + )""" params.append(focus_area_id) q += " ORDER BY mm.name ASC" cur.execute(q, params) - return [r2d(r) for r in cur.fetchall()] + rows = [r2d(r) for r in cur.fetchall()] + for m in rows: + _attach_context(cur, m) + return rows @router.get("/maturity-models/{model_id}") @@ -249,19 +357,15 @@ def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_ """ INSERT INTO maturity_models ( name, description, - focus_area_id, style_direction_id, target_group_id, level_count, status, version, created_by, club_id, import_source, import_id - ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( name, data.get("description"), - data.get("focus_area_id"), - data.get("style_direction_id"), - data.get("target_group_id"), level_count, data.get("status") or "draft", data.get("version") or "1.0", @@ -274,9 +378,22 @@ def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_ mid = cur.fetchone()["id"] except Exception as e: if "unique" in str(e).lower() or "duplicate" in str(e).lower(): - raise HTTPException(409, "Ein Modell mit diesem Namen und Kontext existiert bereits") from e + raise HTTPException(409, "Konflikt: import_id oder Name bereits vergeben") from e raise + if any( + k in data + for k in ( + "focus_area_ids", + "style_direction_ids", + "target_group_ids", + "focus_area_id", + "style_direction_id", + "target_group_id", + ) + ): + _write_context_junctions(cur, mid, data) + levels = data.get("levels") if levels: _replace_levels(cur, mid, level_count, levels) @@ -320,15 +437,7 @@ def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = D sets: List[str] = [] vals: List[Any] = [] - for key in ( - "name", - "description", - "focus_area_id", - "style_direction_id", - "target_group_id", - "status", - "version", - ): + for key in ("name", "description", "status", "version"): if key in data: sets.append(f"{key} = %s") vals.append(data[key]) @@ -343,6 +452,19 @@ def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = D tuple(vals), ) + if any( + k in data + for k in ( + "focus_area_ids", + "style_direction_ids", + "target_group_ids", + "focus_area_id", + "style_direction_id", + "target_group_id", + ) + ): + _write_context_junctions(cur, model_id, data) + if "levels" in data and data["levels"] is not None: _replace_levels(cur, model_id, level_count, data["levels"]) @@ -373,7 +495,7 @@ def add_model_skill(model_id: int, data: Dict[str, Any], session: dict = Depends with get_db() as conn: cur = get_cursor(conn) - if not _row_maturity_model(cur, model_id): + if not _base_maturity_model(cur, model_id): raise HTTPException(404, "Reifegradmodell nicht gefunden") cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,)) if not cur.fetchone(): diff --git a/backend/version.py b/backend/version.py index 3e49959..47b9fff 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.7.1" +APP_VERSION = "0.7.2" BUILD_DATE = "2026-04-27" -DB_SCHEMA_VERSION = "20260427024" +DB_SCHEMA_VERSION = "20260427025" MODULE_VERSIONS = { "auth": "1.0.0", @@ -19,10 +19,19 @@ MODULE_VERSIONS = { "admin": "1.0.0", "membership": "1.0.0", "catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) - "maturity_models": "1.0.0", # Migration 024: Reifegradmodelle / Fähigkeitsmatrix + "maturity_models": "1.1.0", # 025: Kontext M:N + Wiki-Bootstrap } CHANGELOG = [ + { + "version": "0.7.2", + "date": "2026-04-27", + "changes": [ + "DB: Migration 025 – maturity_model_focus_areas / _style_directions / _target_groups (M:N)", + "Bootstrap-Reifegradmodell für alle importierten Skills (Wiki), Stufen 1–5", + "API+UI: Kontext als Mehrfachauswahl; Matrix zeigt Haupt-/Untergruppe pro Fähigkeit", + ], + }, { "version": "0.7.1", "date": "2026-04-27", diff --git a/frontend/src/pages/AdminMaturityModelsPage.jsx b/frontend/src/pages/AdminMaturityModelsPage.jsx index af81d8c..5856f40 100644 --- a/frontend/src/pages/AdminMaturityModelsPage.jsx +++ b/frontend/src/pages/AdminMaturityModelsPage.jsx @@ -4,6 +4,31 @@ import { useAuth } from '../context/AuthContext' import api from '../utils/api' import AdminPageNav from '../components/AdminPageNav' +function MultiIdSelect({ label, options, valueIds, onChange, hint }) { + return ( +
+ + + {hint ? ( +
{hint}
+ ) : null} +
+ ) +} + export default function AdminMaturityModelsPage() { const { user } = useAuth() const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' @@ -23,9 +48,9 @@ export default function AdminMaturityModelsPage() { const [newModel, setNewModel] = useState({ name: '', level_count: 5, - focus_area_id: '', - style_direction_id: '', - target_group_id: '', + focus_area_ids: [], + style_direction_ids: [], + target_group_ids: [], status: 'draft' }) @@ -76,9 +101,9 @@ export default function AdminMaturityModelsPage() { setMeta({ name: d.name, description: d.description || '', - focus_area_id: d.focus_area_id ?? '', - style_direction_id: d.style_direction_id ?? '', - target_group_id: d.target_group_id ?? '', + 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' }) @@ -118,13 +143,9 @@ export default function AdminMaturityModelsPage() { name: newModel.name.trim(), level_count: parseInt(String(newModel.level_count), 10), status: newModel.status, - focus_area_id: newModel.focus_area_id ? parseInt(String(newModel.focus_area_id), 10) : null, - style_direction_id: newModel.style_direction_id - ? parseInt(String(newModel.style_direction_id), 10) - : null, - target_group_id: newModel.target_group_id - ? parseInt(String(newModel.target_group_id), 10) - : null + 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() @@ -132,9 +153,9 @@ export default function AdminMaturityModelsPage() { setNewModel({ name: '', level_count: 5, - focus_area_id: '', - style_direction_id: '', - target_group_id: '', + focus_area_ids: [], + style_direction_ids: [], + target_group_ids: [], status: 'draft' }) } catch (e) { @@ -152,11 +173,9 @@ export default function AdminMaturityModelsPage() { await api.updateMaturityModel(selectedId, { name: meta.name, description: meta.description || null, - focus_area_id: meta.focus_area_id === '' ? null : parseInt(String(meta.focus_area_id), 10), - style_direction_id: - meta.style_direction_id === '' ? null : parseInt(String(meta.style_direction_id), 10), - target_group_id: - meta.target_group_id === '' ? null : parseInt(String(meta.target_group_id), 10), + 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 }) @@ -343,10 +362,18 @@ export default function AdminMaturityModelsPage() { onClick={() => selectModel(m.id)} >
{m.name}
-
- {[m.focus_area_name, m.style_direction_name, m.target_group_name] - .filter(Boolean) - .join(' · ') || 'Allgemein'} +
+ {(m.focus_areas || []).length + ? `Fokus: ${m.focus_areas.map((f) => f.name).join(', ')}` + : 'Fokus: alle'} +
+ {(m.style_directions || []).length + ? `Stil: ${m.style_directions.map((s) => s.name).join(', ')}` + : 'Stil: alle'} +
+ {(m.target_groups || []).length + ? `Zielgr.: ${m.target_groups.map((t) => t.name).join(', ')}` + : 'Zielgr.: alle'}
{m.status} · {m.level_count} Stufen
@@ -384,39 +411,27 @@ export default function AdminMaturityModelsPage() { - - - - - - + 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." + /> @@ -454,55 +469,27 @@ export default function AdminMaturityModelsPage() { onChange={(e) => setMeta((m) => ({ ...m, description: e.target.value }))} />
-
-
- - -
-
- - -
-
-
- - -
+ setMeta((m) => ({ ...m, focus_area_ids: ids }))} + hint="Strg/Cmd + Klick für mehrere. Alle abwählen = gilt in jedem Fokusbereich." + /> + setMeta((m) => ({ ...m, style_direction_ids: ids }))} + hint="Strg/Cmd + Klick für mehrere." + /> + setMeta((m) => ({ ...m, target_group_ids: ids }))} + hint="Strg/Cmd + Klick für mehrere." + />
@@ -640,17 +627,25 @@ export default function AdminMaturityModelsPage() {
    {(detail.model_skills || []).map((ms) => ( -
  • - {ms.skill_name} - {' '} - +
  • +
    + {ms.skill_name} + {' '} + +
    + {(ms.skill_main_category_name || ms.skill_subcategory_name) ? ( +
    + {ms.skill_main_category_name} + {ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''} +
    + ) : null}
  • ))}
@@ -690,10 +685,17 @@ export default function AdminMaturityModelsPage() { left: 0, background: 'var(--surface)', fontWeight: 600, - whiteSpace: 'nowrap' + maxWidth: 220, + verticalAlign: 'top' }} > - {ms.skill_name} +
{ms.skill_name}
+ {(ms.skill_main_category_name || ms.skill_subcategory_name) ? ( +
+ {ms.skill_main_category_name || '—'} + {ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''} +
+ ) : null} {(detail.levels || []).map((l) => { const key = `${ms.skill_id}-${l.level_number}`