feat: update version to 0.7.3 and enhance maturity model context bindings
- Incremented application version to 0.7.3 and updated database schema version to 20260427026. - Enhanced maturity model context bindings with new hierarchical resolution logic for focus areas, style directions, and training types. - Added new API endpoints for managing maturity model context bindings. - Updated frontend components to support the new context binding functionality and improved admin UI for better user experience. - Documented changes in the changelog for version 0.7.3, including new features and improvements.
This commit is contained in:
parent
469ec93074
commit
3397b2094d
35
backend/migrations/026_maturity_model_context_bindings.sql
Normal file
35
backend/migrations/026_maturity_model_context_bindings.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- Migration 026: Hierarchische Kontext-Zuordnung Reifegradmodell → Fokus / Stilrichtung / Trainingsstil
|
||||||
|
-- Vererbung: Modell auf Fokus-Ebene gilt als Basis; spezifischere Zeilen überschreiben Zelltexte
|
||||||
|
-- für dieselbe Fähigkeit (skill_id) beim Zusammenführen (resolve).
|
||||||
|
-- Datum: 2026-04-27
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS maturity_model_context_bindings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
|
||||||
|
focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE CASCADE,
|
||||||
|
style_direction_id INT REFERENCES style_directions(id) ON DELETE CASCADE,
|
||||||
|
training_type_id INT REFERENCES training_types(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_mcb_tier CHECK (
|
||||||
|
(style_direction_id IS NULL AND training_type_id IS NULL)
|
||||||
|
OR (style_direction_id IS NOT NULL AND training_type_id IS NULL)
|
||||||
|
OR (style_direction_id IS NOT NULL AND training_type_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mmcb_model ON maturity_model_context_bindings(maturity_model_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mmcb_focus ON maturity_model_context_bindings(focus_area_id);
|
||||||
|
|
||||||
|
-- Pro Ebene höchstens eine Zuordnung je Kontext
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_mmcb_focus_only
|
||||||
|
ON maturity_model_context_bindings (focus_area_id)
|
||||||
|
WHERE style_direction_id IS NULL AND training_type_id IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_mmcb_focus_style
|
||||||
|
ON maturity_model_context_bindings (focus_area_id, style_direction_id)
|
||||||
|
WHERE style_direction_id IS NOT NULL AND training_type_id IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_mmcb_focus_style_ttype
|
||||||
|
ON maturity_model_context_bindings (focus_area_id, style_direction_id, training_type_id)
|
||||||
|
WHERE training_type_id IS NOT NULL;
|
||||||
|
|
@ -247,25 +247,142 @@ def _dim_score(items: List[Dict[str, Any]], query_id: Optional[int]) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maturity-models/resolve")
|
def _binding_model_active(cur, maturity_model_id: int) -> bool:
|
||||||
def resolve_maturity_model(
|
b = _base_maturity_model(cur, maturity_model_id)
|
||||||
focus_area_id: Optional[int] = Query(default=None),
|
return bool(b and b.get("status") == "active")
|
||||||
style_direction_id: Optional[int] = Query(default=None),
|
|
||||||
target_group_id: Optional[int] = Query(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
def _resolve_binding_model_ids(
|
||||||
):
|
cur,
|
||||||
|
focus_area_id: int,
|
||||||
|
style_direction_id: Optional[int],
|
||||||
|
training_type_id: Optional[int],
|
||||||
|
) -> List[int]:
|
||||||
"""
|
"""
|
||||||
Wählt das spezifischste aktive Modell, das zum Kontext passt.
|
Kette Basis → Stilrichtung → Trainingsstil (training_types).
|
||||||
Leere M:N-Zuordnung = Wildcard für diese Dimension.
|
Ohne Basis-Zeile (nur Fokus): leere Liste → Aufrufer nutzt Legacy-Resolve.
|
||||||
|
Nur **aktive** Modelle werden eingereiht; inaktive Overlays werden übersprungen.
|
||||||
"""
|
"""
|
||||||
with get_db() as conn:
|
chain: List[int] = []
|
||||||
cur = get_cursor(conn)
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT maturity_model_id FROM maturity_model_context_bindings
|
||||||
|
WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id IS NULL
|
||||||
|
""",
|
||||||
|
(focus_area_id,),
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if not r:
|
||||||
|
return []
|
||||||
|
mid0 = int(r["maturity_model_id"])
|
||||||
|
if not _binding_model_active(cur, mid0):
|
||||||
|
return []
|
||||||
|
chain.append(mid0)
|
||||||
|
|
||||||
|
if style_direction_id is not None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT maturity_model_id FROM maturity_model_context_bindings
|
||||||
|
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id IS NULL
|
||||||
|
""",
|
||||||
|
(focus_area_id, style_direction_id),
|
||||||
|
)
|
||||||
|
r2 = cur.fetchone()
|
||||||
|
if r2 and _binding_model_active(cur, int(r2["maturity_model_id"])):
|
||||||
|
chain.append(int(r2["maturity_model_id"]))
|
||||||
|
|
||||||
|
if style_direction_id is not None and training_type_id is not None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT maturity_model_id FROM maturity_model_context_bindings
|
||||||
|
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id = %s
|
||||||
|
""",
|
||||||
|
(focus_area_id, style_direction_id, training_type_id),
|
||||||
|
)
|
||||||
|
r3 = cur.fetchone()
|
||||||
|
if r3 and _binding_model_active(cur, int(r3["maturity_model_id"])):
|
||||||
|
chain.append(int(r3["maturity_model_id"]))
|
||||||
|
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_loaded_models(loaded: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Überlagert Zelltexte in Reihenfolge der Kette; Zeilen kommen aus Basis + fehlende Fähigkeiten aus Overlays."""
|
||||||
|
if not loaded:
|
||||||
|
raise ValueError("merge: keine Modelle")
|
||||||
|
base = loaded[0]
|
||||||
|
base_lc = int(base.get("level_count") or 5)
|
||||||
|
base_levels = base.get("levels") or []
|
||||||
|
|
||||||
|
skill_rows: Dict[int, Dict[str, Any]] = {}
|
||||||
|
order: List[int] = []
|
||||||
|
for ms in base.get("model_skills") or []:
|
||||||
|
sid = int(ms["skill_id"])
|
||||||
|
skill_rows[sid] = dict(ms)
|
||||||
|
order.append(sid)
|
||||||
|
|
||||||
|
for fm in loaded[1:]:
|
||||||
|
for ms in fm.get("model_skills") or []:
|
||||||
|
sid = int(ms["skill_id"])
|
||||||
|
if sid not in skill_rows:
|
||||||
|
row = dict(ms)
|
||||||
|
row["maturity_model_id"] = base["id"]
|
||||||
|
skill_rows[sid] = row
|
||||||
|
order.append(sid)
|
||||||
|
|
||||||
|
merged_model_skills = [skill_rows[sid] for sid in order]
|
||||||
|
|
||||||
|
cell_map: Dict[tuple, Dict[str, Any]] = {}
|
||||||
|
for fm in loaded:
|
||||||
|
for sl in fm.get("skill_levels") or []:
|
||||||
|
ln = int(sl["level_number"])
|
||||||
|
if ln < 1 or ln > base_lc:
|
||||||
|
continue
|
||||||
|
sid = int(sl["skill_id"])
|
||||||
|
key = (sid, ln)
|
||||||
|
cell_map[key] = {
|
||||||
|
"maturity_model_id": base["id"],
|
||||||
|
"skill_id": sid,
|
||||||
|
"level_number": ln,
|
||||||
|
"description": sl.get("description") or "",
|
||||||
|
"observable_criteria": sl.get("observable_criteria"),
|
||||||
|
"example_exercise_hints": sl.get("example_exercise_hints"),
|
||||||
|
"ai_generated": sl.get("ai_generated"),
|
||||||
|
"skill_name": sl.get("skill_name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for sid, ln in list(cell_map.keys()):
|
||||||
|
row = cell_map[(sid, ln)]
|
||||||
|
if not row.get("skill_name"):
|
||||||
|
row["skill_name"] = skill_rows.get(sid, {}).get("skill_name")
|
||||||
|
|
||||||
|
merged_skill_levels = list(cell_map.values())
|
||||||
|
merged_skill_levels.sort(key=lambda x: (x.get("skill_name") or "", x["level_number"]))
|
||||||
|
|
||||||
|
out = {
|
||||||
|
**base,
|
||||||
|
"model_skills": merged_model_skills,
|
||||||
|
"skill_levels": merged_skill_levels,
|
||||||
|
"levels": base_levels,
|
||||||
|
"level_count": base_lc,
|
||||||
|
"resolution": {
|
||||||
|
"merged": len(loaded) > 1,
|
||||||
|
"source_model_ids": [int(m["id"]) for m in loaded],
|
||||||
|
"binding_strategy": "hierarchical_override",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_resolve_pick_model_id(
|
||||||
|
cur,
|
||||||
|
focus_area_id: Optional[int],
|
||||||
|
style_direction_id: Optional[int],
|
||||||
|
target_group_id: Optional[int],
|
||||||
|
) -> Optional[int]:
|
||||||
cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id")
|
cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id")
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
enriched: List[Dict[str, Any]] = []
|
enriched: List[Dict[str, Any]] = []
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
for m in rows:
|
for m in rows:
|
||||||
_attach_context(cur, m)
|
_attach_context(cur, m)
|
||||||
enriched.append(m)
|
enriched.append(m)
|
||||||
|
|
@ -290,10 +407,49 @@ def resolve_maturity_model(
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
best = max(candidates, key=lambda m: (score(m), m.get("id") or 0))
|
best = max(candidates, key=lambda m: (score(m), m.get("id") or 0))
|
||||||
model_id = best["id"]
|
return int(best["id"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/maturity-models/resolve")
|
||||||
|
def resolve_maturity_model(
|
||||||
|
focus_area_id: Optional[int] = Query(default=None),
|
||||||
|
style_direction_id: Optional[int] = Query(default=None),
|
||||||
|
target_group_id: Optional[int] = Query(default=None),
|
||||||
|
training_type_id: Optional[int] = Query(
|
||||||
|
default=None,
|
||||||
|
description="Trainingsstil (training_types, z. B. Leistungssport); nur mit Stilrichtung sinnvoll.",
|
||||||
|
),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Liefert die Fähigkeitsmatrix zum Kontext.
|
||||||
|
|
||||||
|
**Priorität 1:** Tabelle `maturity_model_context_bindings` (Fokus → optional Stilrichtung → optional Trainingsstil).
|
||||||
|
Mehrere Modelle werden zusammengeführt: spätere Stufen überschreiben Zelltexte für dieselbe Fähigkeit/Stufe.
|
||||||
|
|
||||||
|
**Priorität 2 (Legacy):** Ein aktives Modell, dessen M:N-Kontext zum Filter passt (Zielgruppe unverändert).
|
||||||
|
"""
|
||||||
|
if focus_area_id is not None:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
return _load_full_model(cur, model_id)
|
chain = _resolve_binding_model_ids(
|
||||||
|
cur,
|
||||||
|
int(focus_area_id),
|
||||||
|
style_direction_id,
|
||||||
|
training_type_id,
|
||||||
|
)
|
||||||
|
if chain:
|
||||||
|
loaded = [_load_full_model(cur, mid) for mid in chain]
|
||||||
|
return _merge_loaded_models(loaded)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
mid = _legacy_resolve_pick_model_id(
|
||||||
|
cur, focus_area_id, style_direction_id, target_group_id
|
||||||
|
)
|
||||||
|
if mid is None:
|
||||||
|
return None
|
||||||
|
return _load_full_model(cur, mid)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maturity-models")
|
@router.get("/maturity-models")
|
||||||
|
|
@ -603,3 +759,141 @@ def upsert_model_skill_levels(model_id: int, data: Dict[str, Any], session: dict
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
return _load_full_model(cur, model_id)
|
return _load_full_model(cur, model_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Kontext-Bindings (Fokus / Stilrichtung / Trainingsstil) — hierarchisch
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/maturity-model-context-bindings")
|
||||||
|
def list_maturity_model_context_bindings(session: dict = Depends(require_auth)):
|
||||||
|
role = session.get("role")
|
||||||
|
if role not in ("admin", "superadmin"):
|
||||||
|
raise HTTPException(403, "Nur Administratoren")
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT b.id, b.maturity_model_id, mm.name AS maturity_model_name, mm.status AS model_status,
|
||||||
|
b.focus_area_id, fa.name AS focus_area_name,
|
||||||
|
b.style_direction_id, sd.name AS style_direction_name,
|
||||||
|
b.training_type_id, tt.name AS training_type_name
|
||||||
|
FROM maturity_model_context_bindings b
|
||||||
|
JOIN maturity_models mm ON mm.id = b.maturity_model_id
|
||||||
|
JOIN focus_areas fa ON fa.id = b.focus_area_id
|
||||||
|
LEFT JOIN style_directions sd ON sd.id = b.style_direction_id
|
||||||
|
LEFT JOIN training_types tt ON tt.id = b.training_type_id
|
||||||
|
ORDER BY fa.sort_order NULLS LAST, fa.name,
|
||||||
|
(b.style_direction_id IS NULL) DESC,
|
||||||
|
sd.name NULLS LAST,
|
||||||
|
(b.training_type_id IS NULL) DESC,
|
||||||
|
tt.name NULLS LAST
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/maturity-model-context-bindings")
|
||||||
|
def upsert_maturity_model_context_binding(data: Dict[str, Any], session: dict = Depends(require_auth)):
|
||||||
|
_require_admin(session)
|
||||||
|
mid = data.get("maturity_model_id")
|
||||||
|
fa = data.get("focus_area_id")
|
||||||
|
if mid is None or fa is None:
|
||||||
|
raise HTTPException(400, "maturity_model_id und focus_area_id sind Pflichtfelder")
|
||||||
|
mid = int(mid)
|
||||||
|
fa = int(fa)
|
||||||
|
sd_raw = data.get("style_direction_id")
|
||||||
|
tt_raw = data.get("training_type_id")
|
||||||
|
sd: Optional[int] = int(sd_raw) if sd_raw is not None else None
|
||||||
|
tt: Optional[int] = int(tt_raw) if tt_raw is not None else None
|
||||||
|
|
||||||
|
if sd is None and tt is not None:
|
||||||
|
raise HTTPException(400, "Trainingsstil nur zusammen mit Stilrichtung erlaubt")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
if not _base_maturity_model(cur, mid):
|
||||||
|
raise HTTPException(404, "Reifegradmodell nicht gefunden")
|
||||||
|
cur.execute("SELECT id FROM focus_areas WHERE id = %s", (fa,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Fokusbereich nicht gefunden")
|
||||||
|
if sd is not None:
|
||||||
|
cur.execute("SELECT id FROM style_directions WHERE id = %s", (sd,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Stilrichtung nicht gefunden")
|
||||||
|
if tt is not None:
|
||||||
|
cur.execute("SELECT id FROM training_types WHERE id = %s", (tt,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Trainingsstil nicht gefunden")
|
||||||
|
|
||||||
|
if sd is None and tt is None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM maturity_model_context_bindings
|
||||||
|
WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id IS NULL
|
||||||
|
""",
|
||||||
|
(fa,),
|
||||||
|
)
|
||||||
|
elif tt is None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM maturity_model_context_bindings
|
||||||
|
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id IS NULL
|
||||||
|
""",
|
||||||
|
(fa, sd),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM maturity_model_context_bindings
|
||||||
|
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id = %s
|
||||||
|
""",
|
||||||
|
(fa, sd, tt),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO maturity_model_context_bindings (
|
||||||
|
maturity_model_id, focus_area_id, style_direction_id, training_type_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(mid, fa, sd, tt),
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()["id"]
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT b.id, b.maturity_model_id, mm.name AS maturity_model_name, mm.status AS model_status,
|
||||||
|
b.focus_area_id, fa.name AS focus_area_name,
|
||||||
|
b.style_direction_id, sd.name AS style_direction_name,
|
||||||
|
b.training_type_id, tt.name AS training_type_name
|
||||||
|
FROM maturity_model_context_bindings b
|
||||||
|
JOIN maturity_models mm ON mm.id = b.maturity_model_id
|
||||||
|
JOIN focus_areas fa ON fa.id = b.focus_area_id
|
||||||
|
LEFT JOIN style_directions sd ON sd.id = b.style_direction_id
|
||||||
|
LEFT JOIN training_types tt ON tt.id = b.training_type_id
|
||||||
|
WHERE b.id = %s
|
||||||
|
""",
|
||||||
|
(new_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return r2d(row) if row else {"id": new_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/maturity-model-context-bindings/{binding_id}")
|
||||||
|
def delete_maturity_model_context_binding(binding_id: int, session: dict = Depends(require_auth)):
|
||||||
|
_require_admin(session)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM maturity_model_context_bindings WHERE id = %s RETURNING id",
|
||||||
|
(binding_id,),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(404, "Zuordnung nicht gefunden")
|
||||||
|
return {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.7.2"
|
APP_VERSION = "0.7.3"
|
||||||
BUILD_DATE = "2026-04-27"
|
BUILD_DATE = "2026-04-27"
|
||||||
DB_SCHEMA_VERSION = "20260427025"
|
DB_SCHEMA_VERSION = "20260427026"
|
||||||
|
|
||||||
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.1.0", # 025: Kontext M:N + Wiki-Bootstrap
|
"maturity_models": "1.2.0", # 026: hierarchische Kontext-Bindings + Merge in resolve
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.7.3",
|
||||||
|
"date": "2026-04-27",
|
||||||
|
"changes": [
|
||||||
|
"DB 026: maturity_model_context_bindings (Fokus / Stilrichtung / Trainingsstil)",
|
||||||
|
"API: resolve merged mehrere Modelle; CRUD Bindings; training_type_id Query",
|
||||||
|
"Admin-Tab Kontext-Zuordnung",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"date": "2026-04-27",
|
"date": "2026-04-27",
|
||||||
|
|
|
||||||
|
|
@ -1099,6 +1099,59 @@ a.analysis-split__nav-item {
|
||||||
max-width: 56rem;
|
max-width: 56rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-bindings__intro {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
max-width: 56rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.admin-bindings__h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.admin-bindings__form-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.admin-bindings__form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
.admin-bindings__code {
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--surface2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.admin-bindings-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.admin-bindings-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.admin-bindings-table th,
|
||||||
|
.admin-bindings-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.admin-bindings-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.admin-bindings-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
.skills-catalog-admin__intro {
|
.skills-catalog-admin__intro {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
max-width: 56rem;
|
max-width: 56rem;
|
||||||
|
|
|
||||||
291
frontend/src/components/admin/MaturityModelBindingsAdmin.jsx
Normal file
291
frontend/src/components/admin/MaturityModelBindingsAdmin.jsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import api from '../../utils/api'
|
||||||
|
|
||||||
|
const TIER_LABEL = {
|
||||||
|
focus: 'Nur Fokusbereich',
|
||||||
|
focus_style: 'Fokus + Stilrichtung',
|
||||||
|
focus_style_type: 'Fokus + Stilrichtung + Trainingsstil'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierFromRow(row) {
|
||||||
|
if (row.style_direction_id == null && row.training_type_id == null) return 'focus'
|
||||||
|
if (row.training_type_id == null) return 'focus_style'
|
||||||
|
return 'focus_style_type'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MaturityModelBindingsAdmin() {
|
||||||
|
const [bindings, setBindings] = useState([])
|
||||||
|
const [models, setModels] = useState([])
|
||||||
|
const [focusAreas, setFocusAreas] = useState([])
|
||||||
|
const [styleDirections, setStyleDirections] = useState([])
|
||||||
|
const [trainingTypes, setTrainingTypes] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [tier, setTier] = useState('focus')
|
||||||
|
const [formModelId, setFormModelId] = useState('')
|
||||||
|
const [formFocusId, setFormFocusId] = useState('')
|
||||||
|
const [formStyleId, setFormStyleId] = useState('')
|
||||||
|
const [formTypeId, setFormTypeId] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const [b, m, fa, sd, tt] = await Promise.all([
|
||||||
|
api.listMaturityModelContextBindings(),
|
||||||
|
api.listMaturityModels({}),
|
||||||
|
api.listFocusAreas({}),
|
||||||
|
api.listStyleDirections({}),
|
||||||
|
api.listTrainingTypes({})
|
||||||
|
])
|
||||||
|
setBindings(b)
|
||||||
|
setModels(m)
|
||||||
|
setFocusAreas(fa)
|
||||||
|
setStyleDirections(sd)
|
||||||
|
setTrainingTypes(tt)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || String(e))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
await load()
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tier === 'focus') {
|
||||||
|
setFormStyleId('')
|
||||||
|
setFormTypeId('')
|
||||||
|
} else if (tier === 'focus_style') {
|
||||||
|
setFormTypeId('')
|
||||||
|
}
|
||||||
|
}, [tier])
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!formModelId || !formFocusId) {
|
||||||
|
setError('Modell und Fokusbereich sind Pflichtfelder.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
maturity_model_id: parseInt(formModelId, 10),
|
||||||
|
focus_area_id: parseInt(formFocusId, 10)
|
||||||
|
}
|
||||||
|
if (tier === 'focus_style' || tier === 'focus_style_type') {
|
||||||
|
if (!formStyleId) {
|
||||||
|
setError('Stilrichtung auswählen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload.style_direction_id = parseInt(formStyleId, 10)
|
||||||
|
}
|
||||||
|
if (tier === 'focus_style_type') {
|
||||||
|
if (!formTypeId) {
|
||||||
|
setError('Trainingsstil auswählen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload.training_type_id = parseInt(formTypeId, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.upsertMaturityModelContextBinding(payload)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || String(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
if (!window.confirm('Diese Zuordnung wirklich entfernen?')) return
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.deleteMaturityModelContextBinding(id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="muted">Lade Kontext-Zuordnungen…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="skills-catalog-admin__error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="card admin-bindings__form-section">
|
||||||
|
<h2 className="admin-bindings__h2">Zuordnung anlegen oder ersetzen</h2>
|
||||||
|
<form className="admin-bindings__form" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Ebene</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={tier}
|
||||||
|
onChange={(e) => setTier(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<option value="focus">{TIER_LABEL.focus}</option>
|
||||||
|
<option value="focus_style">{TIER_LABEL.focus_style}</option>
|
||||||
|
<option value="focus_style_type">{TIER_LABEL.focus_style_type}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Fokusbereich</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formFocusId}
|
||||||
|
onChange={(e) => setFormFocusId(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{focusAreas.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{(tier === 'focus_style' || tier === 'focus_style_type') && (
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Stilrichtung</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formStyleId}
|
||||||
|
onChange={(e) => setFormStyleId(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{styleDirections.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tier === 'focus_style_type' && (
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Trainingsstil (z. B. Leistungssport)</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formTypeId}
|
||||||
|
onChange={(e) => setFormTypeId(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{trainingTypes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Reifegradmodell</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formModelId}
|
||||||
|
onChange={(e) => setFormModelId(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{models.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name} ({m.status})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-bindings__table-section">
|
||||||
|
<h2 className="admin-bindings__h2">Aktive Zuordnungen</h2>
|
||||||
|
<div className="admin-bindings-table-wrap">
|
||||||
|
<table className="admin-bindings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ebene</th>
|
||||||
|
<th>Fokus</th>
|
||||||
|
<th>Stilrichtung</th>
|
||||||
|
<th>Trainingsstil</th>
|
||||||
|
<th>Modell</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
bindings.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td>{TIER_LABEL[tierFromRow(row)]}</td>
|
||||||
|
<td>{row.focus_area_name}</td>
|
||||||
|
<td>{row.style_direction_name || '—'}</td>
|
||||||
|
<td>{row.training_type_name || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{row.maturity_model_name}
|
||||||
|
<span className="muted"> ({row.model_status})</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-small"
|
||||||
|
onClick={() => handleDelete(row.id)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import AdminPageNav from '../components/AdminPageNav'
|
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'
|
||||||
|
|
||||||
export default function AdminMaturityModelsPage() {
|
export default function AdminMaturityModelsPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -44,10 +45,25 @@ export default function AdminMaturityModelsPage() {
|
||||||
>
|
>
|
||||||
Reifegradmodelle
|
Reifegradmodelle
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'bindings'}
|
||||||
|
className={'admin-tabs__tab' + (tab === 'bindings' ? ' admin-tabs__tab--active' : '')}
|
||||||
|
onClick={() => setTab('bindings')}
|
||||||
|
>
|
||||||
|
Kontext-Zuordnung
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-tabs__panel" role="tabpanel">
|
<div className="admin-tabs__panel" role="tabpanel">
|
||||||
{tab === 'catalog' ? <SkillsCatalogAdmin /> : <MaturityModelsAdminPanel />}
|
{tab === 'catalog' ? (
|
||||||
|
<SkillsCatalogAdmin />
|
||||||
|
) : tab === 'bindings' ? (
|
||||||
|
<MaturityModelBindingsAdmin />
|
||||||
|
) : (
|
||||||
|
<MaturityModelsAdminPanel />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,22 @@ export async function upsertMaturityModelSkillLevels(modelId, data) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hierarchische Zuordnung Modell → Fokus / Stilrichtung / Trainingsstil (training_types) */
|
||||||
|
export async function listMaturityModelContextBindings() {
|
||||||
|
return request('/api/maturity-model-context-bindings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertMaturityModelContextBinding(data) {
|
||||||
|
return request('/api/maturity-model-context-bindings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMaturityModelContextBinding(id) {
|
||||||
|
return request(`/api/maturity-model-context-bindings/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
// Style Directions (formerly Training Styles)
|
// 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()
|
||||||
|
|
@ -680,6 +696,9 @@ export const api = {
|
||||||
updateStyleDirectionTargetGroup,
|
updateStyleDirectionTargetGroup,
|
||||||
deleteStyleDirectionTargetGroup,
|
deleteStyleDirectionTargetGroup,
|
||||||
listMaturityModels,
|
listMaturityModels,
|
||||||
|
listMaturityModelContextBindings,
|
||||||
|
upsertMaturityModelContextBinding,
|
||||||
|
deleteMaturityModelContextBinding,
|
||||||
resolveMaturityModel,
|
resolveMaturityModel,
|
||||||
getMaturityModel,
|
getMaturityModel,
|
||||||
createMaturityModel,
|
createMaturityModel,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user