shinkan-jinkendo/frontend/src/pages/TrainingUnitRunPage.jsx
Lars 83ee300192
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s
refactor: enhance layout and responsiveness across multiple pages
- Updated app.css to improve responsive design, introducing new classes for consistent page widths and grid layouts.
- Refactored various page components to utilize the new layout classes, ensuring better adaptability on different screen sizes.
- Adjusted padding and margin properties for improved visual consistency and user experience across the application.
2026-05-05 12:39:15 +02:00

356 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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'
import ExercisePeekModal from '../components/ExercisePeekModal'
import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
function storageKey(unitId) {
return `sj_training_run_checked_${unitId}`
}
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 [peekExerciseId, setPeekExerciseId] = useState(null)
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 app-page app-page--constrained-sm" style={{ paddingBottom: '2rem' }}>
<ExercisePeekModal
open={peekExerciseId != null}
exerciseId={peekExerciseId}
onClose={() => setPeekExerciseId(null)}
/>
<nav className="training-run-toolbar no-print" style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Zur Planung
</button>
<Link
to={`/planning/run/${unitId}/coach`}
className="btn btn-primary"
style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
Im Training (Coach)
</Link>
<button type="button" className="btn btn-secondary" 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={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginTop: '0.55rem', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.82rem', margin: 0 }}
onClick={() => setPeekExerciseId(it.exercise_id)}
>
Katalog (Popup)
</button>
<Link
to={`/exercises/${it.exercise_id}`}
style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
>
Vollständige Seite öffnen
</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>
)
}