feat: update maturity models and version to 0.7.2
- 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:
parent
5277f4f4cf
commit
f1ee1eec7e
160
backend/migrations/025_maturity_model_context_mn.sql
Normal file
160
backend/migrations/025_maturity_model_context_mn.sql
Normal 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 $$;
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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 1–5",
|
||||||
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user