shinkan-jinkendo/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
Lars 6e6270b717
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
Enhance navigation and return context in exercise and training module components
- 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.
2026-05-20 07:25:05 +02:00

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>
)
}