Merge pull request 'Trainingsplanung und Rahmenplanung' (#9) from develop into main
Reviewed-on: #9
This commit is contained in:
commit
a34dc19f5d
|
|
@ -1,31 +1,30 @@
|
|||
# Shinkan Jinkendo - Projekt-Status
|
||||
|
||||
**Stand:** 2026-04-27
|
||||
**Version (Code):** 0.7.9 (`backend/version.py`, APP_VERSION)
|
||||
**DB-Schema-Version:** `20260427030`
|
||||
**Stand:** 2026-05-05
|
||||
**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION)
|
||||
**DB-Schema-Version:** `20260505037`
|
||||
**Branch:** develop
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Aktueller Meilenstein:** Übungsvarianten Ende-zu-Ende (API, DB 030, Planung, UI) sowie Listen-Suche ohne Full-Page-Reload ✅
|
||||
**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 **030**: `training_unit_exercises.exercise_variant_id` (FK zu `exercise_variants`, ON DELETE SET NULL).
|
||||
- ✅ **GET `/api/exercises?include_variants=true`** für Trainingsplanung und Übersichten.
|
||||
- ✅ Varianten-**CRUD** + **Reorder**; Validierung in der Trainingsplanung (Variante gehört zur Übung).
|
||||
- ✅ **Übungsliste**: Filter-Chips, Modal, `listFetching` statt Full-Page-Spinner, `<datalist>`-Titel aus Treffern.
|
||||
- ✅ **Medien-Upload**: rollenbasierte Limits (Standard 50 MB, Admin bis 1024 MB, Env-Vars).
|
||||
- ✅ **RichTextEditor**: Selection-Restore, Listen-Styling im Editor.
|
||||
- ✅ 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)
|
||||
**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. Prod-Deployment der Migrationen **020–030** und Smoke-Tests.
|
||||
2. Optional: Server-Autocomplete für Suche; Progressions-Serien als Blöcke (siehe Feature-Doc).
|
||||
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 +41,8 @@
|
|||
| 023 | Skills Complete Import (69 Skills) | ✅ | 🔲 |
|
||||
| 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** | ✅ | 🔲 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
- [x] CRUD (Create, Read, Update, Delete)
|
||||
- [x] M:N Beziehungen (Focus Areas, Styles, Target Groups, Skills)
|
||||
- [x] **Varianten** (CRUD, Reorder, Voraussetzung) + Anzeige im Detail
|
||||
- [x] **Progressionsgraph zwischen Übungen** (Bibliotheks-Container, Kanten, Sequenz-Bulk, Varianten-Knoten — Zwischenstand, siehe TRAINING_FRAMEWORK_SPEC §4)
|
||||
- [x] Medien (Upload/Embed, rollenabhängige Größenlimits)
|
||||
- [x] Suche & Filter (Multi-Filter, Chips, Fokus beim Suchen)
|
||||
- [x] Exercise Blocks (Bausteine)
|
||||
|
|
@ -73,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:**
|
||||
|
|
@ -121,7 +125,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
|
||||
### Dev
|
||||
|
||||
Branch `develop`; Migrations bis mindestens **030** 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
|
||||
|
||||
|
|
@ -133,15 +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-27 | ✅ Neu |
|
||||
| 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-27 | ✅ Aktualisiert (030) |
|
||||
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-04-27 | ✅ Referenz |
|
||||
| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (v1.3) |
|
||||
| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-27 | ✅ 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-27 | ✅ Diese Datei |
|
||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-05 | ✅ Diese Datei |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -152,4 +157,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
|||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-04-27
|
||||
**Letzte Aktualisierung:** 2026-05-05
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo - Fachliches Domänenmodell
|
||||
|
||||
**Version:** 0.4.0
|
||||
**Stand:** 2026-04-27 (Migration 023: Skills Complete Import)
|
||||
**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
|
||||
|
||||
---
|
||||
|
|
@ -458,6 +458,22 @@ skill_level_definitions (
|
|||
|
||||
**Umsetzung (Trainingsplanung):** Ein Eintrag in `training_unit_exercises` kann optional eine konkrete Varianten-ID (`exercise_variant_id`, Migration 030) tragen; Bindung wird gegen die gewählte Übung validiert. Varianten werden über die Übungs-API verwaltet (`technical/EXERCISES_API_SPEC.md`).
|
||||
|
||||
### Progressionsgraph zwischen Übungen (Zwischenstand, CURR‑002 Stufe 1)
|
||||
|
||||
**Abgrenzung:** Zusätzlich zur Varianten-Reihe **innerhalb** einer Übung gibt es optional einen **Bibliotheks-Progressionsgraphen**: gerichtete Kanten zwischen **Übungen** (Knoten optional auf konkrete **Varianten** eingegrenzt). Gemeinsamer Kontainer pro Graph (`exercise_progression_graphs`); Kanten mit Typ z. B. Nachfolger oder Schwester.
|
||||
|
||||
**Rolle:** **unterstützend** für Planung und spätere Rahmenprogramme — keine Pflicht, jeden Trainingsablauf als Graph zu modellieren (**CURR‑013**).
|
||||
|
||||
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
|
||||
|
||||
### 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 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**).
|
||||
|
||||
**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**).
|
||||
|
||||
---
|
||||
|
||||
## Methodenbezug (§11.5)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
# Konzept: Trainingsplanung über Einheiten hinweg, Kurspläne, Governance, Assessments
|
||||
|
||||
**Status:** Arbeitspapier (lebend)
|
||||
**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/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Kurz-Zielbild (Problem)
|
||||
|
||||
- **Heute:** Planung denkt stark pro **einer** Trainingseinheit (Struktur + Übungen).
|
||||
- **Gewünscht:**
|
||||
- **Trainingsrahmenprogramm** (über **mehrere** Session-Slots): **mehrere** **Entwicklungsziele** über denselben Zeitraum sowie **Zuordnung von Übungen** zu Sessions; unterstützt **manuelle** Verteilung (ohne Pflicht-Progressionsgraph) und optional **persistente Progressionsbäume** (v. a. Verwalter) — siehe CURR‑010, CURR‑011, CURR‑013.
|
||||
- **Mehrwöchige / periodische Planung** (z. B. Monat) mit **Entwicklungszielen** und Verteilung aufbauender Elemente über **mehrere Einheiten** – auch **ohne** vollständiges „Kursprogramm“-Produkt.
|
||||
- **Standard-Kurs-/Stufenpläne** (zeitlos, mehrschrittig) als Basis für konkrete Durchführung; Instanzen **editierbar ohne** Änderung der Vorlage.
|
||||
- **Governance** einheitlich über Domänen (Übungen, Verein, Trainingspläne, Kurspläne …), ohne spätere Modellbrüche.
|
||||
- **Nachvollziehbarkeit:** real durchgeführte Einheiten an Pläne/Vorlagen zurückführen; **Feedback** zur Verbesserung von Standardplänen.
|
||||
- **Assessments** als Spezialfall eines Plans (Tests, z. B. Gürtel), später ggf. Teilnehmerbezug und Erwartungsniveau.
|
||||
|
||||
---
|
||||
|
||||
## 2. Geführte Konzepterstellung (Arbeitsschritte)
|
||||
|
||||
**Konzept-Arbeit:** Ziele klären → Optionen → **Entscheidung** in §5 → Glossar §4 pflegen.
|
||||
|
||||
### 2.a Umsetzungs-Reihenfolge „Rahmenprogramm“ (product / binding laut CURR-001–002)
|
||||
|
||||
| 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 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)
|
||||
|
||||
| Schritt | Thema | Status |
|
||||
|--------|--------|--------|
|
||||
| **A** | Scope & Reihenfolge relativ zu Kursprogramm, Backlog-Themen | ✅ siehe §5 CURR-001–004 |
|
||||
| **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) | ➕ **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:** **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`**.
|
||||
|
||||
---
|
||||
|
||||
### 2.c Schritt B – Governance-Muster (Leitplan)
|
||||
|
||||
#### B.1 Ziel
|
||||
|
||||
Ein **wiedererkennbares Muster** für alle **Bibliotheksobjekte** (Übung, Trainingsplan-Vorlage, Übungsblock, künftig Rahmen-/Kurs-Vorlage, Progressions-Paket …), damit **CURR-004** (spätere Rechte nach Rolle/Zugehörigkeit) **ohne Modellbruch** nachrüstbar ist.
|
||||
|
||||
#### B.2 Ist-Stand im Produkt (kurz, Stand Code-Review)
|
||||
|
||||
| Objekt | Relevante Felder / Muster |
|
||||
|--------|---------------------------|
|
||||
| `exercises`, `exercise_blocks` | `visibility` ∈ `private` \| `club` \| `official`, `club_id`, `created_by` — **Referenzmuster** |
|
||||
| `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)
|
||||
|
||||
1. **Bibliothek vs. Instanz**
|
||||
- **Bibliothek:** zeitlose Objekte (Übung, Vorlage, Rahmen-Template, Graph-Definition …). Tragen **Sichtbarkeits-Metadaten** nach gemeinsamem Muster.
|
||||
- **Instanz:** z. B. `training_unit` am Termin; **inhaltliche** Bearbeitung **entkoppelt** von der Vorlage (**Kopie** der Struktur, nicht „Live-Link“ zur Überschreibung der Vorlage).
|
||||
- Zugriff auf Instanzen: primär über **Trainingsgruppe / Rolle** (Trainer, Co-Trainer); **nicht** zwingend über `visibility` der Vorlage dublett führen in Phase 1.
|
||||
|
||||
2. **Einheitlicher Governance-Kern für neue & nachzuziehende Bibliothekstypen**
|
||||
- Minimal: **`visibility`** (gleiche Semantik wie bei Übungen), **`club_id`** (optional, NULL = nicht vereinsgebunden / global nutzbar je nach Policy), **`created_by`**.
|
||||
- **Sparte (`division`):** optional später **`division_id`** oder M:N — **nicht** im MVP-Kern erzwingen, damit keine parallele „Rights-Welt“ pro Objekttyp entsteht.
|
||||
|
||||
3. **Policy vs. Speicherung**
|
||||
- DB-Felder beschreiben **„wem gehört es / welche Lesestufe“** intentionell; **durchsetzende** Filter in API/UI folgen schrittweise (CURR-004).
|
||||
- Vermeiden: Objekttypen mit völlig anderen Spaltennamen für dieselbe Idee (`owner` vs `created_by` ohne Konvention).
|
||||
|
||||
4. **Herkunft / Lineage (nur Metadaten)**
|
||||
- Wo sinnvoll: `plan_template_id`, später `framework_program_template_id`, optional `forked_from_*` für **Nachvollziehbarkeit** ohne Kopplung für Writes.
|
||||
- Detail Arbeitspaket **Schritt E**; nicht mit Governance-Kern vermischen.
|
||||
|
||||
5. **Progressionsgraph**
|
||||
- Als eigener Bibliotheks-**Kontainer** oder als **Annotion** an Übung(en): gleicher Governance-Kern; **Ausnahmen** nicht vorwegnahmen ohne technische Spec (§6 offene Fragen).
|
||||
|
||||
#### B.4 Bewusste Nicht-Ziele in diesem Schritt
|
||||
|
||||
- Keine endgültige **Matrix** „Welche Rolle sieht welches `official`“.
|
||||
- Keine Pflicht-Anbindung Sportler/Lehrender außerhalb bestehender Gruppenmitgliedschaft.
|
||||
|
||||
---
|
||||
|
||||
### 2.d Schritt C – Trainingsrahmenprogramm (Ausarbeitung · Status **Kern geklärt**)
|
||||
|
||||
#### Checkliste — Abgleich Produktfeedback 2026‑04‑29
|
||||
|
||||
| Check | Ergebnis | Verweis |
|
||||
|-------|----------|---------|
|
||||
| **C1** | ✅ **Eigene Rahmen-Entität** (C1a) — nicht die Einheitenvorlage überladen | **CURR‑009** |
|
||||
| **C2** | ✅ Slots erlauben **beliebige Übungen** über Trainings-/Slots zu verteilen; **persistenter Progressionsgraph** ist **unterstützend**, **Pflicht‑Pflege** im Graph gilt **nicht** (Admin‑Aufwand; v. a. global) | **CURR‑010**, **CURR‑013** |
|
||||
| **C3** | ✅ Über denselben Planungszeitraum **mehrere** gleichzeitige **Entwicklungsziele** (nicht nur ein Sammelfeld) | **CURR‑011** |
|
||||
| **C4** | ✅ **Zwei Nutzungsbilder**, kein „entweder C4a oder C4b global“ — siehe Ausführungen unten zu **Konkret** vs. **Bibliothek** | **CURR‑012** |
|
||||
| **C5** | ✅ `training_plan_template` bleibt **eine‑Einheit‑Mikrovorlage**; Rahmen adressiert **n** Sessions; pro Slot weiterhin möglich: **optional** `training_plan_template_id` (Technical Spec entscheidet MVP‑Pflicht) | Glossar |
|
||||
|
||||
---
|
||||
|
||||
#### C4 verständlich: **„Materialisierung“** = zwei echte Situationen
|
||||
|
||||
| Nutzungsbild | Kontext | Gruppe / Datum | Typische Persistenz‑Schicht |
|
||||
|--------------|---------|----------------|----------------------------|
|
||||
| **A – Kurzfrist‑ / Basisplanung („nächste Wochen“)** | Konkret für **eine Trainingsgruppe** geplant | **Immer:** Gruppe + Termin(e) wie heute beim Training | Existierende oder neu angelegte **`training_units`**; Rahmen fungiert als **Planhilfe**/Vorlage **über mehrere dieser Einheiten** |
|
||||
| **B – Kursprogramm-/Lehrplan‑Bibliothek** | Übergeordnete **Struktur** ohne laufenden Kurs | **In der Bibliotheksvorlage selbst oft:** keine Gruppe/Zeit | Rahmen-/Kurs‑Vorlage **zeit‑ und gruppenlos** gespeichert; **Übertrag** (`Instanziierung`) erst bei „wir machen einen Kurs daraus“: dann Wahl **Gruppe + Zeitraum** → Anlegen oder Befüllen von **`training_units`** |
|
||||
|
||||
**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)** 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.
|
||||
|
||||
---
|
||||
|
||||
#### C1/Erinnerung Checkbox (historisch)
|
||||
|
||||
- **`[x] C1a`** eigene Rahmen‑Entität bestätigt (Chat 2026‑04‑29).
|
||||
|
||||
---
|
||||
|
||||
*Konkretisierung technischer Felder (ein Objekt zwei Modi vs. zwei Typen) → **Technical Spec**; keine neuen Contradicts zu CURR‑001 ohne expliziten Beschluss.*
|
||||
|
||||
---
|
||||
|
||||
## 3. Richtungen aus Diskussion (nicht-binding, wenn nicht in §5)
|
||||
|
||||
| Thema | Richtung |
|
||||
|--------|----------|
|
||||
| Assessments als Plantyp | **Spezielle Form eines Trainingsplans**; Test-Übungen; später **Sportlerbezug**/Erwartungsniveau (Gürtel) — aktuell **Backlog**, siehe CURR-003. |
|
||||
| Bibliothek vs. Durchführung | Konkrete Pläne/Einheiten editierbar **ohne** Änderung am Standard-/Rahmen-Template (**Kopien / Instanzen**). |
|
||||
|
||||
*Scope-Reihenfolge und Governance-Startpunkt sind ab 2026-04-29 in §5 festgehalten (CURR-001 bis CURR-004).*
|
||||
|
||||
---
|
||||
|
||||
## 4. Glossar (wird ergänzt)
|
||||
|
||||
| Begriff | Bedeutung (vorläufig) |
|
||||
|---------|------------------------|
|
||||
| **Bibliotheksobjekt** | Zeitlose Vorlage (Übung, Trainingsplan-Vorlage, Kursrahmen, später Assessment-Vorlage …) |
|
||||
| **Governance-Kern** | Einheitliches Minimalset an Metadaten für Bibliotheksobjekte: v. a. `visibility`, `club_id`, `created_by` (siehe §2.c) |
|
||||
| **Instanz (Training)** | `training_unit` o. Ä.; Zugriff über Gruppe/Rolle; Inhalt als **Kopie** aus Vorlagen, nicht schreibend an Vorlage gekoppelt |
|
||||
| **Trainingsrahmenprogramm** | Über **mehrere Session-Slots**: **mehrere gleichzeitige Entwicklungsziele** und **Zuordnung von Übungen** zu Slots/Einheiten; **manuelle** Zuordnung + optional **Progression** (**CURR‑010**, **CURR‑011**, **CURR‑013**) |
|
||||
| **Progressionsbaum / -graph** | Optionale gerichtete Beziehungen **zwischen Übungen** zur **Unterstützung** beim Planen (**CURR‑010** — **kein Pflicht‑Pflegeschritt für jede Zuordnung**); v. a. für globale Pflege |
|
||||
| **Rahmen-Vorlage** („Framework“) | Bibliothekskontainer mit **ordered Slots**, **zeitlos möglich** (Modus B) oder im Konkretkontext an Gruppe geknüpfte Planung (**CURR‑012**); eigene Entität (**CURR‑009**) |
|
||||
| **Slot** | Position in der Reihenfolge eines Rahmens; trägt **Übungszuweisungen** („Stückliste“), optional Hinweise/Text; Datum optional bis Materialisierung |
|
||||
| **Materialisierung / Instanziierung** | Überführung aus (ggf. zeit‑/gruppenloser) **Rahmen-Bibliothek** in konkrete **`training_units`** mit Gruppe/Zeitraum — **CURR‑012 Modus B**. Modus A bleibt nahe bestehender Einheiten-Planung |
|
||||
| **Konkret-Planung (Modus A)** | Mehr‑Wochen für **bekannte** Trainingsgruppe + Terminen — **`training_units`** |
|
||||
| **Bibliotheks-Rahmen (Modus B)** | Strukturierte Vorlage ohne Gruppe/Uhrzeit („Kursprogramm“‑Wurzel bis zum Import in einen Kurs) |
|
||||
| **Kursprogramm** | Wie Curriculum-/Stufen-Standard; **planerisch nachgelagert** an Rahmenprogramm (CURR-003) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Entscheidungsprotokoll (binding)
|
||||
|
||||
| ID | Datum | Entscheidung | Begründung / Kontext |
|
||||
|----|--------|---------------|---------------------|
|
||||
| **CURR-013** | 2026-04-29 | Präzisierung zu **CURR‑002 (1)+(2)**: **Persistenter Progressionsgraph** ist **unterstützend**, **nicht** die alleinige Quelle für Slot‑geplante Übungen. Der Planungsmodus **muss beliebige Übungen** auf Slots verteilen **ohne** Pflicht zur Graph‑Pflege im Alltag. Reichhaltiges Pflegen von Progressionsbezügen v. a. durch **globale Verwalter** erwünscht, nicht verpflichtend pro Übungszuordnung. | Chat C2 |
|
||||
| **CURR-012** | 2026-04-29 | **Zwei Nutzungsbilder:** **Modus A (Konkret)** — Mehr‑Wochenplan für **bekannte Trainingsgruppe + Termine** → bestehende/neue **`training_units`**; Rahmen als Planhilfe über mehrere Einheiten. **Modus B (Bibliothek)** — **Kurs-/Stufen‑Struktur ohne** Gruppe/Uhrzeit bis zur **Übernahme**; dann Zuordnung von Gruppe+Zeitraum und **Instanziierung** in **`training_units`**. Bulk‑Anlegen (**C4a**) und Verknüpfen existierender (**C4b**) sind **Modus‑A‑Alternativen**. | Chat C4 |
|
||||
| **CURR-011** | 2026-04-29 | **Mehrere parallele Entwicklungsziele** im selben Planungszeitraum → Datenmodell: **Zielliste mit ≥1 Einträgen** auf Rahmen‑Ebene (Details Technical Spec). **Nicht** nur ein einziges Sammelziel‑Feld. | Chat C3 |
|
||||
| **CURR-010** | 2026-04-29 | **Slot‑Inhalt:** tragend **direkte Zuordnung beliebiger Übungen** („Stückliste“); Option **Graph** für Vorschläge/Anreicherung. **`training_plan_template_id` pro Slot** weiterhin **optional** (MVP offen). | Chat C2 |
|
||||
| **CURR-009** | 2026-04-29 | **C1a:** **Neue eigene Bibliotheks‑Entität** für Mehr‑Slot‑Rahmen (**Framework**/`training_framework_*`-Arbeitscode); **`training_plan_template`** bleibt **eine Einheit**‑Mikrovorlage (**C5**). | Chat C1 |
|
||||
| **CURR-008** | 2026-04-29 | **Migration / Backfill (Early-Installation):** Migrationen betreffen aktuell nur **frühe Systeme ohne weitere Nutzer**. Vereins‑Zuordnung für Bestands-/Default‑Zeilen erfolgt beim Backfill mit dem **Standard‑Verein der Installation** (konkret: Club‑ID bzw. Konvention im Migrate‑Skript dokumentieren — z. B. erster Verein oder `DEFAULT_CLUB_ID` in Env). **`visibility`**-Default beim Hinzufügen der Spalte: **`club`**, wenn fachlich alles diesem Vereinskontext zugeordnet wird; anderenfalls bei Multi‑Tenant eigene Migrate‑Anweisung. | Nutzerfestlegung; pragmatisches Backfill ohne Mehr‑Mandanten‑Heuristik; §6 entsprechend vereinfacht. |
|
||||
| **CURR-007** | 2026-04-29 | **`training_plan_templates`** weichen aktuell vom Übungs-Muster ab (**kein** `visibility`). **Festlegung:** Bei der nächsten sinnvollen Migration auf den **gemeinsamen Governance-Kern** angleichen (**`visibility`** zusätzlich zu `club_id` / `created_by`), Semantik **analog zu Übungen** im Vereinskontext; von dieser Linie nur abweichen, wenn ausdrücklich anders dokumentiert. | Bekannte Schulden bis Migration vermeiden; neue Objekttypen sollen CURR-005 folgen; Zuordnung/Backfill **CURR‑008**. |
|
||||
| **CURR-006** | 2026-04-29 | **Instanz-Ebene (`training_unit` u. Ä.):** In der Rahmenprogramm-Phase **keine** neue parallele `visibility`-Schicht auf der Einheit; **Zugriff** über **`group_id`** und bestehende Trainer-/Mitgliedschaftslogik. **Lineage** zu Vorlagen/Rahmen nur als **optionale Metadaten-FKs** (`plan_template_id`, spätere Erweiterungen), ohne dass Schreiben in der Einheit die Vorlage ändert. | CURR-004-kompatibel: API-Policy später ergänzbar ohne Instanz-Umbau. |
|
||||
| **CURR-005** | 2026-04-29 | **Governance-Kern für Bibliotheksobjekte** (Übung, neue/alte Vorlagen, künftig Rahmen-/Kurs-/Progressions-Container): **`visibility`** im Sinne von `exercises` (`private` \| `club` \| `official`), **`club_id`** optional (NULL wenn nicht vereinsspezifisch), **`created_by`**. Sparte später optional **`division_id`** oder Verknüpfungstabelle — **nicht** Blocker für ersten Progressions-/Rahmen-Entwurf. | Einheitliche Semantik; Altabweichungen gezielt nachziehen (CURR-007). |
|
||||
| **CURR-004** | 2026-04-29 | **Sichtbarkeit:** Aktuell **globale Nutzungs-/Planungssicht** für alle; Architektur und Datenmodell aber **von Anfang an** so gestalten, dass **spätere** Einschränkungen nach Rollen und Zugehörigkeiten (Verein, Gruppe, Sparte …) ohne Bruch eingeführt werden können. | Vorbereitung einheitlicher Governance; siehe CURR-005/006 für Konkretisierung. |
|
||||
| **CURR-003** | 2026-04-29 | **Nachgelagert / explizites Backlog (nicht Phase Rahmenprogramm):** **Kursprogramm** kommt nach dem Rahmenprogramm (planerisch ähnlich); **Assessments**, **Sportlerakte**, **KI-Optimierungen** ebenfalls zurückgestellt bis Rahmenkern steht. | Priorität liegt auf Persistenz Progression → Multi-Einheiten-Planung → Einheitenvorschläge. |
|
||||
| **CURR-002** | 2026-04-29 | **Umsetzungsreihenfolge Rahmenprogramm:** **(1)** Progressionsbezüge **zwischen Übungen** müssen als **persistierter Graph/Baum** modellierbar sein. **(2)** **Planungsmodus**, der **Übungen** (u. a. aus Progression, **auch manuell**, **CURR‑010**) **auf mehrere Trainingseinheiten verteilt**, **mehrere Ziele** (**CURR‑011**) enthält und als **speicherbares Rahmen‑Template** dient. **(3)** **Warenkorb**-Idee beim Ausarbeiten einer **einzelnen** Einheit. | Reihenfolge vom Datenkern zur UX; Zuordnung/Graph **CURR‑013** |
|
||||
| **CURR-001** | 2026-04-29 | Vor dem separaten Produkt **„Kursprogramm“** wird das **„Trainingsrahmenprogramm“** (Ziele + Progression über mehrere Einheiten) angegangen — **nicht** umgekehrt. | Kursprogramm baut auf derselben Planungslogik auf; erst gemeinsamen Kern liefern. |
|
||||
|
||||
*Format:* Neue Zeile **oben** einfügen (neueste zuerst).
|
||||
|
||||
---
|
||||
|
||||
## 6. Offene Fragen (Backlog)
|
||||
|
||||
- **Minimal-UI** Rahmenprogramm vs. bestehende Kalender/Liste `training_units`?
|
||||
- ~~Governance Migrate Default~~ → **CURR‑008**
|
||||
- ~~Slots / C4 generisch~~ → **CURR‑012** (Modi A/B)
|
||||
- ~~Relation zwei Vorlagenfamilien~~ → **CURR‑009** (**Rahmen** neu, **Einheit** bleibt `training_plan_template`)
|
||||
- **Technical:** ~~gleiche DB‑Entität mit `plan_mode` (**A \| B**) vs. **konsequente Teilung** zweier Objekttypen?~~ → **Festgelegt:** eine Entität **`training_framework_programs`** + **`plan_mode`**; siehe **`technical/TRAINING_FRAMEWORK_SPEC.md` §2.0**.
|
||||
- **Progressionsgraph:** Kantentypen (nächste Übung vs. **Variante** vs. Level innerhalb gleicher Übung); optional **Skills**-Anbindung
|
||||
|
||||
---
|
||||
|
||||
## 7. Produkt-Backlog (explizit, nicht aktuelle Phase)
|
||||
|
||||
Siehe **CURR-003:** Kurs-/Stufenprogramm (nach Rahmenkern), Assessments (Plantyp/Testübungen/Sportler), Sportlerakte, KI-/optimierungsunterstützte Planung.
|
||||
|
||||
---
|
||||
|
||||
## 8. Nächste Aktion (für dich / Team)
|
||||
|
||||
1. ~~**Schritt C**~~ · siehe §2.d · **CURR‑009 bis CURR‑013**
|
||||
2. ~~Progressionsgraph Stufe 1~~ ✅ siehe **`technical/TRAINING_FRAMEWORK_SPEC.md`** §3–§4 · **Jetzt:** **`TRAINING_FRAMEWORK_SPEC.md`** §2 (Checkliste) mit **DDL-/API-Abschnitt Rahmen** ergänzen (**CURR‑002 (2)**); Modus A/B siehe Funktionskonzept §6
|
||||
3. **Migrate** weiter **CURR‑007 / CURR‑008** (ideal parallel oder vor erster Rahmen‑Migration mit neuem Bibliothekstyp)
|
||||
4. Konzeptpaket optional **Schritt E** Lineage vor Implementierung Großrelease
|
||||
|
||||
---
|
||||
|
||||
## 9. Changelog dieser Datei
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|-----------|
|
||||
| 2026-04-30 | §8 Punkt 2 angepasst (Graph ✅; nächster Fokus Rahmen‑Spec **CURR‑002 (2)**). |
|
||||
| 2026-04-28 | Technische Ausarbeitung gebündelt: neue Datei **`technical/TRAINING_FRAMEWORK_SPEC.md`** (Stub); Verweis §8. |
|
||||
| 2026-04-29 | **CURR‑009–013**, **CURR‑002** präzisiert; Glossar Modi A/B Slot; §2.d C geklärt; §6‑Backlog gekürzt. |
|
||||
| 2026-04-29 | CURR‑008 (Migration Standard‑Verein); **§2.d Schritt C** Checkpoints C1–C5; Glossar/§6 angepasst. |
|
||||
| 2026-04-29 | CURR-001–004; Umsetzungsreihenfolge §2.a; Glossar Rahmenprogramm/Progressionsgraph; Scope-Backlog. |
|
||||
| 2026-04-28 | Erstanlage aus Konzept-Arbeitsphase Chat; Schritttabelle und Protokollstruktur. |
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
# Gelieferte Features & technische Basis (April 2026)
|
||||
# Gelieferte Features & technische Basis (Q2 2026)
|
||||
|
||||
**Stand:** 2026-04-27
|
||||
**Referenz:** `backend/version.py` — **APP_VERSION 0.7.9**, **DB_SCHEMA_VERSION 20260427030**
|
||||
**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**. 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/`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -11,20 +11,32 @@ Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren**
|
|||
|
||||
| Migration | Inhalt |
|
||||
|-----------|--------|
|
||||
| **032–034** | **Progressionsgraph Übung→Übung:** Container `exercise_progression_graphs`, Kanten `exercise_progression_edges`; **`notes`** (033); optionale Varianten-Endpunkte + Constraints (034) |
|
||||
| **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`** |
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend – Übungen (`routers/exercises.py`)
|
||||
## 2. Backend – Progressionsgraphen (`routers/exercise_progression_graphs.py`)
|
||||
|
||||
### 2.1 Liste & Suche
|
||||
- REST unter **`/api/exercise-progression-graphs`** inkl. Kanten-CRUD, **`POST …/edges/sequence`** (Reihe auf einmal), **`POST …/edges/delete-batch`**.
|
||||
- AuthZ wie Trainingsvorlagen: Admin/Superadmin oder Graph‑Ersteller; Anlegen mit Trainings-/Planungsrolle (`_has_planning_role`).
|
||||
- Listenresponses mit Übungstiteln und Variantennamen (JOIN).
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend – Übungen (`routers/exercises.py`)
|
||||
|
||||
### 3.1 Liste & Suche
|
||||
|
||||
- `GET /api/exercises` mit Filtern u. a.: Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeiten, **Skill-Stufe min/max**, `visibility_any`, `status_any`, `search`, **`ai_search`** (Platzhalter, derzeit gleiche Volltextlogik wie `search`).
|
||||
- Optional: **`include_variants=true`** — liefert pro Übung ein kompaktes **`variants`**-JSON (id, variant_name, sequence_order) für Planung/UI.
|
||||
|
||||
### 2.2 Übungsvarianten (CRUD)
|
||||
### 3.2 Übungsvarianten (CRUD)
|
||||
|
||||
Implementiert gemäß **`EXERCISES_API_SPEC.md`** (Varianten-Abschnitt):
|
||||
|
||||
|
|
@ -35,7 +47,7 @@ Implementiert gemäß **`EXERCISES_API_SPEC.md`** (Varianten-Abschnitt):
|
|||
|
||||
Sortierung der Varianten im Detail: **`sequence_order`**, dann **`progression_level`**, dann **`id`**.
|
||||
|
||||
### 2.3 Medien-Upload – Größenlimits
|
||||
### 3.3 Medien-Upload – Größenlimits
|
||||
|
||||
- Standard: **50 MB** pro Datei (`EXERCISE_MEDIA_MAX_UPLOAD_MB`, Default 50).
|
||||
- **`admin`** / **`superadmin`**: **1024 MB** Default (`EXERCISE_MEDIA_ADMIN_MAX_UPLOAD_MB`), nie unter dem Nutzer-Limit (in MB verglichen).
|
||||
|
|
@ -44,16 +56,18 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
|||
|
||||
---
|
||||
|
||||
## 3. Backend – Trainingsplanung (`routers/training_planning.py`)
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
||||
## 5. Frontend – Übungsliste (`ExercisesListPage.jsx`)
|
||||
|
||||
- Tabs **Liste** · **Progressionsgraphen** (`ExerciseProgressionGraphPanel`): Graphen anlegen/bearbeiten, Kanten inkl. Sequenz-Bulk und Tabellenansicht.
|
||||
- **Filter-Modal** (Fokus, Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeit + Stufen von/bis, Sichtbarkeit, Status).
|
||||
- **Filter-Chips** unter der Suchleiste; Klick entfernt einen Filter; Badge am Filter-Button = Anzahl Chips.
|
||||
- **Kein Vollbild-Spinner** bei jeder Suche: nur noch **`listFetching`** — Suchfelder bleiben im DOM (**Fokus/Cursor** bleiben erhalten); Liste zeigt optional „Aktualisiere Treffer…“.
|
||||
|
|
@ -62,29 +76,30 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
|
|||
|
||||
---
|
||||
|
||||
## 5. Frontend – Übung bearbeiten (`ExerciseFormPage.jsx`)
|
||||
## 6. Frontend – Übung bearbeiten (`ExerciseFormPage.jsx`)
|
||||
|
||||
- **Varianten-Editor**: eingeklappter Bereich (`<details>`), **eine Variante zur Zeit** über Dropdown oder „Neue Variante“; Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Speichern/Löschen pro Variante.
|
||||
- **Medien** wie zuvor (Formularteil).
|
||||
- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung.
|
||||
|
||||
Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Bearbeitung erfolgt unter **`/exercises/:id/edit`** (Routing-Doku ggf. anpassen).
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend – Übung Detail (`ExerciseDetailPage.jsx`)
|
||||
## 7. Frontend – Übung Detail (`ExerciseDetailPage.jsx`)
|
||||
|
||||
- Varianten-Abschnitt mit **Meta** (Dauer, Schwierigkeit, Material, Progressionsstufe) wo vorhanden.
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend – Trainingsplanung (`TrainingPlanningPage.jsx`)
|
||||
## 8. Frontend – Trainingsplanung (`TrainingPlanningPage.jsx`)
|
||||
|
||||
- `listExercises({ include_variants: true })`.
|
||||
- Pro Zeile: Übung + **Variante** (optional), Dauer, Reihenfolge.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rich-Text (`RichTextEditor.jsx` + CSS)
|
||||
## 9. Rich-Text (`RichTextEditor.jsx` + CSS)
|
||||
|
||||
- **Selection Save/Restore** vor Toolbar-Klicks (`insertUnorderedList` / `insertOrderedList` zuverlässiger bei Mehrzeilen-Markierung).
|
||||
- **`styleWithCSS` false** vor Formatbefehlen.
|
||||
|
|
@ -92,24 +107,36 @@ Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Be
|
|||
|
||||
---
|
||||
|
||||
## 9. Admin – Matrix / Reifegrad (Kontext)
|
||||
## 10. Admin – Matrix / Reifegrad (Kontext)
|
||||
|
||||
- Bereits dokumentiert in **`CHANGELOG`** / Module **`maturity_models`**: Matrix-Stack-Bundle Export/Import, Kontext-Bindings — siehe `version.py` und Admin-UI-Pfade.
|
||||
|
||||
---
|
||||
|
||||
## 10. Nächste sinnvolle Schritte (nicht Lieferstand)
|
||||
## 11. Trainingsrahmen: Bibliothek + Slot‑Blueprint (DB **036–037**)
|
||||
|
||||
- **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).
|
||||
|
||||
---
|
||||
|
||||
## 11. Verweise
|
||||
## 13. Verweise
|
||||
|
||||
| Thema | Dokument |
|
||||
|--------|----------|
|
||||
| Rahmenprogramm / Progressionsgraph | `technical/TRAINING_FRAMEWORK_SPEC.md` |
|
||||
| API Übungen | `technical/EXERCISES_API_SPEC.md` |
|
||||
| Domänenmodell | `functional/DOMAIN_MODEL.md` |
|
||||
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo - Datenbank-Schema (Technisch)
|
||||
|
||||
**Version:** 0.4.0
|
||||
**Stand:** 2026-04-27
|
||||
**Aktuell deployed:** Migration 023 (Skills Complete Import)
|
||||
**Version:** 0.5.2
|
||||
**Stand:** 2026-05-05
|
||||
**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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -40,6 +40,13 @@ Dieses Dokument beschreibt die **technische Datenbankstruktur** von Shinkan Jink
|
|||
| 021 | 2026-04-27 | ~~Import Skills from Matrix~~ (DEPRECATED) | ⚠️ Faulty |
|
||||
| **022** | **2026-04-27** | **Skills Schema Complete (BREAKING)** | ✅ Deployed |
|
||||
| **023** | **2026-04-27** | **Skills Complete Import (69 Skills)** | ✅ Deployed |
|
||||
| 024–031 | *versch.* | Reifegradmodelle, Medien, Planvorlagen/Sektionen u. a. — siehe `backend/migrations/` | ✅ je Umgebung |
|
||||
| **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, 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`** | ✅ |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -265,17 +272,74 @@ 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
|
||||
```
|
||||
|
||||
### Progressionsgraph Übung → Übung (Migrationen 032–034)
|
||||
|
||||
Separater gerichteter Graph **zwischen** Übungen (nicht zu verwechseln mit Varianten-Reihen **innerhalb** einer Übung, Migration 014). Detail-DDL und REST siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §3.
|
||||
|
||||
```sql
|
||||
exercise_progression_graphs (
|
||||
id, name, description, visibility, club_id, created_by,
|
||||
created_at, updated_at
|
||||
)
|
||||
exercise_progression_edges (
|
||||
id, graph_id,
|
||||
from_exercise_id, to_exercise_id,
|
||||
from_exercise_variant_id, -- nullable (Migration 034)
|
||||
to_exercise_variant_id,
|
||||
edge_type, -- z. B. next_exercise, sibling
|
||||
notes, -- Migration 033
|
||||
created_at
|
||||
)
|
||||
```
|
||||
|
||||
### MediaWiki Import (Migration 018)
|
||||
|
||||
```sql
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# Exercises API Specification
|
||||
|
||||
**Version:** 1.3
|
||||
**Datum:** 2026-04-27
|
||||
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits siehe Code)
|
||||
**Version:** 1.4
|
||||
**Datum:** 2026-04-30
|
||||
**Status:** Teilweise implementiert (Liste mit Filtern + Varianten + Medienlimits + Progressionsgraphen siehe Code)
|
||||
**Autor:** Claude Code
|
||||
**Änderungen v1.4:** Endpoints **`/exercise-progression-graphs`** inkl. Kanten, **`POST …/edges/sequence`**, **`POST …/edges/delete-batch`** — Detailtabellen siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3.3
|
||||
**Änderungen v1.3:** `GET /exercises` erweiterte Query-Parameter (`include_variants`, Multi-Filter, `ai_search`-Platzhalter); Dokumentation angepasst
|
||||
**Änderungen v1.2:** KI-Assistenz Endpoints, Skill-Level-System (benannte Stufen), intensity als low/medium/high
|
||||
**Änderungen v1.1:** Exercise Blocks Endpoints, Permissions dokumentiert, age_groups korrigiert
|
||||
|
|
@ -60,6 +61,20 @@ Development: https://dev.shinkan.jinkendo.de/api
|
|||
| PUT | `/exercise-blocks/{id}/items/{item_id}` | Update item |
|
||||
| DELETE | `/exercise-blocks/{id}/items/{item_id}` | Remove item |
|
||||
| PUT | `/exercise-blocks/{id}/items/reorder` | Reorder items (DnD) |
|
||||
| **Progressionsgraphen** (Übung→Übung) |
|
||||
| GET | `/exercise-progression-graphs` | Liste Graphen |
|
||||
| GET | `/exercise-progression-graphs/{id}` | Detail; Query `include_edges` |
|
||||
| POST | `/exercise-progression-graphs` | Graph anlegen |
|
||||
| PUT | `/exercise-progression-graphs/{id}` | Metadaten |
|
||||
| DELETE | `/exercise-progression-graphs/{id}` | Graph + Kanten |
|
||||
| GET | `/exercise-progression-graphs/{id}/edges` | Kantenliste |
|
||||
| POST | `/exercise-progression-graphs/{id}/edges` | Einzelkante |
|
||||
| POST | `/exercise-progression-graphs/{id}/edges/sequence` | Reihe (`steps`) in einer Transaktion |
|
||||
| PUT | `/exercise-progression-graphs/{id}/edges/{edge_id}` | z. B. Notiz |
|
||||
| DELETE | `/exercise-progression-graphs/{id}/edges/{edge_id}` | Kante löschen |
|
||||
| POST | `/exercise-progression-graphs/{id}/edges/delete-batch` | `{ edge_ids }` |
|
||||
|
||||
Vollständige Pfadtabelle, Auth und Feldgrenzen: **`TRAINING_FRAMEWORK_SPEC.md`** §3.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# Exercise System Architecture
|
||||
|
||||
**Version:** 1.0
|
||||
**Datum:** 2026-04-24
|
||||
**Version:** 1.1
|
||||
**Datum:** 2026-04-30
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
**Autor:** Claude Code
|
||||
**Autor:** Claude Code
|
||||
**Änderungen v1.1:** Progressionsgraph **zwischen** Übungen (Migration 032–034); Verweis `TRAINING_FRAMEWORK_SPEC.md`
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -54,6 +55,10 @@ Exercise Block ──── (N) Block Items ──── (1) Exercise
|
|||
└── is_placeholder (for templates)
|
||||
```
|
||||
|
||||
### 1.1b Progressionsgraph zwischen Übungen (nicht „Serie“)
|
||||
|
||||
**Abgrenzung:** Separates Konzept von der **Varianten-Serie** (§1.1): hier geht es um **gerichtete Kanten zwischen verschiedenen Übungen** (optional mit Varianten als Knoten-Endpunkten), gruppiert in Bibliotheks-Containern (`exercise_progression_graphs`). Schema, REST, Produktgrenzen und Backlog (parallele Alternativ-Pakete): **`TRAINING_FRAMEWORK_SPEC.md`** §3–§4.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Medien-Strategie
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
|
||||
**Basis:** Migrationen 001-013 (bereits deployed)
|
||||
|
||||
**Progressionsgraph zwischen Übungen:** Migrationen **032–034** — nicht Bestandteil dieses „Exercise Catalog“-Schemas-Dokuments; siehe **`TRAINING_FRAMEWORK_SPEC.md`** §3 und **`DATABASE_SCHEMA.md`** (Migrationshistorie).
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration 014: Variant Progression + Search
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# Frontend Routing & Navigation Specification
|
||||
|
||||
**Version:** 1.1
|
||||
**Datum:** 2026-04-27
|
||||
**Version:** 1.2
|
||||
**Datum:** 2026-04-30
|
||||
**Status:** DRAFT - Awaiting Review
|
||||
**Autor:** Claude Code
|
||||
**Änderungen v1.2:** Übersicht **Übungen**: Tabs Liste \| Progressionsgraphen auf `/exercises`; Progressions-Editor ohne neue Routen (Panel + Formularblock unter `/exercises/:id/edit`)
|
||||
**Änderungen v1.1:** Übungsvarianten-Bearbeitung nur unter `/exercises/:id/edit` (keine VariantFormPage-Routen)
|
||||
|
||||
---
|
||||
|
|
@ -13,10 +14,10 @@
|
|||
### 1.1 Route-Übersicht
|
||||
|
||||
```
|
||||
/exercises → ExercisesListPage (Grid + Filter + Chips)
|
||||
/exercises → ExercisesListPage — Tabs: **Liste** \| **Progressionsgraphen** (`ExerciseProgressionGraphPanel`)
|
||||
/exercises/new → ExerciseFormPage (Create)
|
||||
/exercises/{id} → ExerciseDetailPage (Accordion-Layout)
|
||||
/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline)
|
||||
/exercises/{id}/edit → ExerciseFormPage (Edit inkl. Varianten-Editor inline + Block Progressionsgraph)
|
||||
|
||||
/exercise-blocks → ExerciseBlocksListPage (Meine Blocks)
|
||||
/exercise-blocks/new → ExerciseBlockFormPage (Create)
|
||||
|
|
@ -672,7 +673,7 @@ function App() {
|
|||
|
||||
---
|
||||
|
||||
**Version:** 1.1
|
||||
**Letzte Änderung:** 2026-04-24
|
||||
**Status:** REVIEWED - Pending Implementation
|
||||
**Review-Änderungen:** Exercise Blocks Routes + Navigation hinzugefügt
|
||||
**Version:** 1.2
|
||||
**Letzte Änderung:** 2026-04-30
|
||||
**Status:** REVIEWED - Pending Implementation
|
||||
**Review-Änderungen:** Progressionsgraphen-UI (Tabs, Formularblock); Exercise Blocks Routes + Navigation (früher)
|
||||
|
|
|
|||
193
.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md
Normal file
193
.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# Trainingsrahmenprogramm — Technische Spezifikation
|
||||
|
||||
**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**).
|
||||
|
||||
---
|
||||
|
||||
## 1. Abgrenzung zu anderen Dokumenten
|
||||
|
||||
| Dokument | Rolle · warum **nicht** hier hineinmischen |
|
||||
|----------|--------------------------------------------|
|
||||
| `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** (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).
|
||||
|
||||
---
|
||||
|
||||
## 2. Rahmenprogramm (CURR‑002 Stufe 2) — Checkliste & technische Ausarbeitung
|
||||
|
||||
### 2.0 Technische Entscheidung: nur Bibliothek + Slot‑Blueprint = `training_units`
|
||||
|
||||
**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).
|
||||
|
||||
**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.
|
||||
|
||||
**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):** `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‑Ü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
|
||||
-- Kopf (ohne Modus-Spalten nach 036)
|
||||
training_framework_programs (
|
||||
id, title NOT NULL, description,
|
||||
planned_period_start, planned_period_end NULL,
|
||||
visibility NOT NULL, club_id, created_by,
|
||||
focus_area_id, style_direction_id NULL REFERENCES …,
|
||||
… timestamps
|
||||
)
|
||||
|
||||
training_framework_goals (
|
||||
id, framework_program_id FK CASCADE, sort_order UNIQUE per framework,
|
||||
title, notes
|
||||
)
|
||||
|
||||
training_framework_slots (
|
||||
id, framework_program_id FK CASCADE, sort_order UNIQUE per framework,
|
||||
title, notes,
|
||||
training_unit_id FK … (Spalte technisch noch vorhanden; fachlich ungenutzt, per 036 geleert)
|
||||
)
|
||||
|
||||
-- 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:** 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`); 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:** wie zuvor — Planungsrolle zum Schreiben; Lesen/Schreiben/Löschen des Rahmens: Admin/Superadmin oder Ersteller.
|
||||
|
||||
**Payload‑Hinweise (JSON), Slots:**
|
||||
|
||||
- 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).
|
||||
|
||||
### 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`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Progressionsgraph Übung → Übung (implementierter Stand)
|
||||
|
||||
### 3.1 Abgrenzung
|
||||
|
||||
- **Zwischen Übungen:** gerichtete Kanten auf Ebene **`exercises`** mit optionalen Endpunkten auf konkreten **`exercise_variants`** (Knoten = „Übung“ oder „Übung · Variante“). Migrationen **032–034**.
|
||||
- **Innerhalb einer Übung:** Reihenfolge / Progressionsmetadaten der Varianten unverändert über **`exercise_variants`** (Migration **014**) — nicht duplizieren.
|
||||
|
||||
AuthZ analog **`training_plan_templates`**: Graph nur für **Admin/Superadmin** oder **Ersteller** (`created_by`); Anlegen neuer Graphen mit **`_has_planning_role`**.
|
||||
|
||||
### 3.2 Migrationen & Schema (Kurz)
|
||||
|
||||
| Mig. | Inhalt |
|
||||
|------|--------|
|
||||
| **032** | `exercise_progression_graphs` (Name, Beschreibung, **`visibility`**, **`club_id`**, **`created_by`**); `exercise_progression_edges` (`graph_id`, von/nach Übung, `edge_type` VARCHAR Default `next_exercise`). FK CASCADE zu Graph und Übungen. |
|
||||
| **033** | `exercise_progression_edges.notes` (freier Text / „Entwicklungsziel“ pro Kante). |
|
||||
| **034** | `from_exercise_variant_id`, `to_exercise_variant_id` (nullable, FK `exercise_variants`, CASCADE). CHECK: gleiche Übung nur mit **zwei verschiedenen Varianten**. UNIQUE-Index über Graph + Endpunkte inkl. `COALESCE(variant_id,0)` + `edge_type`. |
|
||||
|
||||
Kantentypen in Produktnutzung: **`next_exercise`** (Nachfolger), **`sibling`** (Schwester / gleiche „Entwicklungslage“, semantisch oft Paar — weiterhin eine gerichtete Kante in DB).
|
||||
|
||||
Listenqueries liefern Join‑Felder **`from_exercise_title`**, **`to_exercise_title`**, **`from_variant_name`**, **`to_variant_name`**.
|
||||
|
||||
### 3.3 REST (`/api`, Router `exercise_progression_graphs.py`)
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---------|------|--------|
|
||||
| GET | `/exercise-progression-graphs` | Liste (+ `edges_count`); Admin sieht alle, sonst nur eigene. |
|
||||
| GET | `/exercise-progression-graphs/{id}` | Detail; `?include_edges=true` |
|
||||
| POST | `/exercise-progression-graphs` | Graph anlegen |
|
||||
| PUT | `/exercise-progression-graphs/{id}` | Metadaten |
|
||||
| DELETE | `/exercise-progression-graphs/{id}` | Graph + Kanten |
|
||||
| GET | `/exercise-progression-graphs/{id}/edges` | Kanten; Query optional `from_exercise_id`, `to_exercise_id` |
|
||||
| POST | `/exercise-progression-graphs/{id}/edges` | Einzelkante; Duplikat/Constraint → **409** |
|
||||
| POST | `/exercise-progression-graphs/{id}/edges/sequence` | **Bulk:** `{ steps: [{ exercise_id, variant_id? }, …], segment_notes?: [...] }` — nur **`next_exercise`**, Transaktion alle oder keine Zeile |
|
||||
| PUT | `/exercise-progression-graphs/{id}/edges/{edge_id}` | z. B. **`notes`** |
|
||||
| DELETE | `/exercise-progression-graphs/{id}/edges/{edge_id}` | eine Kante |
|
||||
| POST | `/exercise-progression-graphs/{id}/edges/delete-batch` | `{ edge_ids: [...] }` — z. B. gesamte sichtbare „Reihe“ löschen |
|
||||
|
||||
### 3.4 Frontend (Stand Code)
|
||||
|
||||
- **`ExercisesListPage`:** Tabs **Liste** · **Progressionsgraphen** → **`ExerciseProgressionGraphPanel`**.
|
||||
- **`ExerciseFormPage`** (nur Edit): eingeklappter Block **Progressionsgraph** mit Kontext „diese Übung“ + Filter „nur betroffene Kanten“.
|
||||
- Panel‑Funktionen: **Sequenz‑Editor** (mehrere Schritte → ein Bulk‑Speichern), zusammengefasste **Reihen‑Lesart** für `next_exercise`, eigene Liste für **Schwestern**, Einzelkantenbereich, Tab **Alle Kanten (Tabelle)**.
|
||||
- API‑Client: `frontend/src/utils/api.js` (`createExerciseProgressionSequence`, `deleteExerciseProgressionEdgesBatch`, …).
|
||||
|
||||
---
|
||||
|
||||
## 4. Zwischenstand für Produkt / Trainingsplanung (bewusste Grenzen)
|
||||
|
||||
**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**
|
||||
|
||||
- Lineare **Reihen** mehrerer Übungen (bzw. Varianten‑Knoten) über **Sequenz‑API** bzw. Sequenz‑UI.
|
||||
- **Nachfolger‑Lesart** als zusammenhängende Kette in der Übersicht.
|
||||
- **Schwester‑Kanten** als eigene Liste (Alternative gleicher „Stufe“ zwischen zwei Knoten).
|
||||
- **Einzelkanten** für Sonderfälle und Verzweigungen.
|
||||
|
||||
**Was noch nicht „ein Knopf“ ist (bekannt)**
|
||||
|
||||
- **Parallele gleichwertige Alternativen** („Paket B bestehend aus mehreren Übungen als echte Alternative zu Paket A“) sind **nicht** als erste‑Klass‑UX modelliert: mehrere Nachfolger aus einem Knoten sind technisch möglich (mehrere `next_exercise`‑Kanten), aber **keine** dedizierte Gruppe „Alternativ‑Set“. Pflege kann mehrzeilig und koplastisch wirken.
|
||||
- **Visualisierung echter Bäume** (Join‑Points, mehrere ausgehende Pfeile in einem Bild) ist nur eingeschränkt über Reihen‑Zusammenfassung + Tabelle abbildbar.
|
||||
|
||||
**Nächste sinnvolle Ausbaustufen** (Backlog Graph‑UX, nicht Blocker Planung)
|
||||
|
||||
- Semantik **`alternative_group_id`** oder **Hyperkanten** (ein UX‑Schritt legt mehrere Kanten mit gemeinsamer Gruppe an).
|
||||
- Komfort beim Pflegen **symmetrischer Schwestern** (ein Klick für zwei Richtungen / Dedupe).
|
||||
- Karten-/Baum‑Layout statt nur Zeilen.
|
||||
|
||||
Details weiterhin Diskussionsgrundlage in `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` §6 (Kantentypen).
|
||||
|
||||
---
|
||||
|
||||
## 5. Changelog (Dokument)
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 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. |
|
||||
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -52,7 +52,7 @@ backend/
|
|||
├── migrations/ # SQL-Migrationen (XXX_*.sql Pattern)
|
||||
└── routers/ # Router-Module
|
||||
auth · profiles · clubs · groups · skills · methods
|
||||
exercises · training_units · training_programs
|
||||
exercises · exercise_progression_graphs · training_units · training_programs
|
||||
planning · import_wiki · admin · membership
|
||||
|
||||
frontend/src/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -152,14 +152,16 @@ def read_root():
|
|||
}
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
app.include_router(exercises.router)
|
||||
app.include_router(exercise_progression_graphs.router)
|
||||
app.include_router(clubs.router)
|
||||
app.include_router(skills.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(training_framework_programs.router)
|
||||
app.include_router(catalogs.router)
|
||||
app.include_router(maturity_models.router)
|
||||
app.include_router(matrix_stack_bundle.router)
|
||||
|
|
|
|||
38
backend/migrations/032_exercise_progression_graph.sql
Normal file
38
backend/migrations/032_exercise_progression_graph.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- Migration 032: Progressionsgraph zwischen Übungen (Übung → Übung), getrennt von Varianten-Progression (014).
|
||||
-- CURR-002 (1): gerichtete Kanten mit optionalem Graph-Kontainer; FK auf exercises; MVP edge_type erweiterbar.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exercise_progression_graphs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
visibility VARCHAR(50) NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('private', 'club', 'official')),
|
||||
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exercise_progression_graphs_club ON exercise_progression_graphs(club_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_exercise_progression_graphs_creator ON exercise_progression_graphs(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_exercise_progression_graphs_visibility ON exercise_progression_graphs(visibility);
|
||||
|
||||
DROP TRIGGER IF EXISTS exercise_progression_graphs_update ON exercise_progression_graphs;
|
||||
CREATE TRIGGER exercise_progression_graphs_update
|
||||
BEFORE UPDATE ON exercise_progression_graphs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exercise_progression_edges (
|
||||
id SERIAL PRIMARY KEY,
|
||||
graph_id INT NOT NULL REFERENCES exercise_progression_graphs(id) ON DELETE CASCADE,
|
||||
from_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
to_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
edge_type VARCHAR(50) NOT NULL DEFAULT 'next_exercise',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
CHECK (from_exercise_id <> to_exercise_id),
|
||||
UNIQUE (graph_id, from_exercise_id, to_exercise_id, edge_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_progression_edges_graph ON exercise_progression_edges(graph_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_progression_edges_from ON exercise_progression_edges(from_exercise_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_progression_edges_to ON exercise_progression_edges(to_exercise_id);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- Migration 033: Optionale Notiz / Entwicklungsziel pro Progressions-Kante (Übung→Übung).
|
||||
|
||||
ALTER TABLE exercise_progression_edges
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
-- Migration 034: Progressionskanten optional mit Übungsvarianten (Knoten = Übung oder konkrete Variante).
|
||||
-- UNIQUE und CHECK angepasst; Kanten innerhalb derselben Übung nur zwischen verschiedenen Varianten.
|
||||
|
||||
ALTER TABLE exercise_progression_edges
|
||||
ADD COLUMN IF NOT EXISTS from_exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS to_exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_progression_edges_from_variant ON exercise_progression_edges(from_exercise_variant_id)
|
||||
WHERE from_exercise_variant_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_progression_edges_to_variant ON exercise_progression_edges(to_exercise_variant_id)
|
||||
WHERE to_exercise_variant_id IS NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE exercise_progression_edges
|
||||
DROP CONSTRAINT exercise_progression_edges_graph_id_from_exercise_id_to_exercise_id_edge_type_key;
|
||||
EXCEPTION
|
||||
WHEN undefined_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE exercise_progression_edges DROP CONSTRAINT exercise_progression_edges_check;
|
||||
EXCEPTION
|
||||
WHEN undefined_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE exercise_progression_edges ADD CONSTRAINT exercise_progression_edges_endpoints_distinct CHECK (
|
||||
(from_exercise_id <> to_exercise_id)
|
||||
OR (
|
||||
from_exercise_variant_id IS NOT NULL
|
||||
AND to_exercise_variant_id IS NOT NULL
|
||||
AND from_exercise_variant_id <> to_exercise_variant_id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS exercise_progression_edges_unique_endpoints
|
||||
ON exercise_progression_edges (
|
||||
graph_id,
|
||||
from_exercise_id,
|
||||
COALESCE(from_exercise_variant_id, 0),
|
||||
to_exercise_id,
|
||||
COALESCE(to_exercise_variant_id, 0),
|
||||
edge_type
|
||||
);
|
||||
92
backend/migrations/035_training_framework_programs.sql
Normal file
92
backend/migrations/035_training_framework_programs.sql
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
-- Migration 035: Trainingsrahmenprogramm (Rahmen‑Vorlage, CURR‑002 Stufe 2 / CURR‑009–013)
|
||||
-- + CURR‑007/008: training_plan_templates.visibility (Backfill club, dann NOT NULL + Default)
|
||||
|
||||
-- ── Trainings‑Mikrovorlagen: gemeinsamer Governance‑Kern (visibility) ─────
|
||||
ALTER TABLE training_plan_templates
|
||||
ADD COLUMN IF NOT EXISTS visibility VARCHAR(50)
|
||||
CHECK (visibility IN ('private', 'club', 'official'));
|
||||
|
||||
UPDATE training_plan_templates
|
||||
SET visibility = 'club'
|
||||
WHERE visibility IS NULL;
|
||||
|
||||
ALTER TABLE training_plan_templates
|
||||
ALTER COLUMN visibility SET DEFAULT 'club';
|
||||
|
||||
ALTER TABLE training_plan_templates
|
||||
ALTER COLUMN visibility SET NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_plan_templates_visibility ON training_plan_templates(visibility);
|
||||
|
||||
-- ── Rahmen‑Header ────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS training_framework_programs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
plan_mode VARCHAR(20) NOT NULL
|
||||
CHECK (plan_mode IN ('concrete', 'library')),
|
||||
-- Modus B (library): immer NULL · Modus A (concrete): optional gebunden an eine Trainingsgruppe
|
||||
group_id INT REFERENCES training_groups(id) ON DELETE SET NULL,
|
||||
planned_period_start DATE,
|
||||
planned_period_end DATE,
|
||||
visibility VARCHAR(50) NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('private', 'club', 'official')),
|
||||
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
CHECK (
|
||||
(plan_mode = 'library' AND group_id IS NULL)
|
||||
OR plan_mode = 'concrete'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_creator ON training_framework_programs(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_club ON training_framework_programs(club_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_visibility ON training_framework_programs(visibility);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_mode ON training_framework_programs(plan_mode);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_group ON training_framework_programs(group_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS training_framework_programs_update ON training_framework_programs;
|
||||
CREATE TRIGGER training_framework_programs_update
|
||||
BEFORE UPDATE ON training_framework_programs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
|
||||
|
||||
-- ── Zielliste (CURR‑011: ≥ 1 durch API beim Speichern) ─────────────────────
|
||||
CREATE TABLE IF NOT EXISTS training_framework_goals (
|
||||
id SERIAL PRIMARY KEY,
|
||||
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||
sort_order INT NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
notes TEXT,
|
||||
UNIQUE (framework_program_id, sort_order)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_goals_framework ON training_framework_goals(framework_program_id);
|
||||
|
||||
-- ── Slots (Sessions im Rahmen) ──────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS training_framework_slots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||
sort_order INT NOT NULL,
|
||||
title VARCHAR(200),
|
||||
notes TEXT,
|
||||
training_unit_id INT REFERENCES training_units(id) ON DELETE SET NULL,
|
||||
UNIQUE (framework_program_id, sort_order)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_slots_framework ON training_framework_slots(framework_program_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_slots_unit ON training_framework_slots(training_unit_id);
|
||||
|
||||
-- ── Übungen pro Slot (tragende „Stückliste“, CURR‑010) ────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS training_framework_slot_exercises (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slot_id INT NOT NULL REFERENCES training_framework_slots(id) ON DELETE CASCADE,
|
||||
exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
|
||||
order_index INT NOT NULL,
|
||||
UNIQUE (slot_id, order_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_slot_exercises_slot ON training_framework_slot_exercises(slot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_slot_exercises_exercise ON training_framework_slot_exercises(exercise_id);
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
-- Migration 036: Rahmenprogramm — nur Bibliothek + Kontext-Stammdaten (Fokus, Stil, Typen, Zielgruppen)
|
||||
-- Grund: Zuordnung zu Gruppen/Kalender nur aus der Planung (Kopie + Lineage), nicht am Rahmenkopf.
|
||||
|
||||
-- ── Kontext am Rahmenkopf (Zuordenbarkeit / Filter) ─────────────────────────
|
||||
ALTER TABLE training_framework_programs
|
||||
ADD COLUMN IF NOT EXISTS focus_area_id INT REFERENCES focus_areas(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS style_direction_id INT REFERENCES style_directions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_focus ON training_framework_programs(focus_area_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_framework_programs_style ON training_framework_programs(style_direction_id);
|
||||
|
||||
-- ── M:N Trainingsstile (training_types Katalog) ─────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS training_framework_program_training_types (
|
||||
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||
training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (framework_program_id, training_type_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tfptt_type ON training_framework_program_training_types(training_type_id);
|
||||
|
||||
-- ── M:N Zielgruppen ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS training_framework_program_target_groups (
|
||||
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||
target_group_id INT NOT NULL REFERENCES target_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (framework_program_id, target_group_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tfptg_tg ON training_framework_program_target_groups(target_group_id);
|
||||
|
||||
-- ── Kein „Konkret“ mehr: Slots nicht an Kalender-Einheiten hängen ───────────
|
||||
UPDATE training_framework_slots SET training_unit_id = NULL WHERE training_unit_id IS NOT NULL;
|
||||
|
||||
-- Gruppe/Modus vom Rahmen lösen (Historie: evtl. noch concrete + group_id gesetzt)
|
||||
UPDATE training_framework_programs SET group_id = NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_training_framework_programs_group;
|
||||
|
||||
ALTER TABLE training_framework_programs DROP CONSTRAINT IF EXISTS training_framework_programs_group_id_fkey;
|
||||
|
||||
ALTER TABLE training_framework_programs DROP COLUMN IF EXISTS group_id;
|
||||
|
||||
-- Inline-CHECK(s) aus Migration 035 (plan_mode + group-Kombination) entfernen
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT c.conname AS cn
|
||||
FROM pg_constraint c
|
||||
WHERE c.conrelid = 'public.training_framework_programs'::regclass
|
||||
AND c.contype = 'c'
|
||||
AND (
|
||||
pg_get_constraintdef(c.oid) ILIKE '%plan_mode%'
|
||||
OR pg_get_constraintdef(c.oid) ILIKE '%group_id%'
|
||||
)
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE training_framework_programs DROP CONSTRAINT %I', r.cn);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE training_framework_programs DROP COLUMN IF EXISTS plan_mode;
|
||||
|
||||
DROP INDEX IF EXISTS idx_training_framework_programs_mode;
|
||||
130
backend/migrations/037_training_framework_blueprint_units.sql
Normal file
130
backend/migrations/037_training_framework_blueprint_units.sql
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
-- Migration 037: Rahmen-Slot-„Blueprint“ = eine training_units-Zeile (Ablauf wie echte Einheit)
|
||||
-- training_framework_slot_exercises migriert nach training_unit_sections / training_unit_section_items,
|
||||
-- dann entfernt.
|
||||
|
||||
-- ── Neue Spalten ───────────────────────────────────────────────────────────────
|
||||
ALTER TABLE training_units
|
||||
ADD COLUMN IF NOT EXISTS framework_slot_id INT REFERENCES training_framework_slots(id)
|
||||
ON DELETE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS origin_framework_slot_id INT REFERENCES training_framework_slots(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
-- Genau eine Blueprint-Einheit pro Slot (PostgreSQL UNIQUE erlaubt mehrere NULLs — hier Partial Index)
|
||||
DROP INDEX IF EXISTS uq_training_units_blueprint_slot;
|
||||
|
||||
CREATE UNIQUE INDEX uq_training_units_blueprint_slot
|
||||
ON training_units(framework_slot_id)
|
||||
WHERE framework_slot_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_units_framework_blueprint_calendar
|
||||
ON training_units(planned_date, group_id)
|
||||
WHERE framework_slot_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_units_origin_slot
|
||||
ON training_units(origin_framework_slot_id)
|
||||
WHERE origin_framework_slot_id IS NOT NULL;
|
||||
|
||||
-- ── Nullable für Blueprint-Zeilen ────────────────────────────────────────────
|
||||
ALTER TABLE training_units ALTER COLUMN planned_date DROP NOT NULL;
|
||||
ALTER TABLE training_units ALTER COLUMN group_id DROP NOT NULL;
|
||||
|
||||
-- ── Für jeden Slot eine Blueprint-Einheit; vorhandene Übungen in erste Sektion ─
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
new_uid INTEGER;
|
||||
new_sec INTEGER;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT s.id AS sid, fp.created_by AS fp_created_by
|
||||
FROM training_framework_slots s
|
||||
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||||
LOOP
|
||||
IF EXISTS (SELECT 1 FROM training_units tu WHERE tu.framework_slot_id = rec.sid) THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
INSERT INTO training_units (
|
||||
group_id,
|
||||
planned_date,
|
||||
planned_time_start,
|
||||
planned_time_end,
|
||||
planned_focus,
|
||||
status,
|
||||
notes,
|
||||
trainer_notes,
|
||||
created_by,
|
||||
plan_template_id,
|
||||
framework_slot_id
|
||||
)
|
||||
VALUES (
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
'planned',
|
||||
NULL,
|
||||
NULL,
|
||||
rec.fp_created_by,
|
||||
NULL,
|
||||
rec.sid
|
||||
)
|
||||
RETURNING id INTO new_uid;
|
||||
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id,
|
||||
order_index,
|
||||
title,
|
||||
guidance_notes
|
||||
)
|
||||
VALUES (new_uid, 0, 'Ablauf', NULL)
|
||||
RETURNING id INTO new_sec;
|
||||
|
||||
INSERT INTO training_unit_section_items (
|
||||
section_id,
|
||||
order_index,
|
||||
item_type,
|
||||
exercise_id,
|
||||
exercise_variant_id,
|
||||
planned_duration_min,
|
||||
actual_duration_min,
|
||||
notes,
|
||||
modifications,
|
||||
note_body
|
||||
)
|
||||
SELECT
|
||||
new_sec,
|
||||
sf.order_index,
|
||||
'exercise'::character varying(20),
|
||||
sf.exercise_id,
|
||||
sf.exercise_variant_id,
|
||||
NULL::integer,
|
||||
NULL::integer,
|
||||
NULL::text,
|
||||
NULL::text,
|
||||
NULL::text
|
||||
FROM training_framework_slot_exercises sf
|
||||
WHERE sf.slot_id = rec.sid
|
||||
ORDER BY sf.order_index;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
DROP TABLE IF EXISTS training_framework_slot_exercises;
|
||||
|
||||
ALTER TABLE training_units DROP CONSTRAINT IF EXISTS chk_training_units_blueprint_vs_scheduled;
|
||||
|
||||
ALTER TABLE training_units
|
||||
ADD CONSTRAINT chk_training_units_blueprint_vs_scheduled CHECK (
|
||||
(
|
||||
framework_slot_id IS NOT NULL
|
||||
AND group_id IS NULL
|
||||
AND planned_date IS NULL
|
||||
AND origin_framework_slot_id IS NULL
|
||||
)
|
||||
OR (
|
||||
framework_slot_id IS NULL
|
||||
AND group_id IS NOT NULL
|
||||
AND planned_date IS NOT NULL
|
||||
)
|
||||
);
|
||||
11
backend/migrations/038_training_unit_lead_trainer.sql
Normal file
11
backend/migrations/038_training_unit_lead_trainer.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Migration 038: Optionale verantwortliche Person pro Trainingstermin (Vertretung)
|
||||
-- Für Vereins-/Trainerübersicht: COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = wirksamer Leitungstrainer.
|
||||
|
||||
ALTER TABLE training_units
|
||||
ADD COLUMN IF NOT EXISTS lead_trainer_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN training_units.lead_trainer_profile_id IS 'Vertretung / expliziter Leiter dieses Terms; NULL = Standard (Haupttrainer der Gruppe)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_units_lead_trainer
|
||||
ON training_units(lead_trainer_profile_id)
|
||||
WHERE lead_trainer_profile_id IS NOT NULL;
|
||||
507
backend/routers/exercise_progression_graphs.py
Normal file
507
backend/routers/exercise_progression_graphs.py
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
"""
|
||||
Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
|
||||
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
|
||||
AuthZ analog training_plan_templates (_template_access / _has_planning_role).
|
||||
"""
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
from auth import require_auth
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
from routers.training_planning import _has_planning_role
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["exercises"])
|
||||
|
||||
|
||||
class ProgressionGraphCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
visibility: str = Field(default="private", pattern="^(private|club|official)$")
|
||||
club_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProgressionGraphUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
|
||||
club_id: Optional[int] = None
|
||||
|
||||
|
||||
class ProgressionEdgeCreate(BaseModel):
|
||||
from_exercise_id: int = Field(..., gt=0)
|
||||
to_exercise_id: int = Field(..., gt=0)
|
||||
from_exercise_variant_id: Optional[int] = Field(default=None)
|
||||
to_exercise_variant_id: Optional[int] = Field(default=None)
|
||||
edge_type: str = Field(default="next_exercise", min_length=1, max_length=50)
|
||||
notes: Optional[str] = Field(None, max_length=4000)
|
||||
|
||||
|
||||
class ProgressionEdgeUpdate(BaseModel):
|
||||
notes: Optional[str] = Field(None, max_length=4000)
|
||||
|
||||
|
||||
class SequenceStep(BaseModel):
|
||||
exercise_id: int = Field(..., gt=0)
|
||||
variant_id: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
class ProgressionSequenceCreate(BaseModel):
|
||||
steps: List[SequenceStep] = Field(..., min_length=2)
|
||||
segment_notes: Optional[List[Optional[str]]] = None
|
||||
"""Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten."""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_segment_notes_len(self):
|
||||
if self.segment_notes is not None and len(self.segment_notes) != len(self.steps) - 1:
|
||||
raise ValueError(
|
||||
f"segment_notes muss genau {len(self.steps) - 1} Einträge haben (len(steps)-1)"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class EdgeIdsBatch(BaseModel):
|
||||
edge_ids: List[int] = Field(..., min_length=1)
|
||||
|
||||
|
||||
_EDGE_SELECT = """
|
||||
SELECT e.id, e.graph_id,
|
||||
e.from_exercise_id, e.from_exercise_variant_id,
|
||||
e.to_exercise_id, e.to_exercise_variant_id,
|
||||
e.edge_type, e.notes, e.created_at,
|
||||
ef.title AS from_exercise_title, et.title AS to_exercise_title,
|
||||
vf.variant_name AS from_variant_name, vt.variant_name AS to_variant_name
|
||||
FROM exercise_progression_edges e
|
||||
JOIN exercises ef ON ef.id = e.from_exercise_id
|
||||
JOIN exercises et ON et.id = e.to_exercise_id
|
||||
LEFT JOIN exercise_variants vf ON vf.id = e.from_exercise_variant_id
|
||||
LEFT JOIN exercise_variants vt ON vt.id = e.to_exercise_variant_id
|
||||
"""
|
||||
|
||||
|
||||
def _graph_access(cur, graph_id: int, profile_id: int, role: str) -> dict:
|
||||
cur.execute(
|
||||
"SELECT * FROM exercise_progression_graphs WHERE id = %s",
|
||||
(graph_id,),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Progressionsgraph nicht gefunden")
|
||||
row = r2d(r)
|
||||
if role in ("admin", "superadmin"):
|
||||
return row
|
||||
if row.get("created_by") != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
|
||||
return row
|
||||
|
||||
|
||||
def _assert_exercises_exist(cur, *exercise_ids: int) -> None:
|
||||
unique_ids = list(dict.fromkeys(exercise_ids))
|
||||
if not unique_ids:
|
||||
return
|
||||
cur.execute(
|
||||
f"SELECT id FROM exercises WHERE id IN ({','.join(['%s'] * len(unique_ids))})",
|
||||
tuple(unique_ids),
|
||||
)
|
||||
found = {r["id"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
|
||||
missing = set(unique_ids) - found
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unbekannte Übung(en): {sorted(missing)}",
|
||||
)
|
||||
|
||||
|
||||
def _assert_variant_for_exercise(cur, exercise_id: int, variant_id: Optional[int]) -> None:
|
||||
if variant_id is None:
|
||||
return
|
||||
cur.execute(
|
||||
"SELECT exercise_id FROM exercise_variants WHERE id = %s",
|
||||
(variant_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=400, detail=f"Unbekannte Variante: {variant_id}")
|
||||
ev_ex = row["exercise_id"] if isinstance(row, dict) else row[0]
|
||||
if ev_ex != exercise_id:
|
||||
raise HTTPException(status_code=400, detail="Variante gehört nicht zur gewählten Übung")
|
||||
|
||||
|
||||
def _insert_edge_row(
|
||||
cur,
|
||||
graph_id: int,
|
||||
from_exercise_id: int,
|
||||
from_variant_id: Optional[int],
|
||||
to_exercise_id: int,
|
||||
to_variant_id: Optional[int],
|
||||
edge_type: str,
|
||||
notes: Optional[str],
|
||||
) -> dict:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO exercise_progression_edges (
|
||||
graph_id,
|
||||
from_exercise_id, from_exercise_variant_id,
|
||||
to_exercise_id, to_exercise_variant_id,
|
||||
edge_type, notes
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
graph_id,
|
||||
from_exercise_id,
|
||||
from_variant_id,
|
||||
to_exercise_id,
|
||||
to_variant_id,
|
||||
edge_type,
|
||||
notes,
|
||||
),
|
||||
)
|
||||
new_id = cur.fetchone()["id"]
|
||||
cur.execute(_EDGE_SELECT + " WHERE e.id = %s", (new_id,))
|
||||
return r2d(cur.fetchone())
|
||||
|
||||
|
||||
@router.get("/exercise-progression-graphs")
|
||||
def list_progression_graphs(session: dict = Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if role in ("admin", "superadmin"):
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
|
||||
FROM exercise_progression_graphs g
|
||||
ORDER BY g.updated_at DESC NULLS LAST, g.name
|
||||
"""
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT g.*,
|
||||
(SELECT COUNT(*) FROM exercise_progression_edges e WHERE e.graph_id = g.id) AS edges_count
|
||||
FROM exercise_progression_graphs g
|
||||
WHERE g.created_by = %s
|
||||
ORDER BY g.updated_at DESC NULLS LAST, g.name
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.get("/exercise-progression-graphs/{graph_id}")
|
||||
def get_progression_graph(
|
||||
graph_id: int,
|
||||
include_edges: bool = Query(default=False),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _graph_access(cur, graph_id, profile_id, role)
|
||||
if include_edges:
|
||||
cur.execute(
|
||||
_EDGE_SELECT + " WHERE e.graph_id = %s ORDER BY e.id",
|
||||
(graph_id,),
|
||||
)
|
||||
row["edges"] = [r2d(r) for r in cur.fetchall()]
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/exercise-progression-graphs", status_code=201)
|
||||
def create_progression_graph(
|
||||
body: ProgressionGraphCreate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
if not _has_planning_role(role):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Anlegen von Progressionsgraphen")
|
||||
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name ist Pflicht")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO exercise_progression_graphs (name, description, visibility, club_id, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(name, body.description, body.visibility, body.club_id, profile_id),
|
||||
)
|
||||
gid = cur.fetchone()["id"]
|
||||
conn.commit()
|
||||
|
||||
return get_progression_graph(gid, include_edges=False, session=session)
|
||||
|
||||
|
||||
@router.put("/exercise-progression-graphs/{graph_id}")
|
||||
def update_progression_graph(
|
||||
graph_id: int,
|
||||
body: ProgressionGraphUpdate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
|
||||
fields: List[str] = []
|
||||
params: List[Any] = []
|
||||
if "name" in data:
|
||||
n = (data["name"] or "").strip()
|
||||
if not n:
|
||||
raise HTTPException(status_code=400, detail="name ist Pflicht")
|
||||
fields.append("name = %s")
|
||||
params.append(n)
|
||||
if "description" in data:
|
||||
fields.append("description = %s")
|
||||
params.append(data["description"])
|
||||
if "visibility" in data:
|
||||
fields.append("visibility = %s")
|
||||
params.append(data["visibility"])
|
||||
if "club_id" in data:
|
||||
fields.append("club_id = %s")
|
||||
params.append(data["club_id"])
|
||||
|
||||
fields.append("updated_at = NOW()")
|
||||
params.append(graph_id)
|
||||
cur.execute(
|
||||
f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
|
||||
tuple(params),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return get_progression_graph(graph_id, include_edges=False, session=session)
|
||||
|
||||
|
||||
@router.delete("/exercise-progression-graphs/{graph_id}")
|
||||
def delete_progression_graph(graph_id: int, session: dict = Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/exercise-progression-graphs/{graph_id}/edges")
|
||||
def list_progression_edges(
|
||||
graph_id: int,
|
||||
from_exercise_id: Optional[int] = Query(default=None),
|
||||
to_exercise_id: Optional[int] = Query(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
q = _EDGE_SELECT + " WHERE e.graph_id = %s"
|
||||
params: List[Any] = [graph_id]
|
||||
if from_exercise_id is not None:
|
||||
q += " AND e.from_exercise_id = %s"
|
||||
params.append(from_exercise_id)
|
||||
if to_exercise_id is not None:
|
||||
q += " AND e.to_exercise_id = %s"
|
||||
params.append(to_exercise_id)
|
||||
q += " ORDER BY e.id"
|
||||
cur.execute(q, tuple(params))
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/exercise-progression-graphs/{graph_id}/edges", status_code=201)
|
||||
def create_progression_edge(
|
||||
graph_id: int,
|
||||
body: ProgressionEdgeCreate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
_assert_exercises_exist(cur, body.from_exercise_id, body.to_exercise_id)
|
||||
fv = body.from_exercise_variant_id
|
||||
tv = body.to_exercise_variant_id
|
||||
_assert_variant_for_exercise(cur, body.from_exercise_id, fv)
|
||||
_assert_variant_for_exercise(cur, body.to_exercise_id, tv)
|
||||
et = (body.edge_type or "next_exercise").strip() or "next_exercise"
|
||||
notes = (body.notes or "").strip() or None
|
||||
try:
|
||||
row = _insert_edge_row(
|
||||
cur,
|
||||
graph_id,
|
||||
body.from_exercise_id,
|
||||
fv,
|
||||
body.to_exercise_id,
|
||||
tv,
|
||||
et,
|
||||
notes,
|
||||
)
|
||||
conn.commit()
|
||||
except IntegrityError as e:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Kante existiert bereits oder Endpunkte unzulässig (gleiche Übung ohne zwei Varianten)",
|
||||
) from e
|
||||
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/exercise-progression-graphs/{graph_id}/edges/sequence", status_code=201)
|
||||
def create_progression_sequence(
|
||||
graph_id: int,
|
||||
body: ProgressionSequenceCreate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Legt n−1 Nachfolger-Kanten (next_exercise) für eine geordnete Schrittliste an."""
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
steps = body.steps
|
||||
n_seg = len(steps) - 1
|
||||
seg_notes = body.segment_notes
|
||||
|
||||
created: List[dict] = []
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
|
||||
ex_ids = [s.exercise_id for s in steps]
|
||||
_assert_exercises_exist(cur, *ex_ids)
|
||||
|
||||
try:
|
||||
for i in range(n_seg):
|
||||
a, b = steps[i], steps[i + 1]
|
||||
_assert_variant_for_exercise(cur, a.exercise_id, a.variant_id)
|
||||
_assert_variant_for_exercise(cur, b.exercise_id, b.variant_id)
|
||||
note = None
|
||||
if seg_notes is not None:
|
||||
raw = seg_notes[i]
|
||||
note = (raw or "").strip() or None if raw is not None else None
|
||||
row = _insert_edge_row(
|
||||
cur,
|
||||
graph_id,
|
||||
a.exercise_id,
|
||||
a.variant_id,
|
||||
b.exercise_id,
|
||||
b.variant_id,
|
||||
"next_exercise",
|
||||
note,
|
||||
)
|
||||
created.append(row)
|
||||
conn.commit()
|
||||
except IntegrityError as e:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Sequenz konnte nicht vollständig angelegt werden (Duplikat oder ungültige Endpunkte). Keine Teilmenge gespeichert.",
|
||||
) from e
|
||||
|
||||
return {"created": created, "count": len(created)}
|
||||
|
||||
|
||||
@router.put("/exercise-progression-graphs/{graph_id}/edges/{edge_id}")
|
||||
def update_progression_edge(
|
||||
graph_id: int,
|
||||
edge_id: int,
|
||||
body: ProgressionEdgeUpdate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
cur.execute(
|
||||
"SELECT id FROM exercise_progression_edges WHERE id = %s AND graph_id = %s",
|
||||
(edge_id, graph_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Kante nicht gefunden")
|
||||
|
||||
if "notes" in data:
|
||||
n = data["notes"]
|
||||
notes_val = (n or "").strip() or None if n is not None else None
|
||||
cur.execute(
|
||||
"UPDATE exercise_progression_edges SET notes = %s WHERE id = %s AND graph_id = %s",
|
||||
(notes_val, edge_id, graph_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.execute(_EDGE_SELECT + " WHERE e.id = %s AND e.graph_id = %s", (edge_id, graph_id))
|
||||
return r2d(cur.fetchone())
|
||||
|
||||
|
||||
@router.delete("/exercise-progression-graphs/{graph_id}/edges/{edge_id}")
|
||||
def delete_progression_edge(
|
||||
graph_id: int,
|
||||
edge_id: int,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM exercise_progression_edges
|
||||
WHERE id = %s AND graph_id = %s
|
||||
RETURNING id
|
||||
""",
|
||||
(edge_id, graph_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Kante nicht gefunden")
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/exercise-progression-graphs/{graph_id}/edges/delete-batch")
|
||||
def delete_progression_edges_batch(
|
||||
graph_id: int,
|
||||
body: EdgeIdsBatch,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Löscht mehrere Kanten (z. B. eine zusammenhängende Kette in einem Schritt)."""
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
ids = body.edge_ids
|
||||
clean_ids = list(dict.fromkeys(ids))
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_graph_access(cur, graph_id, profile_id, role)
|
||||
cur.execute(
|
||||
f"""
|
||||
DELETE FROM exercise_progression_edges
|
||||
WHERE graph_id = %s AND id IN ({",".join(["%s"] * len(clean_ids))})
|
||||
""",
|
||||
(graph_id, *clean_ids),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
return {"ok": True, "deleted": deleted}
|
||||
507
backend/routers/training_framework_programs.py
Normal file
507
backend/routers/training_framework_programs.py
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
"""
|
||||
Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere Session-Slots.
|
||||
|
||||
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
|
||||
nicht über group_id oder training_unit_id am Rahmen.
|
||||
AuthZ wie Planungs-Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from auth import require_auth
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
||||
from routers.training_planning import (
|
||||
_has_planning_role,
|
||||
_hydrate_training_unit_payload,
|
||||
_optional_positive_int,
|
||||
_insert_sections_from_legacy_exercises,
|
||||
_replace_unit_sections,
|
||||
_validate_variant_for_exercise,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["training_framework_programs"])
|
||||
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
|
||||
|
||||
|
||||
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||||
cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden")
|
||||
row = r2d(r)
|
||||
if role in ("admin", "superadmin"):
|
||||
return row
|
||||
if row.get("created_by") != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
|
||||
return row
|
||||
|
||||
|
||||
def _training_type_ids(cur, framework_id: int) -> List[int]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT training_type_id
|
||||
FROM training_framework_program_training_types
|
||||
WHERE framework_program_id = %s
|
||||
ORDER BY training_type_id
|
||||
""",
|
||||
(framework_id,),
|
||||
)
|
||||
return [r["training_type_id"] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _target_group_ids(cur, framework_id: int) -> List[int]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT target_group_id
|
||||
FROM training_framework_program_target_groups
|
||||
WHERE framework_program_id = %s
|
||||
ORDER BY target_group_id
|
||||
""",
|
||||
(framework_id,),
|
||||
)
|
||||
return [r["target_group_id"] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
fid = row["id"]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, framework_program_id, sort_order, title, notes
|
||||
FROM training_framework_goals
|
||||
WHERE framework_program_id = %s
|
||||
ORDER BY sort_order
|
||||
""",
|
||||
(fid,),
|
||||
)
|
||||
row["goals"] = [r2d(g) for g in cur.fetchall()]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, framework_program_id, sort_order, title, notes
|
||||
FROM training_framework_slots
|
||||
WHERE framework_program_id = %s
|
||||
ORDER BY sort_order
|
||||
""",
|
||||
(fid,),
|
||||
)
|
||||
slots = [r2d(s) for s in cur.fetchall()]
|
||||
for s in slots:
|
||||
cur.execute(
|
||||
"SELECT id FROM training_units WHERE framework_slot_id = %s",
|
||||
(s["id"],),
|
||||
)
|
||||
row_b = cur.fetchone()
|
||||
if not row_b:
|
||||
s["blueprint_training_unit_id"] = None
|
||||
s["sections"] = []
|
||||
s["exercises"] = []
|
||||
continue
|
||||
uid = row_b["id"]
|
||||
s["blueprint_training_unit_id"] = uid
|
||||
unit_min: Dict[str, Any] = {"id": uid}
|
||||
_hydrate_training_unit_payload(cur, unit_min)
|
||||
s["sections"] = unit_min.get("sections", [])
|
||||
s["exercises"] = unit_min.get("exercises", [])
|
||||
row["slots"] = slots
|
||||
row["training_type_ids"] = _training_type_ids(cur, fid)
|
||||
row["target_group_ids"] = _target_group_ids(cur, fid)
|
||||
return row
|
||||
|
||||
|
||||
def _assert_visibility(val: Optional[str]) -> Optional[str]:
|
||||
if val is None:
|
||||
return None
|
||||
if val not in _VALID_VISIBILITY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="visibility muss private, club oder official sein",
|
||||
)
|
||||
return val
|
||||
|
||||
|
||||
def _parse_positive_int_ids(raw: Any, label: str) -> List[int]:
|
||||
if raw is None:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise HTTPException(status_code=400, detail=f"{label} muss eine Liste von IDs sein")
|
||||
out: List[int] = []
|
||||
for item in raw:
|
||||
if item in (None, ""):
|
||||
continue
|
||||
try:
|
||||
n = int(item)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail=f"{label}: ungültige ID") from None
|
||||
if n <= 0:
|
||||
raise HTTPException(status_code=400, detail=f"{label}: ungültige ID")
|
||||
if n not in out:
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
def _replace_training_types(cur, framework_id: int, ids: Sequence[int]) -> None:
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_program_training_types WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
for tid in ids:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_framework_program_training_types (framework_program_id, training_type_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(framework_id, tid),
|
||||
)
|
||||
|
||||
|
||||
def _replace_target_groups(cur, framework_id: int, ids: Sequence[int]) -> None:
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_program_target_groups WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
for gid in ids:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_framework_program_target_groups (framework_program_id, target_group_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(framework_id, gid),
|
||||
)
|
||||
|
||||
|
||||
def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None:
|
||||
if not goals_in:
|
||||
raise HTTPException(status_code=400, detail="Mindestens ein Entwicklungsziel (goals) ist erforderlich")
|
||||
for gi, g in enumerate(goals_in):
|
||||
title_g = (g.get("title") or "").strip()
|
||||
if not title_g:
|
||||
raise HTTPException(status_code=400, detail="Jedes Ziel braucht ein nicht-leeres title")
|
||||
order_ix = g.get("sort_order")
|
||||
if order_ix is None:
|
||||
order_ix = gi
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_framework_goals (
|
||||
framework_program_id, sort_order, title, notes
|
||||
) VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(framework_id, int(order_ix), title_g[:500], g.get("notes")),
|
||||
)
|
||||
|
||||
|
||||
def _insert_default_blueprint_section(cur, blueprint_unit_id: int) -> None:
|
||||
"""Leerer Ablauf, falls noch keine Sektionen existieren."""
|
||||
cur.execute(
|
||||
"SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s",
|
||||
(blueprint_unit_id,),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return
|
||||
_replace_unit_sections(
|
||||
cur,
|
||||
blueprint_unit_id,
|
||||
[{"title": "Ablauf", "order_index": 0, "guidance_notes": None, "items": []}],
|
||||
)
|
||||
|
||||
|
||||
def _insert_slots_and_blueprints(
|
||||
cur,
|
||||
framework_id: int,
|
||||
slots_in: Optional[List[Any]],
|
||||
profile_id: int,
|
||||
) -> None:
|
||||
if slots_in is None:
|
||||
return
|
||||
for si, slot in enumerate(slots_in):
|
||||
order_ix = slot.get("sort_order")
|
||||
if order_ix is None:
|
||||
order_ix = si
|
||||
title_s = slot.get("title")
|
||||
if title_s is not None:
|
||||
title_s = title_s.strip() or None
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_framework_slots (
|
||||
framework_program_id, sort_order, title, notes, training_unit_id
|
||||
) VALUES (%s, %s, %s, %s, NULL)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
framework_id,
|
||||
int(order_ix),
|
||||
title_s,
|
||||
slot.get("notes"),
|
||||
),
|
||||
)
|
||||
sid = cur.fetchone()["id"]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date,
|
||||
planned_time_start, planned_time_end, planned_focus,
|
||||
status, notes, trainer_notes,
|
||||
created_by, plan_template_id, framework_slot_id
|
||||
) VALUES (
|
||||
NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
'planned', NULL, NULL,
|
||||
%s, NULL, %s
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
(profile_id, sid),
|
||||
)
|
||||
bid = cur.fetchone()["id"]
|
||||
|
||||
sections_in = slot.get("sections")
|
||||
exercises_in = slot.get("exercises")
|
||||
|
||||
if sections_in is not None:
|
||||
if len(sections_in) == 0:
|
||||
_insert_default_blueprint_section(cur, bid)
|
||||
else:
|
||||
_replace_unit_sections(cur, bid, sections_in)
|
||||
elif exercises_in is not None and len(exercises_in) > 0:
|
||||
for raw in exercises_in:
|
||||
eid = raw.get("exercise_id")
|
||||
if not eid:
|
||||
continue
|
||||
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
|
||||
_validate_variant_for_exercise(cur, int(eid), vid)
|
||||
_insert_sections_from_legacy_exercises(cur, bid, exercises_in)
|
||||
else:
|
||||
_insert_default_blueprint_section(cur, bid)
|
||||
|
||||
|
||||
@router.get("/training-framework-programs")
|
||||
def list_training_framework_programs(session=Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
base_sel = """
|
||||
SELECT fp.*,
|
||||
fa.name AS focus_area_name,
|
||||
sd.name AS style_direction_name,
|
||||
(SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id)
|
||||
AS goals_count,
|
||||
(SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id)
|
||||
AS slots_count,
|
||||
(SELECT COUNT(*)::int FROM training_framework_program_training_types t
|
||||
WHERE t.framework_program_id = fp.id) AS training_types_count,
|
||||
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
|
||||
WHERE tg.framework_program_id = fp.id) AS target_groups_count,
|
||||
(
|
||||
SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name)
|
||||
FROM training_framework_program_training_types j
|
||||
JOIN training_types typ ON typ.id = j.training_type_id
|
||||
WHERE j.framework_program_id = fp.id
|
||||
) AS training_type_names_agg,
|
||||
(
|
||||
SELECT STRING_AGG(tg.name::text, ', ' ORDER BY tg.name)
|
||||
FROM training_framework_program_target_groups j
|
||||
JOIN target_groups tg ON tg.id = j.target_group_id
|
||||
WHERE j.framework_program_id = fp.id
|
||||
) AS target_group_names_agg
|
||||
FROM training_framework_programs fp
|
||||
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
|
||||
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
|
||||
"""
|
||||
if role in ("admin", "superadmin"):
|
||||
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
|
||||
else:
|
||||
cur.execute(
|
||||
base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title",
|
||||
(profile_id,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.get("/training-framework-programs/{framework_id}")
|
||||
def get_training_framework_program(framework_id: int, session=Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _framework_access(cur, framework_id, profile_id, role)
|
||||
return _hydrate_framework(cur, row)
|
||||
|
||||
|
||||
@router.post("/training-framework-programs")
|
||||
def create_training_framework_program(data: dict, session=Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
if not _has_planning_role(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme anlegen")
|
||||
|
||||
title = (data.get("title") or "").strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title ist Pflicht")
|
||||
|
||||
vis = data.get("visibility") or "private"
|
||||
vis = _assert_visibility(vis)
|
||||
club_id = data.get("club_id")
|
||||
goals_in = data.get("goals")
|
||||
slots_in = data.get("slots")
|
||||
if not isinstance(goals_in, list) or not goals_in:
|
||||
raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht")
|
||||
|
||||
fa_id = _optional_positive_int(data.get("focus_area_id"), "focus_area_id")
|
||||
sd_id = _optional_positive_int(data.get("style_direction_id"), "style_direction_id")
|
||||
tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids")
|
||||
tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_framework_programs (
|
||||
title, description,
|
||||
planned_period_start, planned_period_end,
|
||||
visibility, club_id, created_by,
|
||||
focus_area_id, style_direction_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
title[:200],
|
||||
data.get("description"),
|
||||
data.get("planned_period_start"),
|
||||
data.get("planned_period_end"),
|
||||
vis,
|
||||
club_id,
|
||||
profile_id,
|
||||
fa_id,
|
||||
sd_id,
|
||||
),
|
||||
)
|
||||
fid = cur.fetchone()["id"]
|
||||
_insert_goal_rows(cur, fid, goals_in)
|
||||
_insert_slots_and_blueprints(cur, fid, slots_in, profile_id)
|
||||
_replace_training_types(cur, fid, tt_ids)
|
||||
_replace_target_groups(cur, fid, tg_ids)
|
||||
conn.commit()
|
||||
|
||||
return get_training_framework_program(fid, session)
|
||||
|
||||
|
||||
@router.put("/training-framework-programs/{framework_id}")
|
||||
def update_training_framework_program(framework_id: int, data: dict, session=Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
if not _has_planning_role(role):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_framework_access(cur, framework_id, profile_id, role)
|
||||
|
||||
header_fields = []
|
||||
header_params: List[Any] = []
|
||||
|
||||
if "title" in data:
|
||||
tit = (data.get("title") or "").strip()
|
||||
if not tit:
|
||||
raise HTTPException(status_code=400, detail="title ist Pflicht")
|
||||
header_fields.append("title = %s")
|
||||
header_params.append(tit[:200])
|
||||
|
||||
if "description" in data:
|
||||
header_fields.append("description = %s")
|
||||
header_params.append(data.get("description"))
|
||||
if "planned_period_start" in data:
|
||||
header_fields.append("planned_period_start = %s")
|
||||
header_params.append(data.get("planned_period_start"))
|
||||
if "planned_period_end" in data:
|
||||
header_fields.append("planned_period_end = %s")
|
||||
header_params.append(data.get("planned_period_end"))
|
||||
|
||||
if "visibility" in data:
|
||||
v = _assert_visibility(data.get("visibility"))
|
||||
if v is None:
|
||||
raise HTTPException(status_code=400, detail="visibility fehlt")
|
||||
header_fields.append("visibility = %s")
|
||||
header_params.append(v)
|
||||
if "club_id" in data:
|
||||
header_fields.append("club_id = %s")
|
||||
header_params.append(data.get("club_id"))
|
||||
|
||||
if "focus_area_id" in data:
|
||||
fidv = data.get("focus_area_id")
|
||||
header_fields.append("focus_area_id = %s")
|
||||
header_params.append(
|
||||
None if fidv in (None, "") else _optional_positive_int(fidv, "focus_area_id")
|
||||
)
|
||||
if "style_direction_id" in data:
|
||||
sidv = data.get("style_direction_id")
|
||||
header_fields.append("style_direction_id = %s")
|
||||
header_params.append(
|
||||
None if sidv in (None, "") else _optional_positive_int(sidv, "style_direction_id")
|
||||
)
|
||||
|
||||
if header_fields:
|
||||
header_fields.append("updated_at = NOW()")
|
||||
header_params.append(framework_id)
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE training_framework_programs
|
||||
SET {", ".join(header_fields)}
|
||||
WHERE id = %s
|
||||
""",
|
||||
tuple(header_params),
|
||||
)
|
||||
|
||||
if "training_type_ids" in data:
|
||||
tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids")
|
||||
_replace_training_types(cur, framework_id, tt_ids)
|
||||
|
||||
if "target_group_ids" in data:
|
||||
tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids")
|
||||
_replace_target_groups(cur, framework_id, tg_ids)
|
||||
|
||||
if "goals" in data:
|
||||
goals_in = data["goals"]
|
||||
if not isinstance(goals_in, list) or not goals_in:
|
||||
raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht")
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_goals WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
_insert_goal_rows(cur, framework_id, goals_in)
|
||||
|
||||
if "slots" in data:
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_slots WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
_insert_slots_and_blueprints(cur, framework_id, data.get("slots") or [], profile_id)
|
||||
|
||||
if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data:
|
||||
cur.execute(
|
||||
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return get_training_framework_program(framework_id, session)
|
||||
|
||||
|
||||
@router.delete("/training-framework-programs/{framework_id}")
|
||||
def delete_training_framework_program(framework_id: int, session=Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_framework_access(cur, framework_id, profile_id, role)
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_programs WHERE id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
|
@ -67,10 +67,14 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
|
|||
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id,
|
||||
tg.trainer_id, tg.co_trainer_ids
|
||||
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
|
||||
tu.lead_trainer_profile_id,
|
||||
tg.trainer_id, tg.co_trainer_ids,
|
||||
fwp.created_by AS framework_created_by
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
LEFT JOIN training_framework_slots fs ON fs.id = tu.framework_slot_id
|
||||
LEFT JOIN training_framework_programs fwp ON fwp.id = fs.framework_program_id
|
||||
WHERE tu.id = %s
|
||||
""",
|
||||
(unit_id,),
|
||||
|
|
@ -84,12 +88,23 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
|||
def _assert_training_unit_permission(
|
||||
cur, unit_row: Dict[str, Any], profile_id: int, role: str
|
||||
) -> None:
|
||||
if unit_row.get("framework_slot_id"):
|
||||
if role in ["admin", "superadmin"]:
|
||||
return
|
||||
if unit_row.get("created_by") == profile_id:
|
||||
return
|
||||
fw_by = unit_row.get("framework_created_by")
|
||||
if fw_by is not None and fw_by == profile_id:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
co_trainers = unit_row["co_trainer_ids"] or []
|
||||
if role not in ["admin", "superadmin"]:
|
||||
if (
|
||||
unit_row["created_by"] != profile_id
|
||||
and unit_row["trainer_id"] != profile_id
|
||||
and profile_id not in co_trainers
|
||||
and unit_row.get("lead_trainer_profile_id") != profile_id
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
|
|
@ -99,6 +114,82 @@ def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) ->
|
|||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
|
||||
def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
|
||||
"""Nicht-Admin: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer."""
|
||||
if role in ("admin", "superadmin"):
|
||||
return
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM training_groups g
|
||||
WHERE g.club_id = %s AND g.status = 'active'
|
||||
AND (
|
||||
g.trainer_id = %s
|
||||
OR (g.co_trainer_ids IS NOT NULL AND g.co_trainer_ids @> jsonb_build_array(%s::int))
|
||||
)
|
||||
LIMIT 1
|
||||
""",
|
||||
(club_id, profile_id, profile_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=403, detail="Kein Zugriff auf diesen Verein")
|
||||
|
||||
|
||||
def _normalize_lead_trainer_profile_id(
|
||||
cur,
|
||||
group_id: int,
|
||||
raw_lead: Any,
|
||||
profile_id: int,
|
||||
role: str,
|
||||
) -> Optional[int]:
|
||||
"""NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext."""
|
||||
if raw_lead is None:
|
||||
return None
|
||||
if raw_lead in ("", []):
|
||||
return None
|
||||
try:
|
||||
nid = int(raw_lead)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig")
|
||||
if nid < 1:
|
||||
raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig")
|
||||
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")
|
||||
if role in ("admin", "superadmin"):
|
||||
return nid
|
||||
if nid == profile_id:
|
||||
return nid
|
||||
cur.execute(
|
||||
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
|
||||
(group_id,),
|
||||
)
|
||||
gr = cur.fetchone()
|
||||
if not gr:
|
||||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||
eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set()
|
||||
for x in gr.get("co_trainer_ids") or []:
|
||||
eligible.add(x)
|
||||
if nid in eligible:
|
||||
return nid
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
|
||||
)
|
||||
|
||||
|
||||
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
|
||||
_ORIGIN_LINEAGE_JOIN = """
|
||||
LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id
|
||||
LEFT JOIN training_framework_programs origin_fp ON origin_fp.id = origin_slot.framework_program_id
|
||||
"""
|
||||
_ORIGIN_LINEAGE_FIELDS = """
|
||||
origin_fp.id AS origin_framework_program_id,
|
||||
origin_fp.title AS origin_framework_program_title,
|
||||
COALESCE(TRIM(origin_slot.title), '') AS origin_framework_slot_title,
|
||||
origin_slot.sort_order AS origin_framework_slot_sort_order
|
||||
"""
|
||||
|
||||
|
||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -138,6 +229,116 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
return secs
|
||||
|
||||
|
||||
def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
"""Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder)."""
|
||||
secs = _fetch_sections(cur, unit_id)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for sec in secs:
|
||||
items_clean: List[Dict[str, Any]] = []
|
||||
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
|
||||
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
||||
oix = it.get("order_index")
|
||||
if itype == "note":
|
||||
items_clean.append(
|
||||
{
|
||||
"item_type": "note",
|
||||
"order_index": oix,
|
||||
"note_body": it.get("note_body") or "",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if itype != "exercise" or not it.get("exercise_id"):
|
||||
continue
|
||||
items_clean.append(
|
||||
{
|
||||
"item_type": "exercise",
|
||||
"order_index": oix,
|
||||
"exercise_id": it["exercise_id"],
|
||||
"exercise_variant_id": it.get("exercise_variant_id"),
|
||||
"planned_duration_min": it.get("planned_duration_min"),
|
||||
"actual_duration_min": it.get("actual_duration_min"),
|
||||
"notes": it.get("notes"),
|
||||
"modifications": it.get("modifications"),
|
||||
}
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"title": sec.get("title"),
|
||||
"order_index": sec.get("order_index"),
|
||||
"guidance_notes": sec.get("guidance_notes"),
|
||||
"items": items_clean,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _copy_blueprint_into_scheduled_unit(
|
||||
cur,
|
||||
blueprint_unit_id: int,
|
||||
group_id: int,
|
||||
planned_date: str,
|
||||
profile_id: int,
|
||||
origin_framework_slot_id: Optional[int],
|
||||
) -> int:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id,
|
||||
planned_date,
|
||||
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,
|
||||
origin_framework_slot_id,
|
||||
framework_slot_id
|
||||
)
|
||||
SELECT
|
||||
%s,
|
||||
%s,
|
||||
planned_time_start,
|
||||
planned_time_end,
|
||||
planned_focus,
|
||||
NULL::DATE,
|
||||
NULL::TIME WITHOUT TIME ZONE,
|
||||
NULL::TIME WITHOUT TIME ZONE,
|
||||
NULL::INT,
|
||||
COALESCE(status, 'planned'),
|
||||
notes,
|
||||
trainer_notes,
|
||||
%s,
|
||||
NULL::INT,
|
||||
%s,
|
||||
NULL::INT
|
||||
FROM training_units
|
||||
WHERE id = %s
|
||||
AND framework_slot_id IS NOT NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
group_id,
|
||||
planned_date,
|
||||
profile_id,
|
||||
origin_framework_slot_id,
|
||||
blueprint_unit_id,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden")
|
||||
nu = row["id"]
|
||||
cloned = _sections_clone_payload(cur, blueprint_unit_id)
|
||||
_replace_unit_sections(cur, nu, cloned)
|
||||
return nu
|
||||
|
||||
|
||||
def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None:
|
||||
flat: List[Dict[str, Any]] = []
|
||||
for sec in sorted(unit.get("sections", []), key=lambda s: s.get("order_index", 0)):
|
||||
|
|
@ -497,39 +698,99 @@ def delete_training_plan_template(template_id: int, session=Depends(require_auth
|
|||
@router.get("/training-units")
|
||||
def list_training_units(
|
||||
group_id: Optional[int] = Query(default=None),
|
||||
club_id: Optional[int] = Query(default=None),
|
||||
start_date: Optional[str] = Query(default=None),
|
||||
end_date: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None),
|
||||
assigned_to_me: bool = Query(default=False),
|
||||
sort: str = Query(default="desc"),
|
||||
limit: Optional[int] = Query(default=None),
|
||||
session=Depends(require_auth),
|
||||
):
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
|
||||
gid = _optional_positive_int(group_id, "group_id") if group_id else None
|
||||
cid = _optional_positive_int(club_id, "club_id") if club_id else None
|
||||
if gid and cid:
|
||||
raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
if cid and role not in ["admin", "superadmin"]:
|
||||
_assert_club_visible_for_trainer(cur, cid, profile_id, role)
|
||||
|
||||
if gid and role not in ["admin", "superadmin"]:
|
||||
cur.execute(
|
||||
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'",
|
||||
(gid,),
|
||||
)
|
||||
gr = cur.fetchone()
|
||||
if not gr:
|
||||
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
||||
cob = gr["co_trainer_ids"] or []
|
||||
if gr["trainer_id"] != profile_id and profile_id not in cob:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
|
||||
|
||||
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
|
||||
lim: Optional[int] = None
|
||||
if limit is not None:
|
||||
try:
|
||||
lim = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="limit ungültig")
|
||||
if lim < 1:
|
||||
raise HTTPException(status_code=400, detail="limit ungültig")
|
||||
lim = min(lim, 250)
|
||||
|
||||
query = """
|
||||
SELECT tu.*,
|
||||
tg.name as group_name,
|
||||
tg.weekday as group_weekday,
|
||||
tg.club_id AS group_club_id,
|
||||
c.name as club_name,
|
||||
p.name as trainer_name
|
||||
p.name as trainer_name,
|
||||
p.name as creator_name,
|
||||
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
|
||||
leadp.name AS lead_trainer_name
|
||||
"""
|
||||
query += "," + _ORIGIN_LINEAGE_FIELDS
|
||||
query += """
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
LEFT JOIN clubs c ON tg.club_id = c.id
|
||||
LEFT JOIN profiles p ON tu.created_by = p.id
|
||||
LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id)
|
||||
"""
|
||||
query += _ORIGIN_LINEAGE_JOIN
|
||||
|
||||
where = []
|
||||
params = []
|
||||
|
||||
if role not in ["admin", "superadmin"]:
|
||||
where.append("(tu.created_by = %s OR tg.trainer_id = %s)")
|
||||
params.extend([profile_id, profile_id])
|
||||
where.append(
|
||||
"(tu.created_by = %s OR tg.trainer_id = %s OR "
|
||||
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
|
||||
)
|
||||
params.extend([profile_id, profile_id, profile_id])
|
||||
|
||||
if group_id:
|
||||
where.append("tu.framework_slot_id IS NULL")
|
||||
|
||||
if gid:
|
||||
where.append("tu.group_id = %s")
|
||||
params.append(group_id)
|
||||
params.append(gid)
|
||||
|
||||
if cid:
|
||||
where.append("tg.club_id = %s")
|
||||
params.append(cid)
|
||||
|
||||
if assigned_to_me:
|
||||
where.append(
|
||||
"(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
|
||||
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
|
||||
)
|
||||
params.extend([profile_id, profile_id])
|
||||
|
||||
if start_date:
|
||||
where.append("tu.planned_date >= %s")
|
||||
|
|
@ -546,7 +807,10 @@ def list_training_units(
|
|||
if where:
|
||||
query += " WHERE " + " AND ".join(where)
|
||||
|
||||
query += " ORDER BY tu.planned_date DESC, tu.planned_time_start DESC"
|
||||
query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST"
|
||||
if lim is not None:
|
||||
query += " LIMIT %s"
|
||||
params.append(lim)
|
||||
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
|
|
@ -570,11 +834,19 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
|
|||
tg.time_end as group_time_end,
|
||||
tg.location as group_location,
|
||||
c.name as club_name,
|
||||
p.name as trainer_name
|
||||
p.name as trainer_name,
|
||||
p.name as creator_name,
|
||||
tg.trainer_id AS trainer_id,
|
||||
tg.co_trainer_ids AS co_trainer_ids,
|
||||
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
|
||||
leadp.name AS lead_trainer_name,
|
||||
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
LEFT JOIN clubs c ON tg.club_id = c.id
|
||||
LEFT JOIN profiles p ON tu.created_by = p.id
|
||||
LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id)
|
||||
""" + _ORIGIN_LINEAGE_JOIN.strip() + """
|
||||
WHERE tu.id = %s
|
||||
""",
|
||||
(unit_id,),
|
||||
|
|
@ -586,12 +858,24 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
|
|||
|
||||
unit = r2d(unit)
|
||||
|
||||
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit["group_id"],))
|
||||
group = cur.fetchone()
|
||||
|
||||
if role not in ["admin", "superadmin"]:
|
||||
if unit["created_by"] != profile_id and (not group or group["trainer_id"] != profile_id):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
if unit.get("framework_slot_id"):
|
||||
if role not in ["admin", "superadmin"]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT fp.created_by FROM training_framework_slots s
|
||||
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||||
WHERE s.id = %s
|
||||
""",
|
||||
(unit["framework_slot_id"],),
|
||||
)
|
||||
fr = cur.fetchone()
|
||||
cb = fr["created_by"] if fr else None
|
||||
if unit["created_by"] != profile_id and cb != profile_id:
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
else:
|
||||
if not unit.get("group_id"):
|
||||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||||
_assert_training_unit_permission(cur, unit, profile_id, role)
|
||||
|
||||
_hydrate_training_unit_payload(cur, unit)
|
||||
return unit
|
||||
|
|
@ -671,6 +955,8 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
|
|||
unit_row = _training_unit_guard_row(cur, unit_id)
|
||||
_assert_training_unit_permission(cur, unit_row, profile_id, role)
|
||||
|
||||
is_blueprint = unit_row.get("framework_slot_id") is not None
|
||||
|
||||
tpl_upd = data.get("plan_template_id") if "plan_template_id" in data else None
|
||||
tpl_id_val = None
|
||||
if tpl_upd not in (None, ""):
|
||||
|
|
@ -690,43 +976,96 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
|
|||
else:
|
||||
trainer_notes_val = data.get("trainer_notes")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE training_units SET
|
||||
planned_date = COALESCE(%s, planned_date),
|
||||
planned_time_start = %s,
|
||||
planned_time_end = %s,
|
||||
planned_focus = %s,
|
||||
actual_date = %s,
|
||||
actual_time_start = %s,
|
||||
actual_time_end = %s,
|
||||
attendance_count = %s,
|
||||
status = %s,
|
||||
notes = %s,
|
||||
trainer_notes = %s,
|
||||
plan_template_id = COALESCE(%s, plan_template_id),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
data.get("planned_date"),
|
||||
data.get("planned_time_start"),
|
||||
data.get("planned_time_end"),
|
||||
data.get("planned_focus"),
|
||||
data.get("actual_date"),
|
||||
data.get("actual_time_start"),
|
||||
data.get("actual_time_end"),
|
||||
data.get("attendance_count"),
|
||||
data.get("status"),
|
||||
data.get("notes"),
|
||||
trainer_notes_val,
|
||||
tpl_id_val,
|
||||
unit_id,
|
||||
),
|
||||
)
|
||||
if is_blueprint:
|
||||
if data.get("reset_from_template"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Rahmen-Blueprints können nicht aus einer Vorlage zurückgesetzt werden",
|
||||
)
|
||||
if tpl_upd not in (None, ""):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="plan_template_id ist bei Rahmen-Blueprints nicht zulässig",
|
||||
)
|
||||
blueprint_fields = []
|
||||
blueprint_params: List[Any] = []
|
||||
if "planned_focus" in data:
|
||||
blueprint_fields.append("planned_focus = %s")
|
||||
blueprint_params.append(data.get("planned_focus"))
|
||||
if "planned_time_start" in data:
|
||||
blueprint_fields.append("planned_time_start = %s")
|
||||
blueprint_params.append(data.get("planned_time_start"))
|
||||
if "planned_time_end" in data:
|
||||
blueprint_fields.append("planned_time_end = %s")
|
||||
blueprint_params.append(data.get("planned_time_end"))
|
||||
if "notes" in data:
|
||||
blueprint_fields.append("notes = %s")
|
||||
blueprint_params.append(data.get("notes"))
|
||||
blueprint_fields.append("trainer_notes = %s")
|
||||
blueprint_params.append(trainer_notes_val)
|
||||
blueprint_params.append(unit_id)
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE training_units SET
|
||||
{", ".join(blueprint_fields)},
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
tuple(blueprint_params),
|
||||
)
|
||||
else:
|
||||
lead_sql = ""
|
||||
lead_params: List[Any] = []
|
||||
if "lead_trainer_profile_id" in data:
|
||||
nl = _normalize_lead_trainer_profile_id(
|
||||
cur,
|
||||
unit_row["group_id"],
|
||||
data.get("lead_trainer_profile_id"),
|
||||
profile_id,
|
||||
role,
|
||||
)
|
||||
lead_sql = ", lead_trainer_profile_id = %s"
|
||||
lead_params.append(nl)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE training_units SET
|
||||
planned_date = COALESCE(%s, planned_date),
|
||||
planned_time_start = %s,
|
||||
planned_time_end = %s,
|
||||
planned_focus = %s,
|
||||
actual_date = %s,
|
||||
actual_time_start = %s,
|
||||
actual_time_end = %s,
|
||||
attendance_count = %s,
|
||||
status = %s,
|
||||
notes = %s,
|
||||
trainer_notes = %s,
|
||||
plan_template_id = COALESCE(%s, plan_template_id),
|
||||
updated_at = NOW()
|
||||
{lead_sql}
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
data.get("planned_date"),
|
||||
data.get("planned_time_start"),
|
||||
data.get("planned_time_end"),
|
||||
data.get("planned_focus"),
|
||||
data.get("actual_date"),
|
||||
data.get("actual_time_start"),
|
||||
data.get("actual_time_end"),
|
||||
data.get("attendance_count"),
|
||||
data.get("status"),
|
||||
data.get("notes"),
|
||||
trainer_notes_val,
|
||||
tpl_id_val,
|
||||
)
|
||||
+ tuple(lead_params)
|
||||
+ (unit_id,),
|
||||
)
|
||||
|
||||
content_handled = False
|
||||
if data.get("reset_from_template"):
|
||||
if not is_blueprint and data.get("reset_from_template"):
|
||||
tid = tpl_id_val or unit_row.get("plan_template_id")
|
||||
if not tid:
|
||||
raise HTTPException(
|
||||
|
|
@ -761,7 +1100,7 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
|
|||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"SELECT created_by FROM training_units WHERE id = %s",
|
||||
"SELECT created_by, framework_slot_id FROM training_units WHERE id = %s",
|
||||
(unit_id,),
|
||||
)
|
||||
|
||||
|
|
@ -770,6 +1109,12 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
|
|||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||||
|
||||
if unit.get("framework_slot_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
|
||||
)
|
||||
|
||||
_assert_delete_training_unit(role, unit["created_by"], profile_id)
|
||||
|
||||
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
|
||||
|
|
@ -778,6 +1123,74 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/training-units/from-framework-slot")
|
||||
def create_training_unit_from_framework_slot(data: dict, session=Depends(require_auth)):
|
||||
"""Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id)."""
|
||||
profile_id = session["profile_id"]
|
||||
role = session.get("role")
|
||||
|
||||
if not _has_planning_role(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen")
|
||||
|
||||
raw_sid = data.get("framework_slot_id")
|
||||
try:
|
||||
slot_id = int(raw_sid)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig")
|
||||
if slot_id < 1:
|
||||
raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig")
|
||||
|
||||
group_id = data.get("group_id")
|
||||
planned_date = data.get("planned_date")
|
||||
if not group_id or not planned_date:
|
||||
raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT fp.created_by FROM training_framework_slots s
|
||||
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||||
WHERE s.id = %s
|
||||
""",
|
||||
(slot_id,),
|
||||
)
|
||||
fw_row = cur.fetchone()
|
||||
if not fw_row:
|
||||
raise HTTPException(status_code=404, detail="Rahmen-Slot nicht gefunden")
|
||||
|
||||
if role not in ["admin", "superadmin"]:
|
||||
if fw_row["created_by"] is not None and fw_row["created_by"] != profile_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Keine Berechtigung für dieses Rahmenprogramm",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"SELECT id FROM training_units WHERE framework_slot_id = %s",
|
||||
(slot_id,),
|
||||
)
|
||||
blueprint = cur.fetchone()
|
||||
if not blueprint:
|
||||
raise HTTPException(status_code=404, detail="Keine Blueprint-Einheit für diesen Slot")
|
||||
|
||||
_can_access_group_for_create(cur, int(group_id), profile_id, role)
|
||||
|
||||
new_id = _copy_blueprint_into_scheduled_unit(
|
||||
cur,
|
||||
int(blueprint["id"]),
|
||||
int(group_id),
|
||||
str(planned_date),
|
||||
profile_id,
|
||||
slot_id,
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
return get_training_unit(new_id, session)
|
||||
|
||||
|
||||
@router.post("/training-units/quick-create")
|
||||
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
|
||||
profile_id = session["profile_id"]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.4"
|
||||
BUILD_DATE = "2026-04-27"
|
||||
DB_SCHEMA_VERSION = "20260428031"
|
||||
APP_VERSION = "0.8.11"
|
||||
BUILD_DATE = "2026-05-05"
|
||||
DB_SCHEMA_VERSION = "20260505038"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.0.0",
|
||||
|
|
@ -11,10 +11,10 @@ MODULE_VERSIONS = {
|
|||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.1.0", # Varianten-CRUD API + UI; Listen mit include_variants
|
||||
"exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
|
||||
"training_units": "0.1.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.3.0",
|
||||
"planning": "0.6.0",
|
||||
"import_wiki": "1.0.0",
|
||||
"admin": "1.0.0",
|
||||
"membership": "1.0.0",
|
||||
|
|
@ -23,6 +23,66 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.11",
|
||||
"date": "2026-05-05",
|
||||
"changes": [
|
||||
"DB 038: training_units.lead_trainer_profile_id (Vertretung / Leitung pro Termin)",
|
||||
"API GET /api/training-units: club_id, assigned_to_me, sort, limit; Co-Trainer in Sichtbarkeit; lead_trainer_name / effective_lead_trainer_profile_id",
|
||||
"API PUT /api/training-units/{id}: lead_trainer_profile_id (Validierung über Gruppe)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.10",
|
||||
"date": "2026-05-05",
|
||||
"changes": [
|
||||
"DB 037: Rahmen-Slot-Blueprints als training_units (framework_slot_id); migration training_framework_slot_exercises → Sektionen/Items; origin_framework_slot_id für Kopien",
|
||||
"API: Rahmen-Slots mit sections/exercises aus Blueprint; Kalender list_training_units ohne Blueprints; POST /api/training-units/from-framework-slot",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.9",
|
||||
"date": "2026-05-05",
|
||||
"changes": [
|
||||
"DB 036: Rahmenprogramm Kontext (Fokusbereich, Stilrichtung, M:N Trainingsarten & Zielgruppen); nur Bibliothek — plan_mode/group_id/Slot-training_unit entfernt.",
|
||||
"API: /api/training-framework-programs ohne concrete/library; Payload focus_area_id, style_direction_id, training_type_ids, target_group_ids",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.8",
|
||||
"date": "2026-05-05",
|
||||
"changes": [
|
||||
"DB 035: Trainingsrahmenprogramm (Rahmen, Ziele, Slots, Slot-Übungen); plan_mode concrete|library",
|
||||
"DB 035: training_plan_templates.visibility + Backfill club (CURR-007/008)",
|
||||
"API: CRUD /api/training-framework-programs (AuthZ wie Übungs-/Planungsbibliothek)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.7",
|
||||
"date": "2026-04-30",
|
||||
"changes": [
|
||||
"DB 034: Progressionskanten mit optionalen Varianten-Endpunkten",
|
||||
"API: POST …/edges/sequence (Reihe auf einmal); POST …/edges/delete-batch",
|
||||
"Frontend Progressions-UI: Sequenz-Editor, Ketten-Ansicht, Variantenwahl",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.6",
|
||||
"date": "2026-04-30",
|
||||
"changes": [
|
||||
"DB 033: exercise_progression_edges.notes (Entwicklungsziel)",
|
||||
"API: Kanten mit notes; JOIN Übungstitel in Listen; PUT Kanten-Notiz",
|
||||
"Frontend: Progressionsgraphen-Tab unter Übungen + Bereich in Übung bearbeiten",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.5",
|
||||
"date": "2026-04-30",
|
||||
"changes": [
|
||||
"DB 032: exercise_progression_graphs + exercise_progression_edges (Übung→Übung, edge_type next_exercise)",
|
||||
"API: CRUD Progressionsgraphen und Kanten unter /api/exercise-progression-graphs",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.4",
|
||||
"date": "2026-04-27",
|
||||
|
|
|
|||
|
|
@ -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`**.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,16 +5,21 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="description" content="Shinkan Jinkendo - Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<!-- PWA / iOS Web App -->
|
||||
<meta name="theme-color" content="#1D9E75" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#181816" media="(prefers-color-scheme: dark)">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<!-- black-translucent: Inhalt bis unter die Statusleiste; passt zu viewport-fit=cover -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Shinkan">
|
||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Icons -->
|
||||
<!-- Icons (Dateien in public/) -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png">
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icon-512.png">
|
||||
|
||||
<title>Shinkan Jinkendo</title>
|
||||
</head>
|
||||
|
|
|
|||
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 611 B |
BIN
frontend/public/favicon-32.png
Normal file
BIN
frontend/public/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 B |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 642 B |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
25
frontend/public/manifest.webmanifest
Normal file
25
frontend/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "Shinkan Jinkendo",
|
||||
"short_name": "Shinkan",
|
||||
"description": "Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f4f3ef",
|
||||
"theme_color": "#1D9E75",
|
||||
"lang": "de",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ import ExerciseFormPage from './pages/ExerciseFormPage'
|
|||
import ClubsPage from './pages/ClubsPage'
|
||||
import SkillsPage from './pages/SkillsPage'
|
||||
import TrainingPlanningPage from './pages/TrainingPlanningPage'
|
||||
import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage'
|
||||
import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage'
|
||||
import TrainingUnitRunPage from './pages/TrainingUnitRunPage'
|
||||
import TrainingCoachPage from './pages/TrainingCoachPage'
|
||||
import AdminCatalogsPage from './pages/AdminCatalogsPage'
|
||||
|
|
@ -157,9 +159,12 @@ function AppRoutes() {
|
|||
</Route>
|
||||
<Route path="clubs" element={<ClubsPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="planning" element={<TrainingPlanningPage />} />
|
||||
<Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} />
|
||||
<Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} />
|
||||
<Route path="planning/framework-programs" element={<TrainingFrameworkProgramsListPage />} />
|
||||
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
|
||||
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
|
||||
<Route path="planning" element={<TrainingPlanningPage />} />
|
||||
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
|
||||
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
|
||||
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
|
||||
|
|
|
|||
1106
frontend/src/app.css
1106
frontend/src/app.css
File diff suppressed because it is too large
Load Diff
|
|
@ -31,11 +31,22 @@ function TagMini({ exercise }) {
|
|||
)
|
||||
}
|
||||
|
||||
export default function ExercisePeekModal({ open, exerciseId, onClose, titleFallback }) {
|
||||
export default function ExercisePeekModal({
|
||||
open,
|
||||
exerciseId,
|
||||
variantId,
|
||||
onClose,
|
||||
titleFallback,
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
const [exercise, setExercise] = useState(null)
|
||||
|
||||
const variant =
|
||||
variantId != null && variantId !== '' && exercise?.variants?.length
|
||||
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setExercise(null)
|
||||
|
|
@ -62,7 +73,7 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, exerciseId])
|
||||
}, [open, exerciseId, variantId])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
|
|
@ -100,6 +111,37 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall
|
|||
{!loading && err && <p style={{ color: 'var(--danger)' }}>{err}</p>}
|
||||
{!loading && exercise && (
|
||||
<>
|
||||
{variant ? (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '0.75rem',
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text3)', marginBottom: 4 }}>
|
||||
Variante
|
||||
</div>
|
||||
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>
|
||||
{variant.variant_name || `Variante #${variant.id}`}
|
||||
</div>
|
||||
{variant.description ? (
|
||||
<div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text2)' }}>
|
||||
<HtmlBlock html={variant.description} />
|
||||
</div>
|
||||
) : null}
|
||||
{variant.execution_changes ? (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Durchführung (Variante)
|
||||
</h4>
|
||||
<HtmlBlock html={variant.execution_changes} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{exercise.summary && (
|
||||
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
|
||||
<HtmlBlock html={exercise.summary} />
|
||||
|
|
|
|||
|
|
@ -22,7 +22,13 @@ const INITIAL_FILTERS = {
|
|||
status_any: [],
|
||||
}
|
||||
|
||||
export default function ExercisePickerModal({ open, onClose, onSelectExercise }) {
|
||||
export default function ExercisePickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelectExercise,
|
||||
multiSelect = false,
|
||||
onSelectExercises = null,
|
||||
}) {
|
||||
const [catalogs, setCatalogs] = useState({
|
||||
focusAreas: [],
|
||||
styleDirections: [],
|
||||
|
|
@ -42,6 +48,13 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
|
|||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [multiPicked, setMultiPicked] = useState([])
|
||||
|
||||
const toggleMultiPick = (ex) => {
|
||||
setMultiPicked((prev) =>
|
||||
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
|
||||
|
|
@ -96,6 +109,7 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
|
|||
setList([])
|
||||
setOffset(0)
|
||||
setHasMore(false)
|
||||
setMultiPicked([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
|
@ -230,7 +244,9 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h3 className="admin-modal-sheet__title">Übung auswählen</h3>
|
||||
<h3 className="admin-modal-sheet__title">
|
||||
{multiSelect ? 'Übungen auswählen' : 'Übung auswählen'}
|
||||
</h3>
|
||||
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
|
|
@ -391,29 +407,16 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
|
|||
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{list.map((ex) => (
|
||||
<li key={ex.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectExercise(ex)
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{list.map((ex) => {
|
||||
const picked = multiPicked.some((p) => p.id === ex.id)
|
||||
const rowInner = (
|
||||
<>
|
||||
<strong style={{ display: 'block' }}>{ex.title}</strong>
|
||||
{(ex.summary || '').trim().length > 0 && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
||||
{(ex.summary || '').length > 120 ? `${(ex.summary || '').slice(0, 120)}…` : ex.summary}
|
||||
{(ex.summary || '').length > 120
|
||||
? `${(ex.summary || '').slice(0, 120)}…`
|
||||
: ex.summary}
|
||||
</span>
|
||||
)}
|
||||
{ex.focus_area && (
|
||||
|
|
@ -421,9 +424,64 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
|
|||
{ex.focus_area}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
if (multiSelect) {
|
||||
return (
|
||||
<li key={ex.id}>
|
||||
<label
|
||||
className="tu-ex-picker-multi-row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: '8px',
|
||||
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked}
|
||||
onChange={() => toggleMultiPick(ex)}
|
||||
style={{ marginTop: '0.35rem', flexShrink: 0 }}
|
||||
aria-label={ex.title ? `Auswahl: ${ex.title}` : 'Auswahl'}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<li key={ex.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectExercise(ex)
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{rowInner}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{hasMore && (
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
|
|
@ -432,6 +490,49 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{multiSelect && typeof onSelectExercises === 'function' ? (
|
||||
<div
|
||||
className="exercise-picker-multi-footer"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
marginTop: 16,
|
||||
paddingTop: 12,
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.92rem', color: 'var(--text2)' }}>
|
||||
{multiPicked.length} ausgewählt
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setMultiPicked([])}
|
||||
disabled={!multiPicked.length}
|
||||
>
|
||||
Auswahl leeren
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!multiPicked.length}
|
||||
onClick={() => {
|
||||
onSelectExercises([...multiPicked])
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
1053
frontend/src/components/ExerciseProgressionGraphPanel.jsx
Normal file
1053
frontend/src/components/ExerciseProgressionGraphPanel.jsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -23,8 +23,9 @@ function Navigation() {
|
|||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
maxWidth: 'none',
|
||||
margin: '0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
|
|
|||
910
frontend/src/components/TrainingUnitSectionsEditor.jsx
Normal file
910
frontend/src/components/TrainingUnitSectionsEditor.jsx
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
import React, { Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { GripVertical, Pencil } from 'lucide-react'
|
||||
import {
|
||||
defaultSection,
|
||||
exerciseRow,
|
||||
noteRow,
|
||||
sectionPlannedMinutes,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
|
||||
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
|
||||
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
||||
|
||||
function dtHasType(e, mime) {
|
||||
const t = e?.dataTransfer?.types
|
||||
if (!t || !mime) return false
|
||||
if (typeof t.contains === 'function' && t.contains(mime)) return true
|
||||
return Array.from(t).includes(mime)
|
||||
}
|
||||
|
||||
function truncatePreview(text, max = 160) {
|
||||
const t = (text || '').replace(/\s+/g, ' ').trim()
|
||||
if (t.length <= max) return t
|
||||
return `${t.slice(0, max - 1)}…`
|
||||
}
|
||||
|
||||
function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
|
||||
const b = [...blocks]
|
||||
if (fromI < 0 || fromI >= b.length) return blocks
|
||||
const [moved] = b.splice(fromI, 1)
|
||||
let insertAt = toBeforeIdx
|
||||
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
|
||||
insertAt = Math.max(0, Math.min(insertAt, b.length))
|
||||
b.splice(insertAt, 0, moved)
|
||||
return b
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
|
||||
* @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben
|
||||
*/
|
||||
export default function TrainingUnitSectionsEditor({
|
||||
sections,
|
||||
onSectionsChange,
|
||||
onRequestExercisePick,
|
||||
onPeekExercise,
|
||||
showExecutionExtras = false,
|
||||
heading = 'Abschnitte & Übungen',
|
||||
hideHeading = false,
|
||||
headingAccessory = null,
|
||||
wideExerciseGrid = false,
|
||||
enableItemDragReorder = true,
|
||||
enableSectionDragReorder = true,
|
||||
slotIndex = null,
|
||||
onMoveSectionsAcrossSlots = null,
|
||||
}) {
|
||||
const ensure = (prev) =>
|
||||
prev && prev.length ? prev : [defaultSection()]
|
||||
|
||||
const patch = useCallback(
|
||||
(updater) => {
|
||||
onSectionsChange((prev) => updater(ensure(prev)))
|
||||
},
|
||||
[onSectionsChange]
|
||||
)
|
||||
|
||||
const sectionToSlot =
|
||||
slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1
|
||||
|
||||
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 [textEdit, setTextEdit] = useState(null)
|
||||
const [draggingPos, setDraggingPos] = useState(null)
|
||||
const [dropTargetPos, setDropTargetPos] = useState(null)
|
||||
|
||||
const [dropSectionBand, setDropSectionBand] = useState(null)
|
||||
/** { slot: number, beforeIdx: number } */
|
||||
|
||||
useEffect(() => {
|
||||
if (!textEdit) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') setTextEdit(null)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [textEdit])
|
||||
|
||||
const clearSectionDnD = () => setDropSectionBand(null)
|
||||
|
||||
const onSectionDragStart = (e, sIdx) => {
|
||||
if (!enableSectionDragReorder) return
|
||||
e.stopPropagation()
|
||||
try {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(
|
||||
DND_TU_SECTION,
|
||||
JSON.stringify({
|
||||
fromSlot: sectionToSlot,
|
||||
fromSectionIdx: sIdx,
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDropSectionBand(null)
|
||||
}
|
||||
|
||||
const onSectionBandDragOver = (e, beforeIdx) => {
|
||||
if (!enableSectionDragReorder) return
|
||||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDropSectionBand({ slot: sectionToSlot, beforeIdx })
|
||||
}
|
||||
|
||||
const onSectionBandDrop = (e, insertBeforeIdx) => {
|
||||
if (!enableSectionDragReorder) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
clearSectionDnD()
|
||||
let raw = ''
|
||||
try {
|
||||
raw = e.dataTransfer.getData(DND_TU_SECTION)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!raw) return
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const fromSi = data.fromSectionIdx
|
||||
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
|
||||
if (typeof fromSi !== 'number') return
|
||||
|
||||
if (
|
||||
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||||
sectionToSlot >= 0 &&
|
||||
fromSlot >= 0
|
||||
) {
|
||||
onMoveSectionsAcrossSlots({
|
||||
fromSlot,
|
||||
fromSectionIdx: fromSi,
|
||||
toSlot: sectionToSlot,
|
||||
toSectionIdx: insertBeforeIdx,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx))
|
||||
}
|
||||
|
||||
const onItemDragStart = (e, sIdx, iIdx) => {
|
||||
if (!enableItemDragReorder) return
|
||||
e.stopPropagation()
|
||||
try {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(
|
||||
DND_TU_ITEM,
|
||||
JSON.stringify({ sectionIndex: sIdx, itemIndex: iIdx })
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDraggingPos({ sIdx, iIdx })
|
||||
}
|
||||
|
||||
const clearDragChrome = () => {
|
||||
setDraggingPos(null)
|
||||
setDropTargetPos(null)
|
||||
}
|
||||
|
||||
const onItemDragOverRow = (e, sIdx, iIdx) => {
|
||||
if (!enableItemDragReorder) return
|
||||
if (!dtHasType(e, DND_TU_ITEM)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDropTargetPos({ sIdx, iIdx })
|
||||
}
|
||||
|
||||
const onItemDropRow = (e, toSIdx, toIdx) => {
|
||||
if (!enableItemDragReorder) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
let raw = ''
|
||||
try {
|
||||
raw = e.dataTransfer.getData(DND_TU_ITEM)
|
||||
} catch {
|
||||
clearDragChrome()
|
||||
return
|
||||
}
|
||||
if (!raw) {
|
||||
clearDragChrome()
|
||||
return
|
||||
}
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
clearDragChrome()
|
||||
return
|
||||
}
|
||||
const fromS = data.sectionIndex
|
||||
const fromI = data.itemIndex
|
||||
if (typeof fromS !== 'number' || typeof fromI !== 'number') {
|
||||
clearDragChrome()
|
||||
return
|
||||
}
|
||||
if (fromS === toSIdx && fromI === toIdx) {
|
||||
clearDragChrome()
|
||||
return
|
||||
}
|
||||
|
||||
patch((prev) => {
|
||||
const list = ensure(prev)
|
||||
if (
|
||||
fromS < 0 ||
|
||||
fromS >= list.length ||
|
||||
toSIdx < 0 ||
|
||||
toSIdx >= list.length ||
|
||||
typeof toIdx !== 'number'
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const fromItems = [...(list[fromS].items || [])]
|
||||
if (fromI < 0 || fromI >= fromItems.length) return prev
|
||||
|
||||
const moved = fromItems[fromI]
|
||||
fromItems.splice(fromI, 1)
|
||||
|
||||
if (fromS === toSIdx) {
|
||||
let insertAt = toIdx
|
||||
if (fromI < toIdx) insertAt = toIdx - 1
|
||||
const bounded = Math.max(0, Math.min(insertAt, fromItems.length))
|
||||
fromItems.splice(bounded, 0, moved)
|
||||
return list.map((sec, i) => (i === fromS ? { ...sec, items: fromItems } : sec))
|
||||
}
|
||||
|
||||
const toItems = [...(list[toSIdx].items || [])]
|
||||
const insertAt = Math.max(0, Math.min(toIdx, toItems.length))
|
||||
toItems.splice(insertAt, 0, moved)
|
||||
return list.map((sec, i) => {
|
||||
if (i === fromS) return { ...sec, items: fromItems }
|
||||
if (i === toSIdx) return { ...sec, items: toItems }
|
||||
return sec
|
||||
})
|
||||
})
|
||||
clearDragChrome()
|
||||
}
|
||||
|
||||
const applyTextEdit = () => {
|
||||
if (!textEdit) return
|
||||
const { kind, sIdx, iIdx, draft } = textEdit
|
||||
if (kind === 'zwischen-note') {
|
||||
updateItem(sIdx, iIdx, 'note_body', draft)
|
||||
} else if (kind === 'exercise-notes') {
|
||||
updateItem(sIdx, iIdx, 'notes', draft)
|
||||
}
|
||||
setTextEdit(null)
|
||||
}
|
||||
|
||||
const list = ensure(sections)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'training-unit-sections-editor' +
|
||||
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '')
|
||||
}
|
||||
>
|
||||
{(!hideHeading || headingAccessory) ? (
|
||||
<div
|
||||
className="tu-editor-heading-toolbar"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{!hideHeading ? (
|
||||
<h3 style={{ margin: 0, fontSize: '1rem', flex: '1 1 200px', minWidth: 0 }}>
|
||||
{heading}
|
||||
</h3>
|
||||
) : headingAccessory ? (
|
||||
<span style={{ flex: '1 1 auto', minWidth: 0 }} />
|
||||
) : null}
|
||||
{headingAccessory ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{headingAccessory}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{list.map((sec, sIdx) => {
|
||||
const planMin = sectionPlannedMinutes(sec)
|
||||
const itemCount = sec.items?.length ?? 0
|
||||
const bandActiveBefore = (bx) =>
|
||||
enableSectionDragReorder &&
|
||||
dropSectionBand &&
|
||||
dropSectionBand.slot === sectionToSlot &&
|
||||
dropSectionBand.beforeIdx === bx
|
||||
|
||||
return (
|
||||
<Fragment key={`secFrag-${sIdx}`}>
|
||||
{enableSectionDragReorder ? (
|
||||
<div
|
||||
className={'tu-section-dropband' + (bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')}
|
||||
title="Abschnitt hier einfügen"
|
||||
onDragOver={(e) => {
|
||||
if (!enableSectionDragReorder) return
|
||||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||||
onSectionBandDragOver(e, sIdx)
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||||
clearSectionDnD()
|
||||
}}
|
||||
onDrop={(e) => onSectionBandDrop(e, sIdx)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="tu-section-shell"
|
||||
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',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{enableSectionDragReorder ? (
|
||||
<span
|
||||
className="tu-sec-drag-grip"
|
||||
draggable
|
||||
onDragStart={(e) => onSectionDragStart(e, sIdx)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Abschnitt ziehen"
|
||||
title="Abschnitt ziehen"
|
||||
>
|
||||
<GripVertical size={16} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
<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) => {
|
||||
const dropHere =
|
||||
enableItemDragReorder &&
|
||||
dropTargetPos?.sIdx === sIdx &&
|
||||
dropTargetPos?.iIdx === iIdx
|
||||
const dragHere =
|
||||
enableItemDragReorder &&
|
||||
draggingPos?.sIdx === sIdx &&
|
||||
draggingPos?.iIdx === iIdx
|
||||
const rowCommon =
|
||||
'tu-item-row' +
|
||||
(dropHere ? ' tu-item-row--drop-target' : '') +
|
||||
(dragHere ? ' tu-item-row--dragging' : '')
|
||||
|
||||
const dndRowProps = enableItemDragReorder
|
||||
? {
|
||||
onDragOverCapture: (ev) => onItemDragOverRow(ev, sIdx, iIdx),
|
||||
onDrop: (ev) => onItemDropRow(ev, sIdx, iIdx),
|
||||
}
|
||||
: {}
|
||||
|
||||
if (it.item_type === 'note') {
|
||||
const notePv = truncatePreview(it.note_body || '', 260)
|
||||
const noteHasText = Boolean((it.note_body || '').trim())
|
||||
return (
|
||||
<div
|
||||
key={`note-${sIdx}-${iIdx}`}
|
||||
className={`${rowCommon} tu-item-row--note`}
|
||||
{...dndRowProps}
|
||||
>
|
||||
{enableItemDragReorder ? (
|
||||
<span
|
||||
className="tu-row-grip"
|
||||
draggable
|
||||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||||
onDragEnd={clearDragChrome}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Eintrag ziehen"
|
||||
>
|
||||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="tu-item-row__nudge">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Eintrag nach oben"
|
||||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||||
disabled={iIdx === 0}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Eintrag nach unten"
|
||||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||||
disabled={iIdx === sec.items.length - 1}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<div className="tu-item-row__body tu-item-row__body--note">
|
||||
<span className="tu-item-row__meta-label">Zwischen-Anmerkung</span>
|
||||
<p
|
||||
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||||
title={noteHasText ? (it.note_body || '').trim() : undefined}
|
||||
>
|
||||
{noteHasText ? notePv : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tu-icon-btn"
|
||||
title="Zwischen-Anmerkung bearbeiten"
|
||||
aria-label="Zwischen-Anmerkung bearbeiten"
|
||||
onClick={() =>
|
||||
setTextEdit({
|
||||
kind: 'zwischen-note',
|
||||
sIdx,
|
||||
iIdx,
|
||||
draft: it.note_body || '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tu-item-row__remove"
|
||||
title="Entfernen"
|
||||
aria-label="Zwischen-Anmerkung entfernen"
|
||||
onClick={() => removeItem(sIdx, iIdx)}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||||
const exTitle =
|
||||
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
||||
const annotPrev = truncatePreview(it.notes || '', 220)
|
||||
const annotHasText = Boolean((it.notes || '').trim())
|
||||
const hasVariants = variantOpts.length > 0 && it.exercise_id
|
||||
const variantIdPeek =
|
||||
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
||||
? undefined
|
||||
: Number(it.exercise_variant_id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`ex-${sIdx}-${iIdx}`}
|
||||
className={`${rowCommon} tu-item-row--exercise`}
|
||||
{...dndRowProps}
|
||||
>
|
||||
<div className="tu-item-row__mainline">
|
||||
{enableItemDragReorder ? (
|
||||
<span
|
||||
className="tu-row-grip"
|
||||
draggable
|
||||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||||
onDragEnd={clearDragChrome}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Eintrag ziehen"
|
||||
>
|
||||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="tu-item-row__nudge">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Eintrag nach oben"
|
||||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||||
disabled={iIdx === 0}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Eintrag nach unten"
|
||||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||||
disabled={iIdx === sec.items.length - 1}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<div className="tu-item-row__body tu-item-row__body--exercise">
|
||||
<div className="tu-ex-title-line">
|
||||
{exTitle ? (
|
||||
<strong className="tu-ex-title">{exTitle}</strong>
|
||||
) : (
|
||||
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
|
||||
)}
|
||||
<span className="tu-ex-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() =>
|
||||
onRequestExercisePick?.({
|
||||
sectionIndex: sIdx,
|
||||
itemIndex: iIdx,
|
||||
})
|
||||
}
|
||||
>
|
||||
{exTitle ? 'Wechseln' : 'Übung suchen…'}
|
||||
</button>
|
||||
{it.exercise_id && onPeekExercise ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() =>
|
||||
onPeekExercise(Number(it.exercise_id), variantIdPeek)
|
||||
}
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tu-ex-meta-line">
|
||||
{hasVariants ? (
|
||||
<select
|
||||
className={`form-input tu-ex-variant-select${
|
||||
wideExerciseGrid ? ' tu-ex-variant-select--wide' : ''
|
||||
}`}
|
||||
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)
|
||||
)
|
||||
}}
|
||||
title="Übungsvariante"
|
||||
>
|
||||
<option value="">Stammübung</option>
|
||||
{variantOpts.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.variant_name || `Variante #${v.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<div className="tu-ex-annot">
|
||||
<span
|
||||
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||||
title={annotHasText ? (it.notes || '').trim() : undefined}
|
||||
>
|
||||
{annotHasText ? annotPrev : '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="tu-icon-btn"
|
||||
title="Anmerkung zur Übung"
|
||||
aria-label="Anmerkung zur Übung bearbeiten"
|
||||
onClick={() =>
|
||||
setTextEdit({
|
||||
kind: 'exercise-notes',
|
||||
sIdx,
|
||||
iIdx,
|
||||
draft: it.notes || '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tu-item-row__side">
|
||||
<input
|
||||
type="number"
|
||||
className="form-input tu-ex-duration"
|
||||
min={1}
|
||||
value={it.planned_duration_min}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
||||
}
|
||||
placeholder="Min"
|
||||
title="Geplante Dauer (Minuten)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="tu-item-row__remove"
|
||||
title="Übung entfernen"
|
||||
aria-label="Übung entfernen"
|
||||
onClick={() => removeItem(sIdx, iIdx)}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showExecutionExtras ? (
|
||||
<label className="tu-ex-run-block form-label">
|
||||
Ist-Dauer / Anpassungen
|
||||
<span className="tu-ex-run-block__controls">
|
||||
<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"
|
||||
/>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={it.modifications || ''}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||||
}
|
||||
placeholder="Abweichungen beim Durchführen"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{enableItemDragReorder ? (
|
||||
<div
|
||||
className={`tu-item-append-drop${
|
||||
dropTargetPos?.sIdx === sIdx && dropTargetPos?.iIdx === itemCount
|
||||
? ' tu-item-append-drop--active'
|
||||
: ''
|
||||
}`}
|
||||
title="Hierhin ziehen, um nach unten einzufügen"
|
||||
onDragOverCapture={(e) => onItemDragOverRow(e, sIdx, itemCount)}
|
||||
onDrop={(e) => onItemDropRow(e, sIdx, itemCount)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div style={{ marginTop: '0.65rem', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
||||
>
|
||||
+ Übung
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() => addItem(sIdx, 'note')}
|
||||
>
|
||||
+ Anmerkung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
{enableSectionDragReorder ? (
|
||||
<div
|
||||
className={
|
||||
'tu-section-dropband tu-section-dropband--end' +
|
||||
(dropSectionBand &&
|
||||
dropSectionBand.slot === sectionToSlot &&
|
||||
dropSectionBand.beforeIdx === list.length
|
||||
? ' tu-section-dropband--active'
|
||||
: '')
|
||||
}
|
||||
title="Abschnitt am Ende einfügen"
|
||||
onDragOver={(e) => {
|
||||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||||
onSectionBandDragOver(e, list.length)
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||||
clearSectionDnD()
|
||||
}}
|
||||
onDrop={(e) => onSectionBandDrop(e, list.length)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={addSection}
|
||||
>
|
||||
+ Abschnitt hinzufügen
|
||||
</button>
|
||||
|
||||
{textEdit ? (
|
||||
<div
|
||||
className="tu-textedit-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) setTextEdit(null)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="tu-textedit-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="tu-textedit-title"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h4 id="tu-textedit-title" className="tu-textedit-title">
|
||||
{textEdit.kind === 'zwischen-note'
|
||||
? 'Zwischen-Anmerkung'
|
||||
: 'Anmerkung zur Übung'}
|
||||
</h4>
|
||||
<textarea
|
||||
className="form-input tu-textedit-textarea"
|
||||
rows={5}
|
||||
value={textEdit.draft}
|
||||
onChange={(e) =>
|
||||
setTextEdit((prev) => (prev ? { ...prev, draft: e.target.value } : prev))
|
||||
}
|
||||
placeholder={
|
||||
textEdit.kind === 'zwischen-note'
|
||||
? 'Hinweise zwischen Übungen …'
|
||||
: 'Kurze Anmerkung zur Übung'
|
||||
}
|
||||
/>
|
||||
<div className="tu-textedit-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={applyTextEdit}>
|
||||
Übernehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setTextEdit(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -98,7 +98,7 @@ function AccountSettingsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="page-padding" style={{ padding: '1rem', maxWidth: '640px', margin: '0 auto' }}>
|
||||
<div className="page-padding app-page" style={{ padding: '1rem' }}>
|
||||
<h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Einstellungen</h1>
|
||||
<p style={{ color: 'var(--text2)', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
|
||||
Konto & Sicherheit
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ export default function AdminCatalogsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div className="app-page">
|
||||
<AdminPageNav />
|
||||
|
||||
<h1 style={{ marginBottom: '24px' }}>Stammdaten-Kataloge</h1>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ function AdminHierarchyPage() {
|
|||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div className="app-page">
|
||||
<AdminPageNav />
|
||||
|
||||
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1>
|
||||
|
|
|
|||
|
|
@ -143,8 +143,7 @@ function ClubsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div className="app-page">
|
||||
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
|
||||
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
|
||||
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
|
||||
|
|
@ -696,7 +695,6 @@ function ClubsPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,86 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||
|
||||
function unitWhenLabel(u) {
|
||||
const d = u.planned_date ? String(u.planned_date).slice(0, 10) : ''
|
||||
const t = u.planned_time_start ? String(u.planned_time_start).slice(0, 5) : ''
|
||||
const bits = [d, t].filter(Boolean)
|
||||
return bits.length ? bits.join(' · ') : 'Termin'
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const [version, setVersion] = useState(null)
|
||||
const [profile, setProfile] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [trainingHome, setTrainingHome] = useState(null)
|
||||
const [trainingHomeErr, setTrainingHomeErr] = useState(null)
|
||||
const { user } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) {
|
||||
setTrainingHome(null)
|
||||
setTrainingHomeErr(null)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setTrainingHomeErr(null)
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const [upcomingRaw, recentRaw, plannedPool] = await Promise.all([
|
||||
api.listTrainingUnits({
|
||||
assigned_to_me: true,
|
||||
status: 'planned',
|
||||
start_date: today,
|
||||
sort: 'asc',
|
||||
limit: 8
|
||||
}),
|
||||
api.listTrainingUnits({
|
||||
assigned_to_me: true,
|
||||
status: 'completed',
|
||||
sort: 'desc',
|
||||
limit: 6
|
||||
}),
|
||||
api.listTrainingUnits({
|
||||
assigned_to_me: true,
|
||||
status: 'planned',
|
||||
start_date: today,
|
||||
sort: 'asc',
|
||||
limit: 40
|
||||
})
|
||||
])
|
||||
const noteHits = (plannedPool || []).filter((u) => {
|
||||
const tn = (u.trainer_notes || '').trim()
|
||||
const n = (u.notes || '').trim()
|
||||
return Boolean(tn || n)
|
||||
}).slice(0, 5)
|
||||
if (!cancelled) {
|
||||
setTrainingHome({
|
||||
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
|
||||
recent: Array.isArray(recentRaw) ? recentRaw : [],
|
||||
plannedWithNotes: noteHits
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
console.error('Dashboard Trainingsübersicht:', e)
|
||||
setTrainingHomeErr(e.message || 'Konnte Trainingsdaten nicht laden')
|
||||
setTrainingHome(null)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [user?.id])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [versionData, profileData] = await Promise.all([
|
||||
|
|
@ -30,7 +98,7 @@ function Dashboard() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
|
||||
<div className="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
|
@ -38,8 +106,7 @@ function Dashboard() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--bg)', padding: '2rem' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div className="app-page">
|
||||
<h1>Dashboard</h1>
|
||||
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
|
||||
Willkommen, {user?.name || user?.email}!
|
||||
|
|
@ -53,10 +120,105 @@ function Dashboard() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{user?.id && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
|
||||
gap: '1rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}
|
||||
>
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
|
||||
{trainingHomeErr ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
|
||||
) : trainingHome?.upcoming?.length ? (
|
||||
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.55 }}>
|
||||
{trainingHome.upcoming.map((u) => (
|
||||
<li key={u.id} style={{ marginBottom: '0.35rem' }}>
|
||||
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
{unitWhenLabel(u)}
|
||||
</Link>
|
||||
{u.group_name ? (
|
||||
<span style={{ color: 'var(--text3)' }}>{` — ${u.group_name}`}</span>
|
||||
) : null}
|
||||
{u.lead_trainer_name ? (
|
||||
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text3)', marginTop: '2px' }}>
|
||||
Leitung: {u.lead_trainer_name}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
|
||||
Keine anstehenden Termine mit dir als Leitung oder Co‑Trainer. Unter{' '}
|
||||
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
|
||||
Trainingsplanung
|
||||
</Link>{' '}
|
||||
kannst du den Vereins‑ oder Gruppen‑Zeitraum einblenden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Vermerk / Hinweise (anstehend)</h3>
|
||||
{trainingHomeErr ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
|
||||
) : trainingHome?.plannedWithNotes?.length ? (
|
||||
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.88rem', lineHeight: 1.5 }}>
|
||||
{trainingHome.plannedWithNotes.map((u) => {
|
||||
const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120)
|
||||
return (
|
||||
<li key={`n-${u.id}`} style={{ marginBottom: '0.5rem' }}>
|
||||
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
{unitWhenLabel(u)}
|
||||
</Link>
|
||||
{u.group_name ? <span style={{ color: 'var(--text3)' }}>{` · ${u.group_name}`}</span> : null}
|
||||
<div style={{ color: 'var(--text2)', marginTop: '4px' }}>
|
||||
{snippet}
|
||||
{(u.trainer_notes || u.notes || '').trim().length > 120 ? '…' : ''}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
|
||||
Keine Einträge mit Allgemein‑ oder Trainer‑Notizen in deinen nächsten geplanten Terminen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Rückschau (durchgeführt)</h3>
|
||||
{trainingHomeErr ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
|
||||
) : trainingHome?.recent?.length ? (
|
||||
<ul style={{ margin: 0, paddingLeft: '1.15rem', color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.55 }}>
|
||||
{trainingHome.recent.map((u) => (
|
||||
<li key={`r-${u.id}`} style={{ marginBottom: '0.35rem' }}>
|
||||
<Link to={`/planning/run/${u.id}`} style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
|
||||
</Link>
|
||||
{u.group_name ? (
|
||||
<span style={{ color: 'var(--text3)' }}>{` — ${u.group_name}`}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>Noch keine abgeschlossenen Einheiten in der Kurzliste.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 260px), 1fr))',
|
||||
gap: '1rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
|
|
@ -122,7 +284,6 @@ function Dashboard() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ function ExerciseDetailPage() {
|
|||
if (error) {
|
||||
const msg = error.message || String(error)
|
||||
return (
|
||||
<div style={{ padding: '1rem', maxWidth: '640px', margin: '0 auto' }}>
|
||||
<div style={{ padding: '1rem' }} className="app-page">
|
||||
<div className="card">
|
||||
<h2>Übung</h2>
|
||||
<p style={{ color: 'var(--danger)' }}>{msg}</p>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'
|
|||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import api, { buildExerciseApiPayload } from '../utils/api'
|
||||
import RichTextEditor from '../components/RichTextEditor'
|
||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||
|
||||
const INTENSITY_OPTIONS = [
|
||||
|
|
@ -698,7 +699,7 @@ function ExerciseFormPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', maxWidth: '720px', margin: '0 auto' }}>
|
||||
<div style={{ padding: '12px' }} className="app-page">
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
|
||||
← Übersicht
|
||||
|
|
@ -1151,6 +1152,18 @@ function ExerciseFormPage() {
|
|||
</details>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
||||
<summary className="exercise-variants-summary">
|
||||
<span className="exercise-variants-summary__title">Progressionsgraph</span>
|
||||
<span className="exercise-variants-summary__badge">Übung → Übung</span>
|
||||
</summary>
|
||||
<div className="exercise-variants-details__body">
|
||||
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
|
|||
import api from '../utils/api'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||
|
|
@ -44,6 +45,7 @@ function ExercisesListPage() {
|
|||
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
||||
const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||
const [pageTab, setPageTab] = useState('list')
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
||||
|
|
@ -287,7 +289,7 @@ function ExercisesListPage() {
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalogsReady) return
|
||||
if (!catalogsReady || pageTab !== 'list') return
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
setListFetching(true)
|
||||
|
|
@ -311,7 +313,7 @@ function ExercisesListPage() {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [queryBase, catalogsReady])
|
||||
}, [queryBase, catalogsReady, pageTab])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
|
|
@ -340,7 +342,7 @@ function ExercisesListPage() {
|
|||
|
||||
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
|
||||
|
||||
if (!catalogsReady) {
|
||||
if (!catalogsReady && pageTab === 'list') {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="spinner"></div>
|
||||
|
|
@ -350,7 +352,7 @@ function ExercisesListPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div className="app-page">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -362,11 +364,44 @@ function ExercisesListPage() {
|
|||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
|
||||
<Link to="/exercises/new" className="btn btn-primary">
|
||||
+ Neu
|
||||
</Link>
|
||||
{pageTab === 'list' ? (
|
||||
<Link to="/exercises/new" className="btn btn-primary">
|
||||
+ Neu
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Übungen Bereiche"
|
||||
style={{ display: 'flex', gap: '8px', marginBottom: '14px', flexWrap: 'wrap' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={pageTab === 'list'}
|
||||
className={pageTab === 'list' ? 'btn btn-primary' : 'btn btn-secondary'}
|
||||
onClick={() => setPageTab('list')}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={pageTab === 'progression'}
|
||||
className={pageTab === 'progression' ? 'btn btn-primary' : 'btn btn-secondary'}
|
||||
onClick={() => setPageTab('progression')}
|
||||
>
|
||||
Progressionsgraphen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pageTab === 'progression' ? (
|
||||
<ExerciseProgressionGraphPanel />
|
||||
) : (
|
||||
<>
|
||||
<div className="card exercise-search-bar" style={{ marginBottom: '12px' }}>
|
||||
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
||||
<datalist id="exercise-search-titles">
|
||||
|
|
@ -687,6 +722,8 @@ function ExercisesListPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export default function MediaWikiImportPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div className="app-page">
|
||||
<AdminPageNav />
|
||||
|
||||
<h1>MediaWiki Import (Semantic MediaWiki)</h1>
|
||||
|
|
|
|||
|
|
@ -143,8 +143,7 @@ function SkillsPage() {
|
|||
const methodsByCategory = groupByCategory(methods)
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div className="app-page">
|
||||
<h1 style={{ marginBottom: '1.5rem' }}>Fähigkeiten & Methoden</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
|
|
@ -509,7 +508,6 @@ function SkillsPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export default function TrainerContextsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<div className="app-page">
|
||||
<h1>Meine Trainer-Bereiche</h1>
|
||||
<p style={{ color: 'var(--text2)', marginBottom: '32px' }}>
|
||||
Definiere deine Tätigkeitsbereiche für fokussierte Ansichten und Filter.
|
||||
|
|
|
|||
1162
frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
Normal file
1162
frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
197
frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
Normal file
197
frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
|
||||
function dashIfEmpty(val) {
|
||||
const s = (val ?? '').toString().trim()
|
||||
return s.length ? s : '—'
|
||||
}
|
||||
|
||||
function FrameworkSummaryMeta({ r }) {
|
||||
const trainingTypes =
|
||||
typeof r.training_type_names_agg === 'string' ? r.training_type_names_agg.trim() : ''
|
||||
const targetGroups =
|
||||
typeof r.target_group_names_agg === 'string' ? r.target_group_names_agg.trim() : ''
|
||||
const styleDir = typeof r.style_direction_name === 'string' ? r.style_direction_name.trim() : ''
|
||||
const focus = typeof r.focus_area_name === 'string' ? r.focus_area_name.trim() : ''
|
||||
|
||||
const rowStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(6.5rem, 32%) 1fr',
|
||||
gap: '0.25rem 0.75rem',
|
||||
alignItems: 'start',
|
||||
marginTop: '0.35rem',
|
||||
lineHeight: 1.45,
|
||||
}
|
||||
|
||||
return (
|
||||
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
|
||||
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
|
||||
</div>
|
||||
{styleDir ? (
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtung</dt>
|
||||
<dd style={{ margin: 0 }}>{styleDir}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Trainingsarten</dt>
|
||||
<dd style={{ margin: 0 }}>{trainingTypes.length ? trainingTypes : '—'}</dd>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Zielgruppen</dt>
|
||||
<dd style={{ margin: 0 }}>{targetGroups.length ? targetGroups : '—'}</dd>
|
||||
</div>
|
||||
<div style={{ ...rowStyle, marginTop: '0.5rem' }}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Kurzbeschreibung</dt>
|
||||
<dd style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{(r.description && String(r.description).trim()) || '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrainingFrameworkProgramsListPage() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const list = await api.listTrainingFrameworkPrograms()
|
||||
setRows(Array.isArray(list) ? list : [])
|
||||
} catch (e) {
|
||||
setError(e.message || 'Laden fehlgeschlagen')
|
||||
setRows([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function handleDelete(id, title) {
|
||||
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
|
||||
try {
|
||||
await api.deleteTrainingFrameworkProgram(id)
|
||||
await load()
|
||||
} catch (e) {
|
||||
alert(e.message || 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
marginBottom: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
|
||||
Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
|
||||
<strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme
|
||||
mit Bezug zum Rahmen).
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/planning/framework-programs/new"
|
||||
className="btn btn-primary"
|
||||
style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Rahmenprogramm anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p style={{ marginBottom: '1rem' }}>
|
||||
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
|
||||
← Zurück zur Trainingsplanung
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<div className="spinner" />
|
||||
<p>Laden…</p>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text2)', marginBottom: '1rem' }}>
|
||||
Noch kein Rahmenprogramm gespeichert. Lege ein neues an — mit Titel, mindestens einem Ziel und optional
|
||||
Slots samt Übungen.
|
||||
</p>
|
||||
<Link
|
||||
to="/planning/framework-programs/new"
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Rahmenprogramm anlegen
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none' }}>
|
||||
{rows.map((r) => (
|
||||
<li key={r.id} className="card" style={{ marginBottom: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: '1 1 220px' }}>
|
||||
<Link
|
||||
to={`/planning/framework-programs/${r.id}`}
|
||||
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
|
||||
>
|
||||
{r.title || `Rahmen #${r.id}`}
|
||||
</Link>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||
<span>
|
||||
{(r.goals_count ?? '—') + ' Ziele · '}
|
||||
{(r.slots_count ?? '—') + ' Slots'}
|
||||
</span>
|
||||
</div>
|
||||
<FrameworkSummaryMeta r={r} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<Link
|
||||
to={`/planning/framework-programs/${r.id}`}
|
||||
className="btn btn-secondary"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -141,7 +141,7 @@ export default function TrainingUnitRunPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="training-run-page" style={{ maxWidth: '720px', margin: '0 auto', paddingBottom: '2rem' }}>
|
||||
<div className="training-run-page app-page" style={{ paddingBottom: '2rem' }}>
|
||||
<ExercisePeekModal
|
||||
open={peekExerciseId != null}
|
||||
exerciseId={peekExerciseId}
|
||||
|
|
|
|||
|
|
@ -443,6 +443,74 @@ export async function reorderExerciseVariants(exerciseId, variantIds) {
|
|||
})
|
||||
}
|
||||
|
||||
// Progressionsgraphen (Übung → Übung), Migration 032/033
|
||||
export async function listExerciseProgressionGraphs() {
|
||||
return request('/api/exercise-progression-graphs')
|
||||
}
|
||||
|
||||
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
|
||||
const q = includeEdges ? '?include_edges=true' : ''
|
||||
return request(`/api/exercise-progression-graphs/${id}${q}`)
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionGraph(data) {
|
||||
return request('/api/exercise-progression-graphs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseProgressionGraph(id, data) {
|
||||
return request(`/api/exercise-progression-graphs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionGraph(id) {
|
||||
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function listExerciseProgressionEdges(graphId, query = {}) {
|
||||
const q = new URLSearchParams()
|
||||
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
|
||||
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
|
||||
const qs = q.toString()
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionEdge(graphId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionSequence(graphId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ edge_ids: edgeIds }),
|
||||
})
|
||||
}
|
||||
|
||||
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
|
||||
export async function suggestExerciseAi(payload) {
|
||||
return request('/api/exercises/ai/suggest', {
|
||||
|
|
@ -814,15 +882,21 @@ export async function deleteTrainerContext(id) {
|
|||
// Training Planning
|
||||
// ============================================================================
|
||||
|
||||
/** Query-Parameter wie GET /api/training-units (group_id, start_date, end_date, status). */
|
||||
/** Query-Parameter wie GET /api/training-units. */
|
||||
export async function listTrainingUnits(filters = {}) {
|
||||
const q = new URLSearchParams()
|
||||
if (filters.group_id != null && filters.group_id !== '') {
|
||||
q.set('group_id', String(filters.group_id))
|
||||
}
|
||||
if (filters.club_id != null && filters.club_id !== '') {
|
||||
q.set('club_id', String(filters.club_id))
|
||||
}
|
||||
if (filters.start_date) q.set('start_date', filters.start_date)
|
||||
if (filters.end_date) q.set('end_date', filters.end_date)
|
||||
if (filters.status) q.set('status', filters.status)
|
||||
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
|
||||
if (filters.sort) q.set('sort', String(filters.sort))
|
||||
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
|
||||
const qs = q.toString()
|
||||
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
|
@ -856,6 +930,14 @@ export async function quickCreateTrainingUnit(data) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
|
||||
export async function createTrainingUnitFromFrameworkSlot(data) {
|
||||
return request('/api/training-units/from-framework-slot', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function listTrainingPlanTemplates() {
|
||||
return request('/api/training-plan-templates')
|
||||
}
|
||||
|
|
@ -882,6 +964,32 @@ export async function deleteTrainingPlanTemplate(id) {
|
|||
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function listTrainingFrameworkPrograms() {
|
||||
return request('/api/training-framework-programs')
|
||||
}
|
||||
|
||||
export async function getTrainingFrameworkProgram(id) {
|
||||
return request(`/api/training-framework-programs/${id}`)
|
||||
}
|
||||
|
||||
export async function createTrainingFrameworkProgram(data) {
|
||||
return request('/api/training-framework-programs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateTrainingFrameworkProgram(id, data) {
|
||||
return request(`/api/training-framework-programs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteTrainingFrameworkProgram(id) {
|
||||
return request(`/api/training-framework-programs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Version & Health
|
||||
// ============================================================================
|
||||
|
|
@ -951,6 +1059,17 @@ export const api = {
|
|||
updateExerciseMedia,
|
||||
deleteExerciseMedia,
|
||||
reorderExerciseMedia,
|
||||
listExerciseProgressionGraphs,
|
||||
getExerciseProgressionGraph,
|
||||
createExerciseProgressionGraph,
|
||||
updateExerciseProgressionGraph,
|
||||
deleteExerciseProgressionGraph,
|
||||
listExerciseProgressionEdges,
|
||||
createExerciseProgressionEdge,
|
||||
updateExerciseProgressionEdge,
|
||||
deleteExerciseProgressionEdge,
|
||||
createExerciseProgressionSequence,
|
||||
deleteExerciseProgressionEdgesBatch,
|
||||
|
||||
// Training Planning
|
||||
listTrainingUnits,
|
||||
|
|
@ -959,11 +1078,17 @@ export const api = {
|
|||
updateTrainingUnit,
|
||||
deleteTrainingUnit,
|
||||
quickCreateTrainingUnit,
|
||||
createTrainingUnitFromFrameworkSlot,
|
||||
listTrainingPlanTemplates,
|
||||
getTrainingPlanTemplate,
|
||||
createTrainingPlanTemplate,
|
||||
updateTrainingPlanTemplate,
|
||||
deleteTrainingPlanTemplate,
|
||||
listTrainingFrameworkPrograms,
|
||||
getTrainingFrameworkProgram,
|
||||
createTrainingFrameworkProgram,
|
||||
updateTrainingFrameworkProgram,
|
||||
deleteTrainingFrameworkProgram,
|
||||
|
||||
// Catalogs
|
||||
listFocusAreas,
|
||||
|
|
|
|||
191
frontend/src/utils/trainingUnitSectionsForm.js
Normal file
191
frontend/src/utils/trainingUnitSectionsForm.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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 async function hydrateExercisePlanningRow(exercise) {
|
||||
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
|
||||
let title = exercise?.title || ''
|
||||
const id = exercise?.id
|
||||
if (!id) return null
|
||||
if (!variants.length) {
|
||||
try {
|
||||
const full = await api.getExercise(id)
|
||||
variants = Array.isArray(full?.variants) ? full.variants : []
|
||||
title = full?.title || title
|
||||
} catch {
|
||||
variants = []
|
||||
}
|
||||
}
|
||||
const row = exerciseRow()
|
||||
row.exercise_id = id
|
||||
row.exercise_variant_id = ''
|
||||
row.exercise_title = title
|
||||
row.variants = variants
|
||||
return row
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Shinkan Jinkendo Frontend Version
|
||||
|
||||
export const APP_VERSION = "0.5.0"
|
||||
export const BUILD_DATE = "2026-04-23"
|
||||
export const APP_VERSION = "0.5.1"
|
||||
export const BUILD_DATE = "2026-05-05"
|
||||
|
||||
export const PAGE_VERSIONS = {
|
||||
LoginPage: "1.0.0",
|
||||
|
|
@ -10,7 +10,9 @@ export const PAGE_VERSIONS = {
|
|||
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
|
||||
ClubsPage: "1.0.0",
|
||||
SkillsPage: "1.0.0",
|
||||
TrainingPlanningPage: "1.3.0",
|
||||
TrainingPlanningPage: "1.3.1",
|
||||
TrainingFrameworkProgramsListPage: "1.1.0",
|
||||
TrainingFrameworkProgramEditPage: "1.5.0",
|
||||
TrainingUnitRunPage: "1.1.0",
|
||||
TrainingCoachPage: "1.0.0",
|
||||
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user