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,28 +247,145 @@ def _dim_score(items: List[Dict[str, Any]], query_id: Optional[int]) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
@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),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Wählt das spezifischste aktive Modell, das zum Kontext passt.
|
||||
Leere M:N-Zuordnung = Wildcard für diese Dimension.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id")
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
def _binding_model_active(cur, maturity_model_id: int) -> bool:
|
||||
b = _base_maturity_model(cur, maturity_model_id)
|
||||
return bool(b and b.get("status") == "active")
|
||||
|
||||
|
||||
def _resolve_binding_model_ids(
|
||||
cur,
|
||||
focus_area_id: int,
|
||||
style_direction_id: Optional[int],
|
||||
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.
|
||||
"""
|
||||
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
|
||||
""",
|
||||
(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")
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for m in rows:
|
||||
_attach_context(cur, m)
|
||||
enriched.append(m)
|
||||
for m in rows:
|
||||
_attach_context(cur, m)
|
||||
enriched.append(m)
|
||||
|
||||
def ok(m: Dict[str, Any]) -> bool:
|
||||
if not _dim_matches(m.get("focus_areas") or [], focus_area_id):
|
||||
|
|
@ -290,10 +407,49 @@ def resolve_maturity_model(
|
|||
if not candidates:
|
||||
return None
|
||||
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:
|
||||
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]
|
||||
return _merge_loaded_models(loaded)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return _load_full_model(cur, model_id)
|
||||
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")
|
||||
|
|
@ -603,3 +759,141 @@ def upsert_model_skill_levels(model_id: int, data: Dict[str, Any], session: dict
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
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
|
||||
|
||||
APP_VERSION = "0.7.2"
|
||||
APP_VERSION = "0.7.3"
|
||||
BUILD_DATE = "2026-04-27"
|
||||
DB_SCHEMA_VERSION = "20260427025"
|
||||
DB_SCHEMA_VERSION = "20260427026"
|
||||
|
||||
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.1.0", # 025: Kontext M:N + Wiki-Bootstrap
|
||||
"maturity_models": "1.2.0", # 026: hierarchische Kontext-Bindings + Merge in resolve
|
||||
}
|
||||
|
||||
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",
|
||||
"date": "2026-04-27",
|
||||
|
|
|
|||
|
|
@ -1099,6 +1099,59 @@ a.analysis-split__nav-item {
|
|||
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 {
|
||||
margin: 0 0 16px;
|
||||
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 SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
|
||||
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
|
||||
import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin'
|
||||
|
||||
export default function AdminMaturityModelsPage() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -44,10 +45,25 @@ export default function AdminMaturityModelsPage() {
|
|||
>
|
||||
Reifegradmodelle
|
||||
</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 className="admin-tabs__panel" role="tabpanel">
|
||||
{tab === 'catalog' ? <SkillsCatalogAdmin /> : <MaturityModelsAdminPanel />}
|
||||
{tab === 'catalog' ? (
|
||||
<SkillsCatalogAdmin />
|
||||
) : tab === 'bindings' ? (
|
||||
<MaturityModelBindingsAdmin />
|
||||
) : (
|
||||
<MaturityModelsAdminPanel />
|
||||
)}
|
||||
</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)
|
||||
export async function listStyleDirections(filters = {}) {
|
||||
const query = new URLSearchParams(filters).toString()
|
||||
|
|
@ -680,6 +696,9 @@ export const api = {
|
|||
updateStyleDirectionTargetGroup,
|
||||
deleteStyleDirectionTargetGroup,
|
||||
listMaturityModels,
|
||||
listMaturityModelContextBindings,
|
||||
upsertMaturityModelContextBinding,
|
||||
deleteMaturityModelContextBinding,
|
||||
resolveMaturityModel,
|
||||
getMaturityModel,
|
||||
createMaturityModel,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user