UX Verbesserung, Navigationsspeicherung #40

Merged
Lars merged 6 commits from develop into main 2026-05-20 10:44:00 +02:00
28 changed files with 497 additions and 190 deletions
Showing only changes of commit 4588ef4c7e - Show all commits

View File

@ -1,7 +1,7 @@
# Navigation — Return-Kontext (Rücksprung) # Navigation — Return-Kontext (Rücksprung)
**Stand:** 2026-05-20 **Stand:** 2026-05-20
**Status:** Spezifikation + schrittweise Umsetzung (Pilot) **Status:** Spezifikation + Phase 12 umgesetzt
**Ziel:** In der PWA (ohne Browser-Back) zuverlässig an den fachlichen Ausgangspunkt zurückkehren — inkl. sinnvollem Label und optional UI-State. **Ziel:** In der PWA (ohne Browser-Back) zuverlässig an den fachlichen Ausgangspunkt zurückkehren — inkl. sinnvollem Label und optional UI-State.
--- ---
@ -52,6 +52,13 @@ Router-State-Schlüssel: **`appReturn`**
| `exerciseList` | — | `/exercises` (Filter/Auswahl via sessionStorage) | | `exerciseList` | — | `/exercises` (Filter/Auswahl via sessionStorage) |
| `planningHub` | `buildPlanningHubReturnState(...)` | `planningHubPathFromReturnState(payload)` | | `planningHub` | `buildPlanningHubReturnState(...)` | `planningHubPathFromReturnState(payload)` |
| `trainingModulesList` | — | `/planning/training-modules` | | `trainingModulesList` | — | `/planning/training-modules` |
| `planTemplatesList` | — | `/planning/plan-templates` |
| `frameworkProgramsList` | — | `/planning/framework-programs` |
| `settings` | — | `/settings` |
| `dashboard` | — | `/` |
| `mediaLibrary` | — | `/media` |
| `trainingRun` | `{ unitId }` | `/planning/run/:unitId` |
| `currentLocation` | — | aktuelle Route (z.B. Einheiten-Editor) |
| (frei) | — | `path` direkt gesetzt | | (frei) | — | `path` direkt gesetzt |
### Legacy-Kompatibilität ### Legacy-Kompatibilität
@ -76,14 +83,23 @@ Zentrale Datei: `frontend/src/utils/navReturnContext.js`
| `navigateWithAppReturn(navigate, to, returnContext, options?)` | Navigation mit gesetztem `appReturn` | | `navigateWithAppReturn(navigate, to, returnContext, options?)` | Navigation mit gesetztem `appReturn` |
| `preserveAppReturnOnNavigate(navigate, location, to, options?)` | Weiterleiten, bestehenden Kontext behalten (z.B. nach `replace`) | | `preserveAppReturnOnNavigate(navigate, location, to, options?)` | Weiterleiten, bestehenden Kontext behalten (z.B. nach `replace`) |
UI-Komponente: **`PageReturnLink`** — einheitlicher Zurück-Link oben auf Editor-/Detailseiten. UI-Komponente: **`PageReturnButton`** — app-typischer Zurück-Schalter (Button mit Pfeil, kein Router-Link).
Links **zum** Ziel: **`NavStateLink`** mit `returnContext` der Quellseite.
### Editor-Aktionen
Auf Vollseiten-Editoren mit **`PageFormEditorChrome`** oder **`FormActionBar`** (`placement="bottom"`):
- **Abbrechen**`goBack()` / `goNavReturn(...)` (Einsprungspunkt, nicht feste Route)
- **Speichern & Schließen** → nach erfolgreichem Save ebenfalls `goBack()`
- Sticky Action Bar unten nutzen, wo vorhanden
--- ---
## Regeln für Entwickler ## Regeln für Entwickler
1. **Jede Navigation** von Kontext A zu Editor B, wo der Nutzer „weitermachen“ soll, setzt `appReturn` (oder nutzt `navigateWithAppReturn`). 1. **Jede Navigation** von Kontext A zu Editor B, wo der Nutzer „weitermachen“ soll, setzt `appReturn` (oder nutzt `navigateWithAppReturn`).
2. **Zielseite** zeigt `PageReturnLink` mit sinnvollem **Default-Fallback** (Bibliothek/Hub). 2. **Zielseite** zeigt `PageReturnButton` mit sinnvollem **Default-Fallback** (Bibliothek/Hub).
3. **Nach Create + `replace: true`:** Return-Kontext mit `preserveAppReturnOnNavigate` erhalten. 3. **Nach Create + `replace: true`:** Return-Kontext mit `preserveAppReturnOnNavigate` erhalten.
4. **Modals:** Schließen reicht; Redirect nach Speichern = Seiten-Navigation → Return setzen. 4. **Modals:** Schließen reicht; Redirect nach Speichern = Seiten-Navigation → Return setzen.
5. **Kein Return-Kontext** in `location.state` für interne Bibliothek → Detail → Bearbeiten, wenn Herkunft = offensichtliche Elternliste (Default-Fallback genügt). 5. **Kein Return-Kontext** in `location.state` für interne Bibliothek → Detail → Bearbeiten, wenn Herkunft = offensichtliche Elternliste (Default-Fallback genügt).
@ -91,20 +107,30 @@ UI-Komponente: **`PageReturnLink`** — einheitlicher Zurück-Link oben auf Edit
--- ---
## Pilot-Umsetzung (Phase 1) ## Umsetzungsstand
### Phase 1 (Pilot)
- [x] Spec + Utility + Tests - [x] Spec + Utility + Tests
- [x] `PageReturnLink` - [x] `PageReturnButton` (ersetzt Link-Variante)
- [x] Übungsliste → Modul speichern → Modul-Editor (dynamischer Zurück-Link) - [x] Übungsliste → Modul speichern → Modul-Editor
- [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter - [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter
- [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge) - [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge)
## Folge-Phasen (noch offen) ### Phase 2 (Flows verbinden)
- Weitere Editoren (Übung, Vorlage, Rahmenprogramm) - [x] Listen → Editoren: Übungen, Module, Vorlagen, Rahmenprogramme
- Optional: globaler Zurück-Button in App-Chrome (Mobile) - [x] Dashboard → Übung bearbeiten / Trainingsablauf / Einheit bearbeiten
- [x] Einstellungen-Unterseiten (Rechtliches, Systeminfo)
- [x] Trainingsablauf + Coach-Modus (`trainingRun`, Planungs-Fallback)
- [x] Medienbibliothek → verknüpfte Übungen/Einheiten
- [x] `ExercisePeekModal` → Vollseite mit Return
- [x] Editoren: Abbrechen + Speichern & Schließen → Einsprungspunkt
### Optional (später)
- Globaler Zurück-Button in App-Chrome (Mobile)
- Nach Speichern: explizite Aktion „Zurück zum Ausgang“ im Toast - Nach Speichern: explizite Aktion „Zurück zum Ausgang“ im Toast
- `ExercisePeekModal` → Vollseite mit Return
--- ---

View File

@ -1466,6 +1466,21 @@ html.modal-scroll-locked .app-main {
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
} }
.page-return-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 0.75rem;
max-width: 100%;
}
.page-return-btn__icon {
flex-shrink: 0;
}
.page-return-btn span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-return-link:hover { .page-return-link:hover {
text-decoration: underline; text-decoration: underline;
} }

View File

