From 49adb395dd83b279cb21b0613297beb37a15cdd8 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 13 May 2026 16:34:38 +0200 Subject: [PATCH 1/5] feat(version): bump to 0.8.110 and update project specifications - Updated app version to 0.8.110 and database schema version to 20260512057, reflecting recent enhancements. - Revised project status documentation to include new versioning and next steps for development. - Enhanced the functional specification for training modules and combination exercises, detailing upcoming features and improvements. - Improved technical specifications to align with the latest code changes, ensuring consistency across documentation. - Introduced new UI elements for toast notifications and unsaved changes prompts to enhance user experience. Co-Authored-By: Claude Sonnet 4.6 --- .claude/docs/PROJECT_STATUS.md | 7 +- ...e Kombinationsuebungen Spezifikation V2.md | 35 +- ..._MODULES_AND_COMBINATION_EXERCISES_SPEC.md | 11 +- .../COMBINATION_TIMING_PROFILE_PLAN.md | 7 +- .../TRAINING_MODULES_IMPLEMENTATION_PLAN.md | 13 +- docs/FACHLICHE_NUTZERFUNKTIONEN.md | 2 +- docs/HANDOVER.md | 17 +- frontend/src/App.jsx | 9 +- frontend/src/app.css | 100 +++++ .../ExerciseProgressionGraphPanel.jsx | 9 +- .../src/components/UnsavedChangesPrompt.jsx | 63 +++ frontend/src/context/ToastContext.jsx | 90 ++++ .../src/hooks/useUnsavedChangesBlocker.js | 31 ++ frontend/src/pages/Dashboard.jsx | 8 +- frontend/src/pages/ExerciseFormPage.jsx | 402 +++++++++++------- frontend/src/pages/ExercisesListPage.jsx | 5 +- frontend/src/pages/MediaLibraryPage.jsx | 7 +- .../TrainingFrameworkProgramEditPage.jsx | 125 +++++- .../TrainingFrameworkProgramsListPage.jsx | 8 +- frontend/src/pages/TrainingModuleEditPage.jsx | 204 +++++++-- .../src/pages/TrainingModulesListPage.jsx | 8 +- frontend/src/pages/TrainingPlanningPage.jsx | 66 +-- frontend/src/utils/activeClub.js | 36 ++ frontend/src/utils/api.js | 6 +- 24 files changed, 1001 insertions(+), 268 deletions(-) create mode 100644 frontend/src/components/UnsavedChangesPrompt.jsx create mode 100644 frontend/src/context/ToastContext.jsx create mode 100644 frontend/src/hooks/useUnsavedChangesBlocker.js diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 493ad03..0b48c3f 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -1,8 +1,8 @@ # Shinkan Jinkendo - Projekt-Status **Stand:** 2026-05-12 -**Version (Code):** 0.8.96 (`backend/version.py`, APP_VERSION) -**DB-Schema-Version:** `20260511053` (`backend/version.py`, DB_SCHEMA_VERSION) +**Version (Code):** 0.8.110 (`backend/version.py`, APP_VERSION) +**DB-Schema-Version:** `20260512057` (`backend/version.py`, DB_SCHEMA_VERSION) **Branch:** develop --- @@ -31,11 +31,12 @@ --- -**Nächste Schritte (Auszug — Planung/Rahmen):** +**Nächste Schritte (Auszug — Planung/Rahmen & Kombination):** 1. Kalender‑UI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk. 2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API). 3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung (**§4**). +4. **Kombinationsübungen / Coach (Fachspez § 10.6):** Coach **Stufe B/C** (archetypgesteuerte Durchführung); **Archetyp-Verwaltung** jenseits Code-Konstanten; **Massen-Vorbelegung** aller Slot-Zeit/Anzahl-Felder; **serverseitige** Validierung Profil ↔ Archetyp — siehe `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Pakete **4e–4g**) und `COMBINATION_TIMING_PROFILE_PLAN.md`. --- diff --git a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md index 55d3643..4150c4b 100644 --- a/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md +++ b/.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md @@ -1,7 +1,7 @@ # Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3 **Status:** fachlicher Spezifikationsentwurf -**Stand:** 2026-05-12 (Anhang A **grob** App **0.8.104**; Zeit‑Pfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** § 10.2.1, § 10.4–10.5, **§ 5.4/§ 6.3** Methoden/Archetypen/Zeitschicht · **Anhang A** +**Stand:** 2026-05-12 (Anhang A App **0.8.110**; Zeit‑Pfad **`COMBINATION_TIMING_PROFILE_PLAN.md`**) · **Coaching/Archetypen:** § 10.2.1, § 10.4–10.6, **§ 5.4/§ 6.3** Methoden/Archetypen/Zeitschicht · **Anhang A** **Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan. **Wichtige Leitlinie dieser Version:** @@ -417,7 +417,7 @@ Alle diese Angaben sind **Anweisungen an den Trainer** und **Coach‑Assistenz** **Nach Einplanung in eine konkrete Trainingseinheit** muss diese Zeitschicht (oder ihr Abgleich mit der Einheitsposition) für den Trainer **bearbeitbar** bleiben, **ohne** die Bibliotheksvorlage still zu überschreiben (kopier-/instanzbasierte Anpassungen — siehe bereits § 2.5 und § 8.3). -**Umsetzung in der App (Stand 0.8.103):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB). **`null`** bedeutet: es wirkt das Ablaufprofil aus dem **Katalog** (`method_profile` der Übung). Ist ein Snapshot gesetzt, ersetzt er den Katalog **vollständig** für diese Platzierung (kein serverseitiges Zusammenführen). Bearbeitung in der Planungs-UI: aufklappbarer Block **„Ablaufprofil für diese Planung (Kombination)“** mit denselben geführten Feldern wie im Übungsformular. +**Umsetzung in der App (Stand 0.8.110):** Pro Übungszeile in einer Trainingseinheit kann optional ein **JSON-Snapshot** des Ablaufprofils gespeichert werden (`planning_method_profile` in der DB, Migration **057**). **`null`** oder fehlender Key: für **Anzeige und Editor** wirkt das **Zusammenführen** aus **Katalog** (`exercises.method_profile` bzw. Join `catalog_method_profile`) **+** Snapshot — der Katalog wird **nicht** durch ein leeres Planungsobjekt verworfen; fehlende bzw. JSON-`null`-Werte im Snapshot **überschreiben** keine Katalogfelder; `slot_profiles_v1` wird **je `slot_index`** zusammengeführt (inkl. konsistenter Steuerungslogik Zeit vs. Ziel‑Wdh.). Persistenz: der Snapshot speichert nur die vom Trainer **gesetzten** Planungsdaten (nach STZ-„Runde“ können leere Objekte als `null` normalisiert werden). **Konkrete Logik:** Frontend `effectiveComboMethodProfile` / `merge` in `frontend/src/utils/comboPlanningMethodProfile.js` (Coach, Planungseditor, Druck/Vorschau konsistent). Bearbeitung in der Planungs-UI: Modal **„Ablauf bearbeiten…“** mit `CombinationMethodProfileEditor` + Vorschau `CombinationPlanBracket`. **Coach:** soll die wirksamen Werte nach **Übernahme** und **Einheitsübersteuerungen** konsistent nachvollziehen (**§ 10.4**). @@ -669,6 +669,20 @@ Archetyp-spezifisches Coaching soll **nicht** als ein einziges UX-„Monolith“ Solange diese Mindestinfos in der Datenpflege noch **nicht** validiert oder nicht geführt erfasst werden, bleibt Coaching bei **Informations-Schicht und manuellen Timern des bestehenden Coach-Dialogs** die fachlich ehrliche Darstellung (siehe Anhang A). +### 10.6 Offene und geplante Erweiterungen (Produkt-Backlog, Stand 2026-05-12) + +Die folgenden Punkte stammen aus **Session-/Chat-Arbeit** an Planung, Klammerdarstellung und Coach **Stufe A**; sie sind **noch nicht** als vollständige Produktfunktion abgeschlossen bzw. bewusst zurückgestellt: + +| Thema | Kurzbeschreibung | Status | +| ----- | ---------------- | ------ | +| **Coaching Stufe B/C (individuelle Archetyp-Steuerung)** | Über **Stufe A** (lesend: Slots, Zeiten, Archetyp-Hinweis, Kandidaten-Texte) hinaus: **pro Archetyp** gesteuerte Durchführung (z. B. Substeps bei Sequenz, Stations-/Rotations-Timer beim Zirkel, Erklärphase bei parallelen Stationen, Abhaken Parcours, Intervalluhr). § 10.4 Stufe **B** (Zeitleiste) und **C** (Assistenz). | **Offen** — aktuell nur informativ/Orientierung; kein archetypspezifischer Zustand im Coach. | +| **Administrierbarkeit der Archetypen** | Archetypen sind **fest** im Code (`COMBINATION_ARCHETYPE_IDS` Backend, `COMBINATION_ARCHETYPE_OPTIONS` Frontend); **keine** DB-/Admin-Oberfläche für Labels, Defaults, Sichtbarkeit oder club-spezifische Erweiterungen. | **Offen** — Änderungen nur per Release/Code-Review. | +| **Einfache Vorbelegung aller Zeit- und Anzahlfelder** | Teilweise: Schnellwahlen (**Zirkel**, **Intervall**), Serien-Default **1**, Archetyp-Map `ARCHETYPE_DEFAULT_REP_SERIES_COUNT`. **Fehlt:** ein Klick „alle Stationen aus globalen Eckwerten / Archetyp-Muster füllen“, Profil-weite **Reset/Übernehmen**-Presets über alle Slots. | **Teilweise** — Ausbau siehe `COMBINATION_TIMING_PROFILE_PLAN.md` § 1 („Archetyp = Struktur + Defaults“). | +| **Archetypbedingte Restriktionen & Server-Validierung** | Client führt geführte Felder; **keine** verbindliche Backend-Prüfung „Profil passt zu Archetyp“ (Pflichtschlüssel, Wertebereiche, unzulässige Slot-Kombinationen). | **Offen** — erhöht Datenqualität und Coach-Verlässlichkeit vor Stufe C. | +| **Governance Archetyp ↔ offizielle Inhalte** | Noch keine getrennte Policy „nur Superadmin darf neue Archetyp-IDs einführen“ (derzeit ohnehin nur Code). | **Offen** — relevant sobald Archetypen konfigurierbar werden. | + +**Hinweis:** § 13.1 nennt Stufe **A** als MVP-Pflicht und **B/C** als Ausbauschritte — die Tabelle oben präzisiert die **noch offenen** Arbeitspakete aus der Umsetzungspraxis. + --- ## 11. Rahmenprogramm-Integration @@ -793,7 +807,7 @@ Die Spezifikation ist daher kein technisches Pflichtenheft, sondern ein fachlich --- -## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.104**, grob) +## Anhang A — Implementierungsabgleich (Stand Code: App **0.8.110**) Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schritt; verhindert „wir haben X gebaut, die Spec sagt aber Y“ ohne dass es dokumentiert wird. @@ -801,14 +815,15 @@ Zweck: dieselbe Tabelle für **Produkt / Architekt / Agent** beim nächsten Schr |--------------------------------------------|-----------------|---------------------------------|-------------------------------------| | **Trainingsmodule (Bibliothek)** | Wiederverwendbare Blöcke, Kopier-Einfügen in Einheit | Bibliothek, API, Übernahme-Modal, Lineage-Spalte | **Phase 3** des Umsetzungsplans: erweiterter Übernahmemodus | | **Kombinationsübung im Katalog** | `exercise_kind=combination`, Slots, Pools (Kandidaten) | Migration 056, CRUD Übung mit `combination_slots`, GET liefert Slots + Kandidatentitel | Fachbezug Haupt-/Nebenmethoden aus § 4/§ 6 dort umsetzen, wo die Domäne es noch nicht abdeckt | -| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` (+ Pilot **`slot_profiles_v1`** je Station in derselben JSON‑Struktur) | Persistenz; Übungsformular: **geführte globale Felder** + **pro Slot** vier Zeitreihen ohne Nutzer‑JSON‑Pflicht; Schnellwahl typische Arbeit/Pause‑Relationen (**Zirkel**, **Intervall**); Reihenfolge UX: Stationen vor Ablaufprofil | JSON‑„Experte“ weiter abschaltbar; Schema‑Pflichtfelder nach Archetyp; Konvergenz flache Schlüssel ↔ `timing_schema` (siehe Arbeitsplan) | -| **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; **Zeitprofil‑Overrides** nach § 8.3 / § 10.5.1 | Picker, `exercise_kind` in Form/PUT, keine Variante bei Kombi; **Override:** DB **`planning_method_profile`** je Sektions-Item (Migration **057**), Planungseditor: Details „Ablaufprofil für diese Planung“, **„Planung wie Katalog“** / **„Aus Katalog kopieren“** | Planungsblöcke als Produktkonzept · Phase 3; serverseitige Validierung Snapshot↔Archetyp optional | -| **Zeitphasen (global / pro Slot)** | § 6.3 | Über `method_profile` / Planungs‑Snapshot (**gleiche JSON-Struktur** wie Katalogprofil): globale Schlüssel im Übungs- und Planungseditor; weiterhin **keine** eigenständigen slotgebundenen Zeitlisten im UI | `slot_timing[]` oder äquivalent definieren und editieren | -| **Coaching Stufe A** | Slots + Kandidaten sichtbar, Archetyp‑Hinweis, Profil lesbar | `CombinationCoachSlots`: wirksames Profil = **Planungs‑Snapshot wenn gesetzt, sonst Katalog**; Anzeige **Key/Value** | Profilwerte **lesend** benutzerfreundlicher labeln (statt nur Schlüsselnamen) | -| **Coaching Stufe B** | Zeitleiste archetypnah (z. B. Schritt pro Station) | **Nein** — ein Coach‑Schritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DB‑Materialisierung; Auswirkung auf Ist‑Zeit pro Item | -| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Coach‑Timer pro Planungsitem | Pro Archetyp UI‑State + Anbindung an `method_profile` | +| **Archetyp + Ablaufprofil am Katalogobjekt** | `method_archetype`, JSON `method_profile` + **`slot_profiles_v1`** | Geführtes Profil (`CombinationMethodProfileEditor`), `advance_mode` je Slot (Zeit / Ziel‑Wdh. / Coach), API-Build aus `ExerciseFormPage` | **Admin-UI für Archetypen** fehlt (nur Code-Konstanten); **serverseitige Validierung** Profil↔Archetyp offen; **volle Vorbelegung** aller Slots aus Preset/Archetyp nur teilweise (Schnellwahl) | +| **Einplanbarkeit (normale Planung)** | Kombi in Sektionen; Overrides § 8.3 | `planning_method_profile` JSONB; Modal **„Ablauf bearbeiten“**; **Merge** Katalog+Planung im Frontend (`effectiveComboMethodProfile`); Payload-Sanitisierung; Backend `Json()` beim Insert | Planungsblöcke Phase 3; **serverseitige** Zusammenführung/Validierung optional (aktuell Merge nur Client) | +| **Darstellung Planung / Lauf / Druck** | Konsistente Zeiten & Wdh. | `CombinationPlanBracket`, `effectiveStationTimingSummary`, Belastungs-Badge je Station; kompakte Kombi-Zeile in `TrainingUnitSectionsEditor` | Feintuning nach Nutzerfeedback | +| **Zeitphasen (global / pro Slot)** | § 6.3 | `slot_profiles_v1`, globale Archetyp-Felder, `inferAdvanceModeFromStoredSlotRow` für Legacy-Zeilen | `timing_schema`-Konvergenz laut `COMBINATION_TIMING_PROFILE_PLAN.md` | +| **Coaching Stufe A** | Slots + Kandidaten, Archetyp, Profil lesbar | `ExerciseFullContent` + `CombinationCoachSlots`: Merge Katalog+Planung; **globale Eckdaten mit fachlichen Labels** (`describeGlobalComboProfile`); Stationstexte inkl. „Wdh. ohne Wechsel zur nächsten Station“ / Pausen-Hinweis | Stufe **B/C** weiterhin **offen** (§ 10.6) | +| **Coaching Stufe B** | Zeitleiste archetypnah | **Nein** — ein Coach‑Schritt = ein Planungsitem | Designentscheid: virtuelle Substeps vs. DB‑Materialisierung; Auswirkung auf Ist‑Zeit pro Item | +| **Coaching Stufe C** | Timer/Wechsel/Abhaken nach Archetyp | Nur **generischer** Minuten-/Ist-Input pro Item; **kein** Stations-Timer-State | Pro Archetyp UI + `method_profile` — Haupt-Backlog | | **Rahmenprogramm** | Gleiche Inhalte wie Einheit | Slot‑Blueprint, `from-framework-slot` | Modul-/Kombi‑UX in Rahmen wie in Einheit konsolidieren (Phase 5) | -| **Coaching-Vorschau im Editor** | § 9.3 Schritt 7 | **Nein** / nicht als eigener Modus | Optional: dieselbe `CombinationCoachSlots`‑Ansicht read‑only im Übungseditor | +| **Coaching-Vorschau im Editor** | § 9.3 Schritt 7 | **Peek** / Run nutzen `CombinationPlanBracket`; kein eigener „Coach-Sim“-Modus im Übungseditor | Optional: eingebettete read-only Coach-Ansicht | **Pflege:** Bei jeder relevanten Codeänderung diese Tabelle **in demselben PR / derselben Session** anpassen (kein stiller Drift). diff --git a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md index 5a69629..d9b0952 100644 --- a/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md +++ b/.claude/docs/technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md @@ -1,13 +1,14 @@ # Trainingsmodule und Kombinationsübungen — Spezifikation (Entwurf) -**Status:** Entwurf zur fachlichen und technischen Abstimmung · **Stand:** 2026-05-12 +**Status:** Entwurf zur fachlichen und technischen Abstimmung · **Stand:** 2026-05-12 (Code **0.8.110**, siehe `backend/version.py`) **Zweck:** Rahmen für Umsetzung, Integration in Planung/Rahmenprogramm und Durchführung im assistierten Training (Coaching-Modus). Dieses Dokument ist **nicht** implementierungsbindend, bis die markierten **offenen Entscheidungen** geschlossen und der Status angehoben wurde. -**Abgleich mit Code (Stand ~0.8.101, Drift vermeiden):** +**Abgleich mit Code (Drift vermeiden):** -- **Kanonische Archetyp-IDs:** fest in `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`); fachliche Tabelle und UI-Labels in `frontend/src/constants/combinationArchetypes.js` — die **fachliche Master-Zuordnung** Name↔ID steht in `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` § 10.2.1. -- **Coaching:** Stufe **A** (informations-/strukturierte Slot- und Kandidatenansicht + Archetyp-Hilfstext) umgesetzt im Trainings-Coach (`ExerciseFullContent` / `CombinationCoachSlots`); Stufen **B/C** bewusst offen — siehe Fachspez § 10.4 und **Anhang A** dort. -- **Umsetzungsplan:** `working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phasen 2/4 mit „teilweise“). +- **Kanonische Archetyp-IDs:** fest in `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`); fachliche Tabelle und UI-Labels in `frontend/src/constants/combinationArchetypes.js` — die **fachliche Master-Zuordnung** Name↔ID steht in `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` § 10.2.1. **Administrierbare Archetypen** (DB/UI) gibt es **nicht**; Erweiterungen nur per Code-Release — Fachspez **§ 10.6**. +- **Planungs-Override:** `planning_method_profile` (Migration **057**) speichert einen **Snapshot**; **Merge** mit Katalogprofil erfolgt im Frontend (`frontend/src/utils/comboPlanningMethodProfile.js` — `effectiveComboMethodProfile`), nicht als serverseitiger Join. Payload-Sanitisierung vor PUT; Backend speichert JSONB zuverlässig (`Json()`). +- **Coaching:** Stufe **A** — Slots, Kandidaten, Archetyp-Hilfstext, **Label** für globale Eckdaten (`describeGlobalComboProfile` in `combinationMethodProfileUi.js`), visuelle Klammer (`CombinationPlanBracket`) in Peek/Run; Stufen **B/C** (archetypgesteuerte Zeitleiste/Takt) **offen** — Fachspez § 10.4, **§ 10.6**, **Anhang A**. +- **Umsetzungsplan:** `working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase 2/4 „teilweise“; Pakete **4e–4g** für Admin, Vorbelegung, Validierung). **Verwandte Dokumente:** diff --git a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md index 6a31017..fe95e45 100644 --- a/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md +++ b/.claude/docs/working/COMBINATION_TIMING_PROFILE_PLAN.md @@ -82,7 +82,12 @@ Objekt‑Shape (Sekunden, ganze Zahlen ≥ 0): - **Trainingsplanung** (`plannerMode`): **keine** Roh‑JSON‑Oberfläche. - **Übungsformular**: Roh‑JSON nur wenn `allowExpertJson === true` (Default false; später z. B. Superadmin/Dev). -- **Coaching‑Ansicht**: nur **wirksame** Zahlen aus Snapshot/Katalog darstellen, mittelfristig Labels statt Schlüsseln. +- **Coaching‑Ansicht**: nur **wirksame** Zahlen aus Snapshot/Katalog (Merge wie in `comboPlanningMethodProfile.js`); **globale** Profilwerte mit **fachlichen Labels** (`describeGlobalComboProfile`), nicht nur Rohschlüsseln. + +### 4.1 Stand Umsetzung (App **0.8.110**, Kurz) + +- **`slot_profiles_v1`** und Schnellwahlen Zirkel/Intervall im geführten Editor umgesetzt; **`advance_mode`** je Slot (Zeit / Ziel‑Wdh. / Coach). +- **Phase 2** dieses Plans (Modal „Archetyp‑Vorlage anwenden?“, nicht‑destruktives Merge über alle Slots) — **noch offen** (Fachspez § 10.6, Umsetzungsplan Paket **4f**). --- diff --git a/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md b/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md index 72cc011..89468f8 100644 --- a/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md +++ b/.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md @@ -2,7 +2,7 @@ **Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, inkl. **§ 10.2.1**, **§ 10.4 Coaching-Stufen**, **Anhang A** Implementierungsabgleich — Drift-Schutz) **Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md` -**Stand dieses Dokuments:** 2026-05-12 (Abgleich mit Code ~App **0.8.102**) +**Stand dieses Dokuments:** 2026-05-12 (Abgleich mit Code App **0.8.110**, siehe `backend/version.py`) ## Ziele @@ -13,9 +13,9 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z | Phase | Inhalt | Status | |-------|--------|--------| | **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“ | **umgesetzt (MVP Schritt 1)** | -| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise** — Migration 056, CRUD/API, Picker/Liste; Übungsformular: geführtes **`method_profile` nach Archetyp** (`CombinationMethodProfileEditor`, `combinationMethodProfileUi.js`) plus Roh‑JSON; **Backend:** keine strenge Validierung Profil ↔ Archetyp | Haupt-/Nebenmethoden an Kombi wo Spec es verlangt; serverseitige Validierung für Profil‑Schlüssel optional | +| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise** — wie links; zusätzlich **057** `planning_method_profile`; Planungs-Merge Client (`effectiveComboMethodProfile`); Archetypen weiterhin **nur Code-Konstanten** (kein Admin) | **Offen:** Archetyp-Admin-UI; Profil↔Archetyp-Validierung Backend; „alle Slots vorbelegen“ / Presets (siehe Fachspez **§ 10.6**); Haupt-/Nebenmethoden an Kombi wo Spec es verlangt | | **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant | -| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** nach Fachspez § 10.4 (Slotliste, Kandidaten aus Katalog geladen, Archetyp-Hilfstexte in `CombinationCoachSlots`/`combinationArchetypes.js`); **Stufe B/C** (Zeitleisten-Splitting, Stations-/Intervall-Timing) — **offen**, siehe Anhang A der Fachspez | +| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** — Merge Katalog+Planung; `CombinationPlanBracket` in Peek/Run; globale Profilzahlen mit Labels (`describeGlobalComboProfile`); Stations-/Timing-Zusammenfassung inkl. Wdh.-Hinweise. **Stufe B/C** — **offen** (§ 10.6, Anhang A) | | **5** | Rahmenprogramm: Modulübernahme UX in Slot-Blueprint-Editor konsolidieren | geplant | ## Coaching — verbindliche Arbeitspakete (gegen Spec-Drift) @@ -23,9 +23,12 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z | Paket | Spec-Referenz | Kurzinhalt | |-------|----------------|-----------| | **4a (Ist/Ziel)** | § 10.2.1 | Archetyp-Schlüssel bleiben identisch zu `backend/routers/exercises.py` (`COMBINATION_ARCHETYPE_IDS`) und `frontend/src/constants/combinationArchetypes.js`. | -| **4b** | § 10.4 Stufe A | Slots + Kandidaten; Archetyp-Hilfstext; `method_profile` **lesend** unter der Kopf-Zeile (Key/Wert‑Liste wenn gepflegt); Feintuning Labels optional. | +| **4b** | § 10.4 Stufe A | **Erreicht (0.8.110):** Slots + Kandidaten; Archetyp-Hilfstext; wirksames Profil lesend mit **fachlichen Labels**; Klammerdarstellung konsistent (`CombinationPlanBracket`, `comboPlanningMethodProfile.js`). | | **4c** | § 10.4 Stufe B | Entscheidung: virtuelle Substeps vs. persistierte Items; Konsistenz `sectionsToPutPayload`/Ist-Zeit. | -| **4d** | § 10.4 Stufe C | Archetyp-spezifische Timer/Wechsel/Abhaken an `method_profile` — nach 4b/4c. | +| **4d** | § 10.4 Stufe C | Archetyp-spezifische Timer/Wechsel/Abhaken an `method_profile` — nach 4c. | +| **4e** | § 10.6 | **Archetyp-Verwaltung:** DB/UI oder Konfiguration statt nur Release — Labels, Defaults, ggf. Vereins-/Rollen-Sichtbarkeit. | +| **4f** | § 10.6 · `COMBINATION_TIMING_PROFILE_PLAN.md` | **Massen-Vorbelegung:** ein Klick alle Slot-Zeiten/Anzahlen aus Archetyp/Global; Modal „Archetyp-Vorlage anwenden?“ (Phase 2 des Timing-Plans). | +| **4g** | § 10.6 | **Backend-Validierung:** Pflichtfelder/Wertebereiche je `method_archetype`; optional serverseitiger Merge mit Katalog (aktuell nur Client). | ## Phase 1 (technische Notizen) diff --git a/docs/FACHLICHE_NUTZERFUNKTIONEN.md b/docs/FACHLICHE_NUTZERFUNKTIONEN.md index d98dc6a..45d3162 100644 --- a/docs/FACHLICHE_NUTZERFUNKTIONEN.md +++ b/docs/FACHLICHE_NUTZERFUNKTIONEN.md @@ -2,7 +2,7 @@ **Zweck:** Überblick über die **wesentlichen, produktiv nutzbaren Funktionen** aus Nutzer- und Fachperspektive – zur Weitergabe an Design, Product Discovery oder externe Fachplanung. -**Technischer Detailstand:** App-Version und Schema siehe `backend/version.py` (Stand Code: **0.8.101**, **DB_SCHEMA_VERSION** siehe dort). +**Technischer Detailstand:** App-Version (`APP_VERSION`) und Datenbankschema (`DB_SCHEMA_VERSION`) siehe **`backend/version.py`** — dort ist der autoritative Stand. **Vertiefung:** Domänenmodell `.claude/docs/functional/DOMAIN_MODEL.md`, Lieferdetal `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md`, Projektstatus `.claude/docs/PROJECT_STATUS.md`, Entwickler-Handover `docs/HANDOVER.md`. diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index b9c3e97..6ad7843 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover **Stand:** 2026-05-12 -**App-Version / DB-Schema:** App **0.8.101**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`) +**App-Version / DB-Schema:** App **0.8.110**, DB-Schema **`20260512057`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -74,11 +74,11 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**. - **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`. -### Trainingsmodule, Kombinationsübungen und Coach (Stand ~0.8.101) +### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.110**) -- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **Anhang A** Abgleich). -- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–d**). -- **Ist kurz:** Trainingsmodule-Bibliothek (Phase **1**) umgesetzt; Kombi-Katalog (**056**) + Einplanung + Coach **Stufe A** (`CombinationCoachSlots`, `combinationArchetypes.js`). Coach **Stufe B/C** und geführtes **`method_profile`** offen — siehe Fachspez Anhang A. +- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich). +- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung). +- **Ist kurz:** Trainingsmodule-Bibliothek (Phase **1**) umgesetzt; Kombi-Katalog (**056**), Planungs-Snapshot **`planning_method_profile` (057)** mit **Client-Merge** Katalog+Planung (`comboPlanningMethodProfile.js`); Planung: Modal **„Ablauf bearbeiten…“**, Klammer `CombinationPlanBracket`; Coach **Stufe A** mit lesenden Profil-Labels und konsistenter Slot-Darstellung (`CombinationCoachSlots`, `ExerciseFullContent`). **Offen:** Coach **Stufe B/C** (individuelle Archetyp-Steuerung), **Administrierbarkeit der Archetypen** (derzeit nur Konstanten), **einfache Vorbelegung aller** Zeit-/Anzahlfelder, **serverseitige** Profil↔Archetyp-Restriktionen — siehe Fachspez **§ 10.6**. --- @@ -160,7 +160,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl 5. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt. 6. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien). 7. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen. -8. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift). +8. **Kombinations-Coach (Archetyp B/C):** Fachspez § 10.4 / **§ 10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift). +9. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**). +10. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`). +11. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**). --- @@ -169,7 +172,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl | Bereich | Einstieg | |---------|----------| | Backend API | `backend/main.py`; u. a. **`media_assets.py`**, **`exercises.py`** (`COMBINATION_ARCHETYPE_IDS`, `enrich_exercise_detail`), **`profiles.py`**, **`training_framework_programs.py`**, `tenant_context.py` | -| Coach-Kombination (Frontend) | `TrainingCoachPage.jsx`, `ExerciseFullContent.jsx`, `CombinationCoachSlots.jsx`, `constants/combinationArchetypes.js` | +| Coach-Kombination / Merge-Profil (Frontend) | `TrainingCoachPage.jsx`, `ExerciseFullContent.jsx`, `CombinationCoachSlots.jsx`, `CombinationPlanBracket.jsx`, `utils/comboPlanningMethodProfile.js`, `utils/combinationMethodProfileUi.js`, `constants/combinationArchetypes.js` | | Migrationen | `backend/migrations/` (040+ Mitgliedschaft/Governance; **045+** Medien-Stack) | | Frontend API | `frontend/src/utils/api.js` | | Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` | diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e462127..2943d4e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import { Outlet, } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' +import { ToastProvider } from './context/ToastContext' import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import DesktopSidebar from './components/DesktopSidebar' import { getMainNavItems } from './config/appNav' @@ -267,9 +268,11 @@ function AppRoutes() { function App() { return ( - - - + + + + + ) } diff --git a/frontend/src/app.css b/frontend/src/app.css index ed100e8..b094299 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -7522,3 +7522,103 @@ a.analysis-split__nav-item { margin: 0 0 8px; color: var(--text1); } + +/* ── Toasts (kurze Infos, ohne OK-Dialog) ───────────────────────── */ +.toast-stack { + position: fixed; + bottom: calc(88px + env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 9500; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + pointer-events: none; + width: min(92vw, 26rem); +} +@media (min-width: 900px) { + .toast-stack { + bottom: 1.5rem; + left: auto; + right: 1.75rem; + transform: none; + align-items: flex-end; + width: min(380px, 36vw); + } +} +.toast { + pointer-events: auto; + width: 100%; + padding: 0.65rem 1rem; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text1); + font-size: 0.9rem; + line-height: 1.4; + text-align: left; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + cursor: pointer; + animation: toast-in 0.22s ease-out; +} +.toast:active { + transform: scale(0.985); +} +.toast:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.toast__text { + display: block; +} +.toast--success { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + background: color-mix(in srgb, var(--accent) 8%, var(--surface)); +} +.toast--info { + background: var(--surface2); +} +.toast--error { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); + background: color-mix(in srgb, var(--danger) 12%, var(--surface)); +} +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Ungesicherte Änderungen — Navigation */ +.unsaved-prompt-backdrop { + position: fixed; + inset: 0; + z-index: 9998; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} +.unsaved-prompt-sheet { + max-width: 22rem; + width: 100%; + padding: 1.15rem 1.25rem 1.25rem; + animation: toast-in 0.2s ease-out; +} +.unsaved-prompt-title { + font-size: 1.06rem; + margin: 0 0 0.5rem; + color: var(--text1); +} +.unsaved-prompt-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 2100409..dfd8661 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import { useAuth } from '../context/AuthContext' +import { getTenantClubDependencyKey } from '../utils/activeClub' import ExercisePickerModal from './ExercisePickerModal' const VIS_OPTIONS = [ @@ -102,6 +103,8 @@ export default function ExerciseProgressionGraphPanel({ }) { const { user } = useAuth() const isSuperadmin = user?.role === 'superadmin' + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + const filteredGraphVisOptions = useMemo( () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), [isSuperadmin], @@ -134,6 +137,10 @@ export default function ExerciseProgressionGraphPanel({ const [notesDraft, setNotesDraft] = useState('') const [uiTab, setUiTab] = useState('overview') + useEffect(() => { + setSelectedGraphId(null) + }, [tenantClubDepKey]) + const refreshGraphs = useCallback(async () => { const list = await api.listExerciseProgressionGraphs() setGraphs(Array.isArray(list) ? list : []) @@ -171,7 +178,7 @@ export default function ExerciseProgressionGraphPanel({ return () => { cancelled = true } - }, [refreshGraphs]) + }, [refreshGraphs, tenantClubDepKey]) useEffect(() => { if (!selectedGraphId) { diff --git a/frontend/src/components/UnsavedChangesPrompt.jsx b/frontend/src/components/UnsavedChangesPrompt.jsx new file mode 100644 index 0000000..845299c --- /dev/null +++ b/frontend/src/components/UnsavedChangesPrompt.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import { createPortal } from 'react-dom' + +/** + * Bei blocker.state === "blocked": Speichern, Abbrechen (auf Seite bleiben), Verwerfen (Navigation fortsetzen). + */ +export default function UnsavedChangesPrompt({ + blocker, + isBusy, + onSave, + onDiscardWithoutSave, + title = 'Ungespeicherte Änderungen', + detail = 'Es gibt Änderungen, die noch nicht gespeichert sind. Was möchten Sie tun?', +}) { + if (!blocker || blocker.state !== 'blocked') return null + + return createPortal( +
{ + if (e.target === e.currentTarget && !isBusy) blocker.reset() + }} + > +
e.stopPropagation()}> +

