feat: add maturity models functionality and update version
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m55s

- Introduced new maturity models feature with CRUD operations in the API.
- Added routes and frontend components for managing maturity models.
- Updated version to 0.7.1 with corresponding build date and schema version.
- Enhanced admin navigation to include maturity models section.
- Documented changes in the changelog for version 0.7.1.
This commit is contained in:
Lars 2026-04-27 11:32:30 +02:00
parent 5626be792f
commit 5277f4f4cf
11 changed files with 1662 additions and 5 deletions

View File

@ -70,7 +70,7 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, import_wiki, import_wiki_admin
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, maturity_models, import_wiki, import_wiki_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -79,6 +79,7 @@ app.include_router(clubs.router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(catalogs.router)
app.include_router(maturity_models.router)
app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)

View File

@ -0,0 +1,132 @@
-- Migration 024: Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen)
-- Datum: 2026-04-27
-- ============================================================================
-- MATURITY MODELS
-- ============================================================================
CREATE TABLE IF NOT EXISTS maturity_models (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
focus_area_id INT REFERENCES focus_areas(id) ON DELETE RESTRICT,
style_direction_id INT REFERENCES style_directions(id) ON DELETE RESTRICT,
target_group_id INT REFERENCES target_groups(id) ON DELETE RESTRICT,
level_count INT NOT NULL DEFAULT 5 CHECK (level_count BETWEEN 3 AND 10),
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
version VARCHAR(20) DEFAULT '1.0',
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
import_source VARCHAR(50),
import_id VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT maturity_models_context_name_unique
UNIQUE (name, focus_area_id, style_direction_id, target_group_id)
);
-- ============================================================================
-- MODEL LEVELS (Stufen-Labels pro Modell)
-- ============================================================================
CREATE TABLE IF NOT EXISTS model_levels (
id SERIAL PRIMARY KEY,
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
level_number INT NOT NULL CHECK (level_number >= 1),
name VARCHAR(100) NOT NULL,
description TEXT,
sort_order INT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(maturity_model_id, level_number)
);
-- ============================================================================
-- MODEL SKILLS: Welche Fähigkeiten sind im Modell geführt (Matrix-Zeilen)
-- ============================================================================
CREATE TABLE IF NOT EXISTS model_skills (
id SERIAL PRIMARY KEY,
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
skill_id INT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
relevance VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(maturity_model_id, skill_id)
);
CREATE INDEX IF NOT EXISTS idx_model_skills_model ON model_skills(maturity_model_id);
CREATE INDEX IF NOT EXISTS idx_model_skills_skill ON model_skills(skill_id);
-- ============================================================================
-- MODEL SKILL LEVELS (Zelltexte der Matrix)
-- ============================================================================
CREATE TABLE IF NOT EXISTS model_skill_levels (
id SERIAL PRIMARY KEY,
maturity_model_id INT NOT NULL REFERENCES maturity_models(id) ON DELETE CASCADE,
skill_id INT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
level_number INT NOT NULL CHECK (level_number >= 1),
description TEXT NOT NULL,
observable_criteria TEXT,
example_exercise_hints JSONB,
ai_generated BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(maturity_model_id, skill_id, level_number)
);
-- ============================================================================
-- SKILLS: optionale Matrix-Felder (Spec)
-- ============================================================================
ALTER TABLE skills
ADD COLUMN IF NOT EXISTS primary_focus_area_id INT REFERENCES focus_areas(id) ON DELETE SET NULL;
ALTER TABLE skills
ADD COLUMN IF NOT EXISTS is_cross_domain BOOLEAN DEFAULT false;
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_maturity_models_focus ON maturity_models(focus_area_id);
CREATE INDEX IF NOT EXISTS idx_maturity_models_style ON maturity_models(style_direction_id);
CREATE INDEX IF NOT EXISTS idx_maturity_models_target ON maturity_models(target_group_id);
CREATE INDEX IF NOT EXISTS idx_maturity_models_status ON maturity_models(status);
CREATE INDEX IF NOT EXISTS idx_model_levels_model ON model_levels(maturity_model_id);
CREATE INDEX IF NOT EXISTS idx_model_skill_levels_model ON model_skill_levels(maturity_model_id);
CREATE INDEX IF NOT EXISTS idx_model_skill_levels_skill ON model_skill_levels(skill_id);
CREATE INDEX IF NOT EXISTS idx_skills_primary_focus ON skills(primary_focus_area_id);
-- ============================================================================
-- TRIGGERS updated_at
-- ============================================================================
DROP TRIGGER IF EXISTS maturity_models_update ON maturity_models;
CREATE TRIGGER maturity_models_update
BEFORE UPDATE ON maturity_models
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
DROP TRIGGER IF EXISTS model_skill_levels_update ON model_skill_levels;
CREATE TRIGGER model_skill_levels_update
BEFORE UPDATE ON model_skill_levels
FOR EACH ROW EXECUTE FUNCTION update_timestamp();

View File

