feat(version): bump to 0.8.110 and update project specifications
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m15s

- Updated app version to 0.8.110 and database schema version to 20260512057, reflecting recent enhancements.
- Revised project status documentation to include new versioning and next steps for development.
- Enhanced the functional specification for training modules and combination exercises, detailing upcoming features and improvements.
- Improved technical specifications to align with the latest code changes, ensuring consistency across documentation.
- Introduced new UI elements for toast notifications and unsaved changes prompts to enhance user experience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 16:34:38 +02:00
parent 81fd7d9b3b
commit 49adb395dd
24 changed files with 1001 additions and 268 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import {
Outlet,
} from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
@ -267,9 +268,11 @@ function AppRoutes() {
function App() {
return (
<AuthProvider>
<Router>
<AppRoutes />
</Router>
<ToastProvider>
<Router>
<AppRoutes />
</Router>
</ToastProvider>
</AuthProvider>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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