Implement planning layout and enhance training module functionality
Some checks failed
Deploy Development / deploy (push) Failing after 23s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 6s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Has been cancelled

- 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.
This commit is contained in:
Lars 2026-05-19 10:02:39 +02:00
parent 82705f0c3e
commit 4fee5a2b47
14 changed files with 525 additions and 207 deletions

View File

@ -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: <ClubsPage /> },
{ path: 'inbox', element: <InboxPage /> },
{ path: 'skills', element: <SkillsPage /> },
{
path: 'planning',
element: <PlanningLayout />,
children: [
{ index: true, element: <TrainingPlanningPage /> },
{ path: 'framework-programs', element: <TrainingFrameworkProgramsListPage /> },
{ path: 'training-modules', element: <TrainingModulesListPage /> },
{ path: 'plan-templates', element: <TrainingPlanTemplatesListPage /> },
],
},
{ path: 'planning/framework-programs/new', element: <TrainingFrameworkProgramEditPage /> },
{ path: 'planning/framework-programs/:id', element: <TrainingFrameworkProgramEditPage /> },
{ path: 'planning/framework-programs', element: <TrainingFrameworkProgramsListPage /> },
{ path: 'planning/training-modules/new', element: <TrainingModuleEditPage /> },
{ path: 'planning/training-modules/:id', element: <TrainingModuleEditPage /> },
{ path: 'planning/training-modules', element: <TrainingModulesListPage /> },
{ path: 'planning/run/:unitId/coach', element: <TrainingCoachPage /> },
{ path: 'planning/run/:unitId', element: <TrainingUnitRunPage /> },
{ path: 'planning', element: <TrainingPlanningPage /> },
{ path: 'admin', element: <AdminHomeRedirect /> },
{
path: 'admin/users',

View File

@ -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%;

View File

@ -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 (
<div
className={`form-action-bar form-action-bar--${placement} form-action-bar--${variant}`}
role="group"
aria-label="Formularaktionen"
>
<div className="form-action-bar__inner">
{showCancelBtn ? (
<button
type="button"
className="btn btn-secondary form-action-bar__btn form-action-bar__btn--cancel"
onClick={onCancel}
disabled={saving}
>
{cancelLabel}
</button>
) : (
<span className="form-action-bar__spacer" aria-hidden />
)}
<div className="form-action-bar__primary-group">
{showSaveBtn ? (
<button
type={formId && !onSave ? 'submit' : 'button'}
form={formId && !onSave ? formId : undefined}
className={saveBtnClass}
disabled={saving}
onClick={onSave || undefined}
>
{saving ? 'Speichern…' : labelSave}
</button>
) : null}
{showCloseBtn ? (
<button
type={formId && !onSaveAndClose ? 'submit' : 'button'}
form={formId && !onSaveAndClose ? formId : undefined}
className={closeBtnClass}
disabled={saving}
onClick={onSaveAndClose || undefined}
>
{saving ? 'Speichern…' : saveAndCloseLabel}
</button>
) : null}
</div>
</div>
</div>
)
}

View File

@ -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 (
<nav
className="admin-page-subtabs page-section-nav page-section-nav--wrap planning-route-nav"
aria-label="Planung"
>
{ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
'admin-page-subtabs__btn' + (isActive ? ' admin-page-subtabs__btn--active' : '')
}
>
<span>{item.label}</span>
</NavLink>
))}
</nav>
)
}

View File

