feat(training-modules): implement training module functionality and UI integration
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 56s

- Added new API endpoints for managing training modules, including listing, creating, updating, and deleting modules.
- Implemented the ability to apply training modules to training units, allowing users to copy module content into specific sections.
- Enhanced the frontend with new pages for managing training modules and integrated modal functionality for applying modules within the training planning page.
- Updated version to 0.8.97 and adjusted database schema version accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-12 21:35:07 +02:00
parent 59d53d6154
commit c1243651bb
13 changed files with 2114 additions and 10 deletions

View File

@ -0,0 +1,684 @@
# Trainingsmodule und Kombinationsübungen — fachliche Spezifikation V3
**Status:** fachlicher Spezifikationsentwurf
**Stand:** 2026-05-12
**Zweck:** Produkt- und Fachspezifikation für Trainingsmodule, Kombinationsübungen, Trainingsmethodenbezug, Planungsintegration und Coaching-Modus in Shinkan.
**Wichtige Leitlinie dieser Version:**
Diese Spezifikation beschreibt bewusst **keine verbindlichen Tabellen, API-Pfade, Spaltennamen oder konkrete Implementierungsdetails**. Die technische Umsetzung soll durch den Coding Agent auf Basis der bestehenden Codebasis geplant werden. Ziel dieses Dokuments ist es, die fachliche Zielarchitektur, Nutzerlogik, Datenbedeutung und Produktentscheidungen so klar zu beschreiben, dass spätere große Refactorings vermieden werden, ohne die bestehende Anwendung durch zu frühe technische Festlegungen zu destabilisieren.
---
## 1. Ausgangslage
Shinkan ist eine trainerzentrierte App für Übungsverwaltung, Trainingsplanung, Rahmenprogramme und Durchführung. Die bestehende Planung arbeitet fachlich mit Trainingseinheiten, Trainingsabschnitten und Einträgen wie Übungen oder Notizen.
Für die nächste Ausbaustufe werden zwei zusätzliche fachliche Bausteine benötigt:
1. **Kombinationsübungen**
Strukturierte Übungsformen, bei denen mehrere Einzelübungen, Stationen, Rollen oder Schritte methodisch zusammenwirken.
2. **Trainingsmodule**
Wiederverwendbare Planungsbausteine, also gespeicherte Übungsfolgen oder Trainingsblöcke, die in konkrete Trainings oder Rahmenprogramme übernommen werden können.
Zusätzlich muss geklärt werden, wie **Trainingsmethoden**, **Methoden-Archetypen** und **konkrete Ablaufprofile** fachlich voneinander getrennt werden.
---
## 2. Fachliche Grundentscheidungen
### 2.1 Trainingsabschnitte bleiben Makrostruktur
Trainingsabschnitte beschreiben die grobe Struktur einer Trainingseinheit, z. B.:
* Aufwärmen,
* Hauptteil,
* Kumite,
* Kata,
* Selbstschutz,
* Abschluss.
Ein Abschnitt ist damit ein Gliederungselement der gesamten Trainingseinheit.
### 2.2 Kombinationsübungen sind nicht an genau einen Abschnitt gebunden
Eine Kombinationsübung darf nicht fachlich oder technisch auf genau einen Trainingsabschnitt reduziert werden.
Sie kann:
* innerhalb eines Abschnitts verwendet werden,
* einen Abschnitt faktisch ausfüllen,
* zwischen zwei Abschnitten stehen,
* als zentraler Block der Einheit auf Trainingsebene liegen,
* Bestandteil eines Trainingsmoduls sein,
* Bestandteil eines Rahmenprogramms oder Rahmen-Slots sein.
Der Abschnitt kann ein sinnvoller Anzeige- oder Planungskontext sein, ist aber nicht die fachliche Heimat der Kombinationsübung.
### 2.3 Kombinationsübungen gehören fachlich zum Übungsbereich
Eine Kombinationsübung ist eine Sonderform einer Übung. Sie besitzt daher die typischen Eigenschaften einer Übung:
* Titel,
* Ziel,
* Durchführung,
* Trainerhinweise,
* Vorbereitung,
* Hilfsmittel,
* Dauer,
* Zielgruppe,
* Fähigkeiten,
* Methodenbezug,
* Medien,
* Sichtbarkeit,
* Freigabestatus.
Zusätzlich besitzt sie eine interne Struktur:
* Slots,
* Stationen,
* Rollen,
* Schritte,
* Übungspools,
* Methoden-Archetyp,
* Ablaufprofil für Planung und Coaching.
### 2.4 Trainingsmodule gehören fachlich zur Planung
Trainingsmodule sind keine Übungen, sondern wiederverwendbare Planungsbausteine.
Ein Trainingsmodul kann enthalten:
* einzelne Übungen,
* Kombinationsübungen,
* Notizen,
* methodische Hinweise,
* kurze wiederverwendbare Übungsfolgen,
* größere Blöcke innerhalb einer Einheit.
Trainingsmodule sollten deshalb fachlich unter **Planung / Bibliothek / Module** verortet werden.
### 2.5 Einfügen bedeutet Kopie mit Herkunft, nicht Live-Verknüpfung
Wenn ein Trainingsmodul oder eine Kombinationsübung in eine konkrete Trainingseinheit übernommen wird, entsteht eine bearbeitbare Planungsinstanz.
Grundsatz:
> Bibliothek = Vorlage.
> Planung = lokal bearbeitbare Übernahme.
> Durchführung = tatsächliche Nutzung im Training.
Spätere Änderungen an der Vorlage dürfen bereits geplante oder historische Einheiten nicht ungefragt verändern.
---
## 3. Zentrale Begriffe
| Begriff | Fachliche Bedeutung |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Trainingseinheit** | Konkretes geplantes oder durchgeführtes Training. |
| **Trainingsabschnitt** | Makrostruktur der Einheit, z. B. Aufwärmen oder Hauptteil. |
| **Planungsblock** | Zusammenhängender Inhalt innerhalb einer Einheit, z. B. Modul, Kombinationsübung oder manuell gruppierter Block. |
| **Kombinationsübung** | Sonderform einer Übung mit interner Struktur aus Slots, Stationen, Rollen oder Schritten. |
| **Trainingsmodul** | Wiederverwendbarer Planungsbaustein aus Übungen, Kombinationsübungen und Notizen. |
| **Trainingsmethode** | Fachlicher Katalogeintrag, der beschreibt, wie eine Trainingsform didaktisch oder sportmethodisch einzuordnen ist. |
| **Methoden-Archetyp** | Ablaufmuster für Planung und Coaching, z. B. rotierender Zirkel oder lineare Sequenz. |
| **Ablaufprofil** | Konkrete Ausprägung eines Archetyps, z. B. Arbeitszeit, Wechselzeit, Runden oder Erklärphase. |
| **Slot** | Platzhalter innerhalb einer Kombinationsübung, z. B. Station 1, Rolle A oder Schritt 2. |
| **Slot-Pool** | Menge möglicher Übungen für einen Slot, aus denen bei der Planung eine konkrete Auswahl getroffen werden kann. |
---
## 4. Trainingsmethoden: Ablage und Beschreibung
### 4.1 Rolle des Methodenkatalogs
Trainingsmethoden sollen als eigenständige fachliche Katalogobjekte geführt werden.
Sie beschreiben nicht eine konkrete Übung, sondern die methodische Qualität einer Trainingsform.
Beispiele:
* Zirkeltraining,
* Rollenspiel,
* strukturierte Übung,
* Koordinationstraining,
* plyometrisches Training,
* Dauermethode,
* extensive Intervallmethode,
* Partnerübung,
* freie Anwendung,
* Reflexionsformat.
Der Methodenkatalog dient:
* der Übungsbeschreibung,
* der Suche und Filterung,
* der Trainingsplanung,
* der fachlichen Standardisierung im Verein,
* der späteren KI- oder Assistenzunterstützung,
* der Qualitätssicherung bei offiziellen Inhalten.
### 4.2 Abgrenzung: Methode, Archetyp, Ablaufprofil
Die drei Begriffe müssen getrennt bleiben.
| Ebene | Frage | Beispiel |
| --------------------- | ---------------------------------------------- | --------------------------------------------------------- |
| **Trainingsmethode** | Welche fachliche Methode wird verwendet? | Zirkeltraining, Rollenspiel, Intervalltraining. |
| **Methoden-Archetyp** | Nach welchem Ablaufmuster wird gesteuert? | rotierender Zirkel, parallele Stationen, lineare Sequenz. |
| **Ablaufprofil** | Wie ist die konkrete Durchführung eingestellt? | 45 Sekunden Arbeit, 15 Sekunden Wechsel, 3 Runden. |
Wichtig:
> Der Methoden-Archetyp ersetzt nicht die Trainingsmethode. Er ergänzt sie nur dort, wo der Ablauf für Planung oder Coaching maschinenlesbar interpretiert werden muss.
### 4.3 Fachliche Beschreibung einer Trainingsmethode
Eine Trainingsmethode sollte aus Trainersicht so beschrieben werden, dass sie zuverlässig angewendet, gesucht und von anderen Methoden unterschieden werden kann.
Empfohlene fachliche Beschreibungsfelder:
| Feld | Zweck |
| ---------------------------------- | -------------------------------------------------------------------------- |
| **Name** | Eindeutige Bezeichnung der Methode. |
| **Kurzbeschreibung** | Schnelle Orientierung in Listen und Auswahlfeldern. |
| **Langbeschreibung** | Fachliche Erklärung der Methode. |
| **Ziel / Nutzen** | Wofür diese Methode besonders geeignet ist. |
| **Typische Einsatzsituationen** | Wann die Methode sinnvoll eingesetzt wird. |
| **Geeignete Zielgruppen** | Altersgruppen, Leistungsgruppen oder Trainingskontexte. |
| **Organisationsform** | Einzelarbeit, Partnerarbeit, Gruppe, Stationen, Kreis, freie Fläche usw. |
| **Belastungscharakter** | locker, technisch, koordinativ, intensiv, intervallartig, spielerisch usw. |
| **Typische Dauer** | Orientierung für Planung und Zeitmanagement. |
| **Benötigte Rahmenbedingungen** | Platz, Material, Gruppengröße, Sicherheitsabstände. |
| **Trainerhinweise** | Wichtige Hinweise für Anleitung und Steuerung. |
| **Risiken / typische Fehler** | Was bei falscher Anwendung problematisch sein kann. |
| **Geeignete Fähigkeiten** | Fähigkeiten, die mit der Methode häufig adressiert werden. |
| **Verwandte Methoden** | Ähnliche oder kombinierbare Methoden. |
| **Abgrenzung zu anderen Methoden** | Wann eine andere Methode passender wäre. |
| **Optionale Standard-Archetypen** | Falls die Methode häufig mit bestimmten Ablaufmustern genutzt wird. |
| **Status und Sichtbarkeit** | Entwurf, freigegeben, offiziell, vereinsintern usw. |
Diese Felder sind fachliche Anforderungen. Die konkrete technische Ablage soll der Coding Agent anhand der bestehenden Methodendomäne planen.
### 4.4 Haupt- und Nebenmethoden
Eine Übung sollte fachlich mindestens eine Hauptmethode haben können.
Zusätzlich können Nebenmethoden sinnvoll sein, weil eine Übung aus mehreren methodischen Perspektiven beschrieben werden kann.
Beispiel:
* Hauptmethode: Zirkeltraining
* Nebenmethode: plyometrisches Training
* weitere Nebenmethode: Koordinationstraining
Produktentscheidung:
> Übungen und Kombinationsübungen sollen eine Hauptmethode und optional weitere Nebenmethoden unterstützen.
Perspektivisch kann zusätzlich unterschieden werden zwischen:
* sportmethodischer Methode,
* didaktischer Vermittlungsmethode,
* organisatorischer Durchführungsform.
Diese Unterscheidung sollte aber im MVP nicht übermodelliert werden.
### 4.5 Trainingsmethoden in Kombinationsübungen
Bei Kombinationsübungen ist der Methodenbezug besonders wichtig.
Eine Kombinationsübung sollte daher fachlich drei Dinge besitzen:
1. **Methode**
Beispiel: Zirkeltraining.
2. **Archetyp**
Beispiel: rotierender Zeit-Zirkel.
3. **Ablaufprofil**
Beispiel: 6 Stationen, 45 Sekunden Arbeit, 15 Sekunden Wechsel, 3 Runden.
So kann dieselbe Methode unterschiedlich angewendet werden:
| Methode | Archetyp | Beispiel |
| ------------------- | ------------------- | --------------------------------------------------- |
| Zirkeltraining | rotierender Zirkel | Alle Gruppen wechseln gemeinsam weiter. |
| Zirkeltraining | parallele Stationen | Stationen laufen parallel, kein gemeinsamer Umlauf. |
| Intervalltraining | Intervallblock | Gemeinsame Zeitdomäne ohne Stationen. |
| Strukturierte Übung | lineare Sequenz | Schritt 1, Schritt 2, Schritt 3. |
### 4.6 Methoden in Trainingsmodulen
Trainingsmodule können ebenfalls einen Methodenbezug besitzen, aber anders als Übungen.
Ein Modul kann:
* eine dominante Methode haben,
* mehrere Methoden enthalten,
* methodisch neutral sein,
* nur aus einzelnen Übungen bestehen, die selbst Methoden besitzen.
Empfehlung:
> Ein Trainingsmodul darf optional eine primäre methodische Ausrichtung besitzen, sollte aber nicht zwingend eine Methode erzwingen.
Beispiel:
* Modul: „Aktivierung und Reaktion“
* Primäre methodische Ausrichtung: Koordinationstraining
* Enthaltene Übungen: Reaktionsspiel, Sprintsignal, Partneraufgabe
### 4.7 Methoden in der Suche und Planung
Der Methodenkatalog soll in der Nutzung sichtbar werden.
Benötigte Such- und Planungsfunktionen:
* Übungen nach Methode filtern,
* Kombinationsübungen nach Methode und Archetyp filtern,
* Trainingsmodule nach methodischer Ausrichtung filtern,
* in der Planung passende Methoden für ein Trainingsziel finden,
* Methoden als Qualitätsmerkmal offizieller Vereinsinhalte nutzen,
* bei der Auswahl einer Kombinationsübung passende Ablaufmuster vorschlagen.
Beispiel aus Trainersicht:
> „Ich suche eine Übung für Kumite, Jugendliche, Schwerpunkt Beinarbeit, Methode Zirkeltraining oder Koordinationstraining, Dauer maximal 15 Minuten.“
### 4.8 Governance des Methodenkatalogs
Trainingsmethoden sind fachliche Standardobjekte. Daher sollten sie stärker kontrolliert werden als private Trainingsnotizen.
Empfehlung:
* offizielle Methoden werden durch Administratoren oder Inhaltsverantwortliche gepflegt,
* Vereine können eigene Ergänzungen oder Spezialisierungen anlegen,
* Trainer können Vorschläge oder private methodische Hinweise erfassen,
* Änderungen an offiziellen Methoden sollten nicht ungeprüft globale Inhalte verändern.
---
## 5. Methoden-Archetypen für Kombinationsübungen
### 5.1 Zweck
Archetypen beschreiben wiederkehrende Ablaufmuster, die für Planung und Coaching relevant sind.
Sie beantworten nicht die Frage „Welche Methode ist das?“, sondern:
> Wie soll dieser Block im Training durchlaufen oder angezeigt werden?
### 5.2 Empfohlene Start-Archetypen
| Archetyp | Fachliche Bedeutung | Coaching-Idee |
| ------------------------ | -------------------------------------------------------------------- | -------------------------------------------------- |
| **Lineare Sequenz** | Übungen bauen nacheinander aufeinander auf. | Schrittfolge mit optionalem Timer. |
| **Rotierender Zirkel** | Mehrere Stationen, Gruppen wechseln nach Zeit weiter. | Gemeinsamer Timer, Wechselhinweis, Rundenzähler. |
| **Parallele Stationen** | Mehrere Stationen laufen gleichzeitig, aber ohne zwingende Rotation. | Vorher erklären, dann paralleler Betrieb. |
| **Parcours** | Stationen oder Aufgaben entlang eines Wegs oder Ablaufs. | Navigation, Abhaken, flexible Reihenfolge möglich. |
| **Partner-/Paarwechsel** | Rollen oder Aufgaben wechseln gekoppelt. | A/B-Logik, Rollenhinweise, Wechselimpulse. |
| **Intervallblock** | Gemeinsame Zeitdomäne mit wiederholten Belastungsphasen. | Globale Uhr, Intervallanzeige. |
| **Freier Methodenblock** | Methodischer Zusammenhang ohne harte Steuerungslogik. | Kompakte Anzeige, manuelles Abhaken. |
### 5.3 Mindestanforderung an Archetypen
Für jeden Archetyp muss fachlich beschrieben sein:
* wann er verwendet wird,
* welche Informationen der Trainer bei der Planung benötigt,
* welche Informationen im Coaching-Modus angezeigt werden,
* welche Angaben verpflichtend sind,
* welche Angaben optional sind,
* wann ein anderer Archetyp besser geeignet wäre.
Die technische Validierung und konkrete Ablage dieser Angaben soll der Coding Agent planen.
---
## 6. Kombinationsübungen
### 6.1 Fachliche Beschreibung
Eine Kombinationsübung ist eine wiederverwendbare Übungsform mit interner Struktur.
Beispiele:
* Kumite-Zirkel mit fünf Stationen,
* Koordinationsparcours,
* Selbstschutz-Parcours,
* Partnerwechselübung,
* methodische Sequenz zur Distanzkontrolle,
* Reaktions- und Explosivitätsblock,
* Aufwärmparcours für Kinder.
### 6.2 Bestandteile
Eine Kombinationsübung sollte fachlich enthalten:
* allgemeine Übungsbeschreibung,
* Ziel,
* Durchführung,
* Trainerhinweise,
* Vorbereitung,
* Hilfsmittel,
* Zielgruppe,
* Fähigkeiten,
* Hauptmethode,
* optionale Nebenmethoden,
* Archetyp,
* Slots / Stationen / Rollen / Schritte,
* mögliche Übungen je Slot,
* optionale Standardwerte für Dauer, Runden oder Wechsel,
* Hinweise für den Coaching-Modus.
### 6.3 Slot- und Pool-Logik
Slots können fest oder variabel sein.
Beispiel fest:
* Station 1 = Seilspringen
* Station 2 = Liegestütz
* Station 3 = Beinarbeit
Beispiel variabel:
* Station 1 = eine Übung aus Pool „Beinarbeit“
* Station 2 = eine Übung aus Pool „Reaktion“
* Station 3 = eine Übung aus Pool „Konter“
Die konkrete Auswahl kann bei der Planung angepasst werden, ohne die Bibliotheksvorlage zu ändern.
---
## 7. Trainingsmodule
### 7.1 Fachliche Beschreibung
Ein Trainingsmodul ist ein wiederverwendbarer Planungsbaustein.
Beispiele:
* Standard-Aufwärmen für Kinder,
* Mobilisation und Aktivierung,
* Kumite-Beinarbeit 20 Minuten,
* SV-Einstieg Wahrnehmung und Distanz,
* Abschlussritual mit Reflexion,
* prüfungsnaher Kihon-Block.
### 7.2 Bestandteile
Ein Trainingsmodul sollte fachlich enthalten:
* Titel,
* Kurzbeschreibung,
* Ziel,
* empfohlene Dauer,
* empfohlene Zielgruppe,
* optional empfohlener Einsatzbereich,
* optionale methodische Ausrichtung,
* enthaltene Übungen,
* enthaltene Kombinationsübungen,
* Notizen oder Trainerhinweise,
* Sichtbarkeit,
* Freigabestatus.
### 7.3 Keine harte Abschnittsbindung
Ein Modul kann für einen Abschnitt empfohlen sein, z. B. „Aufwärmen“, darf aber nicht technisch darauf beschränkt werden.
Ein Modul kann:
* in einen Abschnitt eingefügt werden,
* als eigener Block auf Einheitsebene eingefügt werden,
* zwischen Abschnitten eingefügt werden,
* in ein Rahmenprogramm übernommen werden.
---
## 8. Planungslogik
### 8.1 Planungsblöcke
Für die Produktlogik braucht Shinkan den Begriff des Planungsblocks.
Ein Planungsblock ist ein zusammengehöriger Inhalt in einer Trainingseinheit.
Planungsblöcke können sein:
* eingefügtes Trainingsmodul,
* eingefügte Kombinationsübung,
* manuell gruppierter Block,
* später ggf. weitere Blocktypen.
### 8.2 Verhältnis zu Abschnitten
Ein Planungsblock kann einem Abschnitt zugeordnet sein, muss aber nicht vollständig in einem Abschnitt aufgehen.
Produktregel:
> Abschnitte gliedern die Einheit. Planungsblöcke gliedern den konkreten Trainingsinhalt.
### 8.3 Lokale Anpassbarkeit
Nach dem Einfügen muss ein Planungsblock lokal angepasst werden können:
* Dauer ändern,
* Übung austauschen,
* Station ergänzen,
* Hinweise anpassen,
* Reihenfolge ändern,
* Block auflösen,
* Block duplizieren,
* Block als neues Modul speichern.
Diese Änderungen betreffen nur die konkrete Einheit oder den konkreten Rahmen-Slot, nicht automatisch das Bibliotheksexemplar.
---
## 9. UX-Anforderungen
### 9.1 Inhalt hinzufügen
Im Planungseditor sollte der Trainer fachlich klar wählen können:
* Übung hinzufügen,
* Kombinationsübung hinzufügen,
* Trainingsmodul hinzufügen,
* Notiz hinzufügen,
* manuellen Block erstellen.
### 9.2 Modul erstellen
Ein Modul sollte auf mehreren Wegen entstehen können:
* leer anlegen,
* aus bestehendem Abschnitt speichern,
* aus markierten Übungen speichern,
* aus einem Teil eines alten Trainings speichern.
### 9.3 Kombinationsübung erstellen
Eine Kombinationsübung sollte geführt angelegt werden:
1. Grunddaten erfassen,
2. Methode wählen,
3. Archetyp wählen,
4. Slots / Stationen / Rollen definieren,
5. Übungen oder Pools zuordnen,
6. Ablaufprofil festlegen,
7. Coaching-Vorschau prüfen,
8. speichern.
### 9.4 Methoden auswählen
Die Methodenauswahl sollte Trainer unterstützen, nicht belasten.
Empfohlene UX:
* Hauptmethode prominent,
* Nebenmethoden optional,
* passende Methoden vorschlagen,
* Methoden kurz erklären,
* bei Kombinationsübungen passende Archetypen vorschlagen,
* keine Pflicht zur Überklassifizierung bei einfachen Übungen.
---
## 10. Coaching- und Assistenzmodus
### 10.1 Ziel
Der Coaching-Modus soll die Durchführung unterstützen, ohne den Trainer zu zwingen, exakt dem Plan zu folgen.
Grundsatz:
> Der Coaching-Modus gibt Orientierung, Zeitstruktur und Ablaufhilfe, bleibt aber in der Praxis flexibel.
### 10.2 Unterschiedliche Anzeige je Archetyp
| Archetyp | Coaching-Anzeige |
| -------------------- | -------------------------------------------- |
| Lineare Sequenz | Schrittfolge mit Weiter/Zurück. |
| Rotierender Zirkel | Stationen, Arbeitszeit, Wechselzeit, Runden. |
| Parallele Stationen | Erst Erklärübersicht, dann Parallelbetrieb. |
| Parcours | Stationen oder Wegpunkte zum Abhaken. |
| Partner-/Paarwechsel | Rollen, Aufgaben und Wechselhinweise. |
| Intervallblock | Globale Zeit, Intervallzähler, Aufgaben. |
| Freier Methodenblock | Kompakte Übersicht und manuelle Steuerung. |
### 10.3 Durchführungsdokumentation
Perspektivisch sollte dokumentierbar sein:
* was durchgeführt wurde,
* was übersprungen wurde,
* was verändert wurde,
* tatsächliche Dauer,
* Trainerhinweise,
* Reflexion,
* Vorschläge zur Verbesserung einer Übung oder eines Moduls.
Die konkrete technische Umsetzung wird nicht in dieser Spezifikation festgelegt.
---
## 11. Rahmenprogramm-Integration
Trainingsmodule und Kombinationsübungen müssen auch in Rahmenprogrammen nutzbar sein.
Regel:
> Was in einer konkreten Trainingseinheit geplant werden kann, sollte grundsätzlich auch in einem Rahmenprogramm oder Rahmen-Slot vorbereitet werden können, sofern es keine echte Durchführung voraussetzt.
Das betrifft insbesondere:
* Modul einfügen,
* Kombinationsübung einfügen,
* methodische Ausrichtung übernehmen,
* Slot-Pools vorbelegen,
* Dauer anpassen,
* später konkrete Einheit daraus ableiten.
Nicht in den Rahmen gehört:
* echte Durchführung,
* tatsächliche Dauer,
* spontane Trainingsnotizen,
* Nachbereitungsreflexion.
---
## 12. Governance
Für Methoden, Übungen, Kombinationsübungen und Module gelten abgestufte Sichtbarkeiten und Verantwortlichkeiten.
Empfohlene fachliche Ebenen:
* privat,
* Verein,
* offiziell,
* archiviert,
* Entwurf,
* freigegeben.
Normale Trainer sollen Inhalte nutzen und lokal anpassen können. Offizielle oder vereinsweite Vorlagen sollen nicht ungeprüft überschrieben werden.
Für Methoden ist eine besondere Qualitätskontrolle sinnvoll, weil sie als fachlicher Katalog für viele Übungen und Planungen wirken.
---
## 13. MVP-Empfehlung
### 13.1 Muss enthalten sein
* Trainingsmodule anlegen und wiederverwenden,
* Kombinationsübungen als fachliche Sonderform von Übungen,
* Methodenbezug mit Hauptmethode und optionalen Nebenmethoden,
* klare Trennung zwischen Methode, Archetyp und Ablaufprofil,
* mindestens folgende Archetypen:
* lineare Sequenz,
* rotierender Zirkel,
* freier Methodenblock,
* Planungsblöcke als fachliches Konzept,
* lokale Anpassbarkeit nach Einfügen,
* einfache Coaching-Ansicht.
### 13.2 Sollte vorbereitet werden
* parallele Stationen,
* Parcours,
* Partner-/Paarwechsel,
* Intervallblock,
* Durchführungsdokumentation,
* Rückfluss von Erfahrungswissen,
* Offline-/PWA-Nutzung,
* stärkere Suche nach Methoden und Archetypen.
### 13.3 Nicht im MVP
* vollständige technische Event-Historie jeder Planänderung,
* automatische Synchronisation alter Einheiten bei Vorlagenänderung,
* komplexe Verschachtelung von Modulen in Modulen,
* individuelles Athleten-Tracking,
* KI-generierte Trainingsplanung,
* verbindliche technische Tabellen- oder API-Architektur.
---
## 14. Arbeitsauftrag an den Coding Agent — fachliche Leitplanken
Der Coding Agent soll die bestehende Codebasis prüfen und auf dieser Grundlage eine technische Umsetzungsplanung erstellen.
Dabei soll er ausdrücklich:
1. bestehende Strukturen wiederverwenden, soweit sinnvoll,
2. keine unnötigen Refactorings auslösen,
3. bestehende Trainingsplanung nicht destabilisieren,
4. Migrationen schrittweise und rückwärtskompatibel planen,
5. vorhandene Methodendomäne berücksichtigen,
6. die Trennung zwischen Trainingsmethode, Archetyp und Ablaufprofil fachlich erhalten,
7. technische Alternativen mit Vor- und Nachteilen darstellen,
8. erst danach konkrete Tabellen, APIs und UI-Komponenten vorschlagen.
Die Spezifikation ist daher kein technisches Pflichtenheft, sondern ein fachlicher Rahmen.
---
## 15. Zusammenfassung der verbindlichen Produktlogik
1. Trainingsabschnitte sind die Makrostruktur der Einheit.
2. Kombinationsübungen sind keine Abschnitte.
3. Kombinationsübungen sind Sonderformen von Übungen.
4. Trainingsmodule sind Planungsbausteine.
5. Trainingsmethoden sind eigenständige fachliche Katalogobjekte.
6. Eine Übung hat eine Hauptmethode und optional Nebenmethoden.
7. Methoden-Archetypen beschreiben Ablaufmuster, nicht die Methode selbst.
8. Ablaufprofile konkretisieren den Archetyp für Planung und Coaching.
9. Einfügen aus Bibliotheken erzeugt lokal bearbeitbare Planungsinhalte.
10. Vorlagenänderungen verändern historische oder konkrete Planungen nicht automatisch.
11. Rahmenprogramme sollen dieselbe Planungslogik nutzen wie konkrete Einheiten.
12. Der Coding Agent entscheidet die technische Umsetzung anhand der bestehenden Codebasis.