@ -0,0 +1,483 @@
"""
Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen)
Lesen: alle authentifizierten Nutzer.
Schreiben: admin, superadmin.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from auth import require_auth
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api", tags=["maturity_models"])
def _require_admin(session: dict) -> None:
role = session.get("role")
if role not in ("admin", "superadmin"):
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,),
)
row = cur.fetchone()
return r2d(row) if row else None
def _load_full_model(cur, model_id: int) -> Dict[str, Any]:
base = _row_maturity_model(cur, model_id)
if not base:
raise HTTPException(404, "Reifegradmodell nicht gefunden")
cur.execute(
"""
SELECT * FROM model_levels
WHERE maturity_model_id = %s
ORDER BY sort_order ASC, level_number ASC
""",
(model_id,),
)
levels = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT ms.*, s.name AS skill_name, s.status AS skill_status
FROM model_skills ms
JOIN skills s ON s.id = ms.skill_id
WHERE ms.maturity_model_id = %s
ORDER BY ms.sort_order ASC, s.name ASC
""",
(model_id,),
)
model_skills = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT msl.*, s.name AS skill_name
FROM model_skill_levels msl
JOIN skills s ON s.id = msl.skill_id
WHERE msl.maturity_model_id = %s
ORDER BY s.name ASC, msl.level_number ASC
""",
(model_id,),
)
skill_levels = [r2d(r) for r in cur.fetchall()]
return {
**base,
"levels": levels,
"model_skills": model_skills,
"skill_levels": skill_levels,
}
def _insert_default_levels(cur, model_id: int, level_count: int) -> None:
for i in range(1, level_count + 1):
cur.execute(
"""
INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order)
VALUES (%s, %s, %s, %s, %s)
""",
(model_id, i, f"Stufe {i}", None, i),
)
def _replace_levels(cur, model_id: int, level_count: int, levels: List[Dict[str, Any]]) -> None:
if len(levels) != level_count:
raise HTTPException(
400,
f"Anzahl der Stufen-Definitionen ({len(levels)}) muss level_count ({level_count}) entsprechen",
)
seen = set()
for lev in levels:
num = int(lev.get("level_number") or 0)
if num < 1 or num > level_count:
raise HTTPException(400, f"Ungültige level_number: {num}")
if num in seen:
raise HTTPException(400, f"Doppelte level_number: {num}")
seen.add(num)
if seen != set(range(1, level_count + 1)):
raise HTTPException(400, "level_number muss lückenlos 1..level_count abdecken")
cur.execute("DELETE FROM model_levels WHERE maturity_model_id = %s", (model_id,))
for lev in sorted(levels, key=lambda x: int(x.get("level_number"))):
cur.execute(
"""
INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order)
VALUES (%s, %s, %s, %s, %s)
""",
(
model_id,
int(lev["level_number"]),
(lev.get("name") or "").strip() or f"Stufe {lev['level_number']}",
lev.get("description"),
int(lev.get("sort_order") or lev["level_number"]),
),
)
@router.get("/maturity-models/resolve")
def resolve_maturity_model(
focus_area_id: Optional[int] = Query(default=None),
style_direction_id: Optional[int] = Query(default=None),
target_group_id: Optional[int] = Query(default=None),
session: dict = Depends(require_auth),
):
"""
Wählt das spezifischste aktive Modell, das zum Kontext passt.
Dimensionen mit NULL im Modell gelten als Wildcard.
"""
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'
"""
)
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
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
candidates = [m for m in rows if matches(m)]
if not candidates:
return None
best = max(candidates, key=lambda m: (score(m), m.get("id") or 0))
model_id = best["id"]
with get_db() as conn:
cur = get_cursor(conn)
return _load_full_model(cur, model_id)
@router.get("/maturity-models")
def list_maturity_models(
status: Optional[str] = Query(default=None),
focus_area_id: Optional[int] = Query(default=None),
session: dict = Depends(require_auth),
):
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
"""
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"
params.append(focus_area_id)
q += " ORDER BY mm.name ASC"
cur.execute(q, params)
return [r2d(r) for r in cur.fetchall()]
@router.get("/maturity-models/{model_id}")
def get_maturity_model(model_id: int, session: dict = Depends(require_auth)):
with get_db() as conn:
cur = get_cursor(conn)
return _load_full_model(cur, model_id)
@router.post("/maturity-models")
def create_maturity_model(data: Dict[str, Any], session: dict = Depends(require_auth)):
_require_admin(session)
name = (data.get("name") or "").strip()
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
level_count = int(data.get("level_count") or 5)
if level_count < 3 or level_count > 10:
raise HTTPException(400, "level_count muss zwischen 3 und 10 liegen")
profile_id = session.get("profile_id")
with get_db() as conn:
cur = get_cursor(conn)
try:
cur.execute(
"""
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)
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",
profile_id,
data.get("club_id"),
data.get("import_source"),
data.get("import_id"),
),
)
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
levels = data.get("levels")
if levels:
_replace_levels(cur, mid, level_count, levels)
else:
_insert_default_levels(cur, mid, level_count)
with get_db() as conn:
cur = get_cursor(conn)
return _load_full_model(cur, mid)
@router.put("/maturity-models/{model_id}")
def update_maturity_model(model_id: int, data: Dict[str, Any], session: dict = Depends(require_auth)):
_require_admin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM maturity_models WHERE id = %s", (model_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Reifegradmodell nicht gefunden")
current = r2d(row)
level_count = int(current["level_count"])
if "level_count" in data and data["level_count"] is not None:
level_count = int(data["level_count"])
if level_count < 3 or level_count > 10:
raise HTTPException(400, "level_count muss zwischen 3 und 10 liegen")
if level_count != int(current["level_count"]):
if "levels" not in data or data["levels"] is None:
raise HTTPException(
400,
"Stufenanzahl ändern nur zusammen mit vollständiger levels-Liste (Stufen-Editor).",
)
if level_count < int(current["level_count"]):
cur.execute(
"DELETE FROM model_skill_levels WHERE maturity_model_id = %s AND level_number > %s",
(model_id, level_count),
)
sets: List[str] = []
vals: List[Any] = []
for key in (
"name",
"description",
"focus_area_id",
"style_direction_id",
"target_group_id",
"status",
"version",
):
if key in data:
sets.append(f"{key} = %s")
vals.append(data[key])
sets.append("level_count = %s")
vals.append(level_count)
sets.append("updated_at = NOW()")
vals.append(model_id)
cur.execute(
f"UPDATE maturity_models SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
if "levels" in data and data["levels"] is not None:
_replace_levels(cur, model_id, level_count, data["levels"])
with get_db() as conn:
cur = get_cursor(conn)
return _load_full_model(cur, model_id)
@router.delete("/maturity-models/{model_id}")
def delete_maturity_model(model_id: int, session: dict = Depends(require_auth)):
role = session.get("role")
if role != "superadmin":
raise HTTPException(403, "Nur Superadmins dürfen Reifegradmodelle löschen")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM maturity_models WHERE id = %s RETURNING id", (model_id,))
if not cur.fetchone():
raise HTTPException(404, "Reifegradmodell nicht gefunden")
return {"ok": True}
@router.post("/maturity-models/{model_id}/skills")
def add_model_skill(model_id: int, data: Dict[str, Any], session: dict = Depends(require_auth)):
_require_admin(session)
skill_id = data.get("skill_id")
if not skill_id:
raise HTTPException(400, "skill_id ist Pflicht")
with get_db() as conn:
cur = get_cursor(conn)
if not _row_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():
raise HTTPException(404, "Fähigkeit nicht gefunden")
try:
cur.execute(
"""
INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance)
VALUES (%s, %s, %s, %s)
RETURNING id
""",
(
model_id,
skill_id,
int(data.get("sort_order") or 0),
data.get("relevance"),
),
)
except Exception as e:
if "unique" in str(e).lower():
raise HTTPException(409, "Fähigkeit ist bereits im Modell") from e
raise
with get_db() as conn:
cur = get_cursor(conn)
return _load_full_model(cur, model_id)
@router.delete("/maturity-models/{model_id}/skills/{skill_id}")
def remove_model_skill(model_id: int, skill_id: int, session: dict = Depends(require_auth)):
_require_admin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM model_skills WHERE maturity_model_id = %s AND skill_id = %s RETURNING id",
(model_id, skill_id),
)
if not cur.fetchone():
raise HTTPException(404, "Zuordnung nicht gefunden")
cur.execute(
"DELETE FROM model_skill_levels WHERE maturity_model_id = %s AND skill_id = %s",
(model_id, skill_id),
)
with get_db() as conn:
cur = get_cursor(conn)
return _load_full_model(cur, model_id)
@router.put("/maturity-models/{model_id}/skill-levels")
def upsert_model_skill_levels(model_id: int, data: Dict[str, Any], session: dict = Depends(require_auth)):
_require_admin(session)
entries: List[Dict[str, Any]] = data.get("entries") or []
if not entries:
raise HTTPException(400, "entries darf nicht leer sein")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT level_count FROM maturity_models WHERE id = %s", (model_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Reifegradmodell nicht gefunden")
level_count = int(row["level_count"])
for e in entries:
sid = e.get("skill_id")
ln = int(e.get("level_number") or 0)
desc = (e.get("description") or "").strip()
if not sid or ln < 1 or ln > level_count:
raise HTTPException(400, "Ungültige skill_id oder level_number")
if not desc:
cur.execute(
"""
DELETE FROM model_skill_levels
WHERE maturity_model_id = %s AND skill_id = %s AND level_number = %s
""",
(model_id, sid, ln),
)
continue
cur.execute(
"""
INSERT INTO model_skill_levels (
maturity_model_id, skill_id, level_number,
description, observable_criteria, example_exercise_hints
) VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT (maturity_model_id, skill_id, level_number)
DO UPDATE SET
description = EXCLUDED.description,
observable_criteria = EXCLUDED.observable_criteria,
example_exercise_hints = EXCLUDED.example_exercise_hints,
updated_at = NOW()
""",
(
model_id,
sid,
ln,
desc,
e.get("observable_criteria"),
e.get("example_exercise_hints"),
),
)
with get_db() as conn:
cur = get_cursor(conn)
return _load_full_model(cur, model_id)

