Merge pull request 'minor improvements. Darstellung, Handlung, Popups' (#32) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 59s

Reviewed-on: #32
This commit is contained in:
Lars 2026-05-13 22:02:42 +02:00
commit 81d1e9bdfd
31 changed files with 1414 additions and 477 deletions

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo - Projekt-Status
**Stand:** 2026-05-12
**Version (Code):** 0.8.96 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260511053` (`backend/version.py`, DB_SCHEMA_VERSION)
**Version (Code):** 0.8.110 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260512057` (`backend/version.py`, DB_SCHEMA_VERSION)
**Branch:** develop
---
@ -31,11 +31,12 @@
---
**Nächste Schritte (Auszug — Planung/Rahmen):**
**Nächste Schritte (Auszug — Planung/Rahmen & Kombination):**
1. KalenderUI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk.
2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API).
3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung (**§4**).
4. **Kombinationsübungen / Coach (Fachspez §10.6):** Coach **Stufe B/C** (archetypgesteuerte Durchführung); **Archetyp-Verwaltung** jenseits Code-Konstanten; **Massen-Vorbelegung** aller Slot-Zeit/Anzahl-Felder; **serverseitige** Validierung Profil ↔ Archetyp — siehe `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Pakete **4e4g**) und `COMBINATION_TIMING_PROFILE_PLAN.md`.
---

View File

@ -1,7 +1,7 @@
# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3
**Status:** fachlicher Spezifikationsentwurf
**Stand:** 2026-05-12 (AnhangA **grob** App **0.8.104**; ZeitPfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** §10.2.1, §10.410.5, **§5.4/§6.3** Methoden/Archetypen/Zeitschicht · **Anhang A**
**Stand:** 2026-05-12 (AnhangA App **0.8.110**; ZeitPfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** §10.2.1, §10.410.6, **§5.4/§6.3** Methoden/Archetypen/Zeitschicht · **Anhang A**
**Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan.
**Wichtige Leitlinie dieser Version:**
@ -417,7 +417,7 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **CoachAssistenz**
**Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits §2.5 und §8.3).
**Umsetzung in der App (Stand 0.8.103):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB). **`null`** bedeutet: es wirkt das Ablaufprofil aus dem **Katalog** (`method_profile` der Übung). Ist ein Snapshot gesetzt, ersetzt er den Katalog **vollständig** für diese Platzierung (kein serverseitiges Zusammenführen). Bearbeitung in der Planungs-UI: aufklappbarer Block **„Ablaufprofil für diese Planung (Kombination)“** mit denselben geführten Feldern wie im Übungsformular.
**Umsetzung in der App (Stand 0.8.110):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB, Migration **057**). **`null`** oder fehlender Key: für **Anzeige und Editor** wirkt das **Zusammenführen** aus **Katalog** (`exercises.method_profile` bzw. Join `catalog_method_profile`) **+** Snapshot — der Katalog wird **nicht** durch ein leeres Planungsobjekt verworfen; fehlende bzw. JSON-`null`-Werte im Snapshot **überschreiben** keine Katalogfelder; `slot_profiles_v1` wird **je `slot_index`** zusammengeführt (inkl. konsistenter Steuerungslogik Zeit vs. ZielWdh.). Persistenz: der Snapshot speichert nur die vom Trainer **gesetzten** Planungsdaten (nach STZ-„Runde“ können leere Objekte als `null` normalisiert werden). **Konkrete Logik:** Frontend `effectiveComboMethodProfile` / `merge` in `frontend/src/utils/comboPlanningMethodProfile.js` (Coach, Planungseditor, Druck/Vorschau konsistent). Bearbeitung in der Planungs-UI: Modal **„Ablauf bearbeiten…“** mit `CombinationMethodProfileEditor` + Vorschau `CombinationPlanBracket`.
**Coach:** soll die wirksamen Werte nach **Übernahme** und **Einheitsübersteuerungen** konsistent nachvollziehen (**§10.4**).
@ -669,6 +669,20 @@ Archetyp-spezifisches Coaching soll **nicht** als ein einziges UX-„Monolith“
Solange diese Mindestinfos in der Datenpflege noch **nicht** validiert oder nicht geführt erfasst werden, bleibt Coaching bei **Informations-Schicht und manuellen Timern des bestehenden Coach-Dialogs** die fachlich ehrliche Darstellung (siehe Anhang A).
### 10.6 Offene und geplante Erweiterungen (Produkt-Backlog, Stand 2026-05-12)
Die folgenden Punkte stammen aus **Session-/Chat-Arbeit** an Planung, Klammerdarstellung und Coach **Stufe A**; sie sind **noch nicht** als vollständige Produktfunktion abgeschlossen bzw. bewusst zurückgestellt:
| Thema | Kurzbeschreibung | Status |
| ----- | ---------------- | ------ |
| **Coaching Stufe B/C (individuelle Archetyp-Steuerung)** | Über **Stufe A** (lesend: Slots, Zeiten, Archetyp-Hinweis, Kandidaten-Texte) hinaus: **pro Archetyp** gesteuerte Durchführung (z.B. Substeps bei Sequenz, Stations-/Rotations-Timer beim Zirkel, Erklärphase bei parallelen Stationen, Abhaken Parcours, Intervalluhr). §10.4 Stufe **B** (Zeitleiste) und **C** (Assistenz). | **Offen** — aktuell nur informativ/Orientierung; kein archetypspezifischer Zustand im Coach. |
| **Administrierbarkeit der Archetypen** | Archetypen sind **fest** im Code (`COMBINATION_ARCHETYPE_IDS` Backend, `COMBINATION_ARCHETYPE_OPTIONS` Frontend); **keine** DB-/Admin-Oberfläche für Labels, Defaults, Sichtbarkeit oder club-spezifische Erweiterungen. | **Offen** — Änderungen nur per Release/Code-Review. |
| **Einfache Vorbelegung aller Zeit- und Anzahlfelder** | Teilweise: Schnellwahlen (**Zirkel**, **Intervall**), Serien-Default **1**, Archetyp-Map `ARCHETYPE_DEFAULT_REP_SERIES_COUNT`. **Fehlt:** ein Klick „alle Stationen aus globalen Eckwerten / Archetyp-Muster füllen“, Profil-weite **Reset/Übernehmen**-Presets über alle Slots. | **Teilweise** — Ausbau siehe `COMBINATION_TIMING_PROFILE_PLAN.md` §1 („Archetyp = Struktur + Defaults“). |
| **Archetypbedingte Restriktionen & Server-Validierung** | Client führt geführte Felder; **keine** verbindliche Backend-Prüfung „Profil passt zu Archetyp“ (Pflichtschlüssel, Wertebereiche, unzulässige Slot-Kombinationen). | **Offen** — erhöht Datenqualität und Coach-Verlässlichkeit vor Stufe C. |
| **Governance Archetyp ↔ offizielle Inhalte** | Noch keine getrennte Policy „nur Superadmin darf neue Archetyp-IDs einführen“ (derzeit ohnehin nur Code). | **Offen** — relevant sobald Archetypen konfigurierbar werden. |
**Hinweis:** §13.1 nennt Stufe **A** als MVP-Pflicht und **B/C** als Ausbauschritte — die Tabelle oben präzisiert die **noch offenen** Arbeitspakete aus der Umsetzungspraxis.
---
## 11. Rahmenprogramm-Integration
@ -793,7 +807,7 @@ Die Spezifikation ist daher kein technisches Pflichtenheft, sondern ein fachlich
---
## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.104**, grob)
## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.110**)
Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schritt; verhindert „wir haben X gebaut, die Spec sagt aber Y“ ohne dass es dokumentiert wird.
@ -801,14 +815,15 @@ Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schr
|--------------------------------------------|-----------------|---------------------------------|-------------------------------------|
| **Trainingsmodule (Bibliothek)** | Wiederverwendbare Blöcke, Kopier-Einfügen in Einheit | Bibliothek, API, Übernahme-Modal, Lineage-Spalte | **Phase 3** des Umsetzungsplans: erweiterter Übernahmemodus |
| **Kombinationsübung im Katalog** | `exercise_kind=combination`, Slots, Pools (Kandidaten) | Migration 056, CRUD Übung mit `combination_slots`, GET liefert Slots + Kandidatentitel | Fachbezug Haupt-/Nebenmethoden aus §4/§6 dort umsetzen, wo die Domäne es noch nicht abdeckt |
| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` (+ Pilot **`slot_profiles_v1`** je Station in derselben JSONStruktur) | Persistenz; Übungsformular: **geführte globale Felder** + **pro Slot** vier Zeitreihen ohne NutzerJSONPflicht; Schnellwahl typische Arbeit/PauseRelationen (**Zirkel**, **Intervall**); Reihenfolge UX: Stationen vor Ablaufprofil | JSON„Experte“ weiter abschaltbar; SchemaPflichtfelder nach Archetyp; Konvergenz flache Schlüssel ↔ `timing_schema` (siehe Arbeitsplan) |
| **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; **ZeitprofilOverrides** nach §8.3 / §10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Override:** DB **`planning_method_profile`** je Sektions-Item (Migration **057**), Planungseditor: Details „Ablaufprofil für diese Planung“, **„Planung wie Katalog“** / **„Aus Katalog kopieren“** | Planungsblöcke als Produktkonzept · Phase 3; serverseitige Validierung Snapshot↔Archetyp optional |
| **Zeitphasen (global / pro Slot)** | §6.3 | Über `method_profile` / PlanungsSnapshot (**gleiche JSON-Struktur** wie Katalogprofil): globale Schlüssel im Übungs- und Planungseditor; weiterhin **keine** eigenständigen slotgebundenen Zeitlisten im UI | `slot_timing[]` oder äquivalent definieren und editieren |
| **Coaching Stufe A** | Slots + Kandidaten sichtbar, ArchetypHinweis, Profil lesbar | `CombinationCoachSlots`: wirksames Profil = **PlanungsSnapshot wenn gesetzt, sonst Katalog**; Anzeige **Key/Value** | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) |
| **Coaching Stufe B** | Zeitleiste archetypnah (z.B. Schritt pro Station) | **Nein** — ein CoachSchritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DBMaterialisierung; Auswirkung auf IstZeit pro Item |
| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** CoachTimer pro Planungsitem | Pro Archetyp UIState + Anbindung an `method_profile` |
| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` + **`slot_profiles_v1`** | Geführtes Profil (`CombinationMethodProfileEditor`), `advance_mode` je Slot (Zeit / ZielWdh. / Coach), API-Build aus `ExerciseFormPage` | **Admin-UI für Archetypen** fehlt (nur Code-Konstanten); **serverseitige Validierung** Profil↔Archetyp offen; **volle Vorbelegung** aller Slots aus Preset/Archetyp nur teilweise (Schnellwahl) |
| **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; Overrides §8.3 | `planning_method_profile` JSONB; Modal **„Ablauf bearbeiten“**; **Merge** Katalog+Planung im Frontend (`effectiveComboMethodProfile`); Payload-Sanitisierung; Backend `Json()` beim Insert | Planungsblöcke Phase 3; **serverseitige** Zusammenführung/Validierung optional (aktuell Merge nur Client) |
| **Darstellung Planung / Lauf / Druck** | Konsistente Zeiten & Wdh. | `CombinationPlanBracket`, `effectiveStationTimingSummary`, Belastungs-Badge je Station; kompakte Kombi-Zeile in `TrainingUnitSectionsEditor` | Feintuning nach Nutzerfeedback |
| **Zeitphasen (global / pro Slot)** | §6.3 | `slot_profiles_v1`, globale Archetyp-Felder, `inferAdvanceModeFromStoredSlotRow` für Legacy-Zeilen | `timing_schema`-Konvergenz laut `COMBINATION_TIMING_PROFILE_PLAN.md` |
| **Coaching Stufe A** | Slots + Kandidaten, Archetyp, Profil lesbar | `ExerciseFullContent` + `CombinationCoachSlots`: Merge Katalog+Planung; **globale Eckdaten mit fachlichen Labels** (`describeGlobalComboProfile`); Stationstexte inkl. „Wdh. ohne Wechsel zur nächsten Station“ / Pausen-Hinweis | Stufe **B/C** weiterhin **offen**10.6) |
| **Coaching Stufe B** | Zeitleiste archetypnah | **Nein** — ein CoachSchritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DBMaterialisierung; Auswirkung auf IstZeit pro Item |
| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Minuten-/Ist-Input pro Item; **kein** Stations-Timer-State | Pro Archetyp UI + `method_profile` — Haupt-Backlog |
| **Rahmenprogramm** | Gleiche Inhalte wie Einheit | SlotBlueprint, `from-framework-slot` | Modul-/KombiUX in Rahmen wie in Einheit konsolidieren (Phase 5) |
| **Coaching-Vorschau im Editor** | §9.3 Schritt 7 | **Nein** / nicht als eigener Modus | Optional: dieselbe `CombinationCoachSlots`Ansicht readonly im Übungseditor |
| **Coaching-Vorschau im Editor** | §9.3 Schritt 7 | **Peek** / Run nutzen `CombinationPlanBracket`; kein eigener „Coach-Sim“-Modus im Übungseditor | Optional: eingebettete read-only Coach-Ansicht |
**Pflege:** Bei jeder relevanten Codeänderung diese Tabelle **in demselben PR / derselben Session** anpassen (kein stiller Drift).

View File

@ -1,13 +1,14 @@
# Trainingsmodule und Kombinationsübungen — Spezifikation (Entwurf)
**Status:** Entwurf zur fachlichen und technischen Abstimmung · **Stand:** 2026-05-12
**Status:** Entwurf zur fachlichen und technischen Abstimmung · **Stand:** 2026-05-12 (Code **0.8.110**, siehe `backend/version.py`)
**Zweck:** Rahmen für Umsetzung, Integration in Planung/Rahmenprogramm und Durchführung im assistierten Training (Coaching-Modus). Dieses Dokument ist **nicht** implementierungsbindend, bis die markierten **offenen Entscheidungen** geschlossen und der Status angehoben wurde.
**Abgleich mit Code (Stand ~0.8.101, Drift vermeiden):**
**Abgleich mit Code (Drift vermeiden):**
- **Kanonische Archetyp-IDs:** fest in `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`); fachliche Tabelle und UI-Labels in `frontend/src/constants/combinationArchetypes.js` — die **fachliche Master-Zuordnung** Name↔ID steht in `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` §10.2.1.
- **Coaching:** Stufe **A** (informations-/strukturierte Slot- und Kandidatenansicht + Archetyp-Hilfstext) umgesetzt im Trainings-Coach (`ExerciseFullContent` / `CombinationCoachSlots`); Stufen **B/C** bewusst offen — siehe Fachspez §10.4 und **Anhang A** dort.
- **Umsetzungsplan:** `working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phasen 2/4 mit „teilweise“).
- **Kanonische Archetyp-IDs:** fest in `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`); fachliche Tabelle und UI-Labels in `frontend/src/constants/combinationArchetypes.js` — die **fachliche Master-Zuordnung** Name↔ID steht in `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` §10.2.1. **Administrierbare Archetypen** (DB/UI) gibt es **nicht**; Erweiterungen nur per Code-Release — Fachspez **§10.6**.
- **Planungs-Override:** `planning_method_profile` (Migration **057**) speichert einen **Snapshot**; **Merge** mit Katalogprofil erfolgt im Frontend (`frontend/src/utils/comboPlanningMethodProfile.js` — `effectiveComboMethodProfile`), nicht als serverseitiger Join. Payload-Sanitisierung vor PUT; Backend speichert JSONB zuverlässig (`Json()`).
- **Coaching:** Stufe **A** — Slots, Kandidaten, Archetyp-Hilfstext, **Label** für globale Eckdaten (`describeGlobalComboProfile` in `combinationMethodProfileUi.js`), visuelle Klammer (`CombinationPlanBracket`) in Peek/Run; Stufen **B/C** (archetypgesteuerte Zeitleiste/Takt) **offen** — Fachspez §10.4, **§10.6**, **Anhang A**.
- **Umsetzungsplan:** `working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase 2/4 „teilweise“; Pakete **4e4g** für Admin, Vorbelegung, Validierung).
**Verwandte Dokumente:**

View File

@ -82,7 +82,12 @@ ObjektShape (Sekunden, ganze Zahlen ≥ 0):
- **Trainingsplanung** (`plannerMode`): **keine** RohJSONOberfläche.
- **Übungsformular**: RohJSON nur wenn `allowExpertJson === true` (Default false; später z.B. Superadmin/Dev).
- **CoachingAnsicht**: nur **wirksame** Zahlen aus Snapshot/Katalog darstellen, mittelfristig Labels statt Schlüsseln.
- **CoachingAnsicht**: nur **wirksame** Zahlen aus Snapshot/Katalog (Merge wie in `comboPlanningMethodProfile.js`); **globale** Profilwerte mit **fachlichen Labels** (`describeGlobalComboProfile`), nicht nur Rohschlüsseln.
### 4.1 Stand Umsetzung (App **0.8.110**, Kurz)
- **`slot_profiles_v1`** und Schnellwahlen Zirkel/Intervall im geführten Editor umgesetzt; **`advance_mode`** je Slot (Zeit / ZielWdh. / Coach).
- **Phase 2** dieses Plans (Modal „ArchetypVorlage anwenden?“, nichtdestruktives Merge über alle Slots) — **noch offen** (Fachspez §10.6, Umsetzungsplan Paket **4f**).
---

View File

@ -2,7 +2,7 @@
**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, inkl. **§10.2.1**, **§10.4 Coaching-Stufen**, **Anhang A** Implementierungsabgleich — Drift-Schutz)
**Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`
**Stand dieses Dokuments:** 2026-05-12 (Abgleich mit Code ~App **0.8.102**)
**Stand dieses Dokuments:** 2026-05-12 (Abgleich mit Code App **0.8.110**, siehe `backend/version.py`)
## Ziele
@ -13,9 +13,9 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z
| Phase | Inhalt | Status |
|-------|--------|--------|
| **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“ | **umgesetzt (MVP Schritt 1)** |
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise**Migration 056, CRUD/API, Picker/Liste; Übungsformular: geführtes **`method_profile` nach Archetyp** (`CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`) plus RohJSON; **Backend:** keine strenge Validierung Profil ↔ Archetyp | Haupt-/Nebenmethoden an Kombi wo Spec es verlangt; serverseitige Validierung für ProfilSchlüssel optional |
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise**wie links; zusätzlich **057** `planning_method_profile`; Planungs-Merge Client (`effectiveComboMethodProfile`); Archetypen weiterhin **nur Code-Konstanten** (kein Admin) | **Offen:** Archetyp-Admin-UI; Profil↔Archetyp-Validierung Backend; „alle Slots vorbelegen“ / Presets (siehe Fachspez **§10.6**); Haupt-/Nebenmethoden an Kombi wo Spec es verlangt |
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant |
| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** nach Fachspez §10.4 (Slotliste, Kandidaten aus Katalog geladen, Archetyp-Hilfstexte in `CombinationCoachSlots`/`combinationArchetypes.js`); **Stufe B/C** (Zeitleisten-Splitting, Stations-/Intervall-Timing) — **offen**, siehe Anhang A der Fachspez |
| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** — Merge Katalog+Planung; `CombinationPlanBracket` in Peek/Run; globale Profilzahlen mit Labels (`describeGlobalComboProfile`); Stations-/Timing-Zusammenfassung inkl. Wdh.-Hinweise. **Stufe B/C****offen**10.6, Anhang A) |
| **5** | Rahmenprogramm: Modulübernahme UX in Slot-Blueprint-Editor konsolidieren | geplant |
## Coaching — verbindliche Arbeitspakete (gegen Spec-Drift)
@ -23,9 +23,12 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z
| Paket | Spec-Referenz | Kurzinhalt |
|-------|----------------|-----------|
| **4a (Ist/Ziel)** | §10.2.1 | Archetyp-Schlüssel bleiben identisch zu `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`) und `frontend/src/constants/combinationArchetypes.js`. |
| **4b** | §10.4 Stufe A | Slots + Kandidaten; Archetyp-Hilfstext; `method_profile` **lesend** unter der Kopf-Zeile (Key/WertListe wenn gepflegt); Feintuning Labels optional. |
| **4b** | §10.4 Stufe A | **Erreicht (0.8.110):** Slots + Kandidaten; Archetyp-Hilfstext; wirksames Profil lesend mit **fachlichen Labels**; Klammerdarstellung konsistent (`CombinationPlanBracket`, `comboPlanningMethodProfile.js`). |
| **4c** | §10.4 Stufe B | Entscheidung: virtuelle Substeps vs. persistierte Items; Konsistenz `sectionsToPutPayload`/Ist-Zeit. |
| **4d** | §10.4 Stufe C | Archetyp-spezifische Timer/Wechsel/Abhaken an `method_profile` — nach 4b/4c. |
| **4d** | §10.4 Stufe C | Archetyp-spezifische Timer/Wechsel/Abhaken an `method_profile` — nach 4c. |
| **4e** | §10.6 | **Archetyp-Verwaltung:** DB/UI oder Konfiguration statt nur Release — Labels, Defaults, ggf. Vereins-/Rollen-Sichtbarkeit. |
| **4f** | §10.6 · `COMBINATION_TIMING_PROFILE_PLAN.md` | **Massen-Vorbelegung:** ein Klick alle Slot-Zeiten/Anzahlen aus Archetyp/Global; Modal „Archetyp-Vorlage anwenden?“ (Phase 2 des Timing-Plans). |
| **4g** | §10.6 | **Backend-Validierung:** Pflichtfelder/Wertebereiche je `method_archetype`; optional serverseitiger Merge mit Katalog (aktuell nur Client). |
## Phase 1 (technische Notizen)

