feat: add training framework program management and update versioning
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s

- Introduced new pages for managing training framework programs, including listing and editing functionalities.
- Updated routing in App.jsx to accommodate new training framework program pages.
- Enhanced API with CRUD operations for training framework programs.
- Incremented application version to 0.5.1 and updated relevant page versions.
- Added informational link in TrainingPlanningPage to guide users to training framework programs.
This commit is contained in:
Lars 2026-05-05 09:02:13 +02:00
parent b054c642a3
commit 8328e7e0d3
6 changed files with 1005 additions and 4 deletions

View File

@ -21,6 +21,8 @@ import ExerciseFormPage from './pages/ExerciseFormPage'
import ClubsPage from './pages/ClubsPage'
import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage'
import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage'
import TrainingUnitRunPage from './pages/TrainingUnitRunPage'
import TrainingCoachPage from './pages/TrainingCoachPage'
import AdminCatalogsPage from './pages/AdminCatalogsPage'
@ -157,9 +159,12 @@ function AppRoutes() {
</Route>
<Route path="clubs" element={<ClubsPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs" element={<TrainingFrameworkProgramsListPage />} />
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />

View File

@ -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 (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
<p>Laden</p>
</div>
)
}
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/framework-programs" style={{ color: 'var(--accent-dark)' }}>
Alle Rahmenprogramme
</Link>
</p>
<h1 style={{ marginBottom: '1rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
<div className="card" style={{ marginBottom: '1rem' }}>
<h3 className="card-title">Stammdaten</h3>
<div className="form-row">
<label className="form-label">Titel *</label>
<input
className="form-input"
value={form.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="z.B. Vorbereitung Gürtelprüfung — 8 Wochen"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Modus</label>
<select
className="form-input"
value={form.plan_mode}
onChange={(e) => updateField('plan_mode', e.target.value)}
>
<option value="library">Bibliothek (zeitlos, ohne Gruppe)</option>
<option value="concrete">Konkret (optional Gruppe & Verknüpfung zu Einheiten)</option>
</select>
<p style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Bibliothek: keine Trainingsgruppe und keine SlotZuordnung zu Terminen. Konkret: optional Gruppe wählen und
Slots mit geplanten Trainingseinheiten verknüpfen.
</p>
</div>
{form.plan_mode === 'concrete' && (
<div className="form-row">
<label className="form-label">Trainingsgruppe (optional)</label>
<select
className="form-input"
value={form.group_id}
onChange={(e) => updateField('group_id', e.target.value)}
>
<option value=""> keine </option>
{groups.map((g) => (
<option key={g.id} value={String(g.id)}>
{g.name} ({g.club_name || 'Verein'})
</option>
))}
</select>
</div>
)}
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label className="form-label">Zeitraum von</label>
<input
type="date"
className="form-input"
value={form.planned_period_start}
onChange={(e) => updateField('planned_period_start', e.target.value)}
/>
</div>
<div>
<label className="form-label">Zeitraum bis</label>
<input
type="date"
className="form-input"
value={form.planned_period_end}
onChange={(e) => updateField('planned_period_end', e.target.value)}
/>
</div>
</div>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={form.visibility}
onChange={(e) => updateField('visibility', e.target.value)}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
</div>
<div>
<label className="form-label">Verein (optional)</label>
<select
className="form-input"
value={form.club_id}
onChange={(e) => updateField('club_id', e.target.value)}
>
<option value=""> keiner </option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
</div>
</div>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 className="card-title" style={{ marginBottom: 0 }}>
Entwicklungsziele
</h3>
<button type="button" className="btn btn-secondary" onClick={addGoal}>
+ Ziel
</button>
</div>
{(form.goals || []).map((g, gi) => (
<div
key={gi}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '12px',
marginBottom: '10px',
background: 'var(--surface2)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
<button type="button" className="btn btn-secondary" onClick={() => moveGoal(gi, -1)}>
</button>
<button type="button" className="btn btn-secondary" onClick={() => moveGoal(gi, 1)}>
</button>
<button type="button" className="btn btn-secondary" onClick={() => removeGoal(gi)}>
Entfernen
</button>
</div>
<div className="form-row">
<label className="form-label">Titel *</label>
<input
className="form-input"
value={g.title}
onChange={(e) =>
setForm((prev) => ({
...prev,
goals: prev.goals.map((x, i) => (i === gi ? { ...x, title: e.target.value } : x)),
}))
}
/>
</div>
<div className="form-row">
<label className="form-label">Notizen</label>
<textarea
className="form-input"
rows={2}
value={g.notes}
onChange={(e) =>
setForm((prev) => ({
...prev,
goals: prev.goals.map((x, i) => (i === gi ? { ...x, notes: e.target.value } : x)),
}))
}
/>
</div>
</div>
))}
</div>
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 className="card-title" style={{ marginBottom: 0 }}>
SessionSlots & Übungen
</h3>
<button type="button" className="btn btn-secondary" onClick={addSlot}>
+ Slot
</button>
</div>
{form.slots.length === 0 ? (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
Noch keine Slots mit <strong>+ Slot</strong> legst du z.B. Woche 1 / Einheit A an und ordnest Übungen zu.
</p>
) : null}
{form.slots.map((slot, si) => (
<div
key={si}
className="card"
style={{ marginBottom: '12px', background: 'var(--surface)', borderStyle: 'dashed' }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '10px' }}>
<button type="button" className="btn btn-secondary" onClick={() => moveSlot(si, -1)}>
Slot
</button>
<button type="button" className="btn btn-secondary" onClick={() => moveSlot(si, 1)}>
Slot
</button>
<button type="button" className="btn btn-secondary" onClick={() => removeSlot(si)}>
Slot entfernen
</button>
</div>
<div className="form-row">
<label className="form-label">SlotTitel</label>
<input
className="form-input"
value={slot.title}
onChange={(e) => slotField(si, 'title', e.target.value)}
placeholder="z.B. Woche 2 — Technik"
/>
</div>
<div className="form-row">
<label className="form-label">Notizen</label>
<textarea
className="form-input"
rows={2}
value={slot.notes}
onChange={(e) => slotField(si, 'notes', e.target.value)}
/>
</div>
{form.plan_mode === 'concrete' && (
<div className="form-row">
<label className="form-label">Trainingseinheit (optional)</label>
<select
className="form-input"
value={slot.training_unit_id}
onChange={(e) => slotField(si, 'training_unit_id', e.target.value)}
disabled={!form.group_id}
>
<option value=""> keine </option>
{units.map((u) => (
<option key={u.id} value={String(u.id)}>
{u.planned_date}
{u.planned_time_start ? ` ${String(u.planned_time_start).slice(0, 5)}` : ''}
{u.planned_focus ? ` · ${u.planned_focus}` : ''}
</option>
))}
</select>
{!form.group_id ? (
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Wähle oben eine Trainingsgruppe, um geplante Einheiten zu laden.
</p>
) : null}
</div>
)}
<div style={{ marginTop: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>Übungen</span>
<button type="button" className="btn btn-secondary" onClick={() => addExerciseToSlot(si)}>
+ Übung
</button>
</div>
{(slot.exercises || []).length === 0 ? (
<p style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
Über <strong>Übung hinzufügen</strong> auswählen.
</p>
) : null}
{(slot.exercises || []).map((ex, ei) => (
<div
key={ei}
style={{
display: 'grid',
gap: '8px',
padding: '10px',
marginBottom: '8px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<button type="button" className="btn btn-secondary" onClick={() => moveExercise(si, ei, -1)}>
</button>
<button type="button" className="btn btn-secondary" onClick={() => moveExercise(si, ei, 1)}>
</button>
<button type="button" className="btn btn-secondary" onClick={() => removeExercise(si, ei)}>
Entfernen
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => setPickerSlotIdx({ slotIdx: si, exerciseIdx: ei })}
>
Übung wählen
</button>
{ex.exercise_id ? (
<button
type="button"
className="btn btn-secondary"
onClick={() => setPeekId(Number(ex.exercise_id))}
>
Vorschau
</button>
) : null}
</div>
<div style={{ fontSize: '0.9rem' }}>
{ex.exercise_id ? (
<>
<strong>{ex.exercise_title || `Übung #${ex.exercise_id}`}</strong>
<span style={{ color: 'var(--text2)' }}> (ID {ex.exercise_id})</span>
</>
) : (
<span style={{ color: 'var(--text3)' }}>Keine Übung gewählt</span>
)}
</div>
{ex.exercise_id && (ex.variants || []).length > 0 ? (
<div>
<label className="form-label">Variante</label>
<select
className="form-input"
value={ex.exercise_variant_id}
onChange={(e) => exerciseField(si, ei, 'exercise_variant_id', e.target.value)}
>
<option value=""> Standard / keine </option>
{(ex.variants || []).map((v) => (
<option key={v.id} value={String(v.id)}>
{v.variant_name || v.name || `Variante ${v.id}`}
</option>
))}
</select>
</div>
) : null}
</div>
))}
</div>
</div>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'center' }}>
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave}>
{saving ? 'Speichern…' : isNew ? 'Anlegen' : 'Speichern'}
</button>
<Link to="/planning/framework-programs" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Abbrechen
</Link>
{!isNew ? (
<button type="button" className="btn btn-secondary" onClick={handleDelete}>
Löschen
</button>
) : null}
</div>
</div>
<ExercisePickerModal
open={pickerSlotIdx != null}
onClose={() => setPickerSlotIdx(null)}
onSelectExercise={(exercise) => {
if (!pickerSlotIdx) return
setExerciseChoice(pickerSlotIdx.slotIdx, pickerSlotIdx.exerciseIdx, exercise)
setPickerSlotIdx(null)
}}
/>
<ExercisePeekModal open={peekId != null} exerciseId={peekId || 0} onClose={() => setPeekId(null)} />
</div>
)
}

View File

@ -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 (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '1rem',
marginBottom: '1.25rem',
}}
>
<div>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
Mehrere Entwicklungsziele und Übungen über SessionSlots verteilen als Vorlage in der Bibliothek oder
im Kontext einer Gruppe.
</p>
</div>
<Link to="/planning/framework-programs/new" className="btn btn-primary" style={{ textDecoration: 'none' }}>
+ Neues Rahmenprogramm
</Link>
</div>
<p style={{ marginBottom: '1rem' }}>
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Zurück zur Trainingsplanung
</Link>
</p>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
{error}
</div>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner" />
<p>Laden</p>
</div>
) : rows.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)' }}>
Noch kein Rahmenprogramm angelegt. Über <strong>Neues Rahmenprogramm</strong> startest du mit Titel,
Zielen und Slots.
</p>
</div>
) : (
<ul style={{ listStyle: 'none' }}>
{rows.map((r) => (
<li key={r.id} className="card" style={{ marginBottom: '12px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '0.75rem',
}}
>
<div>
<Link
to={`/planning/framework-programs/${r.id}`}
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
>
{r.title || `Rahmen #${r.id}`}
</Link>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
<span>{MODE_LABELS[r.plan_mode] || r.plan_mode}</span>
{typeof r.goals_count === 'number' || typeof r.slots_count === 'number' ? (
<span>
{' '}
· {r.goals_count ?? '—'} Ziele · {r.slots_count ?? '—'} Slots
</span>
) : null}
</div>
{r.description ? (
<p style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--text2)' }}>{r.description}</p>
) : null}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<Link
to={`/planning/framework-programs/${r.id}`}
className="btn btn-secondary"
style={{ textDecoration: 'none' }}
>
Bearbeiten
</Link>
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
Löschen
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View File

@ -542,6 +542,15 @@ function TrainingPlanningPage() {
<strong>Trainingseinheiten</strong> im gewählten Zeitraum.
</p>
<div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}>
<p style={{ margin: 0, 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).
</p>
</div>
{!loading && groups.length === 0 && (
<div
className="card"

View File

@ -950,6 +950,32 @@ export async function deleteTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
}
export async function listTrainingFrameworkPrograms() {
return request('/api/training-framework-programs')
}
export async function getTrainingFrameworkProgram(id) {
return request(`/api/training-framework-programs/${id}`)
}
export async function createTrainingFrameworkProgram(data) {
return request('/api/training-framework-programs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingFrameworkProgram(id, data) {
return request(`/api/training-framework-programs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingFrameworkProgram(id) {
return request(`/api/training-framework-programs/${id}`, { method: 'DELETE' })
}
// ============================================================================
// Version & Health
// ============================================================================
@ -1043,6 +1069,11 @@ export const api = {
createTrainingPlanTemplate,
updateTrainingPlanTemplate,
deleteTrainingPlanTemplate,
listTrainingFrameworkPrograms,
getTrainingFrameworkProgram,
createTrainingFrameworkProgram,
updateTrainingFrameworkProgram,
deleteTrainingFrameworkProgram,
// Catalogs
listFocusAreas,

View File

@ -1,7 +1,7 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.5.0"
export const BUILD_DATE = "2026-04-23"
export const APP_VERSION = "0.5.1"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
@ -10,7 +10,9 @@ export const PAGE_VERSIONS = {
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
ClubsPage: "1.0.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.3.0",
TrainingPlanningPage: "1.3.1",
TrainingFrameworkProgramsListPage: "1.0.0",
TrainingFrameworkProgramEditPage: "1.0.0",
TrainingUnitRunPage: "1.1.0",
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables