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.
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)
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"}

View File

@ -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",

View File

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

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 = {
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 (
<div className="admin-bindings">
<p className="admin-bindings__intro muted">
<strong>Vererbung:</strong> 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{' '}
<strong>dieselben Fähigkeiten</strong> 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 <strong>Basis-Modell</strong> (Fokus-Ebene).
</p>
<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.
<strong>Zusammenführung:</strong> Zu einem Fokus können mehrere Zeilen existieren (nur Fokus, Fokus +
Stilrichtung, Fokus + Trainingsstil ohne Stil, oder alle drei). Beim Aufruf von{' '}
<code className="admin-bindings__code">/maturity-models/resolve</code> 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{' '}
<strong>ersten</strong> (am wenigsten spezifischen) Modell in dieser Kette.
</p>
{error ? (
@ -156,6 +158,7 @@ export default function MaturityModelBindingsAdmin() {
>
<option value="focus">{TIER_LABEL.focus}</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>
</select>
</div>
@ -195,9 +198,9 @@ export default function MaturityModelBindingsAdmin() {
</select>
</div>
)}
{tier === 'focus_style_type' && (
{(tier === 'focus_training' || tier === 'focus_style_type') && (
<div>
<label className="form-label">Trainingsstil (z. B. Leistungssport)</label>
<label className="form-label">Trainingsstil (z. B. Breitensport)</label>
<select
className="form-input"
value={formTypeId}
@ -255,8 +258,9 @@ export default function MaturityModelBindingsAdmin() {
{bindings.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
Noch keine Einträge. Legen Sie mindestens eine Fokus-Basis-Zeile an, damit{' '}
<code>/maturity-models/resolve</code> die neue Logik nutzt.
Noch keine Einträge. Sobald hier Zeilen existieren und ein passender Kontext an{' '}
<code>/maturity-models/resolve</code> übergeben wird, werden diese Zuordnungen statt der
reinen Legacy-Suche verwendet.
</td>
</tr>
) : (

View File

@ -5,6 +5,7 @@ import AdminPageNav from '../components/AdminPageNav'
import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin'
import MaturityMatrixToolsAdmin from '../components/admin/MaturityMatrixToolsAdmin'
export default function AdminMaturityModelsPage() {
const { user } = useAuth()
@ -54,6 +55,15 @@ export default function AdminMaturityModelsPage() {
>
Kontext-Zuordnung
</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 className="admin-tabs__panel" role="tabpanel">
@ -61,6 +71,8 @@ export default function AdminMaturityModelsPage() {
<SkillsCatalogAdmin />
) : tab === 'bindings' ? (
<MaturityModelBindingsAdmin />
) : tab === 'matrixviz' ? (
<MaturityMatrixToolsAdmin />
) : (
<MaturityModelsAdminPanel />
)}

View File

@ -342,6 +342,24 @@ export async function deleteMaturityModelContextBinding(id) {
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)
export async function listStyleDirections(filters = {}) {
const query = new URLSearchParams(filters).toString()
@ -699,6 +717,9 @@ export const api = {
listMaturityModelContextBindings,
upsertMaturityModelContextBinding,
deleteMaturityModelContextBinding,
importMaturityModelBundle,
exportMaturityModelBundle,
exportResolvedMaturityBundle,
resolveMaturityModel,
getMaturityModel,
createMaturityModel,