View File

@ -2,7 +2,7 @@
**Zweck:** Überblick über die **wesentlichen, produktiv nutzbaren Funktionen** aus Nutzer- und Fachperspektive zur Weitergabe an Design, Product Discovery oder externe Fachplanung.
**Technischer Detailstand:** App-Version und Schema siehe `backend/version.py` (Stand Code: **0.8.101**, **DB_SCHEMA_VERSION** siehe dort).
**Technischer Detailstand:** App-Version (`APP_VERSION`) und Datenbankschema (`DB_SCHEMA_VERSION`) siehe **`backend/version.py`** — dort ist der autoritative Stand.
**Vertiefung:** Domänenmodell `.claude/docs/functional/DOMAIN_MODEL.md`, Lieferdetal `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md`, Projektstatus `.claude/docs/PROJECT_STATUS.md`, Entwickler-Handover `docs/HANDOVER.md`.

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-12
**App-Version / DB-Schema:** App **0.8.101**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
**App-Version / DB-Schema:** App **0.8.110**, DB-Schema **`20260512057`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -74,11 +74,11 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
### Trainingsmodule, Kombinationsübungen und Coach (Stand ~0.8.101)
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.110**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§10.2.1** IDs, **§10.4** Coaching-Stufen, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ad**).
- **Ist kurz:** Trainingsmodule-Bibliothek (Phase **1**) umgesetzt; Kombi-Katalog (**056**) + Einplanung + Coach **Stufe A** (`CombinationCoachSlots`, `combinationArchetypes.js`). Coach **Stufe B/C** und geführtes **`method_profile`** offen — siehe Fachspez Anhang A.
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§10.2.1** IDs, **§10.4** Coaching-Stufen, **§10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
- **Ist kurz:** Trainingsmodule-Bibliothek (Phase **1**) umgesetzt; Kombi-Katalog (**056**), Planungs-Snapshot **`planning_method_profile` (057)** mit **Client-Merge** Katalog+Planung (`comboPlanningMethodProfile.js`); Planung: Modal **„Ablauf bearbeiten…“**, Klammer `CombinationPlanBracket`; Coach **Stufe A** mit lesenden Profil-Labels und konsistenter Slot-Darstellung (`CombinationCoachSlots`, `ExerciseFullContent`). **Offen:** Coach **Stufe B/C** (individuelle Archetyp-Steuerung), **Administrierbarkeit der Archetypen** (derzeit nur Konstanten), **einfache Vorbelegung aller** Zeit-/Anzahlfelder, **serverseitige** Profil↔Archetyp-Restriktionen — siehe Fachspez **§10.6**.
---
@ -160,7 +160,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
5. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
6. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
7. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
8. **Kombinations-Coach (Archetyp B/C):** Fachspez §10.4; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
8. **Kombinations-Coach (Archetyp B/C):** Fachspez §10.4 / **§10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
9. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**).
10. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`).
11. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**).
---
@ -169,7 +172,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Bereich | Einstieg |
|---------|----------|
| Backend API | `backend/main.py`; u.a. **`media_assets.py`**, **`exercises.py`** (`COMBINATION_ARCHETYPE_IDS`, `enrich_exercise_detail`), **`profiles.py`**, **`training_framework_programs.py`**, `tenant_context.py` |
| Coach-Kombination (Frontend) | `TrainingCoachPage.jsx`, `ExerciseFullContent.jsx`, `CombinationCoachSlots.jsx`, `constants/combinationArchetypes.js` |
| Coach-Kombination / Merge-Profil (Frontend) | `TrainingCoachPage.jsx`, `ExerciseFullContent.jsx`, `CombinationCoachSlots.jsx`, `CombinationPlanBracket.jsx`, `utils/comboPlanningMethodProfile.js`, `utils/combinationMethodProfileUi.js`, `constants/combinationArchetypes.js` |
| Migrationen | `backend/migrations/` (040+ Mitgliedschaft/Governance; **045+** Medien-Stack) |
| Frontend API | `frontend/src/utils/api.js` |
| Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` |

