feat: update version to 0.7.4 and enhance maturity model functionality
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m59s

- Incremented application version to 0.7.4 and updated database schema version to 20260427027.
- Enhanced maturity model context bindings to support new filtering options for training styles.
- Introduced new API endpoints for importing and exporting maturity model bundles.
- Updated frontend components to include a matrix view and improved admin UI for managing maturity models.
- Documented changes in the changelog for version 0.7.4, detailing new features and improvements.
This commit is contained in:
Lars 2026-04-27 12:48:22 +02:00
parent 3397b2094d
commit f2c007cc68
8 changed files with 1043 additions and 71 deletions

View File

@ -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;

View File

@ -6,9 +6,11 @@ Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt übe
Lesen: alle authentifizierten Nutzer. Lesen: alle authentifizierten Nutzer.
Schreiben: admin, superadmin. Schreiben: admin, superadmin.
""" """
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from auth import require_auth from auth import require_auth
from db import get_db, get_cursor, r2d 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") 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( def _resolve_binding_model_ids(
cur, cur,
focus_area_id: int, focus_area_id: int,
@ -259,51 +286,44 @@ def _resolve_binding_model_ids(
training_type_id: Optional[int], training_type_id: Optional[int],
) -> List[int]: ) -> List[int]:
""" """
Kette Basis Stilrichtung Trainingsstil (training_types). Alle Bindings zum Fokus, die zur Abfrage passen (inkl. Fokus+Trainingsstil ohne Stilrichtung),
Ohne Basis-Zeile (nur Fokus): leere Liste Aufrufer nutzt Legacy-Resolve. sortiert nach Spezifität (weniger spezifisch zuerst). Gleiche model_id nur einmal.
Nur **aktive** Modelle werden eingereiht; inaktive Overlays werden übersprungen. Nur aktive Modelle. Leere Liste Legacy-Resolve.
""" """
chain: List[int] = []
cur.execute( cur.execute(
""" """
SELECT maturity_model_id FROM maturity_model_context_bindings SELECT id, maturity_model_id, style_direction_id, training_type_id
WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id IS NULL FROM maturity_model_context_bindings
WHERE focus_area_id = %s
""", """,
(focus_area_id,), (focus_area_id,),
) )
r = cur.fetchone() rows = [r2d(r) for r in cur.fetchall()]
if not r: 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 [] return []
mid0 = int(r["maturity_model_id"]) matching.sort(
if not _binding_model_active(cur, mid0): key=lambda r: (
return [] _binding_dim_count(r.get("style_direction_id"), r.get("training_type_id")),
chain.append(mid0) int(r["id"]),
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"])): out: List[int] = []
chain.append(int(r2["maturity_model_id"])) for r in matching:
mid = int(r["maturity_model_id"])
if style_direction_id is not None and training_type_id is not None: if not _binding_model_active(cur, mid):
cur.execute( continue
""" if mid not in out:
SELECT maturity_model_id FROM maturity_model_context_bindings out.append(mid)
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id = %s return out
""",
(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]: 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": { "resolution": {
"merged": len(loaded) > 1, "merged": len(loaded) > 1,
"source_model_ids": [int(m["id"]) for m in loaded], "source_model_ids": [int(m["id"]) for m in loaded],
"binding_strategy": "hierarchical_override", "binding_strategy": "specificity_merge",
}, },
} }
return out return out
@ -417,21 +437,22 @@ def resolve_maturity_model(
target_group_id: Optional[int] = Query(default=None), target_group_id: Optional[int] = Query(default=None),
training_type_id: Optional[int] = Query( training_type_id: Optional[int] = Query(
default=None, 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), session: dict = Depends(require_auth),
): ):
""" """
Liefert die Fähigkeitsmatrix zum Kontext. Liefert die Fähigkeitsmatrix zum Kontext.
**Priorität 1:** Tabelle `maturity_model_context_bindings` (Fokus optional Stilrichtung optional Trainingsstil). **Priorität 1:** `maturity_model_context_bindings`: alle passenden Zeilen zum Fokus (z. B. nur Fokus,
Mehrere Modelle werden zusammengeführt: spätere Stufen überschreiben Zelltexte für dieselbe Fähigkeit/Stufe. 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:
with get_db() as conn: cur = get_cursor(conn)
cur = get_cursor(conn) if focus_area_id is not None:
chain = _resolve_binding_model_ids( chain = _resolve_binding_model_ids(
cur, cur,
int(focus_area_id), int(focus_area_id),
@ -442,8 +463,6 @@ def resolve_maturity_model(
loaded = [_load_full_model(cur, mid) for mid in chain] loaded = [_load_full_model(cur, mid) for mid in chain]
return _merge_loaded_models(loaded) return _merge_loaded_models(loaded)
with get_db() as conn:
cur = get_cursor(conn)
mid = _legacy_resolve_pick_model_id( mid = _legacy_resolve_pick_model_id(
cur, focus_area_id, style_direction_id, target_group_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 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 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if not _base_maturity_model(cur, mid): if not _base_maturity_model(cur, mid):
@ -835,7 +851,7 @@ def upsert_maturity_model_context_binding(data: Dict[str, Any], session: dict =
""", """,
(fa,), (fa,),
) )
elif tt is None: elif sd is not None and tt is None:
cur.execute( cur.execute(
""" """
DELETE FROM maturity_model_context_bindings DELETE FROM maturity_model_context_bindings
@ -843,6 +859,14 @@ def upsert_maturity_model_context_binding(data: Dict[str, Any], session: dict =
""", """,
(fa, sd), (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: else:
cur.execute( cur.execute(
""" """
@ -897,3 +921,365 @@ def delete_maturity_model_context_binding(binding_id: int, session: dict = Depen
if not cur.fetchone(): if not cur.fetchone():
raise HTTPException(404, "Zuordnung nicht gefunden") raise HTTPException(404, "Zuordnung nicht gefunden")
return {"ok": True} 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"}

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.7.3" APP_VERSION = "0.7.4"
BUILD_DATE = "2026-04-27" BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260427026" DB_SCHEMA_VERSION = "20260427027"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.0.0", "auth": "1.0.0",
@ -19,10 +19,19 @@ MODULE_VERSIONS = {
"admin": "1.0.0", "admin": "1.0.0",
"membership": "1.0.0", "membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) "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 = [ 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", "version": "0.7.3",
"date": "2026-04-27", "date": "2026-04-27",

View File

@ -1152,6 +1152,146 @@ a.analysis-split__nav-item {
border-bottom: none; 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 { .skills-catalog-admin__intro {
margin: 0 0 16px; margin: 0 0 16px;
max-width: 56rem; max-width: 56rem;

View File

@ -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 (
<div className="admin-matrix-tools">
<p className="admin-matrix-tools__intro muted">
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).
</p>
{error ? (
<div className="skills-catalog-admin__error" role="alert">
{error}
</div>
) : null}
{message ? <p className="muted admin-matrix-tools__msg">{message}</p> : null}
<section className="card admin-matrix-tools__section">
<h2 className="admin-matrix-tools__h2">Kontext und Anzeige</h2>
<div className="admin-matrix-tools__filters">
<div>
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={focusId}
onChange={(e) => setFocusId(e.target.value)}
>
<option value=""> wählen </option>
{focusAreas.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
</div>
<div>
<label className="form-label">Stilrichtung (optional)</label>
<select
className="form-input"
value={styleId}
onChange={(e) => setStyleId(e.target.value)}
>
<option value=""> alle / egal </option>
{styles.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div>
<label className="form-label">Trainingsstil (optional)</label>
<select className="form-input" value={typeId} onChange={(e) => setTypeId(e.target.value)}>
<option value=""> alle / egal </option>
{trainingTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
</div>
<div className="admin-matrix-tools__actions">
<button type="button" className="btn btn-primary" disabled={loading} onClick={handleResolve}>
{loading ? 'Lade…' : 'Matrix auflösen & anzeigen'}
</button>
<button type="button" className="btn btn-secondary" onClick={handleExportResolved}>
Aufgelöste Matrix exportieren (JSON)
</button>
</div>
{matrix?.resolution ? (
<p className="muted admin-matrix-tools__meta">
Quelle:{' '}
{matrix.resolution.merged
? `zusammengeführt aus Modell-IDs ${(matrix.resolution.source_model_ids || []).join(', ')}`
: `Modell-ID ${matrix.id}`}
</p>
) : null}
</section>
{matrix ? (
<section className="card admin-matrix-tools__section admin-matrix-tools__visual">
<h2 className="admin-matrix-tools__h2">
{matrix.name}
<span className="muted admin-matrix-tools__subtitle">
{' '}
· {matrix.level_count} Stufen · {matrix.model_skills?.length || 0} Fähigkeiten
</span>
</h2>
{groups.map((g) => (
<div key={`${g.main}-${g.sub}`} className="admin-matrix-visual-group">
<h3 className="admin-matrix-visual-group__main">{g.main}</h3>
<h4 className="admin-matrix-visual-group__sub">{g.sub}</h4>
<div className="admin-matrix-visual-table-wrap">
<table className="admin-matrix-visual-table">
<thead>
<tr>
<th className="admin-matrix-visual-table__skill">Fähigkeit</th>
{levels.map((lv) => (
<th key={lv.level_number} className="admin-matrix-visual-table__level">
<span className="admin-matrix-visual-table__ln">{lv.level_number}</span>
<span className="admin-matrix-visual-table__lname">{lv.name}</span>
</th>
))}
</tr>
</thead>
<tbody>
{g.skills.map((ms) => {
const cells = cellMapForSkill(matrix, ms.skill_id)
return (
<tr key={ms.skill_id}>
<td className="admin-matrix-visual-table__skill-cell">{ms.skill_name}</td>
{levels.map((lv) => {
const c = cells[lv.level_number]
return (
<td key={lv.level_number} className="admin-matrix-visual-table__cell">
{c ? (
<>
<div className="admin-matrix-visual-table__goal">
{c.description || '—'}
</div>
{c.observable_criteria ? (
<div className="admin-matrix-visual-table__obs muted">
<strong>Beobachtung:</strong> {c.observable_criteria}
</div>
) : null}
</>
) : (
<span className="muted"></span>
)}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
))}
</section>
) : null}
<section className="card admin-matrix-tools__section">
<h2 className="admin-matrix-tools__h2">Export / Import (JSON)</h2>
<div className="admin-matrix-tools__io-grid">
<div>
<h3 className="admin-matrix-tools__h3">Gespeichertes Modell exportieren</h3>
<p className="muted admin-matrix-tools__hint">
Enthält Stufen, Fähigkeiten, Zelltexte und die dem Modell zugeordneten Kontext-Bindings.
</p>
<label className="form-label">Modell</label>
<select
className="form-input"
value={exportModelId}
onChange={(e) => setExportModelId(e.target.value)}
>
<option value=""> wählen </option>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.status})
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary admin-matrix-tools__btn-mt"
onClick={handleExportStored}
>
JSON herunterladen
</button>
</div>
<div>
<h3 className="admin-matrix-tools__h3">Import</h3>
<p className="muted admin-matrix-tools__hint">
Datei <code className="admin-bindings__code">shinkan.maturity_model.v1</code> oder{' '}
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Aufgelöste
Matrizen legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings).
</p>
<label className="form-label">Modus</label>
<select
className="form-input"
value={importMode}
onChange={(e) => setImportMode(e.target.value)}
>
<option value="create">Neues Modell anlegen</option>
<option value="replace">Bestehendes Modell ersetzen (Matrix-Inhalt)</option>
</select>
{importMode === 'replace' ? (
<>
<label className="form-label">Ziel-Modell-ID</label>
<input
className="form-input"
type="number"
inputMode="numeric"
value={replaceModelId}
onChange={(e) => setReplaceModelId(e.target.value)}
placeholder="z. B. 3"
/>
</>
) : null}
<label className="form-label admin-matrix-tools__check">
<input
type="checkbox"
checked={importBindings}
onChange={(e) => setImportBindings(e.target.checked)}
/>{' '}
Kontext-Bindings mit importieren (nur bei <code className="admin-bindings__code">.maturity_model.v1</code>)
</label>
<label className="form-label">JSON-Datei</label>
<input type="file" accept="application/json,.json" onChange={handleImportFile} />
</div>
</div>
</section>
</div>
)
}

View File

@ -4,12 +4,16 @@ import api from '../../utils/api'
const TIER_LABEL = { const TIER_LABEL = {
focus: 'Nur Fokusbereich', focus: 'Nur Fokusbereich',
focus_style: 'Fokus + Stilrichtung', focus_style: 'Fokus + Stilrichtung',
focus_training: 'Fokus + Trainingsstil',
focus_style_type: 'Fokus + Stilrichtung + Trainingsstil' focus_style_type: 'Fokus + Stilrichtung + Trainingsstil'
} }
function tierFromRow(row) { function tierFromRow(row) {
if (row.style_direction_id == null && row.training_type_id == null) return 'focus' const hs = row.style_direction_id != null
if (row.training_type_id == null) return 'focus_style' 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' return 'focus_style_type'
} }
@ -67,6 +71,8 @@ export default function MaturityModelBindingsAdmin() {
setFormTypeId('') setFormTypeId('')
} else if (tier === 'focus_style') { } else if (tier === 'focus_style') {
setFormTypeId('') setFormTypeId('')
} else if (tier === 'focus_training') {
setFormStyleId('')
} }
}, [tier]) }, [tier])
@ -87,7 +93,7 @@ export default function MaturityModelBindingsAdmin() {
} }
payload.style_direction_id = parseInt(formStyleId, 10) payload.style_direction_id = parseInt(formStyleId, 10)
} }
if (tier === 'focus_style_type') { if (tier === 'focus_training' || tier === 'focus_style_type') {
if (!formTypeId) { if (!formTypeId) {
setError('Trainingsstil auswählen.') setError('Trainingsstil auswählen.')
return return
@ -125,16 +131,12 @@ export default function MaturityModelBindingsAdmin() {
return ( return (
<div className="admin-bindings"> <div className="admin-bindings">
<p className="admin-bindings__intro muted"> <p className="admin-bindings__intro muted">
<strong>Vererbung:</strong> Ein Modell auf Ebene Fokusbereich gilt als Basis für diesen Fokus. Gibt es <strong>Zusammenführung:</strong> Zu einem Fokus können mehrere Zeilen existieren (nur Fokus, Fokus +
eine spezifischere Zeile (Fokus + Stilrichtung oder + Trainingsstil), werden bei der Auflösung der Matrix{' '} Stilrichtung, Fokus + Trainingsstil ohne Stil, oder alle drei). Beim Aufruf von{' '}
<strong>dieselben Fähigkeiten</strong> mit den Texten aus dem spezifischeren Modell überschrieben. <code className="admin-bindings__code">/maturity-models/resolve</code> werden alle zur Anfrage passenden
Zusätzliche Fähigkeiten aus einem Overlay-Modell erscheinen als weitere Zeilen. Stufen (Spalten) kommen Zeilen ermittelt, nach Spezifität sortiert (weniger zuerst) und zu einer Matrix verbunden: spezifischere
immer vom <strong>Basis-Modell</strong> (Fokus-Ebene). Zuordnungen überschreiben Zelltexte gleicher Fähigkeit und Stufe. Stufen (Spalten) stammen vom{' '}
</p> <strong>ersten</strong> (am wenigsten spezifischen) Modell in dieser Kette.
<p className="admin-bindings__intro muted">
API <code className="admin-bindings__code">GET /api/maturity-models/resolve</code> berücksichtigt{' '}
<code className="admin-bindings__code">training_type_id</code> (Trainingsstil ={' '}
<em>training_types</em>, z. B. Leistungssport) zusätzlich zu Fokus und Stilrichtung.
</p> </p>
{error ? ( {error ? (
@ -156,6 +158,7 @@ export default function MaturityModelBindingsAdmin() {
> >
<option value="focus">{TIER_LABEL.focus}</option> <option value="focus">{TIER_LABEL.focus}</option>
<option value="focus_style">{TIER_LABEL.focus_style}</option> <option value="focus_style">{TIER_LABEL.focus_style}</option>
<option value="focus_training">{TIER_LABEL.focus_training}</option>
<option value="focus_style_type">{TIER_LABEL.focus_style_type}</option> <option value="focus_style_type">{TIER_LABEL.focus_style_type}</option>
</select> </select>
</div> </div>
@ -195,9 +198,9 @@ export default function MaturityModelBindingsAdmin() {
</select> </select>
</div> </div>
)} )}
{tier === 'focus_style_type' && ( {(tier === 'focus_training' || tier === 'focus_style_type') && (
<div> <div>
<label className="form-label">Trainingsstil (z. B. Leistungssport)</label> <label className="form-label">Trainingsstil (z. B. Breitensport)</label>
<select <select
className="form-input" className="form-input"
value={formTypeId} value={formTypeId}
@ -255,8 +258,9 @@ export default function MaturityModelBindingsAdmin() {
{bindings.length === 0 ? ( {bindings.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="muted"> <td colSpan={6} className="muted">
Noch keine Einträge. Legen Sie mindestens eine Fokus-Basis-Zeile an, damit{' '} Noch keine Einträge. Sobald hier Zeilen existieren und ein passender Kontext an{' '}
<code>/maturity-models/resolve</code> die neue Logik nutzt. <code>/maturity-models/resolve</code> übergeben wird, werden diese Zuordnungen statt der
reinen Legacy-Suche verwendet.
</td> </td>
</tr> </tr>
) : ( ) : (

View File

@ -5,6 +5,7 @@ import AdminPageNav from '../components/AdminPageNav'
import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin' import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel' import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin' import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin'
import MaturityMatrixToolsAdmin from '../components/admin/MaturityMatrixToolsAdmin'
export default function AdminMaturityModelsPage() { export default function AdminMaturityModelsPage() {
const { user } = useAuth() const { user } = useAuth()
@ -54,6 +55,15 @@ export default function AdminMaturityModelsPage() {
> >
Kontext-Zuordnung Kontext-Zuordnung
</button> </button>
<button
type="button"
role="tab"
aria-selected={tab === 'matrixviz'}
className={'admin-tabs__tab' + (tab === 'matrixviz' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('matrixviz')}
>
Matrix-Ansicht und Export
</button>
</div> </div>
<div className="admin-tabs__panel" role="tabpanel"> <div className="admin-tabs__panel" role="tabpanel">
@ -61,6 +71,8 @@ export default function AdminMaturityModelsPage() {
<SkillsCatalogAdmin /> <SkillsCatalogAdmin />
) : tab === 'bindings' ? ( ) : tab === 'bindings' ? (
<MaturityModelBindingsAdmin /> <MaturityModelBindingsAdmin />
) : tab === 'matrixviz' ? (
<MaturityMatrixToolsAdmin />
) : ( ) : (
<MaturityModelsAdminPanel /> <MaturityModelsAdminPanel />
)} )}

View File

@ -342,6 +342,24 @@ export async function deleteMaturityModelContextBinding(id) {
return request(`/api/maturity-model-context-bindings/${id}`, { method: 'DELETE' }) return request(`/api/maturity-model-context-bindings/${id}`, { method: 'DELETE' })
} }
export async function importMaturityModelBundle(payload) {
return request('/api/maturity-models/import', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export async function exportMaturityModelBundle(modelId) {
return request(`/api/maturity-models/${modelId}/export`)
}
export async function exportResolvedMaturityBundle(filters = {}) {
const query = new URLSearchParams(
Object.entries(filters).filter(([, v]) => v !== undefined && v !== null && v !== '')
).toString()
return request(`/api/maturity-models/export-resolved${query ? '?' + query : ''}`)
}
// Style Directions (formerly Training Styles) // Style Directions (formerly Training Styles)
export async function listStyleDirections(filters = {}) { export async function listStyleDirections(filters = {}) {
const query = new URLSearchParams(filters).toString() const query = new URLSearchParams(filters).toString()
@ -699,6 +717,9 @@ export const api = {
listMaturityModelContextBindings, listMaturityModelContextBindings,
upsertMaturityModelContextBinding, upsertMaturityModelContextBinding,
deleteMaturityModelContextBinding, deleteMaturityModelContextBinding,
importMaturityModelBundle,
exportMaturityModelBundle,
exportResolvedMaturityBundle,
resolveMaturityModel, resolveMaturityModel,
getMaturityModel, getMaturityModel,
createMaturityModel, createMaturityModel,