diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 9675635..069dd33 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
} />
} />
- } />
+ } />
+ } />
+ } />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
new file mode 100644
index 0000000..5b2d806
--- /dev/null
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -0,0 +1,809 @@
+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 ExercisePeekModal from '../components/ExercisePeekModal'
+
+function emptyGoal() {
+ return { title: '', notes: '' }
+}
+
+function emptyExercise() {
+ return { exercise_id: '', exercise_variant_id: '', exercise_title: '', variants: [] }
+}
+
+function emptySlot() {
+ return { title: '', notes: '', training_unit_id: '', exercises: [] }
+}
+
+function defaultForm() {
+ return {
+ title: '',
+ description: '',
+ plan_mode: 'library',
+ group_id: '',
+ planned_period_start: '',
+ planned_period_end: '',
+ visibility: 'private',
+ club_id: '',
+ goals: [emptyGoal()],
+ slots: [],
+ }
+}
+
+function serverFrameworkToForm(fw) {
+ const goalsIn = Array.isArray(fw.goals) && fw.goals.length ? fw.goals : [emptyGoal()]
+ return {
+ title: fw.title || '',
+ description: fw.description || '',
+ plan_mode: fw.plan_mode || 'library',
+ group_id: fw.group_id != null ? String(fw.group_id) : '',
+ planned_period_start: fw.planned_period_start || '',
+ planned_period_end: fw.planned_period_end || '',
+ visibility: fw.visibility || 'private',
+ club_id: fw.club_id != null ? String(fw.club_id) : '',
+ goals: goalsIn.map((g) => ({
+ title: g.title || '',
+ notes: g.notes || '',
+ })),
+ slots: (fw.slots || []).map((s) => ({
+ title: s.title || '',
+ notes: s.notes || '',
+ training_unit_id: s.training_unit_id != null ? String(s.training_unit_id) : '',
+ exercises: (s.exercises || []).map((ex) => ({
+ exercise_id: ex.exercise_id,
+ exercise_variant_id: ex.exercise_variant_id != null ? String(ex.exercise_variant_id) : '',
+ exercise_title: ex.exercise_title || '',
+ variants: [],
+ })),
+ })),
+ }
+}
+
+async function enrichSlotExercisesWithVariants(formSlots) {
+ const ids = new Set()
+ for (const s of formSlots || []) {
+ for (const it of s.exercises || []) {
+ if (it.exercise_id) ids.add(Number(it.exercise_id))
+ }
+ }
+ const cache = new Map()
+ await Promise.all(
+ [...ids].map(async (id) => {
+ try {
+ const ex = await api.getExercise(id)
+ cache.set(id, {
+ title: ex.title || '',
+ variants: Array.isArray(ex.variants) ? ex.variants : [],
+ })
+ } catch {
+ cache.set(id, { title: '', variants: [] })
+ }
+ })
+ )
+ return (formSlots || []).map((s) => ({
+ ...s,
+ exercises: (s.exercises || []).map((it) => {
+ if (!it.exercise_id) return it
+ const c = cache.get(Number(it.exercise_id))
+ if (!c) return it
+ return {
+ ...it,
+ exercise_title: it.exercise_title || c.title,
+ variants: it.variants?.length ? it.variants : c.variants,
+ }
+ }),
+ }))
+}
+
+function buildApiPayload(form) {
+ const goals = (form.goals || [])
+ .map((g, i) => ({
+ sort_order: i,
+ title: (g.title || '').trim(),
+ notes: (g.notes || '').trim() || null,
+ }))
+ .filter((g) => g.title)
+ if (goals.length === 0) {
+ throw new Error('Mindestens ein Entwicklungsziel mit Titel ist erforderlich.')
+ }
+
+ const slots = (form.slots || []).map((s, si) => {
+ const tu =
+ form.plan_mode === 'concrete' && s.training_unit_id
+ ? parseInt(s.training_unit_id, 10)
+ : null
+ const exercises = (s.exercises || [])
+ .map((ex, j) => {
+ if (!ex.exercise_id) return null
+ const vid = ex.exercise_variant_id
+ return {
+ exercise_id: parseInt(ex.exercise_id, 10),
+ exercise_variant_id:
+ vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
+ order_index: j,
+ }
+ })
+ .filter(Boolean)
+ return {
+ sort_order: si,
+ title: (s.title || '').trim() || null,
+ notes: (s.notes || '').trim() || null,
+ training_unit_id: tu,
+ exercises,
+ }
+ })
+
+ const groupId =
+ form.plan_mode === 'library'
+ ? null
+ : form.group_id && !Number.isNaN(parseInt(form.group_id, 10))
+ ? parseInt(form.group_id, 10)
+ : null
+
+ const clubId =
+ form.club_id && !Number.isNaN(parseInt(form.club_id, 10))
+ ? parseInt(form.club_id, 10)
+ : null
+
+ return {
+ title: (form.title || '').trim(),
+ description: (form.description || '').trim() || null,
+ plan_mode: form.plan_mode,
+ group_id: groupId,
+ planned_period_start: form.planned_period_start || null,
+ planned_period_end: form.planned_period_end || null,
+ visibility: form.visibility || 'private',
+ club_id: clubId,
+ goals,
+ slots,
+ }
+}
+
+export default function TrainingFrameworkProgramEditPage() {
+ const { id: routeId } = useParams()
+ const navigate = useNavigate()
+ const isNew = routeId === 'new'
+
+ const [loading, setLoading] = useState(!isNew)
+ const [saving, setSaving] = useState(false)
+ const [form, setForm] = useState(defaultForm())
+ const [groups, setGroups] = useState([])
+ const [clubs, setClubs] = useState([])
+ const [units, setUnits] = useState([])
+ const [pickerSlotIdx, setPickerSlotIdx] = useState(null)
+ const [peekId, setPeekId] = useState(null)
+
+ const loadMeta = useCallback(async () => {
+ try {
+ const [gr, cl] = await Promise.all([
+ api.listTrainingGroups({ status: 'active' }),
+ api.listClubs(),
+ ])
+ setGroups(Array.isArray(gr) ? gr : [])
+ setClubs(Array.isArray(cl) ? cl : [])
+ } catch {
+ setGroups([])
+ setClubs([])
+ }
+ }, [])
+
+ useEffect(() => {
+ loadMeta()
+ }, [loadMeta])
+
+ useEffect(() => {
+ if (form.plan_mode !== 'concrete' || !form.group_id) {
+ setUnits([])
+ return
+ }
+ let cancelled = false
+ ;(async () => {
+ try {
+ const today = new Date()
+ const start = new Date(today)
+ start.setFullYear(start.getFullYear() - 1)
+ const end = new Date(today)
+ end.setFullYear(end.getFullYear() + 1)
+ const u = await api.listTrainingUnits({
+ group_id: parseInt(form.group_id, 10),
+ start_date: start.toISOString().slice(0, 10),
+ end_date: end.toISOString().slice(0, 10),
+ })
+ if (!cancelled) setUnits(Array.isArray(u) ? u : [])
+ } catch {
+ if (!cancelled) setUnits([])
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [form.plan_mode, form.group_id])
+
+ useEffect(() => {
+ if (isNew) {
+ setForm(defaultForm())
+ setLoading(false)
+ return
+ }
+ const fid = parseInt(routeId, 10)
+ if (Number.isNaN(fid)) {
+ navigate('/planning/framework-programs', { replace: true })
+ return
+ }
+ setLoading(true)
+ let cancelled = false
+ ;(async () => {
+ try {
+ const fw = await api.getTrainingFrameworkProgram(fid)
+ if (cancelled) return
+ let next = serverFrameworkToForm(fw)
+ next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) }
+ setForm(next)
+ } catch (e) {
+ alert(e.message || 'Laden fehlgeschlagen')
+ navigate('/planning/framework-programs')
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [isNew, routeId, navigate])
+
+ const updateField = (key, val) => {
+ setForm((prev) => {
+ const n = { ...prev, [key]: val }
+ if (key === 'plan_mode' && val === 'library') {
+ n.group_id = ''
+ }
+ return n
+ })
+ }
+
+ const moveGoal = (idx, dir) => {
+ setForm((prev) => {
+ const j = idx + dir
+ if (j < 0 || j >= prev.goals.length) return prev
+ const g = [...prev.goals]
+ ;[g[idx], g[j]] = [g[j], g[idx]]
+ return { ...prev, goals: g }
+ })
+ }
+
+ const addGoal = () => setForm((prev) => ({ ...prev, goals: [...prev.goals, emptyGoal()] }))
+ const removeGoal = (idx) =>
+ setForm((prev) => {
+ const g = prev.goals.filter((_, i) => i !== idx)
+ return { ...prev, goals: g.length ? g : [emptyGoal()] }
+ })
+
+ const moveSlot = (idx, dir) => {
+ setForm((prev) => {
+ const j = idx + dir
+ if (j < 0 || j >= prev.slots.length) return prev
+ const sl = [...prev.slots]
+ ;[sl[idx], sl[j]] = [sl[j], sl[idx]]
+ return { ...prev, slots: sl }
+ })
+ }
+
+ const addSlot = () => setForm((prev) => ({ ...prev, slots: [...prev.slots, emptySlot()] }))
+ const removeSlot = (idx) =>
+ setForm((prev) => ({ ...prev, slots: prev.slots.filter((_, i) => i !== idx) }))
+
+ const slotField = (sIdx, key, val) => {
+ setForm((prev) => ({
+ ...prev,
+ slots: prev.slots.map((s, i) => (i === sIdx ? { ...s, [key]: val } : s)),
+ }))
+ }
+
+ const addExerciseToSlot = (sIdx) => {
+ setForm((prev) => ({
+ ...prev,
+ slots: prev.slots.map((s, i) =>
+ i === sIdx ? { ...s, exercises: [...(s.exercises || []), emptyExercise()] } : s
+ ),
+ }))
+ }
+
+ const moveExercise = (sIdx, eIdx, dir) => {
+ setForm((prev) => ({
+ ...prev,
+ slots: prev.slots.map((s, i) => {
+ if (i !== sIdx) return s
+ const j = eIdx + dir
+ const ex = [...(s.exercises || [])]
+ if (j < 0 || j >= ex.length) return s
+ ;[ex[eIdx], ex[j]] = [ex[j], ex[eIdx]]
+ return { ...s, exercises: ex }
+ }),
+ }))
+ }
+
+ const removeExercise = (sIdx, eIdx) => {
+ setForm((prev) => ({
+ ...prev,
+ slots: prev.slots.map((s, i) => {
+ if (i !== sIdx) return s
+ return { ...s, exercises: (s.exercises || []).filter((_, ei) => ei !== eIdx) }
+ }),
+ }))
+ }
+
+ const setExerciseChoice = async (sIdx, eIdx, exercise) => {
+ const vid = ''
+ let variants = Array.isArray(exercise.variants) ? exercise.variants : []
+ let title = exercise.title || ''
+ if (!variants.length) {
+ try {
+ const full = await api.getExercise(exercise.id)
+ variants = Array.isArray(full.variants) ? full.variants : []
+ title = full.title || title
+ } catch {
+ variants = []
+ }
+ }
+ setForm((prev) => ({
+ ...prev,
+ slots: prev.slots.map((s, i) => {
+ if (i !== sIdx) return s
+ const exRows = [...(s.exercises || [])]
+ const row = exRows[eIdx] || emptyExercise()
+ exRows[eIdx] = {
+ ...row,
+ exercise_id: exercise.id,
+ exercise_variant_id: vid,
+ exercise_title: title,
+ variants,
+ }
+ return { ...s, exercises: exRows }
+ }),
+ }))
+ }
+
+ const exerciseField = (sIdx, eIdx, key, val) => {
+ setForm((prev) => ({
+ ...prev,
+ slots: prev.slots.map((s, i) => {
+ if (i !== sIdx) return s
+ return {
+ ...s,
+ exercises: (s.exercises || []).map((ex, ei) =>
+ ei === eIdx ? { ...ex, [key]: val } : ex
+ ),
+ }
+ }),
+ }))
+ }
+
+ const handleSave = async () => {
+ if (!(form.title || '').trim()) {
+ alert('Titel ist Pflichtfeld.')
+ return
+ }
+ let payload
+ try {
+ payload = buildApiPayload(form)
+ } catch (e) {
+ alert(e.message || 'Validierung')
+ return
+ }
+ if (!payload.title) {
+ alert('Titel ist Pflichtfeld.')
+ return
+ }
+ setSaving(true)
+ try {
+ if (isNew) {
+ const created = await api.createTrainingFrameworkProgram(payload)
+ navigate(`/planning/framework-programs/${created.id}`, { replace: true })
+ } else {
+ const fid = parseInt(routeId, 10)
+ await api.updateTrainingFrameworkProgram(fid, payload)
+ const refreshed = await api.getTrainingFrameworkProgram(fid)
+ let next = serverFrameworkToForm(refreshed)
+ next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) }
+ setForm(next)
+ }
+ } catch (e) {
+ alert(e.message || 'Speichern fehlgeschlagen')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handleDelete() {
+ if (isNew) return
+ const fid = parseInt(routeId, 10)
+ if (!confirm('Dieses Rahmenprogramm wirklich löschen?')) return
+ try {
+ await api.deleteTrainingFrameworkProgram(fid)
+ navigate('/planning/framework-programs')
+ } catch (e) {
+ alert(e.message || 'Löschen fehlgeschlagen')
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+ ← Alle Rahmenprogramme
+
+
+
+
{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}
+
+
+
Stammdaten
+
+ Titel *
+ updateField('title', e.target.value)}
+ placeholder="z. B. Vorbereitung Gürtelprüfung — 8 Wochen"
+ />
+
+
+ Beschreibung
+
+
+
Modus
+
updateField('plan_mode', e.target.value)}
+ >
+ Bibliothek (zeitlos, ohne Gruppe)
+ Konkret (optional Gruppe & Verknüpfung zu Einheiten)
+
+
+ Bibliothek: keine Trainingsgruppe und keine Slot‑Zuordnung zu Terminen. Konkret: optional Gruppe wählen und
+ Slots mit geplanten Trainingseinheiten verknüpfen.
+
+
+
+ {form.plan_mode === 'concrete' && (
+
+ Trainingsgruppe (optional)
+ updateField('group_id', e.target.value)}
+ >
+ — keine —
+ {groups.map((g) => (
+
+ {g.name} ({g.club_name || 'Verein'})
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ Sichtbarkeit
+ updateField('visibility', e.target.value)}
+ >
+ Privat
+ Verein
+ Offiziell
+
+
+
+ Verein (optional)
+ updateField('club_id', e.target.value)}
+ >
+ — keiner —
+ {clubs.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+
+
+
+
+
+ Entwicklungsziele
+
+
+ + Ziel
+
+
+ {(form.goals || []).map((g, gi) => (
+
+
+ moveGoal(gi, -1)}>
+ ↑
+
+ moveGoal(gi, 1)}>
+ ↓
+
+ removeGoal(gi)}>
+ Entfernen
+
+
+
+ Titel *
+
+ setForm((prev) => ({
+ ...prev,
+ goals: prev.goals.map((x, i) => (i === gi ? { ...x, title: e.target.value } : x)),
+ }))
+ }
+ />
+
+
+ Notizen
+
+
+ ))}
+
+
+
+
+
+ Session‑Slots & Übungen
+
+
+ + Slot
+
+
+
+ {form.slots.length === 0 ? (
+
+ Noch keine Slots — mit + Slot legst du z. B. „Woche 1 / Einheit A“ an und ordnest Übungen zu.
+
+ ) : null}
+
+ {form.slots.map((slot, si) => (
+
+
+ moveSlot(si, -1)}>
+ Slot ↑
+
+ moveSlot(si, 1)}>
+ Slot ↓
+
+ removeSlot(si)}>
+ Slot entfernen
+
+
+
+ Slot‑Titel
+ slotField(si, 'title', e.target.value)}
+ placeholder="z. B. Woche 2 — Technik"
+ />
+
+
+ Notizen
+
+ {form.plan_mode === 'concrete' && (
+
+
Trainingseinheit (optional)
+
slotField(si, 'training_unit_id', e.target.value)}
+ disabled={!form.group_id}
+ >
+ — keine —
+ {units.map((u) => (
+
+ {u.planned_date}
+ {u.planned_time_start ? ` ${String(u.planned_time_start).slice(0, 5)}` : ''}
+ {u.planned_focus ? ` · ${u.planned_focus}` : ''}
+
+ ))}
+
+ {!form.group_id ? (
+
+ Wähle oben eine Trainingsgruppe, um geplante Einheiten zu laden.
+
+ ) : null}
+
+ )}
+
+
+
+ Übungen
+ addExerciseToSlot(si)}>
+ + Übung
+
+
+ {(slot.exercises || []).length === 0 ? (
+
+ Über Übung hinzufügen auswählen.
+
+ ) : null}
+ {(slot.exercises || []).map((ex, ei) => (
+
+
+ moveExercise(si, ei, -1)}>
+ ↑
+
+ moveExercise(si, ei, 1)}>
+ ↓
+
+ removeExercise(si, ei)}>
+ Entfernen
+
+ setPickerSlotIdx({ slotIdx: si, exerciseIdx: ei })}
+ >
+ Übung wählen
+
+ {ex.exercise_id ? (
+ setPeekId(Number(ex.exercise_id))}
+ >
+ Vorschau
+
+ ) : null}
+
+
+ {ex.exercise_id ? (
+ <>
+ {ex.exercise_title || `Übung #${ex.exercise_id}`}
+ (ID {ex.exercise_id})
+ >
+ ) : (
+ Keine Übung gewählt
+ )}
+
+ {ex.exercise_id && (ex.variants || []).length > 0 ? (
+
+ Variante
+ exerciseField(si, ei, 'exercise_variant_id', e.target.value)}
+ >
+ — Standard / keine —
+ {(ex.variants || []).map((v) => (
+
+ {v.variant_name || v.name || `Variante ${v.id}`}
+
+ ))}
+
+
+ ) : null}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ {saving ? 'Speichern…' : isNew ? 'Anlegen' : 'Speichern'}
+
+
+ Abbrechen
+
+ {!isNew ? (
+
+ Löschen
+
+ ) : null}
+
+
+
+
setPickerSlotIdx(null)}
+ onSelectExercise={(exercise) => {
+ if (!pickerSlotIdx) return
+ setExerciseChoice(pickerSlotIdx.slotIdx, pickerSlotIdx.exerciseIdx, exercise)
+ setPickerSlotIdx(null)
+ }}
+ />
+
+ setPeekId(null)} />
+
+ )
+}
diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
new file mode 100644
index 0000000..df89fea
--- /dev/null
+++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
@@ -0,0 +1,145 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../utils/api'
+
+const MODE_LABELS = {
+ concrete: 'Konkret (Gruppe / Einheiten)',
+ library: 'Bibliothek',
+}
+
+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 (
+
+
+
+
+
Trainingsrahmenprogramme
+
+ Mehrere Entwicklungsziele und Übungen über Session‑Slots verteilen — als Vorlage in der Bibliothek oder
+ im Kontext einer Gruppe.
+
+
+
+ + Neues Rahmenprogramm
+
+
+
+
+
+ ← Zurück zur Trainingsplanung
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+ ) : rows.length === 0 ? (
+
+
+ Noch kein Rahmenprogramm angelegt. Über Neues Rahmenprogramm startest du mit Titel,
+ Zielen und Slots.
+
+
+ ) : (
+
+ {rows.map((r) => (
+
+
+
+
+ {r.title || `Rahmen #${r.id}`}
+
+
+ {MODE_LABELS[r.plan_mode] || r.plan_mode}
+ {typeof r.goals_count === 'number' || typeof r.slots_count === 'number' ? (
+
+ {' '}
+ · {r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots
+
+ ) : null}
+
+ {r.description ? (
+
{r.description}
+ ) : null}
+
+
+
+ Bearbeiten
+
+ handleDelete(r.id, r.title)}>
+ Löschen
+
+
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 600071b..78696d7 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -542,6 +542,15 @@ function TrainingPlanningPage() {
Trainingseinheiten im gewählten Zeitraum.
+
+
+ Mehrere Einheiten strukturieren auf einmal:{' '}
+
+ Trainingsrahmenprogramme
+ {' '}
+ (Ziele, Slots, Übungen als Vorlage).
+
+
{!loading && groups.length === 0 && (