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
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:
parent
82705f0c3e
commit
4fee5a2b47
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
82
frontend/src/components/FormActionBar.jsx
Normal file
82
frontend/src/components/FormActionBar.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
frontend/src/components/planning/PlanningRouteNav.jsx
Normal file
31
frontend/src/components/planning/PlanningRouteNav.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, Vorlagen‑Ablauf).
|
|
||||||
</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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; Vereinsinhalte als Vereinsadmin; offizielle nur als
|
|
||||||
Plattform‑Admin. 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
14
frontend/src/layouts/PlanningLayout.jsx
Normal file
14
frontend/src/layouts/PlanningLayout.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
150
frontend/src/pages/TrainingPlanTemplatesListPage.jsx
Normal file
150
frontend/src/pages/TrainingPlanTemplatesListPage.jsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user