shinkan-jinkendo/frontend/src/pages/TrainingUnitRunPage.jsx

356 lines
14 KiB
React
Raw Normal View History

/**
* 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>
)
}