All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
- Introduced a new `PageReturnLink` component for consistent back navigation across pages. - Updated `SaveSelectedExercisesAsModuleModal` and `SaveExercisesAsModuleModal` to utilize `navigateWithAppReturn`, preserving navigation context when redirecting after saving. - Enhanced `TrainingModuleEditPage` and `TrainingUnitEditPage` with improved return context handling, allowing users to navigate back to their previous locations seamlessly. - Added CSS styles for the new return link to improve visual consistency and user experience.
315 lines
11 KiB
JavaScript
315 lines
11 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import FormActionBar from '../FormActionBar'
|
|
import FormModalOverlay from '../FormModalOverlay'
|
|
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'
|
|
import { navigateWithAppReturn } from '../../utils/navReturnContext'
|
|
|
|
/**
|
|
* Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl).
|
|
*/
|
|
export default function SaveExercisesAsModuleModal({
|
|
open,
|
|
onClose,
|
|
unitId,
|
|
planningModalClubId,
|
|
returnContext,
|
|
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) {
|
|
navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext)
|
|
}
|
|
onSuccess?.()
|
|
onClose()
|
|
} catch (err) {
|
|
toast.error(err.message || 'Speichern fehlgeschlagen')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<FormModalOverlay open={open} raised onBackdropClick={onClose}>
|
|
<div className="card modal-panel--form modal-panel--narrow">
|
|
<h2 className="modal-panel__title">Übungen als Trainingsmodul</h2>
|
|
<p className="modal-panel__intro">
|
|
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 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
|
|
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>
|
|
|
|
<FormActionBar
|
|
placement="bottom"
|
|
variant="modal"
|
|
formId="save-module-form"
|
|
saving={submitting}
|
|
showSave={false}
|
|
saveAndCloseLabel="Modul anlegen"
|
|
saveAndCloseShortLabel="Anlegen"
|
|
onCancel={onClose}
|
|
/>
|
|
</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>
|
|
</FormModalOverlay>
|
|
)
|
|
}
|