@ -5,6 +5,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import NavStateLink from './NavStateLink'
import ExerciseRichTextBlock from './ExerciseRichTextBlock' import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import CombinationPlanBracket from './CombinationPlanBracket' import CombinationPlanBracket from './CombinationPlanBracket'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
@ -36,6 +37,8 @@ export default function ExercisePeekModal({
titleFallback, titleFallback,
/** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */ /** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */
peekExtras, peekExtras,
/** Rücksprung-Kontext für „Vollständige Übungsseite“ */
returnContext,
}) { }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null) const [err, setErr] = useState(null)
@ -255,13 +258,24 @@ export default function ExercisePeekModal({
</div> </div>
{top?.exerciseId != null ? ( {top?.exerciseId != null ? (
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}> <div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
<Link {returnContext ? (
to={`/exercises/${top.exerciseId}`} <NavStateLink
className="btn btn-secondary" to={`/exercises/${top.exerciseId}`}
style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }} returnContext={returnContext}
> className="btn btn-secondary"
Vollständige Übungsseite öffnen style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}
</Link> >
Vollständige Übungsseite öffnen
</NavStateLink>
) : (
<Link
to={`/exercises/${top.exerciseId}`}
className="btn btn-secondary"
style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}
>
Vollständige Übungsseite öffnen
</Link>
)}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -0,0 +1,12 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { linkStateWithAppReturn } from '../utils/navReturnContext'
/** Router-Link mit appReturn im State (Rücksprung vom Zielscreen). */
export default function NavStateLink({ to, returnContext, state, children, ...rest }) {
return (
<Link to={to} state={linkStateWithAppReturn(returnContext, state)} {...rest}>
{children}
</Link>
)
}

View File

@ -1,27 +1,30 @@
import React from 'react' import React from 'react'
import { Link } from 'react-router-dom'
import { useFormEditorActions } from '../context/FormEditorActionsContext' import { useFormEditorActions } from '../context/FormEditorActionsContext'
import PageReturnButton from './PageReturnButton'
/** /**
* Vollseiten-Editor: Zurück/Titel oben; FormActionBar fix unten (alle Viewports via FormEditorBottomSlot). * Vollseiten-Editor: Zurück-Schalter + Titel oben; FormActionBar fix unten (FormEditorBottomSlot).
*/ */
export default function PageFormEditorChrome({ export default function PageFormEditorChrome({
title, title,
backTo, fallbackPath,
backLabel = 'Zurück', fallbackLabel,
actionConfig, actionConfig,
children, children,
testId, testId,
showReturn = true,
}) { }) {
useFormEditorActions(actionConfig) useFormEditorActions(actionConfig)
return ( return (
<div className="page-form-editor" data-testid={testId}> <div className="page-form-editor" data-testid={testId}>
<header className="page-form-editor__header"> <header className="page-form-editor__header">
{backTo ? ( {showReturn && fallbackPath && fallbackLabel ? (
<Link to={backTo} className="page-form-editor__back"> <PageReturnButton
{backLabel} fallbackPath={fallbackPath}
</Link> fallbackLabel={fallbackLabel}
className="page-return-btn page-form-editor__back btn btn-secondary btn-small"
/>
) : null} ) : null}
<h1 className="page-form-editor__title">{title}</h1> <h1 className="page-form-editor__title">{title}</h1>
</header> </header>

View File

@ -0,0 +1,30 @@
import React, { useMemo } from 'react'
import { ArrowLeft } from 'lucide-react'
import { useNavReturn } from '../hooks/useNavReturn'
/**
* App-typischer Zurück-Schalter (kein Router-Link) nutzt appReturn / History / Fallback.
*/
export default function PageReturnButton({
fallbackPath,
fallbackLabel,
className = 'page-return-btn btn btn-secondary btn-small',
}) {
const fallback = useMemo(
() =>
fallbackPath && fallbackLabel
? { path: fallbackPath, label: fallbackLabel }
: null,
[fallbackPath, fallbackLabel]
)
const { goBack, target } = useNavReturn(fallback)
if (!target?.label) return null
return (
<button type="button" className={className} onClick={goBack} aria-label={target.label}>
<ArrowLeft size={16} strokeWidth={2.25} className="page-return-btn__icon" aria-hidden />
<span>{target.label}</span>
</button>
)
}

View File

@ -1,36 +0,0 @@
import React from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { goNavReturn, resolveNavReturnTarget } from '../utils/navReturnContext'
/**
* Einheitlicher Zurück-Link für Editor-/Detailseiten (PWA-sicher).
*/
export default function PageReturnLink({
fallbackPath,
fallbackLabel,
className = 'page-return-link',
style,
}) {
const location = useLocation()
const navigate = useNavigate()
const target = resolveNavReturnTarget(location, {
path: fallbackPath,
label: fallbackLabel,
})
if (!target?.path || !target?.label) return null
const handleClick = (e) => {
e.preventDefault()
goNavReturn(navigate, location, {
path: fallbackPath,
label: fallbackLabel,
})
}
return (
<a href={target.path} className={className} style={style} onClick={handleClick}>
{target.label}
</a>
)
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react' import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom' import { useNavigate, useParams, Link, useLocation } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../../utils/api' import api, { buildExerciseApiPayload } from '../../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl' import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl'
import RichTextEditor from '../RichTextEditor' import RichTextEditor from '../RichTextEditor'
@ -27,6 +27,14 @@ import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi }
import { GripVertical } from 'lucide-react' import { GripVertical } from 'lucide-react'
import UnsavedChangesPrompt from '../UnsavedChangesPrompt' import UnsavedChangesPrompt from '../UnsavedChangesPrompt'
import PageFormEditorChrome from '../PageFormEditorChrome' import PageFormEditorChrome from '../PageFormEditorChrome'
import { useNavReturn } from '../../hooks/useNavReturn'
import {
EXERCISES_LIST_PATH,
buildCurrentLocationReturnContext,
buildExercisesListReturnContext,
linkStateWithAppReturn,
preserveAppReturnOnNavigate,
} from '../../utils/navReturnContext'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker'
const INTENSITY_OPTIONS = [ const INTENSITY_OPTIONS = [
@ -469,6 +477,9 @@ function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
function ExerciseFormPageRoot() { function ExerciseFormPageRoot() {
const { id: routeId } = useParams() const { id: routeId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const exercisesListReturn = useMemo(() => buildExercisesListReturnContext(), [])
const { goBack } = useNavReturn(exercisesListReturn)
const { user } = useAuth() const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
@ -941,16 +952,16 @@ function ExerciseFormPageRoot() {
setVariants((ex.variants || []).map(apiVariantToRow)) setVariants((ex.variants || []).map(apiVariantToRow))
setFormDirty(false) setFormDirty(false)
toast.success('Gespeichert.') toast.success('Gespeichert.')
if (closeAfter) navigate('/exercises') if (closeAfter) goBack()
return true return true
} }
const created = await api.createExercise(payload) const created = await api.createExercise(payload)
setFormDirty(false) setFormDirty(false)
toast.success('Übung angelegt.') toast.success('Übung angelegt.')
if (closeAfter) { if (closeAfter) {
navigate('/exercises') goBack()
} else if (!fromUnsavedDialog) { } else if (!fromUnsavedDialog) {
navigate(`/exercises/${created.id}/edit`, { replace: true }) preserveAppReturnOnNavigate(navigate, location, `/exercises/${created.id}/edit`, { replace: true })
} }
return true return true
} catch (err) { } catch (err) {
@ -960,7 +971,7 @@ function ExerciseFormPageRoot() {
setSaving(false) setSaving(false)
} }
}, },
[exerciseId, formData, isEdit, navigate, toast], [exerciseId, formData, isEdit, navigate, location, toast, goBack],
) )
const handleSubmit = useCallback( const handleSubmit = useCallback(
@ -979,10 +990,6 @@ function ExerciseFormPageRoot() {
[performSaveAttempt], [performSaveAttempt],
) )
const goBackToList = useCallback(() => {
navigate('/exercises')
}, [navigate])
const actionConfig = useMemo( const actionConfig = useMemo(
() => ({ () => ({
formId: 'exercise-form', formId: 'exercise-form',
@ -990,11 +997,11 @@ function ExerciseFormPageRoot() {
isNew: !isEdit, isNew: !isEdit,
onSave: handleSubmit, onSave: handleSubmit,
onSaveAndClose: handleSaveAndClose, onSaveAndClose: handleSaveAndClose,
onCancel: goBackToList, onCancel: goBack,
showSave: true, showSave: true,
showSaveAndClose: true, showSaveAndClose: true,
}), }),
[saving, isEdit, handleSubmit, handleSaveAndClose, goBackToList], [saving, isEdit, handleSubmit, handleSaveAndClose, goBack],
) )
const handleUnsavedDialogSave = async () => { const handleUnsavedDialogSave = async () => {
@ -1198,15 +1205,17 @@ function ExerciseFormPageRoot() {
<PageFormEditorChrome <PageFormEditorChrome
testId="exercise-form-page" testId="exercise-form-page"
title={isEdit ? 'Übung bearbeiten' : 'Neue Übung'} title={isEdit ? 'Übung bearbeiten' : 'Neue Übung'}
backTo="/exercises" fallbackPath={EXERCISES_LIST_PATH}
backLabel="Übersicht" fallbackLabel="Zurück zur Übungsliste"
actionConfig={actionConfig} actionConfig={actionConfig}
> >
{isEdit ? ( {isEdit ? (
<p style={{ margin: '0 0 12px' }}> <p style={{ margin: '0 0 12px' }}>
<Link <Link
to={`/exercises/${exerciseId}`} to={`/exercises/${exerciseId}`}
state={{ fromExerciseEdit: true }} state={linkStateWithAppReturn(
buildCurrentLocationReturnContext(location, 'Zurück zur Bearbeitung')
)}
className="btn btn-secondary" className="btn btn-secondary"
style={{ fontSize: '0.875rem' }} style={{ fontSize: '0.875rem' }}
> >

View File

@ -1,5 +1,10 @@
import React from 'react' import React, { useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import NavStateLink from '../NavStateLink'
import {
buildExercisesListReturnContext,
navigateWithAppReturn,
} from '../../utils/navReturnContext'
import { import {
Eye, Eye,
Pencil, Pencil,
@ -141,12 +146,14 @@ export default function ExerciseListCard({
selectionPinned = false, selectionPinned = false,
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const listReturn = useMemo(() => buildExercisesListReturnContext(), [])
const focusNames = exerciseFocusNames(exercise) const focusNames = exerciseFocusNames(exercise)
const styleNames = coerceApiNameList(exercise.style_direction_names) const styleNames = coerceApiNameList(exercise.style_direction_names)
const typeNames = coerceApiNameList(exercise.training_type_names) const typeNames = coerceApiNameList(exercise.training_type_names)
const titleText = (exercise.title || 'Übung').replace(/"/g, '') const titleText = (exercise.title || 'Übung').replace(/"/g, '')
const openExercisePage = () => navigate(`/exercises/${exercise.id}`) const openExercisePage = () =>
navigateWithAppReturn(navigate, `/exercises/${exercise.id}`, listReturn)
const handleBodyClick = (e) => { const handleBodyClick = (e) => {
if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return
@ -186,9 +193,13 @@ export default function ExerciseListCard({
aria-label={`${titleText}“ öffnen`} aria-label={`${titleText}“ öffnen`}
> >
<h3 className="exercise-card-title"> <h3 className="exercise-card-title">
<Link to={`/exercises/${exercise.id}`} onClick={(e) => e.stopPropagation()}> <NavStateLink
to={`/exercises/${exercise.id}`}
returnContext={listReturn}
onClick={(e) => e.stopPropagation()}
>
{exercise.title} {exercise.title}
</Link> </NavStateLink>
{selectionPinned ? ( {selectionPinned ? (
<span className="exercise-card__selection-badge" title="In Modul-Auswahl"> <span className="exercise-card__selection-badge" title="In Modul-Auswahl">
Auswahl Auswahl
@ -246,14 +257,15 @@ export default function ExerciseListCard({
> >
<Eye size={18} strokeWidth={2} aria-hidden /> <Eye size={18} strokeWidth={2} aria-hidden />
</button> </button>
<Link <NavStateLink
to={`/exercises/${exercise.id}/edit`} to={`/exercises/${exercise.id}/edit`}
returnContext={listReturn}
className="exercise-card__icon-btn" className="exercise-card__icon-btn"
title="Bearbeiten" title="Bearbeiten"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`} aria-label={`${titleText}“ bearbeiten`}
> >
<Pencil size={18} strokeWidth={2} aria-hidden /> <Pencil size={18} strokeWidth={2} aria-hidden />
</Link> </NavStateLink>
{canUserRequestExerciseDelete(user, exercise) ? ( {canUserRequestExerciseDelete(user, exercise) ? (
<button <button
type="button" type="button"

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react' import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
import { Link } from 'react-router-dom'
import api from '../../utils/api' import api from '../../utils/api'
import { useAuth } from '../../context/AuthContext' import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub' import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
@ -11,6 +10,7 @@ import ExerciseListSearchBar from './ExerciseListSearchBar'
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar' import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal' import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
import ExercisePeekModal from '../ExercisePeekModal' import ExercisePeekModal from '../ExercisePeekModal'
import NavStateLink from '../NavStateLink'
import { buildExercisesListReturnContext } from '../../utils/navReturnContext' import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips' import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery' import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
@ -481,9 +481,13 @@ function ExercisesListPageRoot() {
<div className="exercises-page__header"> <div className="exercises-page__header">
<h1 className="page-title exercises-page__title">Übungen</h1> <h1 className="page-title exercises-page__title">Übungen</h1>
{pageTab === 'list' ? ( {pageTab === 'list' ? (
<Link to="/exercises/new" className="btn btn-primary"> <NavStateLink
to="/exercises/new"
returnContext={exercisesModuleReturnContext}
className="btn btn-primary"
>
+ Neu + Neu
</Link> </NavStateLink>
) : ( ) : (
<span aria-hidden="true" /> <span aria-hidden="true" />
)} )}

View File

@ -6,6 +6,7 @@ import api from '../../utils/api'
import { useToast } from '../../context/ToastContext' import { useToast } from '../../context/ToastContext'
import { useAuth } from '../../context/AuthContext' import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships } from '../../utils/activeClub' import { activeClubMemberships } from '../../utils/activeClub'
import { navigateWithAppReturn } from '../../utils/navReturnContext'
/** /**
* Übernimmt den gespeicherten Ablauf einer geplanten Trainingseinheit in ein Rahmenprogramm (neu oder bestehend, Slot wählbar). * Übernimmt den gespeicherten Ablauf einer geplanten Trainingseinheit in ein Rahmenprogramm (neu oder bestehend, Slot wählbar).
@ -15,6 +16,7 @@ export default function TrainingPublishToFrameworkModal({
onClose, onClose,
unitId, unitId,
planningModalClubId, planningModalClubId,
returnContext,
onSuccess, onSuccess,
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
@ -131,7 +133,7 @@ export default function TrainingPublishToFrameworkModal({
}) })
toast.success('Ablauf wurde im Rahmenprogramm gespeichert.') toast.success('Ablauf wurde im Rahmenprogramm gespeichert.')
if (created?.id) { if (created?.id) {
navigate(`/planning/framework-programs/${created.id}`) navigateWithAppReturn(navigate, `/planning/framework-programs/${created.id}`, returnContext)
} }
onSuccess?.() onSuccess?.()
resetAndClose() resetAndClose()
@ -177,7 +179,7 @@ export default function TrainingPublishToFrameworkModal({
const updated = await api.publishTrainingUnitToFramework(unitId, payload) const updated = await api.publishTrainingUnitToFramework(unitId, payload)
toast.success('Ablauf wurde im Rahmenprogramm gespeichert.') toast.success('Ablauf wurde im Rahmenprogramm gespeichert.')
if (updated?.id) { if (updated?.id) {
navigate(`/planning/framework-programs/${updated.id}`) navigateWithAppReturn(navigate, `/planning/framework-programs/${updated.id}`, returnContext)
} }
onSuccess?.() onSuccess?.()
resetAndClose() resetAndClose()

View File

@ -0,0 +1,19 @@
import { useCallback, useMemo } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { goNavReturn, resolveNavReturnTarget } from '../utils/navReturnContext'
/**
* @param {{ path: string, label: string } | null | undefined} fallback
*/
export function useNavReturn(fallback) {
const location = useLocation()
const navigate = useNavigate()
const target = useMemo(
() => resolveNavReturnTarget(location, fallback),
[location, fallback]
)
const goBack = useCallback(() => {
goNavReturn(navigate, location, fallback)
}, [navigate, location, fallback])
return { goBack, target }
}

View File

@ -1,9 +1,14 @@
import React, { useState, useEffect, useMemo } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react' import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react'
import NavStateLink from '../components/NavStateLink'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { getTenantClubDependencyKey } from '../utils/activeClub'
import {
buildDashboardReturnContext,
buildTrainingRunReturnContext,
} from '../utils/navReturnContext'
import EmailVerificationBanner from '../components/EmailVerificationBanner' import EmailVerificationBanner from '../components/EmailVerificationBanner'
import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget' import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget'
import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget' import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget'
@ -26,6 +31,7 @@ function Dashboard() {
const [dashboardKpisErr, setDashboardKpisErr] = useState(null) const [dashboardKpisErr, setDashboardKpisErr] = useState(null)
const { user, loading: authLoading } = useAuth() const { user, loading: authLoading } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const dashboardReturn = useMemo(() => buildDashboardReturnContext(), [])
useEffect(() => { useEffect(() => {
if (!user?.id) { if (!user?.id) {
@ -166,14 +172,20 @@ function Dashboard() {
<ul className="dashboard-preview-card__list"> <ul className="dashboard-preview-card__list">
{phase0Stats.draftPreview.map((ex) => ( {phase0Stats.draftPreview.map((ex) => (
<li key={ex.id}> <li key={ex.id}>
<Link to={`/exercises/${ex.id}/edit`} className="dashboard-preview-card__link"> <NavStateLink
to={`/exercises/${ex.id}/edit`}
returnContext={dashboardReturn}
className="dashboard-preview-card__link"
>
{ex.title} {ex.title}
</Link> </NavStateLink>
</li> </li>
))} ))}
</ul> </ul>
<p style={{ margin: '0.75rem 0 0', fontSize: '0.86rem' }}> <p style={{ margin: '0.75rem 0 0', fontSize: '0.86rem' }}>
<Link to={draftsHref}>Alle Entwürfe in der Übersicht</Link> <NavStateLink to={draftsHref} returnContext={dashboardReturn}>
Alle Entwürfe in der Übersicht
</NavStateLink>
</p> </p>
</div> </div>
) : null} ) : null}
@ -205,9 +217,13 @@ function Dashboard() {
<ul className="dashboard-preview-card__list"> <ul className="dashboard-preview-card__list">
{trainingHome.upcoming.map((u) => ( {trainingHome.upcoming.map((u) => (
<li key={u.id}> <li key={u.id}>
<Link to={`/planning/run/${u.id}`} className="dashboard-preview-card__link"> <NavStateLink
to={`/planning/run/${u.id}`}
returnContext={buildTrainingRunReturnContext(u.id) || dashboardReturn}
className="dashboard-preview-card__link"
>
{unitWhenLabel(u)} {unitWhenLabel(u)}
</Link> </NavStateLink>
{u.group_name ? ( {u.group_name ? (
<span className="dashboard-preview-card__meta">{`${u.group_name}`}</span> <span className="dashboard-preview-card__meta">{`${u.group_name}`}</span>
) : null} ) : null}
@ -237,9 +253,13 @@ function Dashboard() {
const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120) const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120)
return ( return (
<li key={`n-${u.id}`}> <li key={`n-${u.id}`}>
<Link to={`/planning/run/${u.id}`} className="dashboard-preview-card__link"> <NavStateLink
to={`/planning/run/${u.id}`}
returnContext={buildTrainingRunReturnContext(u.id) || dashboardReturn}
className="dashboard-preview-card__link"
>
{unitWhenLabel(u)} {unitWhenLabel(u)}
</Link> </NavStateLink>
{u.group_name ? ( {u.group_name ? (
<span className="dashboard-preview-card__meta">{` · ${u.group_name}`}</span> <span className="dashboard-preview-card__meta">{` · ${u.group_name}`}</span>
) : null} ) : null}
@ -266,9 +286,13 @@ function Dashboard() {
<ul className="dashboard-preview-card__list"> <ul className="dashboard-preview-card__list">
{trainingHome.reviewPending.map((u) => ( {trainingHome.reviewPending.map((u) => (
<li key={`r-${u.id}`}> <li key={`r-${u.id}`}>
<Link to={`/planning/units/${u.id}/edit`} className="dashboard-preview-card__link"> <NavStateLink
to={`/planning/units/${u.id}/edit`}
returnContext={dashboardReturn}
className="dashboard-preview-card__link"
>
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'} {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
</Link> </NavStateLink>
{u.group_name ? ( {u.group_name ? (
<span className="dashboard-preview-card__meta">{`${u.group_name}`}</span> <span className="dashboard-preview-card__meta">{`${u.group_name}`}</span>
) : null} ) : null}

View File

@ -1,6 +1,12 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom' import { useParams, useLocation } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import PageReturnButton from '../components/PageReturnButton'
import NavStateLink from '../components/NavStateLink'
import {
EXERCISES_LIST_PATH,
buildCurrentLocationReturnContext,
} from '../utils/navReturnContext'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock' import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip' import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
import CombinationPlanBracket from '../components/CombinationPlanBracket' import CombinationPlanBracket from '../components/CombinationPlanBracket'
@ -54,8 +60,11 @@ function metaParts(exercise) {
function ExerciseDetailPage() { function ExerciseDetailPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const editReturnContext = useMemo(
() => buildCurrentLocationReturnContext(location, 'Zurück zur Übung'),
[location]
)
const [exercise, setExercise] = useState(null) const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -98,9 +107,10 @@ function ExerciseDetailPage() {
<div className="card"> <div className="card">
<h2>Übung</h2> <h2>Übung</h2>
<p style={{ color: 'var(--danger)' }}>{msg}</p> <p style={{ color: 'var(--danger)' }}>{msg}</p>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}> <PageReturnButton
Zur Übersicht fallbackPath={EXERCISES_LIST_PATH}
</button> fallbackLabel="Zurück zur Übungsliste"
/>
</div> </div>
</div> </div>
) )
@ -109,7 +119,6 @@ function ExerciseDetailPage() {
if (!exercise) return null if (!exercise) return null
const meta = metaParts(exercise) const meta = metaParts(exercise)
const fromExerciseEdit = location.state?.fromExerciseEdit === true
const isCombinationDetail = const isCombinationDetail =
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' && (exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
@ -131,13 +140,19 @@ function ExerciseDetailPage() {
onClose={() => setEmbeddedPeekExerciseId(null)} onClose={() => setEmbeddedPeekExerciseId(null)}
/> />
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}> <PageReturnButton
Übersicht fallbackPath={EXERCISES_LIST_PATH}
</button> fallbackLabel="Zurück zur Übungsliste"
className="page-return-btn btn btn-secondary btn-small"
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary"> <NavStateLink
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'} to={`/exercises/${exercise.id}/edit`}
</Link> returnContext={editReturnContext}
className="btn btn-primary"
>
Bearbeiten
</NavStateLink>
</div> </div>
</div> </div>

View File

@ -1,5 +1,7 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react' import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import NavStateLink from '../components/NavStateLink'
import { buildMediaLibraryReturnContext } from '../utils/navReturnContext'
import { import {
LayoutGrid, LayoutGrid,
List, List,
@ -108,21 +110,23 @@ function parseTagsInput(s) {
.filter(Boolean) .filter(Boolean)
} }
function MediaUsageBlock({ usage, compact }) { function MediaUsageBlock({ usage, compact, returnContext }) {
const u = usage || { exercises: [], training_units: [] } const u = usage || { exercises: [], training_units: [] }
const ex = u.exercises || [] const ex = u.exercises || []
const tus = u.training_units || [] const tus = u.training_units || []
if (!ex.length && !tus.length) if (!ex.length && !tus.length)
return <span className="media-library__hint">{compact ? '—' : 'Noch in keiner Übung / Einheit verknüpft.'}</span> return <span className="media-library__hint">{compact ? '—' : 'Noch in keiner Übung / Einheit verknüpft.'}</span>
const LinkOrNav = returnContext ? NavStateLink : Link
const linkExtra = returnContext ? { returnContext } : {}
return ( return (
<div className="media-library__usage-links"> <div className="media-library__usage-links">
{ex.length ? ( {ex.length ? (
<div> <div>
<strong>Übungen</strong>{' '} <strong>Übungen</strong>{' '}
{ex.map((e) => ( {ex.map((e) => (
<Link key={e.id} to={`/exercises/${e.id}`} title={e.title}> <LinkOrNav key={e.id} to={`/exercises/${e.id}`} title={e.title} {...linkExtra}>
{e.title.length > (compact ? 18 : 40) ? `${e.title.slice(0, compact ? 18 : 40)}` : e.title} {e.title.length > (compact ? 18 : 40) ? `${e.title.slice(0, compact ? 18 : 40)}` : e.title}
</Link> </LinkOrNav>
))} ))}
</div> </div>
) : null} ) : null}
@ -134,9 +138,9 @@ function MediaUsageBlock({ usage, compact }) {
[t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}` [t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}`
const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}` : label const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}` : label
return ( return (
<Link key={t.id} to={`/planning/units/${t.id}/edit`} title={label}> <LinkOrNav key={t.id} to={`/planning/units/${t.id}/edit`} title={label} {...linkExtra}>
{short} {short}
</Link> </LinkOrNav>
) )
})} })}
</div> </div>
@ -297,6 +301,7 @@ export default function MediaLibraryPage() {
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
const hasClubOrgAdmin = activeClubMemberships(user?.clubs).some((c) => (c.roles || []).includes('club_admin')) const hasClubOrgAdmin = activeClubMemberships(user?.clubs).some((c) => (c.roles || []).includes('club_admin'))
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const mediaLibraryReturn = useMemo(() => buildMediaLibraryReturnContext(), [])
const archiveVisOptions = useMemo( const archiveVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
@ -1004,7 +1009,7 @@ export default function MediaLibraryPage() {
))} ))}
</div> </div>
) : null} ) : null}
<MediaUsageBlock usage={it.usage} compact /> <MediaUsageBlock usage={it.usage} compact returnContext={mediaLibraryReturn} />
{(it.lifecycle_state || 'active') === 'active' && !it.legal_hold_active && ( {(it.lifecycle_state || 'active') === 'active' && !it.legal_hold_active && (
<button <button
type="button" type="button"
@ -1074,7 +1079,7 @@ export default function MediaLibraryPage() {
{(it.tags || []).length ? (it.tags || []).join(', ') : '—'} {(it.tags || []).length ? (it.tags || []).join(', ') : '—'}
</td> </td>
<td className="media-library__td-usage media-library__td-sub"> <td className="media-library__td-usage media-library__td-sub">
<MediaUsageBlock usage={it.usage} compact /> <MediaUsageBlock usage={it.usage} compact returnContext={mediaLibraryReturn} />
</td> </td>
{viewer?.show_club_meta ? ( {viewer?.show_club_meta ? (
<td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td> <td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td>
@ -1333,7 +1338,7 @@ export default function MediaLibraryPage() {
<div className="media-library__meta-block"> <div className="media-library__meta-block">
<span className="media-library__meta-k">Verwendung</span> <span className="media-library__meta-k">Verwendung</span>
<div className="media-library__meta-v"> <div className="media-library__meta-v">
<MediaUsageBlock usage={modal.usage} compact={false} /> <MediaUsageBlock usage={modal.usage} compact={false} returnContext={mediaLibraryReturn} />
</div> </div>
</div> </div>

View File

@ -1,5 +1,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Scale } from 'lucide-react' import { Scale } from 'lucide-react'
import PageReturnButton from '../components/PageReturnButton'
import { SETTINGS_PATH } from '../utils/navReturnContext'
const LEGAL_LINKS = [ const LEGAL_LINKS = [
{ to: '/impressum', label: 'Impressum', description: 'Angaben zum Betreiber und Verantwortlichen' }, { to: '/impressum', label: 'Impressum', description: 'Angaben zum Betreiber und Verantwortlichen' },
@ -12,9 +14,7 @@ function SettingsLegalPage() {
return ( return (
<div className="page-padding app-page" style={{ padding: '1rem' }}> <div className="page-padding app-page" style={{ padding: '1rem' }}>
<p style={{ marginBottom: '0.75rem' }}> <p style={{ marginBottom: '0.75rem' }}>
<Link to="/settings" style={{ fontSize: '0.9rem' }}> <PageReturnButton fallbackPath={SETTINGS_PATH} fallbackLabel="Zurück zu Einstellungen" />
Zurück zu Einstellungen
</Link>
</p> </p>
<h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Rechtliches</h1> <h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Rechtliches</h1>

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import PageReturnButton from '../components/PageReturnButton'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import { SETTINGS_PATH } from '../utils/navReturnContext'
/** /**
* Technische System- und Build-Infos (ehemals Dashboard) unter Einstellungen für Betrieb/Diagnose. * Technische System- und Build-Infos (ehemals Dashboard) unter Einstellungen für Betrieb/Diagnose.
@ -33,9 +34,7 @@ function SettingsSystemInfoPage() {
return ( return (
<div className="page-padding app-page" style={{ padding: '1rem' }}> <div className="page-padding app-page" style={{ padding: '1rem' }}>
<p style={{ marginBottom: '0.75rem' }}> <p style={{ marginBottom: '0.75rem' }}>
<Link to="/settings" style={{ fontSize: '0.9rem' }}> <PageReturnButton fallbackPath={SETTINGS_PATH} fallbackLabel="Zurück zu Einstellungen" />
Zurück zu Einstellungen
</Link>
</p> </p>
<h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Systeminformationen</h1> <h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Systeminformationen</h1>
<p <p

View File

@ -6,6 +6,11 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent' import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import PageReturnButton from '../components/PageReturnButton'
import {
buildPlanningHubFallbackReturnContext,
buildTrainingRunReturnContext,
} from '../utils/navReturnContext'
import { import {
COACH_ENTRY_BRANCH_GATE, COACH_ENTRY_BRANCH_GATE,
buildCoachSavePlanPayload, buildCoachSavePlanPayload,
@ -184,6 +189,8 @@ export default function TrainingCoachPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const idNum = unitId ? parseInt(unitId, 10) : NaN const idNum = unitId ? parseInt(unitId, 10) : NaN
const planningReturn = useMemo(() => buildPlanningHubFallbackReturnContext(), [])
const runReturn = useMemo(() => buildTrainingRunReturnContext(unitId), [unitId])
const coachFocusResetRef = useRef(null) const coachFocusResetRef = useRef(null)
@ -667,9 +674,11 @@ export default function TrainingCoachPage() {
return ( return (
<div className="card" style={{ margin: '1rem', padding: '1.5rem' }}> <div className="card" style={{ margin: '1rem', padding: '1.5rem' }}>
<p style={{ marginBottom: '1rem' }}>{loadError || 'Nicht gefunden.'}</p> <p style={{ marginBottom: '1rem' }}>{loadError || 'Nicht gefunden.'}</p>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}> <PageReturnButton
Zur Planung fallbackPath={planningReturn.path}
</button> fallbackLabel={planningReturn.label}
className="page-return-btn btn btn-secondary btn-small"
/>
</div> </div>
) )
} }
@ -680,6 +689,7 @@ export default function TrainingCoachPage() {
key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'} key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'}
open={candidatePeekId != null} open={candidatePeekId != null}
exerciseId={candidatePeekId} exerciseId={candidatePeekId}
returnContext={runReturn}
onClose={() => setCandidatePeekId(null)} onClose={() => setCandidatePeekId(null)}
/> />
<nav <nav
@ -697,9 +707,11 @@ export default function TrainingCoachPage() {
<button type="button" className="btn btn-secondary" onClick={() => navigate(`/planning/run/${unitId}`)}> <button type="button" className="btn btn-secondary" onClick={() => navigate(`/planning/run/${unitId}`)}>
Zur Planansicht Zur Planansicht
</button> </button>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}> <PageReturnButton
Planung fallbackPath={planningReturn.path}
</button> fallbackLabel={planningReturn.label}
className="page-return-btn btn btn-secondary btn-small"
/>
{streamFocusOptions.length > 0 ? ( {streamFocusOptions.length > 0 ? (
<label <label
className="no-print" className="no-print"

View File

@ -6,7 +6,14 @@ import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import PageSectionNav from '../components/PageSectionNav' import PageSectionNav from '../components/PageSectionNav'
import FormActionBar from '../components/FormActionBar' import FormActionBar from '../components/FormActionBar'
import PageReturnButton from '../components/PageReturnButton'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useNavReturn } from '../hooks/useNavReturn'
import {
FRAMEWORK_PROGRAMS_LIST_PATH,
buildFrameworkProgramsListReturnContext,
preserveAppReturnOnNavigate,
} from '../utils/navReturnContext'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { import {
@ -212,6 +219,8 @@ export default function TrainingFrameworkProgramEditPage() {
const { id: idParam } = useParams() const { id: idParam } = useParams()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const frameworkListReturn = useMemo(() => buildFrameworkProgramsListReturnContext(), [])
const { goBack } = useNavReturn(frameworkListReturn)
/** Route `…/framework-programs/new` hat kein dynamisches `:id` — useParams ist dann leer. */ /** Route `…/framework-programs/new` hat kein dynamisches `:id` — useParams ist dann leer. */
const isNew = /\/framework-programs\/new\/?$/.test(location.pathname) const isNew = /\/framework-programs\/new\/?$/.test(location.pathname)
@ -328,7 +337,7 @@ export default function TrainingFrameworkProgramEditPage() {
} }
const fid = parseInt(idParam, 10) const fid = parseInt(idParam, 10)
if (Number.isNaN(fid)) { if (Number.isNaN(fid)) {
navigate('/planning/framework-programs', { replace: true }) navigate(FRAMEWORK_PROGRAMS_LIST_PATH, { replace: true })
return return
} }
setLoading(true) setLoading(true)
@ -342,7 +351,7 @@ export default function TrainingFrameworkProgramEditPage() {
setForm(next) setForm(next)
} catch (e) { } catch (e) {
toast.error(e.message || 'Laden fehlgeschlagen') toast.error(e.message || 'Laden fehlgeschlagen')
navigate('/planning/framework-programs') goBack()
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
} }
@ -450,9 +459,11 @@ export default function TrainingFrameworkProgramEditPage() {
const created = await api.createTrainingFrameworkProgram(payload) const created = await api.createTrainingFrameworkProgram(payload)
toast.success('Rahmenprogramm angelegt.') toast.success('Rahmenprogramm angelegt.')
if (closeAfter) { if (closeAfter) {
navigate('/planning/framework-programs') goBack()
} else if (!fromUnsavedDialog) { } else if (!fromUnsavedDialog) {
navigate(`/planning/framework-programs/${created.id}`, { replace: true }) preserveAppReturnOnNavigate(navigate, location, `/planning/framework-programs/${created.id}`, {
replace: true,
})
} }
return true return true
} }
@ -466,7 +477,7 @@ export default function TrainingFrameworkProgramEditPage() {
setBypassDirty(false) setBypassDirty(false)
setBaselineReady(true) setBaselineReady(true)
toast.success('Gespeichert.') toast.success('Gespeichert.')
if (closeAfter) navigate('/planning/framework-programs') if (closeAfter) goBack()
return true return true
} catch (e) { } catch (e) {
toast.error(e.message || 'Speichern fehlgeschlagen') toast.error(e.message || 'Speichern fehlgeschlagen')
@ -495,7 +506,7 @@ export default function TrainingFrameworkProgramEditPage() {
if (!confirm('Dieses Rahmenprogramm wirklich löschen?')) return if (!confirm('Dieses Rahmenprogramm wirklich löschen?')) return
try { try {
await api.deleteTrainingFrameworkProgram(fid) await api.deleteTrainingFrameworkProgram(fid)
navigate('/planning/framework-programs') goBack()
} catch (e) { } catch (e) {
toast.error(e.message || 'Löschen fehlgeschlagen') toast.error(e.message || 'Löschen fehlgeschlagen')
} }
@ -846,11 +857,10 @@ export default function TrainingFrameworkProgramEditPage() {
return ( return (
<div className="app-page"> <div className="app-page">
<div className="framework-edit"> <div className="framework-edit">
<p style={{ marginBottom: '0.75rem' }}> <PageReturnButton
<Link to="/planning/framework-programs" style={{ color: 'var(--accent-dark)' }}> fallbackPath={FRAMEWORK_PROGRAMS_LIST_PATH}
Alle Rahmenprogramme fallbackLabel="Zurück zu Rahmenprogrammen"
</Link> />
</p>
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1> <h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
@ -1263,7 +1273,7 @@ export default function TrainingFrameworkProgramEditPage() {
saving={saving} saving={saving}
onSave={handleSave} onSave={handleSave}
onSaveAndClose={handleSaveAndClose} onSaveAndClose={handleSaveAndClose}
onCancel={() => navigate('/planning/framework-programs')} onCancel={goBack}
cancelLabel="Abbrechen" cancelLabel="Abbrechen"
/> />
{!isNew ? ( {!isNew ? (

View File

@ -1,8 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
function dashIfEmpty(val) { function dashIfEmpty(val) {
const s = (val ?? '').toString().trim() const s = (val ?? '').toString().trim()
@ -59,6 +60,7 @@ function FrameworkSummaryMeta({ r }) {
export default function TrainingFrameworkProgramsListPage() { export default function TrainingFrameworkProgramsListPage() {
const { user } = useAuth() const { user } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const frameworkListReturn = useMemo(() => buildFrameworkProgramsListReturnContext(), [])
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -119,13 +121,14 @@ export default function TrainingFrameworkProgramsListPage() {
</div> </div>
</details> </details>
</div> </div>
<Link <NavStateLink
to="/planning/framework-programs/new" to="/planning/framework-programs/new"
returnContext={frameworkListReturn}
className="btn btn-primary" className="btn btn-primary"
style={{ textDecoration: 'none', whiteSpace: 'nowrap' }} style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}
> >
Rahmenprogramm anlegen Rahmenprogramm anlegen
</Link> </NavStateLink>
</div> </div>
{error && ( {error && (
@ -145,13 +148,14 @@ export default function TrainingFrameworkProgramsListPage() {
Noch kein Rahmenprogramm gespeichert. Lege ein neues an mit Titel, mindestens einem Ziel und optional Noch kein Rahmenprogramm gespeichert. Lege ein neues an mit Titel, mindestens einem Ziel und optional
Slots samt Übungen. Slots samt Übungen.
</p> </p>
<Link <NavStateLink
to="/planning/framework-programs/new" to="/planning/framework-programs/new"
returnContext={frameworkListReturn}
className="btn btn-primary btn-full" className="btn btn-primary btn-full"
style={{ textDecoration: 'none' }} style={{ textDecoration: 'none' }}
> >
Rahmenprogramm anlegen Rahmenprogramm anlegen
</Link> </NavStateLink>
</div> </div>
) : ( ) : (
<ul className="framework-programs-list"> <ul className="framework-programs-list">
@ -167,12 +171,13 @@ export default function TrainingFrameworkProgramsListPage() {
}} }}
> >
<div style={{ minWidth: 0, flex: '1 1 220px' }}> <div style={{ minWidth: 0, flex: '1 1 220px' }}>
<Link <NavStateLink
to={`/planning/framework-programs/${r.id}`} to={`/planning/framework-programs/${r.id}`}
returnContext={frameworkListReturn}
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }} style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
> >
{r.title || `Rahmen #${r.id}`} {r.title || `Rahmen #${r.id}`}
</Link> </NavStateLink>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}> <div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
<span> <span>
{(r.goals_count ?? '—') + ' Ziele · '} {(r.goals_count ?? '—') + ' Ziele · '}
@ -182,13 +187,14 @@ export default function TrainingFrameworkProgramsListPage() {
<FrameworkSummaryMeta r={r} /> <FrameworkSummaryMeta r={r} />
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<Link <NavStateLink
to={`/planning/framework-programs/${r.id}`} to={`/planning/framework-programs/${r.id}`}
returnContext={frameworkListReturn}
className="btn btn-secondary" className="btn btn-secondary"
style={{ textDecoration: 'none' }} style={{ textDecoration: 'none' }}
> >
Bearbeiten Bearbeiten
</Link> </NavStateLink>
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}> <button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
Löschen Löschen
</button> </button>

View File

@ -3,7 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePickerModal from '../components/ExercisePickerModal'
import FormActionBar from '../components/FormActionBar' import FormActionBar from '../components/FormActionBar'
import PageReturnLink from '../components/PageReturnLink' import PageReturnButton from '../components/PageReturnButton'
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
@ -377,7 +377,7 @@ export default function TrainingModuleEditPage() {
return ( return (
<div className="app-page"> <div className="app-page">
<PageReturnLink <PageReturnButton
fallbackPath={TRAINING_MODULES_LIST_PATH} fallbackPath={TRAINING_MODULES_LIST_PATH}
fallbackLabel="Zurück zur Modul-Bibliothek" fallbackLabel="Zurück zur Modul-Bibliothek"
/> />

View File

@ -1,12 +1,14 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
export default function TrainingModulesListPage() { export default function TrainingModulesListPage() {
const { user } = useAuth() const { user } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const modulesListReturn = useMemo(() => buildTrainingModulesListReturnContext(), [])
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -60,9 +62,14 @@ export default function TrainingModulesListPage() {
lokale Kopie (mit Herkunftsmarkierung). lokale Kopie (mit Herkunftsmarkierung).
</p> </p>
</div> </div>
<Link to="/planning/training-modules/new" className="btn btn-primary" style={{ textDecoration: 'none' }}> <NavStateLink
to="/planning/training-modules/new"
returnContext={modulesListReturn}
className="btn btn-primary"
style={{ textDecoration: 'none' }}
>
Neues Modul Neues Modul
</Link> </NavStateLink>
</div> </div>
{error ? ( {error ? (
@ -88,8 +95,9 @@ export default function TrainingModulesListPage() {
}} }}
> >
<div style={{ flex: '1 1 220px', minWidth: 0 }}> <div style={{ flex: '1 1 220px', minWidth: 0 }}>
<Link <NavStateLink
to={`/planning/training-modules/${r.id}`} to={`/planning/training-modules/${r.id}`}
returnContext={modulesListReturn}
style={{ style={{
fontWeight: 700, fontWeight: 700,
fontSize: '1.05rem', fontSize: '1.05rem',
@ -99,7 +107,7 @@ export default function TrainingModulesListPage() {
}} }}
> >
{(r.title || '').trim() || `Modul #${r.id}`} {(r.title || '').trim() || `Modul #${r.id}`}
</Link> </NavStateLink>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}> <p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
{(r.summary || '').trim() || '—'}{' '} {(r.summary || '').trim() || '—'}{' '}
<span style={{ color: 'var(--text3)' }}> <span style={{ color: 'var(--text3)' }}>

View File

@ -1,12 +1,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import FormActionBar from '../components/FormActionBar' import FormActionBar from '../components/FormActionBar'
import PageReturnButton from '../components/PageReturnButton'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useNavReturn } from '../hooks/useNavReturn'
import {
PLAN_TEMPLATES_LIST_PATH,
buildPlanTemplatesListReturnContext,
} from '../utils/navReturnContext'
import { import {
defaultSection, defaultSection,
formSectionsFromPlanTemplateRows, formSectionsFromPlanTemplateRows,
@ -31,6 +37,8 @@ function templateFormSnapshot({ name, description, visibility, clubIdField, sect
export default function TrainingPlanTemplateEditPage() { export default function TrainingPlanTemplateEditPage() {
const { id: routeId } = useParams() const { id: routeId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const templatesListReturn = useMemo(() => buildPlanTemplatesListReturnContext(), [])
const { goBack } = useNavReturn(templatesListReturn)
const templateId = parseInt(routeId, 10) const templateId = parseInt(routeId, 10)
const toast = useToast() const toast = useToast()
const { user } = useAuth() const { user } = useAuth()
@ -183,7 +191,7 @@ export default function TrainingPlanTemplateEditPage() {
baselineRef.current = templateFormSnapshot(latestFormRef.current) baselineRef.current = templateFormSnapshot(latestFormRef.current)
setBypassDirty(false) setBypassDirty(false)
toast.success('Vorlage gespeichert.') toast.success('Vorlage gespeichert.')
if (closeAfter) navigate('/planning/plan-templates') if (closeAfter) goBack()
return true return true
} catch (err) { } catch (err) {
const msg = err.message || 'Speichern fehlgeschlagen' const msg = err.message || 'Speichern fehlgeschlagen'
@ -220,11 +228,10 @@ export default function TrainingPlanTemplateEditPage() {
return ( return (
<div className="app-page"> <div className="app-page">
<p style={{ marginBottom: '0.75rem' }}> <PageReturnButton
<Link to="/planning/plan-templates" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}> fallbackPath={PLAN_TEMPLATES_LIST_PATH}
Zurück zu Vorlagen fallbackLabel="Zurück zu Vorlagen"
</Link> />
</p>
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}> <h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsvorlage bearbeiten Trainingsvorlage bearbeiten
@ -321,7 +328,7 @@ export default function TrainingPlanTemplateEditPage() {
variant="page" variant="page"
formId="plan-template-form" formId="plan-template-form"
saving={saving} saving={saving}
onCancel={() => navigate('/planning/plan-templates')} onCancel={goBack}
onSaveAndClose={handleSaveAndClose} onSaveAndClose={handleSaveAndClose}
/> />
</form> </form>

View File

@ -1,11 +1,12 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { getTenantClubDependencyKey } from '../utils/activeClub'
import { canDeleteLibraryContent, canEditLibraryContent } from '../utils/libraryContentPermissions' import { canDeleteLibraryContent, canEditLibraryContent } from '../utils/libraryContentPermissions'
import PlanTemplateStructurePreview from '../components/planning/PlanTemplateStructurePreview' import PlanTemplateStructurePreview from '../components/planning/PlanTemplateStructurePreview'
import { buildPlanTemplatesListReturnContext } from '../utils/navReturnContext'
function visibilityLabel(v) { function visibilityLabel(v) {
const x = String(v || 'club').toLowerCase() const x = String(v || 'club').toLowerCase()
@ -16,6 +17,7 @@ function visibilityLabel(v) {
export default function TrainingPlanTemplatesListPage() { export default function TrainingPlanTemplatesListPage() {
const { user } = useAuth() const { user } = useAuth()
const templatesListReturn = useMemo(() => buildPlanTemplatesListReturnContext(), [])
const toast = useToast() const toast = useToast()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
@ -113,8 +115,9 @@ export default function TrainingPlanTemplatesListPage() {
> >
<div style={{ minWidth: 0, flex: '1 1 240px' }}> <div style={{ minWidth: 0, flex: '1 1 240px' }}>
{canEdit ? ( {canEdit ? (
<Link <NavStateLink
to={`/planning/plan-templates/${t.id}`} to={`/planning/plan-templates/${t.id}`}
returnContext={templatesListReturn}
style={{ style={{
fontWeight: 700, fontWeight: 700,
fontSize: '1rem', fontSize: '1rem',
@ -124,7 +127,7 @@ export default function TrainingPlanTemplatesListPage() {
}} }}
> >
{(t.name || '').trim() || `Vorlage #${t.id}`} {(t.name || '').trim() || `Vorlage #${t.id}`}
</Link> </NavStateLink>
) : ( ) : (
<strong style={{ color: 'var(--text1)', fontSize: '1rem', wordBreak: 'break-word' }}> <strong style={{ color: 'var(--text1)', fontSize: '1rem', wordBreak: 'break-word' }}>
{(t.name || '').trim() || `Vorlage #${t.id}`} {(t.name || '').trim() || `Vorlage #${t.id}`}
@ -157,13 +160,14 @@ export default function TrainingPlanTemplatesListPage() {
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', flexShrink: 0 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', flexShrink: 0 }}>
{canEdit ? ( {canEdit ? (
<Link <NavStateLink
className="btn btn-secondary" className="btn btn-secondary"
style={{ textDecoration: 'none' }} style={{ textDecoration: 'none' }}
to={`/planning/plan-templates/${t.id}`} to={`/planning/plan-templates/${t.id}`}
returnContext={templatesListReturn}
> >
Bearbeiten Bearbeiten
</Link> </NavStateLink>
) : ( ) : (
<span style={{ fontSize: '0.82rem', color: 'var(--text3)', alignSelf: 'center' }}> <span style={{ fontSize: '0.82rem', color: 'var(--text3)', alignSelf: 'center' }}>
nur Lesen nur Lesen

View File

@ -32,8 +32,12 @@ import {
import PageFormEditorChrome from '../components/PageFormEditorChrome' import PageFormEditorChrome from '../components/PageFormEditorChrome'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes' import { buildPlanUnitEditPath } from '../utils/planningUnitRoutes'
import { goNavReturn, buildCurrentLocationReturnContext } from '../utils/navReturnContext' import {
PLANNING_HUB_PATH,
buildCurrentLocationReturnContext,
goNavReturn,
} from '../utils/navReturnContext'
export default function TrainingUnitEditPage() { export default function TrainingUnitEditPage() {
const { id: routeId } = useParams() const { id: routeId } = useParams()
@ -121,7 +125,7 @@ export default function TrainingUnitEditPage() {
const goBack = useCallback(() => { const goBack = useCallback(() => {
goNavReturn(navigate, location, { goNavReturn(navigate, location, {
path: planningHubPathFromReturnState(location.state?.planningReturn), path: PLANNING_HUB_PATH,
label: 'Zurück zur Planung', label: 'Zurück zur Planung',
}) })
}, [location, navigate]) }, [location, navigate])
@ -526,7 +530,7 @@ export default function TrainingUnitEditPage() {
[saving, editingUnit, handleSubmit, goBack] [saving, editingUnit, handleSubmit, goBack]
) )
const hubBackPath = planningHubPathFromReturnState(location.state?.planningReturn) const hubBackPath = PLANNING_HUB_PATH
const pageTitle = editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit' const pageTitle = editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'
const handleSaveAsTemplate = async (opts = {}) => { const handleSaveAsTemplate = async (opts = {}) => {
@ -659,8 +663,8 @@ export default function TrainingUnitEditPage() {
<PageFormEditorChrome <PageFormEditorChrome
testId="planning-unit-form" testId="planning-unit-form"
title={pageTitle} title={pageTitle}
backTo={hubBackPath} fallbackPath={hubBackPath}
backLabel="Trainingsplanung" fallbackLabel="Zurück zur Planung"
actionConfig={actionConfig} actionConfig={actionConfig}
> >
<TrainingUnitFormShell <TrainingUnitFormShell
@ -732,6 +736,7 @@ export default function TrainingUnitEditPage() {
onSuccess={() => setPublishFrameworkOpen(false)} onSuccess={() => setPublishFrameworkOpen(false)}
unitId={editingUnit?.id} unitId={editingUnit?.id}
planningModalClubId={planningClubId} planningModalClubId={planningClubId}
returnContext={moduleSaveReturnContext}
/> />
<SaveExercisesAsModuleModal <SaveExercisesAsModuleModal

View File

@ -3,10 +3,16 @@
* Phasen: Ganzgruppe vs. Split (planLoc); Druck mit optional getrennten Breakout-Seiten. * Phasen: Ganzgruppe vs. Split (planLoc); Druck mit optional getrennten Breakout-Seiten.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import PageReturnButton from '../components/PageReturnButton'
import NavStateLink from '../components/NavStateLink'
import CombinationPlanBracket from '../components/CombinationPlanBracket' import CombinationPlanBracket from '../components/CombinationPlanBracket'
import {
buildPlanningHubFallbackReturnContext,
buildTrainingRunReturnContext,
} from '../utils/navReturnContext'
import { import {
buildPlanRunViewModelFromSections, buildPlanRunViewModelFromSections,
itemStableKey, itemStableKey,
@ -34,8 +40,9 @@ function statusLabel(s) {
export default function TrainingUnitRunPage() { export default function TrainingUnitRunPage() {
const { unitId } = useParams() const { unitId } = useParams()
const navigate = useNavigate()
const idNum = unitId ? parseInt(unitId, 10) : NaN const idNum = unitId ? parseInt(unitId, 10) : NaN
const planningReturn = useMemo(() => buildPlanningHubFallbackReturnContext(), [])
const runReturn = useMemo(() => buildTrainingRunReturnContext(unitId), [unitId])
const [unit, setUnit] = useState(null) const [unit, setUnit] = useState(null)
const [loadError, setLoadError] = useState(null) const [loadError, setLoadError] = useState(null)
@ -337,12 +344,13 @@ export default function TrainingUnitRunPage() {
> >
Katalog (Popup) Katalog (Popup)
</button> </button>
<Link <NavStateLink
to={`/exercises/${it.exercise_id}`} to={`/exercises/${it.exercise_id}`}
returnContext={runReturn}
style={{ fontSize: '0.82rem', color: 'var(--accent)' }} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
> >
Vollständige Seite öffnen Vollständige Seite öffnen
</Link> </NavStateLink>
</div> </div>
)} )}
</span> </span>
@ -368,9 +376,11 @@ export default function TrainingUnitRunPage() {
return ( return (
<div className="card" style={{ margin: '1rem', padding: '1.5rem' }}> <div className="card" style={{ margin: '1rem', padding: '1.5rem' }}>
<p style={{ marginBottom: '1rem' }}>{loadError || 'Trainingseinheit nicht gefunden.'}</p> <p style={{ marginBottom: '1rem' }}>{loadError || 'Trainingseinheit nicht gefunden.'}</p>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}> <PageReturnButton
Zur Planung fallbackPath={planningReturn.path}
</button> fallbackLabel={planningReturn.label}
className="page-return-btn btn btn-secondary btn-small"
/>
</div> </div>
) )
} }
@ -382,6 +392,7 @@ export default function TrainingUnitRunPage() {
exerciseId={peekCtx?.exerciseId} exerciseId={peekCtx?.exerciseId}
variantId={peekCtx?.variantId ?? undefined} variantId={peekCtx?.variantId ?? undefined}
peekExtras={peekCtx?.peekExtras ?? undefined} peekExtras={peekCtx?.peekExtras ?? undefined}
returnContext={runReturn}
onClose={() => setPeekCtx(null)} onClose={() => setPeekCtx(null)}
/> />
@ -395,11 +406,14 @@ export default function TrainingUnitRunPage() {
alignItems: 'center', alignItems: 'center',
}} }}
> >
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}> <PageReturnButton
Zur Planung fallbackPath={planningReturn.path}
</button> fallbackLabel={planningReturn.label}
<Link className="page-return-btn btn btn-secondary btn-small"
/>
<NavStateLink
to={`/planning/run/${unitId}/coach`} to={`/planning/run/${unitId}/coach`}
returnContext={runReturn}
className="btn btn-primary" className="btn btn-primary"
style={{ style={{
textDecoration: 'none', textDecoration: 'none',
@ -409,7 +423,7 @@ export default function TrainingUnitRunPage() {
}} }}
> >
Im Training (Coach) Im Training (Coach)
</Link> </NavStateLink>
<button type="button" className="btn btn-secondary" onClick={() => window.print()}> <button type="button" className="btn btn-secondary" onClick={() => window.print()}>
Drucken / PDF Drucken / PDF
</button> </button>

View File

@ -3,12 +3,17 @@
*/ */
import { import {
buildPlanningHubReturnState, buildPlanningHubReturnState,
PLANNING_HUB_PATH,
planningHubPathFromReturnState, planningHubPathFromReturnState,
} from './planningUnitRoutes' } from './planningUnitRoutes'
export const NAV_RETURN_STATE_KEY = 'appReturn' export const NAV_RETURN_STATE_KEY = 'appReturn'
export const EXERCISES_LIST_PATH = '/exercises' export const EXERCISES_LIST_PATH = '/exercises'
export const TRAINING_MODULES_LIST_PATH = '/planning/training-modules' export const TRAINING_MODULES_LIST_PATH = '/planning/training-modules'
export const PLAN_TEMPLATES_LIST_PATH = '/planning/plan-templates'
export const FRAMEWORK_PROGRAMS_LIST_PATH = '/planning/framework-programs'
export const SETTINGS_PATH = '/settings'
export const DASHBOARD_PATH = '/'
export function buildNavReturnContext(opts) { export function buildNavReturnContext(opts) {
const path = String(opts?.path || '').trim() const path = String(opts?.path || '').trim()
@ -38,6 +43,74 @@ export function buildTrainingModulesListReturnContext() {
}) })
} }
export function buildPlanTemplatesListReturnContext() {
return buildNavReturnContext({
path: PLAN_TEMPLATES_LIST_PATH,
label: 'Zurück zu Vorlagen',
kind: 'planTemplatesList',
})
}
export function buildFrameworkProgramsListReturnContext() {
return buildNavReturnContext({
path: FRAMEWORK_PROGRAMS_LIST_PATH,
label: 'Zurück zu Rahmenprogrammen',
kind: 'frameworkProgramsList',
})
}
export function buildSettingsReturnContext() {
return buildNavReturnContext({
path: SETTINGS_PATH,
label: 'Zurück zu Einstellungen',
kind: 'settings',
})
}
export function buildDashboardReturnContext() {
return buildNavReturnContext({
path: DASHBOARD_PATH,
label: 'Zurück zur Übersicht',
kind: 'dashboard',
})
}
export const MEDIA_LIBRARY_PATH = '/media'
export function buildMediaLibraryReturnContext() {
return buildNavReturnContext({
path: MEDIA_LIBRARY_PATH,
label: 'Zurück zur Medienbibliothek',
kind: 'mediaLibrary',
})
}
export function buildTrainingRunReturnContext(unitId) {
const id = String(unitId || '').trim()
if (!id) return null
return buildNavReturnContext({
path: `/planning/run/${id}`,
label: 'Zurück zum Trainingsablauf',
kind: 'trainingRun',
payload: { unitId: id },
})
}
export function buildPlanningHubFallbackReturnContext() {
return buildNavReturnContext({
path: PLANNING_HUB_PATH,
label: 'Zurück zur Planung',
kind: 'planningHub',
})
}
/** Router-Link state mit appReturn (optional zusätzlicher State). */
export function linkStateWithAppReturn(returnContext, extraState) {
const state = { ...(extraState || {}) }
if (returnContext) state[NAV_RETURN_STATE_KEY] = returnContext
return Object.keys(state).length > 0 ? state : undefined
}
/** /**
* Rücksprung zur aktuellen Route (z. B. Einheiten-Editor). * Rücksprung zur aktuellen Route (z. B. Einheiten-Editor).
*/ */

View File

@ -2,8 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
import { import {
appReturnFromPlanningReturn, appReturnFromPlanningReturn,
buildExercisesListReturnContext, buildExercisesListReturnContext,
buildMediaLibraryReturnContext,
buildNavReturnContext, buildNavReturnContext,
buildPlanningHubReturnContext, buildPlanningHubReturnContext,
buildTrainingRunReturnContext,
goNavReturn, goNavReturn,
readNavReturnFromLocation, readNavReturnFromLocation,
resolveNavReturnTarget, resolveNavReturnTarget,
@ -82,4 +84,17 @@ describe('navReturnContext', () => {
expect(ctx?.path).toContain('group=7') expect(ctx?.path).toContain('group=7')
expect(ctx?.kind).toBe('planningHub') expect(ctx?.kind).toBe('planningHub')
}) })
it('buildTrainingRunReturnContext', () => {
const ctx = buildTrainingRunReturnContext(42)
expect(ctx?.path).toBe('/planning/run/42')
expect(ctx?.kind).toBe('trainingRun')
expect(buildTrainingRunReturnContext('')).toBeNull()
})
it('buildMediaLibraryReturnContext', () => {
const ctx = buildMediaLibraryReturnContext()
expect(ctx?.path).toBe('/media')
expect(ctx?.kind).toBe('mediaLibrary')
})
}) })