@ -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({
}}
>
<div
className="card"
className="card modal-panel--form"
style={{
maxWidth: 'min(560px, 100%)',
width: '100%',
padding: '1.25rem',
maxHeight: '90vh',
overflowY: 'auto',
}}
>
<h2 style={{ marginTop: 0, marginBottom: '0.65rem' }}>Übungen als Trainingsmodul</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0, marginBottom: '0.65rem', flexShrink: 0 }}>Übungen als Trainingsmodul</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem', flexShrink: 0 }}>
Es werden die <strong>gespeicherten</strong> Übungspositionen der Einheit vom{' '}
<strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand
brauchst.
@ -183,7 +183,8 @@ export default function SaveExercisesAsModuleModal({
) : candidates.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>In dieser Einheit sind keine Übungen im Ablauf hinterlegt.</p>
) : (
<form onSubmit={handleSubmit}>
<form id="save-module-form" className="modal-form-shell" onSubmit={handleSubmit}>
<div className="modal-form-shell__body">
<div style={{ marginBottom: '1rem' }}>
<label className="form-label">Modultitel</label>
<input
@ -286,14 +287,17 @@ export default function SaveExercisesAsModuleModal({
</div>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={submitting || !candidates.length}>
{submitting ? 'Speichern…' : 'Modul anlegen'}
</button>
</div>
<FormActionBar
placement="bottom"
variant="modal"
formId="save-module-form"
saving={submitting}
showSave={false}
saveAndCloseLabel="Modul anlegen"
onCancel={onClose}
/>
</form>
)}

View File

@ -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 (
<div className="app-page">
<>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsplanung</h1>
<div
@ -1232,25 +1216,9 @@ function TrainingPlanningPageRoot() {
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem' }}>
Wähle eine Trainingsgruppe und lege <strong>Trainingseinheiten</strong> für den Zeitraum an (Inhalt: Abschnitte
und Übungen).
und Übungen). Rahmenprogramme, Module und Vorlagen erreichst du über die Registerkarten oben.
</p>
<div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.92rem', color: 'var(--text2)' }}>
Mehrere Einheiten strukturieren auf einmal:{' '}
<Link to="/planning/framework-programs" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsrahmenprogramme
</Link>{' '}
(Ziele, Sessions, VorlagenAblauf).
</p>
<p style={{ margin: 0, fontSize: '0.92rem', color: 'var(--text2)' }}>
Wiederverwendbare Blöcke innerhalb einer Einheit:{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
(übernahme als Kopie beim Bearbeiten einer Einheit).
</p>
</div>
{!loading && groups.length === 0 && (
<div
className="card"
@ -2023,7 +1991,8 @@ function TrainingPlanningPageRoot() {
draftPlanTemplateId={draftPlanTemplateId}
onDraftTemplateSelect={applyTemplateFromSelect}
planTemplates={planTemplates}
onDeletePlanTemplate={handleDeletePlanTemplate}
onSaveOnly={(e) => 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)}
/>
</div>
</>
)
}

View File

@ -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 (
<div
data-testid="planning-unit-form-modal"
className="modal-overlay"
style={{
position: 'fixed',
top: 0,
@ -68,27 +72,31 @@ export default function TrainingPlanningUnitFormModal({
justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
className="modal-panel--form"
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(12px, 3vw, 2rem)',
maxWidth: 'min(1100px, 100%)',
width: '100%',
maxHeight: '92vh',
overflowY: 'auto',
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '1rem' }}>
<h2 style={{ marginBottom: '1rem', flexShrink: 0 }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h2>
<form
id={formId}
className="modal-form-shell"
onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSubmit?.(e))}
>
<div className="modal-form-shell__body">
{editingUnit?.origin_framework_slot_id
? (() => {
const L = frameworkLineageText(editingUnit)
@ -150,80 +158,11 @@ export default function TrainingPlanningUnitFormModal({
</select>
<p className="training-planning-template-panel__help">
Übernimmt nur die <strong>Sektionsstruktur</strong> 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 <Link to="/planning/plan-templates">Planung Vorlagen</Link>.
</p>
</div>
)}
{planTemplates.length > 0 && typeof onDeletePlanTemplate === 'function' ? (
<details
className="card"
style={{
marginBottom: '1.35rem',
padding: '12px 14px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text1)' }}>
Gespeicherte Vorlagen löschen
</summary>
<p style={{ margin: '0.65rem 0 0.75rem', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.45 }}>
Entfernen nach Rolle: eigene private Vorlagen; Vereins­inhalte als Vereins­admin; offizielle nur als
PlattformAdmin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt.
</p>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{planTemplates.map((t, ti) => {
const canDel = user && canDeleteLibraryContent(user, t)
return (
<li
key={t.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '10px',
padding: '8px 0',
borderTop: ti === 0 ? 'none' : '1px solid var(--border)',
}}
>
<span style={{ minWidth: 0, flex: 1, fontSize: '0.9rem' }}>
<strong style={{ color: 'var(--text1)' }}>{t.name}</strong>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', marginLeft: '6px' }}>
(
{String(t.visibility || 'club').toLowerCase() === 'private'
? 'Privat'
: String(t.visibility || 'club').toLowerCase() === 'official'
? 'Offiziell'
: 'Verein'}
)
</span>
{typeof t.sections_count === 'number' ? (
<span style={{ fontSize: '0.82rem', color: 'var(--text2)', marginLeft: '6px' }}>
· {t.sections_count} Abschn.
</span>
) : null}
</span>
{canDel ? (
<button
type="button"
className="btn btn-danger"
style={{ flexShrink: 0, padding: '6px 12px', fontSize: '0.82rem' }}
onClick={() => onDeletePlanTemplate(t)}
>
Löschen
</button>
) : (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', flexShrink: 0 }}>nur Lesen</span>
)}
</li>
)
})}
</ul>
</details>
) : null}
<form onSubmit={onSubmit}>
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
@ -649,14 +588,17 @@ export default function TrainingPlanningUnitFormModal({
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
{editingUnit ? 'Speichern' : 'Erstellen'}
</button>
<button type="button" className="btn btn-secondary" onClick={onCancel}>
Abbrechen
</button>
</div>
<FormActionBar
placement="bottom"
variant="modal"
formId={formId}
isNew={!editingUnit}
onSave={onSaveOnly ? () => onSaveOnly() : undefined}
onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined}
onCancel={onCancel}
showSave={Boolean(onSaveOnly)}
showSaveAndClose
/>
</form>
</div>
</div>

View File

@ -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({
}}
>
<div
className="card"
className="card modal-panel--form"
style={{
maxWidth: 'min(520px, 100%)',
width: '100%',
padding: '1.25rem',
maxHeight: '90vh',
overflowY: 'auto',
}}
>
<h2 style={{ marginTop: 0, marginBottom: '0.65rem' }}>Ablauf ins Rahmenprogramm übernehmen</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0, marginBottom: '0.65rem', flexShrink: 0 }}>Ablauf ins Rahmenprogramm übernehmen</h2>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem', flexShrink: 0 }}>
Es wird der <strong>zuletzt gespeicherte</strong> Ablauf dieser Einheit aus der Datenbank übernommen.
Nicht gespeicherte Änderungen im Formular sind nicht enthalten bitte vorher die Einheit speichern.
</p>
<form onSubmit={handleSubmit}>
<form id="publish-framework-form" className="modal-form-shell" onSubmit={handleSubmit}>
<div className="modal-form-shell__body">
<div style={{ marginBottom: '1rem' }}>
<span className="form-label">Ziel</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginTop: '0.35rem' }}>
@ -405,14 +406,17 @@ export default function TrainingPublishToFrameworkModal({
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={resetAndClose} disabled={submitting}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Speichern…' : 'In Rahmen übernehmen'}
</button>
</div>
<FormActionBar
placement="bottom"
variant="modal"
formId="publish-framework-form"
saving={submitting}
showSave={false}
saveAndCloseLabel="In Rahmen übernehmen"
onCancel={resetAndClose}
/>
</form>
</div>
</div>

View File

@ -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 (
<div className="app-page planning-layout">
<PlanningRouteNav />
<div className="planning-layout__main">
<Outlet />
</div>
</div>
)
}

View File

@ -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() {
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'center' }}>
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave}>
{saving ? 'Speichern…' : isNew ? 'Anlegen' : 'Speichern'}
<FormActionBar
isNew={isNew}
saving={saving}
onSave={handleSave}
onSaveAndClose={handleSaveAndClose}
onCancel={() => navigate('/planning/framework-programs')}
cancelLabel="Abbrechen"
/>
{!isNew ? (
<button type="button" className="btn btn-secondary" onClick={handleDelete} style={{ marginTop: '10px' }}>
Löschen
</button>
<Link to="/planning/framework-programs" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Abbrechen
</Link>
{!isNew ? (
<button type="button" className="btn btn-secondary" onClick={handleDelete}>
Löschen
</button>
) : null}
</div>
) : null}
</div>
<ExercisePickerModal

View File

@ -92,7 +92,7 @@ export default function TrainingFrameworkProgramsListPage() {
}
return (
<div className="app-page">
<>
<div
style={{
display: 'flex',
@ -108,11 +108,8 @@ export default function TrainingFrameworkProgramsListPage() {
Trainingsrahmenprogramme
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
Vorlagen für Ziele und Sessions die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
.
Vorlagen für Ziele und Sessions die Verknüpfung mit Gruppenterminen erfolgt in der
Trainingsplanung (Registerkarte oben).
</p>
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
@ -131,12 +128,6 @@ export default function TrainingFrameworkProgramsListPage() {
</Link>
</div>
<p style={{ marginBottom: '1rem' }}>
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Zurück zur Trainingsplanung
</Link>
</p>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
{error}
@ -207,6 +198,6 @@ export default function TrainingFrameworkProgramsListPage() {
))}
</ul>
)}
</div>
</>
)
}

View File

@ -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 ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : (
<form className="card" style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }} onSubmit={handleSave}>
<form
id="training-module-form"
className="card page-form-shell"
style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }}
onSubmit={handleSave}
>
<div className="page-form-shell__scroll">
<div className="form-row">
<label className="form-label">Titel *</label>
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
@ -648,14 +662,17 @@ export default function TrainingModuleEditPage() {
))}
</ul>
<div style={{ display: 'flex', gap: '10px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern …' : isNew ? 'Anlegen' : 'Speichern'}
</button>
<Link to="/planning/training-modules" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Abbrechen
</Link>
</div>
<FormActionBar
formId="training-module-form"
isNew={isNew}
saving={saving}
onSave={() => handleSave()}
onSaveAndClose={handleSaveAndClose}
onCancel={() => navigate('/planning/training-modules')}
cancelLabel="Abbrechen"
/>
</form>
)}

