Update version to 0.8.147 and add functionality to save exercises as training modules
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Has been cancelled

- Incremented app version to 0.8.147 and updated changelog to reflect the new version.
- Introduced a new modal for saving exercises as training modules within the training planning interface.
- Enhanced the TrainingPlanningPageRoot component to manage the new save module functionality, including state management for the modal.
- Updated the TrainingPlanningUnitFormModal to include an option for saving exercises as a module, improving user experience in training planning.
This commit is contained in:
Lars 2026-05-19 09:41:27 +02:00
parent 7693139242
commit a51f794945
5 changed files with 467 additions and 6 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.146"
APP_VERSION = "0.8.147"
BUILD_DATE = "2026-05-19"
DB_SCHEMA_VERSION = "20260516065"
@ -37,7 +37,12 @@ MODULE_VERSIONS = {
CHANGELOG = [
{
"version": "0.8.146",
"version": "0.8.147",
"date": "2026-05-19",
"changes": [
"Planung: Liste Rahmen-Session & Übungen→Modul; Dialog Modul aus Einheit; klarere Rahmen-Unit-ID aus Liste",
],
},
"date": "2026-05-19",
"changes": [
"Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework",

View File

@ -0,0 +1,326 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../../utils/api'
import { useToast } from '../../context/ToastContext'
import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships } from '../../utils/activeClub'
import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit'
/**
* Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl).
*/
export default function SaveExercisesAsModuleModal({
open,
onClose,
unitId,
planningModalClubId,
onSuccess,
}) {
const navigate = useNavigate()
const toast = useToast()
const { user } = useAuth()
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const roleLc = String(user?.role || '').toLowerCase()
const isSuperadmin = roleLc === 'superadmin'
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [loadErr, setLoadErr] = useState('')
const [unitLabel, setUnitLabel] = useState('')
const [candidates, setCandidates] = useState([])
const [selected, setSelected] = useState(() => [])
const [title, setTitle] = useState('')
const [visibility, setVisibility] = useState('club')
const [clubId, setClubId] = useState('')
const resetLocal = useCallback(() => {
setLoadErr('')
setUnitLabel('')
setCandidates([])
setSelected([])
setTitle('')
setVisibility('club')
setClubId('')
}, [])
useEffect(() => {
if (!open || !unitId) {
resetLocal()
return
}
if (planningModalClubId != null && planningModalClubId !== '') {
setClubId(String(planningModalClubId))
} else if (memberClubs.length === 1) {
setClubId(String(memberClubs[0].id))
}
setLoading(true)
setLoadErr('')
api
.getTrainingUnit(unitId)
.then((u) => {
const dateStr = (u.planned_date || '').trim() || 'Training'
setUnitLabel(dateStr)
setTitle(`Modul · ${dateStr}`)
const c = collectExercisePlacementsForModule(u)
setCandidates(c)
setSelected(c.map(() => true))
})
.catch((e) => {
setLoadErr(e.message || 'Einheit konnte nicht geladen werden')
setCandidates([])
setSelected([])
})
.finally(() => setLoading(false))
}, [open, unitId, planningModalClubId, memberClubs.length, resetLocal])
const toggleOne = (idx) => {
setSelected((prev) => {
const next = [...prev]
next[idx] = !next[idx]
return next
})
}
const setAll = (on) => {
setSelected(candidates.map(() => on))
}
const selectedCount = selected.filter(Boolean).length
const handleSubmit = async (e) => {
e.preventDefault()
if (!unitId || submitting) return
const itemsPayload = []
let oi = 0
for (let i = 0; i < candidates.length; i += 1) {
if (!selected[i]) continue
const c = candidates[i]
itemsPayload.push({
item_type: 'exercise',
order_index: oi,
exercise_id: c.exercise_id,
exercise_variant_id: c.exercise_variant_id,
planned_duration_min: c.planned_duration_min,
notes: c.notes,
})
oi += 1
}
if (!itemsPayload.length) {
toast.error('Mindestens eine Übung auswählen.')
return
}
const tit = (title || '').trim()
if (!tit) {
toast.error('Bitte einen Modultitel angeben.')
return
}
let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
return
}
if (visibility !== 'club') cid = null
setSubmitting(true)
try {
const created = await api.createTrainingModule({
title: tit,
visibility,
club_id: cid,
items: itemsPayload,
})
toast.success('Trainingsmodul gespeichert.')
if (created?.id) {
navigate(`/planning/training-modules/${created.id}`)
}
onSuccess?.()
onClose()
} catch (err) {
toast.error(err.message || 'Speichern fehlgeschlagen')
} finally {
setSubmitting(false)
}
}
if (!open) return null
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
className="card"
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' }}>
Es werden die <strong>gespeicherten</strong> Übungspositionen der Einheit vom{' '}
<strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand
brauchst.
</p>
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : loadErr ? (
<p style={{ color: 'var(--danger)' }}>{loadErr}</p>
) : candidates.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>In dieser Einheit sind keine Übungen im Ablauf hinterlegt.</p>
) : (
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label className="form-label">Modultitel</label>
<input
className="form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
placeholder="z. B. Aufwärmsequenz"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginBottom: '0.75rem' }}>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => setAll(true)}>
Alle
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => setAll(false)}>
Keine
</button>
<span style={{ fontSize: '0.82rem', color: 'var(--text3)', alignSelf: 'center' }}>
{selectedCount} von {candidates.length} gewählt (Reihenfolge wie im Plan)
</span>
</div>
<div
className="card"
style={{
marginBottom: '1rem',
padding: '10px 12px',
maxHeight: 'min(240px, 40vh)',
overflowY: 'auto',
background: 'var(--surface2)',
}}
>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{candidates.map((c, idx) => (
<li
key={`${c.exercise_id}-${idx}`}
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
padding: '8px 0',
borderTop: idx === 0 ? 'none' : '1px solid var(--border)',
}}
>
<input
type="checkbox"
checked={!!selected[idx]}
onChange={() => toggleOne(idx)}
style={{ marginTop: 4 }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 600, color: 'var(--text1)', fontSize: '0.92rem' }}>
{c.exercise_title}
</div>
{c.contextLabel ? (
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: 2 }}>{c.contextLabel}</div>
) : null}
<div style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: 2 }}>
{c.exercise_variant_id ? `Variante #${c.exercise_variant_id}` : 'Standard-Variante'}
{c.planned_duration_min != null && Number.isFinite(Number(c.planned_duration_min))
? ` · ${c.planned_duration_min} Min (Plan)`
: ''}
</div>
</div>
</li>
))}
</ul>
</div>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={visibility}
onChange={(e) => {
const v = e.target.value
setVisibility(v)
if (v === 'club' && !clubId && planningModalClubId != null) {
setClubId(String(planningModalClubId))
}
}}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
{visibility === 'club' ? (
<div className="form-row" style={{ marginBottom: '1rem' }}>
<label className="form-label">Verein</label>
<select className="form-input" value={clubId} onChange={(e) => setClubId(e.target.value)}>
<option value=""> Verein wählen </option>
{memberClubs.map((cl) => (
<option key={cl.id} value={String(cl.id)}>
{cl.name || `Verein #${cl.id}`}
</option>
))}
</select>
</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>
</form>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Abbrechen
</button>
</div>
) : null}
{!loading && loadErr ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
) : null}
{!loading && !loadErr && candidates.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
) : null}
</div>
</div>
)
}

