diff --git a/backend/version.py b/backend/version.py
index 597299b..b885c74 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.146"
+APP_VERSION = "0.8.147"
BUILD_DATE = "2026-05-19"
DB_SCHEMA_VERSION = "20260516065"
@@ -37,7 +37,12 @@ MODULE_VERSIONS = {
CHANGELOG = [
{
- "version": "0.8.146",
+ "version": "0.8.147",
+ "date": "2026-05-19",
+ "changes": [
+ "Planung: Liste – Rahmen-Session & Übungen→Modul; Dialog Modul aus Einheit; klarere Rahmen-Unit-ID aus Liste",
+ ],
+ },
"date": "2026-05-19",
"changes": [
"Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework",
diff --git a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
new file mode 100644
index 0000000..ad00fd1
--- /dev/null
+++ b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
@@ -0,0 +1,326 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import api from '../../utils/api'
+import { useToast } from '../../context/ToastContext'
+import { useAuth } from '../../context/AuthContext'
+import { activeClubMemberships } from '../../utils/activeClub'
+import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit'
+
+/**
+ * Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl).
+ */
+export default function SaveExercisesAsModuleModal({
+ open,
+ onClose,
+ unitId,
+ planningModalClubId,
+ onSuccess,
+}) {
+ const navigate = useNavigate()
+ const toast = useToast()
+ const { user } = useAuth()
+ const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
+ const roleLc = String(user?.role || '').toLowerCase()
+ const isSuperadmin = roleLc === 'superadmin'
+
+ const [loading, setLoading] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [loadErr, setLoadErr] = useState('')
+ const [unitLabel, setUnitLabel] = useState('')
+ const [candidates, setCandidates] = useState([])
+ const [selected, setSelected] = useState(() => [])
+
+ const [title, setTitle] = useState('')
+ const [visibility, setVisibility] = useState('club')
+ const [clubId, setClubId] = useState('')
+
+ const resetLocal = useCallback(() => {
+ setLoadErr('')
+ setUnitLabel('')
+ setCandidates([])
+ setSelected([])
+ setTitle('')
+ setVisibility('club')
+ setClubId('')
+ }, [])
+
+ useEffect(() => {
+ if (!open || !unitId) {
+ resetLocal()
+ return
+ }
+ if (planningModalClubId != null && planningModalClubId !== '') {
+ setClubId(String(planningModalClubId))
+ } else if (memberClubs.length === 1) {
+ setClubId(String(memberClubs[0].id))
+ }
+ setLoading(true)
+ setLoadErr('')
+ api
+ .getTrainingUnit(unitId)
+ .then((u) => {
+ const dateStr = (u.planned_date || '').trim() || 'Training'
+ setUnitLabel(dateStr)
+ setTitle(`Modul · ${dateStr}`)
+ const c = collectExercisePlacementsForModule(u)
+ setCandidates(c)
+ setSelected(c.map(() => true))
+ })
+ .catch((e) => {
+ setLoadErr(e.message || 'Einheit konnte nicht geladen werden')
+ setCandidates([])
+ setSelected([])
+ })
+ .finally(() => setLoading(false))
+ }, [open, unitId, planningModalClubId, memberClubs.length, resetLocal])
+
+ const toggleOne = (idx) => {
+ setSelected((prev) => {
+ const next = [...prev]
+ next[idx] = !next[idx]
+ return next
+ })
+ }
+
+ const setAll = (on) => {
+ setSelected(candidates.map(() => on))
+ }
+
+ const selectedCount = selected.filter(Boolean).length
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!unitId || submitting) return
+ const itemsPayload = []
+ let oi = 0
+ for (let i = 0; i < candidates.length; i += 1) {
+ if (!selected[i]) continue
+ const c = candidates[i]
+ itemsPayload.push({
+ item_type: 'exercise',
+ order_index: oi,
+ exercise_id: c.exercise_id,
+ exercise_variant_id: c.exercise_variant_id,
+ planned_duration_min: c.planned_duration_min,
+ notes: c.notes,
+ })
+ oi += 1
+ }
+ if (!itemsPayload.length) {
+ toast.error('Mindestens eine Übung auswählen.')
+ return
+ }
+ const tit = (title || '').trim()
+ if (!tit) {
+ toast.error('Bitte einen Modultitel angeben.')
+ return
+ }
+ let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null
+ if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
+ toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
+ return
+ }
+ if (visibility !== 'club') cid = null
+
+ setSubmitting(true)
+ try {
+ const created = await api.createTrainingModule({
+ title: tit,
+ visibility,
+ club_id: cid,
+ items: itemsPayload,
+ })
+ toast.success('Trainingsmodul gespeichert.')
+ if (created?.id) {
+ navigate(`/planning/training-modules/${created.id}`)
+ }
+ onSuccess?.()
+ onClose()
+ } catch (err) {
+ toast.error(err.message || 'Speichern fehlgeschlagen')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ if (!open) return null
+
+ return (
+
+
+
Übungen als Trainingsmodul
+
+ Es werden die gespeicherten Übungspositionen der Einheit vom{' '}
+ {unitLabel || '…'} verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand
+ brauchst.
+
+
+ {loading ? (
+
Laden …
+ ) : loadErr ? (
+
{loadErr}
+ ) : candidates.length === 0 ? (
+
In dieser Einheit sind keine Übungen im Ablauf hinterlegt.
+ ) : (
+
+ )}
+
+ {loading ? (
+
+
+ Abbrechen
+
+
+ ) : null}
+
+ {!loading && loadErr ? (
+
+
+ Schließen
+
+
+ ) : null}
+
+ {!loading && !loadErr && candidates.length === 0 ? (
+
+
+ Schließen
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx
index f17d57d..29cd11b 100644
--- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx
+++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx
@@ -12,6 +12,7 @@ import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal'
+import SaveExercisesAsModuleModal from './SaveExercisesAsModuleModal'
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
import {
defaultSection,
@@ -53,6 +54,10 @@ function TrainingPlanningPageRoot() {
const [showModal, setShowModal] = useState(false)
const [editingUnit, setEditingUnit] = useState(null)
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
+ /** Einheit für „Rahmen-Session“-Dialog (Liste oder geöffnetes Bearbeiten) */
+ const [publishFrameworkUnitId, setPublishFrameworkUnitId] = useState(null)
+ const [saveModuleOpen, setSaveModuleOpen] = useState(false)
+ const [saveModuleUnitId, setSaveModuleUnitId] = useState(null)
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
@@ -1853,6 +1858,28 @@ function TrainingPlanningPageRoot() {
handleEdit(unit)}>
Bearbeiten
+ {
+ setPublishFrameworkUnitId(unit.id)
+ setPublishFrameworkOpen(true)
+ }}
+ >
+ Rahmen-Session…
+
+ {
+ setSaveModuleUnitId(unit.id)
+ setSaveModuleOpen(true)
+ }}
+ >
+ Übungen → Modul…
+
{mayConfigureSessionAssignments(unit) ? (
setPublishFrameworkOpen(false)}
- onSuccess={() => setShowModal(false)}
- unitId={editingUnit?.id}
+ onClose={() => {
+ setPublishFrameworkOpen(false)
+ setPublishFrameworkUnitId(null)
+ }}
+ onSuccess={() => {
+ setShowModal(false)
+ setPublishFrameworkUnitId(null)
+ }}
+ unitId={publishFrameworkUnitId ?? editingUnit?.id}
+ planningModalClubId={planningModalClubId}
+ />
+
+ {
+ setSaveModuleOpen(false)
+ setSaveModuleUnitId(null)
+ }}
+ onSuccess={() => {
+ setShowModal(false)
+ setSaveModuleUnitId(null)
+ }}
+ unitId={saveModuleUnitId ?? editingUnit?.id}
planningModalClubId={planningModalClubId}
/>
@@ -1985,7 +2032,18 @@ function TrainingPlanningPageRoot() {
sectionsEditMode={sectionsEditMode}
setSectionsEditMode={setSectionsEditMode}
onSaveAsTemplate={handleSaveAsTemplate}
- onRequestPublishToFramework={() => setPublishFrameworkOpen(true)}
+ onRequestPublishToFramework={() => {
+ if (editingUnit?.id) {
+ setPublishFrameworkUnitId(editingUnit.id)
+ setPublishFrameworkOpen(true)
+ }
+ }}
+ onRequestSaveAsModule={() => {
+ if (editingUnit?.id) {
+ setSaveModuleUnitId(editingUnit.id)
+ setSaveModuleOpen(true)
+ }
+ }}
onRequestTrainingModulePick={(ctx) => {
void openModuleApplyModal(ctx)
}}
diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx
index caaa064..4dc7fa1 100644
--- a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx
+++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx
@@ -30,6 +30,7 @@ export default function TrainingPlanningUnitFormModal({
setSectionsEditMode,
onSaveAsTemplate,
onRequestPublishToFramework,
+ onRequestSaveAsModule,
onRequestTrainingModulePick,
onRequestExercisePick,
onPeekExercise,
@@ -504,6 +505,17 @@ export default function TrainingPlanningUnitFormModal({
Als Rahmen-Session speichern…
) : null}
+ {editingUnit?.id && !editingUnit?.framework_slot_id ? (
+ onRequestSaveAsModule?.()}
+ title="Gespeicherte Übungen als Trainingsmodul sichern"
+ >
+ Übungen als Modul…
+
+ ) : null}
>
}
diff --git a/frontend/src/utils/trainingPlanModuleFromUnit.js b/frontend/src/utils/trainingPlanModuleFromUnit.js
new file mode 100644
index 0000000..e96dcf8
--- /dev/null
+++ b/frontend/src/utils/trainingPlanModuleFromUnit.js
@@ -0,0 +1,60 @@
+/**
+ * Übungspositionen aus GET /api/training-units/:id für die Modul-Erstellung (Reihenfolge = Timeline).
+ * @param {object} unit – hydratisierte Trainingseinheit mit `phases` und/oder `sections`
+ * @returns {Array<{ exercise_id: number, exercise_variant_id: number|null, planned_duration_min: number|null, notes: string|null, exercise_title?: string, contextLabel: string }>}
+ */
+export function collectExercisePlacementsForModule(unit) {
+ if (!unit || typeof unit !== 'object') return []
+
+ const rows = []
+
+ const pushFromSection = (sec, ctx) => {
+ const st = (sec?.title || '').trim()
+ for (const it of sec?.items || []) {
+ if ((it?.item_type || 'exercise') !== 'exercise') continue
+ const eid = it.exercise_id
+ if (!eid) continue
+ const labelParts = [ctx, st].filter(Boolean)
+ rows.push({
+ exercise_id: Number(eid),
+ exercise_variant_id:
+ it.exercise_variant_id != null && it.exercise_variant_id !== ''
+ ? Number(it.exercise_variant_id)
+ : null,
+ planned_duration_min:
+ it.planned_duration_min != null && it.planned_duration_min !== ''
+ ? Number(it.planned_duration_min)
+ : null,
+ notes: it.notes != null && String(it.notes).trim() ? String(it.notes).trim() : null,
+ exercise_title: (it.exercise_title || '').trim() || `Übung #${eid}`,
+ contextLabel: labelParts.length ? labelParts.join(' · ') : '',
+ })
+ }
+ }
+
+ const phases = Array.isArray(unit.phases) ? unit.phases : []
+ for (const ph of phases) {
+ const pk = String(ph?.phase_kind || '').toLowerCase()
+ const phaseCtx = (ph?.title || '').trim()
+ if (pk === 'parallel') {
+ for (const st of ph.streams || []) {
+ const streamCtx = [phaseCtx, (st?.title || '').trim()].filter(Boolean).join(' · ')
+ for (const sec of st?.sections || []) {
+ pushFromSection(sec, streamCtx)
+ }
+ }
+ } else {
+ for (const sec of ph?.sections || []) {
+ pushFromSection(sec, phaseCtx)
+ }
+ }
+ }
+
+ if (!rows.length && Array.isArray(unit.sections)) {
+ for (const sec of unit.sections) {
+ pushFromSection(sec, '')
+ }
+ }
+
+ return rows
+}