diff --git a/backend/migrations/027_maturity_bindings_focus_training.sql b/backend/migrations/027_maturity_bindings_focus_training.sql new file mode 100644 index 0000000..4ff3d34 --- /dev/null +++ b/backend/migrations/027_maturity_bindings_focus_training.sql @@ -0,0 +1,17 @@ +-- Migration 027: Kontext-Bindings – Fokus + Trainingsstil ohne Stilrichtung (z. B. Karate + Breitensport) +-- Ersetzt die alte CHECK-Constraint-Kette; partielle Unique-Indizes für alle vier Kombinationen. +-- Datum: 2026-04-27 + +ALTER TABLE maturity_model_context_bindings DROP CONSTRAINT IF EXISTS chk_mcb_tier; + +DROP INDEX IF EXISTS uq_mmcb_focus_style_ttype; + +-- Fokus + Stilrichtung + Trainingsstil (alle drei gesetzt) +CREATE UNIQUE INDEX IF NOT EXISTS uq_mmcb_focus_style_training + ON maturity_model_context_bindings (focus_area_id, style_direction_id, training_type_id) + WHERE style_direction_id IS NOT NULL AND training_type_id IS NOT NULL; + +-- Fokus + Trainingsstil (ohne Stilrichtung) +CREATE UNIQUE INDEX IF NOT EXISTS uq_mmcb_focus_training + ON maturity_model_context_bindings (focus_area_id, training_type_id) + WHERE style_direction_id IS NULL AND training_type_id IS NOT NULL; diff --git a/backend/routers/maturity_models.py b/backend/routers/maturity_models.py index 3fece24..ee5719e 100644 --- a/backend/routers/maturity_models.py +++ b/backend/routers/maturity_models.py @@ -6,9 +6,11 @@ Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt übe Lesen: alle authentifizierten Nutzer. Schreiben: admin, superadmin. """ +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse from auth import require_auth from db import get_db, get_cursor, r2d @@ -252,6 +254,31 @@ def _binding_model_active(cur, maturity_model_id: int) -> bool: return bool(b and b.get("status") == "active") +def _binding_matches_query_dims( + style_direction_id: Optional[int], + training_type_id: Optional[int], + b_style: Any, + b_tt: Any, +) -> bool: + """Zeile passt zur Abfrage, wenn gesetzte Dimensionswerte der Zeile mit der Anfrage übereinstimmen.""" + if b_style is not None: + if style_direction_id is None or int(b_style) != int(style_direction_id): + return False + if b_tt is not None: + if training_type_id is None or int(b_tt) != int(training_type_id): + return False + return True + + +def _binding_dim_count(b_style: Any, b_tt: Any) -> int: + n = 0 + if b_style is not None: + n += 1 + if b_tt is not None: + n += 1 + return n + + def _resolve_binding_model_ids( cur, focus_area_id: int, @@ -259,51 +286,44 @@ def _resolve_binding_model_ids( 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. + Alle Bindings zum Fokus, die zur Abfrage passen (inkl. Fokus+Trainingsstil ohne Stilrichtung), + sortiert nach Spezifität (weniger spezifisch zuerst). Gleiche model_id nur einmal. + Nur aktive Modelle. Leere Liste → Legacy-Resolve. """ - 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 + SELECT id, maturity_model_id, style_direction_id, training_type_id + FROM maturity_model_context_bindings + WHERE focus_area_id = %s """, (focus_area_id,), ) - r = cur.fetchone() - if not r: + rows = [r2d(r) for r in cur.fetchall()] + matching: List[Dict[str, Any]] = [] + for r in rows: + if _binding_matches_query_dims( + style_direction_id, + training_type_id, + r.get("style_direction_id"), + r.get("training_type_id"), + ): + matching.append(r) + if not matching: 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), + matching.sort( + key=lambda r: ( + _binding_dim_count(r.get("style_direction_id"), r.get("training_type_id")), + int(r["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 + ) + out: List[int] = [] + for r in matching: + mid = int(r["maturity_model_id"]) + if not _binding_model_active(cur, mid): + continue + if mid not in out: + out.append(mid) + return out def _merge_loaded_models(loaded: List[Dict[str, Any]]) -> Dict[str, Any]: @@ -368,7 +388,7 @@ def _merge_loaded_models(loaded: List[Dict[str, Any]]) -> Dict[str, Any]: "resolution": { "merged": len(loaded) > 1, "source_model_ids": [int(m["id"]) for m in loaded], - "binding_strategy": "hierarchical_override", + "binding_strategy": "specificity_merge", }, } return out @@ -417,21 +437,22 @@ def resolve_maturity_model( 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.", + description="Trainingsstil (training_types, z. B. Breitensport); auch ohne Stilrichtung nutzbar.", ), 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 1:** `maturity_model_context_bindings`: alle passenden Zeilen zum Fokus (z. B. nur Fokus, + Fokus+Stilrichtung, Fokus+Trainingsstil, alle drei) werden nach Spezifität gemerged; spezifischere + Zuordnungen überschreiben Zelltexte gleicher Fähigkeit/Stufe. - **Priorität 2 (Legacy):** Ein aktives Modell, dessen M:N-Kontext zum Filter passt (Zielgruppe unverändert). + **Priorität 2 (Legacy):** Ein aktives Modell per M:N am Modell (Zielgruppe unverändert). """ - if focus_area_id is not None: - with get_db() as conn: - cur = get_cursor(conn) + with get_db() as conn: + cur = get_cursor(conn) + if focus_area_id is not None: chain = _resolve_binding_model_ids( cur, int(focus_area_id), @@ -442,8 +463,6 @@ def resolve_maturity_model( loaded = [_load_full_model(cur, mid) for mid in chain] return _merge_loaded_models(loaded) - with get_db() as conn: - cur = get_cursor(conn) mid = _legacy_resolve_pick_model_id( cur, focus_area_id, style_direction_id, target_group_id ) @@ -808,9 +827,6 @@ def upsert_maturity_model_context_binding(data: Dict[str, Any], session: dict = 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): @@ -835,7 +851,7 @@ def upsert_maturity_model_context_binding(data: Dict[str, Any], session: dict = """, (fa,), ) - elif tt is None: + elif sd is not None and tt is None: cur.execute( """ DELETE FROM maturity_model_context_bindings @@ -843,6 +859,14 @@ def upsert_maturity_model_context_binding(data: Dict[str, Any], session: dict = """, (fa, sd), ) + elif sd is None and tt is not None: + cur.execute( + """ + DELETE FROM maturity_model_context_bindings + WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id = %s + """, + (fa, tt), + ) else: cur.execute( """ @@ -897,3 +921,365 @@ def delete_maturity_model_context_binding(binding_id: int, session: dict = Depen if not cur.fetchone(): raise HTTPException(404, "Zuordnung nicht gefunden") return {"ok": True} + + +# ═══════════════════════════════════════════════════════════════════════════ +# Export / Import (JSON) +# ═══════════════════════════════════════════════════════════════════════════ + + +def _strip_model_for_export(full: Dict[str, Any]) -> Dict[str, Any]: + ms_out: List[Dict[str, Any]] = [] + for ms in full.get("model_skills") or []: + ms_out.append( + { + "skill_id": ms.get("skill_id"), + "sort_order": ms.get("sort_order", 0), + "relevance": ms.get("relevance"), + "skill_name": ms.get("skill_name"), + "skill_subcategory_name": ms.get("skill_subcategory_name"), + "skill_subcategory_slug": ms.get("skill_subcategory_slug"), + "skill_main_category_name": ms.get("skill_main_category_name"), + "skill_main_category_slug": ms.get("skill_main_category_slug"), + } + ) + sl_out: List[Dict[str, Any]] = [] + for sl in full.get("skill_levels") or []: + sl_out.append( + { + "skill_id": sl.get("skill_id"), + "level_number": sl.get("level_number"), + "description": sl.get("description") or "", + "observable_criteria": sl.get("observable_criteria"), + "skill_name": sl.get("skill_name"), + } + ) + lv_out: List[Dict[str, Any]] = [] + for lv in full.get("levels") or []: + lv_out.append( + { + "level_number": lv.get("level_number"), + "name": lv.get("name"), + "description": lv.get("description"), + "sort_order": lv.get("sort_order"), + } + ) + return { + "id": full.get("id"), + "name": full.get("name"), + "description": full.get("description"), + "level_count": full.get("level_count"), + "status": full.get("status"), + "version": full.get("version"), + "levels": lv_out, + "model_skills": ms_out, + "skill_levels": sl_out, + } + + +def _delete_bindings_for_model(cur, model_id: int) -> None: + cur.execute( + "DELETE FROM maturity_model_context_bindings WHERE maturity_model_id = %s", + (model_id,), + ) + + +def _insert_binding_row(cur, model_id: int, fa: int, sd: Optional[int], tt: Optional[int]) -> None: + 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) + """, + (model_id, fa, sd, tt), + ) + + +def _apply_import_bindings(cur, model_id: int, binds: List[Dict[str, Any]]) -> None: + for b in binds: + fa = int(b["focus_area_id"]) + sd_raw = b.get("style_direction_id") + tt_raw = b.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 + cur.execute("SELECT id FROM focus_areas WHERE id = %s", (fa,)) + if not cur.fetchone(): + continue + if sd is not None: + cur.execute("SELECT id FROM style_directions WHERE id = %s", (sd,)) + if not cur.fetchone(): + continue + if tt is not None: + cur.execute("SELECT id FROM training_types WHERE id = %s", (tt,)) + if not cur.fetchone(): + continue + 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 sd is not None and 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), + ) + elif sd is None and tt is not None: + cur.execute( + """ + DELETE FROM maturity_model_context_bindings + WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id = %s + """, + (fa, tt), + ) + 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), + ) + _insert_binding_row(cur, model_id, fa, sd, tt) + + +def _apply_import_model_payload(cur, model_id: int, m: Dict[str, Any]) -> None: + cur.execute( + "DELETE FROM model_skill_levels WHERE maturity_model_id = %s", (model_id,) + ) + cur.execute("DELETE FROM model_skills WHERE maturity_model_id = %s", (model_id,)) + cur.execute("DELETE FROM model_levels WHERE maturity_model_id = %s", (model_id,)) + + lc = int(m.get("level_count") or 5) + if lc < 3 or lc > 10: + raise HTTPException(400, "level_count muss zwischen 3 und 10 liegen") + + cur.execute( + """ + UPDATE maturity_models SET + level_count = %s, + name = COALESCE(%s, name), + description = COALESCE(%s, description), + status = COALESCE(%s, status), + version = COALESCE(%s, version), + updated_at = NOW() + WHERE id = %s + """, + ( + lc, + m.get("name"), + m.get("description"), + m.get("status"), + m.get("version"), + model_id, + ), + ) + + levels = m.get("levels") or [] + if not levels: + _insert_default_levels(cur, model_id, lc) + else: + for lev in levels: + cur.execute( + """ + INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order) + VALUES (%s, %s, %s, %s, %s) + """, + ( + model_id, + int(lev["level_number"]), + lev.get("name") or f"Stufe {lev['level_number']}", + lev.get("description"), + int(lev.get("sort_order") or lev["level_number"]), + ), + ) + + for ms in m.get("model_skills") or []: + sid = int(ms["skill_id"]) + cur.execute("SELECT id FROM skills WHERE id = %s", (sid,)) + if not cur.fetchone(): + raise HTTPException(400, f"Unbekannte skill_id {sid} (Fähigkeit fehlt in dieser Datenbank)") + cur.execute( + """ + INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance) + VALUES (%s, %s, %s, %s) + """, + (model_id, sid, int(ms.get("sort_order") or 0), ms.get("relevance")), + ) + + for sl in m.get("skill_levels") or []: + sid = int(sl["skill_id"]) + ln = int(sl["level_number"]) + if ln < 1 or ln > lc: + continue + desc = (sl.get("description") or "").strip() + if not desc: + continue + cur.execute("SELECT id FROM skills WHERE id = %s", (sid,)) + if not cur.fetchone(): + raise HTTPException(400, f"skill_levels: unbekannte skill_id {sid}") + cur.execute( + """ + INSERT INTO model_skill_levels ( + maturity_model_id, skill_id, level_number, description, observable_criteria + ) VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (maturity_model_id, skill_id, level_number) + DO UPDATE SET + description = EXCLUDED.description, + observable_criteria = EXCLUDED.observable_criteria, + updated_at = NOW() + """, + (model_id, sid, ln, desc, sl.get("observable_criteria")), + ) + + +@router.get("/maturity-models/{model_id}/export") +def export_maturity_model_bundle(model_id: int, session: dict = Depends(require_auth)): + _require_admin(session) + with get_db() as conn: + cur = get_cursor(conn) + if not _base_maturity_model(cur, model_id): + raise HTTPException(404, "Reifegradmodell nicht gefunden") + full = _load_full_model(cur, model_id) + cur.execute( + """ + SELECT focus_area_id, style_direction_id, training_type_id + FROM maturity_model_context_bindings + WHERE maturity_model_id = %s + """, + (model_id,), + ) + binds = [r2d(r) for r in cur.fetchall()] + + bundle = { + "kind": "shinkan.maturity_model.v1", + "export_version": 1, + "exported_at": datetime.now(timezone.utc).isoformat(), + "model": _strip_model_for_export(full), + "context_bindings_for_model": binds, + } + fname = f"reifegradmodell-{model_id}.json" + return JSONResponse( + content=bundle, + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +@router.get("/maturity-models/export-resolved") +def export_resolved_maturity_bundle( + focus_area_id: int = Query(..., description="Fokusbereich (Pflicht)"), + style_direction_id: Optional[int] = Query(default=None), + training_type_id: Optional[int] = Query(default=None), + session: dict = Depends(require_auth), +): + """Exportiert die per Bindings aufgelöste Matrix (inkl. Merge) als JSON.""" + _require_admin(session) + 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] + merged = _merge_loaded_models(loaded) + else: + mid = _legacy_resolve_pick_model_id( + cur, focus_area_id, style_direction_id, None + ) + if mid is None: + raise HTTPException( + 404, + "Kein Modell für diesen Kontext (keine Bindings und kein Legacy-Treffer)", + ) + merged = _load_full_model(cur, mid) + + bundle = { + "kind": "shinkan.maturity_matrix_resolved.v1", + "export_version": 1, + "exported_at": datetime.now(timezone.utc).isoformat(), + "resolve_params": { + "focus_area_id": focus_area_id, + "style_direction_id": style_direction_id, + "training_type_id": training_type_id, + }, + "matrix": _strip_model_for_export(merged), + "resolution": merged.get("resolution"), + } + return JSONResponse( + content=bundle, + headers={ + "Content-Disposition": 'attachment; filename="faehigkeitsmatrix-aufgeloest.json"' + }, + ) + + +@router.post("/maturity-models/import") +def import_maturity_model_bundle(data: Dict[str, Any], session: dict = Depends(require_auth)): + _require_admin(session) + kind = data.get("kind") + if kind not in ("shinkan.maturity_model.v1", "shinkan.maturity_matrix_resolved.v1"): + raise HTTPException( + 400, + "kind muss shinkan.maturity_model.v1 oder shinkan.maturity_matrix_resolved.v1 sein", + ) + mode = (data.get("mode") or "create").strip().lower() + if mode not in ("create", "replace"): + raise HTTPException(400, "mode muss create oder replace sein") + + mpart = data.get("model") or data.get("matrix") or {} + name = (mpart.get("name") or "").strip() + if mode == "create" and not name: + raise HTTPException(400, "Modellname fehlt") + + import_bindings = bool(data.get("import_bindings", True)) + profile_id = session.get("profile_id") + replace_id = data.get("replace_model_id") + if mode == "replace": + if not replace_id: + raise HTTPException(400, "replace_model_id erforderlich bei mode=replace") + replace_id = int(replace_id) + + with get_db() as conn: + cur = get_cursor(conn) + if mode == "create": + lc = int(mpart.get("level_count") or 5) + if lc < 3 or lc > 10: + raise HTTPException(400, "level_count ungültig") + cur.execute( + """ + INSERT INTO maturity_models ( + name, description, level_count, status, version, created_by + ) VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + name, + mpart.get("description"), + lc, + mpart.get("status") or "draft", + mpart.get("version") or "1.0", + profile_id, + ), + ) + new_id = int(cur.fetchone()["id"]) + _apply_import_model_payload(cur, new_id, mpart) + if import_bindings and kind == "shinkan.maturity_model.v1": + binds = data.get("context_bindings_for_model") or [] + if binds: + _apply_import_bindings(cur, new_id, binds) + return {"ok": True, "id": new_id, "mode": "create"} + + if not _base_maturity_model(cur, replace_id): + raise HTTPException(404, "replace_model_id nicht gefunden") + _apply_import_model_payload(cur, replace_id, mpart) + if import_bindings and kind == "shinkan.maturity_model.v1": + binds = data.get("context_bindings_for_model") or [] + _delete_bindings_for_model(cur, replace_id) + if binds: + _apply_import_bindings(cur, replace_id, binds) + return {"ok": True, "id": replace_id, "mode": "replace"} diff --git a/backend/version.py b/backend/version.py index 7e5f498..f24274d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.7.3" +APP_VERSION = "0.7.4" BUILD_DATE = "2026-04-27" -DB_SCHEMA_VERSION = "20260427026" +DB_SCHEMA_VERSION = "20260427027" 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.2.0", # 026: hierarchische Kontext-Bindings + Merge in resolve + "maturity_models": "1.3.0", # 027: Fokus+Trainingsstil; Export/Import; resolve-Merge } CHANGELOG = [ + { + "version": "0.7.4", + "date": "2026-04-27", + "changes": [ + "DB 027: Bindings Fokus+Trainingsstil ohne Stilrichtung", + "resolve: alle passenden Bindings nach Spezifität mergen", + "API: Export/Import JSON (Modell + aufgelöste Matrix)", + ], + }, { "version": "0.7.3", "date": "2026-04-27", diff --git a/frontend/src/app.css b/frontend/src/app.css index 2ba6bad..9e7c2ee 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1152,6 +1152,146 @@ a.analysis-split__nav-item { border-bottom: none; } +.admin-matrix-tools__intro { + margin: 0 0 16px; + max-width: 56rem; + line-height: 1.55; +} +.admin-matrix-tools__msg { + margin: 0 0 12px; +} +.admin-matrix-tools__section { + margin-bottom: 20px; +} +.admin-matrix-tools__h2 { + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 14px; +} +.admin-matrix-tools__h3 { + font-size: 1rem; + font-weight: 600; + margin: 0 0 8px; +} +.admin-matrix-tools__hint { + font-size: 14px; + margin: 0 0 12px; + line-height: 1.45; +} +.admin-matrix-tools__filters { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} +.admin-matrix-tools__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 8px; +} +.admin-matrix-tools__meta { + font-size: 13px; + margin: 0; +} +.admin-matrix-tools__subtitle { + font-weight: 400; + font-size: 14px; +} +.admin-matrix-tools__io-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; +} +.admin-matrix-tools__btn-mt { + margin-top: 12px; +} +.admin-matrix-tools__check { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 12px 0; + cursor: pointer; +} +.admin-matrix-tools__check input { + margin-top: 3px; +} + +.admin-matrix-visual-group { + margin-bottom: 28px; +} +.admin-matrix-visual-group__main { + margin: 0 0 4px; + font-size: 1.05rem; + font-weight: 700; + color: var(--accent-dark); +} +@media (prefers-color-scheme: dark) { + .admin-matrix-visual-group__main { + color: var(--accent); + } +} +.admin-matrix-visual-group__sub { + margin: 0 0 12px; + font-size: 0.95rem; + font-weight: 600; + color: var(--text2); +} +.admin-matrix-visual-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border: 1px solid var(--border); + border-radius: 12px; +} +.admin-matrix-visual-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + min-width: 640px; +} +.admin-matrix-visual-table th, +.admin-matrix-visual-table td { + border: 1px solid var(--border); + padding: 10px 12px; + vertical-align: top; +} +.admin-matrix-visual-table thead th { + background: var(--surface2); + font-weight: 600; +} +.admin-matrix-visual-table__skill { + min-width: 160px; + max-width: 220px; +} +.admin-matrix-visual-table__level { + min-width: 140px; +} +.admin-matrix-visual-table__ln { + display: block; + font-size: 12px; + color: var(--text3); +} +.admin-matrix-visual-table__lname { + display: block; + font-size: 13px; +} +.admin-matrix-visual-table__skill-cell { + font-weight: 600; + background: var(--surface2); +} +.admin-matrix-visual-table__goal { + line-height: 1.45; +} +.admin-matrix-visual-table__obs { + margin-top: 8px; + font-size: 13px; + line-height: 1.4; +} +.admin-matrix-visual-table__obs strong { + color: var(--text2); + font-weight: 600; +} + .skills-catalog-admin__intro { margin: 0 0 16px; max-width: 56rem; diff --git a/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx b/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx new file mode 100644 index 0000000..69b8029 --- /dev/null +++ b/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx @@ -0,0 +1,383 @@ +import React, { useEffect, useMemo, useState } from 'react' +import api from '../../utils/api' + +function downloadJson(obj, filename) { + const blob = new Blob([JSON.stringify(obj, null, 2)], { + type: 'application/json;charset=utf-8' + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +function groupModelSkills(model) { + if (!model?.model_skills?.length) return [] + const groups = new Map() + for (const ms of model.model_skills) { + const main = ms.skill_main_category_name || '—' + const sub = ms.skill_subcategory_name || '—' + const k = `${main}\t${sub}` + if (!groups.has(k)) groups.set(k, { main, sub, skills: [] }) + groups.get(k).skills.push(ms) + } + return Array.from(groups.values()) +} + +function cellMapForSkill(model, skillId) { + const m = {} + for (const sl of model.skill_levels || []) { + if (Number(sl.skill_id) === Number(skillId)) { + m[sl.level_number] = sl + } + } + return m +} + +export default function MaturityMatrixToolsAdmin() { + const [focusAreas, setFocusAreas] = useState([]) + const [styles, setStyles] = useState([]) + const [trainingTypes, setTrainingTypes] = useState([]) + const [models, setModels] = useState([]) + const [focusId, setFocusId] = useState('') + const [styleId, setStyleId] = useState('') + const [typeId, setTypeId] = useState('') + const [matrix, setMatrix] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [message, setMessage] = useState('') + const [importMode, setImportMode] = useState('create') + const [replaceModelId, setReplaceModelId] = useState('') + const [exportModelId, setExportModelId] = useState('') + const [importBindings, setImportBindings] = useState(true) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const [fa, sd, tt, m] = await Promise.all([ + api.listFocusAreas({}), + api.listStyleDirections({}), + api.listTrainingTypes({}), + api.listMaturityModels({}) + ]) + if (!cancelled) { + setFocusAreas(fa) + setStyles(sd) + setTrainingTypes(tt) + setModels(m) + } + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } + })() + return () => { + cancelled = true + } + }, []) + + const groups = useMemo(() => (matrix ? groupModelSkills(matrix) : []), [matrix]) + const levels = matrix?.levels || [] + + async function handleResolve() { + if (!focusId) { + setError('Fokusbereich wählen.') + return + } + setLoading(true) + setError('') + setMessage('') + setMatrix(null) + try { + const params = { focus_area_id: focusId } + if (styleId) params.style_direction_id = styleId + if (typeId) params.training_type_id = typeId + const m = await api.resolveMaturityModel(params) + setMatrix(m) + if (!m) setError('Keine Matrix für diesen Kontext.') + else setMessage('Matrix geladen.') + } catch (e) { + setError(e.message || String(e)) + } finally { + setLoading(false) + } + } + + async function handleExportResolved() { + if (!focusId) { + setError('Fokusbereich wählen.') + return + } + setError('') + setMessage('') + try { + const params = { focus_area_id: focusId } + if (styleId) params.style_direction_id = styleId + if (typeId) params.training_type_id = typeId + const bundle = await api.exportResolvedMaturityBundle(params) + downloadJson(bundle, 'faehigkeitsmatrix-aufgeloest.json') + setMessage('Export gestartet (Download).') + } catch (e) { + setError(e.message || String(e)) + } + } + + async function handleExportStored() { + if (!exportModelId) { + setError('Modell für Export auswählen.') + return + } + setError('') + setMessage('') + try { + const bundle = await api.exportMaturityModelBundle(parseInt(exportModelId, 10)) + downloadJson(bundle, `reifegradmodell-${exportModelId}.json`) + setMessage('Export gestartet (Download).') + } catch (e) { + setError(e.message || String(e)) + } + } + + async function handleImportFile(e) { + const file = e.target.files?.[0] + if (!file) return + setError('') + setMessage('') + try { + const data = JSON.parse(await file.text()) + const payload = { ...data, mode: importMode, import_bindings: importBindings } + if (importMode === 'replace') { + if (!replaceModelId) { + setError('Bei „Ersetzen“ die Ziel-Modell-ID angeben.') + e.target.value = '' + return + } + payload.replace_model_id = parseInt(replaceModelId, 10) + } + const res = await api.importMaturityModelBundle(payload) + setMessage(`Import erfolgreich. Modell-ID: ${res.id}`) + setModels(await api.listMaturityModels({})) + } catch (err) { + setError(err.message || String(err)) + } finally { + e.target.value = '' + } + } + + return ( +
+

+ Matrix nach Kontext auflösen, hierarchisch nach Hauptkategorie und Kategorie darstellen, sowie JSON + exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, oder aufgelöste Matrix). +

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

{message}

: null} + +
+

Kontext und Anzeige

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ {matrix?.resolution ? ( +

+ Quelle:{' '} + {matrix.resolution.merged + ? `zusammengeführt aus Modell-IDs ${(matrix.resolution.source_model_ids || []).join(', ')}` + : `Modell-ID ${matrix.id}`} +

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

+ {matrix.name} + + {' '} + · {matrix.level_count} Stufen · {matrix.model_skills?.length || 0} Fähigkeiten + +

+ {groups.map((g) => ( +
+

{g.main}

+

{g.sub}

+
+ + + + + {levels.map((lv) => ( + + ))} + + + + {g.skills.map((ms) => { + const cells = cellMapForSkill(matrix, ms.skill_id) + return ( + + + {levels.map((lv) => { + const c = cells[lv.level_number] + return ( + + ) + })} + + ) + })} + +
Fähigkeit + {lv.level_number} + {lv.name} +
{ms.skill_name} + {c ? ( + <> +
+ {c.description || '—'} +
+ {c.observable_criteria ? ( +
+ Beobachtung: {c.observable_criteria} +
+ ) : null} + + ) : ( + + )} +
+
+
+ ))} +
+ ) : null} + +
+

Export / Import (JSON)

+
+
+

Gespeichertes Modell exportieren

+

+ Enthält Stufen, Fähigkeiten, Zelltexte und die dem Modell zugeordneten Kontext-Bindings. +

+ + + +
+
+

Import

+

+ Datei shinkan.maturity_model.v1 oder{' '} + shinkan.maturity_matrix_resolved.v1. Aufgelöste + Matrizen legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings). +

+ + + {importMode === 'replace' ? ( + <> + + setReplaceModelId(e.target.value)} + placeholder="z. B. 3" + /> + + ) : null} + + + +
+
+
+
+ ) +} diff --git a/frontend/src/components/admin/MaturityModelBindingsAdmin.jsx b/frontend/src/components/admin/MaturityModelBindingsAdmin.jsx index 3dc5d68..f3bad93 100644 --- a/frontend/src/components/admin/MaturityModelBindingsAdmin.jsx +++ b/frontend/src/components/admin/MaturityModelBindingsAdmin.jsx @@ -4,12 +4,16 @@ import api from '../../utils/api' const TIER_LABEL = { focus: 'Nur Fokusbereich', focus_style: 'Fokus + Stilrichtung', + focus_training: 'Fokus + Trainingsstil', 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' + const hs = row.style_direction_id != null + const ht = row.training_type_id != null + if (!hs && !ht) return 'focus' + if (hs && !ht) return 'focus_style' + if (!hs && ht) return 'focus_training' return 'focus_style_type' } @@ -67,6 +71,8 @@ export default function MaturityModelBindingsAdmin() { setFormTypeId('') } else if (tier === 'focus_style') { setFormTypeId('') + } else if (tier === 'focus_training') { + setFormStyleId('') } }, [tier]) @@ -87,7 +93,7 @@ export default function MaturityModelBindingsAdmin() { } payload.style_direction_id = parseInt(formStyleId, 10) } - if (tier === 'focus_style_type') { + if (tier === 'focus_training' || tier === 'focus_style_type') { if (!formTypeId) { setError('Trainingsstil auswählen.') return @@ -125,16 +131,12 @@ export default function MaturityModelBindingsAdmin() { 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. + Zusammenführung: Zu einem Fokus können mehrere Zeilen existieren (nur Fokus, Fokus + + Stilrichtung, Fokus + Trainingsstil ohne Stil, oder alle drei). Beim Aufruf von{' '} + /maturity-models/resolve werden alle zur Anfrage passenden + Zeilen ermittelt, nach Spezifität sortiert (weniger zuerst) und zu einer Matrix verbunden: spezifischere + Zuordnungen überschreiben Zelltexte gleicher Fähigkeit und Stufe. Stufen (Spalten) stammen vom{' '} + ersten (am wenigsten spezifischen) Modell in dieser Kette.

{error ? ( @@ -156,6 +158,7 @@ export default function MaturityModelBindingsAdmin() { > +
@@ -195,9 +198,9 @@ export default function MaturityModelBindingsAdmin() { )} - {tier === 'focus_style_type' && ( + {(tier === 'focus_training' || tier === 'focus_style_type') && (
- +