From 3397b2094dba9f11b38119738409d638e94483a1 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Apr 2026 12:35:48 +0200 Subject: [PATCH] feat: update version to 0.7.3 and enhance maturity model context bindings - Incremented application version to 0.7.3 and updated database schema version to 20260427026. - Enhanced maturity model context bindings with new hierarchical resolution logic for focus areas, style directions, and training types. - Added new API endpoints for managing maturity model context bindings. - Updated frontend components to support the new context binding functionality and improved admin UI for better user experience. - Documented changes in the changelog for version 0.7.3, including new features and improvements. --- .../026_maturity_model_context_bindings.sql | 35 ++ backend/routers/maturity_models.py | 338 ++++++++++++++++-- backend/version.py | 15 +- frontend/src/app.css | 53 +++ .../admin/MaturityModelBindingsAdmin.jsx | 291 +++++++++++++++ .../src/pages/AdminMaturityModelsPage.jsx | 18 +- frontend/src/utils/api.js | 19 + 7 files changed, 743 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/026_maturity_model_context_bindings.sql create mode 100644 frontend/src/components/admin/MaturityModelBindingsAdmin.jsx diff --git a/backend/migrations/026_maturity_model_context_bindings.sql b/backend/migrations/026_maturity_model_context_bindings.sql new file mode 100644 index 0000000..761115f --- /dev/null +++ b/backend/migrations/026_maturity_model_context_bindings.sql @@ -0,0 +1,35 @@ +-- Migration 026: Hierarchische Kontext-Zuordnung Reifegradmodell → Fokus / Stilrichtung / Trainingsstil +-- Vererbung: Modell auf Fokus-Ebene gilt als Basis; spezifischere Zeilen überschreiben Zelltexte +-- für dieselbe Fähigkeit (skill_id) beim Zusammenführen (resolve). +-- Datum: 2026-04-27 + +CREATE TABLE IF NOT EXISTS maturity_model_context_bindings ( + 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, + style_direction_id INT REFERENCES style_directions(id) ON DELETE CASCADE, + training_type_id INT REFERENCES training_types(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + CONSTRAINT chk_mcb_tier CHECK ( + (style_direction_id IS NULL AND training_type_id IS NULL) + OR (style_direction_id IS NOT NULL AND training_type_id IS NULL) + OR (style_direction_id IS NOT NULL AND training_type_id IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_mmcb_model ON maturity_model_context_bindings(maturity_model_id); +CREATE INDEX IF NOT EXISTS idx_mmcb_focus ON maturity_model_context_bindings(focus_area_id); + +-- Pro Ebene höchstens eine Zuordnung je Kontext +CREATE UNIQUE INDEX IF NOT EXISTS uq_mmcb_focus_only + ON maturity_model_context_bindings (focus_area_id) + WHERE style_direction_id IS NULL AND training_type_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_mmcb_focus_style + ON maturity_model_context_bindings (focus_area_id, style_direction_id) + WHERE style_direction_id IS NOT NULL AND training_type_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_mmcb_focus_style_ttype + ON maturity_model_context_bindings (focus_area_id, style_direction_id, training_type_id) + WHERE training_type_id IS NOT NULL; diff --git a/backend/routers/maturity_models.py b/backend/routers/maturity_models.py index 63d8466..3fece24 100644 --- a/backend/routers/maturity_models.py +++ b/backend/routers/maturity_models.py @@ -247,28 +247,145 @@ def _dim_score(items: List[Dict[str, Any]], query_id: Optional[int]) -> int: return 0 -@router.get("/maturity-models/resolve") -def resolve_maturity_model( - focus_area_id: Optional[int] = Query(default=None), - style_direction_id: Optional[int] = Query(default=None), - target_group_id: Optional[int] = Query(default=None), - session: dict = Depends(require_auth), -): - """ - Wählt das spezifischste aktive Modell, das zum Kontext passt. - Leere M:N-Zuordnung = Wildcard für diese Dimension. - """ - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id") - rows = [r2d(r) for r in cur.fetchall()] +def _binding_model_active(cur, maturity_model_id: int) -> bool: + b = _base_maturity_model(cur, maturity_model_id) + return bool(b and b.get("status") == "active") + +def _resolve_binding_model_ids( + cur, + focus_area_id: int, + style_direction_id: Optional[int], + training_type_id: Optional[int], +) -> List[int]: + """ + Kette Basis → Stilrichtung → Trainingsstil (training_types). + Ohne Basis-Zeile (nur Fokus): leere Liste → Aufrufer nutzt Legacy-Resolve. + Nur **aktive** Modelle werden eingereiht; inaktive Overlays werden übersprungen. + """ + chain: List[int] = [] + cur.execute( + """ + SELECT maturity_model_id FROM maturity_model_context_bindings + WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id IS NULL + """, + (focus_area_id,), + ) + r = cur.fetchone() + if not r: + return [] + mid0 = int(r["maturity_model_id"]) + if not _binding_model_active(cur, mid0): + return [] + chain.append(mid0) + + if style_direction_id is not None: + cur.execute( + """ + SELECT maturity_model_id FROM maturity_model_context_bindings + WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id IS NULL + """, + (focus_area_id, style_direction_id), + ) + r2 = cur.fetchone() + if r2 and _binding_model_active(cur, int(r2["maturity_model_id"])): + chain.append(int(r2["maturity_model_id"])) + + if style_direction_id is not None and training_type_id is not None: + cur.execute( + """ + SELECT maturity_model_id FROM maturity_model_context_bindings + WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id = %s + """, + (focus_area_id, style_direction_id, training_type_id), + ) + r3 = cur.fetchone() + if r3 and _binding_model_active(cur, int(r3["maturity_model_id"])): + chain.append(int(r3["maturity_model_id"])) + + return chain + + +def _merge_loaded_models(loaded: List[Dict[str, Any]]) -> Dict[str, Any]: + """Überlagert Zelltexte in Reihenfolge der Kette; Zeilen kommen aus Basis + fehlende Fähigkeiten aus Overlays.""" + if not loaded: + raise ValueError("merge: keine Modelle") + base = loaded[0] + base_lc = int(base.get("level_count") or 5) + base_levels = base.get("levels") or [] + + skill_rows: Dict[int, Dict[str, Any]] = {} + order: List[int] = [] + for ms in base.get("model_skills") or []: + sid = int(ms["skill_id"]) + skill_rows[sid] = dict(ms) + order.append(sid) + + for fm in loaded[1:]: + for ms in fm.get("model_skills") or []: + sid = int(ms["skill_id"]) + if sid not in skill_rows: + row = dict(ms) + row["maturity_model_id"] = base["id"] + skill_rows[sid] = row + order.append(sid) + + merged_model_skills = [skill_rows[sid] for sid in order] + + cell_map: Dict[tuple, Dict[str, Any]] = {} + for fm in loaded: + for sl in fm.get("skill_levels") or []: + ln = int(sl["level_number"]) + if ln < 1 or ln > base_lc: + continue + sid = int(sl["skill_id"]) + key = (sid, ln) + cell_map[key] = { + "maturity_model_id": base["id"], + "skill_id": sid, + "level_number": ln, + "description": sl.get("description") or "", + "observable_criteria": sl.get("observable_criteria"), + "example_exercise_hints": sl.get("example_exercise_hints"), + "ai_generated": sl.get("ai_generated"), + "skill_name": sl.get("skill_name"), + } + + for sid, ln in list(cell_map.keys()): + row = cell_map[(sid, ln)] + if not row.get("skill_name"): + row["skill_name"] = skill_rows.get(sid, {}).get("skill_name") + + merged_skill_levels = list(cell_map.values()) + merged_skill_levels.sort(key=lambda x: (x.get("skill_name") or "", x["level_number"])) + + out = { + **base, + "model_skills": merged_model_skills, + "skill_levels": merged_skill_levels, + "levels": base_levels, + "level_count": base_lc, + "resolution": { + "merged": len(loaded) > 1, + "source_model_ids": [int(m["id"]) for m in loaded], + "binding_strategy": "hierarchical_override", + }, + } + return out + + +def _legacy_resolve_pick_model_id( + cur, + focus_area_id: Optional[int], + style_direction_id: Optional[int], + target_group_id: Optional[int], +) -> Optional[int]: + cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id") + rows = [r2d(r) for r in cur.fetchall()] 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) + 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): @@ -290,10 +407,49 @@ def resolve_maturity_model( if not candidates: return None best = max(candidates, key=lambda m: (score(m), m.get("id") or 0)) - model_id = best["id"] + return int(best["id"]) + + +@router.get("/maturity-models/resolve") +def resolve_maturity_model( + focus_area_id: Optional[int] = Query(default=None), + style_direction_id: Optional[int] = Query(default=None), + target_group_id: Optional[int] = Query(default=None), + training_type_id: Optional[int] = Query( + default=None, + description="Trainingsstil (training_types, z. B. Leistungssport); nur mit Stilrichtung sinnvoll.", + ), + session: dict = Depends(require_auth), +): + """ + Liefert die Fähigkeitsmatrix zum Kontext. + + **Priorität 1:** Tabelle `maturity_model_context_bindings` (Fokus → optional Stilrichtung → optional Trainingsstil). + Mehrere Modelle werden zusammengeführt: spätere Stufen überschreiben Zelltexte für dieselbe Fähigkeit/Stufe. + + **Priorität 2 (Legacy):** Ein aktives Modell, dessen M:N-Kontext zum Filter passt (Zielgruppe unverändert). + """ + if focus_area_id is not None: + with get_db() as conn: + cur = get_cursor(conn) + chain = _resolve_binding_model_ids( + cur, + int(focus_area_id), + style_direction_id, + training_type_id, + ) + if chain: + loaded = [_load_full_model(cur, mid) for mid in chain] + return _merge_loaded_models(loaded) + with get_db() as conn: cur = get_cursor(conn) - return _load_full_model(cur, model_id) + mid = _legacy_resolve_pick_model_id( + cur, focus_area_id, style_direction_id, target_group_id + ) + if mid is None: + return None + return _load_full_model(cur, mid) @router.get("/maturity-models") @@ -603,3 +759,141 @@ def upsert_model_skill_levels(model_id: int, data: Dict[str, Any], session: dict with get_db() as conn: cur = get_cursor(conn) return _load_full_model(cur, model_id) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Kontext-Bindings (Fokus / Stilrichtung / Trainingsstil) — hierarchisch +# ═══════════════════════════════════════════════════════════════════════════ + + +@router.get("/maturity-model-context-bindings") +def list_maturity_model_context_bindings(session: dict = Depends(require_auth)): + role = session.get("role") + if role not in ("admin", "superadmin"): + raise HTTPException(403, "Nur Administratoren") + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT b.id, b.maturity_model_id, mm.name AS maturity_model_name, mm.status AS model_status, + b.focus_area_id, fa.name AS focus_area_name, + b.style_direction_id, sd.name AS style_direction_name, + b.training_type_id, tt.name AS training_type_name + FROM maturity_model_context_bindings b + JOIN maturity_models mm ON mm.id = b.maturity_model_id + JOIN focus_areas fa ON fa.id = b.focus_area_id + LEFT JOIN style_directions sd ON sd.id = b.style_direction_id + LEFT JOIN training_types tt ON tt.id = b.training_type_id + ORDER BY fa.sort_order NULLS LAST, fa.name, + (b.style_direction_id IS NULL) DESC, + sd.name NULLS LAST, + (b.training_type_id IS NULL) DESC, + tt.name NULLS LAST + """ + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/maturity-model-context-bindings") +def upsert_maturity_model_context_binding(data: Dict[str, Any], session: dict = Depends(require_auth)): + _require_admin(session) + mid = data.get("maturity_model_id") + fa = data.get("focus_area_id") + if mid is None or fa is None: + raise HTTPException(400, "maturity_model_id und focus_area_id sind Pflichtfelder") + mid = int(mid) + fa = int(fa) + sd_raw = data.get("style_direction_id") + tt_raw = data.get("training_type_id") + sd: Optional[int] = int(sd_raw) if sd_raw is not None else None + tt: Optional[int] = int(tt_raw) if tt_raw is not None else None + + if sd is None and tt is not None: + raise HTTPException(400, "Trainingsstil nur zusammen mit Stilrichtung erlaubt") + + with get_db() as conn: + cur = get_cursor(conn) + if not _base_maturity_model(cur, mid): + raise HTTPException(404, "Reifegradmodell nicht gefunden") + cur.execute("SELECT id FROM focus_areas WHERE id = %s", (fa,)) + if not cur.fetchone(): + raise HTTPException(404, "Fokusbereich nicht gefunden") + if sd is not None: + cur.execute("SELECT id FROM style_directions WHERE id = %s", (sd,)) + if not cur.fetchone(): + raise HTTPException(404, "Stilrichtung nicht gefunden") + if tt is not None: + cur.execute("SELECT id FROM training_types WHERE id = %s", (tt,)) + if not cur.fetchone(): + raise HTTPException(404, "Trainingsstil nicht gefunden") + + if sd is None and tt is None: + cur.execute( + """ + DELETE FROM maturity_model_context_bindings + WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id IS NULL + """, + (fa,), + ) + elif tt is None: + cur.execute( + """ + DELETE FROM maturity_model_context_bindings + WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id IS NULL + """, + (fa, sd), + ) + else: + cur.execute( + """ + DELETE FROM maturity_model_context_bindings + WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id = %s + """, + (fa, sd, tt), + ) + + cur.execute( + """ + INSERT INTO maturity_model_context_bindings ( + maturity_model_id, focus_area_id, style_direction_id, training_type_id + ) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + (mid, fa, sd, tt), + ) + new_id = cur.fetchone()["id"] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT b.id, b.maturity_model_id, mm.name AS maturity_model_name, mm.status AS model_status, + b.focus_area_id, fa.name AS focus_area_name, + b.style_direction_id, sd.name AS style_direction_name, + b.training_type_id, tt.name AS training_type_name + FROM maturity_model_context_bindings b + JOIN maturity_models mm ON mm.id = b.maturity_model_id + JOIN focus_areas fa ON fa.id = b.focus_area_id + LEFT JOIN style_directions sd ON sd.id = b.style_direction_id + LEFT JOIN training_types tt ON tt.id = b.training_type_id + WHERE b.id = %s + """, + (new_id,), + ) + row = cur.fetchone() + return r2d(row) if row else {"id": new_id} + + +@router.delete("/maturity-model-context-bindings/{binding_id}") +def delete_maturity_model_context_binding(binding_id: int, session: dict = Depends(require_auth)): + _require_admin(session) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM maturity_model_context_bindings WHERE id = %s RETURNING id", + (binding_id,), + ) + if not cur.fetchone(): + raise HTTPException(404, "Zuordnung nicht gefunden") + return {"ok": True} diff --git a/backend/version.py b/backend/version.py index 47b9fff..7e5f498 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.7.2" +APP_VERSION = "0.7.3" BUILD_DATE = "2026-04-27" -DB_SCHEMA_VERSION = "20260427025" +DB_SCHEMA_VERSION = "20260427026" 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.1.0", # 025: Kontext M:N + Wiki-Bootstrap + "maturity_models": "1.2.0", # 026: hierarchische Kontext-Bindings + Merge in resolve } CHANGELOG = [ + { + "version": "0.7.3", + "date": "2026-04-27", + "changes": [ + "DB 026: maturity_model_context_bindings (Fokus / Stilrichtung / Trainingsstil)", + "API: resolve merged mehrere Modelle; CRUD Bindings; training_type_id Query", + "Admin-Tab Kontext-Zuordnung", + ], + }, { "version": "0.7.2", "date": "2026-04-27", diff --git a/frontend/src/app.css b/frontend/src/app.css index b7d27f3..2ba6bad 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1099,6 +1099,59 @@ a.analysis-split__nav-item { max-width: 56rem; } +.admin-bindings__intro { + margin: 0 0 12px; + max-width: 56rem; + line-height: 1.55; +} +.admin-bindings__h2 { + font-size: 1.05rem; + font-weight: 700; + margin: 0 0 12px; +} +.admin-bindings__form-section { + margin-bottom: 20px; +} +.admin-bindings__form { + display: grid; + gap: 12px; + max-width: 440px; +} +.admin-bindings__code { + font-size: 12px; + background: var(--surface2); + padding: 2px 6px; + border-radius: 6px; + font-family: ui-monospace, monospace; +} +.admin-bindings-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border: 1px solid var(--border); + border-radius: 12px; +} +.admin-bindings-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.admin-bindings-table th, +.admin-bindings-table td { + text-align: left; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.admin-bindings-table th { + font-weight: 600; + color: var(--text2); + font-size: 13px; + background: var(--surface2); +} +.admin-bindings-table tbody tr:last-child td { + border-bottom: none; +} + .skills-catalog-admin__intro { margin: 0 0 16px; max-width: 56rem; diff --git a/frontend/src/components/admin/MaturityModelBindingsAdmin.jsx b/frontend/src/components/admin/MaturityModelBindingsAdmin.jsx new file mode 100644 index 0000000..3dc5d68 --- /dev/null +++ b/frontend/src/components/admin/MaturityModelBindingsAdmin.jsx @@ -0,0 +1,291 @@ +import React, { useCallback, useEffect, useState } from 'react' +import api from '../../utils/api' + +const TIER_LABEL = { + focus: 'Nur Fokusbereich', + focus_style: 'Fokus + Stilrichtung', + focus_style_type: 'Fokus + Stilrichtung + Trainingsstil' +} + +function tierFromRow(row) { + if (row.style_direction_id == null && row.training_type_id == null) return 'focus' + if (row.training_type_id == null) return 'focus_style' + return 'focus_style_type' +} + +export default function MaturityModelBindingsAdmin() { + const [bindings, setBindings] = useState([]) + const [models, setModels] = useState([]) + const [focusAreas, setFocusAreas] = useState([]) + const [styleDirections, setStyleDirections] = useState([]) + const [trainingTypes, setTrainingTypes] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const [tier, setTier] = useState('focus') + const [formModelId, setFormModelId] = useState('') + const [formFocusId, setFormFocusId] = useState('') + const [formStyleId, setFormStyleId] = useState('') + const [formTypeId, setFormTypeId] = useState('') + + const load = useCallback(async () => { + setError('') + try { + const [b, m, fa, sd, tt] = await Promise.all([ + api.listMaturityModelContextBindings(), + api.listMaturityModels({}), + api.listFocusAreas({}), + api.listStyleDirections({}), + api.listTrainingTypes({}) + ]) + setBindings(b) + setModels(m) + setFocusAreas(fa) + setStyleDirections(sd) + setTrainingTypes(tt) + } catch (e) { + setError(e.message || String(e)) + } + }, []) + + useEffect(() => { + let cancelled = false + ;(async () => { + setLoading(true) + await load() + if (!cancelled) setLoading(false) + })() + return () => { + cancelled = true + } + }, [load]) + + useEffect(() => { + if (tier === 'focus') { + setFormStyleId('') + setFormTypeId('') + } else if (tier === 'focus_style') { + setFormTypeId('') + } + }, [tier]) + + async function handleSubmit(e) { + e.preventDefault() + if (!formModelId || !formFocusId) { + setError('Modell und Fokusbereich sind Pflichtfelder.') + return + } + const payload = { + maturity_model_id: parseInt(formModelId, 10), + focus_area_id: parseInt(formFocusId, 10) + } + if (tier === 'focus_style' || tier === 'focus_style_type') { + if (!formStyleId) { + setError('Stilrichtung auswählen.') + return + } + payload.style_direction_id = parseInt(formStyleId, 10) + } + if (tier === 'focus_style_type') { + if (!formTypeId) { + setError('Trainingsstil auswählen.') + return + } + payload.training_type_id = parseInt(formTypeId, 10) + } + + setSaving(true) + setError('') + try { + await api.upsertMaturityModelContextBinding(payload) + await load() + } catch (err) { + setError(err.message || String(err)) + } finally { + setSaving(false) + } + } + + async function handleDelete(id) { + if (!window.confirm('Diese Zuordnung wirklich entfernen?')) return + setError('') + try { + await api.deleteMaturityModelContextBinding(id) + await load() + } catch (err) { + setError(err.message || String(err)) + } + } + + if (loading) { + return

Lade Kontext-Zuordnungen…

+ } + + return ( +
+

+ Vererbung: Ein Modell auf Ebene „Fokusbereich“ gilt als Basis für diesen Fokus. Gibt es + eine spezifischere Zeile (Fokus + Stilrichtung oder + Trainingsstil), werden bei der Auflösung der Matrix{' '} + dieselben Fähigkeiten mit den Texten aus dem spezifischeren Modell überschrieben. + Zusätzliche Fähigkeiten aus einem Overlay-Modell erscheinen als weitere Zeilen. Stufen (Spalten) kommen + immer vom Basis-Modell (Fokus-Ebene). +

+

+ API GET /api/maturity-models/resolve berücksichtigt{' '} + training_type_id (Trainingsstil ={' '} + training_types, z. B. Leistungssport) zusätzlich zu Fokus und Stilrichtung. +

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

Zuordnung anlegen oder ersetzen

+
+
+ + +
+
+ + +
+ {(tier === 'focus_style' || tier === 'focus_style_type') && ( +
+ + +
+ )} + {tier === 'focus_style_type' && ( +
+ + +
+ )} +
+ + +
+ +
+
+ +
+

Aktive Zuordnungen

+
+ + + + + + + + + + + + {bindings.length === 0 ? ( + + + + ) : ( + bindings.map((row) => ( + + + + + + + + + )) + )} + +
EbeneFokusStilrichtungTrainingsstilModell +
+ Noch keine Einträge. Legen Sie mindestens eine Fokus-Basis-Zeile an, damit{' '} + /maturity-models/resolve die neue Logik nutzt. +
{TIER_LABEL[tierFromRow(row)]}{row.focus_area_name}{row.style_direction_name || '—'}{row.training_type_name || '—'} + {row.maturity_model_name} + ({row.model_status}) + + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/AdminMaturityModelsPage.jsx b/frontend/src/pages/AdminMaturityModelsPage.jsx index a2b9769..2ee9554 100644 --- a/frontend/src/pages/AdminMaturityModelsPage.jsx +++ b/frontend/src/pages/AdminMaturityModelsPage.jsx @@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext' import AdminPageNav from '../components/AdminPageNav' import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin' import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel' +import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin' export default function AdminMaturityModelsPage() { const { user } = useAuth() @@ -44,10 +45,25 @@ export default function AdminMaturityModelsPage() { > Reifegradmodelle +
- {tab === 'catalog' ? : } + {tab === 'catalog' ? ( + + ) : tab === 'bindings' ? ( + + ) : ( + + )}
) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8a33049..e410a73 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -326,6 +326,22 @@ export async function upsertMaturityModelSkillLevels(modelId, data) { }) } +/** Hierarchische Zuordnung Modell → Fokus / Stilrichtung / Trainingsstil (training_types) */ +export async function listMaturityModelContextBindings() { + return request('/api/maturity-model-context-bindings') +} + +export async function upsertMaturityModelContextBinding(data) { + return request('/api/maturity-model-context-bindings', { + method: 'POST', + body: JSON.stringify(data) + }) +} + +export async function deleteMaturityModelContextBinding(id) { + return request(`/api/maturity-model-context-bindings/${id}`, { method: 'DELETE' }) +} + // Style Directions (formerly Training Styles) export async function listStyleDirections(filters = {}) { const query = new URLSearchParams(filters).toString() @@ -680,6 +696,9 @@ export const api = { updateStyleDirectionTargetGroup, deleteStyleDirectionTargetGroup, listMaturityModels, + listMaturityModelContextBindings, + upsertMaturityModelContextBinding, + deleteMaturityModelContextBinding, resolveMaturityModel, getMaturityModel, createMaturityModel,