From 49adb395dd83b279cb21b0613297beb37a15cdd8 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 16:34:38 +0200
Subject: [PATCH 1/5] feat(version): bump to 0.8.110 and update project
specifications
- Updated app version to 0.8.110 and database schema version to 20260512057, reflecting recent enhancements.
- Revised project status documentation to include new versioning and next steps for development.
- Enhanced the functional specification for training modules and combination exercises, detailing upcoming features and improvements.
- Improved technical specifications to align with the latest code changes, ensuring consistency across documentation.
- Introduced new UI elements for toast notifications and unsaved changes prompts to enhance user experience.
Co-Authored-By: Claude Sonnet 4.6
---
.claude/docs/PROJECT_STATUS.md | 7 +-
...e Kombinationsuebungen Spezifikation V2.md | 35 +-
..._MODULES_AND_COMBINATION_EXERCISES_SPEC.md | 11 +-
.../COMBINATION_TIMING_PROFILE_PLAN.md | 7 +-
.../TRAINING_MODULES_IMPLEMENTATION_PLAN.md | 13 +-
docs/FACHLICHE_NUTZERFUNKTIONEN.md | 2 +-
docs/HANDOVER.md | 17 +-
frontend/src/App.jsx | 9 +-
frontend/src/app.css | 100 +++++
.../ExerciseProgressionGraphPanel.jsx | 9 +-
.../src/components/UnsavedChangesPrompt.jsx | 63 +++
frontend/src/context/ToastContext.jsx | 90 ++++
.../src/hooks/useUnsavedChangesBlocker.js | 31 ++
frontend/src/pages/Dashboard.jsx | 8 +-
frontend/src/pages/ExerciseFormPage.jsx | 402 +++++++++++-------
frontend/src/pages/ExercisesListPage.jsx | 5 +-
frontend/src/pages/MediaLibraryPage.jsx | 7 +-
.../TrainingFrameworkProgramEditPage.jsx | 125 +++++-
.../TrainingFrameworkProgramsListPage.jsx | 8 +-
frontend/src/pages/TrainingModuleEditPage.jsx | 204 +++++++--
.../src/pages/TrainingModulesListPage.jsx | 8 +-
frontend/src/pages/TrainingPlanningPage.jsx | 66 +--
frontend/src/utils/activeClub.js | 36 ++
frontend/src/utils/api.js | 6 +-
24 files changed, 1001 insertions(+), 268 deletions(-)
create mode 100644 frontend/src/components/UnsavedChangesPrompt.jsx
create mode 100644 frontend/src/context/ToastContext.jsx
create mode 100644 frontend/src/hooks/useUnsavedChangesBlocker.js
diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md
index 493ad03..0b48c3f 100644
--- a/.claude/docs/PROJECT_STATUS.md
+++ b/.claude/docs/PROJECT_STATUS.md
@@ -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. Kalender‑UI: „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 **4e–4g**) und `COMBINATION_TIMING_PROFILE_PLAN.md`.
---
diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
index 55d3643..4150c4b 100644
--- a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
+++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
@@ -1,7 +1,7 @@
# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3
**Status:** fachlicher Spezifikationsentwurf
-**Stand:** 2026-05-12 (Anhang A **grob** App **0.8.104**; Zeit‑Pfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** § 10.2.1, § 10.4–10.5, **§ 5.4/§ 6.3** Methoden/Archetypen/Zeitschicht · **Anhang A**
+**Stand:** 2026-05-12 (Anhang A App **0.8.110**; Zeit‑Pfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** § 10.2.1, § 10.4–10.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 **Coach‑Assistenz**
**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. Ziel‑Wdh.). 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 JSON‑Struktur) | Persistenz; Übungsformular: **geführte globale Felder** + **pro Slot** vier Zeitreihen ohne Nutzer‑JSON‑Pflicht; Schnellwahl typische Arbeit/Pause‑Relationen (**Zirkel**, **Intervall**); Reihenfolge UX: Stationen vor Ablaufprofil | JSON‑„Experte“ weiter abschaltbar; Schema‑Pflichtfelder nach Archetyp; Konvergenz flache Schlüssel ↔ `timing_schema` (siehe Arbeitsplan) |
-| **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; **Zeitprofil‑Overrides** 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` / Planungs‑Snapshot (**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, Archetyp‑Hinweis, Profil lesbar | `CombinationCoachSlots`: wirksames Profil = **Planungs‑Snapshot 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 Coach‑Schritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DB‑Materialisierung; Auswirkung auf Ist‑Zeit pro Item |
-| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Coach‑Timer pro Planungsitem | Pro Archetyp UI‑State + 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 / Ziel‑Wdh. / 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 Coach‑Schritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DB‑Materialisierung; Auswirkung auf Ist‑Zeit 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 | Slot‑Blueprint, `from-framework-slot` | Modul-/Kombi‑UX 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 read‑only 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).
diff --git a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
index 5a69629..d9b0952 100644
--- a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
+++ b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md
@@ -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 **4e–4g** für Admin, Vorbelegung, Validierung).
**Verwandte Dokumente:**
diff --git a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
index 6a31017..fe95e45 100644
--- a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
+++ b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md
@@ -82,7 +82,12 @@ Objekt‑Shape (Sekunden, ganze Zahlen ≥ 0):
- **Trainingsplanung** (`plannerMode`): **keine** Roh‑JSON‑Oberfläche.
- **Übungsformular**: Roh‑JSON nur wenn `allowExpertJson === true` (Default false; später z. B. Superadmin/Dev).
-- **Coaching‑Ansicht**: nur **wirksame** Zahlen aus Snapshot/Katalog darstellen, mittelfristig Labels statt Schlüsseln.
+- **Coaching‑Ansicht**: 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 / Ziel‑Wdh. / Coach).
+- **Phase 2** dieses Plans (Modal „Archetyp‑Vorlage anwenden?“, nicht‑destruktives Merge über alle Slots) — **noch offen** (Fachspez § 10.6, Umsetzungsplan Paket **4f**).
---
diff --git a/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md b/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md
index 72cc011..89468f8 100644
--- a/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md
+++ b/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md
@@ -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 Roh‑JSON; **Backend:** keine strenge Validierung Profil ↔ Archetyp | Haupt-/Nebenmethoden an Kombi wo Spec es verlangt; serverseitige Validierung für Profil‑Schlü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/Wert‑Liste 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)
diff --git a/docs/FACHLICHE_NUTZERFUNKTIONEN.md b/docs/FACHLICHE_NUTZERFUNKTIONEN.md
index d98dc6a..45d3162 100644
--- a/docs/FACHLICHE_NUTZERFUNKTIONEN.md
+++ b/docs/FACHLICHE_NUTZERFUNKTIONEN.md
@@ -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`.
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index b9c3e97..6ad7843 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -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 **4a–d**).
-- **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 **4a–g** — 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` |
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e462127..2943d4e 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -9,6 +9,7 @@ import {
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'
@@ -267,9 +268,11 @@ function AppRoutes() {
function App() {
return (
-
-
-
+
+
+
+
+
)
}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index ed100e8..b094299 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -7522,3 +7522,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;
+}
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index 2100409..dfd8661 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -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) {
diff --git a/frontend/src/components/UnsavedChangesPrompt.jsx b/frontend/src/components/UnsavedChangesPrompt.jsx
new file mode 100644
index 0000000..845299c
--- /dev/null
+++ b/frontend/src/components/UnsavedChangesPrompt.jsx
@@ -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(
+ {
+ if (e.target === e.currentTarget && !isBusy) blocker.reset()
+ }}
+ >
+
e.stopPropagation()}>
+
+ {title}
+
+
+ {detail}
+
+
+
+ Speichern
+
+ blocker.reset()}>
+ Abbrechen
+
+ {
+ onDiscardWithoutSave()
+ blocker.proceed()
+ }}
+ >
+ Nicht speichern
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/frontend/src/context/ToastContext.jsx b/frontend/src/context/ToastContext.jsx
new file mode 100644
index 0000000..cb77da3
--- /dev/null
+++ b/frontend/src/context/ToastContext.jsx
@@ -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 (
+
+ {children}
+
+ {items.map((t) => (
+ removeToast(t.id)}
+ title="Schließen"
+ >
+ {t.message}
+
+ ))}
+
+
+ )
+}
+
+export function useToast() {
+ const ctx = useContext(ToastContext)
+ if (!ctx) {
+ throw new Error('useToast muss innerhalb von ToastProvider verwendet werden.')
+ }
+ return ctx
+}
diff --git a/frontend/src/hooks/useUnsavedChangesBlocker.js b/frontend/src/hooks/useUnsavedChangesBlocker.js
new file mode 100644
index 0000000..47fe8b6
--- /dev/null
+++ b/frontend/src/hooks/useUnsavedChangesBlocker.js
@@ -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])
+}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index e744684..d37f622 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -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 {
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index 5e29bbb..975fa07 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -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 Wechsel‑Pool. Ü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
@@ -1832,6 +1921,29 @@ function ExerciseFormPage() {
+ {formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
+
+
Verein (Sichtbarkeit)
+
{
+ const v = e.target.value
+ updateFormField('club_id', v === '' ? null : Number(v))
+ }}
+ >
+ {visibilityClubChoices.map((c) => (
+
+ {(c.name || '').trim() || `Verein #${c.id}`}
+
+ ))}
+
+
+ Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
+
+
+ ) : null}
+
{saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'}
@@ -2321,6 +2433,12 @@ function ExerciseFormPage() {
OPENROUTER_API_KEY, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
api.suggestExerciseAi).
+ setFormDirty(false)}
+ />
)
}
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 5f42b15..de91e4d 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -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
diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx
index 085180a..479a7f3 100644
--- a/frontend/src/pages/MediaLibraryPage.jsx
+++ b/frontend/src/pages/MediaLibraryPage.jsx
@@ -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(() => {
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 36dbe4c..d6df2d8 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -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')
}
}
@@ -1147,6 +1228,12 @@ export default function TrainingFrameworkProgramEditPage() {
variantId={peekCtx?.variantId ?? undefined}
onClose={() => setPeekCtx(null)}
/>
+ setBypassDirty(true)}
+ />
)
}
diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
index 0194980..b9d4841 100644
--- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
@@ -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
diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx
index 4700e12..5797e02 100644
--- a/frontend/src/pages/TrainingModuleEditPage.jsx
+++ b/frontend/src/pages/TrainingModuleEditPage.jsx
@@ -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).
- ) : clubChoices.length === 0 ? (
+ ) : visibilityClubChoices.length === 0 ? (
- 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.
- ) : clubChoices.length === 1 ? (
+ ) : visibilityClubChoices.length === 1 ? (
<>
- 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.
>
) : (
@@ -351,7 +493,7 @@ export default function TrainingModuleEditPage() {
onChange={(e) => setClubIdField(e.target.value)}
>
Automatisch (aktueller Verein im Profil)
- {clubChoices.map((c) => {
+ {visibilityClubChoices.map((c) => {
const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
return (
@@ -518,6 +660,12 @@ export default function TrainingModuleEditPage() {
)}
setPickerOpen(false)} onSelectExercise={pickExercise} />
+ setBypassDirty(true)}
+ />
)
}
diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx
index e2a85a4..fc04a0d 100644
--- a/frontend/src/pages/TrainingModulesListPage.jsx
+++ b/frontend/src/pages/TrainingModulesListPage.jsx
@@ -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
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 1ee21b8..2db122c 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -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)
}
}
diff --git a/frontend/src/utils/activeClub.js b/frontend/src/utils/activeClub.js
index 2aa95fe..066adf3 100644
--- a/frontend/src/utils/activeClub.js
+++ b/frontend/src/utils/activeClub.js
@@ -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'
+}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 8cc9fee..edb1596 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -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'
From d50bed428b1342be1e001f1188e7c4b35b41e7b1 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 17:19:59 +0200
Subject: [PATCH 2/5] refactor(App): migrate to Data Router for improved
routing and unsaved changes handling
- Replaced `BrowserRouter` and `Routes` with `createBrowserRouter` and `RouterProvider` to support unsaved changes blocking.
- Restructured route definitions for better organization and clarity, maintaining existing functionality.
- Added comments to clarify the necessity of the Data Router for handling unsaved changes.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/App.jsx | 213 +++++++++++++++++++++----------------------
1 file changed, 106 insertions(+), 107 deletions(-)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 2943d4e..5f05dae 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,8 +1,7 @@
import React from 'react'
import {
- BrowserRouter as Router,
- Routes,
- Route,
+ RouterProvider,
+ createBrowserRouter,
Navigate,
NavLink,
useLocation,
@@ -163,115 +162,115 @@ function PublicRoute({ children }) {
return !isAuthenticated ? children :
}
-function AppRoutes() {
- return (
-
- } />
-
-
-
-
- }
- />
-
- {/* P-01: Öffentliche Rechtstextseiten — kein Auth erforderlich */}
- } />
- } />
- } />
- } />
-
- }>
- } />
- } />
- } />
- } />
- } />
- } />
-
- } />
- } />
- } />
- } />
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
- } />
-
-
- } />
-
- )
-}
+/**
+ * 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: },
+ {
+ path: '/login',
+ element: (
+
+
+
+ ),
+ },
+ { path: '/impressum', element: },
+ { path: '/datenschutz', element: },
+ { path: '/nutzungsbedingungen', element: },
+ { path: '/medienrichtlinie', element: },
+ {
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'profile', element: },
+ { path: 'settings', element: },
+ { path: 'settings/system', element: },
+ { path: 'settings/legal', element: },
+ { path: 'media', element: },
+ {
+ path: 'exercises',
+ children: [
+ { index: true, element: },
+ { path: 'new', element: },
+ { path: ':id/edit', element: },
+ { path: ':id', element: },
+ ],
+ },
+ { path: 'clubs', element: },
+ { path: 'inbox', element: },
+ { path: 'skills', element: },
+ { path: 'planning/framework-programs/new', element: },
+ { path: 'planning/framework-programs/:id', element: },
+ { path: 'planning/framework-programs', element: },
+ { path: 'planning/training-modules/new', element: },
+ { path: 'planning/training-modules/:id', element: },
+ { path: 'planning/training-modules', element: },
+ { path: 'planning/run/:unitId/coach', element: },
+ { path: 'planning/run/:unitId', element: },
+ { path: 'planning', element: },
+ { path: 'admin', element: },
+ {
+ path: 'admin/users',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'admin/hierarchy',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'admin/maturity-models',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'admin/catalogs',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'admin/mediawiki-import',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'admin/legal-documents',
+ element: (
+
+
+
+ ),
+ },
+ { path: 'trainer-contexts', element: },
+ ],
+ },
+ { path: '*', element: },
+])
function App() {
return (
-
-
-
+
)
From 00edc7a93d9fd8450bc6c34c5bd184f9778ca5d4 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 21:41:32 +0200
Subject: [PATCH 3/5] feat(exercise-detail): enhance combination exercise
display with candidate links and bracket visualization
- Introduced `flattenCombinationCandidateLinks` function to streamline the extraction of unique candidate exercises for combination details.
- Updated the `ExerciseDetailPage` to conditionally render a `CombinationPlanBracket` for combination exercises, improving the visual representation of training runs.
- Enhanced the UI to display linked individual exercises associated with combination slots, providing clearer navigation and context for users.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/pages/ExerciseDetailPage.jsx | 98 +++++++++++++++--------
1 file changed, 65 insertions(+), 33 deletions(-)
diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx
index 9909f5e..3888320 100644
--- a/frontend/src/pages/ExerciseDetailPage.jsx
+++ b/frontend/src/pages/ExerciseDetailPage.jsx
@@ -3,6 +3,7 @@ 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 { formatSkillLevelSlug } from '../constants/skillLevels'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
@@ -51,6 +52,28 @@ function metaParts(exercise) {
return parts
}
+/** Eindeutige Kandidaten-Übungen für Schnellnavigation unter der Klammerdarstellung */
+function flattenCombinationCandidateLinks(slots) {
+ const rows = []
+ const seen = new Set()
+ sortCombinationSlotsForDisplay(slots || []).forEach((s) => {
+ const cands =
+ s.candidates && s.candidates.length
+ ? s.candidates
+ : (s.candidate_exercise_ids || []).map((id) => ({
+ exercise_id: id,
+ title: null,
+ }))
+ cands.forEach((c) => {
+ const eid = c.exercise_id
+ if (eid == null || seen.has(eid)) return
+ seen.add(eid)
+ rows.push({ exercise_id: eid, title: (c.title || '').trim() || null })
+ })
+ })
+ return rows
+}
+
function ExerciseDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
@@ -108,6 +131,20 @@ 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 combinationCandidateLinks = isCombinationDetail
+ ? flattenCombinationCandidateLinks(exercise.combination_slots)
+ : []
+ const catalogMethodProfileForBracket =
+ exercise.method_profile &&
+ typeof exercise.method_profile === 'object' &&
+ !Array.isArray(exercise.method_profile)
+ ? exercise.method_profile
+ : {}
+
return (
@@ -137,39 +174,34 @@ function ExerciseDetailPage() {
{meta.length > 0 &&
{meta.join(' · ')}
}
- {(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
- Array.isArray(exercise.combination_slots) &&
- exercise.combination_slots.length > 0 && (
-
- Stationen und Übungspools
- {exercise.method_archetype ? (
-
- Archetyp: {String(exercise.method_archetype)}
-
- ) : null}
-
- {sortCombinationSlotsForDisplay(exercise.combination_slots).map((s, idx) => (
-
- {(s.title || '').trim() || `Station ${idx + 1}`}
-
- {(s.candidates && s.candidates.length
- ? s.candidates
- : (s.candidate_exercise_ids || []).map((id) => ({
- exercise_id: id,
- title: null,
- }))
- ).map((c) => (
-
- Übung #{c.exercise_id}
- {c.title ? ` — ${c.title}` : ''}
-
- ))}
-
-
- ))}
-
-
- )}
+ {isCombinationDetail ? (
+
+ Ablauf und Stationen
+
+ Katalog‑Ablauf mit Archetyp, Zeiten und Stationen — dieselbe Darstellung wie in der Planung und Vorschau.
+
+
+
+
+ {combinationCandidateLinks.length > 0 ? (
+
+
Verknüpfte Einzelübungen
+
+ {combinationCandidateLinks.map((c) => (
+
+ {c.title || `Übung #${c.exercise_id}`}
+
+ ))}
+
+
+ ) : null}
+
+ ) : null}
{exercise.goal && (
From 502dddd3b3b5fe7329fea046443dcffb21952746 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 13 May 2026 21:51:52 +0200
Subject: [PATCH 4/5] feat(combo-planning): enhance candidate interaction and
UI for combination exercises
- Introduced new CSS styles for interactive candidate buttons and links in the `CombinationPlanBracket` and `CombinationCoachSlots` components, improving user engagement.
- Updated `CombinationPlanBracket` to conditionally render candidates as buttons or links based on interaction type, enhancing navigation options.
- Refactored candidate handling in `CombinationCoachSlots` to support new interaction methods, streamlining candidate exercise display.
- Enhanced `ExercisePeekModal` and related components to support candidate peek functionality, allowing for a more seamless user experience.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/app.css | 62 +++++++++
.../src/components/CombinationCoachSlots.jsx | 49 +++++--
.../src/components/CombinationPlanBracket.jsx | 69 +++++++---
.../src/components/ExerciseFullContent.jsx | 14 +-
frontend/src/components/ExercisePeekModal.jsx | 129 ++++++++++++------
.../components/TrainingUnitSectionsEditor.jsx | 6 +
frontend/src/pages/ExerciseDetailPage.jsx | 39 +-----
frontend/src/pages/TrainingCoachPage.jsx | 9 ++
.../TrainingFrameworkProgramEditPage.jsx | 1 +
frontend/src/pages/TrainingPlanningPage.jsx | 1 +
10 files changed, 270 insertions(+), 109 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index b094299..2503d53 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -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;
}
diff --git a/frontend/src/components/CombinationCoachSlots.jsx b/frontend/src/components/CombinationCoachSlots.jsx
index a5286af..e2b5975 100644
--- a/frontend/src/components/CombinationCoachSlots.jsx
+++ b/frontend/src/components/CombinationCoachSlots.jsx
@@ -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({
<>
{ex.title}
-
- Im Katalog öffnen
-
+ {typeof onOpenCandidatePeek === 'function' ? (
+ onOpenCandidatePeek(cid)}
+ >
+ Details anzeigen
+
+ ) : (
+
+ Im Katalog öffnen
+
+ )}
>
) : (
@@ -320,9 +341,19 @@ export default function CombinationCoachSlots({
) : null}
-
- Volle Übungsseite
-
+ {typeof onOpenCandidatePeek === 'function' ? (
+ onOpenCandidatePeek(cid)}
+ >
+ Volle Übungsansicht
+
+ ) : (
+
+ Volle Übungsseite
+
+ )}
>
)
diff --git a/frontend/src/components/CombinationPlanBracket.jsx b/frontend/src/components/CombinationPlanBracket.jsx
index fa63cc6..28438ee 100644
--- a/frontend/src/components/CombinationPlanBracket.jsx
+++ b/frontend/src/components/CombinationPlanBracket.jsx
@@ -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({
{stationTitle}
-
{names || '(keine Einzelübung)'}
+ {candidateInteraction === 'button' && typeof onCandidatePeek === 'function' && candRows.length > 0 ? (
+
+ {candRows.map((c, ci) => (
+
+ {ci > 0 ? ↔ : null}
+ onCandidatePeek(c.exerciseId, c.label)}
+ >
+ {c.label}
+
+
+ ))}
+
+ ) : candidateInteraction === 'link' && candRows.length > 0 ? (
+
+ {candRows.map((c, ci) => (
+
+ {ci > 0 ? ↔ : null}
+
+ {c.label}
+
+
+ ))}
+
+ ) : (
+
{names || '(keine Einzelübung)'}
+ )}
{timing ? (
Zeit / Steuerung
diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx
index 9615ac1..6458396 100644
--- a/frontend/src/components/ExerciseFullContent.jsx
+++ b/frontend/src/components/ExerciseFullContent.jsx
@@ -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 (
@@ -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}
{exercise.title}
diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx
index e3efdb8..9fae195 100644
--- a/frontend/src/components/ExercisePeekModal.jsx
+++ b/frontend/src/components/ExercisePeekModal.jsx
@@ -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
>]} */
+ 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} */
+ 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()}
>
-
-
- {loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`}
+
+ {stack.length > 1 ? (
+ setStack((s) => (s.length > 1 ? s.slice(0, -1) : s))}
+ >
+ ← Zurück
+
+ ) : null}
+
+ {loading ? '…' : exercise?.title || titleFallback || (top?.exerciseId != null ? `Übung #${top.exerciseId}` : 'Übung')}
Schließen
@@ -130,14 +177,10 @@ export default function ExercisePeekModal({
>
@@ -210,13 +253,17 @@ export default function ExercisePeekModal({
>
)}
- {exerciseId && (
+ {top?.exerciseId != null ? (
-
+
Vollständige Übungsseite öffnen
- )}
+ ) : null}
)
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index f6fdea9..995802d 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -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
+ }
/>
) : (
diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx
index 3888320..aa35822 100644
--- a/frontend/src/pages/ExerciseDetailPage.jsx
+++ b/frontend/src/pages/ExerciseDetailPage.jsx
@@ -5,7 +5,6 @@ import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
import CombinationPlanBracket from '../components/CombinationPlanBracket'
import { formatSkillLevelSlug } from '../constants/skillLevels'
-import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
function TagRow({ exercise }) {
const tags = []
@@ -52,28 +51,6 @@ function metaParts(exercise) {
return parts
}
-/** Eindeutige Kandidaten-Übungen für Schnellnavigation unter der Klammerdarstellung */
-function flattenCombinationCandidateLinks(slots) {
- const rows = []
- const seen = new Set()
- sortCombinationSlotsForDisplay(slots || []).forEach((s) => {
- const cands =
- s.candidates && s.candidates.length
- ? s.candidates
- : (s.candidate_exercise_ids || []).map((id) => ({
- exercise_id: id,
- title: null,
- }))
- cands.forEach((c) => {
- const eid = c.exercise_id
- if (eid == null || seen.has(eid)) return
- seen.add(eid)
- rows.push({ exercise_id: eid, title: (c.title || '').trim() || null })
- })
- })
- return rows
-}
-
function ExerciseDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
@@ -135,9 +112,6 @@ function ExerciseDetailPage() {
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
Array.isArray(exercise.combination_slots) &&
exercise.combination_slots.length > 0
- const combinationCandidateLinks = isCombinationDetail
- ? flattenCombinationCandidateLinks(exercise.combination_slots)
- : []
const catalogMethodProfileForBracket =
exercise.method_profile &&
typeof exercise.method_profile === 'object' &&
@@ -186,20 +160,9 @@ function ExerciseDetailPage() {
methodProfile={catalogMethodProfileForBracket}
combinationSlots={exercise.combination_slots}
planningAdjusted={false}
+ candidateInteraction="link"
/>
- {combinationCandidateLinks.length > 0 ? (
-
-
Verknüpfte Einzelübungen
-
- {combinationCandidateLinks.map((c) => (
-
- {c.title || `Übung #${c.exercise_id}`}
-
- ))}
-
-
- ) : null}
) : null}
diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx
index 8cb8481..a81bef5 100644
--- a/frontend/src/pages/TrainingCoachPage.jsx
+++ b/frontend/src/pages/TrainingCoachPage.jsx
@@ -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 (
+ setCandidatePeekId(null)}
+ />
setCandidatePeekId(id)}
/>
>
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index d6df2d8..f0a2a00 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -1223,6 +1223,7 @@ export default function TrainingFrameworkProgramEditPage() {
/>
Date: Wed, 13 May 2026 21:58:24 +0200
Subject: [PATCH 5/5] feat(exercise-detail): add embedded peek functionality
for individual exercises
- Introduced `ExercisePeekModal` to allow users to view details of individual exercises without navigating away from the Exercise Detail Page.
- Updated the UI to include a button interaction for peeking at exercises, enhancing user experience and accessibility.
- Modified the description in the Exercise Detail section to clarify the new peek feature and its functionality.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/pages/ExerciseDetailPage.jsx | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx
index aa35822..584a347 100644
--- a/frontend/src/pages/ExerciseDetailPage.jsx
+++ b/frontend/src/pages/ExerciseDetailPage.jsx
@@ -4,6 +4,7 @@ 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'
function TagRow({ exercise }) {
@@ -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
@@ -121,6 +124,12 @@ function ExerciseDetailPage() {
return (
+
setEmbeddedPeekExerciseId(null)}
+ />
navigate('/exercises')}>
← Übersicht
@@ -152,7 +161,9 @@ function ExerciseDetailPage() {
Ablauf und Stationen
- Katalog‑Ablauf mit Archetyp, Zeiten und Stationen — dieselbe Darstellung wie in der Planung und Vorschau.
+ Katalog‑Ablauf 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.
setEmbeddedPeekExerciseId(Number(exerciseId))}
/>