feat: enhance TrainingCoachPage with exercise details and navigation
- Replaced ExercisePeekModal with ExerciseFullContent for improved exercise visibility. - Added CoachStepNavBar component for better navigation between training steps. - Implemented loading and error handling for fetching exercise details based on the current step. - Updated UI elements for a more cohesive layout and improved user experience.
This commit is contained in:
parent
af3476b833
commit
6fd316e985
|
|
@ -2740,3 +2740,27 @@ a.analysis-split__nav-item {
|
|||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Coach — volle Übung, Nur-Mittelbereich scrollt; Steuerung oben/unten sichtbar */
|
||||
.training-coach-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
min-height: calc(100dvh - var(--header-h) - var(--nav-h) - env(safe-area-inset-bottom, 0px) - 48px);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.training-coach-layout {
|
||||
min-height: calc(100dvh - env(safe-area-inset-bottom, 0px) - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
.training-coach-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
|
|
|||
203
frontend/src/components/ExerciseFullContent.jsx
Normal file
203
frontend/src/components/ExerciseFullContent.jsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Voller Katalog-Inhalt einer Übung (Lesemodus für Coach/Mobile).
|
||||
*/
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
function resolveMediaUrl(filePath) {
|
||||
if (!filePath) return null
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
|
||||
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
|
||||
return `${API_BASE}${p}`
|
||||
}
|
||||
|
||||
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 MediaBlock({ media }) {
|
||||
if (media.embed_url) {
|
||||
return (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<a href={media.embed_url} target="_blank" rel="noreferrer">
|
||||
{media.embed_url}
|
||||
</a>
|
||||
{media.embed_platform && (
|
||||
<span style={{ color: 'var(--text2)', marginLeft: '0.5rem', fontSize: '0.8rem' }}>
|
||||
({media.embed_platform})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const src = resolveMediaUrl(media.file_path)
|
||||
if (!src) return null
|
||||
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={media.title || media.original_filename || ''}
|
||||
style={{ maxWidth: '100%', borderRadius: '8px', marginTop: '0.5rem' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
|
||||
return <video src={src} controls style={{ width: '100%', marginTop: '0.5rem', borderRadius: '8px' }} />
|
||||
}
|
||||
return (
|
||||
<a href={src} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: '0.5rem' }}>
|
||||
{media.title || media.original_filename || 'Datei öffnen'}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function TagRow({ exercise }) {
|
||||
const tags = []
|
||||
;(exercise.focus_areas || []).forEach((f) => {
|
||||
tags.push({ key: `fa-${f.id}`, label: f.name, accent: !!f.is_primary })
|
||||
})
|
||||
;(exercise.training_styles || []).forEach((t) => {
|
||||
tags.push({ key: `ts-${t.id}`, label: t.name, accent: false })
|
||||
})
|
||||
;(exercise.training_types || []).forEach((t) => {
|
||||
tags.push({ key: `tt-${t.id}`, label: t.name, accent: false })
|
||||
})
|
||||
;(exercise.target_groups || []).forEach((g) => {
|
||||
tags.push({ key: `tg-${g.id}`, label: g.name, accent: !!g.is_primary })
|
||||
})
|
||||
if (tags.length === 0) return null
|
||||
return (
|
||||
<div className="exercise-tag-row">
|
||||
{tags.map((t) => (
|
||||
<span key={t.key} className={`exercise-tag${t.accent ? ' exercise-tag--accent' : ''}`}>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function metaParts(exercise) {
|
||||
const parts = []
|
||||
if (exercise.duration_min != null || exercise.duration_max != null) {
|
||||
const a = exercise.duration_min
|
||||
const b = exercise.duration_max
|
||||
if (a != null && b != null && a !== b) parts.push(`${a}–${b} Min.`)
|
||||
else if (a != null) parts.push(`ca. ${a} Min.`)
|
||||
else if (b != null) parts.push(`ca. ${b} Min.`)
|
||||
}
|
||||
if (exercise.group_size_min != null || exercise.group_size_max != null) {
|
||||
const a = exercise.group_size_min
|
||||
const b = exercise.group_size_max
|
||||
if (a != null && b != null && a !== b) parts.push(`Gruppe ${a}–${b}`)
|
||||
else if (a != null) parts.push(`Gruppe ab ${a}`)
|
||||
else if (b != null) parts.push(`Gruppe bis ${b}`)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number }} props
|
||||
*/
|
||||
export default function ExerciseFullContent({ exercise, loading, error, exerciseId }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '1rem' }}>
|
||||
<div className="spinner" />
|
||||
<p style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '0.9rem' }}>Übung aus Katalog laden…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return <p style={{ color: 'var(--danger)', fontSize: '0.92rem', padding: '4px 0' }}>{error}</p>
|
||||
}
|
||||
if (!exercise) return null
|
||||
|
||||
const meta = metaParts(exercise)
|
||||
|
||||
return (
|
||||
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
|
||||
{meta.length > 0 && (
|
||||
<p className="exercise-meta-line" style={{ marginBottom: '10px', color: 'var(--text3)', fontSize: '0.86rem' }}>
|
||||
{meta.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
<TagRow exercise={exercise} />
|
||||
{exercise.summary && (
|
||||
<section className="card" style={{ marginTop: '12px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px', letterSpacing: '0.04em' }}>
|
||||
Kurzbeschreibung
|
||||
</h3>
|
||||
<HtmlBlock html={exercise.summary} />
|
||||
</section>
|
||||
)}
|
||||
{exercise.goal && (
|
||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ziel</h3>
|
||||
<HtmlBlock html={exercise.goal} />
|
||||
</section>
|
||||
)}
|
||||
{(exercise.equipment || []).length > 0 && (
|
||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
Material & Aufbau
|
||||
</h3>
|
||||
<ul style={{ paddingLeft: '1.1rem', margin: 0 }}>
|
||||
{exercise.equipment.map((x, i) => (
|
||||
<li key={i}>{x}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{exercise.preparation && (
|
||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Vorbereitung</h3>
|
||||
<HtmlBlock html={exercise.preparation} />
|
||||
</section>
|
||||
)}
|
||||
{exercise.execution && (
|
||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf</h3>
|
||||
<HtmlBlock html={exercise.execution} />
|
||||
</section>
|
||||
)}
|
||||
{(exercise.media || []).length > 0 && (
|
||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
Medien
|
||||
</h3>
|
||||
{exercise.media.map((m) => (
|
||||
<div key={m.id} style={{ marginBottom: '12px' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong>
|
||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
|
||||
<MediaBlock media={m} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{exercise.trainer_notes && (
|
||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
Hinweise Trainer (Katalog)
|
||||
</h3>
|
||||
<HtmlBlock html={exercise.trainer_notes} />
|
||||
</section>
|
||||
)}
|
||||
{exerciseId != null && (
|
||||
<p style={{ marginTop: '12px' }}>
|
||||
<Link to={`/exercises/${exerciseId}`} style={{ fontSize: '0.86rem', color: 'var(--accent)' }}>
|
||||
Volle Übungsseite im Browser
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
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 ExerciseFullContent from '../components/ExerciseFullContent'
|
||||
import {
|
||||
flattenPlanTimeline,
|
||||
itemStableKey,
|
||||
|
|
@ -26,16 +26,30 @@ function formatClock(totalSec) {
|
|||
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 } }))
|
||||
}
|
||||
|
||||
function CoachStepNavBar({ step, timelineLength, onPrev, onNext, onDone }) {
|
||||
const disPrev = step <= 0
|
||||
const disNext = step >= timelineLength - 1
|
||||
return (
|
||||
<div className="training-coach-stepbar" style={{ marginBottom: '10px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginBottom: '8px' }}>
|
||||
<button type="button" className="btn btn-secondary" style={{ minHeight: '50px', fontWeight: 700 }} disabled={disPrev} onClick={onPrev}>
|
||||
← zurück
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" style={{ minHeight: '50px', fontWeight: 700 }} disabled={disNext} onClick={onNext}>
|
||||
weiter →
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', minHeight: '48px', fontWeight: 700 }} onClick={onDone}>
|
||||
Abgeschlossen & weiter
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TrainingCoachPage() {
|
||||
const { unitId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -45,8 +59,10 @@ export default function TrainingCoachPage() {
|
|||
const [loadError, setLoadError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [peekId, setPeekId] = useState(null)
|
||||
const [outlineOpen, setOutlineOpen] = useState(false)
|
||||
const [catalogExercise, setCatalogExercise] = useState(null)
|
||||
const [catalogLoading, setCatalogLoading] = useState(false)
|
||||
const [catalogError, setCatalogError] = useState(null)
|
||||
const [debriefOpen, setDebriefOpen] = useState(false)
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
|
@ -193,6 +209,44 @@ export default function TrainingCoachPage() {
|
|||
return out
|
||||
}, [timeline, deltas])
|
||||
|
||||
useEffect(() => {
|
||||
const item = currentEntry?.item
|
||||
if (!item || item.item_type === 'note') {
|
||||
setCatalogExercise(null)
|
||||
setCatalogError(null)
|
||||
setCatalogLoading(false)
|
||||
return
|
||||
}
|
||||
const eid = item.exercise_id
|
||||
if (!eid) {
|
||||
setCatalogExercise(null)
|
||||
setCatalogError(null)
|
||||
setCatalogLoading(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setCatalogLoading(true)
|
||||
setCatalogError(null)
|
||||
setCatalogExercise(null)
|
||||
api.getExercise(eid)
|
||||
.then((ex) => {
|
||||
if (!cancelled) {
|
||||
setCatalogExercise(ex)
|
||||
setCatalogLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setCatalogError(err.message || String(err))
|
||||
setCatalogExercise(null)
|
||||
setCatalogLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [step, currentEntry?.item?.exercise_id, currentEntry?.item?.item_type])
|
||||
|
||||
const handleSaveDebrief = async () => {
|
||||
setSaveOk(null)
|
||||
setSaving(true)
|
||||
|
|
@ -246,12 +300,18 @@ export default function TrainingCoachPage() {
|
|||
}
|
||||
|
||||
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} />
|
||||
|
||||
<div className="training-coach-page training-coach-layout">
|
||||
<nav
|
||||
className="no-print training-coach-top"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '12px' }}
|
||||
className="no-print training-coach-meta-nav"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate(`/planning/run/${unitId}`)}>
|
||||
Zur Planansicht
|
||||
|
|
@ -269,32 +329,31 @@ export default function TrainingCoachPage() {
|
|||
</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 }}>
|
||||
<header
|
||||
className="card training-coach-hero training-coach-hero--compact"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '12px 14px',
|
||||
marginBottom: '8px',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
borderLeft: `4px solid var(--accent)`
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
Coach · Schritt {(step || 0) + 1} / {Math.max(timeline.length, 1)}
|
||||
</div>
|
||||
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', 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' }}>
|
||||
<div className="card training-coach-outline" style={{ flexShrink: 0, marginBottom: '8px', padding: '10px 12px', maxHeight: 'min(28vh, 260px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>Ablauf · Antippen zum Springen</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto', minHeight: 0 }}>
|
||||
{timeline.map((ent, ix) => {
|
||||
const lbl = summarizeTimelineEntry(ent)
|
||||
const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}`
|
||||
|
|
@ -308,7 +367,7 @@ export default function TrainingCoachPage() {
|
|||
textAlign: 'left',
|
||||
justifyContent: 'flex-start',
|
||||
opacity: active ? 1 : 0.92,
|
||||
fontWeight: active ? 700 : 500,
|
||||
fontWeight: active ? 700 : 500
|
||||
}}
|
||||
onClick={() => setStep(ix)}
|
||||
>
|
||||
|
|
@ -322,208 +381,218 @@ export default function TrainingCoachPage() {
|
|||
)}
|
||||
|
||||
{timeline.length === 0 ? (
|
||||
<p className="card" style={{ padding: '1rem' }}>
|
||||
Dieser Plan ist leer.{' '}
|
||||
<Link to="/planning">Unter Planung ergänzen</Link>.
|
||||
<p className="card" style={{ padding: '1rem', flexShrink: 0 }}>
|
||||
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>
|
||||
<div
|
||||
className="training-coach-assist training-coach-assist--compact"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginBottom: '8px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--accent-light)',
|
||||
borderRadius: '12px'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
|
||||
Als Nächstes
|
||||
</div>
|
||||
{nextEntry ? (
|
||||
<>
|
||||
<p style={{ margin: '0 0 6px', fontSize: '0.94rem', color: 'var(--text1)' }}>
|
||||
<p style={{ margin: '0', fontSize: '0.9rem', color: 'var(--text1)', lineHeight: 1.4 }}>
|
||||
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
|
||||
</p>
|
||||
{next2Entry && (
|
||||
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text2)' }}>
|
||||
<p style={{ margin: '6px 0 0', fontSize: '0.82rem', 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 style={{ margin: 0, fontSize: '0.88rem', color: 'var(--text2)' }}>
|
||||
Letzter Punkt — unten Zeit speichern / Nachbereitung öffnen.
|
||||
</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>
|
||||
<CoachStepNavBar
|
||||
step={step}
|
||||
timelineLength={timeline.length}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onDone={markCurrentDoneAdvance}
|
||||
/>
|
||||
|
||||
{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 className="training-coach-scroll">
|
||||
{currentEntry?.item?.item_type === 'note' ? (
|
||||
<div className="card" style={{ padding: '16px 14px' }}>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
{currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil {step + 1}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</div>
|
||||
<p style={{ fontSize: '1.05rem', lineHeight: 1.52, whiteSpace: 'pre-wrap', margin: 0 }}>{currentEntry.item.note_body || ''}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card training-coach-plan-strip" style={{ padding: '12px 14px', marginBottom: '12px', borderRadius: '12px', borderLeft: `3px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '6px' }}>
|
||||
In diesem Training · {currentEntry?.sec.title || 'Abschnitt'} · Teil {step + 1}
|
||||
</div>
|
||||
{currentEntry?.item && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>
|
||||
{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}
|
||||
</p>
|
||||
<p style={{ fontSize: '0.83rem', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
geplant <strong>{currentEntry.item.planned_duration_min ?? '—'}</strong> Min · Ist (Plan):{' '}
|
||||
<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.82rem', color: 'var(--text2)', marginBottom: '10px' }}>{currentEntry.item.exercise_focus_area}</p>
|
||||
) : null}
|
||||
{currentEntry.item.notes ? (
|
||||
<div style={{ marginBottom: '10px', padding: '10px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
||||
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--text3)' }}>Zu dieser Platzierung</div>
|
||||
<p style={{ marginTop: '4px', whiteSpace: 'pre-wrap', fontSize: '0.93rem' }}>{currentEntry.item.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{currentEntry.item.modifications ? (
|
||||
<div style={{ padding: '10px', borderRadius: '8px', border: `1px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--accent-dark)' }}>Anpassung</div>
|
||||
<p style={{ marginTop: '4px', whiteSpace: 'pre-wrap', fontSize: '0.93rem' }}>{currentEntry.item.modifications}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '14px 0 12px' }} />
|
||||
<div style={{ fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.04em', color: 'var(--text3)', marginBottom: '6px' }}>
|
||||
Übung aus dem Katalog (vollständig)
|
||||
</div>
|
||||
<ExerciseFullContent
|
||||
loading={catalogLoading}
|
||||
error={catalogError}
|
||||
exercise={catalogExercise}
|
||||
exerciseId={currentEntry?.item?.exercise_id ?? null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentEntry.item.item_type !== 'note' && (
|
||||
{unit.trainer_notes ? (
|
||||
<div className="card" style={{ marginBottom: '12px', padding: '12px 14px', borderLeft: `4px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: 'var(--text3)', textTransform: 'uppercase' }}>Einheit · Trainernotizen</div>
|
||||
<p style={{ fontSize: '0.88rem', marginTop: '6px', whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>{unit.trainer_notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card training-coach-debrief" style={{ padding: '14px', marginBottom: '8px', borderRadius: '10px' }}>
|
||||
<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 & speichern</span>
|
||||
<span>{debriefOpen ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{debriefOpen && (
|
||||
<>
|
||||
<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 style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '14px 0 10px', lineHeight: 1.5 }}>
|
||||
<strong>Ist‑Minuten</strong> anpassen und Einheit speichern (inkl. Trainer-Ergänzung).
|
||||
</p>
|
||||
{currentEntry.item.exercise_focus_area && (
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text2)', marginBottom: '10px' }}>
|
||||
Bereich: {currentEntry.item.exercise_focus_area}
|
||||
<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>
|
||||
)}
|
||||
{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}
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', minHeight: '48px' }} disabled={saving} onClick={handleSaveDebrief}>
|
||||
{saving ? 'Speichert…' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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' }}>
|
||||
<div className="card training-coach-timer" style={{ flexShrink: 0, padding: '12px', textAlign: 'center', background: 'var(--surface2)', borderRadius: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text3)', marginBottom: '6px' }}>Zeitnahme</div>
|
||||
<div style={{ fontSize: '2.2rem', fontWeight: 800, letterSpacing: '0.06em', fontVariantNumeric: 'tabular-nums', marginBottom: '10px' }}>
|
||||
{formatClock(tickDisplaySec)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', justifyContent: 'center', marginBottom: '10px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', justifyContent: 'center', marginBottom: '8px' }}>
|
||||
{!runStartAt ? (
|
||||
<button type="button" className="btn btn-primary" style={{ minWidth: '112px', minHeight: '48px' }} onClick={timerStart}>
|
||||
<button type="button" className="btn btn-primary" style={{ minWidth: '100px', minHeight: '46px' }} onClick={timerStart}>
|
||||
Start
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-secondary" style={{ minWidth: '112px', minHeight: '48px' }} onClick={timerPause}>
|
||||
<button type="button" className="btn btn-secondary" style={{ minWidth: '100px', minHeight: '46px' }} onClick={timerPause}>
|
||||
Pause / Stopp
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary" style={{ minHeight: '48px' }} onClick={() => timerReset()}>
|
||||
<button type="button" className="btn btn-secondary" style={{ minHeight: '46px' }} 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>Ist‑Minuten</strong> für dieses Element und kann später mit „Nachbereitung“ auf dem Server gespeichert werden.
|
||||
<p style={{ fontSize: '0.76rem', color: 'var(--text2)', marginBottom: '10px' }}>
|
||||
Übernimmt Ist‑Minuten für diesen Platz (gerundet).
|
||||
</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 type="button" className="btn btn-primary" style={{ width: '100%', maxWidth: '400px', minHeight: '46px' }} onClick={applySuggestedDuration}>
|
||||
{`Übernehmen 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 & 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 & speichern</span>
|
||||
<span>{debriefOpen ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{debriefOpen && (
|
||||
<>
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '14px 0 10px', lineHeight: 1.5 }}>
|
||||
Übertragene <strong>Ist‑Minuten</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>
|
||||
<CoachStepNavBar step={step} timelineLength={timeline.length} onPrev={goPrev} onNext={goNext} onDone={markCurrentDoneAdvance} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user