From 4fee5a2b47f94b1b96ae3a7c45fe76cd364cfd1b Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 May 2026 10:02:39 +0200 Subject: [PATCH] Implement planning layout and enhance training module functionality - Added a new PlanningLayout component to manage the training planning interface, allowing for better organization of related pages. - Introduced a FormActionBar component across various modals and forms to standardize action buttons for saving and canceling. - Updated the TrainingPlanningPageRoot and TrainingPlanningUnitFormModal to utilize the new FormActionBar for improved user experience. - Enhanced the TrainingModuleEditPage and TrainingFrameworkProgramEditPage with save and close functionality, streamlining the editing process. - Refactored existing modals to incorporate the new layout and action bar, ensuring consistency across the application. --- frontend/src/App.jsx | 15 +- frontend/src/app.css | 100 ++++++++++++ frontend/src/components/FormActionBar.jsx | 82 ++++++++++ .../components/planning/PlanningRouteNav.jsx | 31 ++++ .../planning/SaveExercisesAsModuleModal.jsx | 28 ++-- .../planning/TrainingPlanningPageRoot.jsx | 67 +++----- .../TrainingPlanningUnitFormModal.jsx | 110 +++---------- .../TrainingPublishToFrameworkModal.jsx | 28 ++-- frontend/src/layouts/PlanningLayout.jsx | 14 ++ .../TrainingFrameworkProgramEditPage.jsx | 38 +++-- .../TrainingFrameworkProgramsListPage.jsx | 17 +- frontend/src/pages/TrainingModuleEditPage.jsx | 41 +++-- .../src/pages/TrainingModulesListPage.jsx | 11 +- .../pages/TrainingPlanTemplatesListPage.jsx | 150 ++++++++++++++++++ 14 files changed, 525 insertions(+), 207 deletions(-) create mode 100644 frontend/src/components/FormActionBar.jsx create mode 100644 frontend/src/components/planning/PlanningRouteNav.jsx create mode 100644 frontend/src/layouts/PlanningLayout.jsx create mode 100644 frontend/src/pages/TrainingPlanTemplatesListPage.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 65c338e..f529dd7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,8 @@ const ClubsPage = lazy(() => import('./pages/ClubsPage')) const InboxPage = lazy(() => import('./pages/InboxPage')) const SkillsPage = lazy(() => import('./pages/SkillsPage')) const TrainingPlanningPage = lazy(() => import('./pages/TrainingPlanningPage')) +const TrainingPlanTemplatesListPage = lazy(() => import('./pages/TrainingPlanTemplatesListPage')) +const PlanningLayout = lazy(() => import('./layouts/PlanningLayout')) const TrainingFrameworkProgramsListPage = lazy(() => import('./pages/TrainingFrameworkProgramsListPage'), ) @@ -223,15 +225,22 @@ const appRouter = createBrowserRouter([ { path: 'clubs', element: }, { path: 'inbox', element: }, { path: 'skills', element: }, + { + path: 'planning', + element: , + children: [ + { index: true, element: }, + { path: 'framework-programs', element: }, + { path: 'training-modules', element: }, + { path: 'plan-templates', element: }, + ], + }, { path: 'planning/framework-programs/new', element: }, { path: 'planning/framework-programs/:id', element: }, - { path: 'planning/framework-programs', element: }, { path: 'planning/training-modules/new', element: }, { path: 'planning/training-modules/:id', element: }, - { path: 'planning/training-modules', element: }, { path: 'planning/run/:unitId/coach', element: }, { path: 'planning/run/:unitId', element: }, - { path: 'planning', element: }, { path: 'admin', element: }, { path: 'admin/users', diff --git a/frontend/src/app.css b/frontend/src/app.css index a3d4378..a43cfc8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1155,6 +1155,106 @@ a.analysis-split__nav-item { min-width: 0; } +/* Planung: gemeinsame Chip-Navigation + Inhalt */ +.planning-layout__main { + min-width: 0; +} +.planning-route-nav { + margin-bottom: 1.25rem; +} + +/* Formular-Aktionsleiste (sticky, schmal, touch-freundlich) */ +.form-action-bar { + flex-shrink: 0; + position: sticky; + bottom: 0; + z-index: 12; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 8px clamp(10px, 2.5vw, 14px); + padding-bottom: max(8px, env(safe-area-inset-bottom, 0px)); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06); +} +.form-action-bar--top { + border-top: none; + border-bottom: 1px solid var(--border); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); +} +.form-action-bar--modal { + margin-top: auto; +} +.form-action-bar__inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + max-width: min(1100px, 100%); + margin: 0 auto; +} +.form-action-bar__spacer { + flex: 0 0 auto; + min-width: 0; +} +.form-action-bar__primary-group { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-left: auto; +} +.form-action-bar__btn { + min-height: 44px; + padding: 8px 14px; + font-size: 0.88rem; + font-weight: 600; + white-space: nowrap; +} +.form-action-bar__btn--cancel { + min-width: 5.5rem; +} + +.modal-panel--form { + display: flex; + flex-direction: column; + max-height: min(92vh, 100%); + overflow: hidden; + min-height: 0; +} +.modal-form-shell { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} +.modal-form-shell__body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding-bottom: 4px; +} +.page-form-shell { + display: flex; + flex-direction: column; + min-height: 0; +} +.page-form-shell__scroll { + flex: 1 1 auto; + min-height: 0; +} +.page-form-shell .form-action-bar { + position: sticky; + bottom: 0; + margin-top: 1rem; + margin-left: calc(-1 * var(--page-pad, 16px)); + margin-right: calc(-1 * var(--page-pad, 16px)); + padding-left: var(--page-pad, 16px); + padding-right: var(--page-pad, 16px); +} + /* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */ .settings-shell { width: 100%; diff --git a/frontend/src/components/FormActionBar.jsx b/frontend/src/components/FormActionBar.jsx new file mode 100644 index 0000000..00614eb --- /dev/null +++ b/frontend/src/components/FormActionBar.jsx @@ -0,0 +1,82 @@ +/** + * Feste Aktionsleiste für Formulare/Modale: Speichern, Speichern & schließen, Abbrechen. + * Bleibt sichtbar (sticky), während der Formularinhalt scrollt. + */ +export default function FormActionBar({ + placement = 'bottom', + variant = 'default', + saving = false, + saveLabel, + saveAndCloseLabel = 'Speichern & schließen', + cancelLabel = 'Abbrechen', + onSave, + onSaveAndClose, + onCancel, + showSave = true, + showSaveAndClose = true, + showCancel = true, + formId, + isNew = false, + primaryIsSaveOnly = false, +}) { + const labelSave = saveLabel ?? (isNew ? 'Anlegen' : 'Speichern') + const showSaveBtn = showSave && (Boolean(onSave) || Boolean(formId)) + const showCloseBtn = showSaveAndClose && (Boolean(onSaveAndClose) || Boolean(formId)) + const showCancelBtn = showCancel && Boolean(onCancel) + + if (!showSaveBtn && !showCloseBtn && !showCancelBtn) return null + + const saveBtnClass = `btn form-action-bar__btn${ + primaryIsSaveOnly ? ' btn-primary' : ' btn-secondary' + }` + const closeBtnClass = `btn form-action-bar__btn${ + primaryIsSaveOnly ? ' btn-secondary' : ' btn-primary' + }` + + return ( +
+
+ {showCancelBtn ? ( + + ) : ( + + )} +
+ {showSaveBtn ? ( + + ) : null} + {showCloseBtn ? ( + + ) : null} +
+
+
+ ) +} diff --git a/frontend/src/components/planning/PlanningRouteNav.jsx b/frontend/src/components/planning/PlanningRouteNav.jsx new file mode 100644 index 0000000..bf3c1da --- /dev/null +++ b/frontend/src/components/planning/PlanningRouteNav.jsx @@ -0,0 +1,31 @@ +import { NavLink } from 'react-router-dom' + +const ITEMS = [ + { to: '/planning', label: 'Trainingsplanung', end: true }, + { to: '/planning/framework-programs', label: 'Rahmenprogramme' }, + { to: '/planning/training-modules', label: 'Trainingsmodule' }, + { to: '/planning/plan-templates', label: 'Vorlagen' }, +] + +/** Oberste Planungs-Navigation (Chip-Register). */ +export default function PlanningRouteNav() { + return ( + + ) +} diff --git a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx index ad00fd1..387ce47 100644 --- a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx +++ b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' +import FormActionBar from '../FormActionBar' import api from '../../utils/api' import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' @@ -160,17 +161,16 @@ export default function SaveExercisesAsModuleModal({ }} >
-

Übungen als Trainingsmodul

-

+

Übungen als Trainingsmodul

+

Es werden die gespeicherten Übungspositionen der Einheit vom{' '} {unitLabel || '…'} verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand brauchst. @@ -183,7 +183,8 @@ export default function SaveExercisesAsModuleModal({ ) : candidates.length === 0 ? (

In dieser Einheit sind keine Übungen im Ablauf hinterlegt.

) : ( -
+ +
) : null} -
- -
+ + )} diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index 29cd11b..3a72640 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -684,29 +684,6 @@ function TrainingPlanningPageRoot() { } } - const handleDeletePlanTemplate = useCallback( - async (tpl) => { - if (!tpl?.id) return - const label = (tpl.name || '').trim() || `Vorlage #${tpl.id}` - if ( - !window.confirm( - `Trainingsvorlage „${label}“ wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden.` - ) - ) { - return - } - try { - await api.deleteTrainingPlanTemplate(tpl.id) - setDraftPlanTemplateId((prev) => (String(prev) === String(tpl.id) ? '' : prev)) - await loadPlanTemplates() - toast.success('Vorlage gelöscht.') - } catch (err) { - toast.error(err.message || 'Löschen fehlgeschlagen') - } - }, - [loadPlanTemplates, toast] - ) - const openModuleApplyModal = useCallback(async (placement) => { setModuleApplyErr('') setModuleApplySearchQuery('') @@ -995,11 +972,11 @@ function TrainingPlanningPageRoot() { } } - const handleSubmit = async (e) => { - e.preventDefault() + const handleSubmit = async (e, { closeAfter = true } = {}) => { + e?.preventDefault?.() if (!formData.group_id || !formData.planned_date) { toast.error('Gruppe und Datum sind Pflichtfelder') - return + return false } try { const planPart = buildPlanPayloadForSave(formData.sections) @@ -1041,15 +1018,22 @@ function TrainingPlanningPageRoot() { } } + let savedUnit if (editingUnit) { - await api.updateTrainingUnit(editingUnit.id, payload) + savedUnit = await api.updateTrainingUnit(editingUnit.id, payload) } else { - await api.createTrainingUnit(payload) + savedUnit = await api.createTrainingUnit(payload) } - setShowModal(false) await loadUnits() + if (closeAfter) { + setShowModal(false) + } else if (savedUnit?.id) { + await handleEdit({ id: savedUnit.id }) + } + return true } catch (err) { toast.error('Fehler beim Speichern: ' + err.message) + return false } } @@ -1179,7 +1163,7 @@ function TrainingPlanningPageRoot() { const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid) return ( -
+ <>

Trainingsplanung

Wähle eine Trainingsgruppe und lege Trainingseinheiten für den Zeitraum an (Inhalt: Abschnitte - und Übungen). + und Übungen). Rahmenprogramme, Module und Vorlagen erreichst du über die Registerkarten oben.

-
-

- Mehrere Einheiten strukturieren auf einmal:{' '} - - Trainingsrahmenprogramme - {' '} - (Ziele, Sessions, Vorlagen‑Ablauf). -

-

- Wiederverwendbare Blöcke innerhalb einer Einheit:{' '} - - Trainingsmodule - {' '} - (übernahme als Kopie beim Bearbeiten einer Einheit). -

-
{!loading && groups.length === 0 && (
handleSubmit(e, { closeAfter: false })} + onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })} clubDirectory={clubDirectory} clubDirectoryForCo={clubDirectoryForCo} planningModalClubId={planningModalClubId} @@ -2123,7 +2092,7 @@ function TrainingPlanningPageRoot() { peekExtras={planningPeekCtx?.peekExtras ?? undefined} onClose={() => setPlanningPeekCtx(null)} /> -
+ ) } diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx index 4dc7fa1..e20a997 100644 --- a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx @@ -1,9 +1,9 @@ import React, { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' +import FormActionBar from '../FormActionBar' import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel' import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor' import { activeClubMemberships } from '../../utils/activeClub' -import { canDeleteLibraryContent } from '../../utils/libraryContentPermissions' import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers' /** @@ -16,11 +16,12 @@ export default function TrainingPlanningUnitFormModal({ updateFormField, setFormData, onSubmit, + onSaveOnly, + onSaveAndClose, onCancel, draftPlanTemplateId, onDraftTemplateSelect, planTemplates, - onDeletePlanTemplate, clubDirectory, clubDirectoryForCo, planningModalClubId, @@ -53,9 +54,12 @@ export default function TrainingPlanningUnitFormModal({ if (!open) return null + const formId = 'planning-unit-form' + return (
-

+

{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}

+
(onSaveAndClose ? onSaveAndClose(e) : onSubmit?.(e))} + > +
{editingUnit?.origin_framework_slot_id ? (() => { const L = frameworkLineageText(editingUnit) @@ -150,80 +158,11 @@ export default function TrainingPlanningUnitFormModal({

Übernimmt nur die Sektionsstruktur aus der Bibliothek; Übungen trägst du unten bei den - Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern. + Abschnitten ein. Vorlagen verwaltest du unter Planung → Vorlagen.

)} - {planTemplates.length > 0 && typeof onDeletePlanTemplate === 'function' ? ( -
- - Gespeicherte Vorlagen löschen - -

- Entfernen nach Rolle: eigene private Vorlagen; Vereins­inhalte als Vereins­admin; offizielle nur als - Plattform‑Admin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt. -

-
    - {planTemplates.map((t, ti) => { - const canDel = user && canDeleteLibraryContent(user, t) - return ( -
  • - - {t.name} - - ( - {String(t.visibility || 'club').toLowerCase() === 'private' - ? 'Privat' - : String(t.visibility || 'club').toLowerCase() === 'official' - ? 'Offiziell' - : 'Verein'} - ) - - {typeof t.sections_count === 'number' ? ( - - · {t.sections_count} Abschn. - - ) : null} - - {canDel ? ( - - ) : ( - nur Lesen - )} -
  • - ) - })} -
-
- ) : null} - -

Planung

@@ -649,14 +588,17 @@ export default function TrainingPlanningUnitFormModal({ />
-
- - -
+ onSaveOnly() : undefined} + onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined} + onCancel={onCancel} + showSave={Boolean(onSaveOnly)} + showSaveAndClose + />
diff --git a/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx index 40aafd2..6208799 100644 --- a/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx +++ b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useMemo } from 'react' import { useNavigate } from 'react-router-dom' +import FormActionBar from '../FormActionBar' import api from '../../utils/api' import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' @@ -205,22 +206,22 @@ export default function TrainingPublishToFrameworkModal({ }} >
-

Ablauf ins Rahmenprogramm übernehmen

-

+

Ablauf ins Rahmenprogramm übernehmen

+

Es wird der zuletzt gespeicherte Ablauf dieser Einheit aus der Datenbank übernommen. Nicht gespeicherte Änderungen im Formular sind nicht enthalten — bitte vorher die Einheit speichern.

-
+ +
Ziel
@@ -405,14 +406,17 @@ export default function TrainingPublishToFrameworkModal({ />
-
- -
+ +
diff --git a/frontend/src/layouts/PlanningLayout.jsx b/frontend/src/layouts/PlanningLayout.jsx new file mode 100644 index 0000000..c11b1b5 --- /dev/null +++ b/frontend/src/layouts/PlanningLayout.jsx @@ -0,0 +1,14 @@ +import { Outlet } from 'react-router-dom' +import PlanningRouteNav from '../components/planning/PlanningRouteNav' + +/** Gemeinsame Hülle für Planung, Rahmenprogramme, Module und Vorlagen. */ +export default function PlanningLayout() { + return ( +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index 3b226f9..3a85582 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -5,6 +5,7 @@ import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePeekModal from '../components/ExercisePeekModal' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import PageSectionNav from '../components/PageSectionNav' +import FormActionBar from '../components/FormActionBar' import { useToast } from '../context/ToastContext' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' @@ -427,7 +428,7 @@ export default function TrainingFrameworkProgramEditPage() { })) } - const performFrameworkSave = async ({ fromUnsavedDialog = false } = {}) => { + const performFrameworkSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { if (!(form.title || '').trim()) { toast.error('Titel ist Pflichtfeld.') return false @@ -448,7 +449,9 @@ export default function TrainingFrameworkProgramEditPage() { if (isNew) { const created = await api.createTrainingFrameworkProgram(payload) toast.success('Rahmenprogramm angelegt.') - if (!fromUnsavedDialog) { + if (closeAfter) { + navigate('/planning/framework-programs') + } else if (!fromUnsavedDialog) { navigate(`/planning/framework-programs/${created.id}`, { replace: true }) } return true @@ -463,6 +466,7 @@ export default function TrainingFrameworkProgramEditPage() { setBypassDirty(false) setBaselineReady(true) toast.success('Gespeichert.') + if (closeAfter) navigate('/planning/framework-programs') return true } catch (e) { toast.error(e.message || 'Speichern fehlgeschlagen') @@ -473,7 +477,11 @@ export default function TrainingFrameworkProgramEditPage() { } const handleSave = async () => { - await performFrameworkSave({ fromUnsavedDialog: false }) + await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: false }) + } + + const handleSaveAndClose = async () => { + await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: true }) } const handleUnsavedDialogSave = async () => { @@ -1250,19 +1258,19 @@ export default function TrainingFrameworkProgramEditPage() {
-
- - - Abbrechen - - {!isNew ? ( - - ) : null} -
+ ) : null}
+ <>

- Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der{' '} - - Trainingsplanung - - . + Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der + Trainingsplanung (Registerkarte oben).

Mehr zur Übernahme in die Planung @@ -131,12 +128,6 @@ export default function TrainingFrameworkProgramsListPage() {
-

- - ← Zurück zur Trainingsplanung - -

- {error && (
{error} @@ -207,6 +198,6 @@ export default function TrainingFrameworkProgramsListPage() { ))} )} -
+ ) } diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx index 5797e02..064897d 100644 --- a/frontend/src/pages/TrainingModuleEditPage.jsx +++ b/frontend/src/pages/TrainingModuleEditPage.jsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' +import FormActionBar from '../components/FormActionBar' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { useAuth } from '../context/AuthContext' import { useToast } from '../context/ToastContext' @@ -301,7 +302,7 @@ export default function TrainingModuleEditPage() { } } - const performModuleSave = async ({ fromUnsavedDialog = false } = {}) => { + const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { if (!title.trim()) { toast.error('Titel ist Pflicht.') return false @@ -313,7 +314,9 @@ export default function TrainingModuleEditPage() { if (isNew) { const created = await api.createTrainingModule(body) toast.success('Trainingsmodul angelegt.') - if (!fromUnsavedDialog) { + if (closeAfter) { + navigate('/planning/training-modules') + } else if (!fromUnsavedDialog) { navigate(`/planning/training-modules/${created.id}`, { replace: true }) } return true @@ -322,6 +325,7 @@ export default function TrainingModuleEditPage() { baselineRef.current = moduleFormSnapshot(latestFormRef.current) setBypassDirty(false) toast.success('Gespeichert.') + if (closeAfter) navigate('/planning/training-modules') return true } catch (err) { const msg = err.message || 'Speichern fehlgeschlagen' @@ -334,8 +338,12 @@ export default function TrainingModuleEditPage() { } const handleSave = async (e) => { - e.preventDefault() - await performModuleSave({ fromUnsavedDialog: false }) + e?.preventDefault?.() + await performModuleSave({ fromUnsavedDialog: false, closeAfter: false }) + } + + const handleSaveAndClose = async () => { + await performModuleSave({ fromUnsavedDialog: false, closeAfter: true }) } const handleUnsavedDialogSave = async () => { @@ -367,7 +375,13 @@ export default function TrainingModuleEditPage() { {loading ? (

