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

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

View File

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

View File

@ -1,7 +1,7 @@
# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3 # Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3
**Status:** fachlicher Spezifikationsentwurf **Status:** fachlicher Spezifikationsentwurf
**Stand:** 2026-05-12 (AnhangA **grob** App **0.8.104**; ZeitPfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** §10.2.1, §10.410.5, **§5.4/§6.3** Methoden/Archetypen/Zeitschicht · **Anhang A** **Stand:** 2026-05-12 (AnhangA App **0.8.110**; ZeitPfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** §10.2.1, §10.410.6, **§5.4/§6.3** Methoden/Archetypen/Zeitschicht · **Anhang A**
**Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan. **Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan.
**Wichtige Leitlinie dieser Version:** **Wichtige Leitlinie dieser Version:**
@ -417,7 +417,7 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **CoachAssistenz**
**Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits §2.5 und §8.3). **Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits §2.5 und §8.3).
**Umsetzung in der App (Stand 0.8.103):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB). **`null`** bedeutet: es wirkt das Ablaufprofil aus dem **Katalog** (`method_profile` der Übung). Ist ein Snapshot gesetzt, ersetzt er den Katalog **vollständig** für diese Platzierung (kein serverseitiges Zusammenführen). Bearbeitung in der Planungs-UI: aufklappbarer Block **„Ablaufprofil für diese Planung (Kombination)“** mit denselben geführten Feldern wie im Übungsformular. **Umsetzung in der App (Stand 0.8.110):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB, Migration **057**). **`null`** oder fehlender Key: für **Anzeige und Editor** wirkt das **Zusammenführen** aus **Katalog** (`exercises.method_profile` bzw. Join `catalog_method_profile`) **+** Snapshot — der Katalog wird **nicht** durch ein leeres Planungsobjekt verworfen; fehlende bzw. JSON-`null`-Werte im Snapshot **überschreiben** keine Katalogfelder; `slot_profiles_v1` wird **je `slot_index`** zusammengeführt (inkl. konsistenter Steuerungslogik Zeit vs. ZielWdh.). Persistenz: der Snapshot speichert nur die vom Trainer **gesetzten** Planungsdaten (nach STZ-„Runde“ können leere Objekte als `null` normalisiert werden). **Konkrete Logik:** Frontend `effectiveComboMethodProfile` / `merge` in `frontend/src/utils/comboPlanningMethodProfile.js` (Coach, Planungseditor, Druck/Vorschau konsistent). Bearbeitung in der Planungs-UI: Modal **„Ablauf bearbeiten…“** mit `CombinationMethodProfileEditor` + Vorschau `CombinationPlanBracket`.
**Coach:** soll die wirksamen Werte nach **Übernahme** und **Einheitsübersteuerungen** konsistent nachvollziehen (**§10.4**). **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). 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 ## 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. 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 | | **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 | | **Kombinationsübung im Katalog** | `exercise_kind=combination`, Slots, Pools (Kandidaten) | Migration 056, CRUD Übung mit `combination_slots`, GET liefert Slots + Kandidatentitel | Fachbezug Haupt-/Nebenmethoden aus §4/§6 dort umsetzen, wo die Domäne es noch nicht abdeckt |
| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` (+ Pilot **`slot_profiles_v1`** je Station in derselben JSONStruktur) | Persistenz; Übungsformular: **geführte globale Felder** + **pro Slot** vier Zeitreihen ohne NutzerJSONPflicht; Schnellwahl typische Arbeit/PauseRelationen (**Zirkel**, **Intervall**); Reihenfolge UX: Stationen vor Ablaufprofil | JSON„Experte“ weiter abschaltbar; SchemaPflichtfelder nach Archetyp; Konvergenz flache Schlüssel ↔ `timing_schema` (siehe Arbeitsplan) | | **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` + **`slot_profiles_v1`** | Geführtes Profil (`CombinationMethodProfileEditor`), `advance_mode` je Slot (Zeit / ZielWdh. / Coach), API-Build aus `ExerciseFormPage` | **Admin-UI für Archetypen** fehlt (nur Code-Konstanten); **serverseitige Validierung** Profil↔Archetyp offen; **volle Vorbelegung** aller Slots aus Preset/Archetyp nur teilweise (Schnellwahl) |
| **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; **ZeitprofilOverrides** nach §8.3 / §10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Override:** DB **`planning_method_profile`** je Sektions-Item (Migration **057**), Planungseditor: Details „Ablaufprofil für diese Planung“, **„Planung wie Katalog“** / **„Aus Katalog kopieren“** | Planungsblöcke als Produktkonzept · Phase 3; serverseitige Validierung Snapshot↔Archetyp optional | | **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) |
| **Zeitphasen (global / pro Slot)** | §6.3 | Über `method_profile` / PlanungsSnapshot (**gleiche JSON-Struktur** wie Katalogprofil): globale Schlüssel im Übungs- und Planungseditor; weiterhin **keine** eigenständigen slotgebundenen Zeitlisten im UI | `slot_timing[]` oder äquivalent definieren und editieren | | **Darstellung Planung / Lauf / Druck** | Konsistente Zeiten & Wdh. | `CombinationPlanBracket`, `effectiveStationTimingSummary`, Belastungs-Badge je Station; kompakte Kombi-Zeile in `TrainingUnitSectionsEditor` | Feintuning nach Nutzerfeedback |
| **Coaching Stufe A** | Slots + Kandidaten sichtbar, ArchetypHinweis, Profil lesbar | `CombinationCoachSlots`: wirksames Profil = **PlanungsSnapshot wenn gesetzt, sonst Katalog**; Anzeige **Key/Value** | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) | | **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 B** | Zeitleiste archetypnah (z.B. Schritt pro Station) | **Nein** — ein CoachSchritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DBMaterialisierung; Auswirkung auf IstZeit pro Item | | **Coaching Stufe 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 C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** CoachTimer pro Planungsitem | Pro Archetyp UIState + Anbindung an `method_profile` | | **Coaching Stufe B** | Zeitleiste archetypnah | **Nein** — ein CoachSchritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DBMaterialisierung; Auswirkung auf IstZeit pro Item |
| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Minuten-/Ist-Input pro Item; **kein** Stations-Timer-State | Pro Archetyp UI + `method_profile` — Haupt-Backlog |
| **Rahmenprogramm** | Gleiche Inhalte wie Einheit | SlotBlueprint, `from-framework-slot` | Modul-/KombiUX in Rahmen wie in Einheit konsolidieren (Phase 5) | | **Rahmenprogramm** | Gleiche Inhalte wie Einheit | SlotBlueprint, `from-framework-slot` | Modul-/KombiUX in Rahmen wie in Einheit konsolidieren (Phase 5) |
| **Coaching-Vorschau im Editor** | §9.3 Schritt 7 | **Nein** / nicht als eigener Modus | Optional: dieselbe `CombinationCoachSlots`Ansicht readonly im Übungseditor | | **Coaching-Vorschau im Editor** | §9.3 Schritt 7 | **Peek** / Run nutzen `CombinationPlanBracket`; kein eigener „Coach-Sim“-Modus im Übungseditor | Optional: eingebettete read-only Coach-Ansicht |
**Pflege:** Bei jeder relevanten Codeänderung diese Tabelle **in demselben PR / derselben Session** anpassen (kein stiller Drift). **Pflege:** Bei jeder relevanten Codeänderung diese Tabelle **in demselben PR / derselben Session** anpassen (kein stiller Drift).

