feat: update version to 0.7.6 and add matrix stack bundle functionality
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m54s

- Incremented application version to 0.7.6 and updated maturity models version to 1.4.0.
- Introduced new API endpoints for exporting and importing matrix stack bundles, enhancing the capabilities for managing maturity models and context bindings.
- Updated frontend components to support the new matrix stack export and import features, including UI elements for stack management.
- Documented changes in the changelog for version 0.7.6, detailing the new matrix stack functionality and its usage.
This commit is contained in:
Lars 2026-04-27 13:13:36 +02:00
parent 863535aa26
commit 2452b5e2e8
7 changed files with 998 additions and 359 deletions

View File

@ -1,348 +1,11 @@
# Shinkan Jinkendo - Session Handover
# Session Handover (Verweis)
**Datum:** 2026-04-22
**Kontext:** App-Entwicklung nach Login-Setup - Kern-Features implementieren
**Dieses Dokument ist veraltet.** Der aktuelle Entwicklungsstand und die Handover-Basis stehen hier:
👉 **`docs/HANDOVER.md`** (Projektroot: `c:\Dev\shinkan-jinkendo\docs\HANDOVER.md`)
Dort: Fähigkeits-/Reifegrad-Stand (Bindings, Resolve, Export/Import, Matrix-Stack), Verweise auf `.claude/docs` für Anforderungen, und **nächste Priorität Übungen (UI/CRUD/Medien)**.
---
## 🎯 Projekt-Mission
**Shinkan Jinkendo** (真観) - Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung.
**NICHT:** Persönliches Athleten-Tracking (das ist Mitai)
**SONDERN:** Trainer verwalten Vereine, Gruppen, Übungen, Training
---
## 📚 Pflicht-Dokumentation (ZUERST LESEN!)
### Fachliches Design
```
c:\Dev\shinkan-jinkendo\.claude\docs\working\SHINKAN_PROJECT_SETUP.md
```
**Enthält:**
- Domain Model (Clubs, Groups, Skills, Methods, Exercises)
- MVP Features
- Datenbank-Schema
- User Stories
### Technisches Setup (bereits erstellt von vorheriger Session)
Siehe: `SHINKAN_PROJECT_SETUP.md` - Abschnitte:
- Tech Stack (React 18, FastAPI, PostgreSQL 16, Docker)
- Ports: Dev 3098/8098, Prod 3003/8003
- Deployment: Gitea Actions auf develop Branch
### Referenz-Codebase
```
c:\Dev\mitai-jinkendo\
```
**Nutzen für Standards:**
- Router-Struktur (`backend/routers/`)
- Frontend-Patterns (`frontend/src/pages/`)
- CSS-System (`frontend/src/app.css`)
**⚠️ WARNUNG:** Mitai-Code NICHT blind kopieren!
- Mitai hat AI-Features, Export, komplexes Membership
- Shinkan ist einfacher, andere DB-Spalten
- **Immer Schema prüfen** vor Copy/Paste!
---
## 🔧 Aktueller Stand
### ✅ Was funktioniert
- **Login/Auth:** lars@stommer.com / 12345678 (admin, premium)
- **Backend API:** http://192.168.2.49:8098
- **Frontend:** http://192.168.2.49:3098
- **Datenbank:**
- profiles, sessions (Auth)
- clubs, divisions, training_groups (Organisation)
- skills (12 Einträge), training_methods (8 Einträge) - bereits geseedet!
- exercises, exercise_skills (leer)
### ❌ Bekannte Probleme
**1. Navigation erscheint nicht im Browser**
- Code existiert: `frontend/src/components/Navigation.jsx`
- Routes hinzugefügt: ProfilePage, ExercisesPage, ClubsPage
- Deploy erfolgt (Commit c4b1b54)
- **Problem:** Nicht im Browser sichtbar - vermutlich Browser-Cache oder Build-Issue
**2. Sessions funktionieren nicht**
- User muss sich ständig neu einloggen
- Backend-Fix gepusht (Commit 08326bd) - Mitai-Spalten entfernt
- **Problem:** Unklar ob Fix deployed wurde
**Erste Aufgabe:** Diese beiden Issues fixen, DANN Features bauen!
### 📁 Code-Struktur
```
c:\Dev\shinkan-jinkendo\
├── backend/
│ ├── main.py (FastAPI setup, Router registration)
│ ├── auth.py (Session management, require_auth)
│ ├── db.py (PostgreSQL connection pool)
│ ├── models.py (Pydantic models)
│ ├── routers/
│ │ ├── auth.py (Login, Register, Logout)
│ │ └── profiles.py (User profile CRUD)
│ └── migrations/
│ ├── 001_auth_membership.sql
│ ├── 002_organization.sql
│ ├── 003_catalogs.sql (Skills + Methods seeded!)
│ └── 004_add_auth_columns.sql
├── frontend/src/
│ ├── App.jsx (Routing + ProtectedRoute)
│ ├── context/
│ │ └── AuthContext.jsx (User state, login/logout)
│ ├── components/
│ │ └── Navigation.jsx (Header-Menü - NICHT SICHTBAR)
│ ├── pages/
│ │ ├── LoginPage.jsx (✅ funktioniert)
│ │ ├── Dashboard.jsx (✅ zeigt Welcome)
│ │ ├── ProfilePage.jsx (NEU - Platzhalter)
│ │ ├── ExercisesPage.jsx (NEU - leer)
│ │ └── ClubsPage.jsx (NEU - leer)
│ └── utils/
│ └── api.js (Zentrale API-Client mit Token-Injektion)
└── .claude/docs/
└── working/
└── SHINKAN_PROJECT_SETUP.md (LESEN!)
```
---
## 🚀 Was JETZT gebaut werden muss (MVP Scope)
### Phase 1: Core CRUD (Priorität)
**1. Übungsverwaltung** (Kernobjekt) - 2-3h
- `backend/routers/exercises.py`
- GET /exercises (Liste mit Filter)
- POST /exercises (Create)
- GET /exercises/{id} (Detail)
- PUT /exercises/{id} (Update)
- DELETE /exercises/{id}
- `frontend/src/pages/ExercisesPage.jsx`
- Liste (Tabelle oder Cards)
- Create-Modal/Form
- Edit inline oder Modal
- Delete mit Confirm
**Schema:** Siehe `migrations/001_auth_membership.sql` - Tabelle `exercises`
**Felder:**
- title, summary, goal, execution, preparation, trainer_notes
- equipment (JSONB array), duration_min/max, group_size_min/max
- age_groups (JSONB), focus_area, secondary_areas (JSONB)
- training_character, visibility (private/club/public)
- primary_method_id, secondary_method_ids (JSONB)
**Referenz:** Mitai hat ähnliche CRUD-Pattern in `routers/weight.py`, `routers/activity.py`
---
**2. Vereinsverwaltung** - 1-2h
- `backend/routers/clubs.py`
- CRUD für clubs
- CRUD für divisions (optional)
- `frontend/src/pages/ClubsPage.jsx`
**Schema:** Siehe `migrations/002_organization.sql`
---
**3. Gruppenverwaltung** - 1-2h
- `backend/routers/groups.py`
- CRUD für training_groups
- Zuordnung zu Clubs
- Trainer-Zuordnung (trainer_id, co_trainer_ids)
- `frontend/src/pages/GroupsPage.jsx`
---
### Phase 2: Kataloge & Zuordnungen (Später)
**4. Skills & Methods anzeigen** - 0.5h
- `/skills` Route (Read-only, bereits geseedet)
- `/methods` Route (Read-only, bereits geseedet)
**5. Übungen ↔ Skills verknüpfen** - 1h
- M:N Relationship über `exercise_skills` Tabelle
- UI: Multi-Select für Skills beim Übung-Erstellen
---
## 🔍 Debugging-Checkliste (Vor neuen Features!)
### Problem: Navigation nicht sichtbar
```bash
# SSH zum Server
ssh lars@192.168.2.49
# Frontend Container prüfen
docker exec dev-shinkan-ui ls -la /usr/share/nginx/html/assets/
# Erwartung: Datei index-CYNK--85.js oder neuer
# Prüfen ob Navigation.jsx im Bundle
docker exec dev-shinkan-ui grep -o "Navigation\|Übungen" /usr/share/nginx/html/assets/index-*.js | head -5
# Falls nicht: Frontend neu bauen
cd /home/lars/docker/shinkan-dev
git pull
docker compose -f docker-compose.dev-env.yml build --no-cache frontend
docker compose -f docker-compose.dev-env.yml up -d
```
### Problem: Sessions funktionieren nicht
```bash
# Backend Logs prüfen
docker logs dev-shinkan-api --tail 50 | grep -E "error|Error|column.*does not exist"
# Erwartung: KEINE "column p.ai_enabled does not exist" Fehler mehr
# Session-Test
curl -X POST http://192.168.2.49:8098/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"lars@stommer.com","password":"12345678"}' | python3 -m json.tool
# Erwartung: {"token": "...", "role": "admin", ...}
```
---
## 🛠️ Entwicklungs-Workflow
### 1. Feature implementieren (Lokal)
```bash
# Backend
cd c:\Dev\shinkan-jinkendo
# Router erstellen: backend/routers/exercises.py
# Models ergänzen: backend/models.py
# Router registrieren: backend/main.py
# Frontend
# Seite erstellen: frontend/src/pages/ExercisesPage.jsx
# API-Funktionen: frontend/src/utils/api.js
# Route in App.jsx
```
### 2. Committen & Pushen
```bash
git add -A
git commit -m "feat: Exercise CRUD implementation"
git push origin develop
```
### 3. Gitea Action deployt automatisch
- Kein manuelles `docker compose` mehr!
- Warte 1-2 Min
- Prüfe: http://192.168.2.49:3098
### 4. Testen im Browser
- Login: lars@stommer.com / 12345678
- Feature testen
- Console prüfen (F12) bei Fehlern
---
## 📊 Datenbank-Zugriff
```bash
# SSH
ssh lars@192.168.2.49
# PostgreSQL CLI
docker exec -it dev-shinkan-postgres psql -U shinkan_dev -d shinkan_dev
# Hilfreiche Queries
\d profiles # Schema anzeigen
\d exercises # Exercise-Schema
SELECT * FROM skills; # Alle Skills (12 Einträge)
SELECT * FROM training_methods; # Alle Methods (8 Einträge)
```
---
## ⚠️ Lessons Learned (Vermeide diese Fehler!)
### 1. Mitai vs Shinkan Schema
**Problem:** Auth.py von Mitai kopiert, aber Spalten fehlen in Shinkan
**Fehler:**
- `ai_enabled`, `ai_limit_day`, `export_enabled` - existieren nur in Mitai
- `auth_type`, `verification_expires`, `trial_ends_at` - mussten hinzugefügt werden
- `created` vs `created_at` - unterschiedliche Spaltennamen
**Regel:** Vor Mitai-Code kopieren → **Schema prüfen**!
### 2. Frontend-Cache
**Problem:** Code deployed aber nicht sichtbar im Browser
**Lösung:**
- Browser-Cache leeren (Ctrl+Shift+R)
- Oder `docker compose build --no-cache frontend`
### 3. Gitea Actions nicht mit manuellen Deploys mischen
**Problem:** Container-Namens-Konflikte
**Regel:** Nur `git push` → Gitea macht den Rest
---
## 📞 Server-Info
| System | Wert |
|--------|------|
| **Pi IP** | 192.168.2.49 |
| **Gitea** | http://192.168.2.144:3000/Lars/shinkan-jinkendo |
| **Dev Frontend** | http://192.168.2.49:3098 |
| **Dev Backend** | http://192.168.2.49:8098 |
| **Deploy Path** | /home/lars/docker/shinkan-dev |
| **DB Name** | shinkan_dev |
| **DB User** | shinkan_dev |
| **DB Pass** | dev_password |
---
## 🎯 Erfolgs-Kriterien MVP
Nach MVP soll User können:
1. ✅ Login/Logout (erledigt)
2. ⏳ Übungen verwalten (CRUD)
3. ⏳ Vereine anlegen
4. ⏳ Trainingsgruppen anlegen
5. ⏳ Übungen Skills zuordnen
6. ⏳ Skills/Methods katalog ansehen
**Geschätzte Zeit:** 6-8h für Punkte 2-6
---
## 🚦 Start-Kommando für neue Session
```
Ich übernehme die Entwicklung von Shinkan Jinkendo.
Kontext gelesen: HANDOVER_NEXT_SESSION.md
Erste Schritte:
1. Navigation + Sessions debuggen
2. Dann: Exercise CRUD implementieren
Bitte bestätige dass du bereit bist und zeige mir den aktuellen Status (Container, letzte Commits, bekannte Probleme).
```
---
**Viel Erfolg!** 🥋
*Historischer Inhalt aus April 2026 wurde durch `docs/HANDOVER.md` ersetzt.*

