diff --git a/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md new file mode 100644 index 0000000..1628e83 --- /dev/null +++ b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md @@ -0,0 +1,144 @@ +# Navigation — Return-Kontext (Rücksprung) + +**Stand:** 2026-05-20 +**Status:** Spezifikation + Phase 1–2 umgesetzt +**Ziel:** In der PWA (ohne Browser-Back) zuverlässig an den fachlichen Ausgangspunkt zurückkehren — inkl. sinnvollem Label und optional UI-State. + +--- + +## 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` diff --git a/frontend/src/app.css b/frontend/src/app.css index f5bcf83..05f59c5 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1459,6 +1459,36 @@ html.modal-scroll-locked .app-main { .page-form-editor__intro { 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 { display: inline-block; margin-bottom: 0.35rem; @@ -2949,15 +2979,66 @@ html.modal-scroll-locked .app-main { flex-shrink: 0; 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 { flex: 1; 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 { margin: 0 0 8px; font-size: 1.05rem; line-height: 1.3; font-weight: 700; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; } .exercise-card-title a { color: inherit; diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx index 9fae195..0afc490 100644 --- a/frontend/src/components/ExercisePeekModal.jsx +++ b/frontend/src/components/ExercisePeekModal.jsx @@ -5,6 +5,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import NavStateLink from './NavStateLink' import ExerciseRichTextBlock from './ExerciseRichTextBlock' import CombinationPlanBracket from './CombinationPlanBracket' import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' @@ -36,6 +37,8 @@ export default function ExercisePeekModal({ titleFallback, /** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */ peekExtras, + /** Rücksprung-Kontext für „Vollständige Übungsseite“ */ + returnContext, }) { const [loading, setLoading] = useState(false) const [err, setErr] = useState(null) @@ -255,13 +258,24 @@ export default function ExercisePeekModal({ {top?.exerciseId != null ? (
- - Vollständige Übungsseite öffnen - + {returnContext ? ( + + Vollständige Übungsseite öffnen + + ) : ( + + Vollständige Übungsseite öffnen + + )}
) : null} diff --git a/frontend/src/components/NavStateLink.jsx b/frontend/src/components/NavStateLink.jsx new file mode 100644 index 0000000..4df41fb --- /dev/null +++ b/frontend/src/components/NavStateLink.jsx @@ -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 ( + + {children} + + ) +} diff --git a/frontend/src/components/PageFormEditorChrome.jsx b/frontend/src/components/PageFormEditorChrome.jsx index 57151be..324d560 100644 --- a/frontend/src/components/PageFormEditorChrome.jsx +++ b/frontend/src/components/PageFormEditorChrome.jsx @@ -1,27 +1,31 @@ import React from 'react' -import { Link } from 'react-router-dom' 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({ title, - backTo, - backLabel = 'Zurück', + fallbackPath, + fallbackLabel, actionConfig, children, testId, + showReturn = false, }) { useFormEditorActions(actionConfig) return (
- {backTo ? ( - - ← {backLabel} - + {showReturn && fallbackPath && fallbackLabel ? ( + ) : null}

{title}

diff --git a/frontend/src/components/PageReturnButton.jsx b/frontend/src/components/PageReturnButton.jsx new file mode 100644 index 0000000..8c58b07 --- /dev/null +++ b/frontend/src/components/PageReturnButton.jsx @@ -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 ( + + ) +} diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 2e1aa9d..95e0226 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -1,5 +1,5 @@ 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 { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl' import RichTextEditor from '../RichTextEditor' @@ -27,6 +27,14 @@ import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } import { GripVertical } from 'lucide-react' import UnsavedChangesPrompt from '../UnsavedChangesPrompt' 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' const INTENSITY_OPTIONS = [ @@ -469,6 +477,9 @@ function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) { function ExerciseFormPageRoot() { const { id: routeId } = useParams() const navigate = useNavigate() + const location = useLocation() + const exercisesListReturn = useMemo(() => buildExercisesListReturnContext(), []) + const { goBack } = useNavReturn(exercisesListReturn) const { user } = useAuth() const isSuperadmin = user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' @@ -941,16 +952,16 @@ function ExerciseFormPageRoot() { setVariants((ex.variants || []).map(apiVariantToRow)) setFormDirty(false) toast.success('Gespeichert.') - if (closeAfter) navigate('/exercises') + if (closeAfter) goBack() return true } const created = await api.createExercise(payload) setFormDirty(false) toast.success('Übung angelegt.') if (closeAfter) { - navigate('/exercises') + goBack() } else if (!fromUnsavedDialog) { - navigate(`/exercises/${created.id}/edit`, { replace: true }) + preserveAppReturnOnNavigate(navigate, location, `/exercises/${created.id}/edit`, { replace: true }) } return true } catch (err) { @@ -960,7 +971,7 @@ function ExerciseFormPageRoot() { setSaving(false) } }, - [exerciseId, formData, isEdit, navigate, toast], + [exerciseId, formData, isEdit, navigate, location, toast, goBack], ) const handleSubmit = useCallback( @@ -979,10 +990,6 @@ function ExerciseFormPageRoot() { [performSaveAttempt], ) - const goBackToList = useCallback(() => { - navigate('/exercises') - }, [navigate]) - const actionConfig = useMemo( () => ({ formId: 'exercise-form', @@ -990,11 +997,11 @@ function ExerciseFormPageRoot() { isNew: !isEdit, onSave: handleSubmit, onSaveAndClose: handleSaveAndClose, - onCancel: goBackToList, + onCancel: goBack, showSave: true, showSaveAndClose: true, }), - [saving, isEdit, handleSubmit, handleSaveAndClose, goBackToList], + [saving, isEdit, handleSubmit, handleSaveAndClose, goBack], ) const handleUnsavedDialogSave = async () => { @@ -1198,15 +1205,17 @@ function ExerciseFormPageRoot() { {isEdit ? (

diff --git a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx index 084dcd3..1fd134d 100644 --- a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx +++ b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx @@ -5,6 +5,7 @@ export default function ExerciseListBulkToolbar({ bulkMaxIds, onClearSelection, onOpenBulkModal, + onOpenSaveModuleModal, }) { if (selectedCount < 1) return null @@ -14,6 +15,9 @@ export default function ExerciseListBulkToolbar({ + diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx index 234c69d..a0d1aee 100644 --- a/frontend/src/components/exercises/ExerciseListCard.jsx +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -1,5 +1,10 @@ -import React from 'react' -import { Link } from 'react-router-dom' +import React, { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import NavStateLink from '../NavStateLink' +import { + buildExercisesListReturnContext, + navigateWithAppReturn, +} from '../../utils/navReturnContext' import { Eye, 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). */ -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 styleNames = coerceApiNameList(exercise.style_direction_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 ( -

+
toggleSelect(exercise.id)} - aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} + onChange={() => toggleSelect(exercise)} + aria-label={`„${titleText}“ auswählen`} className="exercise-card-layout__check" /> -
+

- {exercise.title} + e.stopPropagation()} + > + {exercise.title} + + {selectionPinned ? ( + + Auswahl + + ) : null}

{focusNames.map((name) => ( @@ -191,22 +248,24 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
- onPeek?.(exercise)} > - - + - + {canUserRequestExerciseDelete(user, exercise) ? ( -
+ + {filterResultExercises.length === 0 ? ( + selectedEntries.length > 0 ? ( +

+ Keine weiteren Treffer für den aktuellen Filter. +

+ ) : null + ) : ( + <> + {listFetching ? ( +

Aktualisiere Treffer…

+ ) : null} +

+ {filterResultExercises.length} Treffer + {selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''} + {hasMore ? ' · es gibt weitere Einträge' : ''} +

+
+ {filterResultExercises.map((exercise) => ( + setPeekExercise({ id: ex.id, title: ex.title })} + /> + ))} +
+ {hasMore && ( +
+ +
+ )} + )} )} diff --git a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx new file mode 100644 index 0000000..0579f89 --- /dev/null +++ b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx @@ -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, 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 ( + +
+

Auswahl als Trainingsmodul

+

+ Die gewählten Übungen werden in der Reihenfolge der Auswahl übernommen. Pro Übung kann + optional eine Variante gesetzt werden. +

+ + {loading ? ( +

Laden …

+ ) : loadErr ? ( +

{loadErr}

+ ) : rows.length === 0 ? ( +

Keine Übungen ausgewählt.

+ ) : ( +
+
+
+ + +
+ + {targetMode === 'append' ? ( +
+ + {modulesLoading ? ( +

Module laden …

+ ) : moduleOptions.length === 0 ? ( +

+ Keine bearbeitbaren Module gefunden. Lege zuerst ein neues Modul an. +

+ ) : ( + + )} +

+ Die neuen Übungen werden ans Ende des gewählten Moduls angefügt. +

+
+ ) : ( + <> +
+ + setTitle(e.target.value)} + required + placeholder="z. B. Technikblock Grundlagen" + /> +
+ +
+ + +
+ {visibility === 'club' ? ( +
+ + +
+ ) : null} + + )} + +
+
    + {rows.map((row, idx) => { + const isCombo = row.exercise_kind === 'combination' + const variants = Array.isArray(row.variants) ? row.variants : [] + return ( +
  • +
    + {idx + 1}. {(row.exercise_title || '').trim() || `Übung #${row.exercise_id}`} + {isCombo ? ( + + Kombination + + ) : null} +
    + {!isCombo && variants.length > 0 ? ( +
    + + +
    + ) : !isCombo ? ( +
    Keine Varianten hinterlegt
    + ) : null} +
  • + ) + })} +
+
+
+ + + + )} + + {loading ? ( +
+ +
+ ) : null} + + {!loading && (loadErr || rows.length === 0) ? ( +
+ +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx index ee2c119..3c49298 100644 --- a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx +++ b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx @@ -7,6 +7,7 @@ import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' import { activeClubMemberships } from '../../utils/activeClub' import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit' +import { navigateWithAppReturn } from '../../utils/navReturnContext' /** * Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl). @@ -16,6 +17,7 @@ export default function SaveExercisesAsModuleModal({ onClose, unitId, planningModalClubId, + returnContext, onSuccess, }) { const navigate = useNavigate() @@ -134,7 +136,7 @@ export default function SaveExercisesAsModuleModal({ }) toast.success('Trainingsmodul gespeichert.') if (created?.id) { - navigate(`/planning/training-modules/${created.id}`) + navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext) } onSuccess?.() onClose() diff --git a/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx index c7714d6..e127865 100644 --- a/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx +++ b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx @@ -6,6 +6,7 @@ import api from '../../utils/api' import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' 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). @@ -15,6 +16,7 @@ export default function TrainingPublishToFrameworkModal({ onClose, unitId, planningModalClubId, + returnContext, onSuccess, }) { const navigate = useNavigate() @@ -131,7 +133,7 @@ export default function TrainingPublishToFrameworkModal({ }) toast.success('Ablauf wurde im Rahmenprogramm gespeichert.') if (created?.id) { - navigate(`/planning/framework-programs/${created.id}`) + navigateWithAppReturn(navigate, `/planning/framework-programs/${created.id}`, returnContext) } onSuccess?.() resetAndClose() @@ -177,7 +179,7 @@ export default function TrainingPublishToFrameworkModal({ const updated = await api.publishTrainingUnitToFramework(unitId, payload) toast.success('Ablauf wurde im Rahmenprogramm gespeichert.') if (updated?.id) { - navigate(`/planning/framework-programs/${updated.id}`) + navigateWithAppReturn(navigate, `/planning/framework-programs/${updated.id}`, returnContext) } onSuccess?.() resetAndClose() diff --git a/frontend/src/hooks/useNavReturn.js b/frontend/src/hooks/useNavReturn.js new file mode 100644 index 0000000..d0addc9 --- /dev/null +++ b/frontend/src/hooks/useNavReturn.js @@ -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 } +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 3d04321..e9f6d97 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,9 +1,14 @@ import React, { useState, useEffect, useMemo } from 'react' import { Link } from 'react-router-dom' import { CalendarCheck, ClipboardList, FilePenLine, Library } from 'lucide-react' +import NavStateLink from '../components/NavStateLink' import { useAuth } from '../context/AuthContext' import api from '../utils/api' import { getTenantClubDependencyKey } from '../utils/activeClub' +import { + buildDashboardReturnContext, + buildTrainingRunReturnContext, +} from '../utils/navReturnContext' import EmailVerificationBanner from '../components/EmailVerificationBanner' import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget' import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget' @@ -26,6 +31,7 @@ function Dashboard() { const [dashboardKpisErr, setDashboardKpisErr] = useState(null) const { user, loading: authLoading } = useAuth() const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + const dashboardReturn = useMemo(() => buildDashboardReturnContext(), []) useEffect(() => { if (!user?.id) { @@ -166,14 +172,20 @@ function Dashboard() {
    {phase0Stats.draftPreview.map((ex) => (
  • - + {ex.title} - +
  • ))}

- Alle Entwürfe in der Übersicht + + Alle Entwürfe in der Übersicht +

) : null} @@ -205,9 +217,13 @@ function Dashboard() {
    {trainingHome.upcoming.map((u) => (
  • - + {unitWhenLabel(u)} - + {u.group_name ? ( {` — ${u.group_name}`} ) : null} @@ -237,9 +253,13 @@ function Dashboard() { const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120) return (
  • - + {unitWhenLabel(u)} - + {u.group_name ? ( {` · ${u.group_name}`} ) : null} @@ -266,9 +286,13 @@ function Dashboard() {
      {trainingHome.reviewPending.map((u) => (
    • - + {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'} - + {u.group_name ? ( {` — ${u.group_name}`} ) : null} diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index 584a347..97c0fa9 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -1,6 +1,12 @@ -import React, { useEffect, useState } from 'react' -import { Link, useNavigate, useParams, useLocation } from 'react-router-dom' +import React, { useEffect, useMemo, useState } from 'react' +import { useParams, useLocation } from 'react-router-dom' 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 ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip' import CombinationPlanBracket from '../components/CombinationPlanBracket' @@ -54,8 +60,11 @@ function metaParts(exercise) { function ExerciseDetailPage() { const { id } = useParams() - const navigate = useNavigate() const location = useLocation() + const editReturnContext = useMemo( + () => buildCurrentLocationReturnContext(location, 'Zurück zur Übung'), + [location] + ) const [exercise, setExercise] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) @@ -98,9 +107,10 @@ function ExerciseDetailPage() {

      Übung

      {msg}

      - +
      ) @@ -109,7 +119,6 @@ function ExerciseDetailPage() { if (!exercise) return null const meta = metaParts(exercise) - const fromExerciseEdit = location.state?.fromExerciseEdit === true const isCombinationDetail = (exercise.exercise_kind || '').toLowerCase().trim() === 'combination' && @@ -131,13 +140,19 @@ function ExerciseDetailPage() { onClose={() => setEmbeddedPeekExerciseId(null)} />
      - +
      - - {fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'} - + + Bearbeiten +
      diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 059d620..abaf85e 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -1,5 +1,7 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react' import { Link } from 'react-router-dom' +import NavStateLink from '../components/NavStateLink' +import { buildMediaLibraryReturnContext } from '../utils/navReturnContext' import { LayoutGrid, List, @@ -108,21 +110,23 @@ function parseTagsInput(s) { .filter(Boolean) } -function MediaUsageBlock({ usage, compact }) { +function MediaUsageBlock({ usage, compact, returnContext }) { const u = usage || { exercises: [], training_units: [] } const ex = u.exercises || [] const tus = u.training_units || [] if (!ex.length && !tus.length) return {compact ? '—' : 'Noch in keiner Übung / Einheit verknüpft.'} + const LinkOrNav = returnContext ? NavStateLink : Link + const linkExtra = returnContext ? { returnContext } : {} return (
      {ex.length ? (
      Übungen{' '} {ex.map((e) => ( - + {e.title.length > (compact ? 18 : 40) ? `${e.title.slice(0, compact ? 18 : 40)}…` : e.title} - + ))}
      ) : null} @@ -134,9 +138,9 @@ function MediaUsageBlock({ usage, compact }) { [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 return ( - + {short} - + ) })}
      @@ -297,6 +301,7 @@ export default function MediaLibraryPage() { const isSuperadmin = user?.role === 'superadmin' const hasClubOrgAdmin = activeClubMemberships(user?.clubs).some((c) => (c.roles || []).includes('club_admin')) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + const mediaLibraryReturn = useMemo(() => buildMediaLibraryReturnContext(), []) const archiveVisOptions = useMemo( () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), @@ -1004,7 +1009,7 @@ export default function MediaLibraryPage() { ))} ) : null} - + {(it.lifecycle_state || 'active') === 'active' && !it.legal_hold_active && ( + ) } @@ -680,6 +689,7 @@ export default function TrainingCoachPage() { key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'} open={candidatePeekId != null} exerciseId={candidatePeekId} + returnContext={runReturn} onClose={() => setCandidatePeekId(null)} />