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

- 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:
Lars 2026-04-27 12:35:48 +02:00
parent 469ec93074
commit 3397b2094d
7 changed files with 743 additions and 26 deletions

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

View File

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

View File

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

View File

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

View 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>
)
}

View File

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

View File

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