feat: update version to 0.7.4 and enhance maturity model functionality
- 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:
parent
3397b2094d
commit
f2c007cc68
17
backend/migrations/027_maturity_bindings_focus_training.sql
Normal file
17
backend/migrations/027_maturity_bindings_focus_training.sql
Normal 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;
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
383
frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx
Normal file
383
frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user