UX Verbesserung, Navigationsspeicherung #40
144
.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md
Normal file
144
.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md
Normal file
|
|
@ -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`
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
12
frontend/src/components/NavStateLink.jsx
Normal file
12
frontend/src/components/NavStateLink.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
30
frontend/src/components/PageReturnButton.jsx
Normal file
30
frontend/src/components/PageReturnButton.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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' }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
19
frontend/src/hooks/useNavReturn.js
Normal file
19
frontend/src/hooks/useNavReturn.js
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 Modul‑Bibliothek
|
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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)' }}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
90
frontend/src/utils/exerciseListSelection.js
Normal file
90
frontend/src/utils/exerciseListSelection.js
Normal 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)
|
||||||
|
}
|
||||||
51
frontend/src/utils/exerciseListSessionState.js
Normal file
51
frontend/src/utils/exerciseListSessionState.js
Normal 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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
215
frontend/src/utils/navReturnContext.js
Normal file
215
frontend/src/utils/navReturnContext.js
Normal 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 })
|
||||||
|
}
|
||||||
100
frontend/src/utils/navReturnContext.test.js
Normal file
100
frontend/src/utils/navReturnContext.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user