feat: add maturity models functionality and update version
- 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:
parent
5626be792f
commit
5277f4f4cf
|
|
@ -70,7 +70,7 @@ def read_root():
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register routers
|
# 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(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -79,6 +79,7 @@ app.include_router(clubs.router)
|
||||||
app.include_router(skills.router)
|
app.include_router(skills.router)
|
||||||
app.include_router(training_planning.router)
|
app.include_router(training_planning.router)
|
||||||
app.include_router(catalogs.router)
|
app.include_router(catalogs.router)
|
||||||
|
app.include_router(maturity_models.router)
|
||||||
app.include_router(import_wiki.router)
|
app.include_router(import_wiki.router)
|
||||||
app.include_router(import_wiki_admin.router)
|
app.include_router(import_wiki_admin.router)
|
||||||
|
|
||||||
|
|
|
||||||
132
backend/migrations/024_maturity_models.sql
Normal file
132
backend/migrations/024_maturity_models.sql
Normal 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();
|
||||||
483
backend/routers/maturity_models.py
Normal file
483
backend/routers/maturity_models.py
Normal 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)
|
||||||
141
backend/scripts/generate_migration_023.py
Normal file
141
backend/scripts/generate_migration_023.py
Normal 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;
|
||||||
|
""")
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.7.0"
|
APP_VERSION = "0.7.1"
|
||||||
BUILD_DATE = "2026-04-24"
|
BUILD_DATE = "2026-04-27"
|
||||||
DB_SCHEMA_VERSION = "20260424002"
|
DB_SCHEMA_VERSION = "20260427024"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.0.0",
|
"auth": "1.0.0",
|
||||||
|
|
@ -19,9 +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
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.7.0",
|
||||||
"date": "2026-04-24",
|
"date": "2026-04-24",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import SkillsPage from './pages/SkillsPage'
|
||||||
import TrainingPlanningPage from './pages/TrainingPlanningPage'
|
import TrainingPlanningPage from './pages/TrainingPlanningPage'
|
||||||
import AdminCatalogsPage from './pages/AdminCatalogsPage'
|
import AdminCatalogsPage from './pages/AdminCatalogsPage'
|
||||||
import AdminHierarchyPage from './pages/AdminHierarchyPage'
|
import AdminHierarchyPage from './pages/AdminHierarchyPage'
|
||||||
|
import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
|
||||||
import TrainerContextsPage from './pages/TrainerContextsPage'
|
import TrainerContextsPage from './pages/TrainerContextsPage'
|
||||||
import MediaWikiImportPage from './pages/MediaWikiImportPage'
|
import MediaWikiImportPage from './pages/MediaWikiImportPage'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
@ -192,6 +193,14 @@ function AppRoutes() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/maturity-models"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminMaturityModelsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/catalogs"
|
path="/admin/catalogs"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
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)
|
* Admin-Seiten-Navigation (horizontal)
|
||||||
|
|
@ -10,6 +10,7 @@ export default function AdminPageNav() {
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
{ 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/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
|
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
|
||||||
]
|
]
|
||||||
|
|
|
||||||
743
frontend/src/pages/AdminMaturityModelsPage.jsx
Normal file
743
frontend/src/pages/AdminMaturityModelsPage.jsx
Normal 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 (3–10)</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 & 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 1…N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginBottom: 12, maxWidth: 200 }}>
|
||||||
|
<label className="form-label">Stufenanzahl (3–10)</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -262,6 +262,64 @@ export async function getAdminHierarchy() {
|
||||||
return request('/api/admin/hierarchy')
|
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)
|
// Style Directions (formerly Training Styles)
|
||||||
export async function listStyleDirections(filters = {}) {
|
export async function listStyleDirections(filters = {}) {
|
||||||
const query = new URLSearchParams(filters).toString()
|
const query = new URLSearchParams(filters).toString()
|
||||||
|
|
@ -587,6 +645,15 @@ export const api = {
|
||||||
createStyleDirectionTargetGroup,
|
createStyleDirectionTargetGroup,
|
||||||
updateStyleDirectionTargetGroup,
|
updateStyleDirectionTargetGroup,
|
||||||
deleteStyleDirectionTargetGroup,
|
deleteStyleDirectionTargetGroup,
|
||||||
|
listMaturityModels,
|
||||||
|
resolveMaturityModel,
|
||||||
|
getMaturityModel,
|
||||||
|
createMaturityModel,
|
||||||
|
updateMaturityModel,
|
||||||
|
deleteMaturityModel,
|
||||||
|
addMaturityModelSkill,
|
||||||
|
removeMaturityModelSkill,
|
||||||
|
upsertMaturityModelSkillLevels,
|
||||||
getStyleDirectionsHierarchy,
|
getStyleDirectionsHierarchy,
|
||||||
listTrainerContexts,
|
listTrainerContexts,
|
||||||
createTrainerContext,
|
createTrainerContext,
|
||||||
|
|
|
||||||
70
skills_mapping_clean.csv
Normal file
70
skills_mapping_clean.csv
Normal 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
|
||||||
|
0
skills_mapping_clean_utf8.csv
Normal file
0
skills_mapping_clean_utf8.csv
Normal file
|
|
Loading…
Reference in New Issue
Block a user