+
+ Plan & Ablauf
+
diff --git a/frontend/src/pages/TrainingUnitRunPage.jsx b/frontend/src/pages/TrainingUnitRunPage.jsx
new file mode 100644
index 0000000..bb4fa4a
--- /dev/null
+++ b/frontend/src/pages/TrainingUnitRunPage.jsx
@@ -0,0 +1,345 @@
+/**
+ * Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert).
+ */
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router-dom'
+import api from '../utils/api'
+
+function storageKey(unitId) {
+ return `sj_training_run_checked_${unitId}`
+}
+
+function itemStableKey(it, secOrder, ix) {
+ if (it && it.id != null) return String(it.id)
+ return `${secOrder}-${it?.item_type || 'row'}-${ix}`
+}
+
+function sortedSections(unit) {
+ const raw = unit?.sections
+ if (!Array.isArray(raw)) return []
+ return [...raw].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
+}
+
+function sortedItems(sec) {
+ const raw = sec?.items
+ if (!Array.isArray(raw)) return []
+ return [...raw].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
+}
+
+function formatMin(m) {
+ if (m === null || m === undefined || m === '') return null
+ const n = Number(m)
+ if (!Number.isFinite(n)) return null
+ return `${n} Min.`
+}
+
+function statusLabel(s) {
+ if (s === 'completed') return 'Durchgeführt'
+ if (s === 'cancelled') return 'Abgesagt'
+ return 'Geplant'
+}
+
+export default function TrainingUnitRunPage() {
+ const { unitId } = useParams()
+ const navigate = useNavigate()
+ const idNum = unitId ? parseInt(unitId, 10) : NaN
+
+ const [unit, setUnit] = useState(null)
+ const [loadError, setLoadError] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [checked, setChecked] = useState(() => new Set())
+
+ const loadChecked = useCallback((uid) => {
+ try {
+ const raw = sessionStorage.getItem(storageKey(uid))
+ if (!raw) return new Set()
+ const arr = JSON.parse(raw)
+ if (!Array.isArray(arr)) return new Set()
+ return new Set(arr.map(String))
+ } catch {
+ return new Set()
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!unitId || Number.isNaN(idNum)) {
+ setLoadError('Ungültige Trainingseinheit')
+ setLoading(false)
+ return
+ }
+ let cancelled = false
+ ;(async () => {
+ setLoading(true)
+ setLoadError(null)
+ try {
+ const u = await api.getTrainingUnit(idNum)
+ if (!cancelled) {
+ setUnit(u)
+ setChecked(loadChecked(idNum))
+ }
+ } catch (e) {
+ if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [unitId, idNum, loadChecked])
+
+ const persistChecked = useCallback(
+ (next) => {
+ setChecked(next)
+ try {
+ sessionStorage.setItem(storageKey(idNum), JSON.stringify([...next]))
+ } catch {
+ /* ignore quota */
+ }
+ },
+ [idNum]
+ )
+
+ const toggle = useCallback(
+ (key) => {
+ const next = new Set(checked)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ persistChecked(next)
+ },
+ [checked, persistChecked]
+ )
+
+ const clearProgress = useCallback(() => {
+ persistChecked(new Set())
+ try {
+ sessionStorage.removeItem(storageKey(idNum))
+ } catch {
+ /* ignore */
+ }
+ }, [idNum, persistChecked])
+
+ const sections = useMemo(() => sortedSections(unit), [unit])
+
+ const totalPlannedMin = useMemo(() => {
+ let t = 0
+ for (const sec of sections) {
+ for (const it of sortedItems(sec)) {
+ if (it.item_type === 'exercise' && it.planned_duration_min != null) {
+ const n = Number(it.planned_duration_min)
+ if (Number.isFinite(n)) t += n
+ }
+ }
+ }
+ return t
+ }, [sections])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (loadError || !unit) {
+ return (
+
+
{loadError || 'Trainingseinheit nicht gefunden.'}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ Training
+ {unit.planned_date && ` · ${unit.planned_date}`}
+ {unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`}
+ {unit.planned_time_end && `–${String(unit.planned_time_end).slice(0, 5)}`}
+
+
+ {unit.group_name && (
+
+ Gruppe: {unit.group_name}
+ {unit.club_name && ` (${unit.club_name})`}
+
+ )}
+ {unit.group_location && (
+
+ Ort: {unit.group_location}
+
+ )}
+ {unit.planned_focus && (
+
+ Fokus: {unit.planned_focus}
+
+ )}
+
+ Status: {statusLabel(unit.status)}
+
+ {totalPlannedMin > 0 && (
+
+ Geplante Zeit (Übungen): ca. {totalPlannedMin} Min.
+
+ )}
+
+ {unit.notes && (
+
+ Hinweis Teilnehmer: {unit.notes}
+
+ )}
+
+
+ {sections.length === 0 ? (
+
+ Noch keine Abschnitte in diesem Plan. Unter Planung bearbeiten.
+
+ ) : (
+
+ {sections.map((sec, si) => {
+ const secOrder = sec.order_index ?? si
+ const items = sortedItems(sec)
+ return (
+
+
+ {sec.title || `Abschnitt ${si + 1}`}
+
+ {sec.guidance_notes && (
+
+ {sec.guidance_notes}
+
+ )}
+
+
+ )
+ })}
+
+ )}
+
+ {unit.trainer_notes && (
+
+
+ Nur Trainer
+
+
{unit.trainer_notes}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/version.js b/frontend/src/version.js
index 219b339..34e727d 100644
--- a/frontend/src/version.js
+++ b/frontend/src/version.js
@@ -10,7 +10,8 @@ export const PAGE_VERSIONS = {
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
ClubsPage: "1.0.0",
SkillsPage: "1.0.0",
- TrainingPlanningPage: "1.1.0",
+ TrainingPlanningPage: "1.2.0",
+ TrainingUnitRunPage: "1.0.0",
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables
TrainerContextsPage: "1.0.0", // New: Trainer-Kontext-Verwaltung
}