feat: update maturity models and version to 0.7.2
Some checks failed
Deploy Development / deploy (push) Successful in 35s
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.2 and updated database schema version to 20260427025.
- Enhanced maturity models functionality to support M:N relationships for focus areas, style directions, and target groups.
- Updated frontend to allow multi-selection for focus areas, style directions, and target groups.
- Documented changes in the changelog for version 0.7.2, including new migration details and UI improvements.
This commit is contained in:
Lars 2026-04-27 11:42:03 +02:00
parent 5277f4f4cf
commit f1ee1eec7e
4 changed files with 497 additions and 204 deletions

View File

@ -0,0 +1,160 @@
-- Migration 025: Reifegradmodell-Kontext als M:N (Fokusbereich, Stilrichtung, Zielgruppe)
-- + Bootstrap: ein Modell aus allen importierten Skills (Wiki / Migration 023)
-- Datum: 2026-04-27
-- ============================================================================
-- JUNCTION TABLES
-- ============================================================================
CREATE TABLE IF NOT EXISTS maturity_model_focus_areas (
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,
is_primary BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (maturity_model_id, focus_area_id)
);
CREATE INDEX IF NOT EXISTS idx_mmfa_model ON maturity_model_focus_areas(maturity_model_id);
CREATE INDEX IF NOT EXISTS idx_mmfa_focus ON maturity_model_focus_areas(focus_area_id);
CREATE TABLE IF NOT EXISTS maturity_model_style_directions (
id SERIAL PRIMARY KEY,
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
style_direction_id INT NOT NULL REFERENCES style_directions(id) ON DELETE CASCADE,
is_primary BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (maturity_model_id, style_direction_id)
);
CREATE INDEX IF NOT EXISTS idx_mmsd_model ON maturity_model_style_directions(maturity_model_id);
CREATE INDEX IF NOT EXISTS idx_mmsd_style ON maturity_model_style_directions(style_direction_id);
CREATE TABLE IF NOT EXISTS maturity_model_target_groups (
id SERIAL PRIMARY KEY,
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE CASCADE,
is_primary BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (maturity_model_id, target_group_id)
);
CREATE INDEX IF NOT EXISTS idx_mmtg_model ON maturity_model_target_groups(maturity_model_id);
CREATE INDEX IF NOT EXISTS idx_mmtg_tg ON maturity_model_target_groups(target_group_id);
-- ============================================================================
-- Daten aus 1:1-FKs übernehmen (falls Spalten noch existieren), dann FK-Spalten entfernen
-- ============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'focus_area_id'
) THEN
INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary)
SELECT id, focus_area_id, true
FROM maturity_models
WHERE focus_area_id IS NOT NULL
ON CONFLICT (maturity_model_id, focus_area_id) DO NOTHING;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'style_direction_id'
) THEN
INSERT INTO maturity_model_style_directions (maturity_model_id, style_direction_id, is_primary)
SELECT id, style_direction_id, true
FROM maturity_models
WHERE style_direction_id IS NOT NULL
ON CONFLICT (maturity_model_id, style_direction_id) DO NOTHING;
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'maturity_models' AND column_name = 'target_group_id'
) THEN
INSERT INTO maturity_model_target_groups (maturity_model_id, target_group_id, is_primary)
SELECT id, target_group_id, true
FROM maturity_models
WHERE target_group_id IS NOT NULL
ON CONFLICT (maturity_model_id, target_group_id) DO NOTHING;
END IF;
END $$;
ALTER TABLE maturity_models DROP CONSTRAINT IF EXISTS maturity_models_context_name_unique;
ALTER TABLE maturity_models DROP COLUMN IF EXISTS focus_area_id;
ALTER TABLE maturity_models DROP COLUMN IF EXISTS style_direction_id;
ALTER TABLE maturity_models DROP COLUMN IF EXISTS target_group_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_maturity_models_wiki_import
ON maturity_models(import_source, import_id)
WHERE import_id IS NOT NULL;
-- ============================================================================
-- Bootstrap: Standard-Modell für alle Skills aus der Wiki-Matrix (Migration 023)
-- ============================================================================
DO $$
DECLARE
mid INT;
n_skills INT;
BEGIN
SELECT COUNT(*) INTO n_skills FROM skills;
IF n_skills = 0 THEN
RAISE NOTICE 'Bootstrap: keine Skills übersprungen';
RETURN;
END IF;
SELECT id INTO mid FROM maturity_models WHERE import_id = 'faehigkeitsmatrix_karatetrainer' LIMIT 1;
IF mid IS NOT NULL THEN
RAISE NOTICE 'Bootstrap: Modell bereits vorhanden (id=%)', mid;
RETURN;
END IF;
INSERT INTO maturity_models (name, description, level_count, status, import_source, import_id, version)
VALUES (
'Fähigkeitsmatrix (Wiki Import)',
'Alle Fähigkeiten aus der Wiki-Matrix (Karatetrainer). Haupt- und Untergruppen kommen aus skill_main_categories / skill_categories.',
5,
'active',
'wiki_matrix',
'faehigkeitsmatrix_karatetrainer',
'1.0'
)
RETURNING id INTO mid;
INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary)
SELECT mid, fa.id, (fa.name = 'Karate')
FROM focus_areas fa
WHERE fa.name IN ('Karate', 'Selbstverteidigung', 'Gewaltschutz')
ON CONFLICT (maturity_model_id, focus_area_id) DO NOTHING;
INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order)
VALUES
(mid, 1, 'Einsteiger', NULL, 1),
(mid, 2, 'Grundlagen', NULL, 2),
(mid, 3, 'Aufbau', NULL, 3),
(mid, 4, 'Fortgeschritten', NULL, 4),
(mid, 5, 'Experte', NULL, 5);
INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance)
SELECT
mid,
s.id,
ROW_NUMBER() OVER (
ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.name
),
NULL
FROM skills s
LEFT JOIN skill_categories sc ON s.category_id = sc.id
LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id;
INSERT INTO model_skill_levels (maturity_model_id, skill_id, level_number, description, observable_criteria)
SELECT mid, sld.skill_id, sld.level, sld.description, NULL
FROM skill_level_definitions sld
ON CONFLICT (maturity_model_id, skill_id, level_number) DO NOTHING;
RAISE NOTICE 'Bootstrap: Reifegradmodell id=% angelegt (% Skills)', mid, n_skills;
END $$;

