feat: add TrainingCoachPage and enhance training planning features
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m54s

- Introduced TrainingCoachPage for coaching-related training sessions.
- Updated app routing to include a new route for the TrainingCoachPage.
- Enhanced TrainingPlanningPage with a link to navigate to the new coaching page.
- Incremented version numbers for TrainingPlanningPage, TrainingUnitRunPage, and added version for TrainingCoachPage.
- Integrated ExercisePeekModal for improved exercise selection and visibility in both TrainingPlanningPage and TrainingUnitRunPage.
This commit is contained in:
Lars 2026-04-29 06:49:16 +02:00
parent 8debdae397
commit 3673053fe2
7 changed files with 838 additions and 24 deletions

View File

@ -21,6 +21,7 @@ import ClubsPage from './pages/ClubsPage'
import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
import TrainingUnitRunPage from './pages/TrainingUnitRunPage'
import TrainingCoachPage from './pages/TrainingCoachPage'
import AdminCatalogsPage from './pages/AdminCatalogsPage'
import AdminHierarchyPage from './pages/AdminHierarchyPage'
import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
@ -153,6 +154,7 @@ function AppRoutes() {
<Route path="clubs" element={<ClubsPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="planning/run/:unitId/coach" element={<TrainingCoachPage />} />
<Route path="planning/run/:unitId" element={<TrainingUnitRunPage />} />
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />

View File

@ -0,0 +1,149 @@
/**
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
*/
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null
const safe = sanitizeTrainerHtml(html)
return (
<div className={`rich-text-content ${className}`} dangerouslySetInnerHTML={{ __html: safe }} />
)
}
function TagMini({ exercise }) {
const parts = []
;(exercise.focus_areas || []).slice(0, 5).forEach((f) => {
parts.push(f.name)
})
if (parts.length === 0) return null
return (
<div className="exercise-tag-row" style={{ marginTop: '10px' }}>
{parts.map((p, i) => (
<span key={i} className="exercise-tag exercise-tag--accent">
{p}
</span>
))}
</div>
)
}
export default function ExercisePeekModal({ open, exerciseId, onClose, titleFallback }) {
const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null)
const [exercise, setExercise] = useState(null)
useEffect(() => {
if (!open) {
setExercise(null)
setErr(null)
return
}
if (!exerciseId) {
setErr('Keine Übung gewählt')
return
}
let cancelled = false
;(async () => {
setLoading(true)
setErr(null)
try {
const data = await api.getExercise(exerciseId)
if (!cancelled) setExercise(data)
} catch (e) {
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [open, exerciseId])
if (!open) return null
return (
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-peek-title"
style={{
maxWidth: '620px',
width: '100%',
maxHeight: '88vh',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-peek-title" className="admin-modal-sheet__title">
{loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`}
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
</div>
<div style={{ overflowY: 'auto', padding: '1rem', flex: 1 }}>
{loading && (
<div style={{ textAlign: 'center', color: 'var(--text2)' }}>
<div className="spinner" />
<p style={{ marginTop: '0.65rem' }}>Laden</p>
</div>
)}
{!loading && err && <p style={{ color: 'var(--danger)' }}>{err}</p>}
{!loading && exercise && (
<>
{exercise.summary && (
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
<HtmlBlock html={exercise.summary} />
</div>
)}
<TagMini exercise={exercise} />
{(exercise.goal || exercise.preparation || exercise.execution || exercise.trainer_notes) && (
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
)}
{exercise.goal && (
<>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>Ziel</h4>
<HtmlBlock html={exercise.goal} />
</>
)}
{exercise.preparation && (
<>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Vorbereitung</h4>
<HtmlBlock html={exercise.preparation} />
</>
)}
{exercise.execution && (
<>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Ablauf</h4>
<HtmlBlock html={exercise.execution} />
</>
)}
{exercise.trainer_notes && (
<>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Trainer-Hinweise</h4>
<HtmlBlock html={exercise.trainer_notes} />
</>
)}
</>
)}
</div>
{exerciseId && (
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
<Link to={`/exercises/${exerciseId}`} className="btn btn-secondary" style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}>
Vollständige Übungsseite öffnen
</Link>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,521 @@
/**
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
*/
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 {
flattenPlanTimeline,
itemStableKey,
sectionsToPutPayload,
summarizeTimelineEntry,
} from '../utils/trainingPlanUtils'
function storageStepKey(unitId) {
return `sj_coach_step_${unitId}`
}
function storageDeltasKey(unitId) {
return `sj_coach_deltas_${unitId}`
}
function formatClock(totalSec) {
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
function statusLabel(s) {
if (s === 'completed') return 'Durchgeführt'
if (s === 'cancelled') return 'Abgesagt'
return 'Geplant'
}
function mergeDelta(setDeltas, itemKey, patch) {
setDeltas((prev) => ({ ...prev, [itemKey]: { ...prev[itemKey], ...patch } }))
}
export default function TrainingCoachPage() {
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 [peekId, setPeekId] = useState(null)
const [outlineOpen, setOutlineOpen] = useState(false)
const [debriefOpen, setDebriefOpen] = useState(false)
const [step, setStep] = useState(0)
const [deltas, setDeltas] = useState({})
const [runStartAt, setRunStartAt] = useState(null)
const [pausedAccumMs, setPausedAccumMs] = useState(0)
const [, setPulse] = useState(0)
const [trainerAppend, setTrainerAppend] = useState('')
const [saveMarkDone, setSaveMarkDone] = useState(true)
const [saving, setSaving] = useState(false)
const [saveOk, setSaveOk] = useState(null)
const reloadUnit = useCallback(async () => {
const u = await api.getTrainingUnit(idNum)
setUnit(u)
}, [idNum])
useEffect(() => {
if (!unitId || Number.isNaN(idNum)) {
setLoadError('Ungültige Trainingseinheit')
setLoading(false)
return
}
let cancelled = false
;(async () => {
setLoading(true)
setLoadError(null)
try {
await reloadUnit()
if (cancelled) return
try {
const s = parseInt(sessionStorage.getItem(storageStepKey(idNum)), 10)
if (!Number.isNaN(s) && s >= 0) setStep(s)
} catch {
/* ignore */
}
try {
const raw = sessionStorage.getItem(storageDeltasKey(idNum))
if (raw) {
const o = JSON.parse(raw)
if (o && typeof o === 'object') setDeltas(o)
}
} catch {
/* ignore */
}
} catch (e) {
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [unitId, idNum, reloadUnit])
useEffect(() => {
sessionStorage.setItem(storageStepKey(idNum), String(step))
}, [idNum, step])
useEffect(() => {
try {
sessionStorage.setItem(storageDeltasKey(idNum), JSON.stringify(deltas))
} catch {
/* quota */
}
}, [idNum, deltas])
useEffect(() => {
if (runStartAt == null) return undefined
const iv = setInterval(() => setPulse((p) => p + 1), 380)
return () => clearInterval(iv)
}, [runStartAt])
const timeline = useMemo(() => flattenPlanTimeline(unit), [unit])
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000))
const clampStep = (s) =>
Math.max(0, Math.min(s, Math.max(timeline.length - 1, 0)))
const currentEntry = timeline[step]
const nextEntry = timeline[step + 1] || null
const next2Entry = timeline[step + 2] || null
const timerStart = () => {
setRunStartAt(Date.now())
}
const timerPause = () => {
if (runStartAt != null) {
setPausedAccumMs((a) => a + (Date.now() - runStartAt))
setRunStartAt(null)
}
}
const timerReset = () => {
setRunStartAt(null)
setPausedAccumMs(0)
}
const applySuggestedDuration = () => {
if (!currentEntry?.item || currentEntry.item.item_type !== 'exercise') return
const key = itemStableKey(currentEntry.item, currentEntry.secOrder, currentEntry.ii)
const min = Math.max(1, Math.round(elapsedMs / 60000)) || null
if (min != null) mergeDelta(setDeltas, key, { actual_duration_min: min })
timerPause()
}
const goPrev = () => setStep((s) => clampStep(s - 1))
const goNext = () => setStep((s) => clampStep(s + 1))
const markCurrentDoneAdvance = () => {
timerPause()
if (step < timeline.length - 1) setStep((s) => s + 1)
}
const durationOverridesForApi = useMemo(() => {
const out = {}
for (let i = 0; i < timeline.length; i++) {
const ent = timeline[i]
const { item } = ent
if (item.item_type !== 'exercise' || item.id == null) continue
const k = itemStableKey(item, ent.secOrder, ent.ii)
const dv = deltas[k]?.actual_duration_min
if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) {
out[String(item.id)] = { actual_duration_min: Number(dv) }
}
}
return out
}, [timeline, deltas])
const handleSaveDebrief = async () => {
setSaveOk(null)
setSaving(true)
try {
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi)
const tn = trainerAppend.trim()
const mergedTrainer = tn
? [unit.trainer_notes || '', `--- (${new Date().toLocaleString('de-DE')}) ---`, tn].filter(Boolean).join('\n')
: unit.trainer_notes
await api.updateTrainingUnit(idNum, {
trainer_notes: mergedTrainer.trim() ? mergedTrainer.trim() : unit.trainer_notes,
...(saveMarkDone ? { status: 'completed' } : {}),
sections: sectionsPayload,
})
await reloadUnit()
setTrainerAppend('')
try {
sessionStorage.removeItem(storageDeltasKey(idNum))
} catch {
/* ignore */
}
setDeltas({})
setSaveOk('Gespeichert.')
setDebriefOpen(false)
} catch (e) {
setSaveOk(`Fehler: ${e.message || e}`)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
<p>Coach laden</p>
</div>
)
}
if (loadError || !unit) {
return (
<div className="card" style={{ margin: '1rem', padding: '1.5rem' }}>
<p style={{ marginBottom: '1rem' }}>{loadError || 'Nicht gefunden.'}</p>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Zur Planung
</button>
</div>
)
}
return (
<div className="training-coach-page" style={{ maxWidth: '720px', margin: '0 auto', paddingBottom: 'calc(env(safe-area-inset-bottom) + 2rem)' }}>
<ExercisePeekModal open={peekId != null} exerciseId={peekId || null} onClose={() => setPeekId(null)} titleFallback={null} />
<nav
className="no-print training-coach-top"
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '12px' }}
>
<button type="button" className="btn btn-secondary" onClick={() => navigate(`/planning/run/${unitId}`)}>
Zur Planansicht
</button>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Planung
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => setOutlineOpen((o) => !o)}
style={{ marginLeft: 'auto' }}
>
{outlineOpen ? 'Rahmen schließen' : 'Trainingsrahmen'}
</button>
</nav>
<header className="card training-coach-hero" style={{ padding: '14px 16px', marginBottom: '12px', background: 'var(--surface)', borderRadius: '12px', borderLeft: `4px solid var(--accent)` }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Im Training · Coach</div>
<h1 style={{ fontSize: '1.28rem', margin: '8px 0 6px', lineHeight: 1.28 }}>
{unit.planned_date}
{unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`}
{unit.planned_focus ? ` · ${unit.planned_focus}` : ''}
</h1>
<div style={{ fontSize: '0.86rem', color: 'var(--text2)', display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center' }}>
{unit.group_name && (
<span>
<strong>Gruppe</strong>: {unit.group_name}
</span>
)}
<span>
<strong>Status:</strong> {statusLabel(unit.status)}
</span>
<span style={{ opacity: 0.85 }}>
Schritt {(step || 0) + 1} / {Math.max(timeline.length, 1)}
</span>
</div>
</header>
{outlineOpen && (
<div className="card training-coach-outline" style={{ marginBottom: '12px', padding: '12px 14px' }}>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf (Antippen springt zum Schritt)</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', maxHeight: '38vh', overflowY: 'auto' }}>
{timeline.map((ent, ix) => {
const lbl = summarizeTimelineEntry(ent)
const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}`
const active = ix === step
return (
<button
key={`${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`}
type="button"
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
style={{
textAlign: 'left',
justifyContent: 'flex-start',
opacity: active ? 1 : 0.92,
fontWeight: active ? 700 : 500,
}}
onClick={() => setStep(ix)}
>
<span style={{ opacity: 0.75 }}>{secTitle.substring(0, 14)} </span>
<span>{lbl}</span>
</button>
)
})}
</div>
</div>
)}
{timeline.length === 0 ? (
<p className="card" style={{ padding: '1rem' }}>
Dieser Plan ist leer.{' '}
<Link to="/planning">Unter Planung ergänzen</Link>.
</p>
) : (
<>
<div className="card training-coach-assist" style={{ marginBottom: '12px', padding: '14px 16px', background: 'var(--accent-light)' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '8px' }}>Assistenz · als Nächstes</div>
{nextEntry ? (
<>
<p style={{ margin: '0 0 6px', fontSize: '0.94rem', color: 'var(--text1)' }}>
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
</p>
{next2Entry && (
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text2)' }}>
<strong>Daraufhin:</strong> {summarizeTimelineEntry(next2Entry)}
</p>
)}
</>
) : (
<p style={{ margin: 0, fontSize: '0.95rem', color: 'var(--text2)' }}>
Dies war der letzte Eintrag.
<br />
Gute Arbeit unten kannst du notieren und Ist-Zeiten speichern.
</p>
)}
</div>
{currentEntry && (
<div className="card training-coach-current" style={{ marginBottom: '14px', padding: '18px 16px' }}>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>
{currentEntry.sec.title || 'Abschnitt'} · Teil {step + 1}
</div>
{currentEntry.item.item_type === 'note' && (
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text3)' }}>Coach-Notiz</div>
<p style={{ fontSize: '1.06rem', lineHeight: 1.52, whiteSpace: 'pre-wrap', marginTop: '8px' }}>{currentEntry.item.note_body || ''}</p>
</div>
)}
{currentEntry.item.item_type !== 'note' && (
<>
<h2 style={{ fontSize: '1.32rem', lineHeight: 1.35, marginBottom: '10px', fontWeight: 800 }}>
{currentEntry.item.exercise_title ||
(currentEntry.item.exercise_id ? `Übung #${currentEntry.item.exercise_id}` : 'Übung')}
{currentEntry.item.exercise_variant_name ? (
<span style={{ fontWeight: 600, color: 'var(--text2)' }}> ({currentEntry.item.exercise_variant_name})</span>
) : null}
</h2>
<p style={{ fontSize: '0.9rem', color: 'var(--text3)', marginBottom: '10px' }}>
geplant <strong>{currentEntry.item.planned_duration_min ?? '—'}</strong> Min · Ist (Plan/Bearbeitung):{' '}
<strong>
{durationOverridesForApi[String(currentEntry.item.id)] != null
? durationOverridesForApi[String(currentEntry.item.id)].actual_duration_min
: currentEntry.item.actual_duration_min ?? '—'}{' '}
Min
</strong>
{durationOverridesForApi[String(currentEntry.item.id)] != null ? ' (Entwurf)' : ''}
</p>
{currentEntry.item.exercise_focus_area && (
<p style={{ fontSize: '0.85rem', color: 'var(--text2)', marginBottom: '10px' }}>
Bereich: {currentEntry.item.exercise_focus_area}
</p>
)}
{currentEntry.item.notes ? (
<div style={{ marginBottom: '12px', padding: '10px', background: 'var(--surface2)', borderRadius: '8px' }}>
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--text3)' }}>Zu dieser Platzierung</div>
<p style={{ fontSize: '0.96rem', marginTop: '4px', whiteSpace: 'pre-wrap' }}>{currentEntry.item.notes}</p>
</div>
) : null}
{currentEntry.item.modifications ? (
<div style={{ marginBottom: '12px', padding: '10px', borderRadius: '8px', border: `1px solid var(--accent)` }}>
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--accent-dark)' }}>Anpassung / Variante</div>
<p style={{ fontSize: '0.96rem', marginTop: '4px', whiteSpace: 'pre-wrap' }}>{currentEntry.item.modifications}</p>
</div>
) : null}
{currentEntry.item.exercise_id ? (
<button type="button" className="btn btn-secondary" onClick={() => setPeekId(currentEntry.item.exercise_id)} style={{ marginTop: '4px' }}>
Übung aus Katalog (Popup)
</button>
) : null}
</>
)}
</div>
)}
<div className="card training-coach-timer" style={{ marginBottom: '14px', padding: '16px', textAlign: 'center', background: 'var(--surface2)' }}>
<div style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text3)', marginBottom: '8px' }}>Zeitnahme für diesen Punkt</div>
<div style={{ fontSize: '2.65rem', fontWeight: 800, letterSpacing: '0.06em', fontVariantNumeric: 'tabular-nums', marginBottom: '14px' }}>
{formatClock(tickDisplaySec)}
</div>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', justifyContent: 'center', marginBottom: '10px' }}>
{!runStartAt ? (
<button type="button" className="btn btn-primary" style={{ minWidth: '112px', minHeight: '48px' }} onClick={timerStart}>
Start
</button>
) : (
<button type="button" className="btn btn-secondary" style={{ minWidth: '112px', minHeight: '48px' }} onClick={timerPause}>
Pause / Stopp
</button>
)}
<button type="button" className="btn btn-secondary" style={{ minHeight: '48px' }} onClick={() => timerReset()}>
Zurücksetzen
</button>
</div>
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', marginBottom: '10px' }}>
Übernimmt die gemessene Zeit (auf volle Minuten gerundet) als <strong>IstMinuten</strong> für dieses Element und kann später mit Nachbereitung auf dem Server gespeichert werden.
</p>
<button type="button" className="btn btn-primary" style={{ width: '100%', maxWidth: '400px', minHeight: '48px' }} onClick={applySuggestedDuration}>
{`Übernehmen für Ist-Zeit (${Math.max(1, Math.round(elapsedMs / 60000))} Min.)`}
</button>
</div>
<div className="training-coach-nav" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '14px' }}>
<button type="button" className="btn btn-secondary" style={{ minHeight: '52px', fontWeight: 700 }} disabled={step <= 0} onClick={goPrev}>
zurück
</button>
<button type="button" className="btn btn-secondary" style={{ minHeight: '52px', fontWeight: 700 }} disabled={step >= timeline.length - 1} onClick={goNext}>
weiter
</button>
</div>
<div style={{ marginBottom: '16px', display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'center' }}>
<button type="button" className="btn btn-primary" style={{ flex: '1 1 200px', minHeight: '50px', fontWeight: 700 }} onClick={markCurrentDoneAdvance}>
Abgeschlossen &amp; weiter
</button>
</div>
{unit.trainer_notes && (
<div className="card" style={{ marginBottom: '14px', padding: '14px', borderLeft: '4px solid var(--accent)' }}>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase' }}>Trainernotiz (dieser Einheit)</div>
<p style={{ fontSize: '0.92rem', marginTop: '6px', whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>{unit.trainer_notes}</p>
</div>
)}
<div className="card training-coach-debrief" style={{ padding: '14px', marginBottom: '12px', borderRadius: '10px', borderColor: 'var(--border)' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%', textAlign: 'left', justifyContent: 'space-between', display: 'flex', alignItems: 'center' }}
onClick={() => setDebriefOpen(!debriefOpen)}
>
<span style={{ fontWeight: 700 }}>Nachbereitung &amp; speichern</span>
<span>{debriefOpen ? '▼' : '▶'}</span>
</button>
{debriefOpen && (
<>
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '14px 0 10px', lineHeight: 1.5 }}>
Übertragene <strong>IstMinuten</strong> (Entwürfe aus dem Timer oder hier anpassen). Beim Speichern werden sie mit dem Plan an den Server geschickt.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline
.filter((e) => e.item.item_type === 'exercise')
.map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
return (
<label key={k} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
<input
type="number"
min="0"
className="form-input"
style={{ margin: 0 }}
value={val === '' || val == null ? '' : String(val)}
placeholder="Min"
onChange={(e) => {
const raw = e.target.value
if (raw === '') mergeDelta(setDeltas, k, { actual_duration_min: null })
else mergeDelta(setDeltas, k, { actual_duration_min: parseInt(raw, 10) })
}}
/>
</label>
)
})}
</div>
<label style={{ display: 'block', marginBottom: '10px', fontSize: '0.88rem', color: 'var(--text2)' }}>
Ergänzung Trainer (wird angehängt · mit Zeitstempel)
<textarea className="form-input" rows={3} value={trainerAppend} onChange={(e) => setTrainerAppend(e.target.value)} style={{ marginTop: '6px' }} />
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', fontSize: '0.88rem' }}>
<input type="checkbox" checked={saveMarkDone} onChange={(e) => setSaveMarkDone(e.target.checked)} />
Einheit als <strong>durchgeführt</strong> markieren
</label>
{saveOk && (
<p
style={{
fontSize: '0.88rem',
marginBottom: '8px',
color: saveOk.startsWith('Fehler') ? 'var(--danger)' : 'var(--accent-dark)',
}}
>
{saveOk}
</p>
)}
<button type="button" className="btn btn-primary" style={{ width: '100%', minHeight: '48px' }} disabled={saving} onClick={handleSaveDebrief}>
{saving ? 'Speichert…' : 'Speichern'}
</button>
</>
)}
</div>
</>
)}
</div>
)
}

View File

@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
@ -186,6 +187,7 @@ function TrainingPlanningPage() {
const [quickTemplateId, setQuickTemplateId] = useState('')
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekExerciseId, setPlanningPeekExerciseId] = useState(null)
const today = new Date().toISOString().split('T')[0]
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
@ -787,6 +789,13 @@ function TrainingPlanningPage() {
>
Plan &amp; Ablauf
</Link>
<Link
to={`/planning/run/${unit.id}/coach`}
className="btn btn-primary"
style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
Im Training (Coach)
</Link>
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
Bearbeiten
</button>
@ -1095,6 +1104,16 @@ function TrainingPlanningPage() {
>
Übung suchen
</button>
{it.exercise_id ? (
<button
type="button"
className="btn btn-secondary"
style={{ margin: 0, whiteSpace: 'nowrap', fontSize: '0.8rem', padding: '6px 10px' }}
onClick={() => setPlanningPeekExerciseId(it.exercise_id)}
>
Katalog kurz zeigen
</button>
) : null}
{(it.exercise_title || it.exercise_id) && (
<span
style={{
@ -1374,6 +1393,11 @@ function TrainingPlanningPage() {
setExercisePickerTarget(null)
}}
/>
<ExercisePeekModal
open={planningPeekExerciseId != null}
exerciseId={planningPeekExerciseId}
onClose={() => setPlanningPeekExerciseId(null)}
/>
</div>
</div>
)

View File

@ -4,28 +4,13 @@
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 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)
@ -48,6 +33,7 @@ export default function TrainingUnitRunPage() {
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 {
@ -156,11 +142,24 @@ export default function TrainingUnitRunPage() {
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' }}>
<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>
<button type="button" className="btn btn-primary" onClick={() => window.print()}>
<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()}>
@ -310,9 +309,20 @@ export default function TrainingUnitRunPage() {
</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
<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>
)}

View File

@ -0,0 +1,107 @@
/**
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
*/
export 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))
}
export 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))
}
export function itemStableKey(it, secOrder, ix) {
if (it && it.id != null) return String(it.id)
return `${secOrder}-${it?.item_type || 'row'}-${ix}`
}
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander. */
export function flattenPlanTimeline(unit) {
const list = []
sortedSections(unit).forEach((sec, si) => {
const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((item, ii) => {
list.push({
si,
ii,
secOrder,
flatIndex: list.length,
sec,
item,
})
})
})
return list
}
export function summarizeTimelineEntry({ item }) {
if (!item) return ''
if (item.item_type === 'note') {
const t = String(item.note_body || '').trim()
return t.length > 72 ? `${t.slice(0, 70)}` : t || 'Notiz'
}
const title = item.exercise_title || (item.exercise_id ? `Übung #${item.exercise_id}` : 'Übung')
const vn = item.exercise_variant_name ? ` · ${item.exercise_variant_name}` : ''
return `${title}${vn}`
}
/** Payload für PUT (schließt bestehendes Unit mit optionalen Overrides pro Abschnitt-Item-ID ab). */
export function sectionsToPutPayload(unit, durationOverridesByItemId = {}) {
return sortedSections(unit).map((sec, si) => ({
order_index: sec.order_index ?? si,
title: ((sec.title || '').trim() || 'Abschnitt'),
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
...(sec.source_template_section_id != null
? { source_template_section_id: sec.source_template_section_id }
: {}),
items: sortedItems(sec)
.map((it, ii) => {
if (it.item_type === 'note') {
return {
item_type: 'note',
order_index: it.order_index ?? ii,
note_body: it.note_body ?? '',
}
}
const eid = it.exercise_id
if (eid === '' || eid == null || Number.isNaN(Number(eid))) {
return null
}
const vid = it.exercise_variant_id
let actual =
durationOverridesByItemId[String(it.id)]?.actual_duration_min ??
it.actual_duration_min
if (actual === '' || actual === undefined) actual = null
else actual = typeof actual === 'number' ? actual : parseInt(String(actual), 10)
if (actual !== null && !Number.isFinite(actual)) actual = null
return {
item_type: 'exercise',
order_index: it.order_index ?? ii,
exercise_id: parseInt(String(eid), 10),
exercise_variant_id:
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(String(vid), 10) : null,
planned_duration_min: coalescePositiveInt(it.planned_duration_min),
actual_duration_min: actual,
notes: trimOrNull(it.notes),
modifications: trimOrNull(it.modifications),
}
})
.filter(Boolean),
}))
}
function coalescePositiveInt(v) {
if (v === '' || v === null || v === undefined) return null
const n = parseInt(String(v), 10)
return Number.isFinite(n) ? n : null
}
function trimOrNull(v) {
const s = v != null ? String(v).trim() : ''
return s ? s : null
}

View File

@ -10,8 +10,9 @@ export const PAGE_VERSIONS = {
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
ClubsPage: "1.0.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.2.0",
TrainingUnitRunPage: "1.0.0",
TrainingPlanningPage: "1.3.0",
TrainingUnitRunPage: "1.1.0",
TrainingCoachPage: "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
}