View File

@ -1,13 +1,14 @@
# Trainingsmodule und Kombinationsübungen — Spezifikation (Entwurf) # 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. **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. - **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**.
- **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. - **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()`).
- **Umsetzungsplan:** `working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phasen 2/4 mit „teilweise“). - **Coaching:** Stufe **A** — Slots, Kandidaten, Archetyp-Hilfstext, **Label** für globale Eckdaten (`describeGlobalComboProfile` in `combinationMethodProfileUi.js`), visuelle Klammer (`CombinationPlanBracket`) in Peek/Run; Stufen **B/C** (archetypgesteuerte Zeitleiste/Takt) **offen** — Fachspez §10.4, **§10.6**, **Anhang A**.
- **Umsetzungsplan:** `working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase 2/4 „teilweise“; Pakete **4e4g** für Admin, Vorbelegung, Validierung).
**Verwandte Dokumente:** **Verwandte Dokumente:**

View File

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

View File

@ -2,7 +2,7 @@
**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, inkl. **§10.2.1**, **§10.4 Coaching-Stufen**, **Anhang A** Implementierungsabgleich — Drift-Schutz) **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` **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 ## Ziele
@ -13,9 +13,9 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z
| Phase | Inhalt | Status | | 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)** | | **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“ | **umgesetzt (MVP Schritt 1)** |
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise**Migration 056, CRUD/API, Picker/Liste; Übungsformular: geführtes **`method_profile` nach Archetyp** (`CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`) plus RohJSON; **Backend:** keine strenge Validierung Profil ↔ Archetyp | Haupt-/Nebenmethoden an Kombi wo Spec es verlangt; serverseitige Validierung für ProfilSchlüssel optional | | **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise**wie links; zusätzlich **057** `planning_method_profile`; Planungs-Merge Client (`effectiveComboMethodProfile`); Archetypen weiterhin **nur Code-Konstanten** (kein Admin) | **Offen:** Archetyp-Admin-UI; Profil↔Archetyp-Validierung Backend; „alle Slots vorbelegen“ / Presets (siehe Fachspez **§10.6**); Haupt-/Nebenmethoden an Kombi wo Spec es verlangt |
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant | | **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 | | **5** | Rahmenprogramm: Modulübernahme UX in Slot-Blueprint-Editor konsolidieren | geplant |
## Coaching — verbindliche Arbeitspakete (gegen Spec-Drift) ## 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 | | 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`. | | **4a (Ist/Ziel)** | §10.2.1 | Archetyp-Schlüssel bleiben identisch zu `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`) und `frontend/src/constants/combinationArchetypes.js`. |
| **4b** | §10.4 Stufe A | Slots + Kandidaten; Archetyp-Hilfstext; `method_profile` **lesend** unter der Kopf-Zeile (Key/WertListe wenn gepflegt); Feintuning Labels optional. | | **4b** | §10.4 Stufe A | **Erreicht (0.8.110):** Slots + Kandidaten; Archetyp-Hilfstext; wirksames Profil lesend mit **fachlichen Labels**; Klammerdarstellung konsistent (`CombinationPlanBracket`, `comboPlanningMethodProfile.js`). |
| **4c** | §10.4 Stufe B | Entscheidung: virtuelle Substeps vs. persistierte Items; Konsistenz `sectionsToPutPayload`/Ist-Zeit. | | **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) ## Phase 1 (technische Notizen)

