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.
This commit is contained in:
parent
23d4281058
commit
8debdae397
|
|
@ -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() {
|
|||
<Route path="clubs" element={<ClubsPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="planning" element={<TrainingPlanningPage />} />
|
||||
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
|
||||
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
|
||||
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
|
||||
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -779,7 +779,14 @@ function TrainingPlanningPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<Link
|
||||
to={`/planning/run/${unit.id}`}
|
||||
className="btn btn-secondary"
|
||||
style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
Plan & Ablauf
|
||||
</Link>
|
||||
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
|
|
|
|||
345
frontend/src/pages/TrainingUnitRunPage.jsx
Normal file
345
frontend/src/pages/TrainingUnitRunPage.jsx
Normal file
|
|
@ -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 (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="spinner" />
|
||||
<p>Plan wird geladen…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError || !unit) {
|
||||
return (
|
||||
<div className="card" style={{ margin: '1rem', padding: '1.5rem' }}>
|
||||
<p style={{ marginBottom: '1rem' }}>{loadError || 'Trainingseinheit nicht gefunden.'}</p>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
|
||||
Zur Planung
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="training-run-page" style={{ maxWidth: '720px', margin: '0 auto', paddingBottom: '2rem' }}>
|
||||
<nav className="training-run-toolbar no-print" style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
|
||||
Zur Planung
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => window.print()}>
|
||||
Drucken / PDF
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" title="Alle Häkchen auf dieser Matte zurücksetzen" onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}>
|
||||
Fortschritt leeren
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<header className="training-run-header card" style={{ marginBottom: '1rem', padding: '1.25rem' }}>
|
||||
<h1 style={{ fontSize: '1.35rem', marginBottom: '0.75rem', lineHeight: 1.3 }}>
|
||||
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)}`}
|
||||
</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--text2)', display: 'grid', gap: '0.35rem' }}>
|
||||
{unit.group_name && (
|
||||
<span>
|
||||
<strong>Gruppe:</strong> {unit.group_name}
|
||||
{unit.club_name && ` (${unit.club_name})`}
|
||||
</span>
|
||||
)}
|
||||
{unit.group_location && (
|
||||
<span>
|
||||
<strong>Ort:</strong> {unit.group_location}
|
||||
</span>
|
||||
)}
|
||||
{unit.planned_focus && (
|
||||
<span>
|
||||
<strong>Fokus:</strong> {unit.planned_focus}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<strong>Status:</strong> {statusLabel(unit.status)}
|
||||
</span>
|
||||
{totalPlannedMin > 0 && (
|
||||
<span>
|
||||
<strong>Geplante Zeit (Übungen):</strong> ca. {totalPlannedMin} Min.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{unit.notes && (
|
||||
<div
|
||||
className="training-run-notes-print"
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.65rem 0.85rem',
|
||||
background: 'var(--accent-light)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.92rem'
|
||||
}}
|
||||
>
|
||||
<strong>Hinweis Teilnehmer:</strong> {unit.notes}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<p className="card" style={{ padding: '1.25rem', color: 'var(--text2)' }}>
|
||||
Noch keine Abschnitte in diesem Plan. Unter <Link to="/planning">Planung</Link> bearbeiten.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{sections.map((sec, si) => {
|
||||
const secOrder = sec.order_index ?? si
|
||||
const items = sortedItems(sec)
|
||||
return (
|
||||
<section key={sec.id ?? `sec-${si}`} className="card training-run-section" style={{ padding: '1.15rem 1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.65rem', color: 'var(--accent-dark)' }}>
|
||||
{sec.title || `Abschnitt ${si + 1}`}
|
||||
</h2>
|
||||
{sec.guidance_notes && (
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', marginBottom: '0.85rem', whiteSpace: 'pre-wrap' }}>
|
||||
{sec.guidance_notes}
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||
{items.map((it, ii) => {
|
||||
const ck = itemStableKey(it, secOrder, ii)
|
||||
const done = checked.has(ck)
|
||||
|
||||
if (it.item_type === 'note') {
|
||||
return (
|
||||
<li key={ck} className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.92rem'
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '4px', width: '20px', height: '20px' }} />
|
||||
<span style={{ whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>
|
||||
<em style={{ fontStyle: 'normal', color: 'var(--text3)', fontSize: '0.8rem' }}>Notiz</em>
|
||||
<br />
|
||||
{it.note_body || ''}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<li key={ck} className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '6px', width: '22px', height: '22px', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: '1.02rem', fontWeight: 600 }}>
|
||||
{title}
|
||||
{variant}
|
||||
</span>
|
||||
{metaParts.length > 0 && (
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text3)', marginTop: '3px', lineHeight: 1.35 }}>
|
||||
{metaParts.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{(it.notes || it.modifications) && (
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '0.88rem', color: 'var(--text2)', whiteSpace: 'pre-wrap' }}>
|
||||
{it.notes && (
|
||||
<>
|
||||
<strong style={{ fontWeight: 600 }}>Coach:</strong> {it.notes}
|
||||
</>
|
||||
)}
|
||||
{it.modifications && (
|
||||
<>
|
||||
{it.notes ? <br /> : null}
|
||||
<strong style={{ fontWeight: 600 }}>Anpassung:</strong> {it.modifications}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{it.exercise_id && (
|
||||
<div className="no-print" style={{ marginTop: '0.5rem' }}>
|
||||
<Link to={`/exercises/${it.exercise_id}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||||
Zur Übung im Katalog
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unit.trainer_notes && (
|
||||
<div className="card training-run-trainer-note no-print" style={{ marginTop: '1.25rem', padding: '1rem', borderLeft: `4px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text3)', marginBottom: '0.35rem', textTransform: 'uppercase' }}>
|
||||
Nur Trainer
|
||||
</div>
|
||||
<p style={{ fontSize: '0.92rem', whiteSpace: 'pre-wrap' }}>{unit.trainer_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="no-print training-run-footer" style={{ marginTop: '1.75rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--text3)' }}>
|
||||
Haken werden nur auf diesem Gerät gespeichert (Session — Tab schließen kann sie löschen).
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user