View File

@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
| training_modules | `/api/training-modules*` | ja | `get_tenant_context` | ja | Bibliotheks-Module wie Vorlagen/Rahmen; POST Default `club_id` bei `visibility=club` |
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
| platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users |
@ -36,12 +37,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
Letzte Änderung: 2026-05-07 — Upload-Dedupe Papierkorb 409 + `reactivate`; DELETE …/media nur Verknüpfung.
Letzte Änderung: 2026-05-12 — Trainingsmodule (`/api/training-modules*`); Governance wie Planungsbibliothek.
---
### Changelog (Fortführung)
- **2026-05-12:** `training_modules` Router dokumentiert.
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.

View File

@ -0,0 +1,30 @@
# Umsetzungsplan: Trainingsmodule & Kombinationsübungen
**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, Stand 2026-05-12)
**Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`
**Stand dieses Dokuments:** 2026-05-12
## Ziele
Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung zu destabilisieren: schrittweise Migrationen, bestehende Sektions-/Item-Struktur (`training_unit_sections`, `training_unit_section_items`) beibehalten, Kopiersemantik bei Übernahmen.
## Phasenüberblick
| Phase | Inhalt | Status |
|-------|--------|--------|
| **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“ | **umgesetzt (MVP Schritt 1)** |
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, Haupt-/Nebenmethoden-M:N, Archetyp + Ablaufprofil | geplant |
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant |
| **4** | Coaching-Ansicht: Archetyp-spezifische Darstellung für MVP-Archetypen | geplant |
| **5** | Rahmenprogramm: Modulübernahme UX in Slot-Blueprint-Editor konsolidieren | geplant |
## Phase 1 (technische Notizen)
- **Governance:** `visibility`/`club_id`/`created_by` analog `training_plan_templates`; Listenfilter `library_content_visibility_sql`.
- **Übernahme:** Keine Live-Verknüpfung; Items werden kopiert; `source_training_module_id` dokumentiert Herkunft.
- **Schnittstelle Übernahme:** `section_order_index` entspricht der Reihenfolge der Abschnitte in der gespeicherten Einheit (0-basiert), konsistent zur Planungs-API.
## Pflege nach Merge
- `DATABASE_SCHEMA.md` bei größeren Schema-Erweiterungen ergänzen.
- `ACCESS_LAYER_ENDPOINT_AUDIT.md` bei neuen mandantenbezogenen Endpunkten fortpflegen.

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
app.include_router(auth.router)
app.include_router(profiles.router)
@ -209,6 +209,7 @@ app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(training_modules.router)
app.include_router(training_framework_programs.router)
app.include_router(catalogs.router)
app.include_router(maturity_models.router)

View File

@ -0,0 +1,60 @@
-- Migration 054: Trainingsmodule (Bibliothek / Planung) — Phase 1 MVP
-- Fachgrundlage: functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md
CREATE TABLE IF NOT EXISTS training_modules (
id SERIAL PRIMARY KEY,
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
created_by INT REFERENCES profiles(id) ON DELETE SET NULL,
title VARCHAR(200) NOT NULL,
summary TEXT,
goal TEXT,
recommended_duration_min INT,
target_group_notes TEXT,
deployment_context_notes TEXT,
primary_method_id INT REFERENCES training_methods(id) ON DELETE SET NULL,
visibility VARCHAR(50) NOT NULL DEFAULT 'club'
CHECK (visibility IN ('private', 'club', 'official')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_training_modules_club ON training_modules(club_id);
CREATE INDEX IF NOT EXISTS idx_training_modules_creator ON training_modules(created_by);
CREATE INDEX IF NOT EXISTS idx_training_modules_visibility ON training_modules(visibility);
CREATE INDEX IF NOT EXISTS idx_training_modules_method ON training_modules(primary_method_id)
WHERE primary_method_id IS NOT NULL;
DROP TRIGGER IF EXISTS training_modules_update ON training_modules;
CREATE TRIGGER training_modules_update
BEFORE UPDATE ON training_modules
FOR EACH ROW EXECUTE FUNCTION update_timestamp();
CREATE TABLE IF NOT EXISTS training_module_items (
id SERIAL PRIMARY KEY,
module_id INT NOT NULL REFERENCES training_modules(id) ON DELETE CASCADE,
order_index INT NOT NULL,
item_type VARCHAR(20) NOT NULL CHECK (item_type IN ('exercise', 'note')),
exercise_id INT REFERENCES exercises(id) ON DELETE SET NULL,
exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL,
planned_duration_min INT,
notes TEXT,
note_body TEXT,
UNIQUE (module_id, order_index),
CHECK (
(item_type = 'exercise' AND exercise_id IS NOT NULL AND note_body IS NULL)
OR
(item_type = 'note' AND exercise_id IS NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_training_module_items_module ON training_module_items(module_id);
CREATE INDEX IF NOT EXISTS idx_training_module_items_exercise ON training_module_items(exercise_id)
WHERE exercise_id IS NOT NULL;
-- Herkunft bei Übernahme aus Modul-Bibliothek (Kopie, keine Live-Verknüpfung)
ALTER TABLE training_unit_section_items
ADD COLUMN IF NOT EXISTS source_training_module_id INT REFERENCES training_modules(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_training_unit_section_items_source_module
ON training_unit_section_items(source_training_module_id)
WHERE source_training_module_id IS NOT NULL;

View File

@ -0,0 +1,381 @@
"""
Trainingsmodule wiederverwendbare Planungsbausteine (Bibliothek).
Governance wie TrainingsMikrovorlagen (`training_plan_templates`):
Liste/Detail über `library_content_visibility_sql`; Schreiben: Ersteller oder PlattformAdmin.
Siehe `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.
"""
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
)
router = APIRouter(prefix="/api", tags=["training_modules"])
def _has_planning_role(role: Optional[str]) -> bool:
return role in ("admin", "superadmin", "trainer", "user")
def _fetch_training_module_row(cur, mid: int) -> Dict[str, Any]:
cur.execute("SELECT * FROM training_modules WHERE id = %s", (mid,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="Trainingsmodul nicht gefunden")
return r2d(r)
def _module_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if not library_content_visible_to_profile(
cur,
profile_id,
row.get("visibility") or "club",
row.get("club_id"),
row.get("created_by"),
role,
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Modul")
def _module_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf dieses Modul ändern")
def _module_access(cur, mid: int, profile_id: int, role: str) -> Dict[str, Any]:
row = _fetch_training_module_row(cur, mid)
_module_assert_readable(cur, row, profile_id, role)
return row
def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
if not variant_id:
return
if not exercise_id:
raise HTTPException(
status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt"
)
cur.execute(
"SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s",
(variant_id, exercise_id),
)
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Variante passt nicht zur gewählten Übung")
def _optional_positive_int(val, field_name: str) -> Optional[int]:
if val is None or val == "":
return None
try:
i = int(val)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"Ungültige {field_name}")
if i < 1:
raise HTTPException(status_code=400, detail=f"Ungültige {field_name}")
return i
def _replace_module_items(cur, module_id: int, items_in: Optional[List[Any]]) -> None:
cur.execute("DELETE FROM training_module_items WHERE module_id = %s", (module_id,))
items_in = items_in or []
for i, raw in enumerate(items_in):
itype = raw.get("item_type")
if not itype:
itype = "exercise" if raw.get("exercise_id") else "note"
order_ix = raw.get("order_index")
if order_ix is None:
order_ix = i
order_ix = int(order_ix)
if itype == "note":
body = raw.get("note_body")
if body is None:
body = ""
cur.execute(
"""
INSERT INTO training_module_items (
module_id, order_index, item_type,
exercise_id, exercise_variant_id,
planned_duration_min, notes, note_body
) VALUES (%s, %s, 'note',
NULL, NULL, NULL, NULL, %s)
""",
(module_id, order_ix, body),
)
continue
eid = raw.get("exercise_id")
if not eid:
continue
eid = int(eid)
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
_validate_variant_for_exercise(cur, eid, vid)
cur.execute(
"""
INSERT INTO training_module_items (
module_id, order_index, item_type,
exercise_id, exercise_variant_id,
planned_duration_min, notes, note_body
) VALUES (%s, %s, 'exercise',
%s, %s, %s, %s, NULL)
""",
(
module_id,
order_ix,
eid,
vid,
raw.get("planned_duration_min"),
raw.get("notes"),
),
)
def load_training_module_for_apply(
cur, module_id: int, profile_id: int, role: Optional[str]
) -> Tuple[List[Dict[str, Any]], int]:
"""
Liest Modul inkl. Items für Übernahme in eine Trainingseinheit.
Returns (items_ordered, module_id).
Raises HTTPException bei 403/404.
"""
row = _fetch_training_module_row(cur, module_id)
_module_assert_readable(cur, row, profile_id, role or "")
cur.execute(
"""
SELECT item_type, exercise_id, exercise_variant_id,
planned_duration_min, notes, note_body
FROM training_module_items
WHERE module_id = %s
ORDER BY order_index ASC
""",
(module_id,),
)
raw_items = [r2d(x) for x in cur.fetchall()]
items: List[Dict[str, Any]] = []
for r in raw_items:
items.append(dict(r))
return items, int(module_id)
@router.get("/training-modules")
def list_training_modules(tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
vis_clause, vis_params = library_content_visibility_sql(
alias="m",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT m.*,
(SELECT COUNT(*) FROM training_module_items i WHERE i.module_id = m.id)
AS items_count
FROM training_modules m
WHERE ({vis_clause})
ORDER BY m.updated_at DESC NULLS LAST, m.title
""",
vis_params,
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/training-modules/{module_id}")
def get_training_module(module_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role)
cur.execute(
"""
SELECT *
FROM training_module_items
WHERE module_id = %s
ORDER BY order_index ASC
""",
(module_id,),
)
row["items"] = [r2d(r) for r in cur.fetchall()]
return row
@router.post("/training-modules")
def create_training_module(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingsmodule anlegen")
title = (data.get("title") or "").strip()
if not title:
raise HTTPException(status_code=400, detail="title ist Pflicht")
vis_raw = data.get("visibility")
visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club"
club_id = data.get("club_id")
if club_id in ("", []):
club_id = None
if visibility == "club" and club_id is None:
club_id = tenant.effective_club_id
primary_method_id = data.get("primary_method_id")
if primary_method_id in ("", []):
primary_method_id = None
if primary_method_id is not None:
primary_method_id = int(primary_method_id)
items_in = data.get("items") or []
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
if primary_method_id is not None:
cur.execute("SELECT 1 FROM training_methods WHERE id = %s", (primary_method_id,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Trainingsmethode nicht gefunden")
cur.execute(
"""
INSERT INTO training_modules (
club_id, created_by, title, summary, goal,
recommended_duration_min, target_group_notes, deployment_context_notes,
primary_method_id, visibility
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
club_id,
profile_id,
title,
(data.get("summary") or "").strip() or None,
data.get("goal"),
data.get("recommended_duration_min"),
data.get("target_group_notes"),
data.get("deployment_context_notes"),
primary_method_id,
visibility,
),
)
mid = cur.fetchone()["id"]
_replace_module_items(cur, mid, items_in if isinstance(items_in, list) else [])
conn.commit()
return get_training_module(mid, tenant)
@router.put("/training-modules/{module_id}")
def update_training_module(
module_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_training_module_row(cur, module_id)
_module_assert_writable(cur, row_prev, profile_id, role)
merged_vis = row_prev.get("visibility") or "club"
merged_club = row_prev.get("club_id")
if "visibility" in data:
v_in = data.get("visibility")
if not isinstance(v_in, str) or v_in not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="visibility ungültig")
merged_vis = v_in
if "club_id" in data:
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
fields: List[str] = []
params: List[Any] = []
if "title" in data:
t = data.get("title")
t = t.strip() if isinstance(t, str) else ""
if not t:
raise HTTPException(status_code=400, detail="title ist Pflicht")
fields.append("title = %s")
params.append(t)
for col in ("summary", "goal", "target_group_notes", "deployment_context_notes"):
if col in data:
fields.append(f"{col} = %s")
v = data.get(col)
if col == "summary" and isinstance(v, str):
v = v.strip() or None
params.append(v)
if "recommended_duration_min" in data:
fields.append("recommended_duration_min = %s")
params.append(data.get("recommended_duration_min"))
if "primary_method_id" in data:
pm = data.get("primary_method_id")
if pm in ("", [], None):
fields.append("primary_method_id = %s")
params.append(None)
else:
pm = int(pm)
cur.execute("SELECT 1 FROM training_methods WHERE id = %s", (pm,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Trainingsmethode nicht gefunden")
fields.append("primary_method_id = %s")
params.append(pm)
if "club_id" in data:
fields.append("club_id = %s")
params.append(merged_club)
if "visibility" in data:
fields.append("visibility = %s")
params.append(merged_vis)
if fields:
fields.append("updated_at = NOW()")
params.append(module_id)
cur.execute(
f"UPDATE training_modules SET {', '.join(fields)} WHERE id = %s",
tuple(params),
)
if "items" in data:
items_in = data["items"]
_replace_module_items(cur, module_id, items_in if isinstance(items_in, list) else [])
conn.commit()
return get_training_module(module_id, tenant)
@router.delete("/training-modules/{module_id}")
def delete_training_module(module_id: int, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row_del = _fetch_training_module_row(cur, module_id)
_module_assert_writable(cur, row_del, profile_id, role)
cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,))
conn.commit()
return {"ok": True}

View File

@ -17,6 +17,7 @@ from club_tenancy import (
is_platform_admin,
library_content_visible_to_profile,
)
from routers.training_modules import load_training_module_for_apply
router = APIRouter(prefix="/api", tags=["training_planning"])
@ -568,6 +569,93 @@ def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]:
return unit
def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int:
cur.execute(
"""
SELECT id FROM training_unit_sections
WHERE training_unit_id = %s AND order_index = %s
""",
(unit_id, section_order_index),
)
r = cur.fetchone()
if not r:
raise HTTPException(
status_code=400, detail="Abschnitt für diese Reihenfolge nicht gefunden"
)
return int(r["id"])
def _append_copied_module_items_to_section(
cur,
section_id: int,
module_items: List[Dict[str, Any]],
source_training_module_id: int,
) -> None:
"""Hängt kopierte ModulItems ans Ende eines Abschnitts (section_order_index in API)."""
cur.execute(
"""
SELECT COALESCE(MAX(order_index), -1) AS mo
FROM training_unit_section_items
WHERE section_id = %s
""",
(section_id,),
)
row = cur.fetchone()
start = int(row["mo"]) + 1 if row and row["mo"] is not None else 0
for i, mi in enumerate(module_items):
oi = start + i
itype = mi.get("item_type")
if itype == "note":
body = mi.get("note_body")
if body is None:
body = ""
cur.execute(
"""
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, source_training_module_id
) VALUES (%s, %s, 'note',
NULL, NULL, NULL, NULL, NULL, NULL, %s, %s)
""",
(section_id, oi, body, source_training_module_id),
)
continue
eid = mi.get("exercise_id")
if not eid:
continue
eid = int(eid)
vid = mi.get("exercise_variant_id")
if vid is not None:
vid = int(vid)
else:
vid = None
_validate_variant_for_exercise(cur, eid, vid)
cur.execute(
"""
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, source_training_module_id
) VALUES (%s, %s, 'exercise',
%s, %s, %s, NULL, %s, NULL, NULL, %s)
""",
(
section_id,
oi,
eid,
vid,
mi.get("planned_duration_min"),
mi.get("notes"),
source_training_module_id,
),
)
def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], start_order: int = 0):
if items_in is None:
items_in = []
@ -1443,6 +1531,46 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
return unit
@router.post("/training-units/{unit_id}/apply-training-module")
def apply_training_module_to_training_unit(
unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
"""Kopiert die Positionen eines Trainingsmoduls ans Ende eines Abschnitts (lokal bearbeitbar)."""
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Module übernehmen")
module_id_raw = data.get("module_id")
if module_id_raw is None or module_id_raw == "":
raise HTTPException(status_code=400, detail="module_id ist Pflicht")
try:
module_id = int(module_id_raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="module_id ungültig")
soy = data.get("section_order_index")
try:
section_order_index = int(soy)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="section_order_index ist Pflicht (Ganzzahl)")
if section_order_index < 0:
raise HTTPException(status_code=400, detail="section_order_index ungültig")
with get_db() as conn:
cur = get_cursor(conn)
unit_row = _training_unit_guard_row(cur, unit_id)
_assert_training_unit_permission(cur, unit_row, profile_id, role)
section_id = _resolve_training_unit_section_id(cur, unit_id, section_order_index)
mod_items, src_mid = load_training_module_for_apply(cur, module_id, profile_id, role)
_append_copied_module_items_to_section(cur, section_id, mod_items, src_mid)
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
conn.commit()
return get_training_unit(unit_id, tenant)
@router.post("/training-units")
def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
profile_id = tenant.profile_id

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.96"
APP_VERSION = "0.8.97"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260511053"
DB_SCHEMA_VERSION = "20260512054"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -24,7 +24,8 @@ MODULE_VERSIONS = {
"exercises": "2.23.0", # P-11: enrich_exercise_detail + download_file blocken Legal-Hold-Assets (451)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
"planning": "0.9.0", # apply-training-module; Trainingsmodule-Bibliothek (Phase 1)
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
@ -34,6 +35,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.97",
"date": "2026-05-12",
"changes": [
"Trainingsmodule (Phase 1): Bibliothek `training_modules` + `training_module_items` (Migration 054); REST `/api/training-modules`; Übernahme in Einheiten per `POST /api/training-units/{id}/apply-training-module`; Herkunft `source_training_module_id` auf kopierten Sektions-Items; UI unter /planning/training-modules und Übernahme-Dialog in der Trainingsplanung.",
"Umsetzungsplan: `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.",
],
},
{
"version": "0.8.96",
"date": "2026-05-12",

View File

@ -26,6 +26,8 @@ import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage'
import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage'
import TrainingModulesListPage from './pages/TrainingModulesListPage'
import TrainingModuleEditPage from './pages/TrainingModuleEditPage'
import TrainingUnitRunPage from './pages/TrainingUnitRunPage'
import TrainingCoachPage from './pages/TrainingCoachPage'
import AdminCatalogsPage from './pages/AdminCatalogsPage'
@ -199,6 +201,9 @@ function AppRoutes() {
<Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs" element={<TrainingFrameworkProgramsListPage />} />
<Route path="planning/training-modules/new" element={<TrainingModuleEditPage />} />
<Route path="planning/training-modules/:id" element={<TrainingModuleEditPage />} />
<Route path="planning/training-modules" element={<TrainingModulesListPage />} />
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />

View File

@ -0,0 +1,443 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
function nextLocalKey() {
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function swapItems(arr, i, j) {
if (i === j || i < 0 || j < 0 || i >= arr.length || j >= arr.length) return [...arr]
const n = [...arr]
;[n[i], n[j]] = [n[j], n[i]]
return n
}
export default function TrainingModuleEditPage() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const isNew = !routeId || routeId === 'new'
const moduleId = !isNew ? parseInt(routeId, 10) : NaN
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [methods, setMethods] = useState([])
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState('')
const [title, setTitle] = useState('')
const [summary, setSummary] = useState('')
const [goal, setGoal] = useState('')
const [recommendedDurationMin, setRecommendedDurationMin] = useState('')
const [targetGroupNotes, setTargetGroupNotes] = useState('')
const [deploymentContextNotes, setDeploymentContextNotes] = useState('')
const [visibility, setVisibility] = useState('club')
const [clubIdField, setClubIdField] = useState('')
const [primaryMethodId, setPrimaryMethodId] = useState('')
const [items, setItems] = useState([])
const itemsPayload = items.map((it, i) => {
if (it.item_type === 'note') {
return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' }
}
const vid =
it.exercise_variant_id !== '' && it.exercise_variant_id != null
? parseInt(it.exercise_variant_id, 10)
: null
return {
item_type: 'exercise',
order_index: i,
exercise_id: parseInt(it.exercise_id, 10),
exercise_variant_id: Number.isFinite(vid) ? vid : null,
planned_duration_min:
it.planned_duration_min !== '' && it.planned_duration_min != null
? parseInt(String(it.planned_duration_min), 10)
: null,
notes: it.notes?.trim() ? it.notes.trim() : null,
}
})
const loadCatalogs = useCallback(async () => {
try {
const m = await api.listMethods({})
setMethods(Array.isArray(m) ? m : [])
} catch {
setMethods([])
}
}, [])
useEffect(() => {
loadCatalogs()
}, [loadCatalogs])
useEffect(() => {
if (isNew || !Number.isFinite(moduleId)) {
setLoading(false)
return
}
let cancelled = false
async function load() {
setLoading(true)
setError('')
try {
const m = await api.getTrainingModule(moduleId)
if (cancelled) return
setTitle((m.title || '').trim())
setSummary((m.summary || '').trim())
setGoal(m.goal || '')
setRecommendedDurationMin(
m.recommended_duration_min != null && m.recommended_duration_min !== ''
? String(m.recommended_duration_min)
: ''
)
setTargetGroupNotes(m.target_group_notes || '')
setDeploymentContextNotes(m.deployment_context_notes || '')
setVisibility((m.visibility || 'club').trim())
setClubIdField(m.club_id != null ? String(m.club_id) : '')
setPrimaryMethodId(m.primary_method_id != null ? String(m.primary_method_id) : '')
const nextItems = []
for (const row of Array.isArray(m.items) ? m.items : []) {
if (row.item_type === 'note') {
nextItems.push({ localKey: nextLocalKey(), item_type: 'note', note_body: row.note_body || '' })
continue
}
const ex = await hydrateExercisePlanningRow({
id: row.exercise_id,
title: '',
variants: [],
})
if (ex) {
ex.localKey = nextLocalKey()
if (row.exercise_variant_id) ex.exercise_variant_id = String(row.exercise_variant_id)
ex.planned_duration_min =
row.planned_duration_min != null && row.planned_duration_min !== ''
? String(row.planned_duration_min)
: ''
ex.notes = row.notes || ''
nextItems.push(ex)
}
}
setItems(nextItems)
} catch (e) {
if (!cancelled) setError(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [isNew, moduleId])
const buildBody = () => {
const cid =
visibility === 'club' && clubIdField !== '' ? parseInt(clubIdField, 10) : null
const pm =
primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null
return {
title: title.trim(),
summary: summary.trim() || null,
goal: goal.trim() || null,
recommended_duration_min:
recommendedDurationMin !== '' ? parseInt(recommendedDurationMin, 10) : null,
target_group_notes: targetGroupNotes.trim() || null,
deployment_context_notes: deploymentContextNotes.trim() || null,
visibility,
club_id:
cid != null && Number.isFinite(cid) && cid >= 1
? cid
: visibility === 'club'
? undefined
: null,
primary_method_id:
pm != null && Number.isFinite(pm) && pm >= 1 ? pm : null,
items: itemsPayload.filter((row) =>
row.item_type === 'note' ? true : Number.isFinite(row.exercise_id) && row.exercise_id >= 1
),
}
}
const handleSave = async (e) => {
e.preventDefault()
if (!title.trim()) {
alert('Titel ist Pflicht.')
return
}
setSaving(true)
setError('')
try {
const body = buildBody()
if (isNew) {
const created = await api.createTrainingModule(body)
navigate(`/planning/training-modules/${created.id}`, { replace: true })
} else {
await api.updateTrainingModule(moduleId, body)
alert('Trainingsmodul gespeichert.')
}
} catch (err) {
setError(err.message || 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
const pickExercise = async (ex) => {
if (!ex?.id) return
const row = await hydrateExercisePlanningRow(ex)
if (row) row.localKey = nextLocalKey()
if (row) setItems((prev) => [...prev, row])
setPickerOpen(false)
}
return (
<div className="app-page">
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/training-modules" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
Zurück zur ModulBibliothek
</Link>
</p>
<h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '40rem' }}>
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie).
</p>
{error ? <p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p> : null}
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : (
<form className="card" style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }} onSubmit={handleSave}>
<div className="form-row">
<label className="form-label">Titel *</label>
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<textarea className="form-input" rows={2} value={summary} onChange={(e) => setSummary(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Ziel</label>
<textarea className="form-input" rows={3} value={goal} onChange={(e) => setGoal(e.target.value)} />
</div>
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
<div>
<label className="form-label">Empfohlene Dauer (Min.)</label>
<input
className="form-input"
type="number"
min={0}
value={recommendedDurationMin}
onChange={(e) => setRecommendedDurationMin(e.target.value)}
/>
</div>
<div>
<label className="form-label">Primäre Trainingsmethode</label>
<select
className="form-input"
value={primaryMethodId}
onChange={(e) => setPrimaryMethodId(e.target.value)}
>
<option value=""></option>
{methods.map((m) => (
<option key={m.id} value={String(m.id)}>
{(m.name || '').trim() || `Methode #${m.id}`}
</option>
))}
</select>
</div>
</div>
<div className="form-row">
<label className="form-label">Empfohlene Zielgruppe (Freitext)</label>
<textarea
className="form-input"
rows={2}
value={targetGroupNotes}
onChange={(e) => setTargetGroupNotes(e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Einsatz / Kontext</label>
<textarea
className="form-input"
rows={2}
value={deploymentContextNotes}
onChange={(e) => setDeploymentContextNotes(e.target.value)}
/>
</div>
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<select className="form-input" value={visibility} onChange={(e) => setVisibility(e.target.value)}>
<option value="private">Privat</option>
<option value="club">Vereinsintern</option>
<option value="official">Offiziell</option>
</select>
</div>
<div>
<label className="form-label">VereinsID (optional, bei VereinsSichtbarkeit)</label>
<input
className="form-input"
type="number"
min={1}
value={clubIdField}
onChange={(e) => setClubIdField(e.target.value)}
placeholder="Leer = aktiver Verein (Server)"
/>
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1.25rem 0' }} />
<h3 style={{ fontSize: '1rem', marginBottom: '0.75rem' }}>Positionen</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setPickerOpen(true)}>
Übung hinzufügen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() =>
setItems((prev) => [...prev, { localKey: nextLocalKey(), item_type: 'note', note_body: '' }])
}
>
Notiz hinzufügen
</button>
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}>
{items.map((it, idx) => (
<li key={it.localKey || idx} style={{ padding: '10px', background: 'var(--surface2)', borderRadius: '8px' }}>
{it.item_type === 'note' ? (
<div>
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text3)' }}>Notiz</span>
<textarea
className="form-input"
style={{ marginTop: '8px' }}
rows={2}
value={it.note_body}
onChange={(e) => {
const v = e.target.value
setItems((prev) =>
prev.map((x, j) =>
j === idx && x.item_type === 'note' ? { ...x, note_body: v } : x
)
)
}}
/>
</div>
) : (
<>
<div style={{ fontWeight: 700, wordBreak: 'break-word', marginBottom: '8px' }}>
{(it.exercise_title || '').trim() || `Übung #${it.exercise_id}`}
</div>
<div style={{ display: 'grid', gap: '8px', gridTemplateColumns: '1fr 1fr' }}>
<div>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Variante
</label>
<select
className="form-input"
value={String(it.exercise_variant_id ?? '')}
onChange={(e) => {
const v = e.target.value
setItems((prev) =>
prev.map((row, j) =>
j === idx && row.item_type === 'exercise' ? { ...row, exercise_variant_id: v } : row
)
)
}}
>
<option value=""></option>
{(it.variants || []).map((v) => (
<option key={v.id} value={String(v.id)}>
{(v.name || '').trim() || `Variante #${v.id}`}
</option>
))}
</select>
</div>
<div>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Minuten (plan)
</label>
<input
className="form-input"
type="number"
min={0}
value={it.planned_duration_min}
onChange={(e) =>
setItems((prev) =>
prev.map((row, j) =>
j === idx && row.item_type === 'exercise'
? { ...row, planned_duration_min: e.target.value }
: row
)
)
}
/>
</div>
</div>
<div style={{ marginTop: '8px' }}>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Hinweis zur Position
</label>
<input
className="form-input"
value={it.notes ?? ''}
onChange={(e) =>
setItems((prev) =>
prev.map((row, j) =>
j === idx && row.item_type === 'exercise' ? { ...row, notes: e.target.value } : row
)
)
}
/>
</div>
</>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
disabled={idx < 1}
onClick={() => setItems((prev) => swapItems(prev, idx, idx - 1))}
>
Nach oben
</button>
<button
type="button"
className="btn btn-secondary"
disabled={idx >= items.length - 1}
onClick={() => setItems((prev) => swapItems(prev, idx, idx + 1))}
>
Nach unten
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setItems((prev) => prev.filter((_, j) => j !== idx))}
>
Entfernen
</button>
</div>
</li>
))}
</ul>
<div style={{ display: 'flex', gap: '10px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern …' : isNew ? 'Anlegen' : 'Speichern'}
</button>
<Link to="/planning/training-modules" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Abbrechen
</Link>
</div>
</form>
)}
<ExercisePickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelectExercise={pickExercise} />
</div>
)
}

View File

@ -0,0 +1,131 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
export default function TrainingModulesListPage() {
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.listTrainingModules()
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(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
try {
await api.deleteTrainingModule(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 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsmodule
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
Wiederverwendbare Übungsfolgen für die{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
. Übernahme in eine Einheit erfolgt dort als lokale Kopie (mit Herkunftsmarkierung).
</p>
</div>
<Link to="/planning/training-modules/new" className="btn btn-primary" style={{ textDecoration: 'none' }}>
Neues Modul
</Link>
</div>
{error ? (
<p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p>
) : null}
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : rows.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0, color: 'var(--text2)' }}>Noch keine Module angelegt.</p>
</div>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}>
{rows.map((r) => (
<li key={r.id} className="card" style={{ padding: '1rem 1.15rem' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
gap: '10px',
alignItems: 'flex-start',
}}
>
<div style={{ flex: '1 1 220px', minWidth: 0 }}>
<Link
to={`/planning/training-modules/${r.id}`}
style={{
fontWeight: 700,
fontSize: '1.05rem',
color: 'var(--accent-dark)',
textDecoration: 'none',
wordBreak: 'break-word',
}}
>
{(r.title || '').trim() || `Modul #${r.id}`}
</Link>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
{(r.summary || '').trim() || '—'}{' '}
<span style={{ color: 'var(--text3)' }}>
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
</span>
</p>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.8rem', color: 'var(--text3)' }}>
Sichtbarkeit: <strong>{r.visibility || '—'}</strong>
</p>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<Link
className="btn btn-secondary"
style={{ textDecoration: 'none' }}
to={`/planning/training-modules/${r.id}`}
>
Bearbeiten
</Link>
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
Löschen
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -144,6 +144,13 @@ function TrainingPlanningPage() {
const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
const [moduleApplyOpen, setModuleApplyOpen] = useState(false)
const [moduleApplyBusy, setModuleApplyBusy] = useState(false)
const [moduleApplyList, setModuleApplyList] = useState([])
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
const [moduleApplyErr, setModuleApplyErr] = useState('')
const [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater)
const [planView, setPlanView] = useState('list')
@ -662,6 +669,58 @@ function TrainingPlanningPage() {
}
}
const openModuleApplyModal = useCallback(async () => {
setModuleApplyErr('')
setModuleApplySectionIx(0)
setModuleApplyOpen(true)
try {
const list = await api.listTrainingModules()
const arr = Array.isArray(list) ? list : []
setModuleApplyList(arr)
setModuleApplyModuleId(arr.length ? String(arr[0].id) : '')
} catch (e) {
setModuleApplyErr(e.message || 'Module konnten nicht geladen werden')
setModuleApplyList([])
}
}, [])
const handleApplyTrainingModuleConfirm = useCallback(async () => {
if (!editingUnit?.id) return
const mid = parseInt(moduleApplyModuleId, 10)
if (!Number.isFinite(mid)) {
alert('Bitte ein Trainingsmodul wählen.')
return
}
let secIx = parseInt(moduleApplySectionIx, 10)
if (!Number.isFinite(secIx)) secIx = 0
if (!formData.sections?.length) {
alert('Keine Abschnitte im Formular.')
return
}
if (secIx < 0 || secIx >= formData.sections.length) secIx = 0
setModuleApplyBusy(true)
setModuleApplyErr('')
try {
await api.applyTrainingModuleToTrainingUnit(editingUnit.id, {
module_id: mid,
section_order_index: secIx,
})
await handleEdit({ id: editingUnit.id })
setModuleApplyOpen(false)
} catch (e) {
setModuleApplyErr(e.message || 'Übernehmen fehlgeschlagen')
} finally {
setModuleApplyBusy(false)
}
}, [
editingUnit?.id,
moduleApplyModuleId,
moduleApplySectionIx,
formData.sections?.length,
handleEdit,
])
const handleTakeLead = async (unit) => {
if (!user?.id) return
try {
@ -972,12 +1031,19 @@ function TrainingPlanningPage() {
</p>
<div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}>
<p style={{ margin: 0, fontSize: '0.92rem', color: 'var(--text2)' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.92rem', color: 'var(--text2)' }}>
Mehrere Einheiten strukturieren auf einmal:{' '}
<Link to="/planning/framework-programs" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsrahmenprogramme
</Link>{' '}
(Ziele, Slots, Übungen als Vorlage).
(Ziele, Sessions, VorlagenAblauf).
</p>
<p style={{ margin: 0, fontSize: '0.92rem', color: 'var(--text2)' }}>
Wiederverwendbare Blöcke innerhalb einer Einheit:{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
(übernahme als Kopie beim Bearbeiten einer Einheit).
</p>
</div>
{!loading && groups.length === 0 && (
@ -1796,6 +1862,118 @@ function TrainingPlanningPage() {
</div>
) : null}
{moduleApplyOpen && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1010,
padding: '1rem',
overflowY: 'auto',
}}
role="presentation"
onMouseDown={(ev) => ev.target === ev.currentTarget && !moduleApplyBusy && setModuleApplyOpen(false)}
>
<div
className="card"
style={{
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(480px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
role="dialog"
aria-labelledby="module-apply-title"
>
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
Trainingsmodul übernehmen
</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Der Inhalt wird <strong>kopiert</strong> und ans Ende des gewählten Abschnitts angehängt (Herkunft wird
gespeichert). Anschließend kannst du ihn lokal bearbeiten.
</p>
{moduleApplyErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{moduleApplyErr}</p>
) : null}
<div className="form-row">
<label className="form-label">Modul</label>
<select
className="form-input"
value={moduleApplyModuleId}
onChange={(e) => setModuleApplyModuleId(e.target.value)}
disabled={moduleApplyBusy || !moduleApplyList.length}
>
{!moduleApplyList.length ? (
<option value="">Keine Module verfügbar</option>
) : null}
{moduleApplyList.map((m) => (
<option key={m.id} value={String(m.id)}>
{(m.title || '').trim() || `Modul #${m.id}`}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">ZielAbschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(moduleApplySectionIx)}
onChange={(e) => setModuleApplySectionIx(parseInt(e.target.value, 10))}
disabled={moduleApplyBusy || !formData.sections?.length}
>
{(formData.sections || []).map((s, i) => (
<option key={`sec-opt-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
</option>
))}
</select>
</div>
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={moduleApplyBusy}
onClick={() => !moduleApplyBusy && setModuleApplyOpen(false)}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={moduleApplyBusy} onClick={handleApplyTrainingModuleConfirm}>
{moduleApplyBusy ? 'Übernehmen …' : 'Übernehmen'}
</button>
</div>
<p style={{ margin: '1rem 0 0', fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Neue Module kannst du unter{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
anlegen.
</p>
</div>
</div>
)}
{frameworkImportOpen && (
<div
style={{
@ -2291,9 +2469,21 @@ function TrainingPlanningPage() {
<TrainingUnitSectionsEditor
heading="Abschnitte & Übungen"
headingAccessory={
<>
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
Vorlage aus Aufbau speichern
</button>
{editingUnit?.id ? (
<button
type="button"
className="btn btn-secondary"
onClick={openModuleApplyModal}
title="Übungen und Notizen aus einem Modul ans Ende eines Abschnitts kopieren"
>
Aus Modul übernehmen
</button>
) : null}
</>
}
sections={formData.sections}
wideExerciseGrid

View File

@ -1327,6 +1327,40 @@ export async function deleteTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
}
export async function listTrainingModules() {
return request('/api/training-modules')
}
export async function getTrainingModule(id) {
return request(`/api/training-modules/${id}`)
}
export async function createTrainingModule(data) {
return request('/api/training-modules', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingModule(id, data) {
return request(`/api/training-modules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingModule(id) {
return request(`/api/training-modules/${id}`, { method: 'DELETE' })
}
/** Kopiert Modul-Inhalte ans Ende eines Abschnitts (section_order_index 0-basiert). */
export async function applyTrainingModuleToTrainingUnit(unitId, data) {
return request(`/api/training-units/${unitId}/apply-training-module`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function listTrainingFrameworkPrograms() {
return request('/api/training-framework-programs')
}
@ -1478,6 +1512,12 @@ export const api = {
createTrainingPlanTemplate,
updateTrainingPlanTemplate,
deleteTrainingPlanTemplate,
listTrainingModules,
getTrainingModule,
createTrainingModule,
updateTrainingModule,
deleteTrainingModule,
applyTrainingModuleToTrainingUnit,
listTrainingFrameworkPrograms,
getTrainingFrameworkProgram,
createTrainingFrameworkProgram,