Laden …

) : ( -
+ +
setTitle(e.target.value)} /> @@ -648,14 +662,17 @@ export default function TrainingModuleEditPage() { ))} -
- - - Abbrechen -
+ + handleSave()} + onSaveAndClose={handleSaveAndClose} + onCancel={() => navigate('/planning/training-modules')} + cancelLabel="Abbrechen" + /> )} diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index fc04a0d..41e0b34 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -40,7 +40,7 @@ export default function TrainingModulesListPage() { } return ( -
+ <>

- Wiederverwendbare Übungsfolgen für die{' '} - - Trainingsplanung - - . Übernahme in eine Einheit erfolgt dort als lokale Kopie (mit Herkunftsmarkierung). + Wiederverwendbare Übungsfolgen für die Trainingsplanung. Übernahme in eine Einheit erfolgt dort als + lokale Kopie (mit Herkunftsmarkierung).

@@ -130,6 +127,6 @@ export default function TrainingModulesListPage() { ))} )} -
+ ) } diff --git a/frontend/src/pages/TrainingPlanTemplatesListPage.jsx b/frontend/src/pages/TrainingPlanTemplatesListPage.jsx new file mode 100644 index 0000000..853091f --- /dev/null +++ b/frontend/src/pages/TrainingPlanTemplatesListPage.jsx @@ -0,0 +1,150 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import api from '../utils/api' +import { useAuth } from '../context/AuthContext' +import { useToast } from '../context/ToastContext' +import { getTenantClubDependencyKey } from '../utils/activeClub' +import { canDeleteLibraryContent } from '../utils/libraryContentPermissions' + +function visibilityLabel(v) { + const x = String(v || 'club').toLowerCase() + if (x === 'private') return 'Privat' + if (x === 'official') return 'Offiziell' + return 'Verein' +} + +export default function TrainingPlanTemplatesListPage() { + const { user } = useAuth() + const toast = useToast() + const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const load = useCallback(async () => { + setLoading(true) + setError('') + try { + const list = await api.listTrainingPlanTemplates() + setRows(Array.isArray(list) ? list : []) + } catch (e) { + setError(e.message || 'Laden fehlgeschlagen') + setRows([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + }, [load, tenantClubDepKey]) + + async function handleDelete(tpl) { + if (!tpl?.id) return + const label = (tpl.name || '').trim() || `Vorlage #${tpl.id}` + if ( + !window.confirm( + `Trainingsvorlage „${label}“ wirklich löschen? Einheiten mit Verweis behalten ihren Ablauf; die Vorlage wird entkoppelt.` + ) + ) { + return + } + try { + await api.deleteTrainingPlanTemplate(tpl.id) + await load() + toast.success('Vorlage gelöscht.') + } catch (e) { + toast.error(e.message || 'Löschen fehlgeschlagen') + } + } + + return ( + <> +
+
+

+ Trainingsvorlagen +

+

+ Mikrovorlagen für die Sektions-Gliederung einer Einheit (ohne Übungen). Neue Vorlagen + legst du beim Bearbeiten einer Trainingseinheit über „Vorlage aus Aufbau speichern“ an. +

+
+
+ + {error ?

{error}

: null} + + {loading ? ( +

Laden …

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

+ Noch keine Vorlagen gespeichert. Öffne unter Trainingsplanung eine Einheit, strukturiere + die Abschnitte und nutze dort „Vorlage aus Aufbau speichern“. +

+
+ ) : ( +
    + {rows.map((t) => { + const canDel = user && canDeleteLibraryContent(user, t) + return ( +
  • +
    +
    + + {(t.name || '').trim() || `Vorlage #${t.id}`} + +
    + {visibilityLabel(t.visibility)} + {typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''} + {t.updated_at ? ( + + {' '} + · aktualisiert{' '} + {String(t.updated_at).slice(0, 10)} + + ) : null} +
    +
    + {canDel ? ( + + ) : ( + nur Lesen + )} +
    +
  • + ) + })} +
+ )} + +

+ Löschen nach Rolle: eigene private Vorlagen; Vereinsinhalte als Vereinsadmin; offizielle nur als + Plattform-Admin. +

+ + ) +}