View File

@ -12,6 +12,7 @@ import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal'
import SaveExercisesAsModuleModal from './SaveExercisesAsModuleModal'
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
import {
defaultSection,
@ -53,6 +54,10 @@ function TrainingPlanningPageRoot() {
const [showModal, setShowModal] = useState(false)
const [editingUnit, setEditingUnit] = useState(null)
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
/** Einheit für „Rahmen-Session“-Dialog (Liste oder geöffnetes Bearbeiten) */
const [publishFrameworkUnitId, setPublishFrameworkUnitId] = useState(null)
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
const [saveModuleUnitId, setSaveModuleUnitId] = useState(null)
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
@ -1853,6 +1858,28 @@ function TrainingPlanningPageRoot() {
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
Bearbeiten
</button>
<button
type="button"
className="btn btn-secondary"
title="Gespeicherten Ablauf als Session eines Rahmenprogramms übernehmen"
onClick={() => {
setPublishFrameworkUnitId(unit.id)
setPublishFrameworkOpen(true)
}}
>
Rahmen-Session
</button>
<button
type="button"
className="btn btn-secondary"
title="Gespeicherte Übungen aus dieser Einheit als Trainingsmodul speichern"
onClick={() => {
setSaveModuleUnitId(unit.id)
setSaveModuleOpen(true)
}}
>
Übungen Modul
</button>
{mayConfigureSessionAssignments(unit) ? (
<button
type="button"
@ -1959,9 +1986,29 @@ function TrainingPlanningPageRoot() {
<TrainingPublishToFrameworkModal
open={publishFrameworkOpen}
onClose={() => setPublishFrameworkOpen(false)}
onSuccess={() => setShowModal(false)}
unitId={editingUnit?.id}
onClose={() => {
setPublishFrameworkOpen(false)
setPublishFrameworkUnitId(null)
}}
onSuccess={() => {
setShowModal(false)
setPublishFrameworkUnitId(null)
}}
unitId={publishFrameworkUnitId ?? editingUnit?.id}
planningModalClubId={planningModalClubId}
/>
<SaveExercisesAsModuleModal
open={saveModuleOpen}
onClose={() => {
setSaveModuleOpen(false)
setSaveModuleUnitId(null)
}}
onSuccess={() => {
setShowModal(false)
setSaveModuleUnitId(null)
}}
unitId={saveModuleUnitId ?? editingUnit?.id}
planningModalClubId={planningModalClubId}
/>
@ -1985,7 +2032,18 @@ function TrainingPlanningPageRoot() {
sectionsEditMode={sectionsEditMode}
setSectionsEditMode={setSectionsEditMode}
onSaveAsTemplate={handleSaveAsTemplate}
onRequestPublishToFramework={() => setPublishFrameworkOpen(true)}
onRequestPublishToFramework={() => {
if (editingUnit?.id) {
setPublishFrameworkUnitId(editingUnit.id)
setPublishFrameworkOpen(true)
}
}}
onRequestSaveAsModule={() => {
if (editingUnit?.id) {
setSaveModuleUnitId(editingUnit.id)
setSaveModuleOpen(true)
}
}}
onRequestTrainingModulePick={(ctx) => {
void openModuleApplyModal(ctx)
}}

View File

@ -30,6 +30,7 @@ export default function TrainingPlanningUnitFormModal({
setSectionsEditMode,
onSaveAsTemplate,
onRequestPublishToFramework,
onRequestSaveAsModule,
onRequestTrainingModulePick,
onRequestExercisePick,
onPeekExercise,
@ -504,6 +505,17 @@ export default function TrainingPlanningUnitFormModal({
Als Rahmen-Session speichern
</button>
) : null}
{editingUnit?.id && !editingUnit?.framework_slot_id ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginBottom: '2px' }}
onClick={() => onRequestSaveAsModule?.()}
title="Gespeicherte Übungen als Trainingsmodul sichern"
>
Übungen als Modul
</button>
) : null}
</div>
</>
}

View File

@ -0,0 +1,60 @@
/**
* Übungspositionen aus GET /api/training-units/:id für die Modul-Erstellung (Reihenfolge = Timeline).
* @param {object} unit hydratisierte Trainingseinheit mit `phases` und/oder `sections`
* @returns {Array<{ exercise_id: number, exercise_variant_id: number|null, planned_duration_min: number|null, notes: string|null, exercise_title?: string, contextLabel: string }>}
*/
export function collectExercisePlacementsForModule(unit) {
if (!unit || typeof unit !== 'object') return []
const rows = []
const pushFromSection = (sec, ctx) => {
const st = (sec?.title || '').trim()
for (const it of sec?.items || []) {
if ((it?.item_type || 'exercise') !== 'exercise') continue
const eid = it.exercise_id
if (!eid) continue
const labelParts = [ctx, st].filter(Boolean)
rows.push({
exercise_id: Number(eid),
exercise_variant_id:
it.exercise_variant_id != null && it.exercise_variant_id !== ''
? Number(it.exercise_variant_id)
: null,
planned_duration_min:
it.planned_duration_min != null && it.planned_duration_min !== ''
? Number(it.planned_duration_min)
: null,
notes: it.notes != null && String(it.notes).trim() ? String(it.notes).trim() : null,
exercise_title: (it.exercise_title || '').trim() || `Übung #${eid}`,
contextLabel: labelParts.length ? labelParts.join(' · ') : '',
})
}
}
const phases = Array.isArray(unit.phases) ? unit.phases : []
for (const ph of phases) {
const pk = String(ph?.phase_kind || '').toLowerCase()
const phaseCtx = (ph?.title || '').trim()
if (pk === 'parallel') {
for (const st of ph.streams || []) {
const streamCtx = [phaseCtx, (st?.title || '').trim()].filter(Boolean).join(' · ')
for (const sec of st?.sections || []) {
pushFromSection(sec, streamCtx)
}
}
} else {
for (const sec of ph?.sections || []) {
pushFromSection(sec, phaseCtx)
}
}
}
if (!rows.length && Array.isArray(unit.sections)) {
for (const sec of unit.sections) {
pushFromSection(sec, '')
}
}
return rows
}