UX Verbesserung, Navigationsspeicherung #40

Merged
Lars merged 6 commits from develop into main 2026-05-20 10:44:00 +02:00
32 changed files with 1683 additions and 214 deletions

View File

@ -0,0 +1,144 @@
# Navigation — Return-Kontext (Rücksprung)
**Stand:** 2026-05-20
**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.
---
## Problem
Viele Flows navigieren von Kontext A zu Editor/Detail B (z.B. Übungsliste → Modulbearbeitung). Die Zielseite kennt A nicht und bietet nur einen **fest verdrahteten** Zurück-Link (z.B. immer „Modul-Bibliothek“). In der installierten PWA fehlt zusätzlich die Browser-Chrome.
Betroffen u.a.:
- Übungsliste → Modul anlegen/bearbeiten
- Planung → Einheiten-Editor (teilweise gelöst via `planningReturn`)
- Modals mit Speichern + Redirect auf Vollseite
---
## Strategie (Hybrid)
| Mechanismus | Wann |
|-------------|------|
| **Expliziter Return-Kontext** (`appReturn` in Router-State) | Seitenwechsel, bei denen das Ziel einen fachlichen Rücksprung anbieten soll |
| **History-Back** (`navigate(-1)`) | Fallback, wenn kein Kontext gesetzt ist und History-Eintrag existiert |
| **Default-Pfad** | Fallback der Zielseite (z.B. Modul-Bibliothek) |
| **Modal schließen** | Overlays/Peek — kein Routing-Return |
**Nicht** als alleinige Lösung: reines Browser-Back (History durch `replace`, Deep Links, Reload unzuverlässig).
---
## Datenmodell
Router-State-Schlüssel: **`appReturn`**
```javascript
{
v: 1, // Schema-Version
path: '/exercises', // Ziel-URL (inkl. Query, falls nötig)
label: 'Zurück zur Übungsliste', // Anzeige im UI (vollständiger Satz)
kind: 'exerciseList', // optional: Typ für erweiterte Wiederherstellung
payload: { ... } // optional: kind-spezifische Daten
}
```
### `kind`-Werte (erweiterbar)
| kind | payload | path-Ableitung |
|------|---------|----------------|
| `exerciseList` | — | `/exercises` (Filter/Auswahl via sessionStorage) |
| `planningHub` | `buildPlanningHubReturnState(...)` | `planningHubPathFromReturnState(payload)` |
| `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 |
### Legacy-Kompatibilität
Bestehendes Feld **`planningReturn`** (Planung ↔ Einheiten-Editor) wird beim Lesen in `appReturn` **bridged** — keine Big-Bang-Migration nötig.
---
## API (Frontend)
Zentrale Datei: `frontend/src/utils/navReturnContext.js`
| Funktion | Zweck |
|----------|--------|
| `buildNavReturnContext({ path, label, kind?, payload? })` | Kontext-Objekt erzeugen |
| `buildExercisesListReturnContext()` | Standard-Rückkehr Übungsliste |
| `buildPlanningHubReturnContext(hubState)` | Planungs-Hub inkl. Filter-Query |
| `buildTrainingModulesListReturnContext()` | Modul-Bibliothek |
| `readNavReturnFromLocation(location)` | Kontext aus `location.state` (+ Legacy) |
| `resolveNavReturnTarget(location, fallback)` | `{ path, label }` für UI |
| `goNavReturn(navigate, location, fallback?)` | Programmatischer Rücksprung (priorisiert: Kontext → History → Fallback) |
| `navigateWithAppReturn(navigate, to, returnContext, options?)` | Navigation mit gesetztem `appReturn` |
| `preserveAppReturnOnNavigate(navigate, location, to, options?)` | Weiterleiten, bestehenden Kontext behalten (z.B. nach `replace`) |
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 **`FormActionBar`** (`placement="bottom"`) oder **`PageFormEditorChrome`**:
- **Kein** separater Zurück-Link/Button oben (wirkt in der App redundant)
- **Abbrechen**`goBack()` / `goNavReturn(...)` (Einsprungspunkt)
- **Speichern & Schließen** → nach erfolgreichem Save ebenfalls `goBack()`
- Sticky Action Bar unten nutzen
**PageReturnButton** nur auf **Leseseiten** ohne Editor-Leiste (z.B. Übungsdetail, Einstellungen-Unterseiten, Trainingsablauf).
---
## Regeln für Entwickler
1. **Jede Navigation** von Kontext A zu Editor B, wo der Nutzer „weitermachen“ soll, setzt `appReturn` (oder nutzt `navigateWithAppReturn`).
2. **Zielseite** zeigt `PageReturnButton` mit sinnvollem **Default-Fallback** (Bibliothek/Hub).
3. **Nach Create + `replace: true`:** Return-Kontext mit `preserveAppReturnOnNavigate` erhalten.
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).
6. **UI-State** (Filter, Auswahl): weiter über bestehende Session-Mechanismen (z.B. `exerciseListSessionState`), nicht im Return-Payload duplizieren, außer kind erfordert Query-Reconstruction (Planung).
---
## Umsetzungsstand
### Phase 1 (Pilot)
- [x] Spec + Utility + Tests
- [x] `PageReturnButton` (ersetzt Link-Variante)
- [x] Übungsliste → Modul speichern → Modul-Editor
- [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter
- [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge)
### Phase 2 (Flows verbinden)
- [x] Listen → Editoren: Übungen, Module, Vorlagen, Rahmenprogramme
- [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
---
## Referenzen
- Bestehend: `frontend/src/utils/planningUnitRoutes.js` (`planningReturn`)
- Session Übungsliste: `frontend/src/utils/exerciseListSessionState.js`
- PWA-Kontext: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`, App-Shell in `App.jsx`

View File

