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 InboxPage = lazy(() => import('./pages/InboxPage'))
const SkillsPage = lazy(() => import('./pages/SkillsPage')) const SkillsPage = lazy(() => import('./pages/SkillsPage'))
const TrainingPlanningPage = lazy(() => import('./pages/TrainingPlanningPage')) const TrainingPlanningPage = lazy(() => import('./pages/TrainingPlanningPage'))
const TrainingPlanTemplatesListPage = lazy(() => import('./pages/TrainingPlanTemplatesListPage'))
const PlanningLayout = lazy(() => import('./layouts/PlanningLayout'))
const TrainingFrameworkProgramsListPage = lazy(() => const TrainingFrameworkProgramsListPage = lazy(() =>
import('./pages/TrainingFrameworkProgramsListPage'), import('./pages/TrainingFrameworkProgramsListPage'),
) )
@ -223,15 +225,22 @@ const appRouter = createBrowserRouter([
{ path: 'clubs', element: <ClubsPage /> }, { path: 'clubs', element: <ClubsPage /> },
{ path: 'inbox', element: <InboxPage /> }, { path: 'inbox', element: <InboxPage /> },
{ path: 'skills', element: <SkillsPage /> }, { 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/new', element: <TrainingFrameworkProgramEditPage /> },
{ path: 'planning/framework-programs/:id', 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/new', element: <TrainingModuleEditPage /> },
{ path: 'planning/training-modules/:id', 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/coach', element: <TrainingCoachPage /> },
{ path: 'planning/run/:unitId', element: <TrainingUnitRunPage /> }, { path: 'planning/run/:unitId', element: <TrainingUnitRunPage /> },
{ path: 'planning', element: <TrainingPlanningPage /> },
{ path: 'admin', element: <AdminHomeRedirect /> }, { path: 'admin', element: <AdminHomeRedirect /> },
{ {
path: 'admin/users', path: 'admin/users',

View File

@ -1155,6 +1155,106 @@ a.analysis-split__nav-item {
min-width: 0; 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 */ /* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */
.settings-shell { .settings-shell {
width: 100%; 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 React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import FormActionBar from '../FormActionBar'
import api from '../../utils/api' 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'
@ -160,17 +161,16 @@ export default function SaveExercisesAsModuleModal({
}} }}
> >
<div <div
className="card" className="card modal-panel--form"
style={{ style={{
maxWidth: 'min(560px, 100%)', maxWidth: 'min(560px, 100%)',
width: '100%', width: '100%',
padding: '1.25rem', padding: '1.25rem',
maxHeight: '90vh', maxHeight: '90vh',
overflowY: 'auto',
}} }}
> >
<h2 style={{ marginTop: 0, marginBottom: '0.65rem' }}>Übungen als Trainingsmodul</h2> <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' }}> <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{' '} Es werden die <strong>gespeicherten</strong> Übungspositionen der Einheit vom{' '}
<strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand <strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand
brauchst. brauchst.
@ -183,7 +183,8 @@ export default function SaveExercisesAsModuleModal({
) : candidates.length === 0 ? ( ) : candidates.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>In dieser Einheit sind keine Übungen im Ablauf hinterlegt.</p> <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' }}> <div style={{ marginBottom: '1rem' }}>
<label className="form-label">Modultitel</label> <label className="form-label">Modultitel</label>
<input <input
@ -286,14 +287,17 @@ export default function SaveExercisesAsModuleModal({
</div> </div>
) : null} ) : 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> </div>
<FormActionBar
placement="bottom"
variant="modal"
formId="save-module-form"
saving={submitting}
showSave={false}
saveAndCloseLabel="Modul anlegen"
onCancel={onClose}
/>
</form> </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) => { const openModuleApplyModal = useCallback(async (placement) => {
setModuleApplyErr('') setModuleApplyErr('')
setModuleApplySearchQuery('') setModuleApplySearchQuery('')
@ -995,11 +972,11 @@ function TrainingPlanningPageRoot() {
} }
} }
const handleSubmit = async (e) => { const handleSubmit = async (e, { closeAfter = true } = {}) => {
e.preventDefault() e?.preventDefault?.()
if (!formData.group_id || !formData.planned_date) { if (!formData.group_id || !formData.planned_date) {
toast.error('Gruppe und Datum sind Pflichtfelder') toast.error('Gruppe und Datum sind Pflichtfelder')
return return false
} }
try { try {
const planPart = buildPlanPayloadForSave(formData.sections) const planPart = buildPlanPayloadForSave(formData.sections)
@ -1041,15 +1018,22 @@ function TrainingPlanningPageRoot() {
} }
} }
let savedUnit
if (editingUnit) { if (editingUnit) {
await api.updateTrainingUnit(editingUnit.id, payload) savedUnit = await api.updateTrainingUnit(editingUnit.id, payload)
} else { } else {
await api.createTrainingUnit(payload) savedUnit = await api.createTrainingUnit(payload)
} }
setShowModal(false)
await loadUnits() await loadUnits()
if (closeAfter) {
setShowModal(false)
} else if (savedUnit?.id) {
await handleEdit({ id: savedUnit.id })
}
return true
} catch (err) { } catch (err) {
toast.error('Fehler beim Speichern: ' + err.message) toast.error('Fehler beim Speichern: ' + err.message)
return false
} }
} }
@ -1179,7 +1163,7 @@ function TrainingPlanningPageRoot() {
const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid) const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid)
return ( return (
<div className="app-page"> <>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsplanung</h1> <h1 style={{ marginBottom: '0.35rem' }}>Trainingsplanung</h1>
<div <div
@ -1232,25 +1216,9 @@ function TrainingPlanningPageRoot() {
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem' }}> <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 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> </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 && ( {!loading && groups.length === 0 && (
<div <div
className="card" className="card"
@ -2023,7 +1991,8 @@ function TrainingPlanningPageRoot() {
draftPlanTemplateId={draftPlanTemplateId} draftPlanTemplateId={draftPlanTemplateId}
onDraftTemplateSelect={applyTemplateFromSelect} onDraftTemplateSelect={applyTemplateFromSelect}
planTemplates={planTemplates} planTemplates={planTemplates}
onDeletePlanTemplate={handleDeletePlanTemplate} onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })}
onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })}
clubDirectory={clubDirectory} clubDirectory={clubDirectory}
clubDirectoryForCo={clubDirectoryForCo} clubDirectoryForCo={clubDirectoryForCo}
planningModalClubId={planningModalClubId} planningModalClubId={planningModalClubId}
@ -2123,7 +2092,7 @@ function TrainingPlanningPageRoot() {
peekExtras={planningPeekCtx?.peekExtras ?? undefined} peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)} onClose={() => setPlanningPeekCtx(null)}
/> />
</div> </>
) )
} }

