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 SkillsPage = lazy(() => import('./pages/SkillsPage'))
|
||||
const TrainingPlanningPage = lazy(() => import('./pages/TrainingPlanningPage'))
|
||||
const TrainingPlanTemplatesListPage = lazy(() => import('./pages/TrainingPlanTemplatesListPage'))
|
||||
const PlanningLayout = lazy(() => import('./layouts/PlanningLayout'))
|
||||
const TrainingFrameworkProgramsListPage = lazy(() =>
|
||||
import('./pages/TrainingFrameworkProgramsListPage'),
|
||||
)
|
||||
|
|
@ -223,15 +225,22 @@ const appRouter = createBrowserRouter([
|
|||
{ path: 'clubs', element: <ClubsPage /> },
|
||||
{ path: 'inbox', element: <InboxPage /> },
|
||||
{ path: 'skills', element: <SkillsPage /> },
|
||||
{
|
||||
path: 'planning',
|
||||
element: <PlanningLayout />,
|
||||
children: [
|
||||
{ index: true, element: <TrainingPlanningPage /> },
|
||||
{ path: 'framework-programs', element: <TrainingFrameworkProgramsListPage /> },
|
||||
{ path: 'training-modules', element: <TrainingModulesListPage /> },
|
||||
{ path: 'plan-templates', element: <TrainingPlanTemplatesListPage /> },
|
||||
],
|
||||
},
|
||||
{ path: 'planning/framework-programs/new', element: <TrainingFrameworkProgramEditPage /> },
|
||||
{ path: 'planning/framework-programs/:id', element: <TrainingFrameworkProgramEditPage /> },
|
||||
{ path: 'planning/framework-programs', element: <TrainingFrameworkProgramsListPage /> },
|
||||
{ path: 'planning/training-modules/new', element: <TrainingModuleEditPage /> },
|
||||
{ path: 'planning/training-modules/:id', element: <TrainingModuleEditPage /> },
|
||||
{ path: 'planning/training-modules', element: <TrainingModulesListPage /> },
|
||||
{ path: 'planning/run/:unitId/coach', element: <TrainingCoachPage /> },
|
||||
{ path: 'planning/run/:unitId', element: <TrainingUnitRunPage /> },
|
||||
{ path: 'planning', element: <TrainingPlanningPage /> },
|
||||
{ path: 'admin', element: <AdminHomeRedirect /> },
|
||||
{
|
||||
path: 'admin/users',
|
||||
|
|
|
|||
|
|
@ -1155,6 +1155,106 @@ a.analysis-split__nav-item {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Planung: gemeinsame Chip-Navigation + Inhalt */
|
||||
.planning-layout__main {
|
||||
min-width: 0;
|
||||
}
|
||||
.planning-route-nav {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* Formular-Aktionsleiste (sticky, schmal, touch-freundlich) */
|
||||
.form-action-bar {
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 12;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px clamp(10px, 2.5vw, 14px);
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.form-action-bar--top {
|
||||
border-top: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.form-action-bar--modal {
|
||||
margin-top: auto;
|
||||
}
|
||||
.form-action-bar__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
max-width: min(1100px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.form-action-bar__spacer {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-action-bar__primary-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.form-action-bar__btn {
|
||||
min-height: 44px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.form-action-bar__btn--cancel {
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.modal-panel--form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(92vh, 100%);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
.modal-form-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-form-shell__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.page-form-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-form-shell__scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.page-form-shell .form-action-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: 1rem;
|
||||
margin-left: calc(-1 * var(--page-pad, 16px));
|
||||
margin-right: calc(-1 * var(--page-pad, 16px));
|
||||
padding-left: var(--page-pad, 16px);
|
||||
padding-right: var(--page-pad, 16px);
|
||||
}
|
||||
|
||||
/* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */
|
||||
.settings-shell {
|
||||
width: 100%;
|
||||
|
|
|
|||
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 { useNavigate } from 'react-router-dom'
|
||||
import FormActionBar from '../FormActionBar'
|
||||
import api from '../../utils/api'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
|
|
@ -160,17 +161,16 @@ export default function SaveExercisesAsModuleModal({
|
|||
}}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
className="card modal-panel--form"
|
||||
style={{
|
||||
maxWidth: 'min(560px, 100%)',
|
||||
width: '100%',
|
||||
padding: '1.25rem',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '0.65rem' }}>Übungen als Trainingsmodul</h2>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem' }}>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '0.65rem', flexShrink: 0 }}>Übungen als Trainingsmodul</h2>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem', flexShrink: 0 }}>
|
||||
Es werden die <strong>gespeicherten</strong> Übungspositionen der Einheit vom{' '}
|
||||
<strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand
|
||||
brauchst.
|
||||
|
|
@ -183,7 +183,8 @@ export default function SaveExercisesAsModuleModal({
|
|||
) : candidates.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)' }}>In dieser Einheit sind keine Übungen im Ablauf hinterlegt.</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form id="save-module-form" className="modal-form-shell" onSubmit={handleSubmit}>
|
||||
<div className="modal-form-shell__body">
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label className="form-label">Modultitel</label>
|
||||
<input
|
||||
|
|
@ -286,14 +287,17 @@ export default function SaveExercisesAsModuleModal({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting || !candidates.length}>
|
||||
{submitting ? 'Speichern…' : 'Modul anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormActionBar
|
||||
placement="bottom"
|
||||
variant="modal"
|
||||
formId="save-module-form"
|
||||
saving={submitting}
|
||||
showSave={false}
|
||||
saveAndCloseLabel="Modul anlegen"
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -684,29 +684,6 @@ function TrainingPlanningPageRoot() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleDeletePlanTemplate = useCallback(
|
||||
async (tpl) => {
|
||||
if (!tpl?.id) return
|
||||
const label = (tpl.name || '').trim() || `Vorlage #${tpl.id}`
|
||||
if (
|
||||
!window.confirm(
|
||||
`Trainingsvorlage „${label}“ wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden.`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteTrainingPlanTemplate(tpl.id)
|
||||
setDraftPlanTemplateId((prev) => (String(prev) === String(tpl.id) ? '' : prev))
|
||||
await loadPlanTemplates()
|
||||
toast.success('Vorlage gelöscht.')
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Löschen fehlgeschlagen')
|
||||
}
|
||||
},
|
||||
[loadPlanTemplates, toast]
|
||||
)
|
||||
|
||||
const openModuleApplyModal = useCallback(async (placement) => {
|
||||
setModuleApplyErr('')
|
||||
setModuleApplySearchQuery('')
|
||||
|
|
@ -995,11 +972,11 @@ function TrainingPlanningPageRoot() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
const handleSubmit = async (e, { closeAfter = true } = {}) => {
|
||||
e?.preventDefault?.()
|
||||
if (!formData.group_id || !formData.planned_date) {
|
||||
toast.error('Gruppe und Datum sind Pflichtfelder')
|
||||
return
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const planPart = buildPlanPayloadForSave(formData.sections)
|
||||
|
|
@ -1041,15 +1018,22 @@ function TrainingPlanningPageRoot() {
|
|||
}
|
||||
}
|
||||
|
||||
let savedUnit
|
||||
if (editingUnit) {
|
||||
await api.updateTrainingUnit(editingUnit.id, payload)
|
||||
savedUnit = await api.updateTrainingUnit(editingUnit.id, payload)
|
||||
} else {
|
||||
await api.createTrainingUnit(payload)
|
||||
savedUnit = await api.createTrainingUnit(payload)
|
||||
}
|
||||
setShowModal(false)
|
||||
await loadUnits()
|
||||
if (closeAfter) {
|
||||
setShowModal(false)
|
||||
} else if (savedUnit?.id) {
|
||||
await handleEdit({ id: savedUnit.id })
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
toast.error('Fehler beim Speichern: ' + err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1179,7 +1163,7 @@ function TrainingPlanningPageRoot() {
|
|||
const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid)
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<>
|
||||
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsplanung</h1>
|
||||
|
||||
<div
|
||||
|
|
@ -1232,25 +1216,9 @@ function TrainingPlanningPageRoot() {
|
|||
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem' }}>
|
||||
Wähle eine Trainingsgruppe und lege <strong>Trainingseinheiten</strong> für den Zeitraum an (Inhalt: Abschnitte
|
||||
und Übungen).
|
||||
und Übungen). Rahmenprogramme, Module und Vorlagen erreichst du über die Registerkarten oben.
|
||||
</p>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}>
|
||||
<p style={{ margin: '0 0 0.5rem', fontSize: '0.92rem', color: 'var(--text2)' }}>
|
||||
Mehrere Einheiten strukturieren auf einmal:{' '}
|
||||
<Link to="/planning/framework-programs" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
Trainingsrahmenprogramme
|
||||
</Link>{' '}
|
||||
(Ziele, Sessions, 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 && (
|
||||
<div
|
||||
className="card"
|
||||
|
|
@ -2023,7 +1991,8 @@ function TrainingPlanningPageRoot() {
|
|||
draftPlanTemplateId={draftPlanTemplateId}
|
||||
onDraftTemplateSelect={applyTemplateFromSelect}
|
||||
planTemplates={planTemplates}
|
||||
onDeletePlanTemplate={handleDeletePlanTemplate}
|
||||
onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })}
|
||||
onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })}
|
||||
clubDirectory={clubDirectory}
|
||||
clubDirectoryForCo={clubDirectoryForCo}
|
||||
planningModalClubId={planningModalClubId}
|
||||
|
|
@ -2123,7 +2092,7 @@ function TrainingPlanningPageRoot() {
|
|||
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
|
||||
onClose={() => setPlanningPeekCtx(null)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import FormActionBar from '../FormActionBar'
|
||||
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
|
||||
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
||||
import { activeClubMemberships } from '../../utils/activeClub'
|
||||
import { canDeleteLibraryContent } from '../../utils/libraryContentPermissions'
|
||||
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
|
||||
|
||||
/**
|
||||
|
|
@ -16,11 +16,12 @@ export default function TrainingPlanningUnitFormModal({
|
|||
updateFormField,
|
||||
setFormData,
|
||||
onSubmit,
|
||||
onSaveOnly,
|
||||
onSaveAndClose,
|
||||
onCancel,
|
||||
draftPlanTemplateId,
|
||||
onDraftTemplateSelect,
|
||||
planTemplates,
|
||||
onDeletePlanTemplate,
|
||||
clubDirectory,
|
||||
clubDirectoryForCo,
|
||||
planningModalClubId,
|
||||
|
|
@ -53,9 +54,12 @@ export default function TrainingPlanningUnitFormModal({
|
|||
|
||||
if (!open) return null
|
||||
|
||||
const formId = 'planning-unit-form'
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="planning-unit-form-modal"
|
||||
className="modal-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
|
|
@ -68,27 +72,31 @@ export default function TrainingPlanningUnitFormModal({
|
|||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '1rem',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-panel--form"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
padding: 'clamp(12px, 3vw, 2rem)',
|
||||
maxWidth: 'min(1100px, 100%)',
|
||||
width: '100%',
|
||||
maxHeight: '92vh',
|
||||
overflowY: 'auto',
|
||||
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
|
||||
boxSizing: 'border-box',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ marginBottom: '1rem', flexShrink: 0 }}>
|
||||
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
||||
</h2>
|
||||
|
||||
<form
|
||||
id={formId}
|
||||
className="modal-form-shell"
|
||||
onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSubmit?.(e))}
|
||||
>
|
||||
<div className="modal-form-shell__body">
|
||||
{editingUnit?.origin_framework_slot_id
|
||||
? (() => {
|
||||
const L = frameworkLineageText(editingUnit)
|
||||
|
|
@ -150,80 +158,11 @@ export default function TrainingPlanningUnitFormModal({
|
|||
</select>
|
||||
<p className="training-planning-template-panel__help">
|
||||
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
|
||||
Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
|
||||
Abschnitten ein. Vorlagen verwaltest du unter <Link to="/planning/plan-templates">Planung → Vorlagen</Link>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{planTemplates.length > 0 && typeof onDeletePlanTemplate === 'function' ? (
|
||||
<details
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '1.35rem',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text1)' }}>
|
||||
Gespeicherte Vorlagen löschen
|
||||
</summary>
|
||||
<p style={{ margin: '0.65rem 0 0.75rem', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
Entfernen nach Rolle: eigene private Vorlagen; 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>
|
||||
|
||||
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
|
||||
|
|
@ -649,14 +588,17 @@ export default function TrainingPlanningUnitFormModal({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
||||
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
||||
{editingUnit ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<FormActionBar
|
||||
placement="bottom"
|
||||
variant="modal"
|
||||
formId={formId}
|
||||
isNew={!editingUnit}
|
||||
onSave={onSaveOnly ? () => onSaveOnly() : undefined}
|
||||
onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined}
|
||||
onCancel={onCancel}
|
||||
showSave={Boolean(onSaveOnly)}
|
||||
showSaveAndClose
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import FormActionBar from '../FormActionBar'
|
||||
import api from '../../utils/api'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
|
|
@ -205,22 +206,22 @@ export default function TrainingPublishToFrameworkModal({
|
|||
}}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
className="card modal-panel--form"
|
||||
style={{
|
||||
maxWidth: 'min(520px, 100%)',
|
||||
width: '100%',
|
||||
padding: '1.25rem',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '0.65rem' }}>Ablauf ins Rahmenprogramm übernehmen</h2>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem' }}>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '0.65rem', flexShrink: 0 }}>Ablauf ins Rahmenprogramm übernehmen</h2>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem', flexShrink: 0 }}>
|
||||
Es wird der <strong>zuletzt gespeicherte</strong> Ablauf dieser Einheit aus der Datenbank übernommen.
|
||||
Nicht gespeicherte Änderungen im Formular sind nicht enthalten — bitte vorher die Einheit speichern.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<form id="publish-framework-form" className="modal-form-shell" onSubmit={handleSubmit}>
|
||||
<div className="modal-form-shell__body">
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<span className="form-label">Ziel</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginTop: '0.35rem' }}>
|
||||
|
|
@ -405,14 +406,17 @@ export default function TrainingPublishToFrameworkModal({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={resetAndClose} disabled={submitting}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Speichern…' : 'In Rahmen übernehmen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormActionBar
|
||||
placement="bottom"
|
||||
variant="modal"
|
||||
formId="publish-framework-form"
|
||||
saving={submitting}
|
||||
showSave={false}
|
||||
saveAndCloseLabel="In Rahmen übernehmen"
|
||||
onCancel={resetAndClose}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
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 TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import FormActionBar from '../components/FormActionBar'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
|
||||
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
|
||||
|
|
@ -427,7 +428,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
}))
|
||||
}
|
||||
|
||||
const performFrameworkSave = async ({ fromUnsavedDialog = false } = {}) => {
|
||||
const performFrameworkSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
|
||||
if (!(form.title || '').trim()) {
|
||||
toast.error('Titel ist Pflichtfeld.')
|
||||
return false
|
||||
|
|
@ -448,7 +449,9 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
if (isNew) {
|
||||
const created = await api.createTrainingFrameworkProgram(payload)
|
||||
toast.success('Rahmenprogramm angelegt.')
|
||||
if (!fromUnsavedDialog) {
|
||||
if (closeAfter) {
|
||||
navigate('/planning/framework-programs')
|
||||
} else if (!fromUnsavedDialog) {
|
||||
navigate(`/planning/framework-programs/${created.id}`, { replace: true })
|
||||
}
|
||||
return true
|
||||
|
|
@ -463,6 +466,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
setBypassDirty(false)
|
||||
setBaselineReady(true)
|
||||
toast.success('Gespeichert.')
|
||||
if (closeAfter) navigate('/planning/framework-programs')
|
||||
return true
|
||||
} catch (e) {
|
||||
toast.error(e.message || 'Speichern fehlgeschlagen')
|
||||
|
|
@ -473,7 +477,11 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
await performFrameworkSave({ fromUnsavedDialog: false })
|
||||
await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: false })
|
||||
}
|
||||
|
||||
const handleSaveAndClose = async () => {
|
||||
await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: true })
|
||||
}
|
||||
|
||||
const handleUnsavedDialogSave = async () => {
|
||||
|
|
@ -1250,19 +1258,19 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'center' }}>
|
||||
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave}>
|
||||
{saving ? 'Speichern…' : isNew ? 'Anlegen' : 'Speichern'}
|
||||
<FormActionBar
|
||||
isNew={isNew}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onSaveAndClose={handleSaveAndClose}
|
||||
onCancel={() => navigate('/planning/framework-programs')}
|
||||
cancelLabel="Abbrechen"
|
||||
/>
|
||||
{!isNew ? (
|
||||
<button type="button" className="btn btn-secondary" onClick={handleDelete} style={{ marginTop: '10px' }}>
|
||||
Löschen
|
||||
</button>
|
||||
<Link to="/planning/framework-programs" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||||
Abbrechen
|
||||
</Link>
|
||||
{!isNew ? (
|
||||
<button type="button" className="btn btn-secondary" onClick={handleDelete}>
|
||||
Löschen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ExercisePickerModal
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -108,11 +108,8 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
Trainingsrahmenprogramme
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
|
||||
Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
|
||||
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
Trainingsplanung
|
||||
</Link>
|
||||
.
|
||||
Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der
|
||||
Trainingsplanung (Registerkarte oben).
|
||||
</p>
|
||||
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
|
||||
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
|
||||
|
|
@ -131,12 +128,6 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<p style={{ marginBottom: '1rem' }}>
|
||||
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
|
||||
← Zurück zur Trainingsplanung
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
|
||||
{error}
|
||||
|
|
@ -207,6 +198,6 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||
import FormActionBar from '../components/FormActionBar'
|
||||
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
|
|
@ -301,7 +302,7 @@ export default function TrainingModuleEditPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const performModuleSave = async ({ fromUnsavedDialog = false } = {}) => {
|
||||
const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
|
||||
if (!title.trim()) {
|
||||
toast.error('Titel ist Pflicht.')
|
||||
return false
|
||||
|
|
@ -313,7 +314,9 @@ export default function TrainingModuleEditPage() {
|
|||
if (isNew) {
|
||||
const created = await api.createTrainingModule(body)
|
||||
toast.success('Trainingsmodul angelegt.')
|
||||
if (!fromUnsavedDialog) {
|
||||
if (closeAfter) {
|
||||
navigate('/planning/training-modules')
|
||||
} else if (!fromUnsavedDialog) {
|
||||
navigate(`/planning/training-modules/${created.id}`, { replace: true })
|
||||
}
|
||||
return true
|
||||
|
|
@ -322,6 +325,7 @@ export default function TrainingModuleEditPage() {
|
|||
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
||||
setBypassDirty(false)
|
||||
toast.success('Gespeichert.')
|
||||
if (closeAfter) navigate('/planning/training-modules')
|
||||
return true
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Speichern fehlgeschlagen'
|
||||
|
|
@ -334,8 +338,12 @@ export default function TrainingModuleEditPage() {
|
|||
}
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault()
|
||||
await performModuleSave({ fromUnsavedDialog: false })
|
||||
e?.preventDefault?.()
|
||||
await performModuleSave({ fromUnsavedDialog: false, closeAfter: false })
|
||||
}
|
||||
|
||||
const handleSaveAndClose = async () => {
|
||||
await performModuleSave({ fromUnsavedDialog: false, closeAfter: true })
|
||||
}
|
||||
|
||||
const handleUnsavedDialogSave = async () => {
|
||||
|
|
@ -367,7 +375,13 @@ export default function TrainingModuleEditPage() {
|
|||
{loading ? (
|
||||
<p style={{ color: 'var(--text2)' }}>Laden …</p>
|
||||
) : (
|
||||
<form className="card" style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }} onSubmit={handleSave}>
|
||||
<form
|
||||
id="training-module-form"
|
||||
className="card page-form-shell"
|
||||
style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }}
|
||||
onSubmit={handleSave}
|
||||
>
|
||||
<div className="page-form-shell__scroll">
|
||||
<div className="form-row">
|
||||
<label className="form-label">Titel *</label>
|
||||
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
|
|
@ -648,14 +662,17 @@ export default function TrainingModuleEditPage() {
|
|||
))}
|
||||
</ul>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Speichern …' : isNew ? 'Anlegen' : 'Speichern'}
|
||||
</button>
|
||||
<Link to="/planning/training-modules" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FormActionBar
|
||||
formId="training-module-form"
|
||||
isNew={isNew}
|
||||
saving={saving}
|
||||
onSave={() => handleSave()}
|
||||
onSaveAndClose={handleSaveAndClose}
|
||||
onCancel={() => navigate('/planning/training-modules')}
|
||||
cancelLabel="Abbrechen"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function TrainingModulesListPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -56,11 +56,8 @@ export default function TrainingModulesListPage() {
|
|||
Trainingsmodule
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
|
||||
Wiederverwendbare Übungsfolgen für die{' '}
|
||||
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
Trainingsplanung
|
||||
</Link>
|
||||
. Übernahme in eine Einheit erfolgt dort als lokale Kopie (mit Herkunftsmarkierung).
|
||||
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Übernahme in eine Einheit erfolgt dort als
|
||||
lokale Kopie (mit Herkunftsmarkierung).
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/planning/training-modules/new" className="btn btn-primary" style={{ textDecoration: 'none' }}>
|
||||
|
|
@ -130,6 +127,6 @@ export default function TrainingModulesListPage() {
|
|||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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