View File

@ -2,7 +2,7 @@
**Zweck:** Überblick über die **wesentlichen, produktiv nutzbaren Funktionen** aus Nutzer- und Fachperspektive zur Weitergabe an Design, Product Discovery oder externe Fachplanung. **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`. **Vertiefung:** Domänenmodell `.claude/docs/functional/DOMAIN_MODEL.md`, Lieferdetal `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md`, Projektstatus `.claude/docs/PROJECT_STATUS.md`, Entwickler-Handover `docs/HANDOVER.md`.

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-12 **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**. 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`**. - **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`. - **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). - **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 **4ad**). - **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
- **Ist kurz:** Trainingsmodule-Bibliothek (Phase **1**) umgesetzt; Kombi-Katalog (**056**) + Einplanung + Coach **Stufe A** (`CombinationCoachSlots`, `combinationArchetypes.js`). Coach **Stufe B/C** und geführtes **`method_profile`** offen — siehe Fachspez Anhang A. - **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. 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). 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. 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 | | 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` | | 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) | | Migrationen | `backend/migrations/` (040+ Mitgliedschaft/Governance; **045+** Medien-Stack) |
| Frontend API | `frontend/src/utils/api.js` | | Frontend API | `frontend/src/utils/api.js` |
| Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` | | Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,51 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePickerModal from '../components/ExercisePickerModal'
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
import { useAuth } from '../context/AuthContext' 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() { function nextLocalKey() {
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
@ -40,18 +81,95 @@ export default function TrainingModuleEditPage() {
const [primaryMethodId, setPrimaryMethodId] = useState('') const [primaryMethodId, setPrimaryMethodId] = useState('')
const [items, setItems] = 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 { 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(() => { useEffect(() => {
if (!isNew || visibility !== 'club') return if (!isNew || visibility !== 'club') return
if ((clubIdField || '').trim() !== '') 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 { else {
const r = getResolvedActiveClubIdForUi(user) const r = getDefaultClubIdForGovernanceForms(user)
if (r) setClubIdField(String(r)) 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) => { const itemsPayload = items.map((it, i) => {
if (it.item_type === 'note') { if (it.item_type === 'note') {
@ -154,8 +272,8 @@ export default function TrainingModuleEditPage() {
if (raw !== '') { if (raw !== '') {
const p = parseInt(raw, 10) const p = parseInt(raw, 10)
if (Number.isFinite(p) && p >= 1) cid = p if (Number.isFinite(p) && p >= 1) cid = p
} else if (clubChoices.length === 1) { } else if (visibilityClubChoices.length === 1) {
cid = clubChoices[0].id cid = visibilityClubChoices[0].id
} }
} }
const pm = const pm =
@ -183,11 +301,10 @@ export default function TrainingModuleEditPage() {
} }
} }
const handleSave = async (e) => { const performModuleSave = async ({ fromUnsavedDialog = false } = {}) => {
e.preventDefault()
if (!title.trim()) { if (!title.trim()) {
alert('Titel ist Pflicht.') toast.error('Titel ist Pflicht.')
return return false
} }
setSaving(true) setSaving(true)
setError('') setError('')
@ -195,18 +312,37 @@ export default function TrainingModuleEditPage() {
const body = buildBody() const body = buildBody()
if (isNew) { if (isNew) {
const created = await api.createTrainingModule(body) const created = await api.createTrainingModule(body)
navigate(`/planning/training-modules/${created.id}`, { replace: true }) toast.success('Trainingsmodul angelegt.')
} else { if (!fromUnsavedDialog) {
await api.updateTrainingModule(moduleId, body) navigate(`/planning/training-modules/${created.id}`, { replace: true })
alert('Trainingsmodul gespeichert.') }
return true
} }
await api.updateTrainingModule(moduleId, body)
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
setBypassDirty(false)
toast.success('Gespeichert.')
return true
} catch (err) { } catch (err) {
setError(err.message || 'Speichern fehlgeschlagen') const msg = err.message || 'Speichern fehlgeschlagen'
setError(msg)
toast.error(msg)
return false
} finally { } finally {
setSaving(false) 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) => { const pickExercise = async (ex) => {
if (!ex?.id) return if (!ex?.id) return
const row = await hydrateExercisePlanningRow(ex) const row = await hydrateExercisePlanningRow(ex)
@ -303,12 +439,16 @@ export default function TrainingModuleEditPage() {
setClubIdField('') setClubIdField('')
return return
} }
const xs = clubChoices const xs = visibilityClubChoices
if (xs.length === 1) setClubIdField(String(xs[0].id)) if (xs.length === 1) setClubIdField(String(xs[0].id))
else if (xs.length === 0) setClubIdField('') else if (xs.length === 0) setClubIdField('')
else { else {
const resolved = getResolvedActiveClubIdForUi(user) const resolved = getDefaultClubIdForGovernanceForms(user)
setClubIdField(resolved != null ? String(resolved) : '') 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 Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
Vereinsbindung fest). Vereinsbindung fest).
</p> </p>
) : clubChoices.length === 0 ? ( ) : visibilityClubChoices.length === 0 ? (
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}> <p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}>
Kein aktiver Verein im Profil bitte zuerst einem Verein beitreten. Kein Verein zur Auswahl bitte aktiven Verein im Profil wählen oder (Plattform-Admin) Vereinsliste
laden.
</p> </p>
) : clubChoices.length === 1 ? ( ) : visibilityClubChoices.length === 1 ? (
<> <>
<input <input
className="form-input" className="form-input"
disabled disabled
readOnly readOnly
value={ value={
(clubChoices[0].short_name || clubChoices[0].name || '').trim() || (visibilityClubChoices[0].short_name || visibilityClubChoices[0].name || '').trim() ||
`Verein #${clubChoices[0].id}` `Verein #${visibilityClubChoices[0].id}`
} }
/> />
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}> <p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet. Fixiert durch deine Mitgliedschaft. Verein-ID {visibilityClubChoices[0].id} wird beim Speichern
verwendet.
</p> </p>
</> </>
) : ( ) : (
@ -351,7 +493,7 @@ export default function TrainingModuleEditPage() {
onChange={(e) => setClubIdField(e.target.value)} onChange={(e) => setClubIdField(e.target.value)}
> >
<option value="">Automatisch (aktueller Verein im Profil)</option> <option value="">Automatisch (aktueller Verein im Profil)</option>
{clubChoices.map((c) => { {visibilityClubChoices.map((c) => {
const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}` const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
return ( return (
<option key={c.id} value={String(c.id)}> <option key={c.id} value={String(c.id)}>
@ -518,6 +660,12 @@ export default function TrainingModuleEditPage() {
)} )}
<ExercisePickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelectExercise={pickExercise} /> <ExercisePickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelectExercise={pickExercise} />
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
/>
</div> </div>
) )
} }

View File

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

View File

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

View File

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

View File

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