Refactor navigation components and enhance return context handling
Some checks failed
Deploy Development / deploy (push) Failing after 24s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 7s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s

- Replaced `PageReturnLink` with `PageReturnButton` for consistent back navigation across various pages.
- Updated multiple components, including `ExercisePeekModal`, `PageFormEditorChrome`, and `ExerciseDetailPage`, to utilize the new return context features.
- Enhanced CSS styles for the new return button to improve visual consistency.
- Improved navigation logic in `TrainingFrameworkProgramEditPage` and `TrainingModuleEditPage` to ensure seamless user experience when navigating back to previous locations.
This commit is contained in:
Lars 2026-05-20 07:42:46 +02:00
parent 6e6270b717
commit 4588ef4c7e
28 changed files with 497 additions and 190 deletions

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 1–2 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')
})
}) })