chore: update versioning and enhance training framework features
- Incremented APP_VERSION to 0.8.10 and DB_SCHEMA_VERSION to 20260505037. - Updated project status and domain model documentation to reflect recent changes. - Enhanced training framework program handling with new slot-blueprint structure. - Introduced API endpoint for creating training units from framework slots. - Improved documentation for training planning and governance concepts.
This commit is contained in:
parent
7e21b44604
commit
c4fbabd8f6
|
|
@ -1,30 +1,30 @@
|
|||
# Shinkan Jinkendo - Projekt-Status
|
||||
|
||||
**Stand:** 2026-04-30
|
||||
**Version (Code):** 0.8.7 (`backend/version.py`, APP_VERSION)
|
||||
**DB-Schema-Version:** `20260430034`
|
||||
**Stand:** 2026-05-05
|
||||
**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION)
|
||||
**DB-Schema-Version:** `20260505037`
|
||||
**Branch:** develop
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Aktueller Meilenstein:** **Progressionsgraph zwischen Übungen** (DB 032–034, API `exercise-progression-graphs`, UI Tabs + Formularblock) — **Zwischenstand**: linear/Reihen/Schwestern gut nutzbar; **parallele gleichwertige Alternativ‑„Pakete“** noch ohne dedizierte UX (**TRAINING_FRAMEWORK_SPEC.md** §4). Ausreichend, um mit **Trainingsplanung / Rahmen** (**CURR‑002 (2)**) weiterzuarbeiten.
|
||||
**Aktueller Meilenstein:** **Trainingsrahmenprogramm Bibliothek + Slot‑Blueprint** (DB **036–037**): Rahmenkopf nur als Vorlage mit Kontext‑Stammdaten; pro Slot genau eine **Blueprint‑`training_unit`** mit **`framework_unit_sections`/`_items`** wie die Planung; Kalenderliste blendet Blueprints aus; **`POST /api/training-units/from-framework-slot`** materialisiert Kopien mit **`origin_framework_slot_id`**. Parallel: **Progressionsgraph** (032–034) bleibt unterstützend (**`TRAINING_FRAMEWORK_SPEC.md`** §3–§4).
|
||||
|
||||
**Letzte dokumentierte Änderungen (April 2026):**
|
||||
**Letzte dokumentierte Änderungen (Mai 2026):**
|
||||
|
||||
- ✅ Migration **032–034**: `exercise_progression_graphs`, `exercise_progression_edges` inkl. **`notes`**, optionale Varianten-Endpunkte.
|
||||
- ✅ **`POST /api/exercise-progression-graphs/{id}/edges/sequence`** und **`…/edges/delete-batch`**.
|
||||
- ✅ **Übungsliste:** Tabs Liste · Progressionsgraphen; **Übung bearbeiten:** Block Progressionsgraph.
|
||||
- ✅ Zuvor geliefert: Varianten Ende-zu-Ende (030), Listen-Suche UX, Medien-Limits, RichText — siehe unten und Feature-Doc.
|
||||
- ✅ Migration **036:** Rahmen nur Bibliothek; Fokus/Stil + M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`/`group_id` am Kopf.
|
||||
- ✅ Migration **037:** `training_units.framework_slot_id` / `origin_framework_slot_id`; Migration Entfall **`training_framework_slot_exercises`**.
|
||||
- ✅ APIs: erweiterte Rahmen‑Hydration (`sections`, `exercises`, `blueprint_training_unit_id`); Planung siehe **`TRAINING_FRAMEWORK_SPEC.md`** §2.4.
|
||||
- ✅ Frontend: `createTrainingUnitFromFrameworkSlot` in `api.js`.
|
||||
|
||||
**Referenz:** Ausführliche technische Liste → [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Zwischenstand Graph → [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md)
|
||||
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Rahmen/Graph: [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md)
|
||||
|
||||
**Nächste Schritte (Auszug):**
|
||||
|
||||
1. **Trainingsplanungs-/Rahmenmodul** nach CURR‑002 (2), CURR‑009–013 (Graph bleibt unterstützend).
|
||||
2. Prod-Deployment Migrationen bis **034** und Smoke-Tests.
|
||||
3. Optional Backlog Graph: Alternativgruppen / bessere Visualisierung verzweigter Graphen.
|
||||
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**).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -42,6 +42,7 @@
|
|||
| 028–029 | exercise_media / skills Stufen | ✅ | 🔲 |
|
||||
| **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 |
|
||||
| **032–034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
|
||||
| **035–037** | **Rahmenprogramm, Bibliothek‑Kopf, Slot‑Blueprint‑Units** | ✅ | 🔲 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -74,8 +75,10 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
|
||||
**Trainingsplanung:**
|
||||
|
||||
- [x] Training Units / Einbinden von Übungen
|
||||
- [x] Training Units / strukturierter Ablauf (Sektionen + Items)
|
||||
- [x] **Optionale Zuordnung einer Übungsvariante** pro Eintrag (`exercise_variant_id`)
|
||||
- [x] **Trainingsrahmenprogramm Bibliothek** (Ziele, Slots, Kontext) + **Slot‑Blueprints** in `training_units` (036–037)
|
||||
- [x] **Materialisierung** aus Rahmen‑Slot (`POST …/training-units/from-framework-slot`; UI‑Anbindung optional)
|
||||
- [ ] Kalender-View / erweiterte Roadmap (Backlog)
|
||||
|
||||
**MediaWiki Import:**
|
||||
|
|
@ -122,7 +125,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
|
||||
### Dev
|
||||
|
||||
Branch `develop`; Migrations bis mindestens **034** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
|
||||
Branch `develop`; Migrations bis mindestens **037** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
|
||||
|
||||
### Prod
|
||||
|
||||
|
|
@ -134,16 +137,16 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
|||
|
||||
| Dokument | Pfad | Stand | Status |
|
||||
|----------|------|-------|--------|
|
||||
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-04-30 | ✅ Aktualisiert (032–034) |
|
||||
| Trainingsrahmen (Zwischenstand Graph) | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-04-30 | ✅ |
|
||||
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-05 | ✅ Aktualisiert (u. a. 036–037) |
|
||||
| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint |
|
||||
| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-04-27 | ✅ Neu |
|
||||
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-04-30 | ✅ Aktualisiert (034) |
|
||||
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-04-30 | ✅ Aktualisiert |
|
||||
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-05 | ✅ Aktualisiert (037) |
|
||||
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-05 | ✅ Aktualisiert |
|
||||
| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-04-30 | ✅ Ergänzt Progressions-API |
|
||||
| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise |
|
||||
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
|
||||
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) |
|
||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-04-30 | ✅ Diese Datei |
|
||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-05 | ✅ Diese Datei |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -154,4 +157,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
|||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-04-30
|
||||
**Letzte Aktualisierung:** 2026-05-05
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo - Fachliches Domänenmodell
|
||||
|
||||
**Version:** 0.4.2
|
||||
**Stand:** 2026-05-05 (Migration 035: Rahmen‑Vorlage `training_framework_programs`; Progressionsgraph unverändert 032–034)
|
||||
**Version:** 0.4.3
|
||||
**Stand:** 2026-05-05 (Migration **036–037:** Rahmen nur Bibliothek; Slot‑Inhalt über Blueprint‑`training_units` + Sektionen/Items wie Planung — siehe `TRAINING_FRAMEWORK_SPEC.md` §2)
|
||||
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
|
||||
|
||||
---
|
||||
|
|
@ -468,9 +468,11 @@ skill_level_definitions (
|
|||
|
||||
### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009)
|
||||
|
||||
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und **direkten Übungszuordnungen** pro Slot („Stückliste“, **CURR‑010**). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** und ersetzt keine Slot-Zuordnung (**CURR‑013**).
|
||||
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**).
|
||||
|
||||
**Zwei Nutzungsmodi (CURR‑012):** **`concrete`** (Kurzfrist‑/Gruppenkontext; optional `group_id`, Slots dürfen **`training_unit_id`** tragen) vs. **`library`** (zeit‑/gruppenlose Vorlage; **`group_id`** und Slot‑Einheitsverknüpfungen sind fachlich gesperrt — technisch werden Einheits-FKs beim Wechsel geleert). **Materialisierung / Bulk‑Anlegen** von `training_units` aus dem Rahmen ist ein **separater** Schritt (Stub/PR). **optional `training_plan_template_id` pro Slot** ist bewusst **deferred** (**C5**/CURR‑010).
|
||||
**Bibliothek only (036):** Kein Kopf‑`plan_mode`/keine Kopf‑`group_id`; Zuordnung zu Gruppe und Datum erfolgt nur über **kopierte** Kalender‑`training_units` (Instanz).
|
||||
|
||||
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Konzept: Trainingsplanung über Einheiten hinweg, Kurspläne, Governance, Assessments
|
||||
|
||||
**Status:** Arbeitspapier (lebend)
|
||||
**Stand:** 2026-04-30 (CURR‑002 Stufe 1 Zwischenstand im Produkt; Rahmen CURR‑002 (2) als nächster Schritt)
|
||||
**Stand:** 2026-05-05 (Rahmen‑Bibliothek **036**, Slot‑Blueprint **037** / API `from-framework-slot`; CURR‑002 Stufe 1 Graph unverändert 032–034)
|
||||
**Zweck:** Erkenntnisse und **getroffene Entscheidungen** festhalten, um Spec- und Implementierungsdrift zu vermeiden.
|
||||
**Kanons:** Bei Widersprüchen mit produktiven Specs zuerst diese Datei mit dem Team abstimmen; technische Details ergänzen später in `technical/`.
|
||||
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
| Stufe | Inhalt | Status |
|
||||
|--------|--------|--------|
|
||||
| **1** | **Progressionsbezüge** zwischen Übungen **persistent speicherbar** (Progressionsbaum / -graph zwischen Übungseinheiten, nicht nur UI) | ✅ **Zwischenstand im Produkt** (Migrationen 032–034, UI/API); UX für **parallele gleichwertige Alternativ‑Pakete** noch kein Erstklass‑Fall — siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4 |
|
||||
| **2** | **Planungs-/Rahmenmodus:** Übungen (beliebig oder aus Progression) auf **mehrere** Session-Slots / Trainingseinheiten **verteilen**, **mehrere Ziele**; speicherbare Rahmen-Vorlage (CURR‑002 (2) i. V. m. **CURR‑010–013**) | **nächster Implementierungsschwerpunkt** — Stufe 1 ist nicht Blocker (**CURR‑013**) |
|
||||
| **2** | **Planungs-/Rahmenmodus:** Übungen auf **mehrere** Session-Slots verteilen; **mehrere Ziele**; speicherbare Rahmen-Vorlage (CURR‑002 (2) i. V. m. **CURR‑010–013**) | **in Arbeit**: Bibliotheks‑Backend + Slot‑Blueprint + Kopie‑API (**037**); **UI Kalender**/Bulk folgt (**CURR‑012**) |
|
||||
| **3** | **Konkrete Einheit:** aus Rahmen-/Verteilungsplan **Vorschläge** beim Ausarbeiten laden; Bezug zur Idee **„Warenkorb“** bei der Übungsplanung | folgt nach 2 |
|
||||
|
||||
### 2.b Übrige Konzept-Schritte (noch durchzuarbeiten)
|
||||
|
|
@ -40,11 +40,11 @@
|
|||
| **B** | **Governance-Muster** (einheitliche Sichtbarkeit; Bibliothek vs. Instanz) | ✅ Leitplan §2.c; Entscheidungen §5 CURR-005–007 |
|
||||
| **C** | **Rahmenprogramm** — §2.d (**C1–C4** ✅ · **C5** Leitplan) | ✅ Kern i. V. m. **CURR‑009–013** |
|
||||
| **D** | **Kurs-/Stufenprogramm:** nach Rahmenprogramm; plantechnisch ähnlich | 📌 zeitlich nachgelagert (CURR-003) |
|
||||
| **E** | **Lineage & Feedback** (Einheit ↔ Vorlage/Rahmen; Issues zur Nachbesserung) | ⬜ offen |
|
||||
| **E** | **Lineage & Feedback** (Einheit ↔ Vorlage/Rahmen; Issues zur Nachbesserung) | ➕ **teilweise:** `training_units.origin_framework_slot_id`; vollständiges Konzept/UX offen |
|
||||
| **F** | **Assessments** | 📌 Backlog (CURR-003) |
|
||||
| **G** | **Progressions-Automatik** (KI, komplexe Vorschläge) | 📌 Backlog (CURR-003) |
|
||||
|
||||
**Aktueller Fokus:** Umsetzung **Trainingsplanungs-/Rahmenmodul** (**CURR‑002 (2)**): Entitäten, Slots, mehrere Ziele — Progressionsgraph Stufe 1 ist **bereits** als unterstützende Bibliotheksfunktion vorhanden (**TRAINING_FRAMEWORK_SPEC.md**). Schritt **E** (Lineage) als nächstes Konzeptpaket möglich.
|
||||
**Aktueller Fokus:** **Kalender-/Planungs‑UI** an **`POST /api/training-units/from-framework-slot`** und Visibility für geteilte Rahmen; weiteres Lineage (**Schritt E**) ergänzend zu **`origin_framework_slot_id`**.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -59,8 +59,9 @@ Ein **wiedererkennbares Muster** für alle **Bibliotheksobjekte** (Übung, Train
|
|||
| Objekt | Relevante Felder / Muster |
|
||||
|--------|---------------------------|
|
||||
| `exercises`, `exercise_blocks` | `visibility` ∈ `private` \| `club` \| `official`, `club_id`, `created_by` — **Referenzmuster** |
|
||||
| `training_plan_templates` | `club_id`, `created_by`, **kein** `visibility` — Abweichung; nachziehen oder Semantik explizit festlegen (siehe CURR-007) |
|
||||
| `training_units` | `group_id`, `created_by`, `plan_template_id` — **Instanz**; Zugriff fachlich über Gruppe/Trainer |
|
||||
| `training_plan_templates` | `club_id`, `created_by`, **`visibility`** seit **035** (Backfill **`club`**); Referenz‑Muster an Übung angleichen (**CURR‑007** erledigt für dieses Feld) |
|
||||
| `training_framework_programs` | `visibility`, `club_id`, `created_by`; Kontext `focus_area_id`, `style_direction_id`; keine Kopf‑`group_id`; Slot‑Inhalt über **Blueprint‑`training_units`** (**036–037**) |
|
||||
| `training_units` | `group_id`, `created_by`, `plan_template_id` — **Instanz** oder **Blueprint** (`framework_slot_id`); Lineage‑Light **`origin_framework_slot_id`** |
|
||||
|
||||
#### B.3 Prinzipien (binding mit §5)
|
||||
|
||||
|
|
@ -114,12 +115,14 @@ Ein **wiedererkennbares Muster** für alle **Bibliotheksobjekte** (Übung, Train
|
|||
|
||||
**Klartext zur früheren Frage „C4a vs. C4b“:** Bei **Modus A** passen **automatisches Anlegen n Einheiten** (früheres C4a) **oder** Zuordnung zu **bereits geplanten** Einheiten (C4b) — je nach Produkt/UI. Bei **Modus B** existieren erst bei der Übernahme überhaupt Gruppe/Zeiten; die Bibliotheksvorlage bleibt **neutral**.
|
||||
|
||||
**Stand Code 036–037:** Am Rahmenkopf gibt es **keine** **`plan_mode`/`group_id`** mehr — die Bibliothek ist immer „Modus B“; konkrete Gruppe/Zeit entstehen **nur** in **`training_units`** (Kalender‑Zeilen oder Übernahme‑API **`from-framework-slot`**).
|
||||
|
||||
---
|
||||
|
||||
#### C2 (Klärung fürs Team)
|
||||
|
||||
„**C2**“ im Entwurf bezog sich auf „**wie** weiß ich pro Slot welche Übungen (nur Progression oder auch Mikro‑Vorlage)?“ — **aktueller Beschluss:**
|
||||
Pro Slot: **Zuordnung von Übung(en)** **direkt** (wie „Stückliste“) ist **tragend**; **Progressionsgraph** liefert **Vorschläge / Pakete**, wenn admins sie pflegen — **Trainer** können **ohne** Graph planen.
|
||||
Pro Slot: **Zuordnung von Übung(en)** als **Teil des vollständigen Ablaufs** (wie geplante Einheit: **Sektionen und Items**, **037**) ist **tragend**; **Progressionsgraph** liefert **Vorschläge / Pakete**, wenn Admins sie pflegen — **Trainer** können **ohne** Graph planen.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# Gelieferte Features & technische Basis (April 2026)
|
||||
# Gelieferte Features & technische Basis (Q2 2026)
|
||||
|
||||
**Stand:** 2026-04-30
|
||||
**Referenz:** `backend/version.py` — **APP_VERSION 0.8.7**, **DB_SCHEMA_VERSION 20260430034**
|
||||
**Stand:** 2026-05-05
|
||||
**Referenz:** `backend/version.py` — **APP_VERSION 0.8.10**, **DB_SCHEMA_VERSION 20260505037**
|
||||
|
||||
Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **`technical/TRAINING_FRAMEWORK_SPEC.md`** §3–§4. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
|
||||
Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. Trainingsrahmen‑Bibliothek + Slot‑Blueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§3–4**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -15,6 +15,9 @@ Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren**
|
|||
| **028** | `exercise_media` erweitert (Embed/Metadaten), `exercise_skills` Level-Felder (VARCHAR); Medien-API |
|
||||
| **029** | Kanonische Fähigkeitsstufen (basis–optimierung), `model_levels`-Namen |
|
||||
| **030** | `training_unit_exercises.exercise_variant_id` → FK `exercise_variants(id)` ON DELETE SET NULL |
|
||||
| **035** | **`training_framework_programs`** + Ziele, Slots (+ frühere Slot‑Übungstabelle, heute entfallen nach **037**); **`training_plan_templates.visibility`** |
|
||||
| **036** | Rahmen nur Bibliothek: Kontext + M:N Trainingsarten/Zielgruppen; keine Modus-Spalten / keine Kopf‑`group_id` |
|
||||
| **037** | **`training_units.framework_slot_id`**, strukturierter Ablauf wie Planung; Entfall **`training_framework_slot_exercises`**; **`origin_framework_slot_id`** |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -55,9 +58,10 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
|||
|
||||
## 4. Backend – Trainingsplanung (`routers/training_planning.py`)
|
||||
|
||||
- `training_unit_exercises`: Schreiben/Lesen von **`exercise_variant_id`**.
|
||||
- Validierung: Variante muss zur gewählten **`exercise_id`** gehören.
|
||||
- JOIN liefert u. a. **`exercise_variant_name`** beim Lesen einer Einheit.
|
||||
- Strukturierte Einheiten: **`training_unit_sections`** + **`training_unit_section_items`** (Migration **031**) — Hauptpfad beim Lesen/Schreiben von Einheiten.
|
||||
- **`training_unit_exercises`:** Legacy-/Nebenpfad; weiterhin **`exercise_variant_id`** (Migration **030**) mit Validierung gegen die gewählte **`exercise_id`**; JOINs liefern u. a. **`exercise_variant_name`**.
|
||||
- **Blueprint‑Zeilen (`framework_slot_id` gesetzt):** **`GET /api/training-units`** listet diese **nicht**; **`PUT`** mit eingeschränkten Regeln (**kein** `plan_template_id` / kein Reset aus Vorlage über diesen Kopf wie bei Kalender‑Einheit).
|
||||
- Übernahme aus Rahmen: **`POST /api/training-units/from-framework-slot`** ({ `framework_slot_id`, `group_id`, `planned_date` }) — tiefe Kopie inkl. Sektionen/Items; **`origin_framework_slot_id`** setzt Lineage‑Light.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -109,16 +113,26 @@ Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Be
|
|||
|
||||
---
|
||||
|
||||
## 11. Nächste sinnvolle Schritte (nicht Lieferstand)
|
||||
## 11. Trainingsrahmen: Bibliothek + Slot‑Blueprint (DB **036–037**)
|
||||
|
||||
- Trainingsplanungs-/Rahmenmodul (**CURR‑002 (2)**) — Progressionsgraph ist unterstützend, siehe **`TRAINING_FRAMEWORK_SPEC.md`** §4.
|
||||
- **036:** `training_framework_programs` nur Bibliothek — `focus_area_id`, `style_direction_id`, M:N `training_framework_program_training_types` / `_target_groups`; Entfall `plan_mode`, `group_id`; Slot‑Verknüpfungen zu Kalender‑Einheiten geleert.
|
||||
- **037:** Pro Slot genau eine **`training_units`**‑Zeile mit **`framework_slot_id`**; Ablauf über **`training_unit_sections`** / **`training_unit_section_items`** (wie Planung); Legacy **`training_framework_slot_exercises`** Datenmigration + **`DROP` TABLE**; geplante Kopien können **`origin_framework_slot_id`** tragen.
|
||||
- **Router `training_framework_programs.py`:** CRUD **`/api/training-framework-programs`**, Slots im Speichern mit neuen Blueprint‑`training_units`, Hydration **`sections`/`exercises`/`blueprint_training_unit_id`**; siehe **`TRAINING_FRAMEWORK_SPEC.md`** §2.3.
|
||||
- **Frontend:** **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** (`api.js`).
|
||||
- **Doku:** **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2; **`technical/DATABASE_SCHEMA.md`**; **`functional/DOMAIN_MODEL.md`** (Trainingsrahmen‑Abschnitt).
|
||||
|
||||
---
|
||||
|
||||
## 12. Nächste sinnvolle Schritte (nicht Lieferstand)
|
||||
|
||||
- Trainingsplanung: Kalender‑UI‑Anbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR‑004** später).
|
||||
- Progressions-Serien als **Blöcke** (angekündigt; Voraussetzung: `prerequisite_variant_id` / `progression_level` vorhanden).
|
||||
- Serverseitige **Suchvorschläge** (Autocomplete-Endpoint), falls datalist nicht reicht.
|
||||
- Optional: Streaming/chunked Upload für sehr große Videos (RAM-Thema).
|
||||
|
||||
---
|
||||
|
||||
## 12. Verweise
|
||||
## 13. Verweise
|
||||
|
||||
| Thema | Dokument |
|
||||
|--------|----------|
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo - Datenbank-Schema (Technisch)
|
||||
|
||||
**Version:** 0.5.1
|
||||
**Version:** 0.5.2
|
||||
**Stand:** 2026-05-05
|
||||
**Hinweis:** Produktiver Deploy sollte mindestens bis Migration **035** (Trainingsrahmenprogramm + Vorlagen-`visibility`) geführt sein — Details siehe `backend/version.py` (`DB_SCHEMA_VERSION`).
|
||||
**Hinweis:** Produktiver Deploy sollte mindestens bis Migration **037** (Rahmen‑Slot‑Blueprints in `training_units`; Entfall `training_framework_slot_exercises`) geführt sein — Details siehe `backend/version.py` (`DB_SCHEMA_VERSION`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -44,7 +44,9 @@ Dieses Dokument beschreibt die **technische Datenbankstruktur** von Shinkan Jink
|
|||
| **032** | **2026-04-30** | **Progressionsgraph Übung→Übung:** `exercise_progression_graphs`, `exercise_progression_edges` | ✅ |
|
||||
| **033** | **2026-04-30** | **`exercise_progression_edges.notes`** | ✅ |
|
||||
| **034** | **2026-04-30** | **Kanten-Endpunkte optional `exercise_variants`; UNIQUE/CHECK** | ✅ |
|
||||
| **035** | **2026-05-05** | **Rahmenprogramm:** `training_framework_programs` (+ Ziele, Slots, Slot-Übungen); **`training_plan_templates.visibility`** (Backfill `club`) — siehe `TRAINING_FRAMEWORK_SPEC.md` | ✅ |
|
||||
| **035** | **2026-05-05** | **Rahmenprogramm:** `training_framework_programs` (+ Ziele, Slots, früher `training_framework_slot_exercises`); **`training_plan_templates.visibility`** (Backfill `club`) — siehe `TRAINING_FRAMEWORK_SPEC.md` | ✅ |
|
||||
| **036** | **2026-05-05** | **Rahmen nur Bibliothek:** Kopf mit `focus_area_id`, `style_direction_id`, M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`, `group_id`; Slot‑`training_unit_id` geleert — siehe `036_framework_program_context_only_library.sql` | ✅ |
|
||||
| **037** | **2026-05-05** | **Slot‑Blueprint:** `training_units.framework_slot_id` (+ CHECK Blueprint vs. Kalender), `origin_framework_slot_id`; Migration Slot‑Übungen → `training_unit_sections`/`training_unit_section_items`; **`DROP training_framework_slot_exercises`** | ✅ |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -270,14 +272,51 @@ exercise_variants (id, exercise_id, name, description, ...)
|
|||
exercise_media (id, exercise_id, type, url, title, description, ...)
|
||||
```
|
||||
|
||||
### Training Planning
|
||||
### Trainingsrahmenprogramm Bibliothek (Migrationen **035–036**)
|
||||
|
||||
Kopf ohne Gruppenbindung (`training_framework_programs`), Ziele, Slots. Slot‑spezifischer Ablauf liegt nach **037** nicht mehr in eigener Übungstabelle, sondern in **`training_units`** mit **`framework_slot_id`** — siehe nächster Abschnitt.
|
||||
|
||||
```sql
|
||||
training_units (id, group_id, date, title, description, ...)
|
||||
training_unit_exercises (
|
||||
training_unit_id, exercise_id, sort_order,
|
||||
exercise_variant_id -- FK exercise_variants(id) ON DELETE SET NULL (Migration 030)
|
||||
training_framework_programs (… focus_area_id, style_direction_id, visibility, club_id, created_by …)
|
||||
training_framework_goals (framework_program_id, sort_order, title, notes)
|
||||
training_framework_slots (framework_program_id, sort_order, title, notes, training_unit_id -- ungenutzt)
|
||||
training_framework_program_training_types (framework_program_id, training_type_id)
|
||||
training_framework_program_target_groups (framework_program_id, target_group_id)
|
||||
```
|
||||
|
||||
### Training Planning & Rahmen‑Blueprint (Migrationen 006, 031, **037**)
|
||||
|
||||
Geplante Einheit und **Rahmen‑Slot‑Blueprint** teilen sich **`training_units`** und den strukturierten Ablauf über **Sektionen** (031). Blueprint‑Zeilen haben **`framework_slot_id`** gesetzt (genau eine Zeile pro Slot); Kalender‑Zeilen haben **`framework_slot_id IS NULL`** und **`group_id` / `planned_date`** gesetzt. Kopien aus dem Rahmen können **`origin_framework_slot_id`** setzen.
|
||||
|
||||
```sql
|
||||
training_units (
|
||||
id,
|
||||
group_id INT NULL REFERENCES training_groups(id), -- Pflicht für Kalender‑Zeilen (CHECK)
|
||||
planned_date DATE NULL, -- Pflicht für Kalender‑Zeilen (CHECK)
|
||||
planned_time_start, planned_time_end, planned_focus,
|
||||
actual_date, actual_time_start, actual_time_end, attendance_count,
|
||||
status, notes, trainer_notes,
|
||||
created_by, plan_template_id REFERENCES training_plan_templates(id),
|
||||
framework_slot_id INT NULL REFERENCES training_framework_slots(id) ON DELETE CASCADE,
|
||||
origin_framework_slot_id INT NULL REFERENCES training_framework_slots(id) ON DELETE SET NULL,
|
||||
…
|
||||
)
|
||||
training_unit_sections (
|
||||
training_unit_id, order_index, title, guidance_notes,
|
||||
source_template_section_id REFERENCES training_plan_template_sections(id)
|
||||
)
|
||||
training_unit_section_items (
|
||||
section_id, order_index, item_type CHECK ('exercise'|'note'),
|
||||
exercise_id, exercise_variant_id, planned_duration_min, actual_duration_min,
|
||||
notes, modifications, note_body
|
||||
)
|
||||
```
|
||||
|
||||
**Legacy (Migration 006, für ältere Codepfade noch referenzierbar):** `training_unit_exercises`; produktiver Standardablauf liegt in **Sections/Items**.
|
||||
|
||||
**Trainingsvorlagen (031):** `training_plan_templates`, `training_plan_template_sections`.
|
||||
|
||||
```sql
|
||||
exercise_blocks (id, name, description, created_by, club_id, ...) -- Migration 017
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Trainingsrahmenprogramm — Technische Spezifikation
|
||||
|
||||
**Status:** Zwischenstand dokumentiert · **Stand:** 2026-05-05
|
||||
**Status:** Rahmen‑Bibliothek + Slot‑Blueprint dokumentiert · **Stand:** 2026-05-05 (Migration **036–037**)
|
||||
**Bindendes Fachkonzept / Entscheide:** `.claude/docs/functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR‑001 bis CURR‑013)
|
||||
|
||||
**Relevant für nächsten Schritt:** CURR‑002 **(2)** Trainingsplanung / Rahmen über mehrere Einheiten — der hier dokumentierte **Progressionsgraph Stufe 1** ist bewusst **unterstützend**, keine Pflicht für Slot-Zuordnungen (**CURR‑013**).
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
| `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_API_SPEC.md` | **Übungskatalog** inkl. Varianten-Progression **innerhalb einer Übung** (Migration 014). Kanten **zwischen** Übungen siehe **§3**. |
|
||||
| `DATABASE_SCHEMA.md` | **Nachgeordnete** Übersicht: Migrationshistorie und Tabellenliste; Detail-DDL primär **hier §2–§3** + SQL unter `backend/migrations/`. |
|
||||
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
|
||||
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Modus A/B, Governance, CURR‑Tabelle). |
|
||||
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURR‑Tabelle). |
|
||||
|
||||
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 1–2. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „Alternative‑Pakete“ in der UI).
|
||||
|
||||
|
|
@ -22,84 +22,91 @@
|
|||
|
||||
## 2. Rahmenprogramm (CURR‑002 Stufe 2) — Checkliste & technische Ausarbeitung
|
||||
|
||||
### 2.0 Technische Entscheidung: eine Tabelle + `plan_mode` (Modus A | B)
|
||||
### 2.0 Technische Entscheidung: nur Bibliothek + Slot‑Blueprint = `training_units`
|
||||
|
||||
**Entscheid:** **Eine** Hauptentität `training_framework_programs` mit **`plan_mode` ∈ {`concrete`, `library`}** statt zweier getrennter Tabellentypen.
|
||||
**Fachlich:** Das Rahmenprogramm ist eine **wiederverwendbare Bibliotheksvorlage** ohne Bindung an eine Trainingsgruppe oder einen Kalendertermin (**Migration 036** entfernte `plan_mode`, `group_id` am Kopf sowie die Nutzung von `training_framework_slots.training_unit_id` für „konkrete“ Kopplung).
|
||||
|
||||
**Begründung:** Gleiche Lebenszyklus‑ und CRUD‑Form (Header, Ziele, Slots, Übungen); die fachliche Unterscheidung A/B lässt sich mit **CHECK**- und API‑Regeln ausdrücken (`library` ⇒ `group_id IS NULL`, keine `training_unit_id` an Slots), ohne doppelte Router/Joins. Zwei physische Typen würden ohne Mehrwert Polymorphismus in der API erzwingen oder eine künstliche Supertyp‑Tabelle nach sich ziehen.
|
||||
**Slot‑Inhalt (Migration 037):** Pro `training_framework_slot` existiert genau eine **Blueprint‑Zeile** in `training_units` mit **`framework_slot_id`** (partieller **UNIQUE**-Index); der Ablauf entspricht **derselben** Struktur wie geplante Einheiten über **`training_unit_sections`** und **`training_unit_section_items`** (Übungen, Notizen, Varianten wie in der Planung). Die frühere Tabelle **`training_framework_slot_exercises`** wird nach Datenübernahme **`DROP`**pt.
|
||||
|
||||
**Abgrenzung Konzept §6:** Dokumentiert hier; Funktionskonzept kann auf diesen Abschnitt verweisen.
|
||||
**Geplante Einheit aus Rahmen:** **`POST /api/training-units/from-framework-slot`** kopiert diese Blueprint‑Unit (**tiefe Kopie**) mit **`group_id` + `planned_date`**; **`origin_framework_slot_id`** hält die Herkunft (Lineage‑Light). **`GET /api/training-units`** blendet Einheiten mit **`framework_slot_id IS NOT NULL`** aus (Kalender/API‑Liste ohne Rahmen‑Blueprints).
|
||||
|
||||
**CHECK‑Constraint auf `training_units`:** Zeile ist entweder **Blueprint** (`framework_slot_id` gesetzt, `group_id`/`planned_date` NULL, kein `origin_framework_slot_id`) oder **Kalender‑Einheit** (`framework_slot_id` NULL, `group_id` und `planned_date` gesetzt; `origin_framework_slot_id` optional).
|
||||
|
||||
**Konsequenz Konzept CURR‑012 („concrete/library“):** Persistiert wird **ein** Kopf ohne Modus-Spalte: Immer Bibliotheks‑Rolle; Konkretisierung nur über Planung/API‑Kopie. Historische DDL mit `plan_mode` siehe **`035`**/`036` in dieser Datei (**§5 Changelog**) und `backend/migrations/`.
|
||||
|
||||
### 2.1 Checkliste (Abhak-Stand)
|
||||
|
||||
- [x] **Entität(en):** eigene Bibliotheks-Entität `training_framework_programs` (**CURR‑009**); `training_plan_templates` unverändert **eine‑Einheit‑Mikrovorlage** (**C5**).
|
||||
- [x] **Modus A vs. B:** ein Datensatz + `plan_mode` + Nullables (**§2.0**); `library` erzwingt `group_id` NULL; **`training_plan_template_id` pro Slot** — **deferred** (Technical: MVP ohne Spalte, bis Nutzen geklärt, **CURR‑010**).
|
||||
- [x] **Zielliste:** `training_framework_goals`, API erzwingt **≥ 1** Ziel beim Anlegen/Ersetzen (**CURR‑011**).
|
||||
- [x] **Slots:** `training_framework_slots` mit **`sort_order`**, optional **Titel/Notizen**; Übungen über **`training_framework_slot_exercises`** (Sortierung **`order_index`**, optional **`exercise_variant_id`**).
|
||||
- [x] **Progressionsgraph:** Stufe 1 besteht (**§3–§4**); **kein Pflichtbezug** pro Slot (**CURR‑013**).
|
||||
- [x] **Konkretkontakt Modus A:** optional **`training_unit_id`** pro Slot; bei gesetzter Rahmen-**`group_id`** muss die Einheit zur gleichen Gruppe gehören. **Live‑Writes** zurück in die Bibliotheksvorlage: **nicht** vorgesehen (**CURR‑006**).
|
||||
- [x] **Instanziierung (Modus B):** Persistenz der Vorlage (**MVP**); Bulk-Anlage von **`training_units`** aus dem Rahmen — **Ausbauschritt**/zweiter PR (**CURR‑012** C4a/b).
|
||||
- [x] **Governance neue Objekte:** `visibility`, `club_id`, `created_by` wie Progressionsgraph (**CURR‑005**). **`training_plan_templates.visibility`** nachgezogen in derselben Migration **035** mit Backfill **`club`** (**CURR‑007**, **CURR‑008**; frühe Installationen).
|
||||
- [x] **REST Rahmenprogramm:** `/api/training-framework-programs` (**§2.3**); Progressions‑API weiter **§3.3**.
|
||||
- [x] **Entität(en):** `training_framework_programs` (**CURR‑009**); `training_plan_templates` unverändert **eine‑Einheit‑Mikrovorlage** (**C5**).
|
||||
- [x] **Bibliothek only (036):** Kopf ohne `plan_mode`/`group_id`; Kontextfilter **`focus_area_id`**, **`style_direction_id`**; M:N **`training_framework_program_training_types`**, **`training_framework_program_target_groups`**.
|
||||
- [x] **Zielliste:** `training_framework_goals`, API **≥ 1** Ziel (**CURR‑011**).
|
||||
- [x] **Slots:** `training_framework_slots` mit **`sort_order`**, optional **Titel/Notizen**; **Ablauf** über zugehörige **Blueprint‑`training_units`** + Sektionen/Items (**037**), nicht mehr `training_framework_slot_exercises`.
|
||||
- [x] **Progressionsgraph:** Stufe 1 (**§3–§4**); **kein Pflichtbezug** pro Slot (**CURR‑013**).
|
||||
- [x] **Kein Live‑Write** von Kalendereinheiten zurück in die Vorlage (**CURR‑006**); Konkretisierung = **Kopie** (siehe **§2.4**).
|
||||
- [x] **Instanziierung (MVP):** `POST /api/training-units/from-framework-slot` — weiterer Ausbau: Bulk, Kalender‑UI‑Flow, **`training_plan_template_id` pro Slot** weiterhin optional/deferred (**CURR‑010**).
|
||||
- [x] **Governance:** `visibility`, `club_id`, **`training_plan_templates.visibility`** (**035**) — (**CURR‑005–008**).
|
||||
- [x] **REST Rahmenprogramm:** `/api/training-framework-programs` (**§2.3**); Planung (**§2.4**).
|
||||
|
||||
### 2.2 DDL‑Skizze (Migration **035**, Kurzüberblick)
|
||||
### 2.2 DDL‑Überblick (Migrationen **035** → **036** → **037**)
|
||||
|
||||
Auszug aktueller Zustand nach **036/037** (Details: `backend/migrations/035_training_framework_programs.sql`, `036_framework_program_context_only_library.sql`, `037_training_framework_blueprint_units.sql`):
|
||||
|
||||
```sql
|
||||
-- Header
|
||||
-- Kopf (ohne Modus-Spalten nach 036)
|
||||
training_framework_programs (
|
||||
id, title NOT NULL, description,
|
||||
plan_mode NOT NULL CHECK (IN 'concrete','library'),
|
||||
group_id FK training_groups NULL,
|
||||
planned_period_start, planned_period_end NULL, -- reine Meta-/UI‑Hilfe
|
||||
planned_period_start, planned_period_end NULL,
|
||||
visibility NOT NULL, club_id, created_by,
|
||||
CHECK ((plan_mode = 'library' AND group_id IS NULL) OR plan_mode = 'concrete'),
|
||||
timestamps + update_trigger
|
||||
focus_area_id, style_direction_id NULL REFERENCES …,
|
||||
… timestamps
|
||||
)
|
||||
|
||||
-- Ziele (≥1 über API beim Speichern)
|
||||
training_framework_goals (
|
||||
id, framework_program_id FK CASCADE, sort_order UNIQUE per framework,
|
||||
title, notes
|
||||
)
|
||||
|
||||
-- Slots
|
||||
training_framework_slots (
|
||||
id, framework_program_id FK CASCADE, sort_order UNIQUE per framework,
|
||||
title, notes, training_unit_id FK training_units SET NULL
|
||||
title, notes,
|
||||
training_unit_id FK … (Spalte technisch noch vorhanden; fachlich ungenutzt, per 036 geleert)
|
||||
)
|
||||
|
||||
-- Stückliste pro Slot (Übung → FK CASCADE beim Löschen der Übung)
|
||||
training_framework_slot_exercises (
|
||||
id, slot_id FK CASCADE,
|
||||
exercise_id FK exercises CASCADE,
|
||||
exercise_variant_id FK exercise_variants NULL,
|
||||
order_index UNIQUE per slot
|
||||
)
|
||||
-- Blueprint: eigene Einheit wie in der Planung (031)
|
||||
training_units.framework_slot_id -- UNIQUE (partial index), FK → slots ON DELETE CASCADE
|
||||
training_units.origin_framework_slot_id -- optional auf Kopien aus dem Rahmen (SET NULL)
|
||||
-- CHECK chk_training_units_blueprint_vs_scheduled (Blueprint vs. Kalender‑Zeile)
|
||||
-- training_framework_slot_exercises → entfallen (037)
|
||||
```
|
||||
|
||||
**Löschkaskaden:** löschen eines **Rahmens** ⇒ Ziele + Slots ⇒ Slot‑Übungen (alles **`ON DELETE CASCADE`** vom Rahmen über Slots). löschen eines **`training_unit`** ⇒ FK am Slot **`SET NULL`**. löschen einer **Übung** ⇒ Zeilen in **`training_framework_slot_exercises`** entfallen (`CASCADE`).
|
||||
|
||||
**Deferred (nicht im MVP):** `training_plan_templates.id` FK je Slot (**CURR‑010** Optional).
|
||||
**Löschkaskaden:** Rahmen löschen ⇒ Ziele + Slots; **Slot löschen** ⇒ zugehörige Blueprint‑`training_unit` per **`ON DELETE CASCADE`** auf `framework_slot_id`.
|
||||
|
||||
### 2.3 REST‑Überblick (`router` `training_framework_programs`)
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---------|------|--------|
|
||||
| GET | `/training-framework-programs` | Liste; Admin/Superadmin alle, sonst eigene (`created_by`); Aggregation `goals_count`, `slots_count` |
|
||||
| GET | `/training-framework-programs/{id}` | Detail inkl. `goals[]`, `slots[]` mit jeweils `exercises[]` (inkl. Titel‑Joins) |
|
||||
| POST | `/training-framework-programs` | Neu; **Pflicht:** `title`, **`plan_mode`**, **`goals`** (≥ 1 Eintrag mit `title`); optional `slots` |
|
||||
| PUT | `/training-framework-programs/{id}` | Header‑Felder; optional volles Ersetzen von **`goals`** und/oder **`slots`** wie bei Vorlagen‑Sektionen |
|
||||
| DELETE | `/training-framework-programs/{id}` | Rahmen löschen (**CASCADE** Kinder) |
|
||||
| GET | `/training-framework-programs` | Liste; Admin/Superadmin alle, sonst eigene (`created_by`); u. a. `goals_count`, `slots_count`, Kontext‑Counts |
|
||||
| GET | `/training-framework-programs/{id}` | Detail inkl. `goals[]`, `slots[]` mit je **`blueprint_training_unit_id`**, **`sections[]`**, **`exercises[]`** (letzteres aus Sektionen geflacht, kompatibel zum Editor) |
|
||||
| POST | `/training-framework-programs` | Neu; **Pflicht:** `title`, **`goals`** (≥ 1); optional `slots` (weiterhin **`exercises[]`** pro Slot möglich — Backend materialisiert Sektionen); Header: `focus_area_id`, `style_direction_id`, `training_type_ids`, `target_group_ids`, … |
|
||||
| PUT | `/training-framework-programs/{id}` | Header; volles Ersetzen von **`goals`** und/oder **`slots`** (neue Slots ⇒ neue Blueprint‑Units) |
|
||||
| DELETE | `/training-framework-programs/{id}` | Rahmen + Kinder |
|
||||
|
||||
**AuthZ:** Schreibzugriff nur mit **`_has_planning_role`** (wie `training_plan_templates`); Lesen/Ändern/Löschen: **Admin/Superadmin** oder **Ersteller** (`created_by`).
|
||||
**AuthZ:** wie zuvor — Planungsrolle zum Schreiben; Lesen/Schreiben/Löschen des Rahmens: Admin/Superadmin oder Ersteller.
|
||||
|
||||
**Payload‑Hinweise (JSON):**
|
||||
**Payload‑Hinweise (JSON), Slots:**
|
||||
|
||||
- `goals`: `[{ sort_order?, title, notes? }, …]` — **`sort_order`** default Reihenfolge im Array
|
||||
- `slots`: `[{ sort_order?, title?, notes?, training_unit_id?, exercises: [{ exercise_id, exercise_variant_id?, order_index? }] }, …]`
|
||||
- Bei **`library`:** **`training_unit_id`** an Slots → **400**; nach Wechsel auf **`library`** werden bestehende Slot-Verknüpfungen zu **`training_units`** geleert.
|
||||
- Weiterhin: `exercises: [{ exercise_id, exercise_variant_id?, order_index? }]` (wird intern in eine Sektion übernommen).
|
||||
- Erweitert möglich: `sections` wie bei **`PUT /api/training-units/{id}`** (voller Ablauf mit Notizen/Items).
|
||||
|
||||
**Minimal‑UI:** im Lieferumfang dieser Iteration nicht enthalten (**OpenAPI `/docs`** / Postman); siehe Funktionskonzept §6.
|
||||
### 2.4 Planung / Kalender (`router` `training_planning.py`, Auszug)
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---------|------|--------|
|
||||
| GET | `/training-units` | Nur **Kalender‑Einheiten** (`framework_slot_id IS NULL`) |
|
||||
| GET/PUT | `/training-units/{id}` | Blueprint lesen/bearbeiten: möglich mit Rahmen‑Auth; spezielle Regeln im **PUT** (kein Template‑Reset, kein `plan_template_id` am Blueprint) |
|
||||
| DELETE | `/training-units/{id}` | Blueprint **nicht** über diesen Pfad löschen (Fehlerhinweis); Slot entfernen über Rahmen‑**PUT** |
|
||||
| POST | `/training-units/from-framework-slot` | Body: `framework_slot_id`, `group_id`, `planned_date` — tiefe Kopie + **`origin_framework_slot_id`** |
|
||||
|
||||
**Frontend:** `createTrainingUnitFromFrameworkSlot` in `frontend/src/utils/api.js`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -151,7 +158,7 @@ Listenqueries liefern Join‑Felder **`from_exercise_title`**, **`to_exercise_ti
|
|||
|
||||
## 4. Zwischenstand für Produkt / Trainingsplanung (bewusste Grenzen)
|
||||
|
||||
**Freigabe:** Der beschriebene Stand gilt als **ausreichend**, um mit dem **Trainingsplanungsmodul** und später Rahmen/Slots (**CURR‑002 (2)**) weiterzuarbeiten — ohne dass Pflicht zur Pflege komplexer Graph‑Strukturen entsteht (**CURR‑013**).
|
||||
**Freigabe:** Der beschriebene Stand unterstützt **Rahmen‑Bibliothek mit vollem Ablauf pro Slot** (wie Planung) und **Kopie in die Gruppenplanung**; der **Progressionsgraph** bleibt **unterstützend** (**CURR‑013**). Offen: Kalender‑UI‑Flow, Bulk‑Instanziierung, erweiterte Lineage/Feedback (**Konzept Schritt E**).
|
||||
|
||||
**Was gut nutzbar ist**
|
||||
|
||||
|
|
@ -179,7 +186,8 @@ Details weiterhin Diskussionsgrundlage in `TRAINING_CURRICULUM_AND_GOVERNANCE_CO
|
|||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-05-05 | **CURR‑002 (2):** §2 Rahmenprogramm — Entscheid **eine Tabelle + `plan_mode`**, DDL‑Skizze, REST‑Überblick; Migration **035**; `training_plan_templates.visibility`. |
|
||||
| 2026-05-05 | **037 / API:** Nur Bibliothek + **Blueprint** pro Slot über `training_units` + Sektionen/Items; `training_framework_slot_exercises` entfernt; `POST …/training-units/from-framework-slot`; Planungsliste ohne Blueprints. **036** dokumentiert am Kopf (Kontext, M:N, kein plan_mode/group_id). **§2** vollständig ersetzt. |
|
||||
| 2026-05-05 | **CURR‑002 (2):** §2 Rahmenprogramm — Entscheid **eine Tabelle + `plan_mode`**, DDL‑Skizze, REST‑Überblick; Migration **035**; `training_plan_templates.visibility`. *(Historisch — Modus-Spalten durch **036** ersetzt.)* |
|
||||
| 2026-04-30 | **Zwischen-Doku:** §3 auf Migrationen 032–034 + API **sequence/delete-batch** + Frontend erweitert; **§4** Produktfreigabe vs. Lücken (parallele Alternativen); Changelog §5. |
|
||||
| 2026-04-30 | §3: erste Fassung Migration 032 + REST‑Basis (CURR‑002 (1)). |
|
||||
| 2026-04-28 | Erstanlage Stub mit Checkliste. |
|
||||
|
|
|
|||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -78,12 +78,12 @@ frontend/src/
|
|||
|
||||
**Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`.
|
||||
|
||||
Kurz (Stand 2026-04-27): App **0.7.9**, DB-Schema-Version **20260427030**; Kern-Features: Übungen mit Varianten, Medien, Trainingsplanung mit optionaler Variantenwahl.
|
||||
Kurz (Stand 2026-05-05): App **0.8.10**, DB‑Schema‑Version **`20260505037`**; Kern: Übungen, Varianten, Medien, Planung mit Sektionen, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md` und `TRAINING_FRAMEWORK_SPEC.md` §2.
|
||||
|
||||
### Log (Auszug)
|
||||
|
||||
- 2026-05-05: Rahmen nur Bibliothek (**036**), Slot‑Ablauf = `training_units` + Sektionen (**037**), `POST /api/training-units/from-framework-slot`, keine `training_framework_slot_exercises` mehr — siehe `DATABASE_SCHEMA.md` / `FEATURES_DELIVERED_2026-Q2.md`.
|
||||
- 2026-04-27: Übungsvarianten API/UI, Migration 030, Listen-UX-Suche, Admin-Upload-Limits — siehe `PROJECT_STATUS.md` und `docs/library/FEATURES_DELIVERED_2026-Q2.md`.
|
||||
- 2026-04-21: Repository- und Initial-Setup (Historie; Details in Git).
|
||||
|
||||
## Domänenmodell (MVP Core)
|
||||
|
||||
|
|
@ -104,12 +104,11 @@ Kurz (Stand 2026-04-27): App **0.7.9**, DB-Schema-Version **20260427030**; Kern-
|
|||
- `exercise_skills` - M:N Übung ↔ Fähigkeit
|
||||
- `exercise_media` - Medien (Bilder, Videos)
|
||||
|
||||
**Trainingsplanung:**
|
||||
- `training_templates` - Vorlagen / Standards
|
||||
- `training_sections` - Trainingsabschnitte
|
||||
- `section_exercises` - Übungen in Abschnitten
|
||||
- `training_units` - Konkrete Trainingseinheiten
|
||||
- `training_programs` - Trainingsprogramme
|
||||
**Trainingsplanung / Rahmen:**
|
||||
- `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**)
|
||||
- `training_units` — Kalender‑Instanzen **und** Rahmen‑Slot‑Blueprints (`framework_slot_id` ab **037**)
|
||||
- `training_framework_programs` + Ziele + Slots (Migration **035–036**) — Bibliotheks‑Rahmen
|
||||
- Legacy: `training_templates` / `section_exercises` o. ä. — in älteren Skizzen; produktiver Pfad siehe Migrationen **006**/**031**
|
||||
|
||||
**Governance:**
|
||||
- `content_change_requests` - Änderungsanfragen
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-04-28
|
||||
**Stand:** 2026-05-05
|
||||
**App-Version / DB-Schema:** siehe `backend/version.py`
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand** und **nächste Baustellen**.
|
||||
|
|
@ -25,6 +25,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
| Übungen: API, DB, Architektur, Routing | `.claude/docs/technical/EXERCISES_API_SPEC.md`, `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_FRONTEND_ROUTING.md` |
|
||||
| Media / Upload | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` |
|
||||
| MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
|
||||
| Rahmenprogramm · Planung (`training_units` Blueprints), Progressionsgraph | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md`; Überblick DB → `.claude/docs/technical/DATABASE_SCHEMA.md`; Domäne → `.claude/docs/functional/DOMAIN_MODEL.md` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -64,7 +65,16 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
|
||||
---
|
||||
|
||||
## 3. Stand: Übungen (Lücke für nächste Session)
|
||||
## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz)
|
||||
|
||||
- **Migration 036:** Rahmenkopf nur Bibliothek (Kontext: Fokusbereich, Stilrichtung; M:N Trainingsarten/Zielgruppen); keine `plan_mode`/keine Kopf‑`group_id`.
|
||||
- **Migration 037:** Pro Rahmen‑Slot eine **`training_units`‑Zeile mit `framework_slot_id`**; strukturierter Ablauf wie echte Einheiten (`training_unit_sections` / `training_unit_section_items`). Tabelle **`training_framework_slot_exercises`** entfällt.
|
||||
- **API:** Rahmen unter **`/api/training-framework-programs`** (Slots liefern u. a. **`blueprint_training_unit_id`**, **`sections[]`**, **`exercises[]`**); Kalenderliste **`GET /api/training-units`** ohne Blueprints; Übernahme **`POST /api/training-units/from-framework-slot`**.
|
||||
- **Code:** `backend/routers/training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**; **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Stand: Übungen (Lücke für nächste Session)
|
||||
|
||||
**Ist (laut Projektdoku und aktuellem Produktziel):**
|
||||
|
||||
|
|
@ -79,18 +89,18 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
|
||||
---
|
||||
|
||||
## 4. Technische Referenz (kurz)
|
||||
## 5. Technische Referenz (kurz)
|
||||
|
||||
| Bereich | Einstieg |
|
||||
|---------|----------|
|
||||
| Backend API | `backend/main.py`, `backend/routers/maturity_models.py`, `matrix_stack_bundle.py`, `exercises.py`, `catalogs.py`, `skills.py` |
|
||||
| Migrationen | `backend/migrations/` (u. a. 024–027 Reifegrad/Bindings) |
|
||||
| Backend API | `backend/main.py`; Router u. a. **`training_framework_programs.py`**, **`training_planning.py`**, `maturity_models.py`, `matrix_stack_bundle.py`, `exercises.py`, `catalogs.py`, `skills.py` |
|
||||
| Migrationen | `backend/migrations/` (u. a. 024–027 Reifegrad/Bindings; **035–037** Rahmenprogramm / Slot‑Blueprint) |
|
||||
| Frontend API | `frontend/src/utils/api.js` |
|
||||
| Version / Changelog | `backend/version.py` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Veraltete Hinweise
|
||||
## 6. Veraltete Hinweise
|
||||
|
||||
Die Datei `.claude/docs/working/HANDOVER_NEXT_SESSION.md` (2026-04-22) ist **historisch**; für den aktuellen Stand gilt **`docs/HANDOVER.md`**.
|
||||
|
||||
|
|
|
|||
442
frontend/src/components/TrainingUnitSectionsEditor.jsx
Normal file
442
frontend/src/components/TrainingUnitSectionsEditor.jsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import {
|
||||
defaultSection,
|
||||
exerciseRow,
|
||||
noteRow,
|
||||
sectionPlannedMinutes,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
|
||||
/**
|
||||
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
|
||||
*/
|
||||
export default function TrainingUnitSectionsEditor({
|
||||
sections,
|
||||
onSectionsChange,
|
||||
onRequestExercisePick,
|
||||
onPeekExercise,
|
||||
showExecutionExtras = false,
|
||||
heading = 'Abschnitte & Übungen',
|
||||
hideHeading = false,
|
||||
}) {
|
||||
const ensure = (prev) =>
|
||||
prev && prev.length ? prev : [defaultSection()]
|
||||
|
||||
const patch = useCallback(
|
||||
(updater) => {
|
||||
onSectionsChange((prev) => updater(ensure(prev)))
|
||||
},
|
||||
[onSectionsChange]
|
||||
)
|
||||
|
||||
const updateSectionField = (sIdx, field, val) => {
|
||||
patch((prev) =>
|
||||
prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s))
|
||||
)
|
||||
}
|
||||
|
||||
const addSection = () => {
|
||||
patch((prev) => [...prev, defaultSection(`Abschnitt ${prev.length + 1}`)])
|
||||
}
|
||||
|
||||
const removeSection = (sIdx) => {
|
||||
patch((prev) => {
|
||||
const next = prev.filter((_, i) => i !== sIdx)
|
||||
return next.length ? next : [defaultSection()]
|
||||
})
|
||||
}
|
||||
|
||||
const moveSection = (sIdx, dir) => {
|
||||
patch((prev) => {
|
||||
const p = [...prev]
|
||||
const ta = sIdx + dir
|
||||
if (ta < 0 || ta >= p.length) return p
|
||||
;[p[sIdx], p[ta]] = [p[ta], p[sIdx]]
|
||||
return p
|
||||
})
|
||||
}
|
||||
|
||||
const addItem = (sIdx, kind) => {
|
||||
patch((prev) =>
|
||||
prev.map((s, i) =>
|
||||
i !== sIdx
|
||||
? s
|
||||
: {
|
||||
...s,
|
||||
items: [...(s.items || []), kind === 'note' ? noteRow() : exerciseRow()],
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const removeItem = (sIdx, iIdx) => {
|
||||
patch((prev) =>
|
||||
prev.map((s, si) =>
|
||||
si !== sIdx ? s : { ...s, items: (s.items || []).filter((_, ii) => ii !== iIdx) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const moveItem = (sIdx, iIdx, dir) => {
|
||||
patch((prev) =>
|
||||
prev.map((s, si) => {
|
||||
if (si !== sIdx) return s
|
||||
const items = [...(s.items || [])]
|
||||
const ta = iIdx + dir
|
||||
if (ta < 0 || ta >= items.length) return s
|
||||
;[items[iIdx], items[ta]] = [items[ta], items[iIdx]]
|
||||
return { ...s, items }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const updateItem = (sIdx, iIdx, field, val) => {
|
||||
patch((prev) =>
|
||||
prev.map((s, si) =>
|
||||
si !== sIdx
|
||||
? s
|
||||
: {
|
||||
...s,
|
||||
items: (s.items || []).map((row, ii) =>
|
||||
ii === iIdx ? { ...row, [field]: val } : row
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const list = ensure(sections)
|
||||
|
||||
return (
|
||||
<div className="training-unit-sections-editor">
|
||||
{!hideHeading ? (
|
||||
<h3 style={{ margin: '0 0 0.75rem', fontSize: '1rem' }}>{heading}</h3>
|
||||
) : null}
|
||||
{list.map((sec, sIdx) => {
|
||||
const planMin = sectionPlannedMinutes(sec)
|
||||
return (
|
||||
<div
|
||||
key={`sec-${sIdx}`}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
padding: '0.75rem',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ flex: '2 1 180px', marginBottom: 0 }}
|
||||
value={sec.title}
|
||||
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
|
||||
placeholder="Abschnittstitel (z. B. Aufwärmen)"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abschnitt hoch"
|
||||
onClick={() => moveSection(sIdx, -1)}
|
||||
disabled={sIdx === 0}
|
||||
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abschnitt runter"
|
||||
onClick={() => moveSection(sIdx, 1)}
|
||||
disabled={sIdx === list.length - 1}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
opacity: sIdx === list.length - 1 ? 0.35 : 1,
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() => removeSection(sIdx)}
|
||||
>
|
||||
Abschnitt entfernen
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={sec.guidance_notes}
|
||||
onChange={(e) => updateSectionField(sIdx, 'guidance_notes', e.target.value)}
|
||||
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||||
/>
|
||||
{planMin > 0 && (
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(sec.items || []).map((it, iIdx) =>
|
||||
it.item_type === 'note' ? (
|
||||
<div key={`note-${sIdx}-${iIdx}`} style={{ marginTop: '0.65rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text2)', marginBottom: '4px' }}>
|
||||
Zwischen-Anmerkung
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '4px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||||
disabled={iIdx === 0}
|
||||
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||||
disabled={iIdx === sec.items.length - 1}
|
||||
style={{
|
||||
padding: '2px',
|
||||
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1,
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
style={{ flex: 1 }}
|
||||
value={it.note_body}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'note_body', e.target.value)}
|
||||
placeholder="Hinweise zwischen Übungen …"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={{ background: 'var(--danger)', color: 'white', border: 'none' }}
|
||||
onClick={() => removeItem(sIdx, iIdx)}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={`ex-${sIdx}-${iIdx}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '28px minmax(0, 1fr) minmax(0, 64px) 36px',
|
||||
gap: '6px',
|
||||
alignItems: 'start',
|
||||
marginTop: '0.65rem',
|
||||
paddingTop: '0.45rem',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||||
disabled={iIdx === 0}
|
||||
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||||
disabled={iIdx === sec.items.length - 1}
|
||||
style={{
|
||||
padding: '2px',
|
||||
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1,
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
style={{ margin: 0, whiteSpace: 'nowrap' }}
|
||||
onClick={() =>
|
||||
onRequestExercisePick?.({ sectionIndex: sIdx, itemIndex: iIdx })
|
||||
}
|
||||
>
|
||||
Übung suchen…
|
||||
</button>
|
||||
{it.exercise_id && onPeekExercise ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
style={{ margin: 0 }}
|
||||
onClick={() => onPeekExercise(Number(it.exercise_id))}
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
) : null}
|
||||
{(it.exercise_title || it.exercise_id) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.82rem',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{it.exercise_title ||
|
||||
(it.exercise_id ? `Übung #${it.exercise_id}` : '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||||
return (
|
||||
<select
|
||||
className="form-input"
|
||||
value={
|
||||
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
||||
? ''
|
||||
: String(it.exercise_variant_id)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
updateItem(
|
||||
sIdx,
|
||||
iIdx,
|
||||
'exercise_variant_id',
|
||||
raw === '' ? '' : parseInt(raw, 10)
|
||||
)
|
||||
}}
|
||||
disabled={!it.exercise_id || variantOpts.length === 0}
|
||||
style={{ margin: 0, fontSize: '0.82rem' }}
|
||||
>
|
||||
<option value="">
|
||||
{variantOpts.length === 0 ? 'Keine Varianten' : 'Stammübung'}
|
||||
</option>
|
||||
{variantOpts.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.variant_name || `Variante #${v.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
})()}
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={it.notes || ''}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'notes', e.target.value)}
|
||||
placeholder="Kurze Anmerkung zur Übung"
|
||||
style={{ fontSize: '0.82rem' }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={1}
|
||||
value={it.planned_duration_min}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
||||
}
|
||||
placeholder="min"
|
||||
title="Geplante Dauer (Minuten)"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
padding: '0.45rem',
|
||||
background: 'var(--danger)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
}}
|
||||
onClick={() => removeItem(sIdx, iIdx)}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
{showExecutionExtras ? (
|
||||
<label
|
||||
className="form-label"
|
||||
style={{
|
||||
gridColumn: '2 / -1',
|
||||
marginTop: 4,
|
||||
display: 'block',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
Ist-Dauer / Anpassungen
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={1}
|
||||
value={it.actual_duration_min}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||||
}
|
||||
placeholder="IST min"
|
||||
style={{ maxWidth: '120px' }}
|
||||
/>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={it.modifications || ''}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||||
}
|
||||
placeholder="Abweichungen beim Durchführen"
|
||||
style={{ marginTop: '6px', fontSize: '0.82rem' }}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '0.65rem', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() => addItem(sIdx, 'exercise')}
|
||||
>
|
||||
+ Übung
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() => addItem(sIdx, 'note')}
|
||||
>
|
||||
+ Anmerkung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={addSection}
|
||||
>
|
||||
+ Abschnitt hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,13 +3,19 @@ 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 {
|
||||
defaultSection,
|
||||
normalizeUnitToForm,
|
||||
enrichSectionsWithVariants,
|
||||
buildSectionsPayload,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
|
||||
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
||||
|
||||
/** Unter dieser Breite: 2 Tabs (Stammdaten | Plan); darüber: alles untereinander */
|
||||
const FRAMEWORK_DESKTOP_MIN_PX = 900
|
||||
|
||||
const DND_FW_EX = 'application/x-shinkan-framework-exercise'
|
||||
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
||||
|
||||
function reorderArray(arr, from, to) {
|
||||
if (from === to || from < 0 || from >= arr.length) return [...arr]
|
||||
const next = [...arr]
|
||||
|
|
@ -23,12 +29,21 @@ function emptyGoal() {
|
|||
return { title: '', notes: '' }
|
||||
}
|
||||
|
||||
function emptyExercise() {
|
||||
return { exercise_id: '', exercise_variant_id: '', exercise_title: '', variants: [] }
|
||||
}
|
||||
|
||||
function emptySlot() {
|
||||
return { title: '', notes: '', exercises: [] }
|
||||
return { title: '', notes: '', sections: [defaultSection('Ablauf')] }
|
||||
}
|
||||
|
||||
async function enrichFrameworkSlotSections(slots) {
|
||||
const out = []
|
||||
for (const s of slots || []) {
|
||||
const sec = normalizeUnitToForm({ sections: s.sections, exercises: s.exercises })
|
||||
out.push({
|
||||
...s,
|
||||
sections: await enrichSectionsWithVariants(sec),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Native-Tooltip für Ziel-Chips (Hover); kurz halten für OS-Tooltip-Limits */
|
||||
|
|
@ -81,52 +96,11 @@ function serverFrameworkToForm(fw) {
|
|||
slots: (fw.slots || []).map((s) => ({
|
||||
title: s.title || '',
|
||||
notes: s.notes || '',
|
||||
exercises: (s.exercises || []).map((ex) => ({
|
||||
exercise_id: ex.exercise_id,
|
||||
exercise_variant_id: ex.exercise_variant_id != null ? String(ex.exercise_variant_id) : '',
|
||||
exercise_title: ex.exercise_title || '',
|
||||
variants: [],
|
||||
})),
|
||||
sections: normalizeUnitToForm({ sections: s.sections, exercises: s.exercises }),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichSlotExercisesWithVariants(formSlots) {
|
||||
const ids = new Set()
|
||||
for (const s of formSlots || []) {
|
||||
for (const it of s.exercises || []) {
|
||||
if (it.exercise_id) ids.add(Number(it.exercise_id))
|
||||
}
|
||||
}
|
||||
const cache = new Map()
|
||||
await Promise.all(
|
||||
[...ids].map(async (id) => {
|
||||
try {
|
||||
const ex = await api.getExercise(id)
|
||||
cache.set(id, {
|
||||
title: ex.title || '',
|
||||
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
||||
})
|
||||
} catch {
|
||||
cache.set(id, { title: '', variants: [] })
|
||||
}
|
||||
})
|
||||
)
|
||||
return (formSlots || []).map((s) => ({
|
||||
...s,
|
||||
exercises: (s.exercises || []).map((it) => {
|
||||
if (!it.exercise_id) return it
|
||||
const c = cache.get(Number(it.exercise_id))
|
||||
if (!c) return it
|
||||
return {
|
||||
...it,
|
||||
exercise_title: it.exercise_title || c.title,
|
||||
variants: it.variants?.length ? it.variants : c.variants,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildApiPayload(form) {
|
||||
const goals = (form.goals || [])
|
||||
.map((g, i) => ({
|
||||
|
|
@ -140,23 +114,13 @@ function buildApiPayload(form) {
|
|||
}
|
||||
|
||||
const slots = (form.slots || []).map((s, si) => {
|
||||
const exercises = (s.exercises || [])
|
||||
.map((ex, j) => {
|
||||
if (!ex.exercise_id) return null
|
||||
const vid = ex.exercise_variant_id
|
||||
return {
|
||||
exercise_id: parseInt(ex.exercise_id, 10),
|
||||
exercise_variant_id:
|
||||
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
|
||||
order_index: j,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
const secList = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')]
|
||||
const sectionsPayload = buildSectionsPayload(secList)
|
||||
return {
|
||||
sort_order: si,
|
||||
title: (s.title || '').trim() || null,
|
||||
notes: (s.notes || '').trim() || null,
|
||||
exercises,
|
||||
sections: sectionsPayload,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -212,11 +176,10 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
const [styleDirections, setStyleDirections] = useState([])
|
||||
const [trainingTypesCatalog, setTrainingTypesCatalog] = useState([])
|
||||
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
|
||||
const [pickerSlotIdx, setPickerSlotIdx] = useState(null)
|
||||
const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
|
||||
const [peekId, setPeekId] = useState(null)
|
||||
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
|
||||
const [goalMenuGi, setGoalMenuGi] = useState(null)
|
||||
const [exerciseMenu, setExerciseMenu] = useState(null)
|
||||
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
|
||||
const [frameworkTab, setFrameworkTab] = useState('meta')
|
||||
const [desktopLayout, setDesktopLayout] = useState(
|
||||
|
|
@ -237,7 +200,6 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
const onPointerDown = (e) => {
|
||||
const t = e.target
|
||||
if (t.closest?.('.framework-popmenu-anchor')) return
|
||||
setExerciseMenu(null)
|
||||
setGoalMenuGi(null)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
|
|
@ -289,7 +251,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
const fw = await api.getTrainingFrameworkProgram(fid)
|
||||
if (cancelled) return
|
||||
let next = serverFrameworkToForm(fw)
|
||||
next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) }
|
||||
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
|
||||
setForm(next)
|
||||
} catch (e) {
|
||||
alert(e.message || 'Laden fehlgeschlagen')
|
||||
|
|
@ -360,85 +322,6 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
}))
|
||||
}
|
||||
|
||||
const addExerciseToSlot = (sIdx) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
slots: prev.slots.map((s, i) =>
|
||||
i === sIdx ? { ...s, exercises: [...(s.exercises || []), emptyExercise()] } : s
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const moveExercise = (sIdx, eIdx, dir) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
slots: prev.slots.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
const j = eIdx + dir
|
||||
const ex = [...(s.exercises || [])]
|
||||
if (j < 0 || j >= ex.length) return s
|
||||
;[ex[eIdx], ex[j]] = [ex[j], ex[eIdx]]
|
||||
return { ...s, exercises: ex }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeExercise = (sIdx, eIdx) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
slots: prev.slots.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
return { ...s, exercises: (s.exercises || []).filter((_, ei) => ei !== eIdx) }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
const setExerciseChoice = async (sIdx, eIdx, exercise) => {
|
||||
const vid = ''
|
||||
let variants = Array.isArray(exercise.variants) ? exercise.variants : []
|
||||
let title = exercise.title || ''
|
||||
if (!variants.length) {
|
||||
try {
|
||||
const full = await api.getExercise(exercise.id)
|
||||
variants = Array.isArray(full.variants) ? full.variants : []
|
||||
title = full.title || title
|
||||
} catch {
|
||||
variants = []
|
||||
}
|
||||
}
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
slots: prev.slots.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
const exRows = [...(s.exercises || [])]
|
||||
const row = exRows[eIdx] || emptyExercise()
|
||||
exRows[eIdx] = {
|
||||
...row,
|
||||
exercise_id: exercise.id,
|
||||
exercise_variant_id: vid,
|
||||
exercise_title: title,
|
||||
variants,
|
||||
}
|
||||
return { ...s, exercises: exRows }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
const exerciseField = (sIdx, eIdx, key, val) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
slots: prev.slots.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
return {
|
||||
...s,
|
||||
exercises: (s.exercises || []).map((ex, ei) =>
|
||||
ei === eIdx ? { ...ex, [key]: val } : ex
|
||||
),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!(form.title || '').trim()) {
|
||||
alert('Titel ist Pflichtfeld.')
|
||||
|
|
@ -465,7 +348,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
await api.updateTrainingFrameworkProgram(fid, payload)
|
||||
const refreshed = await api.getTrainingFrameworkProgram(fid)
|
||||
let next = serverFrameworkToForm(refreshed)
|
||||
next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) }
|
||||
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
|
||||
setForm(next)
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -487,44 +370,6 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const insertExerciseBefore = (fromS, fromE, toS, toE) => {
|
||||
setForm((prev) => {
|
||||
const slots = prev.slots.map((s) => ({ ...s, exercises: [...(s.exercises || [])] }))
|
||||
if (fromS < 0 || fromS >= slots.length || toS < 0 || toS >= slots.length) return prev
|
||||
const fromList = slots[fromS].exercises
|
||||
if (fromE < 0 || fromE >= fromList.length) return prev
|
||||
const [moved] = fromList.splice(fromE, 1)
|
||||
let insertAt = toE
|
||||
if (fromS === toS && fromE < toE) insertAt -= 1
|
||||
insertAt = Math.max(0, Math.min(insertAt, slots[toS].exercises.length))
|
||||
slots[toS].exercises.splice(insertAt, 0, moved)
|
||||
return { ...prev, slots }
|
||||
})
|
||||
}
|
||||
|
||||
const appendExerciseToSlot = (fromS, fromE, toS) => {
|
||||
setForm((prev) => {
|
||||
const slots = prev.slots.map((s) => ({ ...s, exercises: [...(s.exercises || [])] }))
|
||||
if (fromS < 0 || fromS >= slots.length || toS < 0 || toS >= slots.length) return prev
|
||||
const fromList = slots[fromS].exercises
|
||||
if (fromE < 0 || fromE >= fromList.length) return prev
|
||||
const [moved] = fromList.splice(fromE, 1)
|
||||
slots[toS].exercises.push(moved)
|
||||
return { ...prev, slots }
|
||||
})
|
||||
}
|
||||
|
||||
const onExerciseDragStart = (e, fromS, fromE) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(DND_FW_EX, JSON.stringify({ fromS, fromE }))
|
||||
setExerciseMenu(null)
|
||||
}
|
||||
|
||||
const onExerciseDragOver = (e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
const onSlotDragStart = (e, slotIdx) => {
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
|
|
@ -539,17 +384,10 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
const onSlotColumnDrop = (e, targetSi) => {
|
||||
e.preventDefault()
|
||||
const slotRaw = e.dataTransfer.getData(DND_FW_SLOT)
|
||||
if (slotRaw) {
|
||||
const { slotIdx } = JSON.parse(slotRaw)
|
||||
if (slotIdx !== targetSi) {
|
||||
setForm((prev) => ({ ...prev, slots: reorderArray([...prev.slots], slotIdx, targetSi) }))
|
||||
}
|
||||
return
|
||||
}
|
||||
const exRaw = e.dataTransfer.getData(DND_FW_EX)
|
||||
if (exRaw) {
|
||||
const { fromS, fromE } = JSON.parse(exRaw)
|
||||
appendExerciseToSlot(fromS, fromE, targetSi)
|
||||
if (!slotRaw) return
|
||||
const { slotIdx } = JSON.parse(slotRaw)
|
||||
if (slotIdx !== targetSi) {
|
||||
setForm((prev) => ({ ...prev, slots: reorderArray([...prev.slots], slotIdx, targetSi) }))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -625,11 +463,9 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
|
||||
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
|
||||
Zielen und Session‑Slots. <strong>Zuordnung zu einer Trainingsgruppe</strong> oder zu{' '}
|
||||
<strong>konkreten Einheiten</strong> erfolgt aus der <strong>Gruppen‑Planung</strong> (Übernahme mit Link /
|
||||
Lineage) — nicht mehr direkt an diesem Datensatz. Pro Slot ist derzeit eine Übungsliste (Stückliste) hinterlegt;
|
||||
die strukturierte Einheitenplanung (Abschnitte wie in der Trainingsplanung) folgt über{' '}
|
||||
<strong>CURR‑010</strong> (Vorlagen‑Modell pro Slot).
|
||||
Zielen und Session‑Slots. <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
|
||||
<strong>Gruppen‑Planung</strong> („Übernahme“). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
|
||||
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>Zwischen‑Anmerkungen</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -987,7 +823,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
<div className="card framework-plan-slots" style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||
<h3 className="card-title" style={{ marginBottom: 0 }}>
|
||||
Session‑Slots & Übungen
|
||||
Session‑Slots & Ablauf
|
||||
</h3>
|
||||
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addSlot}>
|
||||
+ Slot
|
||||
|
|
@ -996,16 +832,15 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
|
||||
{form.slots.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||||
Noch keine Slots — mit <strong>+ Slot</strong> legst du Spalten an (z. B. „Woche 1“) und ordnest Übungen
|
||||
zu. Bei mehreren Spalten erscheint eine horizontale Scroll-Leiste.
|
||||
Noch keine Slots — mit <strong>+ Slot</strong> legst du Spalten an (z. B. „Woche 1“). In jedem Slot
|
||||
strukturierst du wie in der Trainingsplanung: Abschnitte, Übungen, Anmerkungen.
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className="framework-slots-hint"
|
||||
style={{ fontSize: '0.8rem', color: 'var(--text3)', marginBottom: '10px', lineHeight: 1.45 }}
|
||||
>
|
||||
Sessions nebeneinander — nach rechts scrollen bzw. wischen. Reihenfolge mit Griff (⋮⋮ / ☰)
|
||||
per Drag & Drop möglich (Browser-abhängig bei Touch); zusätzlich Pfeile und Menüs nutzbar.
|
||||
Reihenfolge der Spalten per Griff (⋮⋮) ziehen; Inhalt eines Slots wie in der Planung bearbeiten.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -1079,190 +914,35 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
</div>
|
||||
</details>
|
||||
|
||||
<div className="framework-slot-card__exercises">
|
||||
<div className="framework-slot-card__exercises-head">
|
||||
<span className="framework-slot-card__exercises-title">Übungen</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() => addExerciseToSlot(si)}
|
||||
>
|
||||
+ Übung
|
||||
</button>
|
||||
</div>
|
||||
{(slot.exercises || []).length === 0 ? (
|
||||
<p className="framework-slot-card__empty-hint">
|
||||
Noch keine Übung — <strong>+ Übung</strong> oder Übung mit dem Griff ☰ hierher ziehen.
|
||||
</p>
|
||||
) : null}
|
||||
{(slot.exercises || []).map((ex, ei) => (
|
||||
<div
|
||||
key={ei}
|
||||
className="framework-ex-row"
|
||||
onDragOver={onExerciseDragOver}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const raw = e.dataTransfer.getData(DND_FW_EX)
|
||||
if (!raw) return
|
||||
const { fromS, fromE } = JSON.parse(raw)
|
||||
if (fromS === si && fromE === ei) return
|
||||
insertExerciseBefore(fromS, fromE, si, ei)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="framework-ex-row__grip"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
onExerciseDragStart(e, si, ei)
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
☰
|
||||
</span>
|
||||
<div className="framework-ex-row__body">
|
||||
<div className="framework-ex-row__title-line">
|
||||
{ex.exercise_id ? (
|
||||
<strong className="framework-ex-row__title">
|
||||
{ex.exercise_title || `Übung #${ex.exercise_id}`}
|
||||
</strong>
|
||||
) : (
|
||||
<span className="framework-ex-row__title framework-ex-row__title--muted">
|
||||
Keine Übung gewählt
|
||||
</span>
|
||||
)}
|
||||
{ex.exercise_id ? (
|
||||
<span className="framework-ex-row__id">#{ex.exercise_id}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="framework-ex-row__row2">
|
||||
{ex.exercise_id && (ex.variants || []).length > 0 ? (
|
||||
<select
|
||||
className="form-input framework-ex-row__variant-select"
|
||||
value={ex.exercise_variant_id}
|
||||
onChange={(e) => exerciseField(si, ei, 'exercise_variant_id', e.target.value)}
|
||||
>
|
||||
<option value="">Variante</option>
|
||||
{(ex.variants || []).map((v) => (
|
||||
<option key={v.id} value={String(v.id)}>
|
||||
{v.variant_name || v.name || `V ${v.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="framework-ex-row__variant-spacer" aria-hidden />
|
||||
)}
|
||||
<div className="framework-popmenu-anchor framework-ex-row__menu-anchor">
|
||||
<button
|
||||
type="button"
|
||||
className="framework-ex-row__kebab"
|
||||
aria-label="Übung-Aktionen"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={
|
||||
exerciseMenu?.slotIdx === si && exerciseMenu?.exIdx === ei ? 'true' : 'false'
|
||||
<div className="framework-slot-card__plan-editor" style={{ marginTop: '0.65rem', minHeight: '120px' }}>
|
||||
<TrainingUnitSectionsEditor
|
||||
heading={`Ablauf · Session ${si + 1}`}
|
||||
sections={slot.sections}
|
||||
showExecutionExtras={false}
|
||||
onSectionsChange={(updater) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
slots: prev.slots.map((sl, ii) =>
|
||||
ii !== si
|
||||
? sl
|
||||
: {
|
||||
...sl,
|
||||
sections: updater(
|
||||
sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]
|
||||
),
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExerciseMenu((prev) =>
|
||||
prev?.slotIdx === si && prev?.exIdx === ei
|
||||
? null
|
||||
: { slotIdx: si, exIdx: ei }
|
||||
)
|
||||
}}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{exerciseMenu?.slotIdx === si && exerciseMenu?.exIdx === ei ? (
|
||||
<ul className="framework-popmenu framework-popmenu--align-end" role="menu">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="framework-popmenu__item"
|
||||
onClick={() => {
|
||||
setPickerSlotIdx({ slotIdx: si, exerciseIdx: ei })
|
||||
setExerciseMenu(null)
|
||||
}}
|
||||
>
|
||||
Übung wählen…
|
||||
</button>
|
||||
</li>
|
||||
{ex.exercise_id ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="framework-popmenu__item"
|
||||
onClick={() => {
|
||||
setPeekId(Number(ex.exercise_id))
|
||||
setExerciseMenu(null)
|
||||
}}
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="framework-popmenu__item"
|
||||
onClick={() => {
|
||||
moveExercise(si, ei, -1)
|
||||
setExerciseMenu(null)
|
||||
}}
|
||||
>
|
||||
Nach oben in diesem Slot
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="framework-popmenu__item"
|
||||
onClick={() => {
|
||||
moveExercise(si, ei, 1)
|
||||
setExerciseMenu(null)
|
||||
}}
|
||||
>
|
||||
Nach unten in diesem Slot
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="framework-popmenu__item framework-popmenu__item--danger"
|
||||
onClick={() => {
|
||||
removeExercise(si, ei)
|
||||
setExerciseMenu(null)
|
||||
}}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="framework-slot-card__append-drop"
|
||||
onDragOver={onExerciseDragOver}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const raw = e.dataTransfer.getData(DND_FW_EX)
|
||||
if (!raw) return
|
||||
const { fromS, fromE } = JSON.parse(raw)
|
||||
appendExerciseToSlot(fromS, fromE, si)
|
||||
),
|
||||
}))
|
||||
}}
|
||||
>
|
||||
Ans Ende ziehen
|
||||
</div>
|
||||
onRequestExercisePick={({ sectionIndex, itemIndex }) =>
|
||||
setSectionPickerCtx({
|
||||
slotIdx: si,
|
||||
sectionIndex,
|
||||
itemIndex,
|
||||
})
|
||||
}
|
||||
onPeekExercise={(id) => setPeekId(id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1288,12 +968,54 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
</div>
|
||||
|
||||
<ExercisePickerModal
|
||||
open={pickerSlotIdx != null}
|
||||
onClose={() => setPickerSlotIdx(null)}
|
||||
onSelectExercise={(exercise) => {
|
||||
if (!pickerSlotIdx) return
|
||||
setExerciseChoice(pickerSlotIdx.slotIdx, pickerSlotIdx.exerciseIdx, exercise)
|
||||
setPickerSlotIdx(null)
|
||||
open={sectionPickerCtx != null}
|
||||
onClose={() => setSectionPickerCtx(null)}
|
||||
onSelectExercise={async (exercise) => {
|
||||
if (!sectionPickerCtx) return
|
||||
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx
|
||||
let variants = Array.isArray(exercise.variants) ? exercise.variants : []
|
||||
let title = exercise.title || ''
|
||||
if (!variants.length) {
|
||||
try {
|
||||
const full = await api.getExercise(exercise.id)
|
||||
variants = Array.isArray(full.variants) ? full.variants : []
|
||||
title = full.title || title
|
||||
} catch {
|
||||
variants = []
|
||||
}
|
||||
}
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
slots: prev.slots.map((sl, ii) =>
|
||||
ii !== slotIdx
|
||||
? sl
|
||||
: {
|
||||
...sl,
|
||||
sections: (sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]).map(
|
||||
(sec, si) =>
|
||||
si !== sIdx
|
||||
? sec
|
||||
: {
|
||||
...sec,
|
||||
items: (sec.items || []).map((row, ji) =>
|
||||
ji !== iIdx
|
||||
? row
|
||||
: row.item_type !== 'exercise'
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
exercise_id: exercise.id,
|
||||
exercise_variant_id: '',
|
||||
exercise_title: title,
|
||||
variants,
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
}))
|
||||
setSectionPickerCtx(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,175 +4,13 @@ import api from '../utils/api'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
|
||||
function defaultSection(title = 'Hauptteil') {
|
||||
return { title, guidance_notes: '', items: [] }
|
||||
}
|
||||
|
||||
function exerciseRow() {
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
exercise_id: '',
|
||||
exercise_variant_id: '',
|
||||
exercise_title: '',
|
||||
variants: [],
|
||||
planned_duration_min: '',
|
||||
actual_duration_min: '',
|
||||
notes: '',
|
||||
modifications: ''
|
||||
}
|
||||
}
|
||||
|
||||
function noteRow() {
|
||||
return { item_type: 'note', note_body: '' }
|
||||
}
|
||||
|
||||
function normalizeUnitToForm(fullUnit) {
|
||||
if (fullUnit.sections && fullUnit.sections.length) {
|
||||
return fullUnit.sections.map((sec) => ({
|
||||
title: sec.title,
|
||||
guidance_notes: sec.guidance_notes || '',
|
||||
items: (sec.items || []).map((it) => {
|
||||
if (it.item_type === 'note') {
|
||||
return { item_type: 'note', note_body: it.note_body || '' }
|
||||
}
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
exercise_id: it.exercise_id,
|
||||
exercise_variant_id: it.exercise_variant_id ?? '',
|
||||
exercise_title: it.exercise_title || '',
|
||||
variants: [],
|
||||
planned_duration_min:
|
||||
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
||||
? String(it.planned_duration_min)
|
||||
: '',
|
||||
actual_duration_min:
|
||||
it.actual_duration_min !== null && it.actual_duration_min !== undefined
|
||||
? String(it.actual_duration_min)
|
||||
: '',
|
||||
notes: it.notes ?? '',
|
||||
modifications: it.modifications ?? ''
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
if (fullUnit.exercises && fullUnit.exercises.length) {
|
||||
return [
|
||||
{
|
||||
title: 'Übungen',
|
||||
guidance_notes: '',
|
||||
items: fullUnit.exercises.map((ex) => ({
|
||||
item_type: 'exercise',
|
||||
exercise_id: ex.exercise_id,
|
||||
exercise_variant_id: ex.exercise_variant_id ?? '',
|
||||
exercise_title: ex.exercise_title || '',
|
||||
variants: [],
|
||||
planned_duration_min:
|
||||
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
|
||||
? String(ex.planned_duration_min)
|
||||
: '',
|
||||
actual_duration_min:
|
||||
ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
|
||||
? String(ex.actual_duration_min)
|
||||
: '',
|
||||
notes: ex.notes ?? '',
|
||||
modifications: ex.modifications ?? ''
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
return [defaultSection()]
|
||||
}
|
||||
|
||||
/** Lädt Varianten/Titel nach, wenn Einheit vom Server ohne variants[] im Client-State ist. */
|
||||
async function enrichSectionsWithVariants(sections) {
|
||||
if (!sections?.length) return sections
|
||||
const ids = []
|
||||
for (const sec of sections) {
|
||||
for (const it of sec.items || []) {
|
||||
if (it.item_type === 'note') continue
|
||||
if (it.exercise_id) ids.push(it.exercise_id)
|
||||
}
|
||||
}
|
||||
const unique = [...new Set(ids)]
|
||||
const cache = new Map()
|
||||
await Promise.all(
|
||||
unique.map(async (id) => {
|
||||
try {
|
||||
const ex = await api.getExercise(id)
|
||||
cache.set(id, {
|
||||
title: ex.title || '',
|
||||
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
||||
})
|
||||
} catch {
|
||||
cache.set(id, { title: '', variants: [] })
|
||||
}
|
||||
})
|
||||
)
|
||||
return sections.map((sec) => ({
|
||||
...sec,
|
||||
items: (sec.items || []).map((it) => {
|
||||
if (it.item_type === 'note') return it
|
||||
if (!it.exercise_id) return it
|
||||
const c = cache.get(it.exercise_id)
|
||||
if (!c) return it
|
||||
return {
|
||||
...it,
|
||||
exercise_title: it.exercise_title || c.title,
|
||||
variants:
|
||||
Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
function parseMin(v) {
|
||||
if (v === '' || v === null || v === undefined) return null
|
||||
const n = parseInt(String(v), 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function buildSectionsPayload(sections) {
|
||||
return sections.map((sec, si) => ({
|
||||
order_index: si,
|
||||
title: (sec.title || '').trim() || 'Abschnitt',
|
||||
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
||||
items: (sec.items || [])
|
||||
.map((it, ii) => {
|
||||
if (it.item_type === 'note') {
|
||||
return {
|
||||
item_type: 'note',
|
||||
order_index: ii,
|
||||
note_body: it.note_body ?? ''
|
||||
}
|
||||
}
|
||||
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
|
||||
return null
|
||||
}
|
||||
const vid = it.exercise_variant_id
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
order_index: ii,
|
||||
exercise_id: parseInt(it.exercise_id, 10),
|
||||
exercise_variant_id:
|
||||
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
|
||||
planned_duration_min: parseMin(it.planned_duration_min),
|
||||
actual_duration_min: parseMin(it.actual_duration_min),
|
||||
notes: it.notes?.trim() ? it.notes.trim() : null,
|
||||
modifications: it.modifications?.trim() ? it.modifications.trim() : null
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}))
|
||||
}
|
||||
|
||||
function sectionPlannedMinutes(sec) {
|
||||
return (sec.items || []).reduce((sum, it) => {
|
||||
if (it.item_type !== 'exercise') return sum
|
||||
const m = parseMin(it.planned_duration_min)
|
||||
return sum + (m || 0)
|
||||
}, 0)
|
||||
}
|
||||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||
import {
|
||||
defaultSection,
|
||||
normalizeUnitToForm,
|
||||
enrichSectionsWithVariants,
|
||||
buildSectionsPayload,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
|
||||
function TrainingPlanningPage() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -435,93 +273,6 @@ function TrainingPlanningPage() {
|
|||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const updateSectionField = (sIdx, field, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sections: prev.sections.map((s, i) => (i === sIdx ? { ...s, [field]: value } : s))
|
||||
}))
|
||||
}
|
||||
|
||||
const addSection = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sections: [...prev.sections, defaultSection(`Abschnitt ${prev.sections.length + 1}`)]
|
||||
}))
|
||||
}
|
||||
|
||||
const removeSection = (sIdx) => {
|
||||
setFormData((prev) => {
|
||||
const next = prev.sections.filter((_, i) => i !== sIdx)
|
||||
return { ...prev, sections: next.length ? next : [defaultSection()] }
|
||||
})
|
||||
}
|
||||
|
||||
const moveSection = (sIdx, dir) => {
|
||||
setFormData((prev) => {
|
||||
const ta = sIdx + dir
|
||||
if (ta < 0 || ta >= prev.sections.length) return prev
|
||||
const copy = [...prev.sections]
|
||||
;[copy[sIdx], copy[ta]] = [copy[ta], copy[sIdx]]
|
||||
return { ...prev, sections: copy }
|
||||
})
|
||||
}
|
||||
|
||||
const addItem = (sIdx, kind) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sections: prev.sections.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
const row = kind === 'note' ? noteRow() : exerciseRow()
|
||||
return { ...s, items: [...s.items, row] }
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
const updateItem = (sIdx, iIdx, field, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sections: prev.sections.map((s, si) => {
|
||||
if (si !== sIdx) return s
|
||||
return {
|
||||
...s,
|
||||
items: s.items.map((it, ii) => {
|
||||
if (ii !== iIdx) return it
|
||||
const next = { ...it, [field]: value }
|
||||
if (field === 'exercise_id') {
|
||||
next.exercise_variant_id = ''
|
||||
next.exercise_title = ''
|
||||
next.variants = []
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
const removeItem = (sIdx, iIdx) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sections: prev.sections.map((s, i) =>
|
||||
i !== sIdx ? s : { ...s, items: s.items.filter((_, j) => j !== iIdx) }
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
const moveItem = (sIdx, iIdx, dir) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sections: prev.sections.map((s, si) => {
|
||||
if (si !== sIdx) return s
|
||||
const items = [...s.items]
|
||||
const ta = iIdx + dir
|
||||
if (ta < 0 || ta >= items.length) return s
|
||||
;[items[iIdx], items[ta]] = [items[ta], items[iIdx]]
|
||||
return { ...s, items }
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
|
||||
|
|
@ -958,316 +709,24 @@ function TrainingPlanningPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{formData.sections.map((sec, sIdx) => {
|
||||
const planMin = sectionPlannedMinutes(sec)
|
||||
return (
|
||||
<div
|
||||
key={`sec-${sIdx}`}
|
||||
style={{
|
||||
marginBottom: '1.25rem',
|
||||
padding: '1rem',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--border, rgba(0,0,0,0.08))'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ flex: '2 1 220px', marginBottom: 0 }}
|
||||
value={sec.title}
|
||||
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
|
||||
placeholder="Abschnittstitel (z. B. Aufwärmen)"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abschnitt hoch"
|
||||
onClick={() => moveSection(sIdx, -1)}
|
||||
disabled={sIdx === 0}
|
||||
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Abschnitt runter"
|
||||
onClick={() => moveSection(sIdx, 1)}
|
||||
disabled={sIdx === formData.sections.length - 1}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
opacity: sIdx === formData.sections.length - 1 ? 0.35 : 1
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn" onClick={() => removeSection(sIdx)}>
|
||||
Abschnitt entfernen
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={sec.guidance_notes}
|
||||
onChange={(e) => updateSectionField(sIdx, 'guidance_notes', e.target.value)}
|
||||
placeholder="Hinweise zum Abschnitt (Aufbau, Zielrichtung der Gruppe, Material …)"
|
||||
/>
|
||||
{planMin > 0 && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||||
</p>
|
||||
)}
|
||||
<TrainingUnitSectionsEditor
|
||||
hideHeading
|
||||
sections={formData.sections}
|
||||
onSectionsChange={(updater) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sections: updater(prev.sections),
|
||||
}))
|
||||
}
|
||||
onRequestExercisePick={({ sectionIndex, itemIndex }) => {
|
||||
setExercisePickerTarget({ sIdx: sectionIndex, iIdx: itemIndex })
|
||||
setExercisePickerOpen(true)
|
||||
}}
|
||||
onPeekExercise={(id) => setPlanningPeekExerciseId(id)}
|
||||
showExecutionExtras={!!editingUnit}
|
||||
/>
|
||||
|
||||
{(sec.items || []).map((it, iIdx) =>
|
||||
it.item_type === 'note' ? (
|
||||
<div key={`note-${sIdx}-${iIdx}`} style={{ marginTop: '0.75rem' }}>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text2)', marginBottom: '4px' }}>
|
||||
Zwischen-Anmerkung
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '4px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||||
disabled={iIdx === 0}
|
||||
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||||
disabled={iIdx === sec.items.length - 1}
|
||||
style={{
|
||||
padding: '2px',
|
||||
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
style={{ flex: 1 }}
|
||||
value={it.note_body}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'note_body', e.target.value)}
|
||||
placeholder="Hinweise (Gruppen teilen, Hallenführung, Auf- und Abbau …)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={{ background: 'var(--danger)', color: 'white', border: 'none' }}
|
||||
onClick={() => removeItem(sIdx, iIdx)}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={`ex-${sIdx}-${iIdx}`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '32px minmax(0, 1fr) minmax(0, 72px) 40px',
|
||||
gap: '6px',
|
||||
alignItems: 'start',
|
||||
marginTop: '0.75rem',
|
||||
paddingTop: '0.5rem',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||||
disabled={iIdx === 0}
|
||||
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||||
disabled={iIdx === sec.items.length - 1}
|
||||
style={{
|
||||
padding: '2px',
|
||||
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ margin: 0, whiteSpace: 'nowrap' }}
|
||||
onClick={() => {
|
||||
setExercisePickerTarget({ sIdx, iIdx })
|
||||
setExercisePickerOpen(true)
|
||||
}}
|
||||
>
|
||||
Übung suchen…
|
||||
</button>
|
||||
{it.exercise_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ margin: 0, whiteSpace: 'nowrap', fontSize: '0.8rem', padding: '6px 10px' }}
|
||||
onClick={() => setPlanningPeekExerciseId(it.exercise_id)}
|
||||
>
|
||||
Katalog kurz zeigen
|
||||
</button>
|
||||
) : null}
|
||||
{(it.exercise_title || it.exercise_id) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
title={
|
||||
it.exercise_title ||
|
||||
(it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
||||
}
|
||||
>
|
||||
{it.exercise_title ||
|
||||
(it.exercise_id ? `Übung #${it.exercise_id}` : '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||||
return (
|
||||
<select
|
||||
className="form-input"
|
||||
value={
|
||||
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
||||
? ''
|
||||
: String(it.exercise_variant_id)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
updateItem(
|
||||
sIdx,
|
||||
iIdx,
|
||||
'exercise_variant_id',
|
||||
raw === '' ? '' : parseInt(raw, 10)
|
||||
)
|
||||
}}
|
||||
disabled={!it.exercise_id || variantOpts.length === 0}
|
||||
style={{ margin: 0, fontSize: '0.875rem' }}
|
||||
>
|
||||
<option value="">
|
||||
{variantOpts.length === 0
|
||||
? 'Keine Varianten'
|
||||
: 'Stammübung'}
|
||||
</option>
|
||||
{variantOpts.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.variant_name || `Variante #${v.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
})()}
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={editingUnit ? 2 : 1}
|
||||
value={it.notes || ''}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'notes', e.target.value)}
|
||||
placeholder="Kurze Anmerkung zur Übung"
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={1}
|
||||
value={it.planned_duration_min}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
||||
}
|
||||
placeholder="min"
|
||||
title="Geplante Dauer (Minuten)"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
background: 'var(--danger)',
|
||||
color: 'white',
|
||||
border: 'none'
|
||||
}}
|
||||
onClick={() => removeItem(sIdx, iIdx)}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
{editingUnit && (
|
||||
<label
|
||||
className="form-label"
|
||||
style={{
|
||||
gridColumn: '2 / -1',
|
||||
marginTop: 4,
|
||||
display: 'block',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
>
|
||||
Ist-Dauer / Anpassungen
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={1}
|
||||
value={it.actual_duration_min}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||||
}
|
||||
placeholder="IST min"
|
||||
style={{ maxWidth: '120px' }}
|
||||
/>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={it.modifications || ''}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||||
}
|
||||
placeholder="Abweichungen beim Durchführen"
|
||||
style={{ marginTop: '6px', fontSize: '0.875rem' }}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '0.85rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => addItem(sIdx, 'exercise')}>
|
||||
+ Übung
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => addItem(sIdx, 'note')}>
|
||||
+ Anmerkung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button type="button" className="btn btn-secondary" onClick={addSection} style={{ marginBottom: '1.75rem' }}>
|
||||
+ Abschnitt hinzufügen
|
||||
</button>
|
||||
<div style={{ marginBottom: '1.75rem' }} />
|
||||
|
||||
{editingUnit && (
|
||||
<>
|
||||
|
|
|
|||
169
frontend/src/utils/trainingUnitSectionsForm.js
Normal file
169
frontend/src/utils/trainingUnitSectionsForm.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import api from './api'
|
||||
|
||||
export function defaultSection(title = 'Hauptteil') {
|
||||
return { title, guidance_notes: '', items: [] }
|
||||
}
|
||||
|
||||
export function exerciseRow() {
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
exercise_id: '',
|
||||
exercise_variant_id: '',
|
||||
exercise_title: '',
|
||||
variants: [],
|
||||
planned_duration_min: '',
|
||||
actual_duration_min: '',
|
||||
notes: '',
|
||||
modifications: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function noteRow() {
|
||||
return { item_type: 'note', note_body: '' }
|
||||
}
|
||||
|
||||
export function normalizeUnitToForm(fullUnit) {
|
||||
if (fullUnit.sections && fullUnit.sections.length) {
|
||||
return fullUnit.sections.map((sec) => ({
|
||||
title: sec.title,
|
||||
guidance_notes: sec.guidance_notes || '',
|
||||
items: (sec.items || []).map((it) => {
|
||||
if (it.item_type === 'note') {
|
||||
return { item_type: 'note', note_body: it.note_body || '' }
|
||||
}
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
exercise_id: it.exercise_id,
|
||||
exercise_variant_id: it.exercise_variant_id ?? '',
|
||||
exercise_title: it.exercise_title || '',
|
||||
variants: [],
|
||||
planned_duration_min:
|
||||
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
||||
? String(it.planned_duration_min)
|
||||
: '',
|
||||
actual_duration_min:
|
||||
it.actual_duration_min !== null && it.actual_duration_min !== undefined
|
||||
? String(it.actual_duration_min)
|
||||
: '',
|
||||
notes: it.notes ?? '',
|
||||
modifications: it.modifications ?? '',
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
if (fullUnit.exercises && fullUnit.exercises.length) {
|
||||
return [
|
||||
{
|
||||
title: 'Übungen',
|
||||
guidance_notes: '',
|
||||
items: fullUnit.exercises.map((ex) => ({
|
||||
item_type: 'exercise',
|
||||
exercise_id: ex.exercise_id,
|
||||
exercise_variant_id: ex.exercise_variant_id ?? '',
|
||||
exercise_title: ex.exercise_title || '',
|
||||
variants: [],
|
||||
planned_duration_min:
|
||||
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
|
||||
? String(ex.planned_duration_min)
|
||||
: '',
|
||||
actual_duration_min:
|
||||
ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
|
||||
? String(ex.actual_duration_min)
|
||||
: '',
|
||||
notes: ex.notes ?? '',
|
||||
modifications: ex.modifications ?? '',
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
return [defaultSection()]
|
||||
}
|
||||
|
||||
export async function enrichSectionsWithVariants(sections) {
|
||||
if (!sections?.length) return sections
|
||||
const ids = []
|
||||
for (const sec of sections) {
|
||||
for (const it of sec.items || []) {
|
||||
if (it.item_type === 'note') continue
|
||||
if (it.exercise_id) ids.push(it.exercise_id)
|
||||
}
|
||||
}
|
||||
const unique = [...new Set(ids)]
|
||||
const cache = new Map()
|
||||
await Promise.all(
|
||||
unique.map(async (id) => {
|
||||
try {
|
||||
const ex = await api.getExercise(id)
|
||||
cache.set(id, {
|
||||
title: ex.title || '',
|
||||
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
||||
})
|
||||
} catch {
|
||||
cache.set(id, { title: '', variants: [] })
|
||||
}
|
||||
})
|
||||
)
|
||||
return sections.map((sec) => ({
|
||||
...sec,
|
||||
items: (sec.items || []).map((it) => {
|
||||
if (it.item_type === 'note') return it
|
||||
if (!it.exercise_id) return it
|
||||
const c = cache.get(it.exercise_id)
|
||||
if (!c) return it
|
||||
return {
|
||||
...it,
|
||||
exercise_title: it.exercise_title || c.title,
|
||||
variants:
|
||||
Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
export function parseMin(v) {
|
||||
if (v === '' || v === null || v === undefined) return null
|
||||
const n = parseInt(String(v), 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function buildSectionsPayload(sections) {
|
||||
return sections.map((sec, si) => ({
|
||||
order_index: si,
|
||||
title: (sec.title || '').trim() || 'Abschnitt',
|
||||
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
||||
items: (sec.items || [])
|
||||
.map((it, ii) => {
|
||||
if (it.item_type === 'note') {
|
||||
return {
|
||||
item_type: 'note',
|
||||
order_index: ii,
|
||||
note_body: it.note_body ?? '',
|
||||
}
|
||||
}
|
||||
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
|
||||
return null
|
||||
}
|
||||
const vid = it.exercise_variant_id
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
order_index: ii,
|
||||
exercise_id: parseInt(it.exercise_id, 10),
|
||||
exercise_variant_id:
|
||||
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
|
||||
planned_duration_min: parseMin(it.planned_duration_min),
|
||||
actual_duration_min: parseMin(it.actual_duration_min),
|
||||
notes: it.notes?.trim() ? it.notes.trim() : null,
|
||||
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
}))
|
||||
}
|
||||
|
||||
export function sectionPlannedMinutes(sec) {
|
||||
return (sec.items || []).reduce((sum, it) => {
|
||||
if (it.item_type !== 'exercise') return sum
|
||||
const m = parseMin(it.planned_duration_min)
|
||||
return sum + (m || 0)
|
||||
}, 0)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user