minor improvements. Darstellung, Handlung, Popups #32
|
|
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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**).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Routes,
|
||||
Route,
|
||||
RouterProvider,
|
||||
createBrowserRouter,
|
||||
Navigate,
|
||||
NavLink,
|
||||
useLocation,
|
||||
Outlet,
|
||||
} from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { ToastProvider } from './context/ToastContext'
|
||||
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
|
||||
import DesktopSidebar from './components/DesktopSidebar'
|
||||
import { getMainNavItems } from './config/appNav'
|
||||
|
|
@ -162,114 +162,116 @@ function PublicRoute({ children }) {
|
|||
return !isAuthenticated ? children : <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/verify" element={<VerifyPage />} />
|
||||
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* P-01: Öffentliche Rechtstextseiten — kein Auth erforderlich */}
|
||||
<Route path="/impressum" element={<LegalPage type="impressum" />} />
|
||||
<Route path="/datenschutz" element={<LegalPage type="datenschutz" />} />
|
||||
<Route path="/nutzungsbedingungen" element={<LegalPage type="nutzungsbedingungen" />} />
|
||||
<Route path="/medienrichtlinie" element={<LegalPage type="medienrichtlinie" />} />
|
||||
|
||||
<Route element={<ProtectedLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="profile" element={<Navigate to="/settings" replace />} />
|
||||
<Route path="settings" element={<AccountSettingsPage />} />
|
||||
<Route path="settings/system" element={<SettingsSystemInfoPage />} />
|
||||
<Route path="settings/legal" element={<SettingsLegalPage />} />
|
||||
<Route path="media" element={<MediaLibraryPage />} />
|
||||
<Route path="exercises">
|
||||
<Route index element={<ExercisesListPage />} />
|
||||
<Route path="new" element={<ExerciseFormPage />} />
|
||||
<Route path=":id/edit" element={<ExerciseFormPage />} />
|
||||
<Route path=":id" element={<ExerciseDetailPage />} />
|
||||
</Route>
|
||||
<Route path="clubs" element={<ClubsPage />} />
|
||||
<Route path="inbox" element={<InboxPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} />
|
||||
<Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} />
|
||||
<Route path="planning/framework-programs" element={<TrainingFrameworkProgramsListPage />} />
|
||||
<Route path="planning/training-modules/new" element={<TrainingModuleEditPage />} />
|
||||
<Route path="planning/training-modules/:id" element={<TrainingModuleEditPage />} />
|
||||
<Route path="planning/training-modules" element={<TrainingModulesListPage />} />
|
||||
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
|
||||
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
|
||||
<Route path="planning" element={<TrainingPlanningPage />} />
|
||||
<Route path="admin" element={<AdminHomeRedirect />} />
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
<PlatformAdminRoute>
|
||||
<AdminUsersPage />
|
||||
</PlatformAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/hierarchy"
|
||||
element={
|
||||
<PlatformAdminRoute>
|
||||
<AdminHierarchyPage />
|
||||
</PlatformAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/maturity-models"
|
||||
element={
|
||||
<PlatformAdminRoute>
|
||||
<AdminMaturityModelsPage />
|
||||
</PlatformAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/catalogs"
|
||||
element={
|
||||
<PlatformAdminRoute>
|
||||
<AdminCatalogsPage />
|
||||
</PlatformAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/mediawiki-import"
|
||||
element={
|
||||
<PlatformAdminRoute>
|
||||
<MediaWikiImportPage />
|
||||
</PlatformAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/legal-documents"
|
||||
element={
|
||||
<PlatformAdminRoute>
|
||||
<AdminLegalDocumentsPage />
|
||||
</PlatformAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Data Router — erforderlich für `useBlocker` (ungespeicherte Änderungen).
|
||||
* Klassisches `BrowserRouter` stellt keinen DataRouterContext bereit; ohne Migration
|
||||
* werfen Seiten mit `useUnsavedChangesBlocker` beim Rendern eine Invariante.
|
||||
*/
|
||||
const appRouter = createBrowserRouter([
|
||||
{ path: '/verify', element: <VerifyPage /> },
|
||||
{
|
||||
path: '/login',
|
||||
element: (
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{ path: '/impressum', element: <LegalPage type="impressum" /> },
|
||||
{ path: '/datenschutz', element: <LegalPage type="datenschutz" /> },
|
||||
{ path: '/nutzungsbedingungen', element: <LegalPage type="nutzungsbedingungen" /> },
|
||||
{ path: '/medienrichtlinie', element: <LegalPage type="medienrichtlinie" /> },
|
||||
{
|
||||
element: <ProtectedLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'profile', element: <Navigate to="/settings" replace /> },
|
||||
{ path: 'settings', element: <AccountSettingsPage /> },
|
||||
{ path: 'settings/system', element: <SettingsSystemInfoPage /> },
|
||||
{ path: 'settings/legal', element: <SettingsLegalPage /> },
|
||||
{ path: 'media', element: <MediaLibraryPage /> },
|
||||
{
|
||||
path: 'exercises',
|
||||
children: [
|
||||
{ index: true, element: <ExercisesListPage /> },
|
||||
{ path: 'new', element: <ExerciseFormPage /> },
|
||||
{ path: ':id/edit', element: <ExerciseFormPage /> },
|
||||
{ path: ':id', element: <ExerciseDetailPage /> },
|
||||
],
|
||||
},
|
||||
{ path: 'clubs', element: <ClubsPage /> },
|
||||
{ path: 'inbox', element: <InboxPage /> },
|
||||
{ path: 'skills', element: <SkillsPage /> },
|
||||
{ path: 'planning/framework-programs/new', element: <TrainingFrameworkProgramEditPage /> },
|
||||
{ path: 'planning/framework-programs/:id', element: <TrainingFrameworkProgramEditPage /> },
|
||||
{ path: 'planning/framework-programs', element: <TrainingFrameworkProgramsListPage /> },
|
||||
{ path: 'planning/training-modules/new', element: <TrainingModuleEditPage /> },
|
||||
{ path: 'planning/training-modules/:id', element: <TrainingModuleEditPage /> },
|
||||
{ path: 'planning/training-modules', element: <TrainingModulesListPage /> },
|
||||
{ path: 'planning/run/:unitId/coach', element: <TrainingCoachPage /> },
|
||||
{ path: 'planning/run/:unitId', element: <TrainingUnitRunPage /> },
|
||||
{ path: 'planning', element: <TrainingPlanningPage /> },
|
||||
{ path: 'admin', element: <AdminHomeRedirect /> },
|
||||
{
|
||||
path: 'admin/users',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminUsersPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/hierarchy',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminHierarchyPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/maturity-models',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminMaturityModelsPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/catalogs',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminCatalogsPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/mediawiki-import',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<MediaWikiImportPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/legal-documents',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminLegalDocumentsPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
])
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<AppRoutes />
|
||||
</Router>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={appRouter} />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6398,6 +6398,68 @@ a.analysis-split__nav-item {
|
|||
color: var(--text3);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.combo-plan-bracket__station-exercises--interactive {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
.combo-plan-bracket__cand-inline {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.combo-plan-bracket__cand-sep {
|
||||
color: var(--text3);
|
||||
font-size: 0.78rem;
|
||||
user-select: none;
|
||||
}
|
||||
.combo-plan-bracket__cand-btn {
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-dark);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.combo-plan-bracket__cand-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface2);
|
||||
}
|
||||
.combo-plan-bracket__cand-link {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-dark);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.combo-plan-bracket__cand-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button.combo-coach-cand-link {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
button.combo-coach-cand-link:hover {
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.training-run-combo-embed {
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
|
@ -7522,3 +7584,103 @@ a.analysis-split__nav-item {
|
|||
margin: 0 0 8px;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
/* ── Toasts (kurze Infos, ohne OK-Dialog) ───────────────────────── */
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
width: min(92vw, 26rem);
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.toast-stack {
|
||||
bottom: 1.5rem;
|
||||
left: auto;
|
||||
right: 1.75rem;
|
||||
transform: none;
|
||||
align-items: flex-end;
|
||||
width: min(380px, 36vw);
|
||||
}
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text1);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
|
||||
cursor: pointer;
|
||||
animation: toast-in 0.22s ease-out;
|
||||
}
|
||||
.toast:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
.toast:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.toast__text {
|
||||
display: block;
|
||||
}
|
||||
.toast--success {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
|
||||
}
|
||||
.toast--info {
|
||||
background: var(--surface2);
|
||||
}
|
||||
.toast--error {
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ungesicherte Änderungen — Navigation */
|
||||
.unsaved-prompt-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.unsaved-prompt-sheet {
|
||||
max-width: 22rem;
|
||||
width: 100%;
|
||||
padding: 1.15rem 1.25rem 1.25rem;
|
||||
animation: toast-in 0.2s ease-out;
|
||||
}
|
||||
.unsaved-prompt-title {
|
||||
font-size: 1.06rem;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--text1);
|
||||
}
|
||||
.unsaved-prompt-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,15 +35,26 @@ export default function CombinationCoachSlots({
|
|||
methodProfile,
|
||||
compactPlanningView = false,
|
||||
omitGlobalKeyValueBlock = false,
|
||||
/** Wenn gesetzt: Kandidaten als Button → Peek (kein Router-Wechsel, PWA-sicher) */
|
||||
onOpenCandidatePeek,
|
||||
}) {
|
||||
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
|
||||
|
||||
const candidateIds = useMemo(() => {
|
||||
const set = new Set()
|
||||
for (const s of slots) {
|
||||
for (const id of s.candidate_exercise_ids || []) {
|
||||
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
|
||||
if (Number.isFinite(n)) set.add(n)
|
||||
if (Array.isArray(s.candidates) && s.candidates.length) {
|
||||
for (const c of s.candidates) {
|
||||
const raw = c.exercise_id
|
||||
if (raw == null) continue
|
||||
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||||
if (Number.isFinite(n)) set.add(n)
|
||||
}
|
||||
} else {
|
||||
for (const id of s.candidate_exercise_ids || []) {
|
||||
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
|
||||
if (Number.isFinite(n)) set.add(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
|
|
@ -282,9 +293,19 @@ export default function CombinationCoachSlots({
|
|||
<>
|
||||
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||||
Im Katalog öffnen
|
||||
</Link>
|
||||
{typeof onOpenCandidatePeek === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="combo-coach-cand-link"
|
||||
onClick={() => onOpenCandidatePeek(cid)}
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
) : (
|
||||
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||||
Im Katalog öffnen
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -320,9 +341,19 @@ export default function CombinationCoachSlots({
|
|||
</details>
|
||||
) : null}
|
||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
|
||||
Volle Übungsseite
|
||||
</Link>
|
||||
{typeof onOpenCandidatePeek === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="combo-coach-cand-link"
|
||||
onClick={() => onOpenCandidatePeek(cid)}
|
||||
>
|
||||
Volle Übungsansicht
|
||||
</button>
|
||||
) : (
|
||||
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
|
||||
Volle Übungsseite
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
|
||||
*/
|
||||
import React, { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
archetypeCoachHint,
|
||||
combinationArchetypeLabel,
|
||||
|
|
@ -14,24 +15,22 @@ import {
|
|||
stationPrimaryLoadLabel,
|
||||
} from '../utils/combinationMethodProfileUi'
|
||||
|
||||
function candidateLine(slot) {
|
||||
const cands = slot.candidates
|
||||
if (Array.isArray(cands) && cands.length > 0) {
|
||||
return cands
|
||||
.map((c) =>
|
||||
((c.title || '').trim() || (c.exercise_id != null ? `Übung #${c.exercise_id}` : '')).trim(),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ↔ ')
|
||||
/** @returns {{ exerciseId: number, label: string }[]} */
|
||||
export function normalizeCombinationSlotCandidates(slot) {
|
||||
const out = []
|
||||
const cands =
|
||||
slot.candidates && slot.candidates.length
|
||||
? slot.candidates
|
||||
: (slot.candidate_exercise_ids || []).map((id) => ({ exercise_id: id, title: null }))
|
||||
for (const c of cands) {
|
||||
const rawId = c.exercise_id
|
||||
if (rawId == null) continue
|
||||
const n = typeof rawId === 'number' ? rawId : parseInt(String(rawId), 10)
|
||||
if (!Number.isFinite(n)) continue
|
||||
const label = ((c.title || '').trim() || `Übung #${n}`).trim()
|
||||
out.push({ exerciseId: n, label })
|
||||
}
|
||||
const ids = slot.candidate_exercise_ids || []
|
||||
return ids
|
||||
.map((raw) => {
|
||||
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||||
return Number.isFinite(n) ? `Übung #${n}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ↔ ')
|
||||
return out
|
||||
}
|
||||
|
||||
export default function CombinationPlanBracket({
|
||||
|
|
@ -39,6 +38,9 @@ export default function CombinationPlanBracket({
|
|||
methodProfile,
|
||||
combinationSlots,
|
||||
planningAdjusted = false,
|
||||
/** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */
|
||||
candidateInteraction = 'none',
|
||||
onCandidatePeek,
|
||||
}) {
|
||||
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
|
||||
const archLabel = arch ? combinationArchetypeLabel(arch) : null
|
||||
|
|
@ -97,7 +99,8 @@ export default function CombinationPlanBracket({
|
|||
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
|
||||
const displayStep = si + 1
|
||||
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
|
||||
const names = candidateLine(slot)
|
||||
const candRows = normalizeCombinationSlotCandidates(slot)
|
||||
const names = candRows.length ? candRows.map((r) => r.label).join(' ↔ ') : ''
|
||||
const slotProfRow = timingByIx.get(stationIx)
|
||||
const loadBadge = stationPrimaryLoadLabel(slotProfRow)
|
||||
const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
|
||||
|
|
@ -112,7 +115,35 @@ export default function CombinationPlanBracket({
|
|||
</div>
|
||||
<div className="combo-plan-bracket__station-main">
|
||||
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
|
||||
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
|
||||
{candidateInteraction === 'button' && typeof onCandidatePeek === 'function' && candRows.length > 0 ? (
|
||||
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
|
||||
{candRows.map((c, ci) => (
|
||||
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
|
||||
{ci > 0 ? <span className="combo-plan-bracket__cand-sep">↔</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="combo-plan-bracket__cand-btn"
|
||||
onClick={() => onCandidatePeek(c.exerciseId, c.label)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : candidateInteraction === 'link' && candRows.length > 0 ? (
|
||||
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
|
||||
{candRows.map((c, ci) => (
|
||||
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
|
||||
{ci > 0 ? <span className="combo-plan-bracket__cand-sep">↔</span> : null}
|
||||
<Link to={`/exercises/${c.exerciseId}`} className="combo-plan-bracket__cand-link">
|
||||
{c.label}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
|
||||
)}
|
||||
{timing ? (
|
||||
<div className="combo-plan-bracket__station-timing">
|
||||
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>
|
||||
|
|
|
|||
|
|
@ -54,9 +54,18 @@ function metaParts(exercise) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null }} props
|
||||
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null, onCandidateExercisePeek?: (exerciseId: number) => void }} props
|
||||
*/
|
||||
export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile, catalogMethodProfileSnapshot }) {
|
||||
export default function ExerciseFullContent({
|
||||
exercise,
|
||||
loading,
|
||||
error,
|
||||
exerciseId,
|
||||
variantId,
|
||||
planningComboMethodProfile,
|
||||
catalogMethodProfileSnapshot,
|
||||
onCandidateExercisePeek,
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '1rem' }}>
|
||||
|
|
@ -129,6 +138,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
|||
combinationSlots={exercise.combination_slots}
|
||||
methodArchetype={exercise.method_archetype}
|
||||
methodProfile={coachComboProfile}
|
||||
onOpenCandidatePeek={onCandidateExercisePeek}
|
||||
/>
|
||||
) : null}
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
|
||||
* Unterstützt Drill-down zu Kandidaten-Übungen bei Kombinationen inkl. „Zurück“ (PWA-sicher).
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||
|
|
@ -25,6 +26,8 @@ function TagMini({ exercise }) {
|
|||
)
|
||||
}
|
||||
|
||||
/** @typedef {{ exerciseId: number, variantId?: number | null, peekExtras?: object | null }} PeekStackEntry */
|
||||
|
||||
export default function ExercisePeekModal({
|
||||
open,
|
||||
exerciseId,
|
||||
|
|
@ -37,36 +40,37 @@ export default function ExercisePeekModal({
|
|||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
const [exercise, setExercise] = useState(null)
|
||||
/** @type {[PeekStackEntry[], React.Dispatch<React.SetStateAction<PeekStackEntry[]>>]} */
|
||||
const [stack, setStack] = useState([])
|
||||
|
||||
const variant =
|
||||
variantId != null && variantId !== '' && exercise?.variants?.length
|
||||
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
|
||||
: null
|
||||
|
||||
const isCombination =
|
||||
exercise &&
|
||||
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||||
|
||||
const comboMethodProfileEffective = useMemo(() => {
|
||||
if (!exercise || !isCombination) return {}
|
||||
const fromPeek =
|
||||
peekExtras?.catalog_method_profile &&
|
||||
typeof peekExtras.catalog_method_profile === 'object' &&
|
||||
!Array.isArray(peekExtras.catalog_method_profile) &&
|
||||
Object.keys(peekExtras.catalog_method_profile).length > 0
|
||||
? peekExtras.catalog_method_profile
|
||||
: exercise.method_profile || {}
|
||||
return effectiveComboMethodProfile(fromPeek, peekExtras?.planning_method_profile ?? null)
|
||||
}, [exercise, isCombination, peekExtras])
|
||||
/** @type {React.MutableRefObject<boolean>} */
|
||||
const wasOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setExercise(null)
|
||||
setErr(null)
|
||||
setStack([])
|
||||
wasOpenRef.current = false
|
||||
return
|
||||
}
|
||||
if (!exerciseId) {
|
||||
setErr('Keine Übung gewählt')
|
||||
if (exerciseId == null || exerciseId === '') return
|
||||
if (!wasOpenRef.current) {
|
||||
wasOpenRef.current = true
|
||||
setStack([
|
||||
{
|
||||
exerciseId: Number(exerciseId),
|
||||
variantId: variantId ?? null,
|
||||
peekExtras: peekExtras ?? null,
|
||||
},
|
||||
])
|
||||
}
|
||||
}, [open, exerciseId, variantId, peekExtras])
|
||||
|
||||
const top = stack.length ? stack[stack.length - 1] : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !top?.exerciseId) {
|
||||
setExercise(null)
|
||||
setErr(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
|
|
@ -74,7 +78,7 @@ export default function ExercisePeekModal({
|
|||
setLoading(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const data = await api.getExercise(exerciseId)
|
||||
const data = await api.getExercise(top.exerciseId)
|
||||
if (!cancelled) setExercise(data)
|
||||
} catch (e) {
|
||||
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
|
||||
|
|
@ -85,7 +89,40 @@ export default function ExercisePeekModal({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, exerciseId, variantId])
|
||||
}, [open, top?.exerciseId])
|
||||
|
||||
const variant =
|
||||
top?.variantId != null &&
|
||||
top.variantId !== '' &&
|
||||
exercise?.variants?.length
|
||||
? exercise.variants.find((v) => String(v.id) === String(top.variantId)) || null
|
||||
: null
|
||||
|
||||
const isCombination =
|
||||
exercise && String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||||
|
||||
const comboMethodProfileEffective = useMemo(() => {
|
||||
if (!exercise || !isCombination) return {}
|
||||
const fromPeek =
|
||||
top?.peekExtras?.catalog_method_profile &&
|
||||
typeof top.peekExtras.catalog_method_profile === 'object' &&
|
||||
!Array.isArray(top.peekExtras.catalog_method_profile) &&
|
||||
Object.keys(top.peekExtras.catalog_method_profile).length > 0
|
||||
? top.peekExtras.catalog_method_profile
|
||||
: exercise.method_profile || {}
|
||||
return effectiveComboMethodProfile(fromPeek, top?.peekExtras?.planning_method_profile ?? null)
|
||||
}, [exercise, isCombination, top?.peekExtras])
|
||||
|
||||
const planningAdjustedBadge =
|
||||
top?.peekExtras?.planning_method_profile != null &&
|
||||
typeof top.peekExtras.planning_method_profile === 'object' &&
|
||||
!Array.isArray(top.peekExtras.planning_method_profile)
|
||||
|
||||
const pushCandidatePeek = (id) => {
|
||||
const n = Number(id)
|
||||
if (!Number.isFinite(n)) return
|
||||
setStack((s) => [...s, { exerciseId: n, variantId: null, peekExtras: null }])
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
|
|
@ -107,9 +144,19 @@ export default function ExercisePeekModal({
|
|||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h3 id="exercise-peek-title" className="admin-modal-sheet__title">
|
||||
{loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`}
|
||||
<div className="admin-modal-sheet__header" style={{ gap: '8px', flexWrap: 'wrap' }}>
|
||||
{stack.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flexShrink: 0, fontWeight: 600 }}
|
||||
onClick={() => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s))}
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
) : null}
|
||||
<h3 id="exercise-peek-title" className="admin-modal-sheet__title" style={{ flex: '1 1 200px', minWidth: 0 }}>
|
||||
{loading ? '…' : exercise?.title || titleFallback || (top?.exerciseId != null ? `Übung #${top.exerciseId}` : 'Übung')}
|
||||
</h3>
|
||||
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||||
Schließen
|
||||
|
|
@ -130,14 +177,10 @@ export default function ExercisePeekModal({
|
|||
<CombinationPlanBracket
|
||||
methodArchetype={exercise.method_archetype || ''}
|
||||
methodProfile={comboMethodProfileEffective}
|
||||
combinationSlots={
|
||||
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
|
||||
}
|
||||
planningAdjusted={
|
||||
peekExtras?.planning_method_profile != null &&
|
||||
typeof peekExtras.planning_method_profile === 'object' &&
|
||||
!Array.isArray(peekExtras.planning_method_profile)
|
||||
}
|
||||
combinationSlots={Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []}
|
||||
planningAdjusted={planningAdjustedBadge}
|
||||
candidateInteraction="button"
|
||||
onCandidatePeek={pushCandidatePeek}
|
||||
/>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
|
||||
</>
|
||||
|
|
@ -210,13 +253,17 @@ export default function ExercisePeekModal({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{exerciseId && (
|
||||
{top?.exerciseId != null ? (
|
||||
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
|
||||
<Link to={`/exercises/${exerciseId}`} className="btn btn-secondary" style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}>
|
||||
<Link
|
||||
to={`/exercises/${top.exerciseId}`}
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}
|
||||
>
|
||||
Vollständige Übungsseite öffnen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1596,6 +1596,12 @@ export default function TrainingUnitSectionsEditor({
|
|||
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
|
||||
!Array.isArray(comboPlanningModalItem.planning_method_profile)
|
||||
}
|
||||
candidateInteraction={onPeekExercise ? 'button' : 'none'}
|
||||
onCandidatePeek={
|
||||
onPeekExercise
|
||||
? (exId) => onPeekExercise(Number(exId), null, undefined)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
63
frontend/src/components/UnsavedChangesPrompt.jsx
Normal file
63
frontend/src/components/UnsavedChangesPrompt.jsx
Normal 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,
|
||||
)
|
||||
}
|
||||
90
frontend/src/context/ToastContext.jsx
Normal file
90
frontend/src/context/ToastContext.jsx
Normal 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
|
||||
}
|
||||
31
frontend/src/hooks/useUnsavedChangesBlocker.js
Normal file
31
frontend/src/hooks/useUnsavedChangesBlocker.js
Normal 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])
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
|
|||
import api from '../utils/api'
|
||||
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
||||
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
||||
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||||
|
||||
function TagRow({ exercise }) {
|
||||
const tags = []
|
||||
|
|
@ -58,6 +59,8 @@ function ExerciseDetailPage() {
|
|||
const [exercise, setExercise] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
/** Schnellansicht für eingebettete Einzelübungen (Kombination) — ohne Route zu verlassen */
|
||||
const [embeddedPeekExerciseId, setEmbeddedPeekExerciseId] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -108,8 +111,25 @@ function ExerciseDetailPage() {
|
|||
const meta = metaParts(exercise)
|
||||
const fromExerciseEdit = location.state?.fromExerciseEdit === true
|
||||
|
||||
const isCombinationDetail =
|
||||
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||||
Array.isArray(exercise.combination_slots) &&
|
||||
exercise.combination_slots.length > 0
|
||||
const catalogMethodProfileForBracket =
|
||||
exercise.method_profile &&
|
||||
typeof exercise.method_profile === 'object' &&
|
||||
!Array.isArray(exercise.method_profile)
|
||||
? exercise.method_profile
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
|
||||
<ExercisePeekModal
|
||||
key={embeddedPeekExerciseId != null ? String(embeddedPeekExerciseId) : 'exercise-detail-peek'}
|
||||
open={embeddedPeekExerciseId != null}
|
||||
exerciseId={embeddedPeekExerciseId}
|
||||
onClose={() => setEmbeddedPeekExerciseId(null)}
|
||||
/>
|
||||
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
|
||||
← Übersicht
|
||||
|
|
@ -137,39 +157,26 @@ function ExerciseDetailPage() {
|
|||
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
|
||||
</div>
|
||||
|
||||
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||||
Array.isArray(exercise.combination_slots) &&
|
||||
exercise.combination_slots.length > 0 && (
|
||||
<section className="card exercise-detail-section">
|
||||
<h2>Stationen und Übungspools</h2>
|
||||
{exercise.method_archetype ? (
|
||||
<p style={{ fontSize: '14px', color: 'var(--text2)', marginTop: 0 }}>
|
||||
Archetyp: <code>{String(exercise.method_archetype)}</code>
|
||||
</p>
|
||||
) : null}
|
||||
<ol style={{ paddingLeft: '1.25rem', marginBottom: 0 }}>
|
||||
{sortCombinationSlotsForDisplay(exercise.combination_slots).map((s, idx) => (
|
||||
<li key={`${s.slot_index}-${idx}-${(s.title || '').slice(0, 8)}`} style={{ marginBottom: '10px' }}>
|
||||
<strong>{(s.title || '').trim() || `Station ${idx + 1}`}</strong>
|
||||
<ul style={{ margin: '4px 0 0', paddingLeft: '1.2rem' }}>
|
||||
{(s.candidates && s.candidates.length
|
||||
? s.candidates
|
||||
: (s.candidate_exercise_ids || []).map((id) => ({
|
||||
exercise_id: id,
|
||||
title: null,
|
||||
}))
|
||||
).map((c) => (
|
||||
<li key={c.exercise_id}>
|
||||
<Link to={`/exercises/${c.exercise_id}`}>Übung #{c.exercise_id}</Link>
|
||||
{c.title ? ` — ${c.title}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
{isCombinationDetail ? (
|
||||
<section className="card exercise-detail-section">
|
||||
<h2>Ablauf und Stationen</h2>
|
||||
<p style={{ marginTop: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
Katalog‑Ablauf mit Archetyp, Zeiten und Stationen. Station bzw. Einzelübung antippen öffnet eine
|
||||
Schnellansicht mit Kurztext und Ablauf, ohne diese Seite zu verlassen. Die vollständige Übungsseite
|
||||
liegt im Popup unten als Link.
|
||||
</p>
|
||||
<div className="training-run-combo-embed">
|
||||
<CombinationPlanBracket
|
||||
methodArchetype={String(exercise.method_archetype || '').trim()}
|
||||
methodProfile={catalogMethodProfileForBracket}
|
||||
combinationSlots={exercise.combination_slots}
|
||||
planningAdjusted={false}
|
||||
candidateInteraction="button"
|
||||
onCandidatePeek={(exerciseId) => setEmbeddedPeekExerciseId(Number(exerciseId))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{exercise.goal && (
|
||||
<section className="card exercise-detail-section">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseFullContent from '../components/ExerciseFullContent'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import {
|
||||
flattenPlanTimeline,
|
||||
itemStableKey,
|
||||
|
|
@ -178,6 +179,7 @@ export default function TrainingCoachPage() {
|
|||
const [trainerAppend, setTrainerAppend] = useState('')
|
||||
const [saveMarkDone, setSaveMarkDone] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [candidatePeekId, setCandidatePeekId] = useState(null)
|
||||
const [saveOk, setSaveOk] = useState(null)
|
||||
|
||||
const reloadUnit = useCallback(async () => {
|
||||
|
|
@ -460,6 +462,12 @@ export default function TrainingCoachPage() {
|
|||
|
||||
return (
|
||||
<div className="training-coach-page training-coach-layout">
|
||||
<ExercisePeekModal
|
||||
key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'}
|
||||
open={candidatePeekId != null}
|
||||
exerciseId={candidatePeekId}
|
||||
onClose={() => setCandidatePeekId(null)}
|
||||
/>
|
||||
<nav
|
||||
className="no-print training-coach-meta-nav"
|
||||
style={{
|
||||
|
|
@ -750,6 +758,7 @@ export default function TrainingCoachPage() {
|
|||
? currentEntry?.item?.planning_method_profile ?? null
|
||||
: null
|
||||
}
|
||||
onCandidateExercisePeek={(id) => setCandidatePeekId(id)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
||||
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
||||
import {
|
||||
defaultSection,
|
||||
normalizeUnitToForm,
|
||||
|
|
@ -78,6 +81,32 @@ function defaultForm() {
|
|||
}
|
||||
}
|
||||
|
||||
function frameworkDraftSnapshot(fm) {
|
||||
const goalsNorm = (fm.goals || []).map((g) => ({
|
||||
t: (g.title || '').trim(),
|
||||
n: (g.notes || '').trim(),
|
||||
}))
|
||||
const slotsNorm = (fm.slots || []).map((s) => ({
|
||||
title: (s.title || '').trim(),
|
||||
notes: (s.notes || '').trim(),
|
||||
sections: s.sections,
|
||||
}))
|
||||
return JSON.stringify({
|
||||
title: (fm.title || '').trim(),
|
||||
description: (fm.description || '').trim(),
|
||||
focus_area_id: fm.focus_area_id || '',
|
||||
style_direction_id: fm.style_direction_id || '',
|
||||
training_type_ids: [...(fm.training_type_ids || [])].map(String).sort(),
|
||||
target_group_ids: [...(fm.target_group_ids || [])].map(String).sort(),
|
||||
planned_period_start: fm.planned_period_start || '',
|
||||
planned_period_end: fm.planned_period_end || '',
|
||||
visibility: (fm.visibility || '').trim(),
|
||||
club_id: (fm.club_id || '').trim(),
|
||||
goals: goalsNorm,
|
||||
slots: slotsNorm,
|
||||
})
|
||||
}
|
||||
|
||||
function serverFrameworkToForm(fw) {
|
||||
const goalsIn = Array.isArray(fw.goals) && fw.goals.length ? fw.goals : [emptyGoal()]
|
||||
return {
|
||||
|
|
@ -196,6 +225,40 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
|
||||
const [mobileSlotIdx, setMobileSlotIdx] = useState(0)
|
||||
|
||||
const toast = useToast()
|
||||
const baselineRef = useRef(null)
|
||||
const latestFormRef = useRef(form)
|
||||
latestFormRef.current = form
|
||||
const [baselineReady, setBaselineReady] = useState(false)
|
||||
const [bypassDirty, setBypassDirty] = useState(false)
|
||||
|
||||
const dirtySignature = frameworkDraftSnapshot(form)
|
||||
|
||||
useEffect(() => {
|
||||
baselineRef.current = null
|
||||
setBaselineReady(false)
|
||||
setBypassDirty(false)
|
||||
}, [idParam, isNew])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
const handle = window.setTimeout(() => {
|
||||
baselineRef.current = frameworkDraftSnapshot(latestFormRef.current)
|
||||
setBaselineReady(true)
|
||||
}, 120)
|
||||
return () => clearTimeout(handle)
|
||||
}, [loading, idParam, isNew])
|
||||
|
||||
const formDirtyEffective =
|
||||
baselineReady &&
|
||||
baselineRef.current != null &&
|
||||
!bypassDirty &&
|
||||
!loading &&
|
||||
dirtySignature !== baselineRef.current
|
||||
|
||||
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
|
||||
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`)
|
||||
const apply = () => setDesktopLayout(!!mq.matches)
|
||||
|
|
@ -266,7 +329,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
|
||||
setForm(next)
|
||||
} catch (e) {
|
||||
alert(e.message || 'Laden fehlgeschlagen')
|
||||
toast.error(e.message || 'Laden fehlgeschlagen')
|
||||
navigate('/planning/framework-programs')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
|
|
@ -353,42 +416,60 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const performFrameworkSave = async ({ fromUnsavedDialog = false } = {}) => {
|
||||
if (!(form.title || '').trim()) {
|
||||
alert('Titel ist Pflichtfeld.')
|
||||
return
|
||||
toast.error('Titel ist Pflichtfeld.')
|
||||
return false
|
||||
}
|
||||
let payload
|
||||
try {
|
||||
payload = buildApiPayload(form)
|
||||
} catch (e) {
|
||||
alert(e.message || 'Validierung')
|
||||
return
|
||||
toast.error(e.message || 'Validierung')
|
||||
return false
|
||||
}
|
||||
if (!payload.title) {
|
||||
alert('Titel ist Pflichtfeld.')
|
||||
return
|
||||
toast.error('Titel ist Pflichtfeld.')
|
||||
return false
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isNew) {
|
||||
const created = await api.createTrainingFrameworkProgram(payload)
|
||||
navigate(`/planning/framework-programs/${created.id}`, { replace: true })
|
||||
} else {
|
||||
const fid = parseInt(idParam, 10)
|
||||
await api.updateTrainingFrameworkProgram(fid, payload)
|
||||
const refreshed = await api.getTrainingFrameworkProgram(fid)
|
||||
let next = serverFrameworkToForm(refreshed)
|
||||
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
|
||||
setForm(next)
|
||||
toast.success('Rahmenprogramm angelegt.')
|
||||
if (!fromUnsavedDialog) {
|
||||
navigate(`/planning/framework-programs/${created.id}`, { replace: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
const fid = parseInt(idParam, 10)
|
||||
await api.updateTrainingFrameworkProgram(fid, payload)
|
||||
const refreshed = await api.getTrainingFrameworkProgram(fid)
|
||||
let next = serverFrameworkToForm(refreshed)
|
||||
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
|
||||
setForm(next)
|
||||
baselineRef.current = frameworkDraftSnapshot(next)
|
||||
setBypassDirty(false)
|
||||
setBaselineReady(true)
|
||||
toast.success('Gespeichert.')
|
||||
return true
|
||||
} catch (e) {
|
||||
alert(e.message || 'Speichern fehlgeschlagen')
|
||||
toast.error(e.message || 'Speichern fehlgeschlagen')
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
await performFrameworkSave({ fromUnsavedDialog: false })
|
||||
}
|
||||
|
||||
const handleUnsavedDialogSave = async () => {
|
||||
const ok = await performFrameworkSave({ fromUnsavedDialog: true })
|
||||
if (ok) blocker.proceed()
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (isNew) return
|
||||
const fid = parseInt(idParam, 10)
|
||||
|
|
@ -397,7 +478,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
await api.deleteTrainingFrameworkProgram(fid)
|
||||
navigate('/planning/framework-programs')
|
||||
} catch (e) {
|
||||
alert(e.message || 'Löschen fehlgeschlagen')
|
||||
toast.error(e.message || 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1142,11 +1223,18 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
/>
|
||||
|
||||
<ExercisePeekModal
|
||||
key={peekCtx != null ? String(peekCtx.exerciseId) : 'fw-peek-closed'}
|
||||
open={peekCtx != null}
|
||||
exerciseId={peekCtx?.exerciseId || 0}
|
||||
variantId={peekCtx?.variantId ?? undefined}
|
||||
onClose={() => setPeekCtx(null)}
|
||||
/>
|
||||
<UnsavedChangesPrompt
|
||||
blocker={blocker}
|
||||
isBusy={saving}
|
||||
onSave={handleUnsavedDialogSave}
|
||||
onDiscardWithoutSave={() => setBypassDirty(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { activeClubMemberships } from '../utils/activeClub'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||
|
|
@ -124,6 +125,8 @@ function filterDirectoryExcludingLead(directory, excludeLeadPid) {
|
|||
}
|
||||
function TrainingPlanningPage() {
|
||||
const { user } = useAuth()
|
||||
const toast = useToast()
|
||||
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const unitDeepLinkHandledRef = useRef(null)
|
||||
const [groups, setGroups] = useState([])
|
||||
|
|
@ -292,27 +295,32 @@ function TrainingPlanningPage() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const groupsData = await api.listTrainingGroups({ status: 'active' })
|
||||
setGroups(groupsData)
|
||||
await loadPlanTemplates()
|
||||
|
||||
if (groupsData.length > 0) {
|
||||
const ownGroup = groupsData.find((g) => g.trainer_id === user?.id)
|
||||
if (ownGroup) {
|
||||
setSelectedGroupId(ownGroup.id)
|
||||
} else if (groupsData.length === 1) {
|
||||
setSelectedGroupId(groupsData[0].id)
|
||||
}
|
||||
setSelectedGroupId((prev) => {
|
||||
const prevStr = prev != null && prev !== '' ? String(prev) : ''
|
||||
const stillThere = prevStr && groupsData.some((g) => String(g.id) === prevStr)
|
||||
if (stillThere) return prevStr
|
||||
const ownGroup = groupsData.find((g) => g.trainer_id === user?.id)
|
||||
if (ownGroup) return String(ownGroup.id)
|
||||
if (groupsData.length === 1) return String(groupsData[0].id)
|
||||
return ''
|
||||
})
|
||||
} else {
|
||||
setSelectedGroupId('')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
alert('Fehler beim Laden: ' + err.message)
|
||||
toast.error('Fehler beim Laden: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [user?.id, loadPlanTemplates])
|
||||
|
||||
const loadUnits = useCallback(async () => {
|
||||
if (!selectedGroupId) return
|
||||
|
|
@ -357,7 +365,7 @@ function TrainingPlanningPage() {
|
|||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
}, [loadData, tenantClubDepKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroupId) {
|
||||
|
|
@ -482,7 +490,7 @@ function TrainingPlanningPage() {
|
|||
setFwImportSelectedSlots(new Set())
|
||||
setFwImportSlotDates({})
|
||||
} catch (e) {
|
||||
alert(e.message || 'Rahmenprogramm laden fehlgeschlagen')
|
||||
toast.error(e.message || 'Rahmenprogramm laden fehlgeschlagen')
|
||||
setFwImportDetail(null)
|
||||
} finally {
|
||||
setFwImportLoading(false)
|
||||
|
|
@ -519,7 +527,7 @@ function TrainingPlanningPage() {
|
|||
|
||||
const submitFrameworkImport = async () => {
|
||||
if (!selectedGroupId) {
|
||||
alert('Bitte zuerst eine Trainingsgruppe wählen.')
|
||||
toast.error('Bitte zuerst eine Trainingsgruppe wählen.')
|
||||
return
|
||||
}
|
||||
const gid = parseInt(selectedGroupId, 10)
|
||||
|
|
@ -531,14 +539,14 @@ function TrainingPlanningPage() {
|
|||
(s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id
|
||||
)
|
||||
if (!picks.length) {
|
||||
alert('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
|
||||
toast.error('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
|
||||
return
|
||||
}
|
||||
for (const s of picks) {
|
||||
const key = String(s.id)
|
||||
const date = fwImportSlotDates[key] || fwImportStartDate
|
||||
if (!date) {
|
||||
alert('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
|
||||
toast.error('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -556,7 +564,7 @@ function TrainingPlanningPage() {
|
|||
setFrameworkImportOpen(false)
|
||||
await loadUnits()
|
||||
} catch (e) {
|
||||
alert(e.message || 'Übernahme fehlgeschlagen')
|
||||
toast.error(e.message || 'Übernahme fehlgeschlagen')
|
||||
} finally {
|
||||
setFwImportSubmitting(false)
|
||||
}
|
||||
|
|
@ -573,7 +581,7 @@ function TrainingPlanningPage() {
|
|||
|
||||
const handleCreate = () => {
|
||||
if (!selectedGroupId) {
|
||||
alert('Bitte wähle zuerst eine Trainingsgruppe')
|
||||
toast.error('Bitte wähle zuerst eine Trainingsgruppe')
|
||||
return
|
||||
}
|
||||
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
|
||||
|
|
@ -602,7 +610,7 @@ function TrainingPlanningPage() {
|
|||
|
||||
const handleCreateForDate = (isoDay) => {
|
||||
if (!selectedGroupId) {
|
||||
alert('Bitte wähle zuerst eine Trainingsgruppe')
|
||||
toast.error('Bitte wähle zuerst eine Trainingsgruppe')
|
||||
return
|
||||
}
|
||||
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
|
||||
|
|
@ -645,7 +653,7 @@ function TrainingPlanningPage() {
|
|||
: [defaultSection()]
|
||||
}))
|
||||
} catch (err) {
|
||||
alert('Vorlage laden: ' + err.message)
|
||||
toast.error('Vorlage laden: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -691,7 +699,7 @@ function TrainingPlanningPage() {
|
|||
setSectionsEditMode(fullUnit.status === 'completed' ? 'debrief' : 'planning')
|
||||
setShowModal(true)
|
||||
} catch (err) {
|
||||
alert('Fehler beim Laden: ' + err.message)
|
||||
toast.error('Fehler beim Laden: ' + err.message)
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
|
@ -745,9 +753,9 @@ function TrainingPlanningPage() {
|
|||
}))
|
||||
})
|
||||
await loadPlanTemplates()
|
||||
alert('Vorlage gespeichert.')
|
||||
toast.success('Vorlage gespeichert.')
|
||||
} catch (err) {
|
||||
alert('Speichern: ' + err.message)
|
||||
toast.error('Speichern: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -793,7 +801,7 @@ function TrainingPlanningPage() {
|
|||
const handleApplyTrainingModuleConfirm = useCallback(async () => {
|
||||
const mid = parseInt(moduleApplyModuleId, 10)
|
||||
if (!Number.isFinite(mid)) {
|
||||
alert('Bitte ein Trainingsmodul wählen.')
|
||||
toast.error('Bitte ein Trainingsmodul wählen.')
|
||||
return
|
||||
}
|
||||
let secIx = parseInt(String(moduleApplySectionIx), 10)
|
||||
|
|
@ -801,7 +809,7 @@ function TrainingPlanningPage() {
|
|||
|
||||
const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
|
||||
if (!baseSections.length) {
|
||||
alert('Keine Abschnitte im Formular.')
|
||||
toast.error('Keine Abschnitte im Formular.')
|
||||
return
|
||||
}
|
||||
if (secIx < 0 || secIx >= baseSections.length) secIx = 0
|
||||
|
|
@ -933,7 +941,7 @@ function TrainingPlanningPage() {
|
|||
await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id })
|
||||
await loadUnits()
|
||||
} catch (err) {
|
||||
alert(err.message || 'Leitung konnte nicht übernommen werden')
|
||||
toast.error(err.message || 'Leitung konnte nicht übernommen werden')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -980,7 +988,7 @@ function TrainingPlanningPage() {
|
|||
})
|
||||
await loadUnits()
|
||||
} catch (err) {
|
||||
alert(err.message || 'Zuweisung konnte nicht gespeichert werden')
|
||||
toast.error(err.message || 'Zuweisung konnte nicht gespeichert werden')
|
||||
} finally {
|
||||
setAssignSaving(false)
|
||||
}
|
||||
|
|
@ -992,14 +1000,14 @@ function TrainingPlanningPage() {
|
|||
await api.deleteTrainingUnit(unit.id)
|
||||
await loadUnits()
|
||||
} catch (err) {
|
||||
alert('Fehler beim Löschen: ' + err.message)
|
||||
toast.error('Fehler beim Löschen: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!formData.group_id || !formData.planned_date) {
|
||||
alert('Gruppe und Datum sind Pflichtfelder')
|
||||
toast.error('Gruppe und Datum sind Pflichtfelder')
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
|
@ -1050,7 +1058,7 @@ function TrainingPlanningPage() {
|
|||
setShowModal(false)
|
||||
await loadUnits()
|
||||
} catch (err) {
|
||||
alert('Fehler beim Speichern: ' + err.message)
|
||||
toast.error('Fehler beim Speichern: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3079,6 +3087,7 @@ function TrainingPlanningPage() {
|
|||
}}
|
||||
/>
|
||||
<ExercisePeekModal
|
||||
key={planningPeekCtx != null ? String(planningPeekCtx.exerciseId) : 'plan-peek-closed'}
|
||||
open={planningPeekCtx != null}
|
||||
exerciseId={planningPeekCtx?.exerciseId}
|
||||
variantId={planningPeekCtx?.variantId ?? undefined}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user