View File

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
Generiert Migration 023: Vollständiger Skills-Import mit Kategorisierung.
"""
import csv
from collections import defaultdict
# Lese CSV
import os
script_dir = os.path.dirname(os.path.abspath(__file__))
csv_path = os.path.join(script_dir, '..', '..', 'skills_mapping_clean.csv')
try:
with open(csv_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
skills = list(reader)
except UnicodeDecodeError:
# Fallback zu latin-1 bei Encoding-Problemen
with open(csv_path, 'r', encoding='latin-1') as f:
reader = csv.DictReader(f)
skills = list(reader)
# Slug-Generierung
def to_slug(name):
"""Wandelt Namen in URL-friendly Slugs um."""
return name.lower().replace(' ', '_').replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss')
# Gruppiere nach Kategorien
by_main_cat = defaultdict(lambda: defaultdict(list))
sub_categories = {} # {sub_cat_name: (slug, main_cat)}
for s in skills:
main = s['main_category']
sub = s['sub_category']
by_main_cat[main][sub].append(s)
if sub not in sub_categories:
sub_categories[sub] = (to_slug(sub), main)
print("""-- Migration 023: Vollständiger Skills-Import
-- Purpose: Produktionsreifer Import aller 69 Skills mit vollständiger Kategorisierung
-- Source: Fähigkeitsmatrix https://karatetrainer.net/index.php?title=Fähigkeitsmatrix
-- Date: 2026-04-27
-- ======================================================================
-- CLEANUP: Alte Daten löschen
-- ======================================================================
-- Erst M:N-Beziehungen löschen
DELETE FROM exercise_skills;
-- Skills löschen (Cascades zu skill_level_definitions)
DELETE FROM skills;
-- Kategorien löschen
DELETE FROM skill_categories;
DELETE FROM skill_main_categories WHERE id > 0; -- Falls Tabelle existiert
-- ======================================================================
-- 1. HAUPT-KATEGORIEN
-- ======================================================================
INSERT INTO skill_main_categories (name, slug, description, sort_order) VALUES
('KARATE Fähigkeiten', 'karate', 'Karate-spezifische Techniken und Fähigkeiten', 1),
('ALLGEMEINE sportliche Fähigkeiten', 'allgemeine', 'Universelle sportliche und mentale Fähigkeiten', 2);
-- ======================================================================
-- 2. UNTERKATEGORIEN
-- ======================================================================
""")
# Sortiere Unterkategorien: Karate zuerst, dann Allgemeine
karate_subs = sorted([(name, slug, main) for name, (slug, main) in sub_categories.items() if main == 'karate'])
allgemeine_subs = sorted([(name, slug, main) for name, (slug, main) in sub_categories.items() if main == 'allgemeine'])
sort_order = 1
print("INSERT INTO skill_categories (name, slug, main_category_id, description, sort_order) VALUES")
for idx, (name, slug, main_cat) in enumerate(karate_subs + allgemeine_subs):
comma = "," if idx < len(karate_subs) + len(allgemeine_subs) - 1 else ";"
print(f"('{name}', '{slug}', (SELECT id FROM skill_main_categories WHERE slug='{main_cat}'), '', {sort_order}){comma}")
sort_order += 1
print("""
-- ======================================================================
-- 3. SKILLS
-- ======================================================================
INSERT INTO skills (name, description, category_id, main_category_id, focus_areas) VALUES""")
# Sortiere Skills: Karate zuerst, dann Allgemeine
karate_skills = []
for sub in [name for name, slug, main in karate_subs]:
karate_skills.extend(by_main_cat['karate'][sub])
allgemeine_skills = []
for sub in [name for name, slug, main in allgemeine_subs]:
allgemeine_skills.extend(by_main_cat['allgemeine'][sub])
all_skills = karate_skills + allgemeine_skills
for idx, s in enumerate(all_skills):
name = s['skill_name'].replace("'", "''") # SQL-Escape
sub_cat_slug = to_slug(s['sub_category'])
main_cat_slug = s['main_category']
focus = s['focus_areas']
comma = "," if idx < len(all_skills) - 1 else ";"
print(f"('{name}', '', (SELECT id FROM skill_categories WHERE slug='{sub_cat_slug}'), (SELECT id FROM skill_main_categories WHERE slug='{main_cat_slug}'), '[\"{ focus}\"]'::jsonb){comma}")
print("""
-- ======================================================================
-- 4. VERIFIKATION
-- ======================================================================
-- Sollte 69 Skills ergeben
DO $$
DECLARE
skill_count INT;
BEGIN
SELECT COUNT(*) INTO skill_count FROM skills;
IF skill_count != 69 THEN
RAISE WARNING 'FEHLER: % Skills gefunden, erwartet 69', skill_count;
ELSE
RAISE NOTICE 'OK: 69 Skills importiert';
END IF;
END $$;
-- Zeige Verteilung
SELECT
mc.name AS hauptkategorie,
sc.name AS unterkategorie,
COUNT(s.id) AS anzahl_skills
FROM skills s
JOIN skill_categories sc ON s.category_id = sc.id
JOIN skill_main_categories mc ON s.main_category_id = mc.id
GROUP BY mc.name, sc.name, mc.sort_order, sc.sort_order
ORDER BY mc.sort_order, sc.sort_order;
""")

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.7.0"
BUILD_DATE = "2026-04-24"
DB_SCHEMA_VERSION = "20260424002"
APP_VERSION = "0.7.1"
BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260427024"
MODULE_VERSIONS = {
"auth": "1.0.0",
@ -19,9 +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
}
CHANGELOG = [
{
"version": "0.7.1",
"date": "2026-04-27",
"changes": [
"DB: Migration 024 maturity_models, model_levels, model_skills, model_skill_levels",
"API: CRUD Reifegradmodelle, Matrix-Pflege, resolve-Kontext",
"Frontend: Admin Fähigkeitsmatrix (/admin/maturity-models)",
],
},
{
"version": "0.7.0",
"date": "2026-04-24",

View File

@ -12,6 +12,7 @@ import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
import AdminCatalogsPage from './pages/AdminCatalogsPage'
import AdminHierarchyPage from './pages/AdminHierarchyPage'
import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage'
import './app.css'
@ -192,6 +193,14 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/admin/maturity-models"
element={
<ProtectedRoute>
<AdminMaturityModelsPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/catalogs"
element={

View File

@ -1,5 +1,5 @@
import { NavLink, useLocation } from 'react-router-dom'
import { TreePine, FolderTree, Download } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3 } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal)
@ -10,6 +10,7 @@ export default function AdminPageNav() {
const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
]

View File

@ -0,0 +1,743 @@
import React, { useEffect, useState } from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
export default function AdminMaturityModelsPage() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const [models, setModels] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [detail, setDetail] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [focusAreas, setFocusAreas] = useState([])
const [styles, setStyles] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [allSkills, setAllSkills] = useState([])
const [newModel, setNewModel] = useState({
name: '',
level_count: 5,
focus_area_id: '',
style_direction_id: '',
target_group_id: '',
status: 'draft'
})
const [meta, setMeta] = useState(null)
const [levelCount, setLevelCount] = useState(5)
const [levelsForm, setLevelsForm] = useState([])
const [cellDraft, setCellDraft] = useState({})
const [skillToAdd, setSkillToAdd] = useState('')
useEffect(() => {
if (!isAdmin) return
let cancelled = false
;(async () => {
try {
const [m, fa, sd, tg, sk] = await Promise.all([
api.listMaturityModels({}),
api.listFocusAreas({}),
api.listStyleDirections({}),
api.listTargetGroups({}),
api.listSkills({ status: 'active' })
])
if (!cancelled) {
setModels(m)
setFocusAreas(fa)
setStyles(sd)
setTargetGroups(tg)
setAllSkills(sk)
}
} catch (e) {
if (!cancelled) setError(e.message)
}
})()
return () => { cancelled = true }
}, [isAdmin])
async function refreshModels() {
const m = await api.listMaturityModels({})
setModels(m)
}
async function selectModel(id) {
setSelectedId(id)
setLoading(true)
setError('')
try {
const d = await api.getMaturityModel(id)
setDetail(d)
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 ?? '',
status: d.status,
version: d.version || '1.0'
})
const lc = parseInt(String(d.level_count), 10)
setLevelCount(lc)
setLevelsForm(
(d.levels || []).map((l) => ({
level_number: l.level_number,
name: l.name,
description: l.description || '',
sort_order: l.sort_order
}))
)
const draft = {}
for (const r of d.skill_levels || []) {
draft[`${r.skill_id}-${r.level_number}`] = {
description: r.description || '',
observable_criteria: r.observable_criteria || ''
}
}
setCellDraft(draft)
} catch (e) {
setError(e.message)
setDetail(null)
setMeta(null)
} finally {
setLoading(false)
}
}
async function handleCreate(e) {
e.preventDefault()
setError('')
setSaving(true)
try {
const payload = {
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
}
const created = await api.createMaturityModel(payload)
await refreshModels()
await selectModel(created.id)
setNewModel({
name: '',
level_count: 5,
focus_area_id: '',
style_direction_id: '',
target_group_id: '',
status: 'draft'
})
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveMeta() {
if (!selectedId || !meta) return
setError('')
setSaving(true)
try {
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),
status: meta.status,
version: meta.version
})
await refreshModels()
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveLevels() {
if (!selectedId || !meta) return
setError('')
setSaving(true)
try {
await api.updateMaturityModel(selectedId, {
level_count: parseInt(String(levelCount), 10),
levels: levelsForm.map((l) => ({
level_number: parseInt(String(l.level_number), 10),
name: l.name,
description: l.description || null,
sort_order: parseInt(String(l.sort_order), 10)
}))
})
await refreshModels()
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleSaveMatrix() {
if (!selectedId || !detail) return
if (!detail.model_skills?.length) {
setError('Matrix: Zuerst Fähigkeiten hinzufügen.')
return
}
setError('')
setSaving(true)
try {
const entries = []
for (const ms of detail.model_skills) {
for (const lev of detail.levels || []) {
const key = `${ms.skill_id}-${lev.level_number}`
const d = cellDraft[key] || { description: '', observable_criteria: '' }
entries.push({
skill_id: ms.skill_id,
level_number: lev.level_number,
description: (d.description || '').trim(),
observable_criteria: (d.observable_criteria || '').trim() || null
})
}
}
await api.upsertMaturityModelSkillLevels(selectedId, { entries })
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleAddSkill() {
if (!selectedId || !skillToAdd) return
setError('')
setSaving(true)
try {
await api.addMaturityModelSkill(selectedId, { skill_id: parseInt(String(skillToAdd), 10) })
setSkillToAdd('')
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleRemoveSkill(skillId) {
if (!selectedId) return
if (!confirm('Fähigkeit aus dem Modell entfernen? Zelltexte werden gelöscht.')) return
setError('')
setSaving(true)
try {
await api.removeMaturityModelSkill(selectedId, skillId)
await selectModel(selectedId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function handleDeleteModel() {
if (!selectedId || !isSuperadmin) return
if (!confirm('Reifegradmodell dauerhaft löschen?')) return
setError('')
setSaving(true)
try {
await api.deleteMaturityModel(selectedId)
setSelectedId(null)
setDetail(null)
setMeta(null)
await refreshModels()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
function onLevelCountChange(raw) {
const next = Math.max(3, Math.min(10, parseInt(String(raw), 10) || 3))
setLevelCount(next)
setLevelsForm((prev) => {
const byNum = Object.fromEntries(prev.map((r) => [r.level_number, { ...r }]))
const rows = []
for (let i = 1; i <= next; i++) {
rows.push(
byNum[i] || {
level_number: i,
name: `Stufe ${i}`,
description: '',
sort_order: i
}
)
}
return rows
})
}
function setCell(skillId, levelNumber, field, value) {
const key = `${skillId}-${levelNumber}`
setCellDraft((prev) => ({
...prev,
[key]: {
description: field === 'description' ? value : (prev[key]?.description ?? ''),
observable_criteria:
field === 'observable_criteria' ? value : (prev[key]?.observable_criteria ?? '')
}
}))
}
if (!isAdmin) {
return <Navigate to="/" replace />
}
return (
<div style={{ padding: '20px', maxWidth: 1400, margin: '0 auto' }}>
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Admin: Fähigkeitsmatrix</h1>
<p style={{ color: 'var(--text2)', marginTop: '-8px' }}>
Reifegradmodelle mit Fokusbereich, Stilrichtung und Zielgruppe. Pro Modell: Stufen definieren,
Fähigkeiten zuordnen, Zelltexte pflegen.
</p>
{error ? (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
{error}
</div>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(240px, 320px) 1fr',
gap: 20,
alignItems: 'start'
}}
>
<div className="card" style={{ padding: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Modelle</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{models.map((m) => (
<li key={m.id} style={{ marginBottom: 8 }}>
<button
type="button"
className={selectedId === m.id ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ width: '100%', textAlign: 'left' }}
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>
<div style={{ fontSize: 11, opacity: 0.75 }}>{m.status} · {m.level_count} Stufen</div>
</button>
</li>
))}
</ul>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<h3 style={{ fontSize: '1rem' }}>Neues Modell</h3>
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label className="form-label">Name</label>
<input
className="form-input"
value={newModel.name}
onChange={(e) => setNewModel((s) => ({ ...s, name: e.target.value }))}
required
/>
<label className="form-label">Stufenanzahl (310)</label>
<input
className="form-input"
type="number"
min={3}
max={10}
value={newModel.level_count}
onChange={(e) => setNewModel((s) => ({ ...s, level_count: e.target.value }))}
/>
<label className="form-label">Status</label>
<select
className="form-input"
value={newModel.status}
onChange={(e) => setNewModel((s) => ({ ...s, status: e.target.value }))}
>
<option value="draft">Entwurf</option>
<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>
<button type="submit" className="btn btn-primary btn-full" disabled={saving}>
Anlegen
</button>
</form>
</div>
<div>
{loading ? <div className="spinner" /> : null}
{!loading && !detail && (
<div className="card" style={{ padding: 24, color: 'var(--text2)' }}>
Modell links wählen oder neu anlegen.
</div>
)}
{!loading && detail && meta && (
<>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Kontext &amp; Metadaten</h2>
<div className="form-row" style={{ display: 'grid', gap: 12 }}>
<div>
<label className="form-label">Name</label>
<input
className="form-input"
value={meta.name}
onChange={(e) => setMeta((m) => ({ ...m, name: e.target.value }))}
/>
</div>
<div>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={2}
value={meta.description}
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>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label">Status</label>
<select
className="form-input"
value={meta.status}
onChange={(e) => setMeta((m) => ({ ...m, status: e.target.value }))}
>
<option value="draft">Entwurf</option>
<option value="active">Aktiv</option>
<option value="archived">Archiviert</option>
</select>
</div>
<div>
<label className="form-label">Version</label>
<input
className="form-input"
value={meta.version}
onChange={(e) => setMeta((m) => ({ ...m, version: e.target.value }))}
/>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary" onClick={handleSaveMeta} disabled={saving}>
Metadaten speichern
</button>
{isSuperadmin ? (
<button type="button" className="btn btn-secondary" onClick={handleDeleteModel} disabled={saving}>
Modell löschen
</button>
) : null}
</div>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Stufen (Bezeichnungen)</h2>
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 0 }}>
Reihenfolge muss lückenlos 1N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
</p>
<div style={{ marginBottom: 12, maxWidth: 200 }}>
<label className="form-label">Stufenanzahl (310)</label>
<input
className="form-input"
type="number"
min={3}
max={10}
value={levelCount}
onChange={(e) => onLevelCountChange(e.target.value)}
/>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8 }}>Nr.</th>
<th style={{ textAlign: 'left', padding: 8 }}>Name</th>
<th style={{ textAlign: 'left', padding: 8 }}>Beschreibung</th>
<th style={{ textAlign: 'left', padding: 8 }}>Sort</th>
</tr>
</thead>
<tbody>
{levelsForm.map((row, idx) => (
<tr key={row.level_number} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: 8 }}>{row.level_number}</td>
<td style={{ padding: 8 }}>
<input
className="form-input"
value={row.name}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, name: e.target.value }
setLevelsForm(next)
}}
/>
</td>
<td style={{ padding: 8 }}>
<input
className="form-input"
value={row.description}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, description: e.target.value }
setLevelsForm(next)
}}
/>
</td>
<td style={{ padding: 8, width: 72 }}>
<input
className="form-input"
type="number"
value={row.sort_order}
onChange={(e) => {
const next = [...levelsForm]
next[idx] = { ...row, sort_order: e.target.value }
setLevelsForm(next)
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 12 }}
onClick={handleSaveLevels}
disabled={saving}
>
Stufen speichern
</button>
</div>
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Fähigkeiten im Modell</h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 220px' }}>
<label className="form-label">Fähigkeit hinzufügen</label>
<select
className="form-input"
value={skillToAdd}
onChange={(e) => setSkillToAdd(e.target.value)}
>
<option value=""> wählen </option>
{allSkills.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<button type="button" className="btn btn-primary" onClick={handleAddSkill} disabled={saving || !skillToAdd}>
Hinzufügen
</button>
</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>
))}
</ul>
</div>
<div className="card" style={{ padding: 16 }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Matrix (Zielbild je Stufe)</h2>
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 0 }}>
Leere Zellen werden beim Speichern aus der Datenbank entfernt. Beobachtungskriterien optional in
zweiter Zeile (nach Speichern mit Beschreibung).
</p>
<div style={{ overflow: 'auto', maxHeight: '70vh' }}>
<table style={{ borderCollapse: 'collapse', fontSize: 13, minWidth: 600 }}>
<thead>
<tr>
<th style={{ padding: 8, border: '1px solid var(--border)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
Fähigkeit
</th>
{(detail.levels || []).map((l) => (
<th
key={l.level_number}
style={{ padding: 8, border: '1px solid var(--border)', minWidth: 160, background: 'var(--surface2)' }}
>
{l.level_number}. {l.name}
</th>
))}
</tr>
</thead>
<tbody>
{(detail.model_skills || []).map((ms) => (
<tr key={ms.skill_id}>
<td
style={{
padding: 8,
border: '1px solid var(--border)',
position: 'sticky',
left: 0,
background: 'var(--surface)',
fontWeight: 600,
whiteSpace: 'nowrap'
}}
>
{ms.skill_name}
</td>
{(detail.levels || []).map((l) => {
const key = `${ms.skill_id}-${l.level_number}`
const d = cellDraft[key] || { description: '', observable_criteria: '' }
return (
<td key={l.level_number} style={{ padding: 6, border: '1px solid var(--border)', verticalAlign: 'top' }}>
<textarea
className="form-input"
rows={3}
placeholder="Zielbild / Erwartung"
value={d.description}
onChange={(e) => setCell(ms.skill_id, l.level_number, 'description', e.target.value)}
style={{ fontSize: 12, width: '100%', minWidth: 140 }}
/>
<textarea
className="form-input"
rows={2}
placeholder="Beobachtungskriterien (optional)"
value={d.observable_criteria}
onChange={(e) => setCell(ms.skill_id, l.level_number, 'observable_criteria', e.target.value)}
style={{ fontSize: 11, width: '100%', minWidth: 140, marginTop: 4 }}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 12 }}
onClick={handleSaveMatrix}
disabled={saving || !(detail.model_skills || []).length}
>
Matrix speichern
</button>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@ -262,6 +262,64 @@ export async function getAdminHierarchy() {
return request('/api/admin/hierarchy')
}
// ============================================================================
// Reifegradmodelle / Fähigkeitsmatrix
// ============================================================================
export async function listMaturityModels(filters = {}) {
const query = new URLSearchParams(
Object.entries(filters).filter(([, v]) => v !== undefined && v !== null && v !== '')
).toString()
return request(`/api/maturity-models${query ? '?' + query : ''}`)
}
export async function resolveMaturityModel(filters = {}) {
const query = new URLSearchParams(
Object.entries(filters).filter(([, v]) => v !== undefined && v !== null && v !== '')
).toString()
return request(`/api/maturity-models/resolve${query ? '?' + query : ''}`)
}
export async function getMaturityModel(id) {
return request(`/api/maturity-models/${id}`)
}
export async function createMaturityModel(data) {
return request('/api/maturity-models', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function updateMaturityModel(id, data) {
return request(`/api/maturity-models/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteMaturityModel(id) {
return request(`/api/maturity-models/${id}`, { method: 'DELETE' })
}
export async function addMaturityModelSkill(modelId, data) {
return request(`/api/maturity-models/${modelId}/skills`, {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function removeMaturityModelSkill(modelId, skillId) {
return request(`/api/maturity-models/${modelId}/skills/${skillId}`, { method: 'DELETE' })
}
export async function upsertMaturityModelSkillLevels(modelId, data) {
return request(`/api/maturity-models/${modelId}/skill-levels`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
// Style Directions (formerly Training Styles)
export async function listStyleDirections(filters = {}) {
const query = new URLSearchParams(filters).toString()
@ -587,6 +645,15 @@ export const api = {
createStyleDirectionTargetGroup,
updateStyleDirectionTargetGroup,
deleteStyleDirectionTargetGroup,
listMaturityModels,
resolveMaturityModel,
getMaturityModel,
createMaturityModel,
updateMaturityModel,
deleteMaturityModel,
addMaturityModelSkill,
removeMaturityModelSkill,
upsertMaturityModelSkillLevels,
getStyleDirectionsHierarchy,
listTrainerContexts,
createTrainerContext,

70
skills_mapping_clean.csv Normal file
View File

@ -0,0 +1,70 @@
skill_name,sub_category,main_category,focus_areas
Dachi Waza,Kihon,karate,karate
Uke Waza,Kihon,karate,karate
Zuki Waza,Kihon,karate,karate
Uchi Waza,Kihon,karate,karate
Geri Waza,Kihon,karate,karate
Nage Waza,Kihon,karate,karate
Nukite Waza,Kihon,karate,karate
Ken Waza,Kihon,karate,karate
Hüfteinsatz,Kihon,karate,karate
Kime,Kihon,karate,karate
Beinarbeit,Kumite,karate,karate
Distanzkontrolle,Kumite,karate,karate
Angriff,Kumite,karate,karate
Abwehr Konter,Kumite,karate,karate
Präzision,Kumite,karate,karate
Antizipation,Kumite,karate,karate
Timing,Kumite,karate,karate
Taktik,Kumite,karate,karate
Fokus,Kumite,karate,karate
Mentale Stärke,Kumite,karate,karate
Technik Kombination,Kata,karate,karate
Kata Ablauf,Kata,karate,karate
Bunkai,Kata,karate,karate
Oyo,Kata,karate,karate
Henka,Kata,karate,karate
Kakushi,Kata,karate,karate
Kata Atmung,Kata,karate,karate
Kata Rhythmus,Kata,karate,karate
Gefahrenbewustsein,Selbstverteidigung,karate,karate
Selbstbehauptung,Selbstverteidigung,karate,karate
Selbstschutz,Selbstverteidigung,karate,karate
Gefahrenabwehr,Selbstverteidigung,karate,karate
Orientierung,Koordination,allgemeine,universal
Differenzierung,Koordination,allgemeine,universal
Kopplung,Koordination,allgemeine,universal
Gleichgewicht,Koordination,allgemeine,universal
Rhythmisierung,Koordination,allgemeine,universal
Reaktion,Koordination,allgemeine,universal
Umstellung,Koordination,allgemeine,universal
Maximalkraft,Kondition,allgemeine,universal
Schnellkraft,Kondition,allgemeine,universal
Reaktivkraft,Kondition,allgemeine,universal
Kraftausdauer,Kondition,allgemeine,universal
Muskelaufbau,Kondition,allgemeine,universal
Reaktionsschnelligkeit,Kondition,allgemeine,universal
Bewegungsschnelligkeit,Kondition,allgemeine,universal
Handlungsschnelligkeit,Kondition,allgemeine,universal
Schnelligkeitsausdauer,Kondition,allgemeine,universal
Grundlagenausdauer,Kondition,allgemeine,universal
Aerobe Ausdauer,Kondition,allgemeine,universal
Anaerobe Ausdauer,Kondition,allgemeine,universal
Regenerationsfähigkeit,Kondition,allgemeine,universal
Ermüdungswiderstandsfähigkeit,Kondition,allgemeine,universal
Flexibilität,Kondition,allgemeine,universal
Aufmerksamkeit,Kognition,allgemeine,universal
Wahrnehmung,Kognition,allgemeine,universal
Urteilsvermögen,Kognition,allgemeine,universal
Merkfähigkeit,Kognition,allgemeine,universal
Lernfähigkeit,Kognition,allgemeine,universal
Deeskalation,Soziale Fähigkeiten,allgemeine,universal
Selbstdisziplin,Soziale Fähigkeiten,allgemeine,universal
Toleranz,Soziale Fähigkeiten,allgemeine,universal
Fairness,Soziale Fähigkeiten,allgemeine,universal
Selbstvertrauen,Psychische Fähigkeiten,allgemeine,universal
Konzentration,Psychische Fähigkeiten,allgemeine,universal
Emotionale Kontrolle,Psychische Fähigkeiten,allgemeine,universal
Motivation,Psychische Fähigkeiten,allgemeine,universal
Stressresistenz,Psychische Fähigkeiten,allgemeine,universal
Stressregulation,Psychische Fähigkeiten,allgemeine,universal
1 skill_name sub_category main_category focus_areas
2 Dachi Waza Kihon karate karate
3 Uke Waza Kihon karate karate
4 Zuki Waza Kihon karate karate
5 Uchi Waza Kihon karate karate
6 Geri Waza Kihon karate karate
7 Nage Waza Kihon karate karate
8 Nukite Waza Kihon karate karate
9 Ken Waza Kihon karate karate
10 Hüfteinsatz Kihon karate karate
11 Kime Kihon karate karate
12 Beinarbeit Kumite karate karate
13 Distanzkontrolle Kumite karate karate
14 Angriff Kumite karate karate
15 Abwehr Konter Kumite karate karate
16 Präzision Kumite karate karate
17 Antizipation Kumite karate karate
18 Timing Kumite karate karate
19 Taktik Kumite karate karate
20 Fokus Kumite karate karate
21 Mentale Stärke Kumite karate karate
22 Technik Kombination Kata karate karate
23 Kata Ablauf Kata karate karate
24 Bunkai Kata karate karate
25 Oyo Kata karate karate
26 Henka Kata karate karate
27 Kakushi Kata karate karate
28 Kata Atmung Kata karate karate
29 Kata Rhythmus Kata karate karate
30 Gefahrenbewustsein Selbstverteidigung karate karate
31 Selbstbehauptung Selbstverteidigung karate karate
32 Selbstschutz Selbstverteidigung karate karate
33 Gefahrenabwehr Selbstverteidigung karate karate
34 Orientierung Koordination allgemeine universal
35 Differenzierung Koordination allgemeine universal
36 Kopplung Koordination allgemeine universal
37 Gleichgewicht Koordination allgemeine universal
38 Rhythmisierung Koordination allgemeine universal
39 Reaktion Koordination allgemeine universal
40 Umstellung Koordination allgemeine universal
41 Maximalkraft Kondition allgemeine universal
42 Schnellkraft Kondition allgemeine universal
43 Reaktivkraft Kondition allgemeine universal
44 Kraftausdauer Kondition allgemeine universal
45 Muskelaufbau Kondition allgemeine universal
46 Reaktionsschnelligkeit Kondition allgemeine universal
47 Bewegungsschnelligkeit Kondition allgemeine universal
48 Handlungsschnelligkeit Kondition allgemeine universal
49 Schnelligkeitsausdauer Kondition allgemeine universal
50 Grundlagenausdauer Kondition allgemeine universal
51 Aerobe Ausdauer Kondition allgemeine universal
52 Anaerobe Ausdauer Kondition allgemeine universal
53 Regenerationsfähigkeit Kondition allgemeine universal
54 Ermüdungswiderstandsfähigkeit Kondition allgemeine universal
55 Flexibilität Kondition allgemeine universal
56 Aufmerksamkeit Kognition allgemeine universal
57 Wahrnehmung Kognition allgemeine universal
58 Urteilsvermögen Kognition allgemeine universal
59 Merkfähigkeit Kognition allgemeine universal
60 Lernfähigkeit Kognition allgemeine universal
61 Deeskalation Soziale Fähigkeiten allgemeine universal
62 Selbstdisziplin Soziale Fähigkeiten allgemeine universal
63 Toleranz Soziale Fähigkeiten allgemeine universal
64 Fairness Soziale Fähigkeiten allgemeine universal
65 Selbstvertrauen Psychische Fähigkeiten allgemeine universal
66 Konzentration Psychische Fähigkeiten allgemeine universal
67 Emotionale Kontrolle Psychische Fähigkeiten allgemeine universal
68 Motivation Psychische Fähigkeiten allgemeine universal
69 Stressresistenz Psychische Fähigkeiten allgemeine universal
70 Stressregulation Psychische Fähigkeiten allgemeine universal

View File