2026-04-29 06:37:52 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert).
|
2026-05-15 12:21:08 +02:00
|
|
|
|
* Phasen: Ganzgruppe vs. Split (planLoc); Druck mit optional getrennten Breakout-Seiten.
|
2026-04-29 06:37:52 +02:00
|
|
|
|
*/
|
|
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
|
|
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
|
|
|
|
import api from '../utils/api'
|
2026-04-29 06:49:16 +02:00
|
|
|
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
2026-05-13 14:24:55 +02:00
|
|
|
|
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
2026-05-15 12:21:08 +02:00
|
|
|
|
import {
|
|
|
|
|
|
buildPlanRunViewModel,
|
|
|
|
|
|
itemStableKey,
|
|
|
|
|
|
sortedSections,
|
|
|
|
|
|
sortedItems,
|
|
|
|
|
|
} from '../utils/trainingPlanUtils'
|
2026-05-13 14:24:55 +02:00
|
|
|
|
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
2026-04-29 06:37:52 +02:00
|
|
|
|
|
|
|
|
|
|
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())
|
2026-05-11 12:44:09 +02:00
|
|
|
|
const [peekCtx, setPeekCtx] = useState(null)
|
2026-05-15 12:21:08 +02:00
|
|
|
|
/** null | printStreamId z. B. p0-s1 — nur dieser Split-Stream in @media print */
|
|
|
|
|
|
const [printOnlyStreamId, setPrintOnlyStreamId] = useState(null)
|
2026-04-29 06:37:52 +02:00
|
|
|
|
|
|
|
|
|
|
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])
|
2026-05-15 12:21:08 +02:00
|
|
|
|
const planModel = useMemo(() => buildPlanRunViewModel(unit), [unit])
|
2026-04-29 06:37:52 +02:00
|
|
|
|
|
2026-05-15 12:21:08 +02:00
|
|
|
|
const printStreamOptions = useMemo(() => {
|
|
|
|
|
|
const opts = []
|
|
|
|
|
|
for (const run of planModel.runs) {
|
|
|
|
|
|
if (run.kind !== 'parallel' || !run.streams) continue
|
|
|
|
|
|
for (const st of run.streams) {
|
|
|
|
|
|
opts.push({
|
|
|
|
|
|
id: st.printStreamId,
|
|
|
|
|
|
label: `${run.phaseTitle ? String(run.phaseTitle).trim().slice(0, 28) : `Phase ${run.phaseOrderIndex}`} · ${st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}`,
|
|
|
|
|
|
})
|
2026-04-29 06:37:52 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-15 12:21:08 +02:00
|
|
|
|
return opts
|
|
|
|
|
|
}, [planModel.runs])
|
|
|
|
|
|
|
|
|
|
|
|
const totalPlannedMin = planModel.totalMin
|
|
|
|
|
|
const showWholeGroupInView = !printOnlyStreamId
|
|
|
|
|
|
const showStreamColumn = (streamPrintId) => !printOnlyStreamId || streamPrintId === printOnlyStreamId
|
|
|
|
|
|
|
|
|
|
|
|
const renderSectionCard = (sec, siInUnit) => {
|
|
|
|
|
|
const secOrder = sec.order_index ?? siInUnit
|
|
|
|
|
|
const items = sortedItems(sec)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section
|
|
|
|
|
|
key={sec.id ?? `sec-${secOrder}-${siInUnit}`}
|
|
|
|
|
|
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 ${siInUnit + 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 training-run-checkbox--printable"
|
|
|
|
|
|
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 exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
|
|
|
|
|
const isComboRow = exKind === 'combination'
|
|
|
|
|
|
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
|
|
|
|
|
|
const comboEffectiveProfile = isComboRow
|
|
|
|
|
|
? effectiveComboMethodProfile(
|
|
|
|
|
|
it.catalog_method_profile || {},
|
|
|
|
|
|
it.planning_method_profile ?? null
|
|
|
|
|
|
)
|
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
|
|
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 training-run-checkbox--printable"
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isComboRow && it.exercise_id ? (
|
|
|
|
|
|
<div className="training-run-combo-embed">
|
|
|
|
|
|
<CombinationPlanBracket
|
|
|
|
|
|
methodArchetype={String(it.catalog_method_archetype || '').trim()}
|
|
|
|
|
|
methodProfile={comboEffectiveProfile || {}}
|
|
|
|
|
|
combinationSlots={
|
|
|
|
|
|
Array.isArray(it.combination_slots) ? it.combination_slots : []
|
|
|
|
|
|
}
|
|
|
|
|
|
planningAdjusted={
|
|
|
|
|
|
it.planning_method_profile != null &&
|
|
|
|
|
|
typeof it.planning_method_profile === 'object' &&
|
|
|
|
|
|
!Array.isArray(it.planning_method_profile)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{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={() =>
|
|
|
|
|
|
setPeekCtx({
|
|
|
|
|
|
exerciseId: it.exercise_id,
|
|
|
|
|
|
variantId:
|
|
|
|
|
|
it.exercise_variant_id != null
|
|
|
|
|
|
? Number(it.exercise_variant_id)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
peekExtras: isComboRow
|
|
|
|
|
|
? {
|
|
|
|
|
|
catalog_method_profile: it.catalog_method_profile,
|
|
|
|
|
|
planning_method_profile: it.planning_method_profile,
|
|
|
|
|
|
}
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-29 06:37:52 +02:00
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-05-05 12:41:59 +02:00
|
|
|
|
<div className="training-run-page app-page" style={{ paddingBottom: '2rem' }}>
|
2026-04-29 06:49:16 +02:00
|
|
|
|
<ExercisePeekModal
|
2026-05-11 12:44:09 +02:00
|
|
|
|
open={peekCtx != null}
|
|
|
|
|
|
exerciseId={peekCtx?.exerciseId}
|
|
|
|
|
|
variantId={peekCtx?.variantId ?? undefined}
|
2026-05-13 14:24:55 +02:00
|
|
|
|
peekExtras={peekCtx?.peekExtras ?? undefined}
|
2026-05-11 12:44:09 +02:00
|
|
|
|
onClose={() => setPeekCtx(null)}
|
2026-04-29 06:49:16 +02:00
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-15 12:21:08 +02:00
|
|
|
|
<nav
|
|
|
|
|
|
className="training-run-toolbar no-print"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
gap: '0.5rem',
|
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-29 06:37:52 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
|
|
|
|
|
|
Zur Planung
|
|
|
|
|
|
</button>
|
2026-04-29 06:49:16 +02:00
|
|
|
|
<Link
|
|
|
|
|
|
to={`/planning/run/${unitId}/coach`}
|
|
|
|
|
|
className="btn btn-primary"
|
2026-05-15 12:21:08 +02:00
|
|
|
|
style={{
|
|
|
|
|
|
textDecoration: 'none',
|
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
}}
|
2026-04-29 06:49:16 +02:00
|
|
|
|
>
|
|
|
|
|
|
Im Training (Coach)
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => window.print()}>
|
2026-04-29 06:37:52 +02:00
|
|
|
|
Drucken / PDF
|
|
|
|
|
|
</button>
|
2026-05-15 12:21:08 +02:00
|
|
|
|
{printStreamOptions.length > 0 ? (
|
|
|
|
|
|
<label
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: '8px',
|
|
|
|
|
|
fontSize: '0.86rem',
|
|
|
|
|
|
color: 'var(--text2)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Druck / Vorschau
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ marginBottom: 0, minWidth: '200px', fontSize: '0.86rem' }}
|
|
|
|
|
|
value={printOnlyStreamId || ''}
|
|
|
|
|
|
onChange={(e) => setPrintOnlyStreamId(e.target.value || null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Gesamter Plan (empfohlen)</option>
|
|
|
|
|
|
{printStreamOptions.map((o) => (
|
|
|
|
|
|
<option key={o.id} value={o.id}>
|
|
|
|
|
|
Nur: {o.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
title="Alle Häkchen auf dieser Matte zurücksetzen"
|
|
|
|
|
|
onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}
|
|
|
|
|
|
>
|
2026-04-29 06:37:52 +02:00
|
|
|
|
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>
|
2026-05-15 12:21:08 +02:00
|
|
|
|
<strong>Geplante Zeit (Übungen, gesamt):</strong> ca. {totalPlannedMin} Min.
|
2026-04-29 06:37:52 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-15 12:21:08 +02:00
|
|
|
|
{printOnlyStreamId ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="no-print"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: '0.75rem',
|
|
|
|
|
|
padding: '0.5rem 0.75rem',
|
|
|
|
|
|
background: 'hsl(200 40% 94%)',
|
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
|
fontSize: '0.82rem',
|
|
|
|
|
|
color: 'hsl(200 25% 28%)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Vorschau wie fürs Drucken: nur die gewählte Split-Gruppe. Ganzgruppen-Blöcke sind ausgeblendet.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{planModel.mode === 'phased' && planModel.runs.length > 0 && showWholeGroupInView && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="training-run-phase-schedule training-run-notes-print"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: '1rem',
|
|
|
|
|
|
padding: '0.75rem 0.9rem',
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
|
fontSize: '0.86rem',
|
|
|
|
|
|
overflowX: 'auto',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<strong style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text1)' }}>
|
|
|
|
|
|
Zeitplanung (Summe Übungsminuten)
|
|
|
|
|
|
</strong>
|
|
|
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '280px' }}>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr style={{ textAlign: 'left', color: 'var(--text3)', fontSize: '0.78rem' }}>
|
|
|
|
|
|
<th style={{ padding: '4px 8px 4px 0', fontWeight: 600 }}>Block</th>
|
|
|
|
|
|
<th style={{ padding: '4px 8px', fontWeight: 600 }}>Art</th>
|
|
|
|
|
|
<th style={{ padding: '4px 8px', fontWeight: 600 }}> Min</th>
|
|
|
|
|
|
<th style={{ padding: '4px 0 4px 8px', fontWeight: 600 }}>∑ bis hier</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{planModel.runs.map((run, ri) => {
|
|
|
|
|
|
const cum = planModel.runCumulativeEnds[ri]
|
|
|
|
|
|
const label =
|
|
|
|
|
|
run.phaseTitle != null && String(run.phaseTitle).trim()
|
|
|
|
|
|
? String(run.phaseTitle).trim()
|
|
|
|
|
|
: run.kind === 'legacy'
|
|
|
|
|
|
? 'Ablauf'
|
|
|
|
|
|
: run.kind === 'whole_group'
|
|
|
|
|
|
? `Ganzgruppe (Phase ${run.phaseOrderIndex})`
|
|
|
|
|
|
: `Parallel (Phase ${run.phaseOrderIndex})`
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr key={`sched-${ri}-${run.kind}`} style={{ borderTop: '1px solid var(--border)' }}>
|
|
|
|
|
|
<td style={{ padding: '6px 8px 6px 0', verticalAlign: 'top' }}>{label}</td>
|
|
|
|
|
|
<td style={{ padding: '6px 8px', color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
|
|
|
|
|
{run.kind === 'parallel' ? 'Split' : run.kind === 'legacy' ? '—' : 'Ganzgruppe'}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td style={{ padding: '6px 8px' }}>{run.minutes || '—'}</td>
|
|
|
|
|
|
<td style={{ padding: '6px 0 6px 8px', fontWeight: 600 }}>{cum}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{unit.planned_time_start && (
|
|
|
|
|
|
<p style={{ margin: '0.55rem 0 0', fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4 }}>
|
|
|
|
|
|
Hinweis: Startzeit oben im Kopf; Minuten sind aus den Übungseinplanungen — ohne Pausen und ohne
|
|
|
|
|
|
automatische Uhrzeitliste.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-04-29 06:37:52 +02:00
|
|
|
|
{unit.notes && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="training-run-notes-print"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: '1rem',
|
|
|
|
|
|
padding: '0.65rem 0.85rem',
|
|
|
|
|
|
background: 'var(--accent-light)',
|
|
|
|
|
|
borderRadius: '8px',
|
2026-05-15 12:21:08 +02:00
|
|
|
|
fontSize: '0.92rem',
|
2026-04-29 06:37:52 +02:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
) : (
|
2026-05-15 12:21:08 +02:00
|
|
|
|
<div className="training-run-body" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
|
|
|
|
|
{planModel.runs.map((run, runIdx) => {
|
|
|
|
|
|
if (run.kind === 'parallel') {
|
|
|
|
|
|
const visibleStreams = run.streams.filter((st) => showStreamColumn(st.printStreamId))
|
|
|
|
|
|
if (!visibleStreams.length) return null
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={`run-p-${run.phaseOrderIndex}-${runIdx}`}
|
|
|
|
|
|
className="training-run-phase training-run-phase--parallel"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="card training-run-phase-banner"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '0.85rem 1rem',
|
|
|
|
|
|
background: 'linear-gradient(135deg, hsl(200 35% 94%), var(--surface2))',
|
|
|
|
|
|
border: '1px solid hsl(200 40% 80%)',
|
|
|
|
|
|
borderRadius: '10px',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
|
|
|
|
|
|
PARALLELE PHASE
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
|
|
|
|
|
|
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
|
|
|
|
|
|
{printOnlyStreamId
|
|
|
|
|
|
? `Auszug eine Gruppe · ca. ${visibleStreams[0]?.minutes ?? run.minutes} Min. (Üb.)`
|
|
|
|
|
|
: `Geplante Übungszeit (gesamt): ca. ${run.minutes} Min. · Jede Spalte kann separat gedruckt werden (Dropdown oder Seitenumbruch).`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="training-run-parallel-columns"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'grid',
|
|
|
|
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
|
|
|
|
|
gap: '1rem',
|
|
|
|
|
|
alignItems: 'start',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{visibleStreams.map((st, streamIdx) => {
|
|
|
|
|
|
const siUnit = (sec) => Math.max(0, sections.indexOf(sec))
|
2026-04-29 06:37:52 +02:00
|
|
|
|
return (
|
2026-05-15 12:21:08 +02:00
|
|
|
|
<div
|
|
|
|
|
|
key={st.printStreamId}
|
|
|
|
|
|
className={
|
|
|
|
|
|
'training-run-breakout-stream' +
|
|
|
|
|
|
(!printOnlyStreamId && streamIdx > 0
|
|
|
|
|
|
? ' training-run-breakout-stream--page-break'
|
|
|
|
|
|
: '')
|
|
|
|
|
|
}
|
|
|
|
|
|
data-print-id={st.printStreamId}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="training-run-stream-ribbon"
|
2026-04-29 06:37:52 +02:00
|
|
|
|
style={{
|
2026-05-15 12:21:08 +02:00
|
|
|
|
marginBottom: '10px',
|
|
|
|
|
|
padding: '8px 12px',
|
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
|
background: 'hsl(200 38% 92%)',
|
|
|
|
|
|
border: '1px dashed hsl(200 42% 58%)',
|
|
|
|
|
|
fontSize: '0.88rem',
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
color: 'hsl(200 30% 28%)',
|
2026-04-29 06:37:52 +02:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-15 12:21:08 +02:00
|
|
|
|
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
|
|
|
|
|
|
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
|
|
|
|
|
|
· ca. {st.minutes} Min. (Üb.)
|
2026-04-29 06:37:52 +02:00
|
|
|
|
</span>
|
2026-05-15 12:21:08 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
|
|
|
|
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-29 06:37:52 +02:00
|
|
|
|
)
|
2026-05-15 12:21:08 +02:00
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-29 06:37:52 +02:00
|
|
|
|
|
2026-05-15 12:21:08 +02:00
|
|
|
|
if (!showWholeGroupInView) return null
|
2026-04-29 06:37:52 +02:00
|
|
|
|
|
2026-05-15 12:21:08 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={`run-wg-${run.phaseOrderIndex}-${runIdx}`}
|
|
|
|
|
|
className="training-run-phase training-run-phase--whole training-run-wg-block"
|
|
|
|
|
|
>
|
|
|
|
|
|
{run.kind !== 'legacy' ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="card training-run-phase-banner"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '0.85rem 1rem',
|
|
|
|
|
|
marginBottom: '0.25rem',
|
|
|
|
|
|
background: 'color-mix(in srgb, var(--accent) 12%, var(--surface2))',
|
|
|
|
|
|
border: '1px solid color-mix(in srgb, var(--accent) 28%, var(--border))',
|
|
|
|
|
|
borderRadius: '10px',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
|
|
|
|
|
|
GANZGRUPPE
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
|
|
|
|
|
|
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
|
|
|
|
|
|
Geplante Übungszeit: ca. {run.minutes} Min.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
|
|
|
|
|
{run.sections.map((sec) => renderSectionCard(sec, Math.max(0, sections.indexOf(sec))))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-29 06:37:52 +02:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{unit.trainer_notes && (
|
2026-05-15 12:21:08 +02:00
|
|
|
|
<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',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-29 06:37:52 +02:00
|
|
|
|
Nur Trainer
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p style={{ fontSize: '0.92rem', whiteSpace: 'pre-wrap' }}>{unit.trainer_notes}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-15 12:21:08 +02:00
|
|
|
|
<footer
|
|
|
|
|
|
className="no-print training-run-footer"
|
|
|
|
|
|
style={{ marginTop: '1.75rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--text3)' }}
|
|
|
|
|
|
>
|
2026-04-29 06:37:52 +02:00
|
|
|
|
Haken werden nur auf diesem Gerät gespeichert (Session — Tab schließen kann sie löschen).
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|