View File

@ -40,7 +40,7 @@ export default function TrainingModulesListPage() {
}
return (
<div className="app-page">
<>
<div
style={{
display: 'flex',
@ -56,11 +56,8 @@ export default function TrainingModulesListPage() {
Trainingsmodule
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
Wiederverwendbare Übungsfolgen für die{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
. Ü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).
</p>
</div>
<Link to="/planning/training-modules/new" className="btn btn-primary" style={{ textDecoration: 'none' }}>
@ -130,6 +127,6 @@ export default function TrainingModulesListPage() {
))}
</ul>
)}
</div>
</>
)
}

View File

@ -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 (
<>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '1rem',
marginBottom: '1.25rem',
}}
>
<div>
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsvorlagen
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '40rem', margin: 0, lineHeight: 1.5 }}>
Mikrovorlagen für die <strong>Sektions-Gliederung</strong> einer Einheit (ohne Übungen). Neue Vorlagen
legst du beim Bearbeiten einer Trainingseinheit über Vorlage aus Aufbau speichern an.
</p>
</div>
</div>
{error ? <p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p> : null}
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : rows.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0, color: 'var(--text2)', lineHeight: 1.5 }}>
Noch keine Vorlagen gespeichert. Öffne unter <strong>Trainingsplanung</strong> eine Einheit, strukturiere
die Abschnitte und nutze dort Vorlage aus Aufbau speichern.
</p>
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: '0.75rem' }}>
{rows.map((t) => {
const canDel = user && canDeleteLibraryContent(user, t)
return (
<li key={t.id} className="card" style={{ padding: '14px 16px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div style={{ minWidth: 0, flex: '1 1 200px' }}>
<strong style={{ color: 'var(--text1)', fontSize: '1rem' }}>
{(t.name || '').trim() || `Vorlage #${t.id}`}
</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '4px' }}>
{visibilityLabel(t.visibility)}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
{t.updated_at ? (
<span style={{ color: 'var(--text3)' }}>
{' '}
· aktualisiert{' '}
{String(t.updated_at).slice(0, 10)}
</span>
) : null}
</div>
</div>
{canDel ? (
<button
type="button"
className="btn btn-secondary"
style={{ flexShrink: 0, color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDelete(t)}
>
Löschen
</button>
) : (
<span style={{ fontSize: '0.82rem', color: 'var(--text3)', flexShrink: 0 }}>nur Lesen</span>
)}
</div>
</li>
)
})}
</ul>
)}
<p style={{ marginTop: '1.25rem', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45, maxWidth: '40rem' }}>
Löschen nach Rolle: eigene private Vorlagen; Vereinsinhalte als Vereinsadmin; offizielle nur als
Plattform-Admin.
</p>
</>
)
}