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
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:
parent
7693139242
commit
a51f794945
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.146"
|
APP_VERSION = "0.8.147"
|
||||||
BUILD_DATE = "2026-05-19"
|
BUILD_DATE = "2026-05-19"
|
||||||
DB_SCHEMA_VERSION = "20260516065"
|
DB_SCHEMA_VERSION = "20260516065"
|
||||||
|
|
||||||
|
|
@ -37,7 +37,12 @@ MODULE_VERSIONS = {
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"date": "2026-05-19",
|
||||||
"changes": [
|
"changes": [
|
||||||
"Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework",
|
"Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework",
|
||||||
|
|
|
||||||
326
frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
Normal file
326
frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal
|
||||||
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
||||||
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
||||||
import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal'
|
import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal'
|
||||||
|
import SaveExercisesAsModuleModal from './SaveExercisesAsModuleModal'
|
||||||
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
|
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
|
||||||
import {
|
import {
|
||||||
defaultSection,
|
defaultSection,
|
||||||
|
|
@ -53,6 +54,10 @@ function TrainingPlanningPageRoot() {
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [editingUnit, setEditingUnit] = useState(null)
|
const [editingUnit, setEditingUnit] = useState(null)
|
||||||
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
|
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) */
|
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
|
||||||
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
|
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
|
||||||
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
|
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
|
||||||
|
|
@ -1853,6 +1858,28 @@ function TrainingPlanningPageRoot() {
|
||||||
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
|
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</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) ? (
|
{mayConfigureSessionAssignments(unit) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1959,9 +1986,29 @@ function TrainingPlanningPageRoot() {
|
||||||
|
|
||||||
<TrainingPublishToFrameworkModal
|
<TrainingPublishToFrameworkModal
|
||||||
open={publishFrameworkOpen}
|
open={publishFrameworkOpen}
|
||||||
onClose={() => setPublishFrameworkOpen(false)}
|
onClose={() => {
|
||||||
onSuccess={() => setShowModal(false)}
|
setPublishFrameworkOpen(false)
|
||||||
unitId={editingUnit?.id}
|
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}
|
planningModalClubId={planningModalClubId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -1985,7 +2032,18 @@ function TrainingPlanningPageRoot() {
|
||||||
sectionsEditMode={sectionsEditMode}
|
sectionsEditMode={sectionsEditMode}
|
||||||
setSectionsEditMode={setSectionsEditMode}
|
setSectionsEditMode={setSectionsEditMode}
|
||||||
onSaveAsTemplate={handleSaveAsTemplate}
|
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) => {
|
onRequestTrainingModulePick={(ctx) => {
|
||||||
void openModuleApplyModal(ctx)
|
void openModuleApplyModal(ctx)
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
setSectionsEditMode,
|
setSectionsEditMode,
|
||||||
onSaveAsTemplate,
|
onSaveAsTemplate,
|
||||||
onRequestPublishToFramework,
|
onRequestPublishToFramework,
|
||||||
|
onRequestSaveAsModule,
|
||||||
onRequestTrainingModulePick,
|
onRequestTrainingModulePick,
|
||||||
onRequestExercisePick,
|
onRequestExercisePick,
|
||||||
onPeekExercise,
|
onPeekExercise,
|
||||||
|
|
@ -504,6 +505,17 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
Als Rahmen-Session speichern…
|
Als Rahmen-Session speichern…
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
frontend/src/utils/trainingPlanModuleFromUnit.js
Normal file
60
frontend/src/utils/trainingPlanModuleFromUnit.js
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user