@ -1459,6 +1459,36 @@ html.modal-scroll-locked .app-main {
.page-form-editor__intro { .page-form-editor__intro {
min-width: 0; min-width: 0;
} }
.page-return-link {
display: inline-block;
margin-bottom: 0.75rem;
color: var(--accent-dark);
font-weight: 600;
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 {
text-decoration: underline;
}
@media (prefers-color-scheme: dark) {
.page-return-link {
color: var(--accent);
}
}
.page-form-editor__back { .page-form-editor__back {
display: inline-block; display: inline-block;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
@ -2949,15 +2979,66 @@ html.modal-scroll-locked .app-main {
flex-shrink: 0; flex-shrink: 0;
accent-color: var(--accent); accent-color: var(--accent);
} }
.exercise-card--selection-pinned {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent);
}
.exercise-card__selection-badge {
display: inline-flex;
align-items: center;
margin-left: 8px;
padding: 2px 7px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--accent-dark);
background: var(--accent-soft);
vertical-align: middle;
}
.exercises-selection-section {
margin-bottom: 1rem;
}
.exercises-selection-section__head {
margin-bottom: 0.65rem;
}
.exercises-selection-section__title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--text1);
}
.exercises-selection-section__hint {
margin: 4px 0 0;
font-size: 0.85rem;
color: var(--text3);
line-height: 1.45;
}
.exercises-list-grid--selection {
margin-bottom: 0.25rem;
}
.exercise-card-body-flex { .exercise-card-body-flex {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.exercise-card__body--clickable {
cursor: pointer;
}
.exercise-card__body--clickable:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 8px;
}
.exercise-card-title { .exercise-card-title {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 1.05rem; font-size: 1.05rem;
line-height: 1.3; line-height: 1.3;
font-weight: 700; font-weight: 700;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
} }
.exercise-card-title a { .exercise-card-title a {
color: inherit; color: inherit;

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,31 @@
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: optional Zurück oben; FormActionBar fix unten (FormEditorBottomSlot).
* Mit actionConfig ist showReturn standardmäßig aus Rücksprung über Abbrechen / Speichern & Schließen.
*/ */
export default function PageFormEditorChrome({ export default function PageFormEditorChrome({
title, title,
backTo, fallbackPath,
backLabel = 'Zurück', fallbackLabel,
actionConfig, actionConfig,
children, children,
testId, testId,
showReturn = false,
}) { }) {
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,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

@ -5,6 +5,7 @@ export default function ExerciseListBulkToolbar({
bulkMaxIds, bulkMaxIds,
onClearSelection, onClearSelection,
onOpenBulkModal, onOpenBulkModal,
onOpenSaveModuleModal,
}) { }) {
if (selectedCount < 1) return null if (selectedCount < 1) return null
@ -14,6 +15,9 @@ export default function ExerciseListBulkToolbar({
<button type="button" className="btn btn-secondary btn-small" onClick={onClearSelection}> <button type="button" className="btn btn-secondary btn-small" onClick={onClearSelection}>
Auswahl aufheben Auswahl aufheben
</button> </button>
<button type="button" className="btn btn-secondary btn-small" onClick={onOpenSaveModuleModal}>
Als Modul speichern
</button>
<button type="button" className="btn btn-primary btn-small" onClick={onOpenBulkModal}> <button type="button" className="btn btn-primary btn-small" onClick={onOpenBulkModal}>
Massenänderung Massenänderung
</button> </button>

View File

@ -1,5 +1,10 @@
import React from 'react' import React, { useMemo } from 'react'
import { Link } 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,
@ -131,23 +136,75 @@ function ExerciseCardScopeStatus({ exercise }) {
/** /**
* Kartenzeile in der Übungsliste (Fokus/Planung keine Virtualisierung im Grid, dafür content-visibility in app.css). * Kartenzeile in der Übungsliste (Fokus/Planung keine Virtualisierung im Grid, dafür content-visibility in app.css).
*/ */
export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete }) { export default function ExerciseListCard({
exercise,
user,
selectedIds,
toggleSelect,
onDelete,
onPeek,
selectionPinned = false,
}) {
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 openExercisePage = () =>
navigateWithAppReturn(navigate, `/exercises/${exercise.id}`, listReturn)
const handleBodyClick = (e) => {
if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return
openExercisePage()
}
const handleBodyKeyDown = (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return
e.preventDefault()
openExercisePage()
}
return ( return (
<div className={exerciseCardClassName(exercise, user?.id)}> <div
className={[
exerciseCardClassName(exercise, user?.id),
selectionPinned ? 'exercise-card--selection-pinned' : '',
]
.filter(Boolean)
.join(' ')}
>
<div className="exercise-card-layout exercise-card-layout--grow"> <div className="exercise-card-layout exercise-card-layout--grow">
<input <input
type="checkbox" type="checkbox"
checked={selectedIds.has(Number(exercise.id))} checked={selectedIds.has(Number(exercise.id))}
onChange={() => toggleSelect(exercise.id)} onChange={() => toggleSelect(exercise)}
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} aria-label={`${titleText}“ auswählen`}
className="exercise-card-layout__check" className="exercise-card-layout__check"
/> />
<div className="exercise-card__body exercise-card-body-flex"> <div
className="exercise-card__body exercise-card-body-flex exercise-card__body--clickable"
role="link"
tabIndex={0}
onClick={handleBodyClick}
onKeyDown={handleBodyKeyDown}
aria-label={`${titleText}“ öffnen`}
>
<h3 className="exercise-card-title"> <h3 className="exercise-card-title">
<Link to={`/exercises/${exercise.id}`}>{exercise.title}</Link> <NavStateLink
to={`/exercises/${exercise.id}`}
returnContext={listReturn}
onClick={(e) => e.stopPropagation()}
>
{exercise.title}
</NavStateLink>
{selectionPinned ? (
<span className="exercise-card__selection-badge" title="In Modul-Auswahl">
Auswahl
</span>
) : null}
</h3> </h3>
<div className="exercise-card-tags"> <div className="exercise-card-tags">
{focusNames.map((name) => ( {focusNames.map((name) => (
@ -191,22 +248,24 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
<ExerciseCardContentStats exercise={exercise} /> <ExerciseCardContentStats exercise={exercise} />
</div> </div>
<div className="exercise-card__actions exercise-card__actions--icons"> <div className="exercise-card__actions exercise-card__actions--icons">
<Link <button
to={`/exercises/${exercise.id}`} type="button"
className="exercise-card__icon-btn" className="exercise-card__icon-btn"
title="Ansehen" title="Vorschau"
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`} aria-label={`${titleText}“ in Vorschau anzeigen`}
onClick={() => onPeek?.(exercise)}
> >
<Eye size={18} strokeWidth={2} aria-hidden /> <Eye size={18} strokeWidth={2} aria-hidden />
</Link> </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'
@ -9,8 +8,18 @@ import ExerciseListFilterModal from './ExerciseListFilterModal'
import ExerciseListBulkModal from './ExerciseListBulkModal' import ExerciseListBulkModal from './ExerciseListBulkModal'
import ExerciseListSearchBar from './ExerciseListSearchBar' import ExerciseListSearchBar from './ExerciseListSearchBar'
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar' import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
import ExercisePeekModal from '../ExercisePeekModal'
import NavStateLink from '../NavStateLink'
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'
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
import {
mergeSelectedWithListEntries,
normalizeSelectedEntries,
snapshotExerciseForSelection,
} from '../../utils/exerciseListSelection'
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery' import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
import { import {
INITIAL_EXERCISE_LIST_FILTERS, INITIAL_EXERCISE_LIST_FILTERS,
@ -51,7 +60,7 @@ function ExercisesListPageRoot() {
const [pageTab, setPageTab] = useState('list') const [pageTab, setPageTab] = useState('list')
const prefsAppliedRef = useRef(false) const prefsAppliedRef = useRef(false)
const [selectedIds, setSelectedIds] = useState(() => new Set()) const [selectedEntries, setSelectedEntries] = useState(() => [])
const [bulkModalOpen, setBulkModalOpen] = useState(false) const [bulkModalOpen, setBulkModalOpen] = useState(false)
const [bulkVisibility, setBulkVisibility] = useState('') const [bulkVisibility, setBulkVisibility] = useState('')
const [bulkStatus, setBulkStatus] = useState('') const [bulkStatus, setBulkStatus] = useState('')
@ -66,12 +75,22 @@ function ExercisesListPageRoot() {
const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([]) const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false) const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([]) const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
const [peekExercise, setPeekExercise] = useState(null)
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
useEffect(() => { useEffect(() => {
if (!user?.id) return if (!user?.id) return
if (prefsAppliedRef.current) return if (prefsAppliedRef.current) return
const session = readExerciseListSessionState()
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs) const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
setFilters(applyDashboardExerciseListUrl(merged)) const filtersFromSession = session?.filters
setFilters(applyDashboardExerciseListUrl(filtersFromSession ?? merged))
if (session) {
setSearchInput(session.searchInput || '')
setAiSearchInput(session.aiSearchInput || '')
setMineOnly(session.mineOnly)
setSelectedEntries(normalizeSelectedEntries(session.selectedEntries))
}
try { try {
const sp = new URLSearchParams(window.location.search) const sp = new URLSearchParams(window.location.search)
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true) if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
@ -81,6 +100,17 @@ function ExercisesListPageRoot() {
prefsAppliedRef.current = true prefsAppliedRef.current = true
}, [user?.id, user?.exercise_list_prefs]) }, [user?.id, user?.exercise_list_prefs])
useEffect(() => {
if (!prefsAppliedRef.current) return
writeExerciseListSessionState({
filters,
searchInput,
aiSearchInput,
mineOnly,
selectedEntries,
})
}, [filters, searchInput, aiSearchInput, mineOnly, selectedEntries])
useEffect(() => { useEffect(() => {
if (!user?.id) prefsAppliedRef.current = false if (!user?.id) prefsAppliedRef.current = false
}, [user?.id]) }, [user?.id])
@ -120,9 +150,20 @@ function ExercisesListPageRoot() {
loadMore, loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
useEffect(() => { const selectedIds = useMemo(
setSelectedIds(new Set()) () => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
}, [queryBase]) [selectedEntries]
)
const selectedExercisesDisplay = useMemo(
() => mergeSelectedWithListEntries(selectedEntries, exercises),
[selectedEntries, exercises]
)
const filterResultExercises = useMemo(
() => exercises.filter((e) => !selectedIds.has(Number(e.id))),
[exercises, selectedIds]
)
const focusOptions = useMemo( const focusOptions = useMemo(
() => () =>
@ -215,38 +256,49 @@ function ExercisesListPageRoot() {
? Number(user.active_club_id) ? Number(user.active_club_id)
: null : null
const toggleSelect = useCallback((id) => { const toggleSelect = useCallback((exercise) => {
setSelectedIds((prev) => { const snap = snapshotExerciseForSelection(exercise)
const n = new Set(prev) if (!snap) return
const nid = Number(id) setSelectedEntries((prev) => {
if (Number.isNaN(nid)) return prev const idx = prev.findIndex((e) => Number(e.id) === snap.id)
if (n.has(nid)) n.delete(nid) if (idx >= 0) return prev.filter((_, i) => i !== idx)
else n.add(nid) return [...prev, snap]
return n
}) })
}, []) }, [])
const clearSelection = useCallback(() => setSelectedIds(new Set()), []) const clearSelection = useCallback(() => setSelectedEntries([]), [])
const toggleSelectAllPage = useCallback(() => { const toggleSelectAllPage = useCallback(() => {
setSelectedIds((prev) => { setSelectedEntries((prev) => {
const n = new Set(prev) const ids = new Set(prev.map((e) => Number(e.id)))
const allSel = const pageIds = filterResultExercises.map((e) => Number(e.id))
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id))) const allSel = pageIds.length > 0 && pageIds.every((id) => ids.has(id))
if (allSel) { if (allSel) {
exercises.forEach((e) => n.delete(Number(e.id))) const remove = new Set(pageIds)
} else { return prev.filter((e) => !remove.has(Number(e.id)))
exercises.forEach((e) => n.add(Number(e.id)))
} }
return n const next = [...prev]
for (const ex of filterResultExercises) {
const snap = snapshotExerciseForSelection(ex)
if (!snap || ids.has(snap.id)) continue
ids.add(snap.id)
next.push(snap)
}
return next
}) })
}, [exercises]) }, [filterResultExercises])
const allOnPageSelected = useMemo( const allOnPageSelected = useMemo(
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))), () =>
[exercises, selectedIds] filterResultExercises.length > 0 &&
filterResultExercises.every((e) => selectedIds.has(Number(e.id))),
[filterResultExercises, selectedIds]
) )
const selectedExercisesInListOrder = selectedExercisesDisplay
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
const bulkVisibilityOptions = useMemo(() => { const bulkVisibilityOptions = useMemo(() => {
const base = [ const base = [
{ id: '', label: '— nicht ändern —' }, { id: '', label: '— nicht ändern —' },
@ -262,6 +314,7 @@ function ExercisesListPageRoot() {
try { try {
await api.deleteExercise(exercise.id) await api.deleteExercise(exercise.id)
setExercises((prev) => prev.filter((e) => e.id !== exercise.id)) setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
setSelectedEntries((prev) => prev.filter((e) => Number(e.id) !== Number(exercise.id)))
} catch (err) { } catch (err) {
alert('Fehler beim Löschen: ' + err.message) alert('Fehler beim Löschen: ' + err.message)
} }
@ -428,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" />
)} )}
@ -480,6 +537,7 @@ function ExercisesListPageRoot() {
bulkMaxIds={BULK_MAX_IDS} bulkMaxIds={BULK_MAX_IDS}
onClearSelection={clearSelection} onClearSelection={clearSelection}
onOpenBulkModal={openBulkModal} onOpenBulkModal={openBulkModal}
onOpenSaveModuleModal={() => setSaveModuleModalOpen(true)}
/> />
<ExerciseListFilterModal <ExerciseListFilterModal
@ -540,44 +598,96 @@ function ExercisesListPageRoot() {
setBulkTargetGroupIds={setBulkTargetGroupIds} setBulkTargetGroupIds={setBulkTargetGroupIds}
/> />
{listFetching && exercises.length === 0 ? ( <SaveSelectedExercisesAsModuleModal
open={saveModuleModalOpen}
onClose={() => setSaveModuleModalOpen(false)}
selectedExercises={selectedExercisesInListOrder}
returnContext={exercisesModuleReturnContext}
onSuccess={clearSelection}
/>
<ExercisePeekModal
open={peekExercise != null}
exerciseId={peekExercise?.id}
titleFallback={peekExercise?.title}
onClose={() => setPeekExercise(null)}
/>
{listFetching && exercises.length === 0 && selectedEntries.length === 0 ? (
<div className="card empty-state" style={{ padding: '2rem 1rem' }}> <div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" /> <div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}> <p className="muted" style={{ marginTop: '12px' }}>
Lade Übungen Lade Übungen
</p> </p>
</div> </div>
) : exercises.length === 0 ? ( ) : exercises.length === 0 && selectedEntries.length === 0 ? (
<div className="card"> <div className="card">
<p className="exercises-empty-text">Keine Übungen gefunden.</p> <p className="exercises-empty-text">Keine Übungen gefunden.</p>
</div> </div>
) : ( ) : (
<> <>
{listFetching ? ( {selectedEntries.length > 0 ? (
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p> <section className="exercises-selection-section" data-testid="exercises-selection-section">
<div className="exercises-selection-section__head">
<h2 className="exercises-selection-section__title">Auswahl ({selectedEntries.length})</h2>
<p className="exercises-selection-section__hint">
Bleibt sichtbar, auch wenn du den Filter wechselst ideal für die Modul-Zusammenstellung.
</p>
</div>
<div className="exercises-list-grid exercises-list-grid--selection">
{selectedExercisesDisplay.map((exercise) => (
<ExerciseListCard
key={`sel-${exercise.id}`}
exercise={exercise}
user={user}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
onDelete={handleDelete}
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
selectionPinned
/>
))}
</div>
</section>
) : null} ) : null}
<p className="exercises-meta-line">
{exercises.length} angezeigt {filterResultExercises.length === 0 ? (
{hasMore ? ' · es gibt weitere Einträge' : ''} selectedEntries.length > 0 ? (
</p> <p className="exercises-meta-line exercises-meta-line--muted">
<div className="exercises-list-grid" data-testid="exercises-list-grid"> Keine weiteren Treffer für den aktuellen Filter.
{exercises.map((exercise) => ( </p>
<ExerciseListCard ) : null
key={exercise.id} ) : (
exercise={exercise} <>
user={user} {listFetching ? (
selectedIds={selectedIds} <p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p>
toggleSelect={toggleSelect} ) : null}
onDelete={handleDelete} <p className="exercises-meta-line">
/> {filterResultExercises.length} Treffer
))} {selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
</div> {hasMore ? ' · es gibt weitere Einträge' : ''}
{hasMore && ( </p>
<div className="exercises-load-more"> <div className="exercises-list-grid" data-testid="exercises-list-grid">
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}> {filterResultExercises.map((exercise) => (
{loadingMore ? 'Laden…' : 'Mehr laden'} <ExerciseListCard
</button> key={exercise.id}
</div> exercise={exercise}
user={user}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
onDelete={handleDelete}
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
/>
))}
</div>
{hasMore && (
<div className="exercises-load-more">
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>
</div>
)}
</>
)} )}
</> </>
)} )}

View File

@ -0,0 +1,408 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import FormActionBar from '../FormActionBar'
import FormModalOverlay from '../FormModalOverlay'
import api from '../../utils/api'
import { useToast } from '../../context/ToastContext'
import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub'
import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm'
import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection'
import { navigateWithAppReturn } from '../../utils/navReturnContext'
/**
* Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge).
* Optional: Positionen an ein bestehendes Modul anfügen.
*/
export default function SaveSelectedExercisesAsModuleModal({
open,
onClose,
/** @type {Array<{ id: number, title?: string }>} */
selectedExercises,
/** Return-Kontext für Modul-Editor (z. B. Übungsliste) */
returnContext,
onSuccess,
}) {
const navigate = useNavigate()
const toast = useToast()
const { user } = useAuth()
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const roleLc = String(user?.role || '').toLowerCase()
const isSuperadmin = roleLc === 'superadmin'
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [loadErr, setLoadErr] = useState('')
/** @type {[Array<object>, Function]} */
const [rows, setRows] = useState([])
const [moduleOptions, setModuleOptions] = useState([])
const [modulesLoading, setModulesLoading] = useState(false)
const [targetMode, setTargetMode] = useState('new')
const [existingModuleId, setExistingModuleId] = useState('')
const [title, setTitle] = useState('')
const [visibility, setVisibility] = useState('club')
const [clubId, setClubId] = useState('')
const resetLocal = useCallback(() => {
setLoadErr('')
setRows([])
setModuleOptions([])
setTargetMode('new')
setExistingModuleId('')
setTitle('')
setVisibility('club')
setClubId('')
}, [])
useEffect(() => {
if (!open) {
resetLocal()
return
}
const defaultClub = getDefaultClubIdForGovernanceForms(user)
if (defaultClub != null) setClubId(String(defaultClub))
else if (memberClubs.length === 1) setClubId(String(memberClubs[0].id))
const count = selectedExercises?.length || 0
setTitle(count > 0 ? `Modul · ${count} Übung${count === 1 ? '' : 'en'}` : '')
if (!count) {
setRows([])
return
}
let cancelled = false
setLoading(true)
setLoadErr('')
;(async () => {
try {
const hydrated = []
for (const ex of selectedExercises) {
const row = await hydrateExercisePlanningRow({ id: ex.id, title: ex.title })
if (row) hydrated.push(row)
}
if (cancelled) return
if (!hydrated.length) {
setLoadErr('Ausgewählte Übungen konnten nicht geladen werden.')
setRows([])
return
}
setRows(hydrated)
} catch (e) {
if (!cancelled) {
setLoadErr(e.message || 'Übungen konnten nicht geladen werden')
setRows([])
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [open, selectedExercises, user, memberClubs.length, resetLocal])
useEffect(() => {
if (!open) return
let cancelled = false
setModulesLoading(true)
api
.listTrainingModules()
.then((list) => {
if (cancelled) return
setModuleOptions(Array.isArray(list) ? list : [])
})
.catch(() => {
if (!cancelled) setModuleOptions([])
})
.finally(() => {
if (!cancelled) setModulesLoading(false)
})
return () => {
cancelled = true
}
}, [open])
const updateRow = (idx, patch) => {
setRows((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
}
const handleSubmit = async (e) => {
e.preventDefault()
if (submitting || !rows.length) return
const newItemsPayload = buildRowsPayload(rows)
if (!newItemsPayload.length) {
toast.error('Keine gültigen Übungspositionen.')
return
}
setSubmitting(true)
try {
if (targetMode === 'append') {
const mid = parseInt(existingModuleId, 10)
if (!Number.isFinite(mid) || mid < 1) {
toast.error('Bitte ein bestehendes Modul wählen.')
return
}
const existing = await api.getTrainingModule(mid)
const existingItems = Array.isArray(existing?.items) ? existing.items : []
const merged = [
...existingItems.map((row, idx) => moduleItemToPayload(row, idx)).filter(Boolean),
...newItemsPayload.map((row, idx) => ({ ...row, order_index: existingItems.length + idx })),
]
await api.updateTrainingModule(mid, { items: merged })
toast.success(`${newItemsPayload.length} Übung(en) an Modul angefügt.`)
navigateWithAppReturn(navigate, `/planning/training-modules/${mid}`, returnContext)
onSuccess?.()
onClose()
return
}
const tit = (title || '').trim()
if (!tit) {
toast.error('Bitte einen Modultitel angeben.')
return
}
let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
const fallback = getDefaultClubIdForGovernanceForms(user)
if (Number.isFinite(fallback) && fallback > 0) cid = fallback
}
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
return
}
if (visibility !== 'club') cid = null
const created = await api.createTrainingModule({
title: tit,
visibility,
club_id: cid,
items: newItemsPayload,
})
toast.success('Trainingsmodul gespeichert.')
if (created?.id) {
navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext)
}
onSuccess?.()
onClose()
} catch (err) {
toast.error(err.message || 'Speichern fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
if (!open) return null
const saveLabel = targetMode === 'append' ? 'An Modul anfügen' : 'Modul anlegen'
return (
<FormModalOverlay open={open} raised onBackdropClick={onClose}>
<div className="card modal-panel--form modal-panel--narrow">
<h2 className="modal-panel__title">Auswahl als Trainingsmodul</h2>
<p className="modal-panel__intro">
Die gewählten Übungen werden in der <strong>Reihenfolge der Auswahl</strong> übernommen. Pro Übung kann
optional eine Variante gesetzt werden.
</p>
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : loadErr ? (
<p style={{ color: 'var(--danger)' }}>{loadErr}</p>
) : rows.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>Keine Übungen ausgewählt.</p>
) : (
<form id="save-selected-module-form" className="modal-form-shell" onSubmit={handleSubmit}>
<div className="modal-form-shell__body">
<div className="form-row" style={{ marginBottom: '1rem' }}>
<label className="form-label">Ziel</label>
<select
className="form-input"
value={targetMode}
onChange={(e) => setTargetMode(e.target.value)}
>
<option value="new">Neues Modul anlegen</option>
<option value="append">Bestehendes Modul erweitern</option>
</select>
</div>
{targetMode === 'append' ? (
<div className="form-row" style={{ marginBottom: '1rem' }}>
<label className="form-label">Bestehendes Modul</label>
{modulesLoading ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>Module laden </p>
) : moduleOptions.length === 0 ? (
<p style={{ color: 'var(--text2)', margin: 0 }}>
Keine bearbeitbaren Module gefunden. Lege zuerst ein neues Modul an.
</p>
) : (
<select
className="form-input"
value={existingModuleId}
onChange={(e) => setExistingModuleId(e.target.value)}
required
>
<option value=""> Modul wählen </option>
{moduleOptions.map((m) => (
<option key={m.id} value={String(m.id)}>
{(m.title || '').trim() || `Modul #${m.id}`}
{m.items_count != null ? ` (${m.items_count} Pos.)` : ''}
</option>
))}
</select>
)}
<p style={{ margin: '6px 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Die neuen Übungen werden <strong>ans Ende</strong> des gewählten Moduls angefügt.
</p>
</div>
) : (
<>
<div style={{ marginBottom: '1rem' }}>
<label className="form-label">Modultitel</label>
<input
className="form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
placeholder="z. B. Technikblock Grundlagen"
/>
</div>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={visibility}
onChange={(e) => {
const v = e.target.value
setVisibility(v)
if (v === 'club' && !clubId) {
const fallback = getDefaultClubIdForGovernanceForms(user)
if (fallback != null) setClubId(String(fallback))
}
}}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
{visibility === 'club' ? (
<div className="form-row" style={{ marginBottom: '1rem' }}>
<label className="form-label">Verein</label>
<select className="form-input" value={clubId} onChange={(e) => setClubId(e.target.value)}>
<option value=""> Verein wählen </option>
{memberClubs.map((cl) => (
<option key={cl.id} value={String(cl.id)}>
{cl.name || `Verein #${cl.id}`}
</option>
))}
</select>
</div>
) : null}
</>
)}
<div
className="card"
style={{
marginBottom: '1rem',
padding: '10px 12px',
maxHeight: 'min(320px, 45vh)',
overflowY: 'auto',
background: 'var(--surface2)',
}}
>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{rows.map((row, idx) => {
const isCombo = row.exercise_kind === 'combination'
const variants = Array.isArray(row.variants) ? row.variants : []
return (
<li
key={`${row.exercise_id}-${idx}`}
style={{
padding: '10px 0',
borderTop: idx === 0 ? 'none' : '1px solid var(--border)',
}}
>
<div style={{ fontWeight: 600, color: 'var(--text1)', fontSize: '0.92rem', marginBottom: 8 }}>
{idx + 1}. {(row.exercise_title || '').trim() || `Übung #${row.exercise_id}`}
{isCombo ? (
<span
style={{
marginLeft: 8,
fontSize: '0.75rem',
fontWeight: 600,
color: 'var(--accent-dark)',
}}
>
Kombination
</span>
) : null}
</div>
{!isCombo && variants.length > 0 ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Variante
</label>
<select
className="form-input"
value={String(row.exercise_variant_id ?? '')}
onChange={(e) => updateRow(idx, { exercise_variant_id: e.target.value })}
>
<option value="">Standard (keine Variante)</option>
{variants.map((v) => (
<option key={v.id} value={String(v.id)}>
{(v.variant_name || v.name || '').trim() || `Variante #${v.id}`}
</option>
))}
</select>
</div>
) : !isCombo ? (
<div style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>Keine Varianten hinterlegt</div>
) : null}
</li>
)
})}
</ul>
</div>
</div>
<FormActionBar
placement="bottom"
variant="modal"
formId="save-selected-module-form"
saving={submitting}
showSave={false}
saveAndCloseLabel={saveLabel}
saveAndCloseShortLabel={targetMode === 'append' ? 'Anfügen' : 'Anlegen'}
onCancel={onClose}
/>
</form>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Abbrechen
</button>
</div>
) : null}
{!loading && (loadErr || rows.length === 0) ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
) : null}
</div>
</FormModalOverlay>
)
}

View File

@ -7,6 +7,7 @@ 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 { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit' import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit'
import { navigateWithAppReturn } from '../../utils/navReturnContext'
/** /**
* Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl). * Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl).
@ -16,6 +17,7 @@ export default function SaveExercisesAsModuleModal({
onClose, onClose,
unitId, unitId,
planningModalClubId, planningModalClubId,
returnContext,
onSuccess, onSuccess,
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
@ -134,7 +136,7 @@ export default function SaveExercisesAsModuleModal({
}) })
toast.success('Trainingsmodul gespeichert.') toast.success('Trainingsmodul gespeichert.')
if (created?.id) { if (created?.id) {
navigate(`/planning/training-modules/${created.id}`) navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext)
} }
onSuccess?.() onSuccess?.()
onClose() onClose()

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

@ -7,6 +7,12 @@ 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 { 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 +218,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 +336,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 +350,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 +458,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 +476,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 +505,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,12 +856,6 @@ 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' }}>
<Link to="/planning/framework-programs" style={{ color: 'var(--accent-dark)' }}>
Alle Rahmenprogramme
</Link>
</p>
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1> <h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
<details className="framework-edit-intro"> <details className="framework-edit-intro">
@ -1263,7 +1267,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

@ -1,5 +1,5 @@
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 { 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'
@ -9,6 +9,11 @@ import { useToast } from '../context/ToastContext'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub' import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
import {
buildTrainingModulesListReturnContext,
goNavReturn,
preserveAppReturnOnNavigate,
} from '../utils/navReturnContext'
function moduleFormSnapshot({ function moduleFormSnapshot({
title, title,
@ -62,6 +67,7 @@ function swapItems(arr, i, j) {
export default function TrainingModuleEditPage() { export default function TrainingModuleEditPage() {
const { id: routeId } = useParams() const { id: routeId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const isNew = !routeId || routeId === 'new' const isNew = !routeId || routeId === 'new'
const moduleId = !isNew ? parseInt(routeId, 10) : NaN const moduleId = !isNew ? parseInt(routeId, 10) : NaN
@ -302,6 +308,12 @@ export default function TrainingModuleEditPage() {
} }
} }
const moduleListReturn = useMemo(() => buildTrainingModulesListReturnContext(), [])
const goBack = useCallback(() => {
goNavReturn(navigate, location, moduleListReturn)
}, [navigate, location, moduleListReturn])
const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
if (!title.trim()) { if (!title.trim()) {
toast.error('Titel ist Pflicht.') toast.error('Titel ist Pflicht.')
@ -315,9 +327,11 @@ export default function TrainingModuleEditPage() {
const created = await api.createTrainingModule(body) const created = await api.createTrainingModule(body)
toast.success('Trainingsmodul angelegt.') toast.success('Trainingsmodul angelegt.')
if (closeAfter) { if (closeAfter) {
navigate('/planning/training-modules') goBack()
} else if (!fromUnsavedDialog) { } else if (!fromUnsavedDialog) {
navigate(`/planning/training-modules/${created.id}`, { replace: true }) preserveAppReturnOnNavigate(navigate, location, `/planning/training-modules/${created.id}`, {
replace: true,
})
} }
return true return true
} }
@ -325,7 +339,7 @@ export default function TrainingModuleEditPage() {
baselineRef.current = moduleFormSnapshot(latestFormRef.current) baselineRef.current = moduleFormSnapshot(latestFormRef.current)
setBypassDirty(false) setBypassDirty(false)
toast.success('Gespeichert.') toast.success('Gespeichert.')
if (closeAfter) navigate('/planning/training-modules') if (closeAfter) goBack()
return true return true
} catch (err) { } catch (err) {
const msg = err.message || 'Speichern fehlgeschlagen' const msg = err.message || 'Speichern fehlgeschlagen'
@ -361,11 +375,6 @@ export default function TrainingModuleEditPage() {
return ( return (
<div className="app-page"> <div className="app-page">
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/training-modules" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
Zurück zur ModulBibliothek
</Link>
</p>
<h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1> <h1 className="page-title">{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '40rem' }}> <p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '40rem' }}>
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie). Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie).
@ -670,7 +679,7 @@ export default function TrainingModuleEditPage() {
saving={saving} saving={saving}
onSave={() => handleSave()} onSave={() => handleSave()}
onSaveAndClose={handleSaveAndClose} onSaveAndClose={handleSaveAndClose}
onCancel={() => navigate('/planning/training-modules')} onCancel={goBack}
cancelLabel="Abbrechen" cancelLabel="Abbrechen"
/> />
</form> </form>

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,5 +1,5 @@
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'
@ -7,6 +7,10 @@ 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 {
buildPlanTemplatesListReturnContext,
} from '../utils/navReturnContext'
import { import {
defaultSection, defaultSection,
formSectionsFromPlanTemplateRows, formSectionsFromPlanTemplateRows,
@ -31,6 +35,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 +189,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,12 +226,6 @@ export default function TrainingPlanTemplateEditPage() {
return ( return (
<div className="app-page"> <div className="app-page">
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/plan-templates" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
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
</h1> </h1>
@ -321,7 +321,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,7 +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 {
PLANNING_HUB_PATH,
buildCurrentLocationReturnContext,
goNavReturn,
} from '../utils/navReturnContext'
export default function TrainingUnitEditPage() { export default function TrainingUnitEditPage() {
const { id: routeId } = useParams() const { id: routeId } = useParams()
@ -119,8 +124,16 @@ export default function TrainingUnitEditPage() {
const [saveModuleOpen, setSaveModuleOpen] = useState(false) const [saveModuleOpen, setSaveModuleOpen] = useState(false)
const goBack = useCallback(() => { const goBack = useCallback(() => {
navigate(planningHubPathFromReturnState(location.state?.planningReturn)) goNavReturn(navigate, location, {
}, [location.state, navigate]) path: PLANNING_HUB_PATH,
label: 'Zurück zur Planung',
})
}, [location, navigate])
const moduleSaveReturnContext = useMemo(
() => buildCurrentLocationReturnContext(location, 'Zurück zur Trainingseinheit'),
[location]
)
const planningClubId = useMemo(() => { const planningClubId = useMemo(() => {
const gid = Number(formData.group_id) const gid = Number(formData.group_id)
@ -517,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 = {}) => {
@ -650,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
@ -723,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
@ -731,6 +745,7 @@ export default function TrainingUnitEditPage() {
onSuccess={() => setSaveModuleOpen(false)} onSuccess={() => setSaveModuleOpen(false)}
unitId={editingUnit?.id} unitId={editingUnit?.id}
planningModalClubId={planningClubId} planningModalClubId={planningClubId}
returnContext={moduleSaveReturnContext}
/> />
<ExercisePickerModal <ExercisePickerModal

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

@ -0,0 +1,90 @@
/** Minimaler Snapshot einer Übung für die modulübergreifende Auswahl (filterunabhängig). */
export function snapshotExerciseForSelection(exercise) {
if (!exercise || exercise.id == null) return null
const id = Number(exercise.id)
if (!Number.isFinite(id) || id < 1) return null
return {
id,
title: exercise.title || '',
summary: exercise.summary || '',
visibility: exercise.visibility,
status: exercise.status,
exercise_kind: exercise.exercise_kind,
created_by: exercise.created_by,
focus_area: exercise.focus_area,
focus_area_names: exercise.focus_area_names,
style_direction_names: exercise.style_direction_names,
training_type_names: exercise.training_type_names,
media_count: exercise.media_count,
variant_count: exercise.variant_count,
media: Array.isArray(exercise.media) ? exercise.media : [],
}
}
export function normalizeSelectedEntries(raw) {
if (!Array.isArray(raw)) return []
const out = []
const seen = new Set()
for (const item of raw) {
const snap = snapshotExerciseForSelection(item)
if (!snap || seen.has(snap.id)) continue
seen.add(snap.id)
out.push(snap)
}
return out
}
export function mergeSelectedWithListEntries(selectedEntries, exercises) {
const byId = new Map()
for (const e of exercises || []) {
const id = Number(e?.id)
if (Number.isFinite(id) && id > 0) byId.set(id, e)
}
return (selectedEntries || []).map((entry) => byId.get(Number(entry.id)) || entry)
}
export function moduleItemToPayload(row, orderIndex) {
if ((row?.item_type || 'exercise') === 'note') {
return {
item_type: 'note',
order_index: orderIndex,
note_body: row.note_body ?? '',
}
}
const eid = Number(row.exercise_id)
if (!Number.isFinite(eid) || eid < 1) return null
const vidRaw = row.exercise_variant_id
const vid =
vidRaw === '' || vidRaw == null || row.exercise_kind === 'combination'
? null
: Number(vidRaw)
return {
item_type: 'exercise',
order_index: orderIndex,
exercise_id: eid,
exercise_variant_id: Number.isFinite(vid) && vid > 0 ? vid : null,
planned_duration_min:
row.planned_duration_min !== '' && row.planned_duration_min != null
? Number(row.planned_duration_min)
: null,
notes: row.notes != null && String(row.notes).trim() ? String(row.notes).trim() : null,
}
}
export function buildRowsPayload(rows) {
return rows
.map((row, idx) =>
moduleItemToPayload(
{
...row,
exercise_id: row.exercise_id,
exercise_variant_id: row.exercise_variant_id,
planned_duration_min: row.planned_duration_min,
notes: row.notes,
exercise_kind: row.exercise_kind,
},
idx
)
)
.filter(Boolean)
}

View File

@ -0,0 +1,51 @@
import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi } from '../constants/exerciseListFilters'
import { normalizeSelectedEntries } from './exerciseListSelection'
const STORAGE_KEY = 'shinkan.exerciseList.session.v1'
function safeParse(raw) {
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
/** @returns {{ filters: object, searchInput: string, aiSearchInput: string, mineOnly: boolean, selectedEntries: object[] } | null} */
export function readExerciseListSessionState() {
if (typeof sessionStorage === 'undefined') return null
const parsed = safeParse(sessionStorage.getItem(STORAGE_KEY))
if (!parsed || typeof parsed !== 'object') return null
const filters =
parsed.filters && typeof parsed.filters === 'object'
? mergeExerciseListPrefsFromApi(parsed.filters)
: { ...INITIAL_EXERCISE_LIST_FILTERS }
return {
filters,
searchInput: typeof parsed.searchInput === 'string' ? parsed.searchInput : '',
aiSearchInput: typeof parsed.aiSearchInput === 'string' ? parsed.aiSearchInput : '',
mineOnly: !!parsed.mineOnly,
selectedEntries: normalizeSelectedEntries(parsed.selectedEntries),
}
}
export function writeExerciseListSessionState(state) {
if (typeof sessionStorage === 'undefined' || !state || typeof state !== 'object') return
try {
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({
filters: state.filters ?? { ...INITIAL_EXERCISE_LIST_FILTERS },
searchInput: typeof state.searchInput === 'string' ? state.searchInput : '',
aiSearchInput: typeof state.aiSearchInput === 'string' ? state.aiSearchInput : '',
mineOnly: !!state.mineOnly,
selectedEntries: normalizeSelectedEntries(state.selectedEntries),
})
)
} catch {
/* quota / private mode */
}
}

View File

@ -0,0 +1,215 @@
/**
* Einheitlicher Rücksprung-Kontext für PWA-Navigation (siehe NAV_RETURN_CONTEXT_SPEC.md).
*/
import {
buildPlanningHubReturnState,
PLANNING_HUB_PATH,
planningHubPathFromReturnState,
} from './planningUnitRoutes'
export { PLANNING_HUB_PATH }
export const NAV_RETURN_STATE_KEY = 'appReturn'
export const EXERCISES_LIST_PATH = '/exercises'
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) {
const path = String(opts?.path || '').trim()
const label = String(opts?.label || '').trim()
if (!path || !label) return null
const ctx = { v: 1, path, label }
if (opts?.kind) ctx.kind = opts.kind
if (opts?.payload && typeof opts.payload === 'object') {
ctx.payload = opts.payload
}
return ctx
}
export function buildExercisesListReturnContext() {
return buildNavReturnContext({
path: EXERCISES_LIST_PATH,
label: 'Zurück zur Übungsliste',
kind: 'exerciseList',
})
}
export function buildTrainingModulesListReturnContext() {
return buildNavReturnContext({
path: TRAINING_MODULES_LIST_PATH,
label: 'Zurück zur Modul-Bibliothek',
kind: 'trainingModulesList',
})
}
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).
*/
export function buildCurrentLocationReturnContext(location, label) {
const path = `${location?.pathname || ''}${location?.search || ''}`.trim()
if (!path) return null
return buildNavReturnContext({
path,
label: String(label || 'Zurück').trim(),
kind: 'currentLocation',
})
}
/** @param {ReturnType<typeof buildPlanningHubReturnState>|object} hubState */
export function buildPlanningHubReturnContext(hubState = {}) {
const payload = buildPlanningHubReturnState(hubState)
return buildNavReturnContext({
path: planningHubPathFromReturnState(payload),
label: 'Zurück zur Planung',
kind: 'planningHub',
payload,
})
}
/** Legacy planningReturn → appReturn */
export function appReturnFromPlanningReturn(planningReturn) {
if (!planningReturn || planningReturn.v !== 1) return null
return buildPlanningHubReturnContext(planningReturn)
}
/**
* @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location
*/
export function readNavReturnFromLocation(location) {
const state = location?.state
if (!state || typeof state !== 'object') return null
const raw = state[NAV_RETURN_STATE_KEY]
if (raw?.v === 1 && raw.path && raw.label) return raw
if (state.planningReturn) return appReturnFromPlanningReturn(state.planningReturn)
return null
}
/**
* @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location
* @param {{ path: string, label: string }|null|undefined} fallback
*/
export function resolveNavReturnTarget(location, fallback) {
const ctx = readNavReturnFromLocation(location)
if (ctx?.path && ctx?.label) {
return { path: ctx.path, label: ctx.label, fromContext: true }
}
if (fallback?.path && fallback?.label) {
return { path: fallback.path, label: fallback.label, fromContext: false }
}
return null
}
/**
* @param {import('react-router-dom').NavigateFunction} navigate
* @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location
* @param {{ path?: string, label?: string }|null|undefined} fallback
*/
export function goNavReturn(navigate, location, fallback) {
const target = resolveNavReturnTarget(location, fallback)
if (target?.fromContext && target.path) {
navigate(target.path)
return
}
if (typeof window !== 'undefined' && window.history.length > 1) {
navigate(-1)
return
}
if (fallback?.path) {
navigate(fallback.path)
return
}
navigate('/')
}
/**
* @param {import('react-router-dom').NavigateFunction} navigate
* @param {string} to
* @param {ReturnType<typeof buildNavReturnContext>|null|undefined} returnContext
* @param {object} [options]
*/
export function navigateWithAppReturn(navigate, to, returnContext, options = {}) {
const state = { ...(options.state || {}) }
if (returnContext) state[NAV_RETURN_STATE_KEY] = returnContext
navigate(to, { ...options, state })
}
/**
* Bestehenden appReturn (oder Legacy planningReturn) beim Weiterleiten erhalten.
*/
export function preserveAppReturnOnNavigate(navigate, location, to, options = {}) {
const existing = readNavReturnFromLocation(location)
const state = { ...(options.state || {}) }
if (existing) state[NAV_RETURN_STATE_KEY] = existing
navigate(to, { ...options, state })
}

View File

@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest'
import {
appReturnFromPlanningReturn,
buildExercisesListReturnContext,
buildMediaLibraryReturnContext,
buildNavReturnContext,
buildPlanningHubReturnContext,
buildTrainingRunReturnContext,
goNavReturn,
readNavReturnFromLocation,
resolveNavReturnTarget,
} from './navReturnContext.js'
describe('navReturnContext', () => {
it('buildNavReturnContext requires path and label', () => {
expect(buildNavReturnContext({ path: '/x', label: 'Zurück' })).toEqual({
v: 1,
path: '/x',
label: 'Zurück',
})
expect(buildNavReturnContext({ path: '', label: 'X' })).toBeNull()
})
it('buildExercisesListReturnContext', () => {
const ctx = buildExercisesListReturnContext()
expect(ctx.path).toBe('/exercises')
expect(ctx.kind).toBe('exerciseList')
})
it('readNavReturnFromLocation prefers appReturn', () => {
const ctx = buildExercisesListReturnContext()
expect(readNavReturnFromLocation({ state: { appReturn: ctx } })).toEqual(ctx)
})
it('readNavReturnFromLocation bridges planningReturn', () => {
const loc = {
state: {
planningReturn: {
v: 1,
selectedGroupId: '3',
planView: 'list',
calendarMonthStr: '',
startDate: '',
endDate: '',
planScope: 'group',
assignedToMeOnly: false,
},
},
}
const ctx = readNavReturnFromLocation(loc)
expect(ctx?.kind).toBe('planningHub')
expect(ctx?.path).toContain('/planning')
expect(ctx?.label).toBe('Zurück zur Planung')
})
it('appReturnFromPlanningReturn', () => {
const ctx = appReturnFromPlanningReturn({
v: 1,
selectedGroupId: '',
planView: 'calendar',
calendarMonthStr: '2026-05',
startDate: '',
endDate: '',
planScope: 'group',
assignedToMeOnly: false,
})
expect(ctx?.path).toContain('month=2026-05')
})
it('resolveNavReturnTarget uses fallback when no state', () => {
const fb = { path: '/planning/training-modules', label: 'Zurück zur Modul-Bibliothek' }
expect(resolveNavReturnTarget({ state: null }, fb)).toEqual({ ...fb, fromContext: false })
})
it('goNavReturn navigates to context path first', () => {
const navigate = vi.fn()
const ctx = buildExercisesListReturnContext()
goNavReturn(navigate, { state: { appReturn: ctx } }, null)
expect(navigate).toHaveBeenCalledWith('/exercises')
})
it('buildPlanningHubReturnContext', () => {
const ctx = buildPlanningHubReturnContext({ selectedGroupId: '7', planView: 'list' })
expect(ctx?.path).toContain('group=7')
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')
})
})