From 8debdae3977292750be9671a718b6eb1f519a00e Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 06:37:52 +0200 Subject: [PATCH] feat: add TrainingUnitRunPage and enhance training planning features - Introduced TrainingUnitRunPage for displaying and printing training plans. - Updated app routing to include a new route for the TrainingUnitRunPage. - Enhanced the TrainingPlanningPage with a link to navigate to the new training run page. - Updated CSS styles for print layout and improved visibility of completed training items. --- frontend/src/App.jsx | 2 + frontend/src/app.css | 36 ++ frontend/src/pages/TrainingPlanningPage.jsx | 9 +- frontend/src/pages/TrainingUnitRunPage.jsx | 345 ++++++++++++++++++++ frontend/src/version.js | 3 +- 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/TrainingUnitRunPage.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0c065f4..e0d59df 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ import ExerciseFormPage from './pages/ExerciseFormPage' import ClubsPage from './pages/ClubsPage' import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' +import TrainingUnitRunPage from './pages/TrainingUnitRunPage' import AdminCatalogsPage from './pages/AdminCatalogsPage' import AdminHierarchyPage from './pages/AdminHierarchyPage' import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage' @@ -152,6 +153,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/app.css b/frontend/src/app.css index 2b122d0..fdb4013 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2704,3 +2704,39 @@ a.analysis-split__nav-item { align-items: center; } } + +/* Trainingsplan — Anzeige / Druck / Ablauf (TrainingUnitRunPage) */ +.training-run-item--done { + opacity: 0.75; +} +.training-run-item--done .training-run-checkbox:checked { + accent-color: var(--accent); +} + +@media print { + .desktop-sidebar, + .bottom-nav, + .app-header--mobile, + .no-print { + display: none !important; + } + body { + background: #fff !important; + color: #000 !important; + } + .app-shell { + max-width: none !important; + } + .app-main { + padding: 12px 14px 20px !important; + } + .training-run-page { + max-width: none !important; + padding: 0 !important; + } + .training-run-section, + .training-run-header { + break-inside: avoid; + page-break-inside: avoid; + } +} diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index 601f9a4..d28a220 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -779,7 +779,14 @@ function TrainingPlanningPage() { -
+
+ + 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 ( +
+
+

Plan wird geladen…

+
+ ) + } + + 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} +

+ )} +
    + {items.map((it, ii) => { + const ck = itemStableKey(it, secOrder, ii) + const done = checked.has(ck) + + if (it.item_type === 'note') { + return ( +
  • + +
  • + ) + } + + const title = + it.exercise_title || + (it.exercise_id ? `Übung #${it.exercise_id}` : 'Übung') + const variant = it.exercise_variant_name ? ` (${it.exercise_variant_name})` : '' + const plan = formatMin(it.planned_duration_min) + const extras = [] + if (it.exercise_focus_area) extras.push(it.exercise_focus_area) + const metaParts = [...extras, plan].filter(Boolean) + + return ( +
  • + +
  • + ) + })} +
+
+ ) + })} +
+ )} + + {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 }