View File

@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import FormActionBar from '../FormActionBar'
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel' import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor' import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
import { activeClubMemberships } from '../../utils/activeClub' import { activeClubMemberships } from '../../utils/activeClub'
import { canDeleteLibraryContent } from '../../utils/libraryContentPermissions'
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers' import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
/** /**
@ -16,11 +16,12 @@ export default function TrainingPlanningUnitFormModal({
updateFormField, updateFormField,
setFormData, setFormData,
onSubmit, onSubmit,
onSaveOnly,
onSaveAndClose,
onCancel, onCancel,
draftPlanTemplateId, draftPlanTemplateId,
onDraftTemplateSelect, onDraftTemplateSelect,
planTemplates, planTemplates,
onDeletePlanTemplate,
clubDirectory, clubDirectory,
clubDirectoryForCo, clubDirectoryForCo,
planningModalClubId, planningModalClubId,
@ -53,9 +54,12 @@ export default function TrainingPlanningUnitFormModal({
if (!open) return null if (!open) return null
const formId = 'planning-unit-form'
return ( return (
<div <div
data-testid="planning-unit-form-modal" data-testid="planning-unit-form-modal"
className="modal-overlay"
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, top: 0,
@ -68,27 +72,31 @@ export default function TrainingPlanningUnitFormModal({
justifyContent: 'center', justifyContent: 'center',
zIndex: 1000, zIndex: 1000,
padding: '1rem', padding: '1rem',
overflowY: 'auto',
}} }}
> >
<div <div
className="modal-panel--form"
style={{ style={{
background: 'var(--surface)', background: 'var(--surface)',
borderRadius: '12px', borderRadius: '12px',
padding: 'clamp(12px, 3vw, 2rem)', padding: 'clamp(12px, 3vw, 2rem)',
maxWidth: 'min(1100px, 100%)', maxWidth: 'min(1100px, 100%)',
width: '100%', width: '100%',
maxHeight: '92vh',
overflowY: 'auto',
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto', margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
boxSizing: 'border-box', boxSizing: 'border-box',
minWidth: 0, minWidth: 0,
}} }}
> >
<h2 style={{ marginBottom: '1rem' }}> <h2 style={{ marginBottom: '1rem', flexShrink: 0 }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'} {editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h2> </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 {editingUnit?.origin_framework_slot_id
? (() => { ? (() => {
const L = frameworkLineageText(editingUnit) const L = frameworkLineageText(editingUnit)
@ -150,80 +158,11 @@ export default function TrainingPlanningUnitFormModal({
</select> </select>
<p className="training-planning-template-panel__help"> <p className="training-planning-template-panel__help">
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den Ü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> </p>
</div> </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> <h3 style={{ marginBottom: '1rem' }}>Planung</h3>
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}> <div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
@ -649,14 +588,17 @@ export default function TrainingPlanningUnitFormModal({
/> />
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}> <FormActionBar
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}> placement="bottom"
{editingUnit ? 'Speichern' : 'Erstellen'} variant="modal"
</button> formId={formId}
<button type="button" className="btn btn-secondary" onClick={onCancel}> isNew={!editingUnit}
Abbrechen onSave={onSaveOnly ? () => onSaveOnly() : undefined}
</button> onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined}
</div> onCancel={onCancel}
showSave={Boolean(onSaveOnly)}
showSaveAndClose
/>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react' import React, { useEffect, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import FormActionBar from '../FormActionBar'
import api from '../../utils/api' 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'
@ -205,22 +206,22 @@ export default function TrainingPublishToFrameworkModal({
}} }}
> >
<div <div
className="card" className="card modal-panel--form"
style={{ style={{
maxWidth: 'min(520px, 100%)', maxWidth: 'min(520px, 100%)',
width: '100%', width: '100%',
padding: '1.25rem', padding: '1.25rem',
maxHeight: '90vh', maxHeight: '90vh',
overflowY: 'auto',
}} }}
> >
<h2 style={{ marginTop: 0, marginBottom: '0.65rem' }}>Ablauf ins Rahmenprogramm übernehmen</h2> <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' }}> <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. 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. Nicht gespeicherte Änderungen im Formular sind nicht enthalten bitte vorher die Einheit speichern.
</p> </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' }}> <div style={{ marginBottom: '1rem' }}>
<span className="form-label">Ziel</span> <span className="form-label">Ziel</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginTop: '0.35rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginTop: '0.35rem' }}>
@ -405,14 +406,17 @@ export default function TrainingPublishToFrameworkModal({
/> />
</div> </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> </div>
<FormActionBar
placement="bottom"
variant="modal"
formId="publish-framework-form"
saving={submitting}
showSave={false}
saveAndCloseLabel="In Rahmen übernehmen"
onCancel={resetAndClose}
/>
</form> </form>
</div> </div>
</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 ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import PageSectionNav from '../components/PageSectionNav' import PageSectionNav from '../components/PageSectionNav'
import FormActionBar from '../components/FormActionBar'
import { useToast } from '../context/ToastContext' 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'
@ -427,7 +428,7 @@ export default function TrainingFrameworkProgramEditPage() {
})) }))
} }
const performFrameworkSave = async ({ fromUnsavedDialog = false } = {}) => { const performFrameworkSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
if (!(form.title || '').trim()) { if (!(form.title || '').trim()) {
toast.error('Titel ist Pflichtfeld.') toast.error('Titel ist Pflichtfeld.')
return false return false
@ -448,7 +449,9 @@ export default function TrainingFrameworkProgramEditPage() {
if (isNew) { if (isNew) {
const created = await api.createTrainingFrameworkProgram(payload) const created = await api.createTrainingFrameworkProgram(payload)
toast.success('Rahmenprogramm angelegt.') toast.success('Rahmenprogramm angelegt.')
if (!fromUnsavedDialog) { if (closeAfter) {
navigate('/planning/framework-programs')
} else if (!fromUnsavedDialog) {
navigate(`/planning/framework-programs/${created.id}`, { replace: true }) navigate(`/planning/framework-programs/${created.id}`, { replace: true })
} }
return true return true
@ -463,6 +466,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')
return true return true
} catch (e) { } catch (e) {
toast.error(e.message || 'Speichern fehlgeschlagen') toast.error(e.message || 'Speichern fehlgeschlagen')
@ -473,7 +477,11 @@ export default function TrainingFrameworkProgramEditPage() {
} }
const handleSave = async () => { 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 () => { const handleUnsavedDialogSave = async () => {
@ -1250,19 +1258,19 @@ export default function TrainingFrameworkProgramEditPage() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'center' }}> <FormActionBar
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave}> isNew={isNew}
{saving ? 'Speichern…' : isNew ? 'Anlegen' : 'Speichern'} 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> </button>
<Link to="/planning/framework-programs" className="btn btn-secondary" style={{ textDecoration: 'none' }}> ) : null}
Abbrechen
</Link>
{!isNew ? (
<button type="button" className="btn btn-secondary" onClick={handleDelete}>
Löschen
</button>
) : null}
</div>
</div> </div>
<ExercisePickerModal <ExercisePickerModal

View File

@ -92,7 +92,7 @@ export default function TrainingFrameworkProgramsListPage() {
} }
return ( return (
<div className="app-page"> <>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@ -108,11 +108,8 @@ export default function TrainingFrameworkProgramsListPage() {
Trainingsrahmenprogramme Trainingsrahmenprogramme
</h1> </h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}> <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{' '} 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 (Registerkarte oben).
Trainingsplanung
</Link>
.
</p> </p>
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}> <details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary> <summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
@ -131,12 +128,6 @@ export default function TrainingFrameworkProgramsListPage() {
</Link> </Link>
</div> </div>
<p style={{ marginBottom: '1rem' }}>
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Zurück zur Trainingsplanung
</Link>
</p>
{error && ( {error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}> <div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
{error} {error}
@ -207,6 +198,6 @@ export default function TrainingFrameworkProgramsListPage() {
))} ))}
</ul> </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 { Link, 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 { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext' 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()) { if (!title.trim()) {
toast.error('Titel ist Pflicht.') toast.error('Titel ist Pflicht.')
return false return false
@ -313,7 +314,9 @@ export default function TrainingModuleEditPage() {
if (isNew) { if (isNew) {
const created = await api.createTrainingModule(body) const created = await api.createTrainingModule(body)
toast.success('Trainingsmodul angelegt.') toast.success('Trainingsmodul angelegt.')
if (!fromUnsavedDialog) { if (closeAfter) {
navigate('/planning/training-modules')
} else if (!fromUnsavedDialog) {
navigate(`/planning/training-modules/${created.id}`, { replace: true }) navigate(`/planning/training-modules/${created.id}`, { replace: true })
} }
return true return true
@ -322,6 +325,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')
return true return true
} catch (err) { } catch (err) {
const msg = err.message || 'Speichern fehlgeschlagen' const msg = err.message || 'Speichern fehlgeschlagen'
@ -334,8 +338,12 @@ export default function TrainingModuleEditPage() {
} }
const handleSave = async (e) => { const handleSave = async (e) => {
e.preventDefault() e?.preventDefault?.()
await performModuleSave({ fromUnsavedDialog: false }) await performModuleSave({ fromUnsavedDialog: false, closeAfter: false })
}
const handleSaveAndClose = async () => {
await performModuleSave({ fromUnsavedDialog: false, closeAfter: true })
} }
const handleUnsavedDialogSave = async () => { const handleUnsavedDialogSave = async () => {
@ -367,7 +375,13 @@ export default function TrainingModuleEditPage() {
{loading ? ( {loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p> <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"> <div className="form-row">
<label className="form-label">Titel *</label> <label className="form-label">Titel *</label>
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} /> <input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
@ -648,14 +662,17 @@ export default function TrainingModuleEditPage() {
))} ))}
</ul> </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> </div>
<FormActionBar
formId="training-module-form"
isNew={isNew}
saving={saving}
onSave={() => handleSave()}
onSaveAndClose={handleSaveAndClose}
onCancel={() => navigate('/planning/training-modules')}
cancelLabel="Abbrechen"
/>
</form> </form>
)} )}

View File

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