View File

@ -1,10 +1,12 @@
""" """
Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen) Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen)
Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt überall).
Lesen: alle authentifizierten Nutzer. Lesen: alle authentifizierten Nutzer.
Schreiben: admin, superadmin. Schreiben: admin, superadmin.
""" """
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -20,29 +22,123 @@ def _require_admin(session: dict) -> None:
raise HTTPException(403, "Nur Administratoren dürfen Reifegradmodelle verwalten") raise HTTPException(403, "Nur Administratoren dürfen Reifegradmodelle verwalten")
def _row_maturity_model(cur, model_id: int) -> Optional[Dict[str, Any]]: def _base_maturity_model(cur, model_id: int) -> Optional[Dict[str, Any]]:
cur.execute( cur.execute("SELECT * FROM maturity_models WHERE id = %s", (model_id,))
"""
SELECT mm.*,
fa.name AS focus_area_name,
sd.name AS style_direction_name,
tg.name AS target_group_name
FROM maturity_models mm
LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id
LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id
LEFT JOIN target_groups tg ON mm.target_group_id = tg.id
WHERE mm.id = %s
""",
(model_id,),
)
row = cur.fetchone() row = cur.fetchone()
return r2d(row) if row else None return r2d(row) if row else None
def _attach_context(cur, m: Dict[str, Any]) -> Dict[str, Any]:
mid = m["id"]
cur.execute(
"""
SELECT fa.id, fa.name, fa.abbreviation, mfa.is_primary
FROM maturity_model_focus_areas mfa
JOIN focus_areas fa ON fa.id = mfa.focus_area_id
WHERE mfa.maturity_model_id = %s
ORDER BY mfa.is_primary DESC NULLS LAST, fa.sort_order, fa.name
""",
(mid,),
)
m["focus_areas"] = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT sd.id, sd.name, msd.is_primary
FROM maturity_model_style_directions msd
JOIN style_directions sd ON sd.id = msd.style_direction_id
WHERE msd.maturity_model_id = %s
ORDER BY msd.is_primary DESC NULLS LAST, sd.name
""",
(mid,),
)
m["style_directions"] = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT tg.id, tg.name, mtg.is_primary
FROM maturity_model_target_groups mtg
JOIN target_groups tg ON tg.id = mtg.target_group_id
WHERE mtg.maturity_model_id = %s
ORDER BY mtg.is_primary DESC NULLS LAST, tg.name
""",
(mid,),
)
m["target_groups"] = [r2d(r) for r in cur.fetchall()]
m["focus_area_name"] = m["focus_areas"][0]["name"] if m["focus_areas"] else None
m["style_direction_name"] = m["style_directions"][0]["name"] if m["style_directions"] else None
m["target_group_name"] = m["target_groups"][0]["name"] if m["target_groups"] else None
return m
def _normalize_id_list(raw: Any) -> Optional[List[int]]:
if raw is None:
return None
if isinstance(raw, (str, bytes)):
raise HTTPException(400, "Erwarte Liste von IDs")
if not isinstance(raw, Sequence):
raise HTTPException(400, "Erwarte Liste von IDs")
out: List[int] = []
for x in raw:
if x is None:
continue
out.append(int(x))
return out
def _write_context_junctions(cur, model_id: int, data: Dict[str, Any]) -> None:
fa = data.get("focus_area_ids")
if fa is None and data.get("focus_area_id") is not None:
fa = [data.get("focus_area_id")]
if fa is not None:
fa = _normalize_id_list(fa)
cur.execute("DELETE FROM maturity_model_focus_areas WHERE maturity_model_id = %s", (model_id,))
for i, fid in enumerate(fa or []):
cur.execute(
"""
INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary)
VALUES (%s, %s, %s)
""",
(model_id, fid, i == 0),
)
sd = data.get("style_direction_ids")
if sd is None and data.get("style_direction_id") is not None:
sd = [data.get("style_direction_id")]
if sd is not None:
sd = _normalize_id_list(sd)
cur.execute("DELETE FROM maturity_model_style_directions WHERE maturity_model_id = %s", (model_id,))
for i, sid in enumerate(sd or []):
cur.execute(
"""
INSERT INTO maturity_model_style_directions (maturity_model_id, style_direction_id, is_primary)
VALUES (%s, %s, %s)
""",
(model_id, sid, i == 0),
)
tg = data.get("target_group_ids")
if tg is None and data.get("target_group_id") is not None:
tg = [data.get("target_group_id")]
if tg is not None:
tg = _normalize_id_list(tg)
cur.execute("DELETE FROM maturity_model_target_groups WHERE maturity_model_id = %s", (model_id,))
for i, tid in enumerate(tg or []):
cur.execute(
"""
INSERT INTO maturity_model_target_groups (maturity_model_id, target_group_id, is_primary)
VALUES (%s, %s, %s)
""",
(model_id, tid, i == 0),
)
def _load_full_model(cur, model_id: int) -> Dict[str, Any]: def _load_full_model(cur, model_id: int) -> Dict[str, Any]:
base = _row_maturity_model(cur, model_id) base = _base_maturity_model(cur, model_id)
if not base: if not base:
raise HTTPException(404, "Reifegradmodell nicht gefunden") raise HTTPException(404, "Reifegradmodell nicht gefunden")
_attach_context(cur, base)
cur.execute( cur.execute(
""" """
@ -56,11 +152,15 @@ def _load_full_model(cur, model_id: int) -> Dict[str, Any]:
cur.execute( cur.execute(
""" """
SELECT ms.*, s.name AS skill_name, s.status AS skill_status SELECT ms.*, s.name AS skill_name, s.status AS skill_status,
sc.name AS skill_subcategory_name, sc.slug AS skill_subcategory_slug,
mc.name AS skill_main_category_name, mc.slug AS skill_main_category_slug
FROM model_skills ms FROM model_skills ms
JOIN skills s ON s.id = ms.skill_id JOIN skills s ON s.id = ms.skill_id
LEFT JOIN skill_categories sc ON s.category_id = sc.id
LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
WHERE ms.maturity_model_id = %s WHERE ms.maturity_model_id = %s
ORDER BY ms.sort_order ASC, s.name ASC ORDER BY ms.sort_order ASC, mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.name ASC
""", """,
(model_id,), (model_id,),
) )
@ -131,6 +231,22 @@ def _replace_levels(cur, model_id: int, level_count: int, levels: List[Dict[str,
) )
def _dim_matches(items: List[Dict[str, Any]], query_id: Optional[int]) -> bool:
if not items:
return True
if query_id is None:
return True
return any(int(x["id"]) == int(query_id) for x in items)
def _dim_score(items: List[Dict[str, Any]], query_id: Optional[int]) -> int:
if not items:
return 0
if query_id is not None and any(int(x["id"]) == int(query_id) for x in items):
return 1
return 0
@router.get("/maturity-models/resolve") @router.get("/maturity-models/resolve")
def resolve_maturity_model( def resolve_maturity_model(
focus_area_id: Optional[int] = Query(default=None), focus_area_id: Optional[int] = Query(default=None),
@ -140,48 +256,37 @@ def resolve_maturity_model(
): ):
""" """
Wählt das spezifischste aktive Modell, das zum Kontext passt. Wählt das spezifischste aktive Modell, das zum Kontext passt.
Dimensionen mit NULL im Modell gelten als Wildcard. Leere M:N-Zuordnung = Wildcard für diese Dimension.
""" """
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id")
"""
SELECT mm.*,
fa.name AS focus_area_name,
sd.name AS style_direction_name,
tg.name AS target_group_name
FROM maturity_models mm
LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id
LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id
LEFT JOIN target_groups tg ON mm.target_group_id = tg.id
WHERE mm.status = 'active'
"""
)
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
def matches(m: Dict[str, Any]) -> bool: enriched: List[Dict[str, Any]] = []
if focus_area_id is not None: with get_db() as conn:
if m.get("focus_area_id") is not None and m["focus_area_id"] != focus_area_id: cur = get_cursor(conn)
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):
return False return False
if style_direction_id is not None: if not _dim_matches(m.get("style_directions") or [], style_direction_id):
if m.get("style_direction_id") is not None and m["style_direction_id"] != style_direction_id:
return False return False
if target_group_id is not None: if not _dim_matches(m.get("target_groups") or [], target_group_id):
if m.get("target_group_id") is not None and m["target_group_id"] != target_group_id:
return False return False
return True return True
def score(m: Dict[str, Any]) -> int: def score(m: Dict[str, Any]) -> int:
s = 0 return (
if focus_area_id is not None and m.get("focus_area_id") == focus_area_id: _dim_score(m.get("focus_areas") or [], focus_area_id)
s += 1 + _dim_score(m.get("style_directions") or [], style_direction_id)
if style_direction_id is not None and m.get("style_direction_id") == style_direction_id: + _dim_score(m.get("target_groups") or [], target_group_id)
s += 1 )
if target_group_id is not None and m.get("target_group_id") == target_group_id:
s += 1
return s
candidates = [m for m in rows if matches(m)] candidates = [m for m in enriched if ok(m)]
if not candidates: if not candidates:
return None return None
best = max(candidates, key=lambda m: (score(m), m.get("id") or 0)) best = max(candidates, key=lambda m: (score(m), m.get("id") or 0))
@ -199,27 +304,30 @@ def list_maturity_models(
): ):
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
q = """ q = "SELECT * FROM maturity_models mm WHERE 1=1"
SELECT mm.*,
fa.name AS focus_area_name,
sd.name AS style_direction_name,
tg.name AS target_group_name
FROM maturity_models mm
LEFT JOIN focus_areas fa ON mm.focus_area_id = fa.id
LEFT JOIN style_directions sd ON mm.style_direction_id = sd.id
LEFT JOIN target_groups tg ON mm.target_group_id = tg.id
WHERE 1=1
"""
params: List[Any] = [] params: List[Any] = []
if status: if status:
q += " AND mm.status = %s" q += " AND mm.status = %s"
params.append(status) params.append(status)
if focus_area_id is not None: if focus_area_id is not None:
q += " AND mm.focus_area_id = %s" q += """ AND (
EXISTS (
SELECT 1 FROM maturity_model_focus_areas mfa
WHERE mfa.maturity_model_id = mm.id
AND mfa.focus_area_id = %s
)
OR NOT EXISTS (
SELECT 1 FROM maturity_model_focus_areas mfa2
WHERE mfa2.maturity_model_id = mm.id
)
)"""
params.append(focus_area_id) params.append(focus_area_id)
q += " ORDER BY mm.name ASC" q += " ORDER BY mm.name ASC"
cur.execute(q, params) cur.execute(q, params)
return [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
for m in rows:
_attach_context(cur, m)
return rows
@router.get("/maturity-models/{model_id}") @router.get("/maturity-models/{model_id}")
@ -249,19 +357,15 @@ def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_
""" """
INSERT INTO maturity_models ( INSERT INTO maturity_models (
name, description, name, description,
focus_area_id, style_direction_id, target_group_id,
level_count, status, version, level_count, status, version,
created_by, club_id, created_by, club_id,
import_source, import_id import_source, import_id
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id RETURNING id
""", """,
( (
name, name,
data.get("description"), data.get("description"),
data.get("focus_area_id"),
data.get("style_direction_id"),
data.get("target_group_id"),
level_count, level_count,
data.get("status") or "draft", data.get("status") or "draft",
data.get("version") or "1.0", data.get("version") or "1.0",
@ -274,9 +378,22 @@ def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_
mid = cur.fetchone()["id"] mid = cur.fetchone()["id"]
except Exception as e: except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower(): if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(409, "Ein Modell mit diesem Namen und Kontext existiert bereits") from e raise HTTPException(409, "Konflikt: import_id oder Name bereits vergeben") from e
raise raise
if any(
k in data
for k in (
"focus_area_ids",
"style_direction_ids",
"target_group_ids",
"focus_area_id",
"style_direction_id",
"target_group_id",
)
):
_write_context_junctions(cur, mid, data)
levels = data.get("levels") levels = data.get("levels")
if levels: if levels:
_replace_levels(cur, mid, level_count, levels) _replace_levels(cur, mid, level_count, levels)
@ -320,15 +437,7 @@ def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = D
sets: List[str] = [] sets: List[str] = []
vals: List[Any] = [] vals: List[Any] = []
for key in ( for key in ("name", "description", "status", "version"):
"name",
"description",
"focus_area_id",
"style_direction_id",
"target_group_id",
"status",
"version",
):
if key in data: if key in data:
sets.append(f"{key} = %s") sets.append(f"{key} = %s")
vals.append(data[key]) vals.append(data[key])
@ -343,6 +452,19 @@ def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = D
tuple(vals), tuple(vals),
) )
if any(
k in data
for k in (
"focus_area_ids",
"style_direction_ids",
"target_group_ids",
"focus_area_id",
"style_direction_id",
"target_group_id",
)
):
_write_context_junctions(cur, model_id, data)
if "levels" in data and data["levels"] is not None: if "levels" in data and data["levels"] is not None:
_replace_levels(cur, model_id, level_count, data["levels"]) _replace_levels(cur, model_id, level_count, data["levels"])
@ -373,7 +495,7 @@ def add_model_skill(model_id: int, data: Dict[str, Any], session: dict = Depends
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if not _row_maturity_model(cur, model_id): if not _base_maturity_model(cur, model_id):
raise HTTPException(404, "Reifegradmodell nicht gefunden") raise HTTPException(404, "Reifegradmodell nicht gefunden")
cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,)) cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,))
if not cur.fetchone(): if not cur.fetchone():

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.7.1" APP_VERSION = "0.7.2"
BUILD_DATE = "2026-04-27" BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260427024" DB_SCHEMA_VERSION = "20260427025"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.0.0", "auth": "1.0.0",
@ -19,10 +19,19 @@ MODULE_VERSIONS = {
"admin": "1.0.0", "admin": "1.0.0",
"membership": "1.0.0", "membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) "catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
"maturity_models": "1.0.0", # Migration 024: Reifegradmodelle / Fähigkeitsmatrix "maturity_models": "1.1.0", # 025: Kontext M:N + Wiki-Bootstrap
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.7.2",
"date": "2026-04-27",
"changes": [
"DB: Migration 025 maturity_model_focus_areas / _style_directions / _target_groups (M:N)",
"Bootstrap-Reifegradmodell für alle importierten Skills (Wiki), Stufen 15",
"API+UI: Kontext als Mehrfachauswahl; Matrix zeigt Haupt-/Untergruppe pro Fähigkeit",
],
},
{ {
"version": "0.7.1", "version": "0.7.1",
"date": "2026-04-27", "date": "2026-04-27",

View File

@ -4,6 +4,31 @@ import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav' import AdminPageNav from '../components/AdminPageNav'
function MultiIdSelect({ label, options, valueIds, onChange, hint }) {
return (
<div>
<label className="form-label">{label}</label>
<select
multiple
className="form-input"
style={{ minHeight: 100, width: '100%' }}
value={(valueIds || []).map(String)}
onChange={(e) => {
const v = Array.from(e.target.selectedOptions, (o) => parseInt(o.value, 10))
onChange(v)
}}
>
{options.map((o) => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
{hint ? (
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{hint}</div>
) : null}
</div>
)
}
export default function AdminMaturityModelsPage() { export default function AdminMaturityModelsPage() {
const { user } = useAuth() const { user } = useAuth()
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
@ -23,9 +48,9 @@ export default function AdminMaturityModelsPage() {
const [newModel, setNewModel] = useState({ const [newModel, setNewModel] = useState({
name: '', name: '',
level_count: 5, level_count: 5,
focus_area_id: '', focus_area_ids: [],
style_direction_id: '', style_direction_ids: [],
target_group_id: '', target_group_ids: [],
status: 'draft' status: 'draft'
}) })
@ -76,9 +101,9 @@ export default function AdminMaturityModelsPage() {
setMeta({ setMeta({
name: d.name, name: d.name,
description: d.description || '', description: d.description || '',
focus_area_id: d.focus_area_id ?? '', focus_area_ids: (d.focus_areas || []).map((x) => x.id),
style_direction_id: d.style_direction_id ?? '', style_direction_ids: (d.style_directions || []).map((x) => x.id),
target_group_id: d.target_group_id ?? '', target_group_ids: (d.target_groups || []).map((x) => x.id),
status: d.status, status: d.status,
version: d.version || '1.0' version: d.version || '1.0'
}) })
@ -118,13 +143,9 @@ export default function AdminMaturityModelsPage() {
name: newModel.name.trim(), name: newModel.name.trim(),
level_count: parseInt(String(newModel.level_count), 10), level_count: parseInt(String(newModel.level_count), 10),
status: newModel.status, status: newModel.status,
focus_area_id: newModel.focus_area_id ? parseInt(String(newModel.focus_area_id), 10) : null, focus_area_ids: newModel.focus_area_ids,
style_direction_id: newModel.style_direction_id style_direction_ids: newModel.style_direction_ids,
? parseInt(String(newModel.style_direction_id), 10) target_group_ids: newModel.target_group_ids
: null,
target_group_id: newModel.target_group_id
? parseInt(String(newModel.target_group_id), 10)
: null
} }
const created = await api.createMaturityModel(payload) const created = await api.createMaturityModel(payload)
await refreshModels() await refreshModels()
@ -132,9 +153,9 @@ export default function AdminMaturityModelsPage() {
setNewModel({ setNewModel({
name: '', name: '',
level_count: 5, level_count: 5,
focus_area_id: '', focus_area_ids: [],
style_direction_id: '', style_direction_ids: [],
target_group_id: '', target_group_ids: [],
status: 'draft' status: 'draft'
}) })
} catch (e) { } catch (e) {
@ -152,11 +173,9 @@ export default function AdminMaturityModelsPage() {
await api.updateMaturityModel(selectedId, { await api.updateMaturityModel(selectedId, {
name: meta.name, name: meta.name,
description: meta.description || null, description: meta.description || null,
focus_area_id: meta.focus_area_id === '' ? null : parseInt(String(meta.focus_area_id), 10), focus_area_ids: meta.focus_area_ids,
style_direction_id: style_direction_ids: meta.style_direction_ids,
meta.style_direction_id === '' ? null : parseInt(String(meta.style_direction_id), 10), target_group_ids: meta.target_group_ids,
target_group_id:
meta.target_group_id === '' ? null : parseInt(String(meta.target_group_id), 10),
status: meta.status, status: meta.status,
version: meta.version version: meta.version
}) })
@ -343,10 +362,18 @@ export default function AdminMaturityModelsPage() {
onClick={() => selectModel(m.id)} onClick={() => selectModel(m.id)}
> >
<div style={{ fontWeight: 600 }}>{m.name}</div> <div style={{ fontWeight: 600 }}>{m.name}</div>
<div style={{ fontSize: 12, opacity: 0.85 }}> <div style={{ fontSize: 12, opacity: 0.85, lineHeight: 1.35 }}>
{[m.focus_area_name, m.style_direction_name, m.target_group_name] {(m.focus_areas || []).length
.filter(Boolean) ? `Fokus: ${m.focus_areas.map((f) => f.name).join(', ')}`
.join(' · ') || 'Allgemein'} : 'Fokus: alle'}
<br />
{(m.style_directions || []).length
? `Stil: ${m.style_directions.map((s) => s.name).join(', ')}`
: 'Stil: alle'}
<br />
{(m.target_groups || []).length
? `Zielgr.: ${m.target_groups.map((t) => t.name).join(', ')}`
: 'Zielgr.: alle'}
</div> </div>
<div style={{ fontSize: 11, opacity: 0.75 }}>{m.status} · {m.level_count} Stufen</div> <div style={{ fontSize: 11, opacity: 0.75 }}>{m.status} · {m.level_count} Stufen</div>
</button> </button>
@ -384,39 +411,27 @@ export default function AdminMaturityModelsPage() {
<option value="active">Aktiv</option> <option value="active">Aktiv</option>
<option value="archived">Archiviert</option> <option value="archived">Archiviert</option>
</select> </select>
<label className="form-label">Fokusbereich (optional)</label> <MultiIdSelect
<select label="Fokusbereiche (leer = alle)"
className="form-input" options={focusAreas}
value={newModel.focus_area_id} valueIds={newModel.focus_area_ids}
onChange={(e) => setNewModel((s) => ({ ...s, focus_area_id: e.target.value }))} onChange={(ids) => setNewModel((s) => ({ ...s, focus_area_ids: ids }))}
> hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
<option value=""></option> />
{focusAreas.map((fa) => ( <MultiIdSelect
<option key={fa.id} value={fa.id}>{fa.name}</option> label="Stilrichtungen (leer = alle)"
))} options={styles}
</select> valueIds={newModel.style_direction_ids}
<label className="form-label">Stilrichtung (optional)</label> onChange={(ids) => setNewModel((s) => ({ ...s, style_direction_ids: ids }))}
<select hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
className="form-input" />
value={newModel.style_direction_id} <MultiIdSelect
onChange={(e) => setNewModel((s) => ({ ...s, style_direction_id: e.target.value }))} label="Zielgruppen (leer = alle)"
> options={targetGroups}
<option value=""></option> valueIds={newModel.target_group_ids}
{styles.map((s) => ( onChange={(ids) => setNewModel((s) => ({ ...s, target_group_ids: ids }))}
<option key={s.id} value={s.id}>{s.name}</option> hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
))} />
</select>
<label className="form-label">Zielgruppe (optional)</label>
<select
className="form-input"
value={newModel.target_group_id}
onChange={(e) => setNewModel((s) => ({ ...s, target_group_id: e.target.value }))}
>
<option value=""></option>
{targetGroups.map((tg) => (
<option key={tg.id} value={tg.id}>{tg.name}</option>
))}
</select>
<button type="submit" className="btn btn-primary btn-full" disabled={saving}> <button type="submit" className="btn btn-primary btn-full" disabled={saving}>
Anlegen Anlegen
</button> </button>
@ -454,55 +469,27 @@ export default function AdminMaturityModelsPage() {
onChange={(e) => setMeta((m) => ({ ...m, description: e.target.value }))} onChange={(e) => setMeta((m) => ({ ...m, description: e.target.value }))}
/> />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <MultiIdSelect
<div> label="Fokusbereiche (keine Auswahl = alle)"
<label className="form-label">Fokusbereich</label> options={focusAreas}
<select valueIds={meta.focus_area_ids}
className="form-input" onChange={(ids) => setMeta((m) => ({ ...m, focus_area_ids: ids }))}
value={meta.focus_area_id === null || meta.focus_area_id === '' ? '' : meta.focus_area_id} hint="Strg/Cmd + Klick für mehrere. Alle abwählen = gilt in jedem Fokusbereich."
onChange={(e) => setMeta((m) => ({ ...m, focus_area_id: e.target.value }))} />
> <MultiIdSelect
<option value=""> (alle)</option> label="Stilrichtungen (keine Auswahl = alle)"
{focusAreas.map((fa) => ( options={styles}
<option key={fa.id} value={fa.id}>{fa.name}</option> valueIds={meta.style_direction_ids}
))} onChange={(ids) => setMeta((m) => ({ ...m, style_direction_ids: ids }))}
</select> hint="Strg/Cmd + Klick für mehrere."
</div> />
<div> <MultiIdSelect
<label className="form-label">Stilrichtung</label> label="Zielgruppen (keine Auswahl = alle)"
<select options={targetGroups}
className="form-input" valueIds={meta.target_group_ids}
value={ onChange={(ids) => setMeta((m) => ({ ...m, target_group_ids: ids }))}
meta.style_direction_id === null || meta.style_direction_id === '' hint="Strg/Cmd + Klick für mehrere."
? '' />
: meta.style_direction_id
}
onChange={(e) => setMeta((m) => ({ ...m, style_direction_id: e.target.value }))}
>
<option value=""> (alle)</option>
{styles.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
</div>
<div>
<label className="form-label">Zielgruppe</label>
<select
className="form-input"
value={
meta.target_group_id === null || meta.target_group_id === ''
? ''
: meta.target_group_id
}
onChange={(e) => setMeta((m) => ({ ...m, target_group_id: e.target.value }))}
>
<option value=""> (alle)</option>
{targetGroups.map((tg) => (
<option key={tg.id} value={tg.id}>{tg.name}</option>
))}
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div> <div>
<label className="form-label">Status</label> <label className="form-label">Status</label>
@ -640,7 +627,8 @@ export default function AdminMaturityModelsPage() {
</div> </div>
<ul style={{ marginTop: 12, paddingLeft: 18 }}> <ul style={{ marginTop: 12, paddingLeft: 18 }}>
{(detail.model_skills || []).map((ms) => ( {(detail.model_skills || []).map((ms) => (
<li key={ms.skill_id} style={{ marginBottom: 6 }}> <li key={ms.skill_id} style={{ marginBottom: 10 }}>
<div>
<strong>{ms.skill_name}</strong> <strong>{ms.skill_name}</strong>
{' '} {' '}
<button <button
@ -651,6 +639,13 @@ export default function AdminMaturityModelsPage() {
> >
entfernen entfernen
</button> </button>
</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 2 }}>
{ms.skill_main_category_name}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
) : null}
</li> </li>
))} ))}
</ul> </ul>
@ -690,10 +685,17 @@ export default function AdminMaturityModelsPage() {
left: 0, left: 0,
background: 'var(--surface)', background: 'var(--surface)',
fontWeight: 600, fontWeight: 600,
whiteSpace: 'nowrap' maxWidth: 220,
verticalAlign: 'top'
}} }}
> >
{ms.skill_name} <div>{ms.skill_name}</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text2)', marginTop: 4, lineHeight: 1.3 }}>
{ms.skill_main_category_name || '—'}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
) : null}
</td> </td>
{(detail.levels || []).map((l) => { {(detail.levels || []).map((l) => {
const key = `${ms.skill_id}-${l.level_number}` const key = `${ms.skill_id}-${l.level_number}`