View File

@ -70,7 +70,7 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, maturity_models, import_wiki, import_wiki_admin
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
app.include_router(auth.router)
app.include_router(profiles.router)
@ -80,6 +80,7 @@ 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(matrix_stack_bundle.router)
app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)

View File

@ -0,0 +1,770 @@
"""
Vollständiger Export/Import: Fähigkeitskatalog (Haupt-/Unterkategorien, Skills, Level-Definitionen),
Reifegradmodelle inkl. Kontext-M:N und Kontext-Bindings.
Ziel: Test Prod; IDs werden über Slugs/Namen aufgelöst, nicht 1:1 übernommen.
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from auth import require_auth
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin/matrix-stack", tags=["admin_matrix_stack"])
KIND_V1 = "shinkan.matrix_stack.v1"
def _require_admin(session: dict) -> None:
role = session.get("role")
if role not in ("admin", "superadmin"):
raise HTTPException(403, "Nur Administratoren")
def _keywords_param(raw: Any) -> Any:
if raw is None:
return None
if isinstance(raw, str):
return raw
return json.dumps(raw)
def _slugify_label(text: str) -> str:
t = (text or "").strip().lower()
t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE)
t = re.sub(r"_+", "_", t).strip("_")
return (t[:48] or "gruppe")
def _jsonable(val: Any) -> Any:
if val is None:
return None
if hasattr(val, "isoformat"):
try:
return val.isoformat()
except Exception:
return str(val)
return val
def _sort_categories_topo(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
by_id = {int(r["id"]): r for r in rows}
children: Dict[Optional[int], List[int]] = {}
for r in rows:
pid = r.get("parent_category_id")
pk = int(pid) if pid is not None else None
children.setdefault(pk, []).append(int(r["id"]))
out: List[Dict[str, Any]] = []
seen = set()
def visit(cid: int) -> None:
if cid in seen:
return
seen.add(cid)
out.append(by_id[cid])
for ch in sorted(children.get(cid, [])):
visit(ch)
roots = sorted(children.get(None, []))
for root_id in roots:
visit(root_id)
# Zyklen / verwaiste Knoten
for r in rows:
rid = int(r["id"])
if rid not in seen:
visit(rid)
return out
def _catalog_name_maps(cur) -> Tuple[Dict[str, int], Dict[str, int], Dict[str, int], Dict[str, int]]:
cur.execute("SELECT id, name FROM focus_areas")
fa = {r["name"].strip(): int(r["id"]) for r in cur.fetchall() if r.get("name")}
cur.execute("SELECT id, name FROM style_directions")
sd = {r["name"].strip(): int(r["id"]) for r in cur.fetchall() if r.get("name")}
cur.execute("SELECT id, name FROM training_types")
tt = {r["name"].strip(): int(r["id"]) for r in cur.fetchall() if r.get("name")}
cur.execute("SELECT id, name FROM target_groups")
tg = {r["name"].strip(): int(r["id"]) for r in cur.fetchall() if r.get("name")}
return fa, sd, tt, tg
def export_matrix_stack_v1(session: dict = Depends(require_auth)) -> JSONResponse:
_require_admin(session)
export_uid = str(uuid.uuid4())
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT * FROM skill_main_categories
ORDER BY sort_order NULLS LAST, name
"""
)
main_cats = [r2d(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM skill_categories ORDER BY sort_order NULLS LAST, name")
raw_cats = [r2d(r) for r in cur.fetchall()]
skill_categories = _sort_categories_topo(raw_cats)
cur.execute(
"""
SELECT s.*,
mc.slug AS _export_main_category_slug,
sc.slug AS _export_category_slug,
pfa.name AS _export_primary_focus_area_name
FROM skills s
LEFT JOIN skill_main_categories mc ON s.main_category_id = mc.id
LEFT JOIN skill_categories sc ON s.category_id = sc.id
LEFT JOIN focus_areas pfa ON s.primary_focus_area_id = pfa.id
ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, s.sort_order NULLS LAST, s.name
"""
)
skills = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT sld.* FROM skill_level_definitions sld
JOIN skills s ON s.id = sld.skill_id
ORDER BY s.name, sld.level
"""
)
skill_level_definitions = [r2d(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM maturity_models ORDER BY id")
maturity_models_raw = [r2d(r) for r in cur.fetchall()]
maturity_blocks: List[Dict[str, Any]] = []
for m in maturity_models_raw:
mid = int(m["id"])
cur.execute(
"""
SELECT fa.name, mfa.is_primary
FROM maturity_model_focus_areas mfa
JOIN focus_areas fa ON fa.id = mfa.focus_area_id
WHERE mfa.maturity_model_id = %s
ORDER BY mfa.is_primary DESC NULLS LAST, fa.sort_order, fa.name
""",
(mid,),
)
fa_rows = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT sd.name, msd.is_primary
FROM maturity_model_style_directions msd
JOIN style_directions sd ON sd.id = msd.style_direction_id
WHERE msd.maturity_model_id = %s
ORDER BY msd.is_primary DESC NULLS LAST, sd.name
""",
(mid,),
)
sd_rows = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT tg.name, mtg.is_primary
FROM maturity_model_target_groups mtg
JOIN target_groups tg ON tg.id = mtg.target_group_id
WHERE mtg.maturity_model_id = %s
ORDER BY mtg.is_primary DESC NULLS LAST, tg.name
""",
(mid,),
)
tg_rows = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT * FROM model_levels
WHERE maturity_model_id = %s
ORDER BY sort_order ASC, level_number ASC
""",
(mid,),
)
levels = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT ms.skill_id, ms.sort_order, ms.relevance
FROM model_skills ms
WHERE ms.maturity_model_id = %s
ORDER BY ms.sort_order ASC, ms.id ASC
""",
(mid,),
)
model_skills = [r2d(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT msl.skill_id, msl.level_number, msl.description, msl.observable_criteria,
msl.example_exercise_hints, msl.ai_generated
FROM model_skill_levels msl
WHERE msl.maturity_model_id = %s
ORDER BY msl.skill_id, msl.level_number
""",
(mid,),
)
skill_levels = [r2d(r) for r in cur.fetchall()]
row = {k: _jsonable(v) for k, v in m.items()}
for drop in ("created_at", "updated_at"):
row.pop(drop, None)
maturity_blocks.append(
{
"source_id": mid,
"model": row,
"legacy_focus_areas": fa_rows,
"legacy_style_directions": sd_rows,
"legacy_target_groups": tg_rows,
"levels": levels,
"model_skills": model_skills,
"skill_levels": skill_levels,
}
)
cur.execute(
"""
SELECT b.maturity_model_id AS maturity_model_source_id,
fa.name AS focus_area_name,
sd.name AS style_direction_name,
tt.name AS training_type_name
FROM maturity_model_context_bindings b
JOIN focus_areas fa ON fa.id = b.focus_area_id
LEFT JOIN style_directions sd ON sd.id = b.style_direction_id
LEFT JOIN training_types tt ON tt.id = b.training_type_id
ORDER BY fa.sort_order, fa.name, sd.name NULLS LAST, tt.name NULLS LAST
"""
)
context_bindings = [r2d(r) for r in cur.fetchall()]
bundle = {
"kind": KIND_V1,
"export_version": 1,
"bundle_export_id": export_uid,
"exported_at": datetime.now(timezone.utc).isoformat(),
"skill_main_categories": main_cats,
"skill_categories": skill_categories,
"skills": skills,
"skill_level_definitions": skill_level_definitions,
"maturity_models": maturity_blocks,
"context_bindings": context_bindings,
}
return JSONResponse(
content=jsonable_encoder(bundle),
headers={
"Content-Disposition": f'attachment; filename="matrix-stack-{export_uid[:8]}.json"'
},
)
def _upsert_main_category(cur, row: Dict[str, Any]) -> int:
name = (row.get("name") or "").strip()
slug = (row.get("slug") or "").strip() or _slugify_label(name)
if not name:
raise HTTPException(400, "skill_main_categories: name fehlt")
cur.execute(
"""
INSERT INTO skill_main_categories (name, slug, description, sort_order)
VALUES (%s, %s, %s, %s)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()
RETURNING id
""",
(name, slug, row.get("description"), row.get("sort_order")),
)
return int(cur.fetchone()["id"])
def _upsert_skill_category(
cur,
row: Dict[str, Any],
main_id_map: Dict[int, int],
cat_id_map: Dict[int, int],
) -> Tuple[int, int]:
"""Returns (old_id, new_id)."""
old_id = int(row["id"])
name = (row.get("name") or "").strip()
slug = (row.get("slug") or "").strip() or _slugify_label(name)
if not name:
raise HTTPException(400, f"skill_categories id={old_id}: name fehlt")
old_main = row.get("main_category_id")
new_main = main_id_map.get(int(old_main)) if old_main is not None else None
old_parent = row.get("parent_category_id")
new_parent = cat_id_map.get(int(old_parent)) if old_parent is not None else None
cur.execute(
"""
INSERT INTO skill_categories (
name, slug, description, parent_category_id, main_category_id, sort_order, status
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
parent_category_id = EXCLUDED.parent_category_id,
main_category_id = EXCLUDED.main_category_id,
sort_order = EXCLUDED.sort_order,
status = EXCLUDED.status,
updated_at = NOW()
RETURNING id
""",
(
name,
slug,
row.get("description"),
new_parent,
new_main,
row.get("sort_order"),
row.get("status") or "active",
),
)
new_id = int(cur.fetchone()["id"])
return old_id, new_id
def import_matrix_stack_v1(data: Dict[str, Any], session: dict = Depends(require_auth)) -> Dict[str, Any]:
_require_admin(session)
if data.get("kind") != KIND_V1:
raise HTTPException(400, f"kind muss {KIND_V1} sein")
replace_all = bool(data.get("replace_all_maturity_models"))
confirm = (data.get("confirm_replace_all") or "").strip()
if replace_all and confirm != "DELETE_MATURITY_STACK":
raise HTTPException(
400,
'replace_all_maturity_models erfordert confirm_replace_all: "DELETE_MATURITY_STACK"',
)
if replace_all and session.get("role") != "superadmin":
raise HTTPException(403, "replace_all_maturity_models nur für Superadmins")
profile_id = session.get("profile_id")
warnings: List[str] = []
main_rows = data.get("skill_main_categories") or []
cat_rows = _sort_categories_topo(list(data.get("skill_categories") or []))
skill_rows = data.get("skills") or []
sld_rows = data.get("skill_level_definitions") or []
model_blocks = data.get("maturity_models") or []
bind_rows = data.get("context_bindings") or []
skill_id_map: Dict[int, int] = {}
model_id_map: Dict[int, int] = {}
with get_db() as conn:
cur = get_cursor(conn)
fa_by_name, sd_by_name, tt_by_name, tg_by_name = _catalog_name_maps(cur)
# ── Katalog: Hauptkategorien ──
main_id_map: Dict[int, int] = {}
for mc in main_rows:
old = int(mc["id"])
main_id_map[old] = _upsert_main_category(cur, mc)
# ── Katalog: Unterkategorien (mehrere Durchläufe für Parent-Kette) ──
cat_id_map: Dict[int, int] = {}
remaining = list(cat_rows)
guard = 0
while remaining and guard < len(cat_rows) + 5:
guard += 1
next_pass: List[Dict[str, Any]] = []
for row in remaining:
old_parent = row.get("parent_category_id")
if old_parent is not None and int(old_parent) not in cat_id_map:
next_pass.append(row)
continue
old_id, new_id = _upsert_skill_category(cur, row, main_id_map, cat_id_map)
cat_id_map[old_id] = new_id
remaining = next_pass
if remaining:
raise HTTPException(400, "skill_categories: Parent-Auflösung fehlgeschlagen (Zyklus?)")
# ── Skills ──
slug_to_cat_id = {}
cur.execute("SELECT id, slug FROM skill_categories WHERE slug IS NOT NULL")
for r in cur.fetchall():
slug_to_cat_id[r["slug"]] = int(r["id"])
main_slug_to_id = {}
cur.execute("SELECT id, slug FROM skill_main_categories")
for r in cur.fetchall():
main_slug_to_id[r["slug"]] = int(r["id"])
for s in skill_rows:
old_sid = int(s["id"])
name = (s.get("name") or "").strip()
if not name:
raise HTTPException(400, f"Skill id={old_sid}: name fehlt")
cat_slug = s.get("_export_category_slug")
main_slug = s.get("_export_main_category_slug")
cat_id = None
main_id = None
if cat_slug:
cat_id = slug_to_cat_id.get(cat_slug)
if cat_id is None and s.get("category_id") is not None:
cat_id = cat_id_map.get(int(s["category_id"]))
if main_slug:
main_id = main_slug_to_id.get(main_slug)
if main_id is None and s.get("main_category_id") is not None:
main_id = main_id_map.get(int(s["main_category_id"]))
pfa_name = s.get("_export_primary_focus_area_name")
pfa_id = None
if pfa_name:
pfa_id = fa_by_name.get(str(pfa_name).strip())
if pfa_id is None:
warnings.append(f"primary_focus_area „{pfa_name}“ für Skill „{name}“ nicht gefunden")
focus_json = s.get("focus_areas")
if isinstance(focus_json, (dict, list)):
focus_json = json.dumps(focus_json)
elif focus_json is None:
focus_json = "[]"
cur.execute(
"""
SELECT id FROM skills
WHERE category_id IS NOT DISTINCT FROM %s AND name = %s
LIMIT 1
""",
(cat_id, name),
)
ex = cur.fetchone()
if ex:
new_sid = int(ex["id"])
cur.execute(
"""
UPDATE skills SET
category = %s,
description = %s,
importance = %s,
keywords = %s,
status = COALESCE(%s, status),
main_category_id = COALESCE(%s, main_category_id),
category_id = COALESCE(%s, category_id),
focus_areas = %s::jsonb,
sort_order = COALESCE(%s, sort_order),
primary_focus_area_id = COALESCE(%s, primary_focus_area_id),
is_cross_domain = COALESCE(%s, is_cross_domain),
level = COALESCE(%s, level),
parent_skill_id = COALESCE(%s, parent_skill_id),
updated_at = NOW()
WHERE id = %s
""",
(
s.get("category"),
s.get("description"),
s.get("importance"),
_keywords_param(s.get("keywords")),
s.get("status"),
main_id,
cat_id,
focus_json,
s.get("sort_order"),
pfa_id,
s.get("is_cross_domain"),
s.get("level"),
None,
new_sid,
),
)
else:
cur.execute(
"""
INSERT INTO skills (
name, category, description, importance, keywords, status,
category_id, main_category_id, focus_areas, sort_order,
primary_focus_area_id, is_cross_domain, level
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s)
RETURNING id
""",
(
name,
s.get("category"),
s.get("description"),
s.get("importance"),
_keywords_param(s.get("keywords")),
s.get("status") or "active",
cat_id,
main_id,
focus_json,
s.get("sort_order"),
pfa_id,
s.get("is_cross_domain"),
s.get("level"),
),
)
new_sid = int(cur.fetchone()["id"])
skill_id_map[old_sid] = new_sid
# parent_skill_id zweite Runde
for s in skill_rows:
old_sid = int(s["id"])
ps = s.get("parent_skill_id")
if ps is None:
continue
new_sid = skill_id_map.get(old_sid)
new_parent = skill_id_map.get(int(ps))
if new_sid and new_parent:
cur.execute(
"UPDATE skills SET parent_skill_id = %s WHERE id = %s",
(new_parent, new_sid),
)
# skill_level_definitions
for sld in sld_rows:
old_sk = int(sld["skill_id"])
new_sk = skill_id_map.get(old_sk)
if not new_sk:
warnings.append(f"skill_level_definitions: Skill {old_sk} nicht gemappt, übersprungen")
continue
lvl = int(sld["level"])
cur.execute(
"""
INSERT INTO skill_level_definitions (skill_id, level, description)
VALUES (%s, %s, %s)
ON CONFLICT (skill_id, level) DO UPDATE SET
description = EXCLUDED.description,
updated_at = NOW()
""",
(new_sk, lvl, sld.get("description") or ""),
)
if replace_all:
cur.execute("DELETE FROM maturity_models")
for block in model_blocks:
src_mid = int(block["source_id"])
mrow = dict(block.get("model") or {})
for drop in ("id", "created_at", "updated_at"):
mrow.pop(drop, None)
lc = int(mrow.get("level_count") or 5)
if lc < 3 or lc > 10:
raise HTTPException(400, "level_count ungültig")
cur.execute(
"""
INSERT INTO maturity_models (
name, description, level_count, status, version,
created_by, import_source, import_id, club_id
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""",
(
(mrow.get("name") or "").strip() or "Importiertes Modell",
mrow.get("description"),
lc,
mrow.get("status") or "draft",
mrow.get("version") or "1.0",
profile_id,
mrow.get("import_source") or "matrix_stack_v1",
mrow.get("import_id") or f"stack:{data.get('bundle_export_id') or 'na'}:{src_mid}",
None,
),
)
new_mid = int(cur.fetchone()["id"])
model_id_map[src_mid] = new_mid
for fa in block.get("legacy_focus_areas") or []:
nm = (fa.get("name") or "").strip()
fid = fa_by_name.get(nm)
if fid is None:
warnings.append(f"Modell {mrow.get('name')}: Fokus „{nm}“ unbekannt")
continue
cur.execute(
"""
INSERT INTO maturity_model_focus_areas (maturity_model_id, focus_area_id, is_primary)
VALUES (%s, %s, %s)
ON CONFLICT (maturity_model_id, focus_area_id) DO NOTHING
""",
(new_mid, fid, bool(fa.get("is_primary"))),
)
for sd in block.get("legacy_style_directions") or []:
nm = (sd.get("name") or "").strip()
sid = sd_by_name.get(nm)
if sid is None:
warnings.append(f"Modell {mrow.get('name')}: Stilrichtung „{nm}“ unbekannt")
continue
cur.execute(
"""
INSERT INTO maturity_model_style_directions (maturity_model_id, style_direction_id, is_primary)
VALUES (%s, %s, %s)
ON CONFLICT (maturity_model_id, style_direction_id) DO NOTHING
""",
(new_mid, sid, bool(sd.get("is_primary"))),
)
for tg in block.get("legacy_target_groups") or []:
nm = (tg.get("name") or "").strip()
tid = tg_by_name.get(nm)
if tid is None:
warnings.append(f"Modell {mrow.get('name')}: Zielgruppe „{nm}“ unbekannt")
continue
cur.execute(
"""
INSERT INTO maturity_model_target_groups (maturity_model_id, target_group_id, is_primary)
VALUES (%s, %s, %s)
ON CONFLICT (maturity_model_id, target_group_id) DO NOTHING
""",
(new_mid, tid, bool(tg.get("is_primary"))),
)
for lev in block.get("levels") or []:
cur.execute(
"""
INSERT INTO model_levels (maturity_model_id, level_number, name, description, sort_order)
VALUES (%s, %s, %s, %s, %s)
""",
(
new_mid,
int(lev["level_number"]),
(lev.get("name") or f"Stufe {lev['level_number']}").strip(),
lev.get("description"),
int(lev.get("sort_order") or lev["level_number"]),
),
)
for ms in block.get("model_skills") or []:
old_sk = int(ms["skill_id"])
new_sk = skill_id_map.get(old_sk)
if not new_sk:
raise HTTPException(400, f"Modell {mrow.get('name')}: unbekannte skill_id {old_sk}")
cur.execute(
"""
INSERT INTO model_skills (maturity_model_id, skill_id, sort_order, relevance)
VALUES (%s, %s, %s, %s)
ON CONFLICT (maturity_model_id, skill_id) DO UPDATE SET
sort_order = EXCLUDED.sort_order,
relevance = EXCLUDED.relevance
""",
(new_mid, new_sk, int(ms.get("sort_order") or 0), ms.get("relevance")),
)
for sl in block.get("skill_levels") or []:
old_sk = int(sl["skill_id"])
new_sk = skill_id_map.get(old_sk)
if not new_sk:
continue
ln = int(sl["level_number"])
desc = (sl.get("description") or "").strip()
if not desc:
continue
cur.execute(
"""
INSERT INTO model_skill_levels (
maturity_model_id, skill_id, level_number,
description, observable_criteria, example_exercise_hints, ai_generated
)
VALUES (%s,%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,
ai_generated = EXCLUDED.ai_generated,
updated_at = NOW()
""",
(
new_mid,
new_sk,
ln,
desc,
sl.get("observable_criteria"),
sl.get("example_exercise_hints"),
sl.get("ai_generated"),
),
)
for b in bind_rows:
src_m = int(b["maturity_model_source_id"])
new_m = model_id_map.get(src_m)
if not new_m:
warnings.append(f"Binding: Modell-Quell-ID {src_m} nicht gefunden, übersprungen")
continue
fa_n = (b.get("focus_area_name") or "").strip()
fa_id = fa_by_name.get(fa_n)
if fa_id is None:
warnings.append(f"Binding: Fokus „{fa_n}“ unbekannt")
continue
sd_n = b.get("style_direction_name")
sd_id = None
if sd_n:
sd_id = sd_by_name.get(str(sd_n).strip())
if sd_id is None:
warnings.append(f"Binding: Stil „{sd_n}“ unbekannt")
continue
tt_n = b.get("training_type_name")
tt_id = None
if tt_n:
tt_id = tt_by_name.get(str(tt_n).strip())
if tt_id is None:
warnings.append(f"Binding: Trainingsstil „{tt_n}“ unbekannt")
continue
if sd_id is None and tt_id is None:
cur.execute(
"""
DELETE FROM maturity_model_context_bindings
WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id IS NULL
""",
(fa_id,),
)
elif sd_id is not None and tt_id is None:
cur.execute(
"""
DELETE FROM maturity_model_context_bindings
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id IS NULL
""",
(fa_id, sd_id),
)
elif sd_id is None and tt_id is not None:
cur.execute(
"""
DELETE FROM maturity_model_context_bindings
WHERE focus_area_id = %s AND style_direction_id IS NULL AND training_type_id = %s
""",
(fa_id, tt_id),
)
else:
cur.execute(
"""
DELETE FROM maturity_model_context_bindings
WHERE focus_area_id = %s AND style_direction_id = %s AND training_type_id = %s
""",
(fa_id, sd_id, tt_id),
)
cur.execute(
"""
INSERT INTO maturity_model_context_bindings (
maturity_model_id, focus_area_id, style_direction_id, training_type_id
)
VALUES (%s, %s, %s, %s)
""",
(new_m, fa_id, sd_id, tt_id),
)
return {
"ok": True,
"skill_id_map": {str(k): v for k, v in skill_id_map.items()},
"model_id_map": {str(k): v for k, v in model_id_map.items()},
"warnings": warnings,
}
router.add_api_route("/export", export_matrix_stack_v1, methods=["GET"])
router.add_api_route("/import", import_matrix_stack_v1, methods=["POST"])

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.7.5"
APP_VERSION = "0.7.6"
BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260427027"
@ -19,10 +19,17 @@ MODULE_VERSIONS = {
"admin": "1.0.0",
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
"maturity_models": "1.3.1", # Resolve: kein Legacy, wenn Fokus bereits Bindings hat
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
}
CHANGELOG = [
{
"version": "0.7.6",
"date": "2026-04-27",
"changes": [
"API: GET/POST /api/admin/matrix-stack (shinkan.matrix_stack.v1) Fähigkeitskatalog, Reifegradmodelle, Kontext-Bindings für Test→Prod",
],
},
{
"version": "0.7.5",
"date": "2026-04-27",

91
docs/HANDOVER.md Normal file
View File

@ -0,0 +1,91 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-04-27
**App-Version:** 0.7.6 (`backend/version.py`)
**DB-Schema-Version:** `20260427027` (Migration 027)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand** und **nächste Baustellen**.
---
## 1. Pflichtlektüre (Kontext & Anforderungen)
| Thema | Pfad |
|--------|------|
| Projekt-Setup, Domain grob | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
| Projekt-Status (Skills, Wiki, Stats) | `.claude/docs/PROJECT_STATUS.md` |
| Übungen: API, DB, Architektur, Routing | `.claude/docs/technical/EXERCISES_API_SPEC.md`, `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_FRONTEND_ROUTING.md` |
| Media / Upload | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` |
| MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
---
## 2. Implementierter Stand: Fähigkeiten & Reifegradmodelle
### 2.1 Datenbank
- **`maturity_models`**, **`model_levels`**, **`model_skills`**, **`model_skill_levels`**: Matrix-Inhalt pro Modell.
- **Kontext am Modell (Legacy, M:N):** `maturity_model_focus_areas`, `maturity_model_style_directions`, `maturity_model_target_groups` (Migration 025).
- **Hierarchische Kontext-Zuordnung (Resolve):** `maturity_model_context_bindings` mit optional `style_direction_id`, `training_type_id` (Migration 026, 027).
- **027:** u. a. `Fokus + Trainingsstil` ohne Stilrichtung (partielle Unique-Indizes).
### 2.2 Resolve-Logik (Backend)
- **`GET /api/maturity-models/resolve`**: Bindings zum Fokus, die zur Anfrage passen; Merge nach Spezifität (weniger spezifisch zuerst); spezifischere Zeilen überschreiben Zelltexte.
- **Matching:** Gesetzte Spalten einer Binding-Zeile müssen mit der Anfrage übereinstimmen; `NULL` in der Zeile = Wildcard (z. B. Fokus+Trainingsstil gilt für alle Stilrichtungen, aber nur für diesen Trainingsstil).
- **Legacy-Fallback:** Nur wenn für den **Fokus keine einzige** Zeile in `maturity_model_context_bindings` existiert. Sonst bei fehlendem Treffer **`null`** (kein stilles Legacy mit falschem Trainingsstil).
### 2.3 Export / Import (einzelnes Modell & aufgelöst)
- **`GET /api/maturity-models/{id}/export`**: `shinkan.maturity_model.v1` inkl. `context_bindings_for_model` (IDs).
- **`GET /api/maturity-models/export-resolved`**: `shinkan.maturity_matrix_resolved.v1` (Query: `focus_area_id`, optional `style_direction_id`, `training_type_id`).
- **`POST /api/maturity-models/import`**: `create` | `replace`, optional `import_bindings` (nur bei `maturity_model.v1`).
### 2.4 Komplett-Stack Test → Prod
- **`GET /api/admin/matrix-stack/export`**: `shinkan.matrix_stack.v1` Katalog (`skill_main_categories`, `skill_categories`, `skills`, `skill_level_definitions`) + alle Reifegradmodelle + Bindings mit **Namen** (Fokus/Stil/Trainingsstil).
- **`POST /api/admin/matrix-stack/import`**: Upsert Katalog per Slug; Skills per Kategorie+Name; Modelle neu anlegen; Bindings per Katalognamen. Optional **`replace_all_maturity_models`** + **`confirm_replace_all: "DELETE_MATURITY_STACK"`** (nur Superadmin).
- Router: `backend/routers/matrix_stack_bundle.py`, in `main.py` registriert.
### 2.5 Frontend (Admin)
- **`frontend/src/pages/AdminMaturityModelsPage.jsx`**: Tabs u. a. Katalog, Modelle, Kontext-Zuordnung, **Matrix-Ansicht und Export**.
- **`MaturityModelBindingsAdmin.jsx`**: Bindings CRUD, Erklärung Merge/Legacy.
- **`MaturityMatrixToolsAdmin.jsx`**: Kontext auflösen, hierarchische Matrix-Ansicht, Export einzelnes Modell / aufgelöst, Import, **Komplett-Stack** Export/Import.
- **`frontend/src/utils/api.js`**: u. a. `exportMatrixStackBundle`, `importMatrixStackBundle`, Reifegrad-APIs.
---
## 3. Stand: Übungen (Lücke für nächste Session)
**Ist (laut Projektdoku und aktuellem Produktziel):**
- Backend: Übungen-CRUD, M:N, Suche, Blöcke, Medien-Struktur u. a. sind in `PROJECT_STATUS.md` als umgesetzt geführt; viele Übungen stammen aus **MediaWiki-Import** (Wiki-Tracking-Tabellen).
- **Soll / Nutzerfeedback:** In der Praxis fehlt oder ist unzureichend: **stabile Liste**, **gerenderte Detailansicht**, **Bearbeiten/Anlegen**, **Medien zuweisen/Upload** konkrete Fehler (404, leere Liste, falsche Route) sind vor Ort zu verifizieren.
**Nächste Session sollte:**
1. Aktuelle Routen und Seiten prüfen (`App.jsx`, `EXERCISES_FRONTEND_ROUTING.md`).
2. `GET /api/exercises` (Filter, Auth) und eine Beispiel-Übung gegen die Dev-DB testen.
3. UI schrittweise: Liste → Detail → Formular → Medien (an Specs in `.claude/docs/technical/` ausrichten).
---
## 4. Technische Referenz (kurz)
| Bereich | Einstieg |
|---------|----------|
| Backend API | `backend/main.py`, `backend/routers/maturity_models.py`, `matrix_stack_bundle.py`, `exercises.py`, `catalogs.py`, `skills.py` |
| Migrationen | `backend/migrations/` (u. a. 024027 Reifegrad/Bindings) |
| Frontend API | `frontend/src/utils/api.js` |
| Version / Changelog | `backend/version.py` |
---
## 5. Veraltete Hinweise
Die Datei `.claude/docs/working/HANDOVER_NEXT_SESSION.md` (2026-04-22) ist **historisch**; für den aktuellen Stand gilt **`docs/HANDOVER.md`**.
---
*Ende Handover-Dokument.*

View File

@ -52,6 +52,9 @@ export default function MaturityMatrixToolsAdmin() {
const [replaceModelId, setReplaceModelId] = useState('')
const [exportModelId, setExportModelId] = useState('')
const [importBindings, setImportBindings] = useState(true)
const [stackWipe, setStackWipe] = useState(false)
const [stackConfirmText, setStackConfirmText] = useState('')
const [stackLoading, setStackLoading] = useState(false)
useEffect(() => {
let cancelled = false
@ -140,6 +143,22 @@ export default function MaturityMatrixToolsAdmin() {
}
}
async function handleExportStack() {
setError('')
setMessage('')
setStackLoading(true)
try {
const bundle = await api.exportMatrixStackBundle()
const name = `matrix-stack-${(bundle.bundle_export_id || 'export').slice(0, 8)}.json`
downloadJson(bundle, name)
setMessage('Komplett-Stack exportiert (Download).')
} catch (err) {
setError(err.message || String(err))
} finally {
setStackLoading(false)
}
}
async function handleImportFile(e) {
const file = e.target.files?.[0]
if (!file) return
@ -147,18 +166,39 @@ export default function MaturityMatrixToolsAdmin() {
setMessage('')
try {
const data = JSON.parse(await file.text())
const payload = { ...data, mode: importMode, import_bindings: importBindings }
if (importMode === 'replace') {
if (!replaceModelId) {
setError('Bei „Ersetzen“ die Ziel-Modell-ID angeben.')
if (data.kind === 'shinkan.matrix_stack.v1') {
if (stackWipe && stackConfirmText !== 'DELETE_MATURITY_STACK') {
setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).')
e.target.value = ''
return
}
payload.replace_model_id = parseInt(replaceModelId, 10)
const payload = {
...data,
replace_all_maturity_models: stackWipe,
confirm_replace_all: stackWipe ? stackConfirmText : undefined
}
const res = await api.importMatrixStackBundle(payload)
const w = res.warnings || []
setMessage(
`Stack-Import OK. Modell-Zuordnung: ${Object.keys(res.model_id_map || {}).length} Modell(e).` +
(w.length ? ` ${w.length} Hinweis(e).` : '')
)
if (w.length) console.warn('matrix_stack import warnings', w)
setModels(await api.listMaturityModels({}))
} else {
const payload = { ...data, mode: importMode, import_bindings: importBindings }
if (importMode === 'replace') {
if (!replaceModelId) {
setError('Bei „Ersetzen“ die Ziel-Modell-ID angeben.')
e.target.value = ''
return
}
payload.replace_model_id = parseInt(replaceModelId, 10)
}
const res = await api.importMaturityModelBundle(payload)
setMessage(`Import erfolgreich. Modell-ID: ${res.id}`)
setModels(await api.listMaturityModels({}))
}
const res = await api.importMaturityModelBundle(payload)
setMessage(`Import erfolgreich. Modell-ID: ${res.id}`)
setModels(await api.listMaturityModels({}))
} catch (err) {
setError(err.message || String(err))
} finally {
@ -170,7 +210,8 @@ export default function MaturityMatrixToolsAdmin() {
<div className="admin-matrix-tools">
<p className="admin-matrix-tools__intro muted">
Matrix nach Kontext auflösen, hierarchisch nach Hauptkategorie und Kategorie darstellen, sowie JSON
exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, oder aufgelöste Matrix).
exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, aufgelöste Matrix, oder{' '}
<strong>Komplett-Stack</strong> mit Fähigkeitskatalog und allen Reifegradmodellen für Test Prod).
</p>
{error ? (
@ -307,6 +348,57 @@ export default function MaturityMatrixToolsAdmin() {
</section>
) : null}
<section className="card admin-matrix-tools__section">
<h2 className="admin-matrix-tools__h2">Komplett-Stack (Katalog + Modelle + Bindings)</h2>
<p className="muted admin-matrix-tools__hint">
Export enthält <code className="admin-bindings__code">skill_main_categories</code>,{' '}
<code className="admin-bindings__code">skill_categories</code>, <code className="admin-bindings__code">skills</code>,{' '}
<code className="admin-bindings__code">skill_level_definitions</code>, alle Reifegradmodelle (Stufen, Matrix-Zellen,
Legacy-Kontext M:N) sowie <code className="admin-bindings__code">context_bindings</code> mit{' '}
<strong>Fokus-/Stil-/Trainingsstil-Namen</strong> für die Ziel-DB. Auf Prod müssen dieselben
Katalognamen für Fokusbereiche, Stilrichtungen und Trainingsstile existieren.
</p>
<div className="admin-matrix-tools__actions">
<button
type="button"
className="btn btn-primary"
disabled={stackLoading}
onClick={handleExportStack}
>
{stackLoading ? 'Export…' : 'Komplett-Stack exportieren'}
</button>
</div>
<h3 className="admin-matrix-tools__h3">Stack-Import (JSON-Datei unten)</h3>
<p className="muted admin-matrix-tools__hint">
Datei mit <code className="admin-bindings__code">kind: shinkan.matrix_stack.v1</code>. Katalog wird per Slug
zusammengeführt; Skills per Kategorie + Name. Optional alle Reifegradmodelle auf der Ziel-DB vorher löschen
(nur Superadmin, Vorsicht).
</p>
<label className="form-label admin-matrix-tools__check">
<input
type="checkbox"
checked={stackWipe}
onChange={(e) => {
setStackWipe(e.target.checked)
if (!e.target.checked) setStackConfirmText('')
}}
/>{' '}
Zuerst alle Reifegradmodelle auf dieser Datenbank löschen (nur Superadmin)
</label>
{stackWipe ? (
<>
<label className="form-label">Bestätigung (exakt)</label>
<input
className="form-input"
value={stackConfirmText}
onChange={(e) => setStackConfirmText(e.target.value)}
placeholder="DELETE_MATURITY_STACK"
autoComplete="off"
/>
</>
) : null}
</section>
<section className="card admin-matrix-tools__section">
<h2 className="admin-matrix-tools__h2">Export / Import (JSON)</h2>
<div className="admin-matrix-tools__io-grid">
@ -339,9 +431,10 @@ export default function MaturityMatrixToolsAdmin() {
<div>
<h3 className="admin-matrix-tools__h3">Import</h3>
<p className="muted admin-matrix-tools__hint">
Datei <code className="admin-bindings__code">shinkan.maturity_model.v1</code> oder{' '}
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Aufgelöste
Matrizen legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings).
<code className="admin-bindings__code">shinkan.matrix_stack.v1</code> (Komplett-Stack),{' '}
<code className="admin-bindings__code">shinkan.maturity_model.v1</code> oder{' '}
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Aufgelöste Matrizen
legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings).
</p>
<label className="form-label">Modus</label>
<select

View File

@ -360,6 +360,18 @@ export async function exportResolvedMaturityBundle(filters = {}) {
return request(`/api/maturity-models/export-resolved${query ? '?' + query : ''}`)
}
/** Komplett: Fähigkeitskatalog + Reifegradmodelle + Kontext-Bindings (slug-/namensbasiert auf Ziel-DB) */
export async function exportMatrixStackBundle() {
return request('/api/admin/matrix-stack/export')
}
export async function importMatrixStackBundle(payload) {
return request('/api/admin/matrix-stack/import', {
method: 'POST',
body: JSON.stringify(payload)
})
}
// Style Directions (formerly Training Styles)
export async function listStyleDirections(filters = {}) {
const query = new URLSearchParams(filters).toString()
@ -720,6 +732,8 @@ export const api = {
importMaturityModelBundle,
exportMaturityModelBundle,
exportResolvedMaturityBundle,
exportMatrixStackBundle,
importMatrixStackBundle,
resolveMaturityModel,
getMaturityModel,
createMaturityModel,