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)
Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt überall).
Lesen: alle authentifizierten Nutzer.
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
@ -20,29 +22,123 @@ def _require_admin(session: dict) -> None:
raise HTTPException(403, "Nur Administratoren dürfen Reifegradmodelle verwalten")
def _row_maturity_model(cur, model_id: int) -> Optional[Dict[str, Any]]:
cur.execute(
"""
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,),
)
def _base_maturity_model(cur, model_id: int) -> Optional[Dict[str, Any]]:
cur.execute("SELECT * FROM maturity_models WHERE id = %s", (model_id,))
row = cur.fetchone()
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]:
base = _row_maturity_model(cur, model_id)
base = _base_maturity_model(cur, model_id)
if not base:
raise HTTPException(404, "Reifegradmodell nicht gefunden")
_attach_context(cur, base)
cur.execute(
"""
@ -56,11 +152,15 @@ def _load_full_model(cur, model_id: int) -> Dict[str, Any]:
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
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
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,),
)
@ -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")
def resolve_maturity_model(
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.
Dimensionen mit NULL im Modell gelten als Wildcard.
Leere M:N-Zuordnung = Wildcard für diese Dimension.
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
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'
"""
)
cur.execute("SELECT * FROM maturity_models WHERE status = 'active' ORDER BY id")
rows = [r2d(r) for r in cur.fetchall()]
def matches(m: Dict[str, Any]) -> bool:
if focus_area_id is not None:
if m.get("focus_area_id") is not None and m["focus_area_id"] != focus_area_id:
return False
if style_direction_id is not None:
if m.get("style_direction_id") is not None and m["style_direction_id"] != style_direction_id:
return False
if target_group_id is not None:
if m.get("target_group_id") is not None and m["target_group_id"] != target_group_id:
return False
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)
def ok(m: Dict[str, Any]) -> bool:
if not _dim_matches(m.get("focus_areas") or [], focus_area_id):
return False
if not _dim_matches(m.get("style_directions") or [], style_direction_id):
return False
if not _dim_matches(m.get("target_groups") or [], target_group_id):
return False
return True
def score(m: Dict[str, Any]) -> int:
s = 0
if focus_area_id is not None and m.get("focus_area_id") == focus_area_id:
s += 1
if style_direction_id is not None and m.get("style_direction_id") == style_direction_id:
s += 1
if target_group_id is not None and m.get("target_group_id") == target_group_id:
s += 1
return s
return (
_dim_score(m.get("focus_areas") or [], focus_area_id)
+ _dim_score(m.get("style_directions") or [], style_direction_id)
+ _dim_score(m.get("target_groups") or [], target_group_id)
)
candidates = [m for m in rows if matches(m)]
candidates = [m for m in enriched if ok(m)]
if not candidates:
return None
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:
cur = get_cursor(conn)
q = """
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
"""
q = "SELECT * FROM maturity_models mm WHERE 1=1"
params: List[Any] = []
if status:
q += " AND mm.status = %s"
params.append(status)
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)
q += " ORDER BY mm.name ASC"
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}")
@ -249,19 +357,15 @@ def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_
"""
INSERT INTO maturity_models (
name, description,
focus_area_id, style_direction_id, target_group_id,
level_count, status, version,
created_by, club_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
""",
(
name,
data.get("description"),
data.get("focus_area_id"),
data.get("style_direction_id"),
data.get("target_group_id"),
level_count,
data.get("status") or "draft",
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"]
except Exception as e:
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
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")
if 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] = []
vals: List[Any] = []
for key in (
"name",
"description",
"focus_area_id",
"style_direction_id",
"target_group_id",
"status",
"version",
):
for key in ("name", "description", "status", "version"):
if key in data:
sets.append(f"{key} = %s")
vals.append(data[key])
@ -343,6 +452,19 @@ def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = D
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:
_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:
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")
cur.execute("SELECT id FROM skills WHERE id = %s", (skill_id,))
if not cur.fetchone():

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.7.1"
APP_VERSION = "0.7.2"
BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260427024"
DB_SCHEMA_VERSION = "20260427025"
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.0.0", # Migration 024: Reifegradmodelle / Fähigkeitsmatrix
"maturity_models": "1.1.0", # 025: Kontext M:N + Wiki-Bootstrap
}
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",
"date": "2026-04-27",

View File

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