feat: add TrainingCoachPage and enhance training planning features
- 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:
parent
8debdae397
commit
3673053fe2
|
|
@ -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 />} />
|
||||
|
|
|
|||
149
frontend/src/components/ExercisePeekModal.jsx
Normal file
149
frontend/src/components/ExercisePeekModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
521
frontend/src/pages/TrainingCoachPage.jsx
Normal file
521
frontend/src/pages/TrainingCoachPage.jsx
Normal 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>Ist‑Minuten</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 & 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 & 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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
107
frontend/src/utils/trainingPlanUtils.js
Normal file
107
frontend/src/utils/trainingPlanUtils.js
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user