diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e0d59df..560f396 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx new file mode 100644 index 0000000..1063e7d --- /dev/null +++ b/frontend/src/components/ExercisePeekModal.jsx @@ -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 ( +
+ ) +} + +function TagMini({ exercise }) { + const parts = [] + ;(exercise.focus_areas || []).slice(0, 5).forEach((f) => { + parts.push(f.name) + }) + if (parts.length === 0) return null + return ( +
+ {parts.map((p, i) => ( + + {p} + + ))} +
+ ) +} + +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 ( +
e.target === e.currentTarget && onClose()}> +
e.stopPropagation()} + > +
+

+ {loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`} +

+ +
+
+ {loading && ( +
+
+

Laden…

+
+ )} + {!loading && err &&

{err}

} + {!loading && exercise && ( + <> + {exercise.summary && ( +
+ +
+ )} + + {(exercise.goal || exercise.preparation || exercise.execution || exercise.trainer_notes) && ( +
+ )} + {exercise.goal && ( + <> +

Ziel

+ + + )} + {exercise.preparation && ( + <> +

Vorbereitung

+ + + )} + {exercise.execution && ( + <> +

Ablauf

+ + + )} + {exercise.trainer_notes && ( + <> +

Trainer-Hinweise

+ + + )} + + )} +
+ {exerciseId && ( +
+ + Vollständige Übungsseite öffnen + +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/TrainingCoachPage.jsx b/frontend/src/pages/TrainingCoachPage.jsx new file mode 100644 index 0000000..b84c3ba --- /dev/null +++ b/frontend/src/pages/TrainingCoachPage.jsx @@ -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 ( +
+
+

Coach laden…

+
+ ) + } + + if (loadError || !unit) { + return ( +
+

{loadError || 'Nicht gefunden.'}

+ +
+ ) + } + + return ( +
+ setPeekId(null)} titleFallback={null} /> + + + +
+
Im Training · Coach
+

+ {unit.planned_date} + {unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`} + {unit.planned_focus ? ` · ${unit.planned_focus}` : ''} +

+
+ {unit.group_name && ( + + Gruppe: {unit.group_name} + + )} + + Status: {statusLabel(unit.status)} + + + Schritt {(step || 0) + 1} / {Math.max(timeline.length, 1)} + +
+
+ + {outlineOpen && ( +
+
Ablauf (Antippen springt zum Schritt)
+
+ {timeline.map((ent, ix) => { + const lbl = summarizeTimelineEntry(ent) + const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}` + const active = ix === step + return ( + + ) + })} +
+
+ )} + + {timeline.length === 0 ? ( +

+ Dieser Plan ist leer.{' '} + Unter Planung ergänzen. +

+ ) : ( + <> +
+
Assistenz · als Nächstes
+ {nextEntry ? ( + <> +

+ Nächste: {summarizeTimelineEntry(nextEntry)} +

+ {next2Entry && ( +

+ Daraufhin: {summarizeTimelineEntry(next2Entry)} +

+ )} + + ) : ( +

+ Dies war der letzte Eintrag. +
+ Gute Arbeit — unten kannst du notieren und Ist-Zeiten speichern. +

+ )} +
+ + {currentEntry && ( +
+
+ {currentEntry.sec.title || 'Abschnitt'} · Teil {step + 1} +
+ + {currentEntry.item.item_type === 'note' && ( +
+
Coach-Notiz
+

{currentEntry.item.note_body || ''}

+
+ )} + + {currentEntry.item.item_type !== 'note' && ( + <> +

+ {currentEntry.item.exercise_title || + (currentEntry.item.exercise_id ? `Übung #${currentEntry.item.exercise_id}` : 'Übung')} + {currentEntry.item.exercise_variant_name ? ( + ({currentEntry.item.exercise_variant_name}) + ) : null} +

+

+ geplant {currentEntry.item.planned_duration_min ?? '—'} Min · Ist (Plan/Bearbeitung):{' '} + + {durationOverridesForApi[String(currentEntry.item.id)] != null + ? durationOverridesForApi[String(currentEntry.item.id)].actual_duration_min + : currentEntry.item.actual_duration_min ?? '—'}{' '} + Min + + {durationOverridesForApi[String(currentEntry.item.id)] != null ? ' (Entwurf)' : ''} +

+ {currentEntry.item.exercise_focus_area && ( +

+ Bereich: {currentEntry.item.exercise_focus_area} +

+ )} + {currentEntry.item.notes ? ( +
+
Zu dieser Platzierung
+

{currentEntry.item.notes}

+
+ ) : null} + {currentEntry.item.modifications ? ( +
+
Anpassung / Variante
+

{currentEntry.item.modifications}

+
+ ) : null} + {currentEntry.item.exercise_id ? ( + + ) : null} + + )} +
+ )} + +
+
Zeitnahme für diesen Punkt
+
+ {formatClock(tickDisplaySec)} +
+
+ {!runStartAt ? ( + + ) : ( + + )} + +
+

+ Übernimmt die gemessene Zeit (auf volle Minuten gerundet) als Ist‑Minuten für dieses Element und kann später mit „Nachbereitung“ auf dem Server gespeichert werden. +

+ +
+ +
+ + +
+ +
+ +
+ + {unit.trainer_notes && ( +
+
Trainernotiz (dieser Einheit)
+

{unit.trainer_notes}

+
+ )} + +
+ + {debriefOpen && ( + <> +

+ Übertragene Ist‑Minuten (Entwürfe aus dem Timer oder hier anpassen). Beim Speichern werden sie mit dem Plan an den Server geschickt. +

+
+ {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 ( + + ) + })} +
+