+ {title} +

+

+ {detail} +

+
+ + + +
+
+
, + document.body, + ) +} diff --git a/frontend/src/context/ToastContext.jsx b/frontend/src/context/ToastContext.jsx new file mode 100644 index 0000000..cb77da3 --- /dev/null +++ b/frontend/src/context/ToastContext.jsx @@ -0,0 +1,90 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +/** Kurze Meldungen ohne OK-Dialog; echte Bestätigungen erfolgen gesondert (modale Wahlmöglichkeiten). */ +const ToastContext = createContext(null) + +function nextId() { + return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` +} + +export function ToastProvider({ children }) { + const [items, setItems] = useState([]) + const timers = useRef(new Map()) + + const removeToast = useCallback((id) => { + const t = timers.current.get(id) + if (t) { + window.clearTimeout(t) + timers.current.delete(id) + } + setItems((prev) => prev.filter((x) => x.id !== id)) + }, []) + + const pushToast = useCallback( + (message, { variant = 'info', duration = 3200, id: forcedId } = {}) => { + const id = forcedId || nextId() + setItems((prev) => [...prev, { id, message: String(message || '').trim(), variant }]) + const ms = + variant === 'error' ? Math.max(duration, 5200) : Math.max(duration, 2400) + const handle = window.setTimeout(() => removeToast(id), ms) + timers.current.set(id, handle) + return id + }, + [removeToast], + ) + + useEffect( + () => () => { + timers.current.forEach((h) => window.clearTimeout(h)) + timers.current.clear() + }, + [], + ) + + const api = useMemo( + () => ({ + /** Erfolgs- und Hinweis-Meldungen (auto-dismiss). */ + show: pushToast, + success: (msg, opts) => pushToast(msg, { ...opts, variant: 'success' }), + info: (msg, opts) => pushToast(msg, { ...opts, variant: 'info' }), + error: (msg, opts) => pushToast(msg, { ...opts, variant: 'error' }), + dismiss: removeToast, + }), + [pushToast, removeToast], + ) + + return ( + + {children} +
+ {items.map((t) => ( + + ))} +
+
+ ) +} + +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) { + throw new Error('useToast muss innerhalb von ToastProvider verwendet werden.') + } + return ctx +} diff --git a/frontend/src/hooks/useUnsavedChangesBlocker.js b/frontend/src/hooks/useUnsavedChangesBlocker.js new file mode 100644 index 0000000..47fe8b6 --- /dev/null +++ b/frontend/src/hooks/useUnsavedChangesBlocker.js @@ -0,0 +1,31 @@ +import { useCallback, useEffect } from 'react' +import { useBlocker } from 'react-router-dom' + +/** + * Blockiert SPA-internes Routing bei ungesichertem Bearbeitungszustand (data router oder BrowserRouter ab RR 6.22). + * Kombination mit einem Dialog (Speichern / Abbrechen / Verwerfen) und optionally useBeforeUnloadWhen. + */ +export function useUnsavedChangesBlocker(when) { + const shouldBlock = useCallback( + ({ currentLocation, nextLocation }) => + !!when && + (currentLocation.pathname !== nextLocation.pathname || + currentLocation.search !== nextLocation.search || + currentLocation.hash !== nextLocation.hash), + [when], + ) + return useBlocker(when ? shouldBlock : false) +} + +/** Tab/Fenster schließen oder harten Reload — natives Browser-Verhalten mit generischem Warnhinweis. */ +export function useBeforeUnloadWhen(when) { + useEffect(() => { + if (!when) return undefined + const fn = (ev) => { + ev.preventDefault() + ev.returnValue = '' + } + window.addEventListener('beforeunload', fn) + return () => window.removeEventListener('beforeunload', fn) + }, [when]) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index e744684..d37f622 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,8 +1,9 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react' import { useAuth } from '../context/AuthContext' import api from '../utils/api' +import { getTenantClubDependencyKey } from '../utils/activeClub' import EmailVerificationBanner from '../components/EmailVerificationBanner' import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget' import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget' @@ -27,6 +28,7 @@ function Dashboard() { const [phase0Stats, setPhase0Stats] = useState(null) const [phase0Err, setPhase0Err] = useState(null) const { user } = useAuth() + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) useEffect(() => { loadData() @@ -88,7 +90,7 @@ function Dashboard() { return () => { cancelled = true } - }, [user?.id]) + }, [user?.id, tenantClubDepKey]) useEffect(() => { if (!user?.id) { @@ -142,7 +144,7 @@ function Dashboard() { return () => { cancelled = true } - }, [user?.id]) + }, [user?.id, tenantClubDepKey]) const loadData = async () => { try { diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 5e29bbb..975fa07 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useMemo } from 'react' +import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react' import { useNavigate, useParams, Link } from 'react-router-dom' import api, { buildExerciseApiPayload } from '../utils/api' import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' @@ -16,9 +16,17 @@ import { import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll' import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels' import { useAuth } from '../context/AuthContext' +import { useToast } from '../context/ToastContext' +import { + activeClubMemberships, + getDefaultClubIdForGovernanceForms, + getTenantClubDependencyKey, +} from '../utils/activeClub' import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes' import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi' import { GripVertical } from 'lucide-react' +import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' +import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' const INTENSITY_OPTIONS = [ { value: '', label: '—' }, @@ -322,6 +330,7 @@ function emptyForm() { training_types_multi: [], target_groups_multi: [], visibility: 'private', + club_id: null, status: 'draft', skills: [], exercise_kind: 'simple', @@ -361,6 +370,12 @@ function detailToForm(exercise) { is_primary: !!g.is_primary, })), visibility: exercise.visibility || 'private', + club_id: + String(exercise.visibility || '').trim().toLowerCase() === 'club' && + exercise.club_id != null && + exercise.club_id !== '' + ? Number(exercise.club_id) + : null, status: exercise.status || 'draft', skills: exercise.skills?.map((s) => ({ @@ -455,6 +470,45 @@ function ExerciseFormPage() { const navigate = useNavigate() const { user } = useAuth() const isSuperadmin = user?.role === 'superadmin' + const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + + const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([]) + useEffect(() => { + if (!isPlatformAdmin) { + setClubsForGovernanceForms([]) + return undefined + } + let cancelled = false + ;(async () => { + try { + const list = await api.listClubs() + if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : []) + } catch { + if (!cancelled) setClubsForGovernanceForms([]) + } + })() + return () => { + cancelled = true + } + }, [isPlatformAdmin, tenantClubDepKey]) + + const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs]) + + /** Plattform-Admin: alle Vereine; sonst nur Mitgliedschafts-Vereine. */ + const visibilityClubChoices = useMemo(() => { + if (isPlatformAdmin && clubsForGovernanceForms.length > 0) { + return [...clubsForGovernanceForms].sort((a, b) => + String(a.name || '').localeCompare(String(b.name || ''), 'de'), + ) + } + return [...membershipClubRows].sort((a, b) => + String(a.name || '').localeCompare(String(b.name || ''), 'de'), + ) + }, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows]) + + const governanceDefaultClubId = useMemo(() => getDefaultClubIdForGovernanceForms(user), [user]) + const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null const isEdit = exerciseId != null @@ -469,6 +523,11 @@ function ExerciseFormPage() { const [saving, setSaving] = useState(false) const [formDirty, setFormDirty] = useState(false) const [skillPick, setSkillPick] = useState('') + + const toast = useToast() + const allowUnloadBlock = Boolean(formDirty && !loading && !saving) + useBeforeUnloadWhen(allowUnloadBlock) + const blocker = useUnsavedChangesBlocker(allowUnloadBlock) const [variants, setVariants] = useState([]) const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft()) const [variantSavingId, setVariantSavingId] = useState(null) @@ -507,15 +566,7 @@ function ExerciseFormPage() { return () => document.removeEventListener('dragover', onDragOverDoc) }, []) - useEffect(() => { - if (!formDirty) return undefined - const warn = (ev) => { - ev.preventDefault() - ev.returnValue = '' - } - window.addEventListener('beforeunload', warn) - return () => window.removeEventListener('beforeunload', warn) - }, [formDirty]) + useEffect(() => { if (!archiveOpen) return undefined @@ -561,7 +612,7 @@ function ExerciseFormPage() { } catch (e) { if (!cancelled) { console.error(e) - alert( + toast.error( 'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' + (e.message || e), ) @@ -572,7 +623,7 @@ function ExerciseFormPage() { return () => { cancelled = true } - }, []) + }, [toast]) useEffect(() => { if (!isEdit) { @@ -599,7 +650,7 @@ function ExerciseFormPage() { setFormDirty(false) } catch (err) { if (!cancelled) { - alert(err.message || 'Übung nicht ladbar') + toast.error(err.message || 'Übung nicht ladbar') navigate('/exercises') } } finally { @@ -610,7 +661,7 @@ function ExerciseFormPage() { return () => { cancelled = true } - }, [isEdit, exerciseId, navigate]) + }, [isEdit, exerciseId, navigate, toast]) useEffect(() => { if (variantEditSelection == null || variantEditSelection === 'new') return @@ -630,6 +681,38 @@ function ExerciseFormPage() { setFormData((prev) => ({ ...prev, [field]: value })) } + useEffect(() => { + if (formData.visibility !== 'club') return + const choices = visibilityClubChoices + if (!choices.length) return + + const id = + formData.club_id != null && formData.club_id !== '' ? Number(formData.club_id) : NaN + const hasValid = + Number.isFinite(id) && id > 0 && choices.some((c) => Number(c.id) === id) + + if (hasValid) return + + const fallback = governanceDefaultClubId + const next = + fallback != null && + Number.isFinite(Number(fallback)) && + choices.some((c) => Number(c.id) === Number(fallback)) + ? Number(fallback) + : Number(choices[0].id) + + setFormData((prev) => { + if (prev.visibility !== 'club') return prev + if (prev.club_id != null && Number(prev.club_id) === next) return prev + return { ...prev, club_id: next } + }) + }, [ + formData.visibility, + formData.club_id, + visibilityClubChoices, + governanceDefaultClubId, + ]) + const [comboStationPickerIx, setComboStationPickerIx] = useState(null) const [comboDropTargetIx, setComboDropTargetIx] = useState(null) @@ -686,7 +769,7 @@ function ExerciseFormPage() { }) let nextIds = ordered if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) { - window.alert( + toast.info( `Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner Wechsel‑Pool. Überschüssige Auswahl wurde abgeschnitten.`, ) nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION) @@ -712,11 +795,11 @@ function ExerciseFormPage() { const addSkillRow = () => { const id = skillPick ? parseInt(skillPick, 10) : null if (!id) { - alert('Fähigkeit wählen') + toast.error('Fähigkeit wählen') return } if (formData.skills.some((s) => s.skill_id === id)) { - alert('Bereits zugeordnet') + toast.info('Bereits zugeordnet') return } updateFormField('skills', [ @@ -752,121 +835,138 @@ function ExerciseFormPage() { updateFormField('skills', next) } - const handleSubmit = async (e) => { - e.preventDefault() - if (!formData.title || formData.title.trim().length < 3) { - alert('Titel mindestens 3 Zeichen') - return - } - const payloadBase = { - ...formData, - equipment: - typeof formData.equipmentLines === 'string' - ? formData.equipmentLines - .split(/[\n,]+/) - .map((s) => s.trim()) - .filter(Boolean) - : [], - } - let payload - try { - payload = buildExerciseApiPayload(payloadBase) - } catch (err) { - alert(err.message) - return - } - setSaving(true) - try { - if (isEdit) { - const saveOnce = (extras = {}) => - api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras)) - try { - await saveOnce() - } catch (firstErr) { - if ( - firstErr.status === 422 && - firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' && - firstErr.payload?.media_assets - ) { - alert( - 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' + - 'Bitte Medium wiederherstellen oder aus der Übung entfernen.', - ) - throw firstErr - } - if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') { - const promo = (firstErr.payload.assets_need_visibility_promotion || []).length - const miss = (firstErr.payload.assets_missing_copyright || []).length - let msg = - 'Die Übung ist oder wird offiziell. ' - if (promo > 0) { - msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. ` + const performSaveAttempt = useCallback( + async ({ fromUnsavedDialog = false } = {}) => { + if (!formData.title || formData.title.trim().length < 3) { + toast.error('Titel mindestens 3 Zeichen') + return false + } + const payloadBase = { + ...formData, + equipment: + typeof formData.equipmentLines === 'string' + ? formData.equipmentLines + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter(Boolean) + : [], + } + let payload + try { + payload = buildExerciseApiPayload(payloadBase) + } catch (err) { + toast.error(err.message) + return false + } + setSaving(true) + try { + if (isEdit) { + const saveOnce = (extras = {}) => + api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras)) + try { + await saveOnce() + } catch (firstErr) { + if ( + firstErr.status === 422 && + firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' && + firstErr.payload?.media_assets + ) { + toast.error( + 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' + + 'Bitte Medium wiederherstellen oder aus der Übung entfernen.', + ) + throw firstErr } - if (miss > 0) { - msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). ` - } - msg += 'Fortfahren?' - if (!window.confirm(msg)) throw firstErr - let defaultCopyright = '' - if (miss > 0) { - defaultCopyright = window.prompt( - 'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):', + if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') { + const promo = (firstErr.payload.assets_need_visibility_promotion || []).length + const miss = (firstErr.payload.assets_missing_copyright || []).length + let msg = 'Die Übung ist oder wird offiziell. ' + if (promo > 0) { + msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. ` + } + if (miss > 0) { + msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). ` + } + msg += 'Fortfahren?' + if (!window.confirm(msg)) throw firstErr + let defaultCopyright = '' + if (miss > 0) { + defaultCopyright = window.prompt( + 'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):', + '© ', + ) + if (!defaultCopyright || String(defaultCopyright).trim().length < 3) { + toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.') + throw firstErr + } + } + await saveOnce({ + promote_attached_media_for_official: true, + ...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}), + }) + } else if ( + firstErr.status === 422 && + firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' && + firstErr.payload?.media_assets + ) { + const miss = firstErr.payload.media_assets.length + const msg = + `Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` + + `${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` + + `Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?` + if (!window.confirm(msg)) throw firstErr + const defaultCopyright = window.prompt( + 'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):', '© ', ) if (!defaultCopyright || String(defaultCopyright).trim().length < 3) { - alert('Mindestens 3 Zeichen für den Copyright-Vermerk.') + toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.') throw firstErr } - } - await saveOnce({ - promote_attached_media_for_official: true, - ...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}), - }) - } else if ( - firstErr.status === 422 && - firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' && - firstErr.payload?.media_assets - ) { - const miss = firstErr.payload.media_assets.length - const msg = - `Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` + - `${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` + - `Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?` - if (!window.confirm(msg)) throw firstErr - const defaultCopyright = window.prompt( - 'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):', - '© ', - ) - if (!defaultCopyright || String(defaultCopyright).trim().length < 3) { - alert('Mindestens 3 Zeichen für den Copyright-Vermerk.') + await saveOnce({ + default_club_media_copyright: String(defaultCopyright).trim(), + }) + } else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') { + toast.error( + 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.', + ) + throw firstErr + } else { throw firstErr } - await saveOnce({ - default_club_media_copyright: String(defaultCopyright).trim(), - }) - } else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') { - alert( - 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.', - ) - throw firstErr - } else { - throw firstErr } + const ex = await api.getExercise(exerciseId) + setMediaList(ex.media || []) + setVariants((ex.variants || []).map(apiVariantToRow)) + setFormDirty(false) + toast.success('Gespeichert.') + return true } - const ex = await api.getExercise(exerciseId) - setMediaList(ex.media || []) - setVariants((ex.variants || []).map(apiVariantToRow)) - setFormDirty(false) - alert('Gespeichert.') - } else { const created = await api.createExercise(payload) - navigate(`/exercises/${created.id}/edit`, { replace: true }) + setFormDirty(false) + toast.success('Übung angelegt.') + if (!fromUnsavedDialog) { + navigate(`/exercises/${created.id}/edit`, { replace: true }) + } + return true + } catch (err) { + toast.error('Fehler beim Speichern: ' + err.message) + return false + } finally { + setSaving(false) } - } catch (err) { - alert('Fehler beim Speichern: ' + err.message) - } finally { - setSaving(false) - } + }, + [exerciseId, formData, isEdit, navigate, toast], + ) + + const handleSubmit = async (e) => { + e.preventDefault() + await performSaveAttempt({ fromUnsavedDialog: false }) + } + + const handleUnsavedDialogSave = async () => { + const ok = await performSaveAttempt({ fromUnsavedDialog: true }) + if (ok) blocker.proceed() } const refreshMedia = async () => { @@ -888,7 +988,7 @@ function ExerciseFormPage() { setArchiveOpen(false) await refreshMedia() } catch (e) { - alert(e.message || String(e)) + toast.error(e.message || String(e)) } } @@ -921,7 +1021,7 @@ function ExerciseFormPage() { } } } catch (err) { - alert(err.message) + toast.error(err.message) } } @@ -940,7 +1040,7 @@ function ExerciseFormPage() { ) setMediaList(next) } catch (e) { - alert(e.message || String(e)) + toast.error(e.message || String(e)) } } @@ -955,7 +1055,7 @@ function ExerciseFormPage() { }) await refreshMedia() } catch (e) { - alert(e.message || String(e)) + toast.error(e.message || String(e)) } finally { setMediaSavingId(null) } @@ -975,7 +1075,7 @@ function ExerciseFormPage() { const saveVariantRow = async (row) => { const payload = buildVariantPayloadFromRow(row) if (payload.variant_name.length < 3) { - alert('Variantenname mindestens 3 Zeichen') + toast.error('Variantenname mindestens 3 Zeichen') return } setVariantSavingId(row.id) @@ -983,7 +1083,7 @@ function ExerciseFormPage() { await api.updateExerciseVariant(exerciseId, row.id, payload) await refreshVariants() } catch (e) { - alert(e.message || String(e)) + toast.error(e.message || String(e)) } finally { setVariantSavingId(null) } @@ -997,7 +1097,7 @@ function ExerciseFormPage() { if (variantEditSelection === id) setVariantEditSelection(null) await refreshVariants() } catch (e) { - alert(e.message || String(e)) + toast.error(e.message || String(e)) } finally { setVariantBusy(false) } @@ -1016,7 +1116,7 @@ function ExerciseFormPage() { await api.reorderExerciseVariants(exerciseId, ids) await refreshVariants() } catch (e) { - alert(e.message || String(e)) + toast.error(e.message || String(e)) } finally { setVariantBusy(false) } @@ -1027,7 +1127,7 @@ function ExerciseFormPage() { if (!exerciseId) return const payload = buildVariantPayloadFromRow(variantDraft) if (payload.variant_name.length < 3) { - alert('Variantenname mindestens 3 Zeichen') + toast.error('Variantenname mindestens 3 Zeichen') return } setVariantBusy(true) @@ -1038,7 +1138,7 @@ function ExerciseFormPage() { if (created?.id != null) setVariantEditSelection(created.id) else setVariantEditSelection(null) } catch (err) { - alert(err.message || String(err)) + toast.error(err.message || String(err)) } finally { setVariantBusy(false) } @@ -1072,18 +1172,7 @@ function ExerciseFormPage() { type="button" className="btn btn-secondary" style={{ marginLeft: '8px' }} - onClick={() => { - if ( - formDirty && - !window.confirm( - 'Es gibt noch nicht über „Speichern“ gesicherte Änderungen (Texte, Zuordnungen, …).\n\n' + - 'Zur Ansicht wechseln und diese Änderungen verwerfen?', - ) - ) { - return - } - navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } }) - }} + onClick={() => navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })} > Ansehen @@ -1832,6 +1921,29 @@ function ExerciseFormPage() { + {formData.visibility === 'club' && visibilityClubChoices.length > 0 ? ( +
+ + +

+ Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar. +

+
+ ) : null} +
) } diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 5f42b15..de91e4d 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -14,7 +14,7 @@ import { } from 'lucide-react' import api from '../utils/api' import { useAuth } from '../context/AuthContext' -import { activeClubMemberships } from '../utils/activeClub' +import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import MultiSelectCombo from '../components/MultiSelectCombo' import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker' @@ -155,6 +155,7 @@ function ExercisesListPage() { const { user, checkAuth } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin' + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const [mineOnly, setMineOnly] = useState(() => { try { @@ -623,7 +624,7 @@ function ExercisesListPage() { return () => { cancelled = true } - }, [queryBase, catalogsReady, pageTab]) + }, [queryBase, catalogsReady, pageTab, tenantClubDepKey]) const loadMore = async () => { if (loadingMore || !hasMore) return diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 085180a..479a7f3 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -22,7 +22,7 @@ import { } from 'lucide-react' import { useAuth } from '../context/AuthContext' import api from '../utils/api' -import { activeClubMemberships } from '../utils/activeClub' +import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import RightsDeclarationDialog from '../components/RightsDeclarationDialog' import ReportContentModal from '../components/ReportContentModal' @@ -296,6 +296,7 @@ export default function MediaLibraryPage() { const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin' const hasClubOrgAdmin = activeClubMemberships(user?.clubs).some((c) => (c.roles || []).includes('club_admin')) + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const archiveVisOptions = useMemo( () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), @@ -381,7 +382,7 @@ export default function MediaLibraryPage() { useEffect(() => { loadClubs() - }, [loadClubs]) + }, [loadClubs, tenantClubDepKey]) const loadItems = useCallback(async () => { const seq = ++mediaListFetchSeqRef.current @@ -415,7 +416,7 @@ export default function MediaLibraryPage() { } finally { if (seq === mediaListFetchSeqRef.current) setLoading(false) } - }, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta]) + }, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta, tenantClubDepKey]) useEffect(() => { const t = setTimeout(() => { diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index 36dbe4c..d6df2d8 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -1,10 +1,13 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams, useLocation } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePeekModal from '../components/ExercisePeekModal' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import PageSectionNav from '../components/PageSectionNav' +import { useToast } from '../context/ToastContext' +import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' +import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { defaultSection, normalizeUnitToForm, @@ -78,6 +81,32 @@ function defaultForm() { } } +function frameworkDraftSnapshot(fm) { + const goalsNorm = (fm.goals || []).map((g) => ({ + t: (g.title || '').trim(), + n: (g.notes || '').trim(), + })) + const slotsNorm = (fm.slots || []).map((s) => ({ + title: (s.title || '').trim(), + notes: (s.notes || '').trim(), + sections: s.sections, + })) + return JSON.stringify({ + title: (fm.title || '').trim(), + description: (fm.description || '').trim(), + focus_area_id: fm.focus_area_id || '', + style_direction_id: fm.style_direction_id || '', + training_type_ids: [...(fm.training_type_ids || [])].map(String).sort(), + target_group_ids: [...(fm.target_group_ids || [])].map(String).sort(), + planned_period_start: fm.planned_period_start || '', + planned_period_end: fm.planned_period_end || '', + visibility: (fm.visibility || '').trim(), + club_id: (fm.club_id || '').trim(), + goals: goalsNorm, + slots: slotsNorm, + }) +} + function serverFrameworkToForm(fw) { const goalsIn = Array.isArray(fw.goals) && fw.goals.length ? fw.goals : [emptyGoal()] return { @@ -196,6 +225,40 @@ export default function TrainingFrameworkProgramEditPage() { /** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */ const [mobileSlotIdx, setMobileSlotIdx] = useState(0) + const toast = useToast() + const baselineRef = useRef(null) + const latestFormRef = useRef(form) + latestFormRef.current = form + const [baselineReady, setBaselineReady] = useState(false) + const [bypassDirty, setBypassDirty] = useState(false) + + const dirtySignature = frameworkDraftSnapshot(form) + + useEffect(() => { + baselineRef.current = null + setBaselineReady(false) + setBypassDirty(false) + }, [idParam, isNew]) + + useEffect(() => { + if (loading) return + const handle = window.setTimeout(() => { + baselineRef.current = frameworkDraftSnapshot(latestFormRef.current) + setBaselineReady(true) + }, 120) + return () => clearTimeout(handle) + }, [loading, idParam, isNew]) + + const formDirtyEffective = + baselineReady && + baselineRef.current != null && + !bypassDirty && + !loading && + dirtySignature !== baselineRef.current + + const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving)) + useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving)) + useEffect(() => { const mq = window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`) const apply = () => setDesktopLayout(!!mq.matches) @@ -266,7 +329,7 @@ export default function TrainingFrameworkProgramEditPage() { next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) } setForm(next) } catch (e) { - alert(e.message || 'Laden fehlgeschlagen') + toast.error(e.message || 'Laden fehlgeschlagen') navigate('/planning/framework-programs') } finally { if (!cancelled) setLoading(false) @@ -353,42 +416,60 @@ export default function TrainingFrameworkProgramEditPage() { })) } - const handleSave = async () => { + const performFrameworkSave = async ({ fromUnsavedDialog = false } = {}) => { if (!(form.title || '').trim()) { - alert('Titel ist Pflichtfeld.') - return + toast.error('Titel ist Pflichtfeld.') + return false } let payload try { payload = buildApiPayload(form) } catch (e) { - alert(e.message || 'Validierung') - return + toast.error(e.message || 'Validierung') + return false } if (!payload.title) { - alert('Titel ist Pflichtfeld.') - return + toast.error('Titel ist Pflichtfeld.') + return false } setSaving(true) try { if (isNew) { const created = await api.createTrainingFrameworkProgram(payload) - navigate(`/planning/framework-programs/${created.id}`, { replace: true }) - } else { - const fid = parseInt(idParam, 10) - await api.updateTrainingFrameworkProgram(fid, payload) - const refreshed = await api.getTrainingFrameworkProgram(fid) - let next = serverFrameworkToForm(refreshed) - next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) } - setForm(next) + toast.success('Rahmenprogramm angelegt.') + if (!fromUnsavedDialog) { + navigate(`/planning/framework-programs/${created.id}`, { replace: true }) + } + return true } + const fid = parseInt(idParam, 10) + await api.updateTrainingFrameworkProgram(fid, payload) + const refreshed = await api.getTrainingFrameworkProgram(fid) + let next = serverFrameworkToForm(refreshed) + next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) } + setForm(next) + baselineRef.current = frameworkDraftSnapshot(next) + setBypassDirty(false) + setBaselineReady(true) + toast.success('Gespeichert.') + return true } catch (e) { - alert(e.message || 'Speichern fehlgeschlagen') + toast.error(e.message || 'Speichern fehlgeschlagen') + return false } finally { setSaving(false) } } + const handleSave = async () => { + await performFrameworkSave({ fromUnsavedDialog: false }) + } + + const handleUnsavedDialogSave = async () => { + const ok = await performFrameworkSave({ fromUnsavedDialog: true }) + if (ok) blocker.proceed() + } + async function handleDelete() { if (isNew) return const fid = parseInt(idParam, 10) @@ -397,7 +478,7 @@ export default function TrainingFrameworkProgramEditPage() { await api.deleteTrainingFrameworkProgram(fid) navigate('/planning/framework-programs') } catch (e) { - alert(e.message || 'Löschen fehlgeschlagen') + toast.error(e.message || 'Löschen fehlgeschlagen') } } @@ -1147,6 +1228,12 @@ export default function TrainingFrameworkProgramEditPage() { variantId={peekCtx?.variantId ?? undefined} onClose={() => setPeekCtx(null)} /> + setBypassDirty(true)} + /> ) } diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx index 0194980..b9d4841 100644 --- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx @@ -1,6 +1,8 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import { useAuth } from '../context/AuthContext' +import { getTenantClubDependencyKey } from '../utils/activeClub' function dashIfEmpty(val) { const s = (val ?? '').toString().trim() @@ -55,6 +57,8 @@ function FrameworkSummaryMeta({ r }) { } export default function TrainingFrameworkProgramsListPage() { + const { user } = useAuth() + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') @@ -75,7 +79,7 @@ export default function TrainingFrameworkProgramsListPage() { useEffect(() => { load() - }, [load]) + }, [load, tenantClubDepKey]) async function handleDelete(id, title) { if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx index 4700e12..5797e02 100644 --- a/frontend/src/pages/TrainingModuleEditPage.jsx +++ b/frontend/src/pages/TrainingModuleEditPage.jsx @@ -1,10 +1,51 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { useAuth } from '../context/AuthContext' -import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub' +import { useToast } from '../context/ToastContext' +import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' +import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' +import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub' + +function moduleFormSnapshot({ + title, + summary, + goal, + recommendedDurationMin, + targetGroupNotes, + deploymentContextNotes, + visibility, + clubIdField, + primaryMethodId, + items, +}) { + const itemRows = items.map((it) => { + if (it.item_type === 'note') { + return { k: 'n', b: it.note_body ?? '' } + } + return { + k: 'e', + id: it.exercise_id, + v: it.exercise_variant_id, + d: it.planned_duration_min, + n: it.notes ?? '', + } + }) + return JSON.stringify({ + title: (title || '').trim(), + summary: (summary || '').trim(), + goal: goal || '', + recommendedDurationMin: recommendedDurationMin || '', + targetGroupNotes: targetGroupNotes || '', + deploymentContextNotes: deploymentContextNotes || '', + visibility: visibility || '', + clubIdField: (clubIdField || '').trim(), + primaryMethodId: (primaryMethodId || '').trim(), + items: itemRows, + }) +} function nextLocalKey() { return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` @@ -40,18 +81,95 @@ export default function TrainingModuleEditPage() { const [primaryMethodId, setPrimaryMethodId] = useState('') const [items, setItems] = useState([]) + const toast = useToast() + const baselineRef = useRef(null) + const latestFormRef = useRef({}) + const [baselineReady, setBaselineReady] = useState(false) + const [bypassDirty, setBypassDirty] = useState(false) + + latestFormRef.current = { + title, + summary, + goal, + recommendedDurationMin, + targetGroupNotes, + deploymentContextNotes, + visibility, + clubIdField, + primaryMethodId, + items, + } + + const dirtySignature = moduleFormSnapshot(latestFormRef.current) + + useEffect(() => { + baselineRef.current = null + setBaselineReady(false) + setBypassDirty(false) + }, [isNew, moduleId]) + + useEffect(() => { + if (loading) return + const handle = window.setTimeout(() => { + baselineRef.current = moduleFormSnapshot(latestFormRef.current) + setBaselineReady(true) + }, 120) + return () => clearTimeout(handle) + }, [loading, isNew, moduleId]) + + const formDirtyEffective = + baselineReady && baselineRef.current != null && !bypassDirty && !loading && dirtySignature !== baselineRef.current + + const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving)) + useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving)) + const { user } = useAuth() - const clubChoices = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs]) + const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([]) + + useEffect(() => { + if (!isPlatformAdmin) { + setClubsForGovernanceForms([]) + return undefined + } + let cancelled = false + ;(async () => { + try { + const list = await api.listClubs() + if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : []) + } catch { + if (!cancelled) setClubsForGovernanceForms([]) + } + })() + return () => { + cancelled = true + } + }, [isPlatformAdmin, tenantClubDepKey]) + + const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs]) + + const visibilityClubChoices = useMemo(() => { + if (isPlatformAdmin && clubsForGovernanceForms.length > 0) { + return [...clubsForGovernanceForms].sort((a, b) => + String(a.name || '').localeCompare(String(b.name || ''), 'de'), + ) + } + return [...membershipClubRows].sort((a, b) => + String(a.name || '').localeCompare(String(b.name || ''), 'de'), + ) + }, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows]) useEffect(() => { if (!isNew || visibility !== 'club') return if ((clubIdField || '').trim() !== '') return - if (clubChoices.length === 1) setClubIdField(String(clubChoices[0].id)) + const xs = visibilityClubChoices + if (xs.length === 1) setClubIdField(String(xs[0].id)) else { - const r = getResolvedActiveClubIdForUi(user) - if (r) setClubIdField(String(r)) + const r = getDefaultClubIdForGovernanceForms(user) + if (r != null && xs.some((c) => Number(c.id) === Number(r))) setClubIdField(String(r)) } - }, [isNew, visibility, clubIdField, clubChoices, user]) + }, [isNew, visibility, clubIdField, visibilityClubChoices, user]) const itemsPayload = items.map((it, i) => { if (it.item_type === 'note') { @@ -154,8 +272,8 @@ export default function TrainingModuleEditPage() { if (raw !== '') { const p = parseInt(raw, 10) if (Number.isFinite(p) && p >= 1) cid = p - } else if (clubChoices.length === 1) { - cid = clubChoices[0].id + } else if (visibilityClubChoices.length === 1) { + cid = visibilityClubChoices[0].id } } const pm = @@ -183,11 +301,10 @@ export default function TrainingModuleEditPage() { } } - const handleSave = async (e) => { - e.preventDefault() + const performModuleSave = async ({ fromUnsavedDialog = false } = {}) => { if (!title.trim()) { - alert('Titel ist Pflicht.') - return + toast.error('Titel ist Pflicht.') + return false } setSaving(true) setError('') @@ -195,18 +312,37 @@ export default function TrainingModuleEditPage() { const body = buildBody() if (isNew) { const created = await api.createTrainingModule(body) - navigate(`/planning/training-modules/${created.id}`, { replace: true }) - } else { - await api.updateTrainingModule(moduleId, body) - alert('Trainingsmodul gespeichert.') + toast.success('Trainingsmodul angelegt.') + if (!fromUnsavedDialog) { + navigate(`/planning/training-modules/${created.id}`, { replace: true }) + } + return true } + await api.updateTrainingModule(moduleId, body) + baselineRef.current = moduleFormSnapshot(latestFormRef.current) + setBypassDirty(false) + toast.success('Gespeichert.') + return true } catch (err) { - setError(err.message || 'Speichern fehlgeschlagen') + const msg = err.message || 'Speichern fehlgeschlagen' + setError(msg) + toast.error(msg) + return false } finally { setSaving(false) } } + const handleSave = async (e) => { + e.preventDefault() + await performModuleSave({ fromUnsavedDialog: false }) + } + + const handleUnsavedDialogSave = async () => { + const ok = await performModuleSave({ fromUnsavedDialog: true }) + if (ok) blocker.proceed() + } + const pickExercise = async (ex) => { if (!ex?.id) return const row = await hydrateExercisePlanningRow(ex) @@ -303,12 +439,16 @@ export default function TrainingModuleEditPage() { setClubIdField('') return } - const xs = clubChoices + const xs = visibilityClubChoices if (xs.length === 1) setClubIdField(String(xs[0].id)) else if (xs.length === 0) setClubIdField('') else { - const resolved = getResolvedActiveClubIdForUi(user) - setClubIdField(resolved != null ? String(resolved) : '') + const resolved = getDefaultClubIdForGovernanceForms(user) + setClubIdField( + resolved != null && xs.some((c) => Number(c.id) === Number(resolved)) + ? String(resolved) + : '', + ) } }} > @@ -324,23 +464,25 @@ export default function TrainingModuleEditPage() { Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine Vereinsbindung fest).

- ) : clubChoices.length === 0 ? ( + ) : visibilityClubChoices.length === 0 ? (

- Kein aktiver Verein im Profil — bitte zuerst einem Verein beitreten. + Kein Verein zur Auswahl — bitte aktiven Verein im Profil wählen oder (Plattform-Admin) Vereinsliste + laden.

- ) : clubChoices.length === 1 ? ( + ) : visibilityClubChoices.length === 1 ? ( <>

- Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet. + Fixiert durch deine Mitgliedschaft. Verein-ID {visibilityClubChoices[0].id} wird beim Speichern + verwendet.

) : ( @@ -351,7 +493,7 @@ export default function TrainingModuleEditPage() { onChange={(e) => setClubIdField(e.target.value)} > - {clubChoices.map((c) => { + {visibilityClubChoices.map((c) => { const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}` return (