All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m9s
- Replaced div elements with FormModalOverlay in SaveExercisesAsModuleModal, TrainingPlanningUnitFormModal, and TrainingPublishToFrameworkModal for a unified modal structure. - Enhanced modal styling and behavior, including adjustments for responsiveness and accessibility. - Introduced new CSS rules to manage overflow and scrolling behavior when modals are active, improving user experience across devices.
582 lines
24 KiB
JavaScript
582 lines
24 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import FormActionBar from '../FormActionBar'
|
||
import FormModalOverlay from '../FormModalOverlay'
|
||
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
|
||
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
||
import { activeClubMemberships } from '../../utils/activeClub'
|
||
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
|
||
|
||
/**
|
||
* Großes Modal: Neue Trainingseinheit / Einheit bearbeiten (Planung, Trainer, Abschnitte, Durchführung, Notizen).
|
||
*/
|
||
export default function TrainingPlanningUnitFormModal({
|
||
open,
|
||
editingUnit,
|
||
formData,
|
||
updateFormField,
|
||
setFormData,
|
||
onSubmit,
|
||
onSaveOnly,
|
||
onSaveAndClose,
|
||
onCancel,
|
||
draftPlanTemplateId,
|
||
onDraftTemplateSelect,
|
||
planTemplates,
|
||
clubDirectory,
|
||
clubDirectoryForCo,
|
||
planningModalClubId,
|
||
user,
|
||
onMetaRefresh,
|
||
sectionsEditMode,
|
||
setSectionsEditMode,
|
||
onSaveAsTemplate,
|
||
onRequestPublishToFramework,
|
||
onRequestSaveAsModule,
|
||
onRequestTrainingModulePick,
|
||
onRequestExercisePick,
|
||
onPeekExercise,
|
||
}) {
|
||
const [newTplVisibility, setNewTplVisibility] = useState('private')
|
||
const [newTplClubId, setNewTplClubId] = useState('')
|
||
|
||
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
|
||
const roleLc = String(user?.role || '').toLowerCase()
|
||
const isSuperadmin = roleLc === 'superadmin'
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
if (planningModalClubId != null && planningModalClubId !== '') {
|
||
setNewTplClubId(String(planningModalClubId))
|
||
} else if (memberClubs.length === 1) {
|
||
setNewTplClubId(String(memberClubs[0].id))
|
||
}
|
||
}, [open, planningModalClubId, memberClubs])
|
||
|
||
if (!open) return null
|
||
|
||
const formId = 'planning-unit-form'
|
||
|
||
return (
|
||
<FormModalOverlay open={open} data-testid="planning-unit-form-modal" onBackdropClick={onCancel}>
|
||
<div className="modal-panel--form">
|
||
<h2 className="modal-panel__title">
|
||
{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)
|
||
return (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
marginBottom: '1.1rem',
|
||
padding: '12px 14px',
|
||
background: 'var(--surface2)',
|
||
fontSize: '0.9rem',
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
<strong style={{ color: 'var(--text1)' }}>Herkunft:</strong>{' '}
|
||
{editingUnit.origin_framework_program_id ? (
|
||
<Link
|
||
to={`/planning/framework-programs/${editingUnit.origin_framework_program_id}`}
|
||
style={{ color: 'var(--accent-dark)' }}
|
||
>
|
||
{L.fpTitle}
|
||
</Link>
|
||
) : (
|
||
L.fpTitle
|
||
)}
|
||
<span style={{ color: 'var(--text2)' }}> · {L.slotBit}</span>
|
||
<p style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
|
||
Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
|
||
geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
|
||
</p>
|
||
</div>
|
||
)
|
||
})()
|
||
: null}
|
||
|
||
{!editingUnit && (
|
||
<div className="training-planning-template-panel" style={{ marginBottom: '1.35rem' }}>
|
||
<label className="form-label training-planning-template-panel__label" htmlFor="planning-draft-template">
|
||
Vorlage für den Ablauf
|
||
</label>
|
||
<select
|
||
id="planning-draft-template"
|
||
className="form-input training-planning-template-panel__select"
|
||
value={draftPlanTemplateId}
|
||
onChange={(e) => onDraftTemplateSelect(e.target.value)}
|
||
>
|
||
<option value="">Ohne Vorlage — leere Gliederung (ein Abschnitt)</option>
|
||
{planTemplates.map((t) => {
|
||
const v = String(t.visibility || 'club').toLowerCase()
|
||
const vLabel = v === 'private' ? 'Privat' : v === 'official' ? 'Offiziell' : 'Verein'
|
||
return (
|
||
<option key={t.id} value={String(t.id)}>
|
||
{t.name}
|
||
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}{' '}
|
||
· {vLabel}
|
||
</option>
|
||
)
|
||
})}
|
||
</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. Vorlagen verwaltest du unter <Link to="/planning/plan-templates">Planung → Vorlagen</Link>.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
|
||
|
||
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
|
||
<div className="form-row">
|
||
<label className="form-label">Datum *</label>
|
||
<input
|
||
type="date"
|
||
className="form-input"
|
||
value={formData.planned_date}
|
||
onChange={(e) => updateFormField('planned_date', e.target.value)}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Von</label>
|
||
<input
|
||
type="time"
|
||
className="form-input"
|
||
value={formData.planned_time_start}
|
||
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Bis</label>
|
||
<input
|
||
type="time"
|
||
className="form-input"
|
||
value={formData.planned_time_end}
|
||
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Trainingsfokus</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={formData.planned_focus}
|
||
onChange={(e) => updateFormField('planned_focus', e.target.value)}
|
||
placeholder="z.B. Grundlagen, Kinder altersgerecht"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className="card"
|
||
style={{
|
||
marginTop: '1.25rem',
|
||
marginBottom: '0.25rem',
|
||
padding: '12px 14px',
|
||
background: 'var(--surface2)',
|
||
}}
|
||
>
|
||
<h3 style={{ margin: '0 0 10px', fontSize: '1rem' }}>Trainerzuordnung (diese Einheit)</h3>
|
||
<div className="form-row">
|
||
<label className="form-label">Leitung</label>
|
||
<select
|
||
className="form-input"
|
||
value={formData.lead_trainer_profile_id}
|
||
onChange={(e) => updateFormField('lead_trainer_profile_id', e.target.value)}
|
||
disabled={!editingUnit && !formData.group_id}
|
||
>
|
||
<option value="">Standard (Haupttrainer der Gruppe)</option>
|
||
{clubDirectory.map((m) => {
|
||
const idStr = String(m.id)
|
||
return (
|
||
<option key={idStr} value={idStr}>
|
||
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
|
||
</option>
|
||
)
|
||
})}
|
||
</select>
|
||
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem', lineHeight: 1.45 }}>
|
||
Für Vertretungen genügt in der Regel die Vereinsmitgliedschaft; Zuweisen dürfen u. a. Haupt-/Co‑Trainer
|
||
dieser Gruppe, der/die Ersteller:in der Einheit oder Vereinsadmins.
|
||
</p>
|
||
</div>
|
||
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={formData.session_assistants_inherit}
|
||
onChange={(e) => updateFormField('session_assistants_inherit', e.target.checked)}
|
||
/>
|
||
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
|
||
Co-Trainer wie in der Trainingsgruppe (Standard)
|
||
</span>
|
||
</label>
|
||
</div>
|
||
{!formData.session_assistants_inherit ? (
|
||
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
|
||
{clubDirectoryForCo.map((m) => {
|
||
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
|
||
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
|
||
const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
|
||
return (
|
||
<label
|
||
key={`co-${mid}`}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
fontSize: '0.875rem',
|
||
marginBottom: '6px',
|
||
cursor: 'pointer',
|
||
color: 'var(--text1)',
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isOn}
|
||
onChange={() => {
|
||
setFormData((prev) => {
|
||
const was = prev.session_assistant_profile_ids.includes(mid)
|
||
const nextIds = was
|
||
? prev.session_assistant_profile_ids.filter((x) => x !== mid)
|
||
: [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b)
|
||
return { ...prev, session_assistant_profile_ids: nextIds }
|
||
})
|
||
}}
|
||
/>
|
||
<span>{labelText}</span>
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
) : null}
|
||
{!clubDirectory.length ? (
|
||
<p style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.4 }}>
|
||
Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<TrainingPlanExerciseVisibilityPanel
|
||
sections={formData.sections}
|
||
targetClubId={planningModalClubId}
|
||
user={user}
|
||
onMetaRefresh={onMetaRefresh}
|
||
/>
|
||
|
||
<div style={{ marginTop: '2rem' }}>
|
||
{editingUnit ? (
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="Modus für Abschnitte und Übungen"
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
}}
|
||
>
|
||
<span className="form-label" style={{ marginBottom: 0, fontSize: '0.82rem' }}>
|
||
Ablauf bearbeiten als
|
||
</span>
|
||
<div
|
||
style={{
|
||
display: 'inline-flex',
|
||
borderRadius: '10px',
|
||
border: '1.5px solid var(--border2)',
|
||
overflow: 'hidden',
|
||
background: 'var(--surface2)',
|
||
}}
|
||
>
|
||
{[
|
||
{ id: 'planning', label: 'Planung' },
|
||
{ id: 'debrief', label: 'Nachbereitung' },
|
||
].map((opt, i) => (
|
||
<button
|
||
key={opt.id}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={sectionsEditMode === opt.id}
|
||
onClick={() => setSectionsEditMode(opt.id)}
|
||
style={{
|
||
border: 'none',
|
||
padding: '8px 14px',
|
||
fontWeight: 600,
|
||
fontSize: '0.85rem',
|
||
cursor: 'pointer',
|
||
background: sectionsEditMode === opt.id ? 'var(--accent-dark)' : 'transparent',
|
||
color: sectionsEditMode === opt.id ? '#fff' : 'var(--text1)',
|
||
whiteSpace: 'nowrap',
|
||
...(i > 0 ? { borderLeft: '1.5px solid var(--border2)' } : {}),
|
||
}}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||
{sectionsEditMode === 'debrief'
|
||
? 'Ist‑Minuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.'
|
||
: 'Ablauf, Übungen und geplante Minuten. Ist‑Werte und Abweichungen unter „Nachbereitung“.'}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
<TrainingUnitSectionsEditor
|
||
heading="Abschnitte & Übungen"
|
||
headingAccessory={
|
||
<>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'flex-end',
|
||
gap: '10px',
|
||
marginBottom: '10px',
|
||
}}
|
||
>
|
||
<div className="form-row" style={{ marginBottom: 0, minWidth: 'min(160px, 100%)' }}>
|
||
<label className="form-label" style={{ fontSize: '0.82rem' }}>
|
||
Neue Vorlage: Sichtbarkeit
|
||
</label>
|
||
<select
|
||
className="form-input"
|
||
value={newTplVisibility}
|
||
onChange={(e) => {
|
||
const v = e.target.value
|
||
setNewTplVisibility(v)
|
||
if (v === 'club' && !newTplClubId && planningModalClubId != null) {
|
||
setNewTplClubId(String(planningModalClubId))
|
||
}
|
||
}}
|
||
>
|
||
<option value="private">Privat (nur du)</option>
|
||
<option value="club">Verein</option>
|
||
{isSuperadmin ? <option value="official">Offiziell (global)</option> : null}
|
||
</select>
|
||
</div>
|
||
{newTplVisibility === 'club' ? (
|
||
<div className="form-row" style={{ marginBottom: 0, flex: '1 1 200px' }}>
|
||
<label className="form-label" style={{ fontSize: '0.82rem' }}>
|
||
Verein
|
||
</label>
|
||
<select
|
||
className="form-input"
|
||
value={newTplClubId}
|
||
onChange={(e) => setNewTplClubId(e.target.value)}
|
||
>
|
||
<option value="">— Verein wählen —</option>
|
||
{memberClubs.map((c) => (
|
||
<option key={c.id} value={String(c.id)}>
|
||
{c.name || `Verein #${c.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ marginBottom: '2px' }}
|
||
onClick={() =>
|
||
onSaveAsTemplate?.({
|
||
visibility: newTplVisibility,
|
||
club_id:
|
||
newTplVisibility === 'club' && newTplClubId
|
||
? parseInt(newTplClubId, 10)
|
||
: null,
|
||
})
|
||
}
|
||
>
|
||
Vorlage aus Aufbau speichern
|
||
</button>
|
||
{editingUnit?.id && !editingUnit?.framework_slot_id ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ marginBottom: '2px' }}
|
||
onClick={() => onRequestPublishToFramework?.()}
|
||
title="Letzten gespeicherten Ablauf ins Rahmenprogramm übernehmen"
|
||
>
|
||
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>
|
||
</>
|
||
}
|
||
sections={formData.sections}
|
||
wideExerciseGrid
|
||
onSectionsChange={(updater) =>
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
sections: updater(prev.sections),
|
||
}))
|
||
}
|
||
onRequestTrainingModulePick={onRequestTrainingModulePick}
|
||
onRequestExercisePick={onRequestExercisePick}
|
||
onPeekExercise={onPeekExercise}
|
||
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||
enableParallelPhaseControls
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ marginBottom: '1.75rem' }} />
|
||
|
||
{editingUnit && (
|
||
<>
|
||
<h3 style={{ marginTop: '0.5rem', marginBottom: '1rem' }}>Durchführung</h3>
|
||
|
||
<div className="responsive-grid-4" style={{ marginBottom: '1rem' }}>
|
||
<div className="form-row">
|
||
<label className="form-label">Tatsächliches Datum</label>
|
||
<input
|
||
type="date"
|
||
className="form-input"
|
||
value={formData.actual_date}
|
||
onChange={(e) => updateFormField('actual_date', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Von</label>
|
||
<input
|
||
type="time"
|
||
className="form-input"
|
||
value={formData.actual_time_start}
|
||
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Bis</label>
|
||
<input
|
||
type="time"
|
||
className="form-input"
|
||
value={formData.actual_time_end}
|
||
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Teilnehmer</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
value={formData.attendance_count}
|
||
onChange={(e) => updateFormField('attendance_count', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Status</label>
|
||
<select
|
||
className="form-input"
|
||
value={formData.status}
|
||
onChange={(e) => updateFormField('status', e.target.value)}
|
||
>
|
||
<option value="planned">Geplant</option>
|
||
<option value="completed">Durchgeführt</option>
|
||
<option value="cancelled">Abgesagt</option>
|
||
</select>
|
||
</div>
|
||
|
||
{formData.status === 'completed' ? (
|
||
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
||
<label
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: '10px',
|
||
cursor: 'pointer',
|
||
lineHeight: 1.45,
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!formData.debrief_completed}
|
||
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
|
||
style={{ marginTop: '3px' }}
|
||
/>
|
||
<span>
|
||
<strong>Rückschau erledigt</strong>
|
||
<span className="muted" style={{ display: 'block', fontSize: '0.82rem', marginTop: '5px' }}>
|
||
Wenn angehakt, erscheint die Einheit nicht mehr unter „Offene Rückschau“ auf dem Dashboard
|
||
(Nachbereitung gilt als abgeschlossen).
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)}
|
||
|
||
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Öffentliche Notizen</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={3}
|
||
value={formData.notes}
|
||
onChange={(e) => updateFormField('notes', e.target.value)}
|
||
placeholder="Für Teilnehmer"
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Trainernotizen</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={3}
|
||
value={formData.trainer_notes}
|
||
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
</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>
|
||
</FormModalOverlay>
|
||
)
|
||
}
|