View File

@ -1,14 +1,14 @@
import React from 'react'
import {
BrowserRouter as Router,
Routes,
Route,
RouterProvider,
createBrowserRouter,
Navigate,
NavLink,
useLocation,
Outlet,
} from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
@ -162,114 +162,116 @@ function PublicRoute({ children }) {
return !isAuthenticated ? children : <Navigate to="/" replace />
}
function AppRoutes() {
return (
<Routes>
<Route path="/verify" element={<VerifyPage />} />
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
{/* P-01: Öffentliche Rechtstextseiten — kein Auth erforderlich */}
<Route path="/impressum" element={<LegalPage type="impressum" />} />
<Route path="/datenschutz" element={<LegalPage type="datenschutz" />} />
<Route path="/nutzungsbedingungen" element={<LegalPage type="nutzungsbedingungen" />} />
<Route path="/medienrichtlinie" element={<LegalPage type="medienrichtlinie" />} />
<Route element={<ProtectedLayout />}>
<Route index element={<Dashboard />} />
<Route path="profile" element={<Navigate to="/settings" replace />} />
<Route path="settings" element={<AccountSettingsPage />} />
<Route path="settings/system" element={<SettingsSystemInfoPage />} />
<Route path="settings/legal" element={<SettingsLegalPage />} />
<Route path="media" element={<MediaLibraryPage />} />
<Route path="exercises">
<Route index element={<ExercisesListPage />} />
<Route path="new" element={<ExerciseFormPage />} />
<Route path=":id/edit" element={<ExerciseFormPage />} />
<Route path=":id" element={<ExerciseDetailPage />} />
</Route>
<Route path="clubs" element={<ClubsPage />} />
<Route path="inbox" element={<InboxPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs" element={<TrainingFrameworkProgramsListPage />} />
<Route path="planning/training-modules/new" element={<TrainingModuleEditPage />} />
<Route path="planning/training-modules/:id" element={<TrainingModuleEditPage />} />
<Route path="planning/training-modules" element={<TrainingModulesListPage />} />
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<AdminHomeRedirect />} />
<Route
path="admin/users"
element={
<PlatformAdminRoute>
<AdminUsersPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/hierarchy"
element={
<PlatformAdminRoute>
<AdminHierarchyPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/maturity-models"
element={
<PlatformAdminRoute>
<AdminMaturityModelsPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/catalogs"
element={
<PlatformAdminRoute>
<AdminCatalogsPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/mediawiki-import"
element={
<PlatformAdminRoute>
<MediaWikiImportPage />
</PlatformAdminRoute>
}
/>
<Route
path="admin/legal-documents"
element={
<PlatformAdminRoute>
<AdminLegalDocumentsPage />
</PlatformAdminRoute>
}
/>
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
/**
* Data Router erforderlich für `useBlocker` (ungespeicherte Änderungen).
* Klassisches `BrowserRouter` stellt keinen DataRouterContext bereit; ohne Migration
* werfen Seiten mit `useUnsavedChangesBlocker` beim Rendern eine Invariante.
*/
const appRouter = createBrowserRouter([
{ path: '/verify', element: <VerifyPage /> },
{
path: '/login',
element: (
<PublicRoute>
<LoginPage />
</PublicRoute>
),
},
{ path: '/impressum', element: <LegalPage type="impressum" /> },
{ path: '/datenschutz', element: <LegalPage type="datenschutz" /> },
{ path: '/nutzungsbedingungen', element: <LegalPage type="nutzungsbedingungen" /> },
{ path: '/medienrichtlinie', element: <LegalPage type="medienrichtlinie" /> },
{
element: <ProtectedLayout />,
children: [
{ index: true, element: <Dashboard /> },
{ path: 'profile', element: <Navigate to="/settings" replace /> },
{ path: 'settings', element: <AccountSettingsPage /> },
{ path: 'settings/system', element: <SettingsSystemInfoPage /> },
{ path: 'settings/legal', element: <SettingsLegalPage /> },
{ path: 'media', element: <MediaLibraryPage /> },
{
path: 'exercises',
children: [
{ index: true, element: <ExercisesListPage /> },
{ path: 'new', element: <ExerciseFormPage /> },
{ path: ':id/edit', element: <ExerciseFormPage /> },
{ path: ':id', element: <ExerciseDetailPage /> },
],
},
{ path: 'clubs', element: <ClubsPage /> },
{ path: 'inbox', element: <InboxPage /> },
{ path: 'skills', element: <SkillsPage /> },
{ path: 'planning/framework-programs/new', element: <TrainingFrameworkProgramEditPage /> },
{ path: 'planning/framework-programs/:id', element: <TrainingFrameworkProgramEditPage /> },
{ path: 'planning/framework-programs', element: <TrainingFrameworkProgramsListPage /> },
{ path: 'planning/training-modules/new', element: <TrainingModuleEditPage /> },
{ path: 'planning/training-modules/:id', element: <TrainingModuleEditPage /> },
{ path: 'planning/training-modules', element: <TrainingModulesListPage /> },
{ path: 'planning/run/:unitId/coach', element: <TrainingCoachPage /> },
{ path: 'planning/run/:unitId', element: <TrainingUnitRunPage /> },
{ path: 'planning', element: <TrainingPlanningPage /> },
{ path: 'admin', element: <AdminHomeRedirect /> },
{
path: 'admin/users',
element: (
<PlatformAdminRoute>
<AdminUsersPage />
</PlatformAdminRoute>
),
},
{
path: 'admin/hierarchy',
element: (
<PlatformAdminRoute>
<AdminHierarchyPage />
</PlatformAdminRoute>
),
},
{
path: 'admin/maturity-models',
element: (
<PlatformAdminRoute>
<AdminMaturityModelsPage />
</PlatformAdminRoute>
),
},
{
path: 'admin/catalogs',
element: (
<PlatformAdminRoute>
<AdminCatalogsPage />
</PlatformAdminRoute>
),
},
{
path: 'admin/mediawiki-import',
element: (
<PlatformAdminRoute>
<MediaWikiImportPage />
</PlatformAdminRoute>
),
},
{
path: 'admin/legal-documents',
element: (
<PlatformAdminRoute>
<AdminLegalDocumentsPage />
</PlatformAdminRoute>
),
},
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
],
},
{ path: '*', element: <Navigate to="/" replace /> },
])
function App() {
return (
<AuthProvider>
<Router>
<AppRoutes />
</Router>
<ToastProvider>
<RouterProvider router={appRouter} />
</ToastProvider>
</AuthProvider>
)
}

View File

@ -6398,6 +6398,68 @@ a.analysis-split__nav-item {
color: var(--text3);
margin-right: 6px;
}
.combo-plan-bracket__station-exercises--interactive {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
}
.combo-plan-bracket__cand-inline {
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.combo-plan-bracket__cand-sep {
color: var(--text3);
font-size: 0.78rem;
user-select: none;
}
.combo-plan-bracket__cand-btn {
margin: 0;
padding: 2px 8px;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
color: var(--accent-dark);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
text-align: left;
line-height: 1.35;
}
.combo-plan-bracket__cand-btn:hover {
border-color: var(--accent);
background: var(--surface2);
}
.combo-plan-bracket__cand-link {
font-size: 0.84rem;
font-weight: 600;
color: var(--accent-dark);
text-decoration: underline;
text-underline-offset: 2px;
}
.combo-plan-bracket__cand-link:hover {
color: var(--accent);
}
button.combo-coach-cand-link {
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
font-size: 0.84rem;
font-weight: 600;
color: var(--accent);
text-decoration: underline;
cursor: pointer;
text-align: left;
}
button.combo-coach-cand-link:hover {
color: var(--accent-dark);
}
.training-run-combo-embed {
margin-top: 0.65rem;
}
@ -7522,3 +7584,103 @@ a.analysis-split__nav-item {
margin: 0 0 8px;
color: var(--text1);
}
/* ── Toasts (kurze Infos, ohne OK-Dialog) ───────────────────────── */
.toast-stack {
position: fixed;
bottom: calc(88px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
z-index: 9500;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
pointer-events: none;
width: min(92vw, 26rem);
}
@media (min-width: 900px) {
.toast-stack {
bottom: 1.5rem;
left: auto;
right: 1.75rem;
transform: none;
align-items: flex-end;
width: min(380px, 36vw);
}
}
.toast {
pointer-events: auto;
width: 100%;
padding: 0.65rem 1rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text1);
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
cursor: pointer;
animation: toast-in 0.22s ease-out;
}
.toast:active {
transform: scale(0.985);
}
.toast:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.toast__text {
display: block;
}
.toast--success {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
}
.toast--info {
background: var(--surface2);
}
.toast--error {
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Ungesicherte Änderungen — Navigation */
.unsaved-prompt-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
background: rgba(0, 0, 0, 0.42);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.unsaved-prompt-sheet {
max-width: 22rem;
width: 100%;
padding: 1.15rem 1.25rem 1.25rem;
animation: toast-in 0.2s ease-out;
}
.unsaved-prompt-title {
font-size: 1.06rem;
margin: 0 0 0.5rem;
color: var(--text1);
}
.unsaved-prompt-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}

View File

@ -35,15 +35,26 @@ export default function CombinationCoachSlots({
methodProfile,
compactPlanningView = false,
omitGlobalKeyValueBlock = false,
/** Wenn gesetzt: Kandidaten als Button → Peek (kein Router-Wechsel, PWA-sicher) */
onOpenCandidatePeek,
}) {
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
const set = new Set()
for (const s of slots) {
for (const id of s.candidate_exercise_ids || []) {
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
if (Number.isFinite(n)) set.add(n)
if (Array.isArray(s.candidates) && s.candidates.length) {
for (const c of s.candidates) {
const raw = c.exercise_id
if (raw == null) continue
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (Number.isFinite(n)) set.add(n)
}
} else {
for (const id of s.candidate_exercise_ids || []) {
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
if (Number.isFinite(n)) set.add(n)
}
}
}
return [...set]
@ -282,9 +293,19 @@ export default function CombinationCoachSlots({
<>
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
<p style={{ margin: 0 }}>
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
Im Katalog öffnen
</Link>
{typeof onOpenCandidatePeek === 'function' ? (
<button
type="button"
className="combo-coach-cand-link"
onClick={() => onOpenCandidatePeek(cid)}
>
Details anzeigen
</button>
) : (
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
Im Katalog öffnen
</Link>
)}
</p>
</>
) : (
@ -320,9 +341,19 @@ export default function CombinationCoachSlots({
</details>
) : null}
<p style={{ marginTop: '8px', marginBottom: 0 }}>
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
Volle Übungsseite
</Link>
{typeof onOpenCandidatePeek === 'function' ? (
<button
type="button"
className="combo-coach-cand-link"
onClick={() => onOpenCandidatePeek(cid)}
>
Volle Übungsansicht
</button>
) : (
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
Volle Übungsseite
</Link>
)}
</p>
</>
)

View File

@ -2,6 +2,7 @@
* Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
*/
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import {
archetypeCoachHint,
combinationArchetypeLabel,
@ -14,24 +15,22 @@ import {
stationPrimaryLoadLabel,
} from '../utils/combinationMethodProfileUi'
function candidateLine(slot) {
const cands = slot.candidates
if (Array.isArray(cands) && cands.length > 0) {
return cands
.map((c) =>
((c.title || '').trim() || (c.exercise_id != null ? `Übung #${c.exercise_id}` : '')).trim(),
)
.filter(Boolean)
.join(' ↔ ')
/** @returns {{ exerciseId: number, label: string }[]} */
export function normalizeCombinationSlotCandidates(slot) {
const out = []
const cands =
slot.candidates && slot.candidates.length
? slot.candidates
: (slot.candidate_exercise_ids || []).map((id) => ({ exercise_id: id, title: null }))
for (const c of cands) {
const rawId = c.exercise_id
if (rawId == null) continue
const n = typeof rawId === 'number' ? rawId : parseInt(String(rawId), 10)
if (!Number.isFinite(n)) continue
const label = ((c.title || '').trim() || `Übung #${n}`).trim()
out.push({ exerciseId: n, label })
}
const ids = slot.candidate_exercise_ids || []
return ids
.map((raw) => {
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
return Number.isFinite(n) ? `Übung #${n}` : ''
})
.filter(Boolean)
.join(' ↔ ')
return out
}
export default function CombinationPlanBracket({
@ -39,6 +38,9 @@ export default function CombinationPlanBracket({
methodProfile,
combinationSlots,
planningAdjusted = false,
/** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */
candidateInteraction = 'none',
onCandidatePeek,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const archLabel = arch ? combinationArchetypeLabel(arch) : null
@ -97,7 +99,8 @@ export default function CombinationPlanBracket({
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
const displayStep = si + 1
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
const names = candidateLine(slot)
const candRows = normalizeCombinationSlotCandidates(slot)
const names = candRows.length ? candRows.map((r) => r.label).join(' ↔ ') : ''
const slotProfRow = timingByIx.get(stationIx)
const loadBadge = stationPrimaryLoadLabel(slotProfRow)
const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
@ -112,7 +115,35 @@ export default function CombinationPlanBracket({
</div>
<div className="combo-plan-bracket__station-main">
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
{candidateInteraction === 'button' && typeof onCandidatePeek === 'function' && candRows.length > 0 ? (
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
{candRows.map((c, ci) => (
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
{ci > 0 ? <span className="combo-plan-bracket__cand-sep"></span> : null}
<button
type="button"
className="combo-plan-bracket__cand-btn"
onClick={() => onCandidatePeek(c.exerciseId, c.label)}
>
{c.label}
</button>
</span>
))}
</div>
) : candidateInteraction === 'link' && candRows.length > 0 ? (
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
{candRows.map((c, ci) => (
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
{ci > 0 ? <span className="combo-plan-bracket__cand-sep"></span> : null}
<Link to={`/exercises/${c.exerciseId}`} className="combo-plan-bracket__cand-link">
{c.label}
</Link>
</span>
))}
</div>
) : (
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
)}
{timing ? (
<div className="combo-plan-bracket__station-timing">
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>

View File

@ -54,9 +54,18 @@ function metaParts(exercise) {
}
/**
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null }} props
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null, onCandidateExercisePeek?: (exerciseId: number) => void }} props
*/
export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile, catalogMethodProfileSnapshot }) {
export default function ExerciseFullContent({
exercise,
loading,
error,
exerciseId,
variantId,
planningComboMethodProfile,
catalogMethodProfileSnapshot,
onCandidateExercisePeek,
}) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '1rem' }}>
@ -129,6 +138,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
combinationSlots={exercise.combination_slots}
methodArchetype={exercise.method_archetype}
methodProfile={coachComboProfile}
onOpenCandidatePeek={onCandidateExercisePeek}
/>
) : null}
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>

View File

@ -1,7 +1,8 @@
/**
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
* Unterstützt Drill-down zu Kandidaten-Übungen bei Kombinationen inkl. Zurück (PWA-sicher).
*/
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
@ -25,6 +26,8 @@ function TagMini({ exercise }) {
)
}
/** @typedef {{ exerciseId: number, variantId?: number | null, peekExtras?: object | null }} PeekStackEntry */
export default function ExercisePeekModal({
open,
exerciseId,
@ -37,36 +40,37 @@ export default function ExercisePeekModal({
const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null)
const [exercise, setExercise] = useState(null)
/** @type {[PeekStackEntry[], React.Dispatch<React.SetStateAction<PeekStackEntry[]>>]} */
const [stack, setStack] = useState([])
const variant =
variantId != null && variantId !== '' && exercise?.variants?.length
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
: null
const isCombination =
exercise &&
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const comboMethodProfileEffective = useMemo(() => {
if (!exercise || !isCombination) return {}
const fromPeek =
peekExtras?.catalog_method_profile &&
typeof peekExtras.catalog_method_profile === 'object' &&
!Array.isArray(peekExtras.catalog_method_profile) &&
Object.keys(peekExtras.catalog_method_profile).length > 0
? peekExtras.catalog_method_profile
: exercise.method_profile || {}
return effectiveComboMethodProfile(fromPeek, peekExtras?.planning_method_profile ?? null)
}, [exercise, isCombination, peekExtras])
/** @type {React.MutableRefObject<boolean>} */
const wasOpenRef = useRef(false)
useEffect(() => {
if (!open) {
setExercise(null)
setErr(null)
setStack([])
wasOpenRef.current = false
return
}
if (!exerciseId) {
setErr('Keine Übung gewählt')
if (exerciseId == null || exerciseId === '') return
if (!wasOpenRef.current) {
wasOpenRef.current = true
setStack([
{
exerciseId: Number(exerciseId),
variantId: variantId ?? null,
peekExtras: peekExtras ?? null,
},
])
}
}, [open, exerciseId, variantId, peekExtras])
const top = stack.length ? stack[stack.length - 1] : null
useEffect(() => {
if (!open || !top?.exerciseId) {
setExercise(null)
setErr(null)
return
}
let cancelled = false
@ -74,7 +78,7 @@ export default function ExercisePeekModal({
setLoading(true)
setErr(null)
try {
const data = await api.getExercise(exerciseId)
const data = await api.getExercise(top.exerciseId)
if (!cancelled) setExercise(data)
} catch (e) {
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
@ -85,7 +89,40 @@ export default function ExercisePeekModal({
return () => {
cancelled = true
}
}, [open, exerciseId, variantId])
}, [open, top?.exerciseId])
const variant =
top?.variantId != null &&
top.variantId !== '' &&
exercise?.variants?.length
? exercise.variants.find((v) => String(v.id) === String(top.variantId)) || null
: null
const isCombination =
exercise && String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const comboMethodProfileEffective = useMemo(() => {
if (!exercise || !isCombination) return {}
const fromPeek =
top?.peekExtras?.catalog_method_profile &&
typeof top.peekExtras.catalog_method_profile === 'object' &&
!Array.isArray(top.peekExtras.catalog_method_profile) &&
Object.keys(top.peekExtras.catalog_method_profile).length > 0
? top.peekExtras.catalog_method_profile
: exercise.method_profile || {}
return effectiveComboMethodProfile(fromPeek, top?.peekExtras?.planning_method_profile ?? null)
}, [exercise, isCombination, top?.peekExtras])
const planningAdjustedBadge =
top?.peekExtras?.planning_method_profile != null &&
typeof top.peekExtras.planning_method_profile === 'object' &&
!Array.isArray(top.peekExtras.planning_method_profile)
const pushCandidatePeek = (id) => {
const n = Number(id)
if (!Number.isFinite(n)) return
setStack((s) => [...s, { exerciseId: n, variantId: null, peekExtras: null }])
}
if (!open) return null
@ -107,9 +144,19 @@ export default function ExercisePeekModal({
}}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-peek-title" className="admin-modal-sheet__title">
{loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`}
<div className="admin-modal-sheet__header" style={{ gap: '8px', flexWrap: 'wrap' }}>
{stack.length > 1 ? (
<button
type="button"
className="btn btn-secondary"
style={{ flexShrink: 0, fontWeight: 600 }}
onClick={() => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s))}
>
Zurück
</button>
) : null}
<h3 id="exercise-peek-title" className="admin-modal-sheet__title" style={{ flex: '1 1 200px', minWidth: 0 }}>
{loading ? '…' : exercise?.title || titleFallback || (top?.exerciseId != null ? `Übung #${top.exerciseId}` : 'Übung')}
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
@ -130,14 +177,10 @@ export default function ExercisePeekModal({
<CombinationPlanBracket
methodArchetype={exercise.method_archetype || ''}
methodProfile={comboMethodProfileEffective}
combinationSlots={
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
}
planningAdjusted={
peekExtras?.planning_method_profile != null &&
typeof peekExtras.planning_method_profile === 'object' &&
!Array.isArray(peekExtras.planning_method_profile)
}
combinationSlots={Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []}
planningAdjusted={planningAdjustedBadge}
candidateInteraction="button"
onCandidatePeek={pushCandidatePeek}
/>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
</>
@ -210,13 +253,17 @@ export default function ExercisePeekModal({
</>
)}
</div>
{exerciseId && (
{top?.exerciseId != null ? (
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
<Link to={`/exercises/${exerciseId}`} className="btn btn-secondary" style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}>
<Link
to={`/exercises/${top.exerciseId}`}
className="btn btn-secondary"
style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}
>
Vollständige Übungsseite öffnen
</Link>
</div>
)}
) : null}
</div>
</div>
)

View File

@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal'
const VIS_OPTIONS = [
@ -102,6 +103,8 @@ export default function ExerciseProgressionGraphPanel({
}) {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const filteredGraphVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
[isSuperadmin],
@ -134,6 +137,10 @@ export default function ExerciseProgressionGraphPanel({
const [notesDraft, setNotesDraft] = useState('')
const [uiTab, setUiTab] = useState('overview')
useEffect(() => {
setSelectedGraphId(null)
}, [tenantClubDepKey])
const refreshGraphs = useCallback(async () => {
const list = await api.listExerciseProgressionGraphs()
setGraphs(Array.isArray(list) ? list : [])
@ -171,7 +178,7 @@ export default function ExerciseProgressionGraphPanel({
return () => {
cancelled = true
}
}, [refreshGraphs])
}, [refreshGraphs, tenantClubDepKey])
useEffect(() => {
if (!selectedGraphId) {

View File

@ -1596,6 +1596,12 @@ export default function TrainingUnitSectionsEditor({
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
!Array.isArray(comboPlanningModalItem.planning_method_profile)
}
candidateInteraction={onPeekExercise ? 'button' : 'none'}
onCandidatePeek={
onPeekExercise
? (exId) => onPeekExercise(Number(exId), null, undefined)
: undefined
}
/>
</div>
) : (

View File

@ -0,0 +1,63 @@
import React from 'react'
import { createPortal } from 'react-dom'
/**
* Bei blocker.state === "blocked": Speichern, Abbrechen (auf Seite bleiben), Verwerfen (Navigation fortsetzen).
*/
export default function UnsavedChangesPrompt({
blocker,
isBusy,
onSave,
onDiscardWithoutSave,
title = 'Ungespeicherte Änderungen',
detail = 'Es gibt Änderungen, die noch nicht gespeichert sind. Was möchten Sie tun?',
}) {
if (!blocker || blocker.state !== 'blocked') return null
return createPortal(
<div
className="unsaved-prompt-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="unsaved-prompt-title"
onMouseDown={(e) => {
if (e.target === e.currentTarget && !isBusy) blocker.reset()
}}
>
<div className="unsaved-prompt-sheet card" onMouseDown={(e) => e.stopPropagation()}>
<h2 id="unsaved-prompt-title" className="unsaved-prompt-title">
{title}
</h2>
<p style={{ margin: '0 0 1rem', fontSize: '0.9375rem', color: 'var(--text2)', lineHeight: 1.55 }}>
{detail}
</p>
<div className="unsaved-prompt-actions">
<button
type="button"
className="btn btn-primary"
disabled={isBusy}
onClick={onSave}
>
Speichern
</button>
<button type="button" className="btn btn-secondary" disabled={isBusy} onClick={() => blocker.reset()}>
Abbrechen
</button>
<button
type="button"
className="btn"
disabled={isBusy}
style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}
onClick={() => {
onDiscardWithoutSave()
blocker.proceed()
}}
>
Nicht speichern
</button>
</div>
</div>
</div>,
document.body,
)
}

View File

@ -0,0 +1,90 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
/** Kurze Meldungen ohne OK-Dialog; echte Bestätigungen erfolgen gesondert (modale Wahlmöglichkeiten). */
const ToastContext = createContext(null)
function nextId() {
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
export function ToastProvider({ children }) {
const [items, setItems] = useState([])
const timers = useRef(new Map())
const removeToast = useCallback((id) => {
const t = timers.current.get(id)
if (t) {
window.clearTimeout(t)
timers.current.delete(id)
}
setItems((prev) => prev.filter((x) => x.id !== id))
}, [])
const pushToast = useCallback(
(message, { variant = 'info', duration = 3200, id: forcedId } = {}) => {
const id = forcedId || nextId()
setItems((prev) => [...prev, { id, message: String(message || '').trim(), variant }])
const ms =
variant === 'error' ? Math.max(duration, 5200) : Math.max(duration, 2400)
const handle = window.setTimeout(() => removeToast(id), ms)
timers.current.set(id, handle)
return id
},
[removeToast],
)
useEffect(
() => () => {
timers.current.forEach((h) => window.clearTimeout(h))
timers.current.clear()
},
[],
)
const api = useMemo(
() => ({
/** Erfolgs- und Hinweis-Meldungen (auto-dismiss). */
show: pushToast,
success: (msg, opts) => pushToast(msg, { ...opts, variant: 'success' }),
info: (msg, opts) => pushToast(msg, { ...opts, variant: 'info' }),
error: (msg, opts) => pushToast(msg, { ...opts, variant: 'error' }),
dismiss: removeToast,
}),
[pushToast, removeToast],
)
return (
<ToastContext.Provider value={api}>
{children}
<div className="toast-stack" aria-live="polite" aria-relevant="additions removals">
{items.map((t) => (
<button
key={t.id}
type="button"
className={`toast toast--${t.variant}`}
onClick={() => removeToast(t.id)}
title="Schließen"
>
<span className="toast__text">{t.message}</span>
</button>
))}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) {
throw new Error('useToast muss innerhalb von ToastProvider verwendet werden.')
}
return ctx
}

View File

@ -0,0 +1,31 @@
import { useCallback, useEffect } from 'react'
import { useBlocker } from 'react-router-dom'
/**
* Blockiert SPA-internes Routing bei ungesichertem Bearbeitungszustand (data router oder BrowserRouter ab RR 6.22).
* Kombination mit einem Dialog (Speichern / Abbrechen / Verwerfen) und optionally useBeforeUnloadWhen.
*/
export function useUnsavedChangesBlocker(when) {
const shouldBlock = useCallback(
({ currentLocation, nextLocation }) =>
!!when &&
(currentLocation.pathname !== nextLocation.pathname ||
currentLocation.search !== nextLocation.search ||
currentLocation.hash !== nextLocation.hash),
[when],
)
return useBlocker(when ? shouldBlock : false)
}
/** Tab/Fenster schließen oder harten Reload — natives Browser-Verhalten mit generischem Warnhinweis. */
export function useBeforeUnloadWhen(when) {
useEffect(() => {
if (!when) return undefined
const fn = (ev) => {
ev.preventDefault()
ev.returnValue = ''
}
window.addEventListener('beforeunload', fn)
return () => window.removeEventListener('beforeunload', fn)
}, [when])
}

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget'
import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget'
@ -27,6 +28,7 @@ function Dashboard() {
const [phase0Stats, setPhase0Stats] = useState(null)
const [phase0Err, setPhase0Err] = useState(null)
const { user } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
useEffect(() => {
loadData()
@ -88,7 +90,7 @@ function Dashboard() {
return () => {
cancelled = true
}
}, [user?.id])
}, [user?.id, tenantClubDepKey])
useEffect(() => {
if (!user?.id) {
@ -142,7 +144,7 @@ function Dashboard() {
return () => {
cancelled = true
}
}, [user?.id])
}, [user?.id, tenantClubDepKey])
const loadData = async () => {
try {

View File

@ -3,8 +3,9 @@ import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
import CombinationPlanBracket from '../components/CombinationPlanBracket'
import ExercisePeekModal from '../components/ExercisePeekModal'
import { formatSkillLevelSlug } from '../constants/skillLevels'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
function TagRow({ exercise }) {
const tags = []
@ -58,6 +59,8 @@ function ExerciseDetailPage() {
const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
/** Schnellansicht für eingebettete Einzelübungen (Kombination) — ohne Route zu verlassen */
const [embeddedPeekExerciseId, setEmbeddedPeekExerciseId] = useState(null)
useEffect(() => {
let cancelled = false
@ -108,8 +111,25 @@ function ExerciseDetailPage() {
const meta = metaParts(exercise)
const fromExerciseEdit = location.state?.fromExerciseEdit === true
const isCombinationDetail =
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
Array.isArray(exercise.combination_slots) &&
exercise.combination_slots.length > 0
const catalogMethodProfileForBracket =
exercise.method_profile &&
typeof exercise.method_profile === 'object' &&
!Array.isArray(exercise.method_profile)
? exercise.method_profile
: {}
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<ExercisePeekModal
key={embeddedPeekExerciseId != null ? String(embeddedPeekExerciseId) : 'exercise-detail-peek'}
open={embeddedPeekExerciseId != null}
exerciseId={embeddedPeekExerciseId}
onClose={() => setEmbeddedPeekExerciseId(null)}
/>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
@ -137,39 +157,26 @@ function ExerciseDetailPage() {
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
</div>
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
Array.isArray(exercise.combination_slots) &&
exercise.combination_slots.length > 0 && (
<section className="card exercise-detail-section">
<h2>Stationen und Übungspools</h2>
{exercise.method_archetype ? (
<p style={{ fontSize: '14px', color: 'var(--text2)', marginTop: 0 }}>
Archetyp: <code>{String(exercise.method_archetype)}</code>
</p>
) : null}
<ol style={{ paddingLeft: '1.25rem', marginBottom: 0 }}>
{sortCombinationSlotsForDisplay(exercise.combination_slots).map((s, idx) => (
<li key={`${s.slot_index}-${idx}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}>
<strong>{(s.title || '').trim() || `Station ${idx + 1}`}</strong>
<ul style={{ margin: '4px 0 0', paddingLeft: '1.2rem' }}>
{(s.candidates && s.candidates.length
? s.candidates
: (s.candidate_exercise_ids || []).map((id) => ({
exercise_id: id,
title: null,
}))
).map((c) => (
<li key={c.exercise_id}>
<Link to={`/exercises/${c.exercise_id}`}>Übung #{c.exercise_id}</Link>
{c.title ? `${c.title}` : ''}
</li>
))}
</ul>
</li>
))}
</ol>
</section>
)}
{isCombinationDetail ? (
<section className="card exercise-detail-section">
<h2>Ablauf und Stationen</h2>
<p style={{ marginTop: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
KatalogAblauf mit Archetyp, Zeiten und Stationen. Station bzw. Einzelübung antippen öffnet eine
Schnellansicht mit Kurztext und Ablauf, ohne diese Seite zu verlassen. Die vollständige Übungsseite
liegt im Popup unten als Link.
</p>
<div className="training-run-combo-embed">
<CombinationPlanBracket
methodArchetype={String(exercise.method_archetype || '').trim()}
methodProfile={catalogMethodProfileForBracket}
combinationSlots={exercise.combination_slots}
planningAdjusted={false}
candidateInteraction="button"
onCandidatePeek={(exerciseId) => setEmbeddedPeekExerciseId(Number(exerciseId))}
/>
</div>
</section>
) : null}
{exercise.goal && (
<section className="card exercise-detail-section">

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef, useMemo } from 'react'
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
@ -16,9 +16,17 @@ import {
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
import {
activeClubMemberships,
getDefaultClubIdForGovernanceForms,
getTenantClubDependencyKey,
} from '../utils/activeClub'
import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes'
import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
const INTENSITY_OPTIONS = [
{ value: '', label: '—' },
@ -322,6 +330,7 @@ function emptyForm() {
training_types_multi: [],
target_groups_multi: [],
visibility: 'private',
club_id: null,
status: 'draft',
skills: [],
exercise_kind: 'simple',
@ -361,6 +370,12 @@ function detailToForm(exercise) {
is_primary: !!g.is_primary,
})),
visibility: exercise.visibility || 'private',
club_id:
String(exercise.visibility || '').trim().toLowerCase() === 'club' &&
exercise.club_id != null &&
exercise.club_id !== ''
? Number(exercise.club_id)
: null,
status: exercise.status || 'draft',
skills:
exercise.skills?.map((s) => ({
@ -455,6 +470,45 @@ function ExerciseFormPage() {
const navigate = useNavigate()
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
useEffect(() => {
if (!isPlatformAdmin) {
setClubsForGovernanceForms([])
return undefined
}
let cancelled = false
;(async () => {
try {
const list = await api.listClubs()
if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
} catch {
if (!cancelled) setClubsForGovernanceForms([])
}
})()
return () => {
cancelled = true
}
}, [isPlatformAdmin, tenantClubDepKey])
const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
/** Plattform-Admin: alle Vereine; sonst nur Mitgliedschafts-Vereine. */
const visibilityClubChoices = useMemo(() => {
if (isPlatformAdmin && clubsForGovernanceForms.length > 0) {
return [...clubsForGovernanceForms].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
)
}
return [...membershipClubRows].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
)
}, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows])
const governanceDefaultClubId = useMemo(() => getDefaultClubIdForGovernanceForms(user), [user])
const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
const isEdit = exerciseId != null
@ -469,6 +523,11 @@ function ExerciseFormPage() {
const [saving, setSaving] = useState(false)
const [formDirty, setFormDirty] = useState(false)
const [skillPick, setSkillPick] = useState('')
const toast = useToast()
const allowUnloadBlock = Boolean(formDirty && !loading && !saving)
useBeforeUnloadWhen(allowUnloadBlock)
const blocker = useUnsavedChangesBlocker(allowUnloadBlock)
const [variants, setVariants] = useState([])
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
const [variantSavingId, setVariantSavingId] = useState(null)
@ -507,15 +566,7 @@ function ExerciseFormPage() {
return () => document.removeEventListener('dragover', onDragOverDoc)
}, [])
useEffect(() => {
if (!formDirty) return undefined
const warn = (ev) => {
ev.preventDefault()
ev.returnValue = ''
}
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [formDirty])
useEffect(() => {
if (!archiveOpen) return undefined
@ -561,7 +612,7 @@ function ExerciseFormPage() {
} catch (e) {
if (!cancelled) {
console.error(e)
alert(
toast.error(
'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
(e.message || e),
)
@ -572,7 +623,7 @@ function ExerciseFormPage() {
return () => {
cancelled = true
}
}, [])
}, [toast])
useEffect(() => {
if (!isEdit) {
@ -599,7 +650,7 @@ function ExerciseFormPage() {
setFormDirty(false)
} catch (err) {
if (!cancelled) {
alert(err.message || 'Übung nicht ladbar')
toast.error(err.message || 'Übung nicht ladbar')
navigate('/exercises')
}
} finally {
@ -610,7 +661,7 @@ function ExerciseFormPage() {
return () => {
cancelled = true
}
}, [isEdit, exerciseId, navigate])
}, [isEdit, exerciseId, navigate, toast])
useEffect(() => {
if (variantEditSelection == null || variantEditSelection === 'new') return
@ -630,6 +681,38 @@ function ExerciseFormPage() {
setFormData((prev) => ({ ...prev, [field]: value }))
}
useEffect(() => {
if (formData.visibility !== 'club') return
const choices = visibilityClubChoices
if (!choices.length) return
const id =
formData.club_id != null && formData.club_id !== '' ? Number(formData.club_id) : NaN
const hasValid =
Number.isFinite(id) && id > 0 && choices.some((c) => Number(c.id) === id)
if (hasValid) return
const fallback = governanceDefaultClubId
const next =
fallback != null &&
Number.isFinite(Number(fallback)) &&
choices.some((c) => Number(c.id) === Number(fallback))
? Number(fallback)
: Number(choices[0].id)
setFormData((prev) => {
if (prev.visibility !== 'club') return prev
if (prev.club_id != null && Number(prev.club_id) === next) return prev
return { ...prev, club_id: next }
})
}, [
formData.visibility,
formData.club_id,
visibilityClubChoices,
governanceDefaultClubId,
])
const [comboStationPickerIx, setComboStationPickerIx] = useState(null)
const [comboDropTargetIx, setComboDropTargetIx] = useState(null)
@ -686,7 +769,7 @@ function ExerciseFormPage() {
})
let nextIds = ordered
if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) {
window.alert(
toast.info(
`Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner WechselPool. Überschüssige Auswahl wurde abgeschnitten.`,
)
nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
@ -712,11 +795,11 @@ function ExerciseFormPage() {
const addSkillRow = () => {
const id = skillPick ? parseInt(skillPick, 10) : null
if (!id) {
alert('Fähigkeit wählen')
toast.error('Fähigkeit wählen')
return
}
if (formData.skills.some((s) => s.skill_id === id)) {
alert('Bereits zugeordnet')
toast.info('Bereits zugeordnet')
return
}
updateFormField('skills', [
@ -752,121 +835,138 @@ function ExerciseFormPage() {
updateFormField('skills', next)
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.title || formData.title.trim().length < 3) {
alert('Titel mindestens 3 Zeichen')
return
}
const payloadBase = {
...formData,
equipment:
typeof formData.equipmentLines === 'string'
? formData.equipmentLines
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
: [],
}
let payload
try {
payload = buildExerciseApiPayload(payloadBase)
} catch (err) {
alert(err.message)
return
}
setSaving(true)
try {
if (isEdit) {
const saveOnce = (extras = {}) =>
api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
try {
await saveOnce()
} catch (firstErr) {
if (
firstErr.status === 422 &&
firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
firstErr.payload?.media_assets
) {
alert(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
)
throw firstErr
}
if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
const miss = (firstErr.payload.assets_missing_copyright || []).length
let msg =
'Die Übung ist oder wird offiziell. '
if (promo > 0) {
msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
const performSaveAttempt = useCallback(
async ({ fromUnsavedDialog = false } = {}) => {
if (!formData.title || formData.title.trim().length < 3) {
toast.error('Titel mindestens 3 Zeichen')
return false
}
const payloadBase = {
...formData,
equipment:
typeof formData.equipmentLines === 'string'
? formData.equipmentLines
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
: [],
}
let payload
try {
payload = buildExerciseApiPayload(payloadBase)
} catch (err) {
toast.error(err.message)
return false
}
setSaving(true)
try {
if (isEdit) {
const saveOnce = (extras = {}) =>
api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
try {
await saveOnce()
} catch (firstErr) {
if (
firstErr.status === 422 &&
firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
firstErr.payload?.media_assets
) {
toast.error(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
)
throw firstErr
}
if (miss > 0) {
msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
}
msg += 'Fortfahren?'
if (!window.confirm(msg)) throw firstErr
let defaultCopyright = ''
if (miss > 0) {
defaultCopyright = window.prompt(
'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
const miss = (firstErr.payload.assets_missing_copyright || []).length
let msg = 'Die Übung ist oder wird offiziell. '
if (promo > 0) {
msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
}
if (miss > 0) {
msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
}
msg += 'Fortfahren?'
if (!window.confirm(msg)) throw firstErr
let defaultCopyright = ''
if (miss > 0) {
defaultCopyright = window.prompt(
'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
'© ',
)
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.')
throw firstErr
}
}
await saveOnce({
promote_attached_media_for_official: true,
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
})
} else if (
firstErr.status === 422 &&
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
firstErr.payload?.media_assets
) {
const miss = firstErr.payload.media_assets.length
const msg =
`Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` +
`${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` +
`Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?`
if (!window.confirm(msg)) throw firstErr
const defaultCopyright = window.prompt(
'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):',
'© ',
)
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
alert('Mindestens 3 Zeichen für den Copyright-Vermerk.')
toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.')
throw firstErr
}
}
await saveOnce({
promote_attached_media_for_official: true,
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
})
} else if (
firstErr.status === 422 &&
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
firstErr.payload?.media_assets
) {
const miss = firstErr.payload.media_assets.length
const msg =
`Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` +
`${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` +
`Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?`
if (!window.confirm(msg)) throw firstErr
const defaultCopyright = window.prompt(
'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):',
'© ',
)
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
alert('Mindestens 3 Zeichen für den Copyright-Vermerk.')
await saveOnce({
default_club_media_copyright: String(defaultCopyright).trim(),
})
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
toast.error(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
)
throw firstErr
} else {
throw firstErr
}
await saveOnce({
default_club_media_copyright: String(defaultCopyright).trim(),
})
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
alert(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
)
throw firstErr
} else {
throw firstErr
}
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
setFormDirty(false)
toast.success('Gespeichert.')
return true
}
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
setFormDirty(false)
alert('Gespeichert.')
} else {
const created = await api.createExercise(payload)
navigate(`/exercises/${created.id}/edit`, { replace: true })
setFormDirty(false)
toast.success('Übung angelegt.')
if (!fromUnsavedDialog) {
navigate(`/exercises/${created.id}/edit`, { replace: true })
}
return true
} catch (err) {
toast.error('Fehler beim Speichern: ' + err.message)
return false
} finally {
setSaving(false)
}
} catch (err) {
alert('Fehler beim Speichern: ' + err.message)
} finally {
setSaving(false)
}
},
[exerciseId, formData, isEdit, navigate, toast],
)
const handleSubmit = async (e) => {
e.preventDefault()
await performSaveAttempt({ fromUnsavedDialog: false })
}
const handleUnsavedDialogSave = async () => {
const ok = await performSaveAttempt({ fromUnsavedDialog: true })
if (ok) blocker.proceed()
}
const refreshMedia = async () => {
@ -888,7 +988,7 @@ function ExerciseFormPage() {
setArchiveOpen(false)
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
toast.error(e.message || String(e))
}
}
@ -921,7 +1021,7 @@ function ExerciseFormPage() {
}
}
} catch (err) {
alert(err.message)
toast.error(err.message)
}
}
@ -940,7 +1040,7 @@ function ExerciseFormPage() {
)
setMediaList(next)
} catch (e) {
alert(e.message || String(e))
toast.error(e.message || String(e))
}
}
@ -955,7 +1055,7 @@ function ExerciseFormPage() {
})
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
toast.error(e.message || String(e))
} finally {
setMediaSavingId(null)
}
@ -975,7 +1075,7 @@ function ExerciseFormPage() {
const saveVariantRow = async (row) => {
const payload = buildVariantPayloadFromRow(row)
if (payload.variant_name.length < 3) {
alert('Variantenname mindestens 3 Zeichen')
toast.error('Variantenname mindestens 3 Zeichen')
return
}
setVariantSavingId(row.id)
@ -983,7 +1083,7 @@ function ExerciseFormPage() {
await api.updateExerciseVariant(exerciseId, row.id, payload)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
toast.error(e.message || String(e))
} finally {
setVariantSavingId(null)
}
@ -997,7 +1097,7 @@ function ExerciseFormPage() {
if (variantEditSelection === id) setVariantEditSelection(null)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
toast.error(e.message || String(e))
} finally {
setVariantBusy(false)
}
@ -1016,7 +1116,7 @@ function ExerciseFormPage() {
await api.reorderExerciseVariants(exerciseId, ids)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
toast.error(e.message || String(e))
} finally {
setVariantBusy(false)
}
@ -1027,7 +1127,7 @@ function ExerciseFormPage() {
if (!exerciseId) return
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
alert('Variantenname mindestens 3 Zeichen')
toast.error('Variantenname mindestens 3 Zeichen')
return
}
setVariantBusy(true)
@ -1038,7 +1138,7 @@ function ExerciseFormPage() {
if (created?.id != null) setVariantEditSelection(created.id)
else setVariantEditSelection(null)
} catch (err) {
alert(err.message || String(err))
toast.error(err.message || String(err))
} finally {
setVariantBusy(false)
}
@ -1072,18 +1172,7 @@ function ExerciseFormPage() {
type="button"
className="btn btn-secondary"
style={{ marginLeft: '8px' }}
onClick={() => {
if (
formDirty &&
!window.confirm(
'Es gibt noch nicht über „Speichern“ gesicherte Änderungen (Texte, Zuordnungen, …).\n\n' +
'Zur Ansicht wechseln und diese Änderungen verwerfen?',
)
) {
return
}
navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })
}}
onClick={() => navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })}
>
Ansehen
</button>
@ -1832,6 +1921,29 @@ function ExerciseFormPage() {
</div>
</div>
{formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
<div className="form-row" style={{ marginTop: '10px' }}>
<label className="form-label">Verein (Sichtbarkeit)</label>
<select
className="form-input"
value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''}
onChange={(e) => {
const v = e.target.value
updateFormField('club_id', v === '' ? null : Number(v))
}}
>
{visibilityClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{(c.name || '').trim() || `Verein #${c.id}`}
</option>
))}
</select>
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)', lineHeight: 1.4 }}>
Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
</p>
</div>
) : null}
<div style={{ marginTop: '16px' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'}
@ -2321,6 +2433,12 @@ function ExerciseFormPage() {
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
<code>api.suggestExerciseAi</code>).
</p>
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setFormDirty(false)}
/>
</div>
)
}

View File

@ -14,7 +14,7 @@ import {
} from 'lucide-react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
@ -155,6 +155,7 @@ function ExercisesListPage() {
const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [mineOnly, setMineOnly] = useState(() => {
try {
@ -623,7 +624,7 @@ function ExercisesListPage() {
return () => {
cancelled = true
}
}, [queryBase, catalogsReady, pageTab])
}, [queryBase, catalogsReady, pageTab, tenantClubDepKey])
const loadMore = async () => {
if (loadingMore || !hasMore) return

View File

@ -22,7 +22,7 @@ import {
} from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import { activeClubMemberships } from '../utils/activeClub'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RightsDeclarationDialog from '../components/RightsDeclarationDialog'
import ReportContentModal from '../components/ReportContentModal'
@ -296,6 +296,7 @@ export default function MediaLibraryPage() {
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const hasClubOrgAdmin = activeClubMemberships(user?.clubs).some((c) => (c.roles || []).includes('club_admin'))
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const archiveVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
@ -381,7 +382,7 @@ export default function MediaLibraryPage() {
useEffect(() => {
loadClubs()
}, [loadClubs])
}, [loadClubs, tenantClubDepKey])
const loadItems = useCallback(async () => {
const seq = ++mediaListFetchSeqRef.current
@ -415,7 +416,7 @@ export default function MediaLibraryPage() {
} finally {
if (seq === mediaListFetchSeqRef.current) setLoading(false)
}
}, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta])
}, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta, tenantClubDepKey])
useEffect(() => {
const t = setTimeout(() => {

View File

@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal'
import {
flattenPlanTimeline,
itemStableKey,
@ -178,6 +179,7 @@ export default function TrainingCoachPage() {
const [trainerAppend, setTrainerAppend] = useState('')
const [saveMarkDone, setSaveMarkDone] = useState(true)
const [saving, setSaving] = useState(false)
const [candidatePeekId, setCandidatePeekId] = useState(null)
const [saveOk, setSaveOk] = useState(null)
const reloadUnit = useCallback(async () => {
@ -460,6 +462,12 @@ export default function TrainingCoachPage() {
return (
<div className="training-coach-page training-coach-layout">
<ExercisePeekModal
key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'}
open={candidatePeekId != null}
exerciseId={candidatePeekId}
onClose={() => setCandidatePeekId(null)}
/>
<nav
className="no-print training-coach-meta-nav"
style={{
@ -750,6 +758,7 @@ export default function TrainingCoachPage() {
? currentEntry?.item?.planning_method_profile ?? null
: null
}
onCandidateExercisePeek={(id) => setCandidatePeekId(id)}
/>
</div>
</>

View File

@ -1,10 +1,13 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import PageSectionNav from '../components/PageSectionNav'
import { useToast } from '../context/ToastContext'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import {
defaultSection,
normalizeUnitToForm,
@ -78,6 +81,32 @@ function defaultForm() {
}
}
function frameworkDraftSnapshot(fm) {
const goalsNorm = (fm.goals || []).map((g) => ({
t: (g.title || '').trim(),
n: (g.notes || '').trim(),
}))
const slotsNorm = (fm.slots || []).map((s) => ({
title: (s.title || '').trim(),
notes: (s.notes || '').trim(),
sections: s.sections,
}))
return JSON.stringify({
title: (fm.title || '').trim(),
description: (fm.description || '').trim(),
focus_area_id: fm.focus_area_id || '',
style_direction_id: fm.style_direction_id || '',
training_type_ids: [...(fm.training_type_ids || [])].map(String).sort(),
target_group_ids: [...(fm.target_group_ids || [])].map(String).sort(),
planned_period_start: fm.planned_period_start || '',
planned_period_end: fm.planned_period_end || '',
visibility: (fm.visibility || '').trim(),
club_id: (fm.club_id || '').trim(),
goals: goalsNorm,
slots: slotsNorm,
})
}
function serverFrameworkToForm(fw) {
const goalsIn = Array.isArray(fw.goals) && fw.goals.length ? fw.goals : [emptyGoal()]
return {
@ -196,6 +225,40 @@ export default function TrainingFrameworkProgramEditPage() {
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
const [mobileSlotIdx, setMobileSlotIdx] = useState(0)
const toast = useToast()
const baselineRef = useRef(null)
const latestFormRef = useRef(form)
latestFormRef.current = form
const [baselineReady, setBaselineReady] = useState(false)
const [bypassDirty, setBypassDirty] = useState(false)
const dirtySignature = frameworkDraftSnapshot(form)
useEffect(() => {
baselineRef.current = null
setBaselineReady(false)
setBypassDirty(false)
}, [idParam, isNew])
useEffect(() => {
if (loading) return
const handle = window.setTimeout(() => {
baselineRef.current = frameworkDraftSnapshot(latestFormRef.current)
setBaselineReady(true)
}, 120)
return () => clearTimeout(handle)
}, [loading, idParam, isNew])
const formDirtyEffective =
baselineReady &&
baselineRef.current != null &&
!bypassDirty &&
!loading &&
dirtySignature !== baselineRef.current
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
useEffect(() => {
const mq = window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`)
const apply = () => setDesktopLayout(!!mq.matches)
@ -266,7 +329,7 @@ export default function TrainingFrameworkProgramEditPage() {
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
setForm(next)
} catch (e) {
alert(e.message || 'Laden fehlgeschlagen')
toast.error(e.message || 'Laden fehlgeschlagen')
navigate('/planning/framework-programs')
} finally {
if (!cancelled) setLoading(false)
@ -353,42 +416,60 @@ export default function TrainingFrameworkProgramEditPage() {
}))
}
const handleSave = async () => {
const performFrameworkSave = async ({ fromUnsavedDialog = false } = {}) => {
if (!(form.title || '').trim()) {
alert('Titel ist Pflichtfeld.')
return
toast.error('Titel ist Pflichtfeld.')
return false
}
let payload
try {
payload = buildApiPayload(form)
} catch (e) {
alert(e.message || 'Validierung')
return
toast.error(e.message || 'Validierung')
return false
}
if (!payload.title) {
alert('Titel ist Pflichtfeld.')
return
toast.error('Titel ist Pflichtfeld.')
return false
}
setSaving(true)
try {
if (isNew) {
const created = await api.createTrainingFrameworkProgram(payload)
navigate(`/planning/framework-programs/${created.id}`, { replace: true })
} else {
const fid = parseInt(idParam, 10)
await api.updateTrainingFrameworkProgram(fid, payload)
const refreshed = await api.getTrainingFrameworkProgram(fid)
let next = serverFrameworkToForm(refreshed)
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
setForm(next)
toast.success('Rahmenprogramm angelegt.')
if (!fromUnsavedDialog) {
navigate(`/planning/framework-programs/${created.id}`, { replace: true })
}
return true
}
const fid = parseInt(idParam, 10)
await api.updateTrainingFrameworkProgram(fid, payload)
const refreshed = await api.getTrainingFrameworkProgram(fid)
let next = serverFrameworkToForm(refreshed)
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
setForm(next)
baselineRef.current = frameworkDraftSnapshot(next)
setBypassDirty(false)
setBaselineReady(true)
toast.success('Gespeichert.')
return true
} catch (e) {
alert(e.message || 'Speichern fehlgeschlagen')
toast.error(e.message || 'Speichern fehlgeschlagen')
return false
} finally {
setSaving(false)
}
}
const handleSave = async () => {
await performFrameworkSave({ fromUnsavedDialog: false })
}
const handleUnsavedDialogSave = async () => {
const ok = await performFrameworkSave({ fromUnsavedDialog: true })
if (ok) blocker.proceed()
}
async function handleDelete() {
if (isNew) return
const fid = parseInt(idParam, 10)
@ -397,7 +478,7 @@ export default function TrainingFrameworkProgramEditPage() {
await api.deleteTrainingFrameworkProgram(fid)
navigate('/planning/framework-programs')
} catch (e) {
alert(e.message || 'Löschen fehlgeschlagen')
toast.error(e.message || 'Löschen fehlgeschlagen')
}
}
@ -1142,11 +1223,18 @@ export default function TrainingFrameworkProgramEditPage() {
/>
<ExercisePeekModal
key={peekCtx != null ? String(peekCtx.exerciseId) : 'fw-peek-closed'}
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId || 0}
variantId={peekCtx?.variantId ?? undefined}
onClose={() => setPeekCtx(null)}
/>
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
/>
</div>
)
}

View File

@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
function dashIfEmpty(val) {
const s = (val ?? '').toString().trim()
@ -55,6 +57,8 @@ function FrameworkSummaryMeta({ r }) {
}
export default function TrainingFrameworkProgramsListPage() {
const { user } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@ -75,7 +79,7 @@ export default function TrainingFrameworkProgramsListPage() {
useEffect(() => {
load()
}, [load])
}, [load, tenantClubDepKey])
async function handleDelete(id, title) {
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return

View File

@ -1,10 +1,51 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
import { useToast } from '../context/ToastContext'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
function moduleFormSnapshot({
title,
summary,
goal,
recommendedDurationMin,
targetGroupNotes,
deploymentContextNotes,
visibility,
clubIdField,
primaryMethodId,
items,
}) {
const itemRows = items.map((it) => {
if (it.item_type === 'note') {
return { k: 'n', b: it.note_body ?? '' }
}
return {
k: 'e',
id: it.exercise_id,
v: it.exercise_variant_id,
d: it.planned_duration_min,
n: it.notes ?? '',
}
})
return JSON.stringify({
title: (title || '').trim(),
summary: (summary || '').trim(),
goal: goal || '',
recommendedDurationMin: recommendedDurationMin || '',
targetGroupNotes: targetGroupNotes || '',
deploymentContextNotes: deploymentContextNotes || '',
visibility: visibility || '',
clubIdField: (clubIdField || '').trim(),
primaryMethodId: (primaryMethodId || '').trim(),
items: itemRows,
})
}
function nextLocalKey() {
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
@ -40,18 +81,95 @@ export default function TrainingModuleEditPage() {
const [primaryMethodId, setPrimaryMethodId] = useState('')
const [items, setItems] = useState([])
const toast = useToast()
const baselineRef = useRef(null)
const latestFormRef = useRef({})
const [baselineReady, setBaselineReady] = useState(false)
const [bypassDirty, setBypassDirty] = useState(false)
latestFormRef.current = {
title,
summary,
goal,
recommendedDurationMin,
targetGroupNotes,
deploymentContextNotes,
visibility,
clubIdField,
primaryMethodId,
items,
}
const dirtySignature = moduleFormSnapshot(latestFormRef.current)
useEffect(() => {
baselineRef.current = null
setBaselineReady(false)
setBypassDirty(false)
}, [isNew, moduleId])
useEffect(() => {
if (loading) return
const handle = window.setTimeout(() => {
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
setBaselineReady(true)
}, 120)
return () => clearTimeout(handle)
}, [loading, isNew, moduleId])
const formDirtyEffective =
baselineReady && baselineRef.current != null && !bypassDirty && !loading && dirtySignature !== baselineRef.current
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
const { user } = useAuth()
const clubChoices = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
useEffect(() => {
if (!isPlatformAdmin) {
setClubsForGovernanceForms([])
return undefined
}
let cancelled = false
;(async () => {
try {
const list = await api.listClubs()
if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
} catch {
if (!cancelled) setClubsForGovernanceForms([])
}
})()
return () => {
cancelled = true
}
}, [isPlatformAdmin, tenantClubDepKey])
const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
const visibilityClubChoices = useMemo(() => {
if (isPlatformAdmin && clubsForGovernanceForms.length > 0) {
return [...clubsForGovernanceForms].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
)
}
return [...membershipClubRows].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
)
}, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows])
useEffect(() => {
if (!isNew || visibility !== 'club') return
if ((clubIdField || '').trim() !== '') return
if (clubChoices.length === 1) setClubIdField(String(clubChoices[0].id))
const xs = visibilityClubChoices
if (xs.length === 1) setClubIdField(String(xs[0].id))
else {
const r = getResolvedActiveClubIdForUi(user)
if (r) setClubIdField(String(r))
const r = getDefaultClubIdForGovernanceForms(user)
if (r != null && xs.some((c) => Number(c.id) === Number(r))) setClubIdField(String(r))
}
}, [isNew, visibility, clubIdField, clubChoices, user])
}, [isNew, visibility, clubIdField, visibilityClubChoices, user])
const itemsPayload = items.map((it, i) => {
if (it.item_type === 'note') {
@ -154,8 +272,8 @@ export default function TrainingModuleEditPage() {
if (raw !== '') {
const p = parseInt(raw, 10)
if (Number.isFinite(p) && p >= 1) cid = p
} else if (clubChoices.length === 1) {
cid = clubChoices[0].id
} else if (visibilityClubChoices.length === 1) {
cid = visibilityClubChoices[0].id
}
}
const pm =
@ -183,11 +301,10 @@ export default function TrainingModuleEditPage() {
}
}
const handleSave = async (e) => {
e.preventDefault()
const performModuleSave = async ({ fromUnsavedDialog = false } = {}) => {
if (!title.trim()) {
alert('Titel ist Pflicht.')
return
toast.error('Titel ist Pflicht.')
return false
}
setSaving(true)
setError('')
@ -195,18 +312,37 @@ export default function TrainingModuleEditPage() {
const body = buildBody()
if (isNew) {
const created = await api.createTrainingModule(body)
navigate(`/planning/training-modules/${created.id}`, { replace: true })
} else {
await api.updateTrainingModule(moduleId, body)
alert('Trainingsmodul gespeichert.')
toast.success('Trainingsmodul angelegt.')
if (!fromUnsavedDialog) {
navigate(`/planning/training-modules/${created.id}`, { replace: true })
}
return true
}
await api.updateTrainingModule(moduleId, body)
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
setBypassDirty(false)
toast.success('Gespeichert.')
return true
} catch (err) {
setError(err.message || 'Speichern fehlgeschlagen')
const msg = err.message || 'Speichern fehlgeschlagen'
setError(msg)
toast.error(msg)
return false
} finally {
setSaving(false)
}
}
const handleSave = async (e) => {
e.preventDefault()
await performModuleSave({ fromUnsavedDialog: false })
}
const handleUnsavedDialogSave = async () => {
const ok = await performModuleSave({ fromUnsavedDialog: true })
if (ok) blocker.proceed()
}
const pickExercise = async (ex) => {
if (!ex?.id) return
const row = await hydrateExercisePlanningRow(ex)
@ -303,12 +439,16 @@ export default function TrainingModuleEditPage() {
setClubIdField('')
return
}
const xs = clubChoices
const xs = visibilityClubChoices
if (xs.length === 1) setClubIdField(String(xs[0].id))
else if (xs.length === 0) setClubIdField('')
else {
const resolved = getResolvedActiveClubIdForUi(user)
setClubIdField(resolved != null ? String(resolved) : '')
const resolved = getDefaultClubIdForGovernanceForms(user)
setClubIdField(
resolved != null && xs.some((c) => Number(c.id) === Number(resolved))
? String(resolved)
: '',
)
}
}}
>
@ -324,23 +464,25 @@ export default function TrainingModuleEditPage() {
Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
Vereinsbindung fest).
</p>
) : clubChoices.length === 0 ? (
) : visibilityClubChoices.length === 0 ? (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}>
Kein aktiver Verein im Profil bitte zuerst einem Verein beitreten.
Kein Verein zur Auswahl bitte aktiven Verein im Profil wählen oder (Plattform-Admin) Vereinsliste
laden.
</p>
) : clubChoices.length === 1 ? (
) : visibilityClubChoices.length === 1 ? (
<>
<input
className="form-input"
disabled
readOnly
value={
(clubChoices[0].short_name || clubChoices[0].name || '').trim() ||
`Verein #${clubChoices[0].id}`
(visibilityClubChoices[0].short_name || visibilityClubChoices[0].name || '').trim() ||
`Verein #${visibilityClubChoices[0].id}`
}
/>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet.
Fixiert durch deine Mitgliedschaft. Verein-ID {visibilityClubChoices[0].id} wird beim Speichern
verwendet.
</p>
</>
) : (
@ -351,7 +493,7 @@ export default function TrainingModuleEditPage() {
onChange={(e) => setClubIdField(e.target.value)}
>
<option value="">Automatisch (aktueller Verein im Profil)</option>
{clubChoices.map((c) => {
{visibilityClubChoices.map((c) => {
const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
return (
<option key={c.id} value={String(c.id)}>
@ -518,6 +660,12 @@ export default function TrainingModuleEditPage() {
)}
<ExercisePickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelectExercise={pickExercise} />
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
/>
</div>
)
}

View File

@ -1,8 +1,12 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
export default function TrainingModulesListPage() {
const { user } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@ -23,7 +27,7 @@ export default function TrainingModulesListPage() {
useEffect(() => {
load()
}, [load])
}, [load, tenantClubDepKey])
async function handleDelete(id, title) {
if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return

View File

@ -2,7 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships } from '../utils/activeClub'
import { useToast } from '../context/ToastContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
@ -124,6 +125,8 @@ function filterDirectoryExcludingLead(directory, excludeLeadPid) {
}
function TrainingPlanningPage() {
const { user } = useAuth()
const toast = useToast()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [searchParams, setSearchParams] = useSearchParams()
const unitDeepLinkHandledRef = useRef(null)
const [groups, setGroups] = useState([])
@ -292,27 +295,32 @@ function TrainingPlanningPage() {
}
}, [])
const loadData = async () => {
const loadData = useCallback(async () => {
try {
const groupsData = await api.listTrainingGroups({ status: 'active' })
setGroups(groupsData)
await loadPlanTemplates()
if (groupsData.length > 0) {
const ownGroup = groupsData.find((g) => g.trainer_id === user?.id)
if (ownGroup) {
setSelectedGroupId(ownGroup.id)
} else if (groupsData.length === 1) {
setSelectedGroupId(groupsData[0].id)
}
setSelectedGroupId((prev) => {
const prevStr = prev != null && prev !== '' ? String(prev) : ''
const stillThere = prevStr && groupsData.some((g) => String(g.id) === prevStr)
if (stillThere) return prevStr
const ownGroup = groupsData.find((g) => g.trainer_id === user?.id)
if (ownGroup) return String(ownGroup.id)
if (groupsData.length === 1) return String(groupsData[0].id)
return ''
})
} else {
setSelectedGroupId('')
}
} catch (err) {
console.error('Failed to load data:', err)
alert('Fehler beim Laden: ' + err.message)
toast.error('Fehler beim Laden: ' + err.message)
} finally {
setLoading(false)
}
}
}, [user?.id, loadPlanTemplates])
const loadUnits = useCallback(async () => {
if (!selectedGroupId) return
@ -357,7 +365,7 @@ function TrainingPlanningPage() {
useEffect(() => {
loadData()
}, [])
}, [loadData, tenantClubDepKey])
useEffect(() => {
if (selectedGroupId) {
@ -482,7 +490,7 @@ function TrainingPlanningPage() {
setFwImportSelectedSlots(new Set())
setFwImportSlotDates({})
} catch (e) {
alert(e.message || 'Rahmenprogramm laden fehlgeschlagen')
toast.error(e.message || 'Rahmenprogramm laden fehlgeschlagen')
setFwImportDetail(null)
} finally {
setFwImportLoading(false)
@ -519,7 +527,7 @@ function TrainingPlanningPage() {
const submitFrameworkImport = async () => {
if (!selectedGroupId) {
alert('Bitte zuerst eine Trainingsgruppe wählen.')
toast.error('Bitte zuerst eine Trainingsgruppe wählen.')
return
}
const gid = parseInt(selectedGroupId, 10)
@ -531,14 +539,14 @@ function TrainingPlanningPage() {
(s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id
)
if (!picks.length) {
alert('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
toast.error('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
return
}
for (const s of picks) {
const key = String(s.id)
const date = fwImportSlotDates[key] || fwImportStartDate
if (!date) {
alert('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
toast.error('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
return
}
}
@ -556,7 +564,7 @@ function TrainingPlanningPage() {
setFrameworkImportOpen(false)
await loadUnits()
} catch (e) {
alert(e.message || 'Übernahme fehlgeschlagen')
toast.error(e.message || 'Übernahme fehlgeschlagen')
} finally {
setFwImportSubmitting(false)
}
@ -573,7 +581,7 @@ function TrainingPlanningPage() {
const handleCreate = () => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
toast.error('Bitte wähle zuerst eine Trainingsgruppe')
return
}
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
@ -602,7 +610,7 @@ function TrainingPlanningPage() {
const handleCreateForDate = (isoDay) => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
toast.error('Bitte wähle zuerst eine Trainingsgruppe')
return
}
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
@ -645,7 +653,7 @@ function TrainingPlanningPage() {
: [defaultSection()]
}))
} catch (err) {
alert('Vorlage laden: ' + err.message)
toast.error('Vorlage laden: ' + err.message)
}
}
@ -691,7 +699,7 @@ function TrainingPlanningPage() {
setSectionsEditMode(fullUnit.status === 'completed' ? 'debrief' : 'planning')
setShowModal(true)
} catch (err) {
alert('Fehler beim Laden: ' + err.message)
toast.error('Fehler beim Laden: ' + err.message)
throw err
}
}, [])
@ -745,9 +753,9 @@ function TrainingPlanningPage() {
}))
})
await loadPlanTemplates()
alert('Vorlage gespeichert.')
toast.success('Vorlage gespeichert.')
} catch (err) {
alert('Speichern: ' + err.message)
toast.error('Speichern: ' + err.message)
}
}
@ -793,7 +801,7 @@ function TrainingPlanningPage() {
const handleApplyTrainingModuleConfirm = useCallback(async () => {
const mid = parseInt(moduleApplyModuleId, 10)
if (!Number.isFinite(mid)) {
alert('Bitte ein Trainingsmodul wählen.')
toast.error('Bitte ein Trainingsmodul wählen.')
return
}
let secIx = parseInt(String(moduleApplySectionIx), 10)
@ -801,7 +809,7 @@ function TrainingPlanningPage() {
const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
if (!baseSections.length) {
alert('Keine Abschnitte im Formular.')
toast.error('Keine Abschnitte im Formular.')
return
}
if (secIx < 0 || secIx >= baseSections.length) secIx = 0
@ -933,7 +941,7 @@ function TrainingPlanningPage() {
await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id })
await loadUnits()
} catch (err) {
alert(err.message || 'Leitung konnte nicht übernommen werden')
toast.error(err.message || 'Leitung konnte nicht übernommen werden')
}
}
@ -980,7 +988,7 @@ function TrainingPlanningPage() {
})
await loadUnits()
} catch (err) {
alert(err.message || 'Zuweisung konnte nicht gespeichert werden')
toast.error(err.message || 'Zuweisung konnte nicht gespeichert werden')
} finally {
setAssignSaving(false)
}
@ -992,14 +1000,14 @@ function TrainingPlanningPage() {
await api.deleteTrainingUnit(unit.id)
await loadUnits()
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
toast.error('Fehler beim Löschen: ' + err.message)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.group_id || !formData.planned_date) {
alert('Gruppe und Datum sind Pflichtfelder')
toast.error('Gruppe und Datum sind Pflichtfelder')
return
}
try {
@ -1050,7 +1058,7 @@ function TrainingPlanningPage() {
setShowModal(false)
await loadUnits()
} catch (err) {
alert('Fehler beim Speichern: ' + err.message)
toast.error('Fehler beim Speichern: ' + err.message)
}
}
@ -3079,6 +3087,7 @@ function TrainingPlanningPage() {
}}
/>
<ExercisePeekModal
key={planningPeekCtx != null ? String(planningPeekCtx.exerciseId) : 'plan-peek-closed'}
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}

View File

@ -37,3 +37,39 @@ export function getResolvedActiveClubIdForUi(user) {
return Number(clubs[0].id)
}
/**
* Fallback für Formulare (Vereins-Sichtbarkeit): wie getResolvedActiveClubIdForUi, aber wenn der
* Nutzer keiner Mitgliedschaft-Vereinsliste angehört, nutzt ein Plattform-Admin weiterhin das in
* effective_club_id / active_club_id gespeicherte Mandantenziel (wie X-Active-Club-Id).
*/
export function getDefaultClubIdForGovernanceForms(user) {
const viaMembership = getResolvedActiveClubIdForUi(user)
if (viaMembership != null) return viaMembership
const role = String(user?.role || '').toLowerCase()
if (role !== 'admin' && role !== 'superadmin') return null
const rawEc = user?.effective_club_id
const rawAc = user?.active_club_id
const nEc = rawEc !== null && rawEc !== '' ? Number(rawEc) : NaN
const nAc = rawAc !== null && rawAc !== '' ? Number(rawAc) : NaN
if (Number.isFinite(nEc) && nEc > 0) return nEc
if (Number.isFinite(nAc) && nAc > 0) return nAc
try {
const ls = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
const nLs = ls && /^\d+$/.test(ls.trim()) ? Number(ls.trim()) : NaN
if (Number.isFinite(nLs) && nLs > 0) return nLs
} catch {
/* ignore */
}
return null
}
/** Für useEffect-/Query-Deps: Änderungen am Mandanten-Vereins-Kontext sollen Daten neu laden. */
export function getTenantClubDependencyKey(user) {
const m = getResolvedActiveClubIdForUi(user)
if (m != null) return String(m)
const d = getDefaultClubIdForGovernanceForms(user)
return d != null ? String(d) : 'none'
}

View File

@ -452,6 +452,8 @@ export function buildExerciseApiPayload(formData, extras = {}) {
.filter((x) => x && x.target_group_id)
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase()
const payload = {
title: (formData.title || '').trim(),
summary: formData.summary || null,
@ -476,9 +478,9 @@ export function buildExerciseApiPayload(formData, extras = {}) {
required_level: s.required_level || null,
target_level: s.target_level || null,
})),
visibility: formData.visibility || 'private',
visibility: visibilityNorm,
status: formData.status || 'draft',
club_id: formData.club_id ?? null,
club_id: visibilityNorm === 'club' ? num(formData.club_id) : null,
exercise_kind:
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'