diff --git a/.claude/docs/working/HANDOVER_NEXT_SESSION.md b/.claude/docs/working/HANDOVER_NEXT_SESSION.md index 5b6f223..2597020 100644 --- a/.claude/docs/working/HANDOVER_NEXT_SESSION.md +++ b/.claude/docs/working/HANDOVER_NEXT_SESSION.md @@ -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.* diff --git a/backend/main.py b/backend/main.py index 0c07a3a..9357374 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/matrix_stack_bundle.py b/backend/routers/matrix_stack_bundle.py new file mode 100644 index 0000000..967cdea --- /dev/null +++ b/backend/routers/matrix_stack_bundle.py @@ -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"]) diff --git a/backend/version.py b/backend/version.py index 5cac34f..51a54ad 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md new file mode 100644 index 0000000..9d91831 --- /dev/null +++ b/docs/HANDOVER.md @@ -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. 024–027 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.* diff --git a/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx b/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx index 69b8029..31891cc 100644 --- a/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx +++ b/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx @@ -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() {

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{' '} + Komplett-Stack mit FĂ€higkeitskatalog und allen Reifegradmodellen fĂŒr Test → Prod).

{error ? ( @@ -307,6 +348,57 @@ export default function MaturityMatrixToolsAdmin() { ) : null} +
+

Komplett-Stack (Katalog + Modelle + Bindings)

+

+ Export enthĂ€lt skill_main_categories,{' '} + skill_categories, skills,{' '} + skill_level_definitions, alle Reifegradmodelle (Stufen, Matrix-Zellen, + Legacy-Kontext M:N) sowie context_bindings mit{' '} + Fokus-/Stil-/Trainingsstil-Namen fĂŒr die Ziel-DB. Auf Prod mĂŒssen dieselben + Katalognamen fĂŒr Fokusbereiche, Stilrichtungen und Trainingsstile existieren. +

+
+ +
+

Stack-Import (JSON-Datei unten)

+

+ Datei mit kind: shinkan.matrix_stack.v1. Katalog wird per Slug + zusammengefĂŒhrt; Skills per Kategorie + Name. Optional alle Reifegradmodelle auf der Ziel-DB vorher löschen + (nur Superadmin, Vorsicht). +

+ + {stackWipe ? ( + <> + + setStackConfirmText(e.target.value)} + placeholder="DELETE_MATURITY_STACK" + autoComplete="off" + /> + + ) : null} +
+

Export / Import (JSON)

@@ -339,9 +431,10 @@ export default function MaturityMatrixToolsAdmin() {

Import

- Datei shinkan.maturity_model.v1 oder{' '} - shinkan.maturity_matrix_resolved.v1. Aufgelöste - Matrizen legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings). + shinkan.matrix_stack.v1 (Komplett-Stack),{' '} + shinkan.maturity_model.v1 oder{' '} + shinkan.maturity_matrix_resolved.v1. Aufgelöste Matrizen + legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings).