diff --git a/backend/version.py b/backend/version.py
index 7dc65a0..6bc2182 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.131"
+APP_VERSION = "0.8.132"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062"
@@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.132",
+ "date": "2026-05-14",
+ "changes": [
+ "Frontend Phase 3 abgeschlossen: TrainingPlanningPageRoot, ExerciseFormPageRoot, ExercisesListPageRoot unter components/; pages/ nur Re-Export (Soft-Limit). Roadmap UMSETZUNGSPLAN Phase 3 / M3 aktualisiert.",
+ ],
+ },
{
"version": "0.8.131",
"date": "2026-05-13",
diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
index ba2f011..000942b 100644
--- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
+++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md
@@ -7,7 +7,7 @@
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
-- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. Weiter: God-Pages (Planung/Formular).
+- **Phase 3 (abgeschlossen 2026-05-14):** Übungsliste modularisiert; Trainingsplanung/Übungsformular: **Page-Dateien unter Soft-Limit** — Implementierung in `TrainingPlanningPageRoot.jsx`, `ExerciseFormPageRoot.jsx`, `ExercisesListPageRoot.jsx`; `pages/*.jsx` nur Re-Export. Playwright **Tests 9–10**.
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
@@ -82,7 +82,9 @@
| Virtualisierung für die längste produktive Liste | A1, S2 |
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
-**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. Offen: Seite unter Soft-Limit (~500 Zeilen, derzeit ~918 LOC), Zerteilung Planung/Übungsformular.
+**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**.
+
+**Abgeschlossen (2026-05-14):** Routen bleiben unter `frontend/src/pages/`; schwere Implementierung in **`components/planning/TrainingPlanningPageRoot.jsx`**, **`components/exercises/ExerciseFormPageRoot.jsx`**, **`components/exercises/ExercisesListPageRoot.jsx`** — **`pages/*` nur Re-Export** (Soft-Limit ~500 Zeilen laut `VERBINDLICHE_REGELN_SHINKAN.md`).
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
@@ -121,7 +123,7 @@
|-------------|--------|
| **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert |
| **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen |
-| **M3** | Phase 3 Referenz-Page + Virtualisierung live |
+| **M3** | Phase 3 abgeschlossen: Page-Dateien Soft-Limit (Re-Export); Virtualisierung Übungsliste |
| **M4** | Phase 4 migrationsbereit für alle neuen Features |
| **M5** | Phase 5 für Top-Listen abgeschlossen |
diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx
new file mode 100644
index 0000000..5d4a94a
--- /dev/null
+++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx
@@ -0,0 +1,2447 @@
+import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
+import { useNavigate, useParams, Link } from 'react-router-dom'
+import api, { buildExerciseApiPayload } from '../../utils/api'
+import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl'
+import RichTextEditor from '../RichTextEditor'
+import ExerciseProgressionGraphPanel from '../ExerciseProgressionGraphPanel'
+import ExerciseMediaThumbTile from '../ExerciseMediaThumbTile'
+import MediaPreviewModal from '../MediaPreviewModal'
+import ReportContentModal from '../ReportContentModal'
+import CombinationMethodProfileEditor from '../CombinationMethodProfileEditor'
+import ExercisePickerModal from '../ExercisePickerModal'
+import {
+ SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
+ buildExerciseMediaDragPayload,
+} from '../../utils/exerciseInlineMediaRefs'
+import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
+import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
+import { useAuth } from '../../context/AuthContext'
+import { useToast } from '../../context/ToastContext'
+import {
+ activeClubMemberships,
+ getDefaultClubIdForGovernanceForms,
+ getTenantClubDependencyKey,
+} from '../../utils/activeClub'
+import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes'
+import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi'
+import { GripVertical } from 'lucide-react'
+import UnsavedChangesPrompt from '../UnsavedChangesPrompt'
+import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker'
+
+const INTENSITY_OPTIONS = [
+ { value: '', label: '—' },
+ { value: 'niedrig', label: 'niedrig' },
+ { value: 'mittel', label: 'mittel' },
+ { value: 'hoch', label: 'hoch' },
+]
+
+const VARIANT_DIFFICULTY = [
+ { value: '', label: '—' },
+ { value: 'easier', label: 'Einfacher' },
+ { value: 'same', label: 'Gleich' },
+ { value: 'harder', label: 'Schwerer' },
+ { value: 'adapted', label: 'Angepasst' },
+]
+
+/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
+const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
+
+/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */
+const MAX_COMBO_CANDIDATES_PER_STATION = 3
+
+const comboTinyNumberInputSx = {
+ width: '3.5rem',
+ maxWidth: '100%',
+ padding: '4px 6px',
+ fontSize: '0.8125rem',
+ textAlign: 'center',
+}
+
+function emptyComboSlotRow() {
+ return {
+ title: '',
+ candidate_exercise_ids: [],
+ exercise_title_by_id: {},
+ advance_mode: 'timed',
+ load_sec: '',
+ consecutive_reps: '',
+ rep_series_count: '1',
+ intra_rep_rest_sec: '',
+ transition_after_sec: '',
+ }
+}
+
+function comboSlotsFromDetail(exercise) {
+ const raw = exercise?.combination_slots
+ const arch = exercise?.method_archetype != null ? String(exercise.method_archetype).trim() : ''
+ const serienFallback = defaultRepSeriesCountForArchetype(arch)
+ const mp =
+ exercise?.method_profile &&
+ typeof exercise.method_profile === 'object' &&
+ !Array.isArray(exercise.method_profile)
+ ? exercise.method_profile
+ : {}
+ const spvList = readSlotProfilesV1(mp)
+ const byIx = new Map(spvList.map((r) => [Number(r.slot_index), r]))
+
+ if (!Array.isArray(raw) || raw.length === 0) {
+ return [emptyComboSlotRow()]
+ }
+ const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0))
+ return sorted.map((s) => {
+ const si = Number(s.slot_index)
+ const st = byIx.get(si) || {}
+ const cands = Array.isArray(s.candidate_exercise_ids)
+ ? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
+ : []
+ const mode = normalizeAdvanceMode(st.advance_mode)
+ let repSer = ''
+ if (st.rep_series_count != null) repSer = String(st.rep_series_count)
+ else if (mode === 'rep' || mode === 'manual') repSer = String(serienFallback)
+ else repSer = '1'
+ return {
+ title: s.title != null ? String(s.title) : '',
+ candidate_exercise_ids: cands,
+ exercise_title_by_id: {},
+ advance_mode: mode,
+ load_sec: st.load_sec != null ? String(st.load_sec) : '',
+ consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
+ rep_series_count: repSer,
+ intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
+ transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '',
+ }
+ })
+}
+
+function emptyVariantDraft() {
+ return {
+ variant_name: '',
+ description: '',
+ execution_changes: '',
+ duration_min: '',
+ duration_max: '',
+ equipment_lines: '',
+ difficulty_adjustment: '',
+ progression_level: 1,
+ prerequisite_variant_id: '',
+ }
+}
+
+function apiVariantToRow(v) {
+ let lines = ''
+ const eq = v.equipment_changes
+ if (Array.isArray(eq)) {
+ lines = eq.join('\n')
+ } else if (typeof eq === 'string' && eq.trim()) {
+ try {
+ const p = JSON.parse(eq)
+ lines = Array.isArray(p) ? p.join('\n') : eq
+ } catch {
+ lines = eq
+ }
+ }
+ return {
+ ...v,
+ duration_min: v.duration_min ?? '',
+ duration_max: v.duration_max ?? '',
+ equipment_lines: lines,
+ progression_level: v.progression_level ?? 1,
+ prerequisite_variant_id: v.prerequisite_variant_id ?? '',
+ difficulty_adjustment: v.difficulty_adjustment ?? '',
+ }
+}
+
+function buildVariantPayloadFromRow(row) {
+ const lines = (row.equipment_lines || '')
+ .split(/[\n,]+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ const pl =
+ row.progression_level === '' || row.progression_level == null
+ ? 1
+ : parseInt(row.progression_level, 10)
+ const so =
+ row.sequence_order === '' || row.sequence_order == null
+ ? null
+ : parseInt(row.sequence_order, 10)
+ return {
+ variant_name: (row.variant_name || '').trim(),
+ description: (row.description || '').trim() || null,
+ execution_changes: (row.execution_changes || '').trim() || null,
+ duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10),
+ duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10),
+ equipment_changes: lines,
+ difficulty_adjustment: row.difficulty_adjustment || null,
+ progression_level: Number.isNaN(pl) ? 1 : pl,
+ sequence_order: so !== null && Number.isNaN(so) ? null : so,
+ prerequisite_variant_id:
+ row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
+ ? null
+ : parseInt(row.prerequisite_variant_id, 10),
+ }
+}
+
+/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
+function ExerciseVariantFields({
+ row,
+ onPatch,
+ prerequisiteOthers,
+ rteMinHeight = '110px',
+ inlineExerciseId,
+ linkedExerciseMedia = [],
+ onExerciseMediaListChanged,
+}) {
+ return (
+ <>
+
+ Variantenname *
+ onPatch({ variant_name: e.target.value })}
+ minLength={3}
+ />
+
+
+ Kurzbeschreibung
+
+
+ Abweichungen zur Durchführung
+ onPatch({ execution_changes: html })}
+ placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
+ minHeight={rteMinHeight}
+ inlineExerciseId={inlineExerciseId}
+ linkedExerciseMedia={linkedExerciseMedia}
+ onExerciseMediaListChanged={onExerciseMediaListChanged}
+ />
+
+
+
+ Materialänderungen (eine Zeile pro Eintrag)
+
+
+
+ Schwere relativ
+ onPatch({ difficulty_adjustment: e.target.value })}
+ >
+ {VARIANT_DIFFICULTY.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+ Progressions-Stufe (1–10)
+
+ onPatch({
+ progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
+ })
+ }
+ />
+
+
+ Voraussetzungs-Variante
+
+ onPatch({
+ prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
+ })
+ }
+ >
+ — keine —
+ {prerequisiteOthers.map((o) => (
+
+ {o.variant_name || `Variante #${o.id}`}
+
+ ))}
+
+
+
+ >
+ )
+}
+
+function emptyForm() {
+ return {
+ title: '',
+ summary: '',
+ goal: '',
+ execution: '',
+ preparation: '',
+ trainer_notes: '',
+ equipmentLines: '',
+ duration_min: '',
+ duration_max: '',
+ group_size_min: '',
+ group_size_max: '',
+ focus_areas_multi: [],
+ training_styles_multi: [],
+ training_types_multi: [],
+ target_groups_multi: [],
+ visibility: 'private',
+ club_id: null,
+ status: 'draft',
+ skills: [],
+ exercise_kind: 'simple',
+ method_archetype: '',
+ method_profile_json: '{}',
+ combination_slots: [emptyComboSlotRow()],
+ }
+}
+
+function detailToForm(exercise) {
+ return {
+ title: exercise.title || '',
+ summary: exercise.summary || '',
+ goal: exercise.goal || '',
+ execution: exercise.execution || '',
+ preparation: exercise.preparation || '',
+ trainer_notes: exercise.trainer_notes || '',
+ equipmentLines: (exercise.equipment || []).join('\n'),
+ duration_min: exercise.duration_min ?? '',
+ duration_max: exercise.duration_max ?? '',
+ group_size_min: exercise.group_size_min ?? '',
+ group_size_max: exercise.group_size_max ?? '',
+ focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
+ focus_area_id: f.focus_area_id,
+ is_primary: !!f.is_primary,
+ })),
+ training_styles_multi: (exercise.training_styles || []).map((t) => ({
+ training_style_id: t.training_style_id,
+ is_primary: !!t.is_primary,
+ })),
+ training_types_multi: (exercise.training_types || []).map((t) => ({
+ training_type_id: t.training_type_id,
+ is_primary: !!t.is_primary,
+ })),
+ target_groups_multi: (exercise.target_groups || []).map((g) => ({
+ target_group_id: g.target_group_id,
+ is_primary: !!g.is_primary,
+ })),
+ visibility: exercise.visibility || 'private',
+ club_id:
+ String(exercise.visibility || '').trim().toLowerCase() === 'club' &&
+ exercise.club_id != null &&
+ exercise.club_id !== ''
+ ? Number(exercise.club_id)
+ : null,
+ status: exercise.status || 'draft',
+ skills:
+ exercise.skills?.map((s) => ({
+ skill_id: s.skill_id,
+ is_primary: s.is_primary || false,
+ intensity: s.intensity || '',
+ required_level: normalizeSkillLevelSlug(s.required_level),
+ target_level: normalizeSkillLevelSlug(s.target_level),
+ })) || [],
+ exercise_kind:
+ String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
+ ? 'combination'
+ : 'simple',
+ method_archetype: exercise.method_archetype != null ? String(exercise.method_archetype) : '',
+ method_profile_json:
+ typeof exercise.method_profile === 'object' &&
+ exercise.method_profile != null &&
+ !Array.isArray(exercise.method_profile)
+ ? JSON.stringify(exercise.method_profile, null, 2)
+ : '{}',
+ combination_slots: comboSlotsFromDetail(exercise),
+ }
+}
+
+function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
+ const setPrimary = (idx) => {
+ setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
+ }
+ const updateRow = (idx, patch) => {
+ const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
+ if (patch.is_primary === true) {
+ next.forEach((r, i) => {
+ if (i !== idx) r.is_primary = false
+ })
+ }
+ setRows(next)
+ }
+ const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
+ const removeRow = (idx) => {
+ const next = rows.filter((_, i) => i !== idx)
+ if (next.length && !next.some((r) => r.is_primary)) next[0].is_primary = true
+ setRows(next)
+ }
+
+ return (
+
+
+
{title}
+
+ + Eintrag
+
+
+ {rows.length === 0 && (
+
{emptyLabel}
+ )}
+ {rows.map((row, idx) => (
+
+ updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
+ >
+ — wählen —
+ {options.map((o) => (
+
+ {o.icon ? `${o.icon} ` : ''}
+ {o.name}
+ {o.abbreviation ? ` (${o.abbreviation})` : ''}
+
+ ))}
+
+
+ setPrimary(idx)}
+ />
+ primär
+
+ removeRow(idx)}>
+ ✕
+
+
+ ))}
+
+ )
+}
+
+function ExerciseFormPageRoot() {
+ const { id: routeId } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const isSuperadmin = user?.role === 'superadmin'
+ const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
+ const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
+
+ const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
+ useEffect(() => {
+ if (!isPlatformAdmin) {
+ setClubsForGovernanceForms([])
+ return undefined
+ }
+ let cancelled = false
+ ;(async () => {
+ try {
+ const list = await api.listClubs()
+ if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
+ } catch {
+ if (!cancelled) setClubsForGovernanceForms([])
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [isPlatformAdmin, tenantClubDepKey])
+
+ const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
+
+ /** Plattform-Admin: alle Vereine; sonst nur Mitgliedschafts-Vereine. */
+ const visibilityClubChoices = useMemo(() => {
+ if (isPlatformAdmin && clubsForGovernanceForms.length > 0) {
+ return [...clubsForGovernanceForms].sort((a, b) =>
+ String(a.name || '').localeCompare(String(b.name || ''), 'de'),
+ )
+ }
+ return [...membershipClubRows].sort((a, b) =>
+ String(a.name || '').localeCompare(String(b.name || ''), 'de'),
+ )
+ }, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows])
+
+ const governanceDefaultClubId = useMemo(() => getDefaultClubIdForGovernanceForms(user), [user])
+
+ const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
+ const isEdit = exerciseId != null
+
+ const [formData, setFormData] = useState(emptyForm)
+ const [skillsCatalog, setSkillsCatalog] = useState([])
+ const [focusAreas, setFocusAreas] = useState([])
+ const [styleDirections, setStyleDirections] = useState([])
+ const [trainingTypes, setTrainingTypes] = useState([])
+ const [targetGroups, setTargetGroups] = useState([])
+ const [mediaList, setMediaList] = useState([])
+ const [loading, setLoading] = useState(!!isEdit)
+ const [saving, setSaving] = useState(false)
+ const [formDirty, setFormDirty] = useState(false)
+ const [skillPick, setSkillPick] = useState('')
+
+ const toast = useToast()
+ const allowUnloadBlock = Boolean(formDirty && !loading && !saving)
+ useBeforeUnloadWhen(allowUnloadBlock)
+ const blocker = useUnsavedChangesBlocker(allowUnloadBlock)
+ const [variants, setVariants] = useState([])
+ const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
+ const [variantSavingId, setVariantSavingId] = useState(null)
+ const [variantBusy, setVariantBusy] = useState(false)
+ const [variantEditSelection, setVariantEditSelection] = useState(null)
+ const variantsDetailsRef = useRef(null)
+
+ const [mediaFields, setMediaFields] = useState({})
+ const [mediaSavingId, setMediaSavingId] = useState(null)
+ const [archiveOpen, setArchiveOpen] = useState(false)
+ const [archiveQ, setArchiveQ] = useState('')
+ const [archiveLoading, setArchiveLoading] = useState(false)
+ const [archiveItems, setArchiveItems] = useState([])
+ const [archiveError, setArchiveError] = useState(null)
+ const [mediaPreview, setMediaPreview] = useState(null)
+ const [reportTarget, setReportTarget] = useState(null)
+
+ useEffect(() => {
+ const next = {}
+ for (const m of mediaList) {
+ next[m.id] = {
+ title: m.title || '',
+ }
+ }
+ setMediaFields(next)
+ }, [mediaList])
+
+ useEffect(() => {
+ const onDragOverDoc = (e) => {
+ const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : []
+ if (!types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) return
+ e.preventDefault()
+ autoScrollForDragNearEdges(e)
+ }
+ document.addEventListener('dragover', onDragOverDoc)
+ return () => document.removeEventListener('dragover', onDragOverDoc)
+ }, [])
+
+
+
+ useEffect(() => {
+ if (!archiveOpen) return undefined
+ let cancelled = false
+ const t = setTimeout(async () => {
+ setArchiveLoading(true)
+ setArchiveError(null)
+ try {
+ const res = await api.listMediaAssets({
+ q: archiveQ.trim() || undefined,
+ limit: 40,
+ })
+ if (!cancelled) setArchiveItems((res.items || []).filter(a => !a.legal_hold_active))
+ } catch (e) {
+ if (!cancelled) setArchiveError(e.message || String(e))
+ } finally {
+ if (!cancelled) setArchiveLoading(false)
+ }
+ }, 280)
+ return () => {
+ cancelled = true
+ clearTimeout(t)
+ }
+ }, [archiveOpen, archiveQ])
+
+ useEffect(() => {
+ let cancelled = false
+ const boot = async () => {
+ try {
+ const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
+ api.listSkills(),
+ api.listFocusAreas(),
+ api.listTrainingStyles(),
+ api.listTrainingTypes(),
+ api.listTargetGroups(),
+ ])
+ if (cancelled) return
+ setSkillsCatalog(skillsData)
+ setFocusAreas(faData)
+ setStyleDirections(sdData)
+ setTrainingTypes(ttData)
+ setTargetGroups(tgData)
+ } catch (e) {
+ if (!cancelled) {
+ console.error(e)
+ toast.error(
+ 'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
+ (e.message || e),
+ )
+ }
+ }
+ }
+ boot()
+ return () => {
+ cancelled = true
+ }
+ }, [toast])
+
+ useEffect(() => {
+ if (!isEdit) {
+ setFormData(emptyForm())
+ setMediaList([])
+ setVariants([])
+ setVariantDraft(emptyVariantDraft())
+ setVariantEditSelection(null)
+ setFormDirty(false)
+ setLoading(false)
+ return
+ }
+ let cancelled = false
+ const load = async () => {
+ setLoading(true)
+ try {
+ const exercise = await api.getExercise(exerciseId)
+ if (cancelled) return
+ setFormData(detailToForm(exercise))
+ setMediaList(exercise.media || [])
+ setVariants((exercise.variants || []).map(apiVariantToRow))
+ setVariantDraft(emptyVariantDraft())
+ setVariantEditSelection(null)
+ setFormDirty(false)
+ } catch (err) {
+ if (!cancelled) {
+ toast.error(err.message || 'Übung nicht ladbar')
+ navigate('/exercises')
+ }
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ }
+ load()
+ return () => {
+ cancelled = true
+ }
+ }, [isEdit, exerciseId, navigate, toast])
+
+ useEffect(() => {
+ if (variantEditSelection == null || variantEditSelection === 'new') return
+ if (!variants.some((v) => v.id === variantEditSelection)) {
+ setVariantEditSelection(null)
+ }
+ }, [variants, variantEditSelection])
+
+ useEffect(() => {
+ if (variantEditSelection != null && variantsDetailsRef.current) {
+ variantsDetailsRef.current.open = true
+ }
+ }, [variantEditSelection])
+
+ const updateFormField = (field, value) => {
+ setFormDirty(true)
+ setFormData((prev) => ({ ...prev, [field]: value }))
+ }
+
+ useEffect(() => {
+ if (formData.visibility !== 'club') return
+ const choices = visibilityClubChoices
+ if (!choices.length) return
+
+ const id =
+ formData.club_id != null && formData.club_id !== '' ? Number(formData.club_id) : NaN
+ const hasValid =
+ Number.isFinite(id) && id > 0 && choices.some((c) => Number(c.id) === id)
+
+ if (hasValid) return
+
+ const fallback = governanceDefaultClubId
+ const next =
+ fallback != null &&
+ Number.isFinite(Number(fallback)) &&
+ choices.some((c) => Number(c.id) === Number(fallback))
+ ? Number(fallback)
+ : Number(choices[0].id)
+
+ setFormData((prev) => {
+ if (prev.visibility !== 'club') return prev
+ if (prev.club_id != null && Number(prev.club_id) === next) return prev
+ return { ...prev, club_id: next }
+ })
+ }, [
+ formData.visibility,
+ formData.club_id,
+ visibilityClubChoices,
+ governanceDefaultClubId,
+ ])
+
+ const [comboStationPickerIx, setComboStationPickerIx] = useState(null)
+ const [comboDropTargetIx, setComboDropTargetIx] = useState(null)
+
+ const reorderCombinationSlots = (fromI, toBeforeIx) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ if (fromI < 0 || fromI >= rows.length) return prev
+ const [moved] = rows.splice(fromI, 1)
+ let insertAt = toBeforeIx
+ if (fromI < toBeforeIx) insertAt = toBeforeIx - 1
+ insertAt = Math.max(0, Math.min(insertAt, rows.length))
+ rows.splice(insertAt, 0, moved)
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const patchComboSlotRow = (idx, patch) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ if (!rows[idx]) return prev
+ rows[idx] = { ...rows[idx], ...patch }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const removeCandidateFromSlot = (slotIdx, exerciseId) => {
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ const row = rows[slotIdx]
+ if (!row) return prev
+ const nextIds = (row.candidate_exercise_ids || []).filter((id) => Number(id) !== Number(exerciseId))
+ const labels = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
+ delete labels[Number(exerciseId)]
+ rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
+ if (!Array.isArray(pickedList) || !pickedList.length) return
+ const rowNow = (formData.combination_slots || [])[slotIdx] || emptyComboSlotRow()
+ const existingIds = Array.isArray(rowNow.candidate_exercise_ids)
+ ? rowNow.candidate_exercise_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n))
+ : []
+ const ordered = [...existingIds]
+ pickedList.forEach((ex) => {
+ if (ex?.id == null) return
+ const id = Number(ex.id)
+ if (!Number.isFinite(id)) return
+ if (!ordered.includes(id)) ordered.push(id)
+ })
+ let nextIds = ordered
+ if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) {
+ toast.info(
+ `Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner Wechsel‑Pool. Überschüssige Auswahl wurde abgeschnitten.`,
+ )
+ nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
+ }
+ setFormDirty(true)
+ setFormData((prev) => {
+ const rows = [...(prev.combination_slots || [])]
+ const row = rows[slotIdx] || emptyComboSlotRow()
+ const labels =
+ row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
+ pickedList.forEach((ex) => {
+ if (ex && ex.id != null) {
+ const id = Number(ex.id)
+ const t = (ex.title || '').trim()
+ if (t) labels[id] = t
+ }
+ })
+ rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
+ return { ...prev, combination_slots: rows }
+ })
+ }
+
+ const addSkillRow = () => {
+ const id = skillPick ? parseInt(skillPick, 10) : null
+ if (!id) {
+ toast.error('Fähigkeit wählen')
+ return
+ }
+ if (formData.skills.some((s) => s.skill_id === id)) {
+ toast.info('Bereits zugeordnet')
+ return
+ }
+ updateFormField('skills', [
+ ...formData.skills,
+ {
+ skill_id: id,
+ is_primary: formData.skills.length === 0,
+ intensity: '',
+ required_level: '',
+ target_level: '',
+ },
+ ])
+ setSkillPick('')
+ }
+
+ const setSkillPrimary = (idx) => {
+ updateFormField(
+ 'skills',
+ formData.skills.map((s, i) => ({ ...s, is_primary: i === idx })),
+ )
+ }
+
+ const updateSkillField = (idx, field, value) => {
+ updateFormField(
+ 'skills',
+ formData.skills.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
+ )
+ }
+
+ const removeSkillRow = (idx) => {
+ const next = formData.skills.filter((_, i) => i !== idx)
+ if (next.length && !next.some((s) => s.is_primary)) next[0].is_primary = true
+ updateFormField('skills', next)
+ }
+
+ const performSaveAttempt = useCallback(
+ async ({ fromUnsavedDialog = false } = {}) => {
+ if (!formData.title || formData.title.trim().length < 3) {
+ toast.error('Titel mindestens 3 Zeichen')
+ return false
+ }
+ const payloadBase = {
+ ...formData,
+ equipment:
+ typeof formData.equipmentLines === 'string'
+ ? formData.equipmentLines
+ .split(/[\n,]+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : [],
+ }
+ let payload
+ try {
+ payload = buildExerciseApiPayload(payloadBase)
+ } catch (err) {
+ toast.error(err.message)
+ return false
+ }
+ setSaving(true)
+ try {
+ if (isEdit) {
+ const saveOnce = (extras = {}) =>
+ api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
+ try {
+ await saveOnce()
+ } catch (firstErr) {
+ if (
+ firstErr.status === 422 &&
+ firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
+ firstErr.payload?.media_assets
+ ) {
+ toast.error(
+ 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
+ 'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
+ )
+ throw firstErr
+ }
+ if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
+ const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
+ const miss = (firstErr.payload.assets_missing_copyright || []).length
+ let msg = 'Die Übung ist oder wird offiziell. '
+ if (promo > 0) {
+ msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
+ }
+ if (miss > 0) {
+ msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
+ }
+ msg += 'Fortfahren?'
+ if (!window.confirm(msg)) throw firstErr
+ let defaultCopyright = ''
+ if (miss > 0) {
+ defaultCopyright = window.prompt(
+ 'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
+ '© ',
+ )
+ if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
+ toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.')
+ throw firstErr
+ }
+ }
+ await saveOnce({
+ promote_attached_media_for_official: true,
+ ...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
+ })
+ } else if (
+ firstErr.status === 422 &&
+ firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
+ firstErr.payload?.media_assets
+ ) {
+ const miss = firstErr.payload.media_assets.length
+ const msg =
+ `Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` +
+ `${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` +
+ `Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?`
+ if (!window.confirm(msg)) throw firstErr
+ const defaultCopyright = window.prompt(
+ 'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):',
+ '© ',
+ )
+ if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
+ toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.')
+ throw firstErr
+ }
+ await saveOnce({
+ default_club_media_copyright: String(defaultCopyright).trim(),
+ })
+ } else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
+ toast.error(
+ 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
+ )
+ throw firstErr
+ } else {
+ throw firstErr
+ }
+ }
+ const ex = await api.getExercise(exerciseId)
+ setMediaList(ex.media || [])
+ setVariants((ex.variants || []).map(apiVariantToRow))
+ setFormDirty(false)
+ toast.success('Gespeichert.')
+ return true
+ }
+ const created = await api.createExercise(payload)
+ setFormDirty(false)
+ toast.success('Übung angelegt.')
+ if (!fromUnsavedDialog) {
+ navigate(`/exercises/${created.id}/edit`, { replace: true })
+ }
+ return true
+ } catch (err) {
+ toast.error('Fehler beim Speichern: ' + err.message)
+ return false
+ } finally {
+ setSaving(false)
+ }
+ },
+ [exerciseId, formData, isEdit, navigate, toast],
+ )
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ await performSaveAttempt({ fromUnsavedDialog: false })
+ }
+
+ const handleUnsavedDialogSave = async () => {
+ const ok = await performSaveAttempt({ fromUnsavedDialog: true })
+ if (ok) blocker.proceed()
+ }
+
+ const refreshMedia = async () => {
+ if (!exerciseId) return
+ const ex = await api.getExercise(exerciseId)
+ setMediaList(ex.media || [])
+ }
+
+ const attachFromArchive = async (assetId) => {
+ if (!exerciseId) return
+ try {
+ await api.attachExerciseMediaFromAsset(exerciseId, {
+ media_asset_id: assetId,
+ context: 'ablauf',
+ title: '',
+ description: '',
+ is_primary: false,
+ })
+ setArchiveOpen(false)
+ await refreshMedia()
+ } catch (e) {
+ toast.error(e.message || String(e))
+ }
+ }
+
+ const linkedArchiveAssetIds = useMemo(
+ () => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)),
+ [mediaList],
+ )
+
+ const handleDeleteMedia = async (mid) => {
+ if (
+ !confirm(
+ 'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.\n\n' +
+ 'Hinweis: Wenn dieser Eintrag noch als Platzhalter im Fließtext steht, zeigt die Vorschau [Medium nicht verfügbar] oder das Speichern der Übung schlägt fehl, bis der Platzhalter entfernt ist.',
+ )
+ ) {
+ return
+ }
+ try {
+ const res = await api.deleteExerciseMedia(exerciseId, mid)
+ await refreshMedia()
+ const oid = res?.orphan_media_asset_id
+ if (oid != null) {
+ if (
+ confirm(
+ 'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
+ )
+ ) {
+ await api.postMediaAssetLifecycle(oid, 'trash_soft')
+ await refreshMedia()
+ }
+ }
+ } catch (err) {
+ toast.error(err.message)
+ }
+ }
+
+ const moveMediaRow = async (idx, dir) => {
+ if (!exerciseId) return
+ const j = idx + dir
+ if (j < 0 || j >= mediaList.length) return
+ const next = [...mediaList]
+ const tmp = next[idx]
+ next[idx] = next[j]
+ next[j] = tmp
+ try {
+ await api.reorderExerciseMedia(
+ exerciseId,
+ next.map((x) => x.id),
+ )
+ setMediaList(next)
+ } catch (e) {
+ toast.error(e.message || String(e))
+ }
+ }
+
+ const saveMediaMeta = async (mid) => {
+ if (!exerciseId) return
+ const fld = mediaFields[mid]
+ if (!fld) return
+ setMediaSavingId(mid)
+ try {
+ await api.updateExerciseMedia(exerciseId, mid, {
+ title: fld.title.trim() || null,
+ })
+ await refreshMedia()
+ } catch (e) {
+ toast.error(e.message || String(e))
+ } finally {
+ setMediaSavingId(null)
+ }
+ }
+
+ const refreshVariants = async () => {
+ if (!exerciseId) return
+ const ex = await api.getExercise(exerciseId)
+ setVariants((ex.variants || []).map(apiVariantToRow))
+ }
+
+ const updateVariantField = (id, patch) => {
+ setFormDirty(true)
+ setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
+ }
+
+ const saveVariantRow = async (row) => {
+ const payload = buildVariantPayloadFromRow(row)
+ if (payload.variant_name.length < 3) {
+ toast.error('Variantenname mindestens 3 Zeichen')
+ return
+ }
+ setVariantSavingId(row.id)
+ try {
+ await api.updateExerciseVariant(exerciseId, row.id, payload)
+ await refreshVariants()
+ } catch (e) {
+ toast.error(e.message || String(e))
+ } finally {
+ setVariantSavingId(null)
+ }
+ }
+
+ const deleteVariantRow = async (id) => {
+ if (!confirm('Variante wirklich löschen?')) return
+ setVariantBusy(true)
+ try {
+ await api.deleteExerciseVariant(exerciseId, id)
+ if (variantEditSelection === id) setVariantEditSelection(null)
+ await refreshVariants()
+ } catch (e) {
+ toast.error(e.message || String(e))
+ } finally {
+ setVariantBusy(false)
+ }
+ }
+
+ const moveVariantRow = async (idx, dir) => {
+ const j = idx + dir
+ if (j < 0 || j >= variants.length) return
+ const next = [...variants]
+ const tmp = next[idx]
+ next[idx] = next[j]
+ next[j] = tmp
+ const ids = next.map((x) => x.id)
+ setVariantBusy(true)
+ try {
+ await api.reorderExerciseVariants(exerciseId, ids)
+ await refreshVariants()
+ } catch (e) {
+ toast.error(e.message || String(e))
+ } finally {
+ setVariantBusy(false)
+ }
+ }
+
+ const createVariantSubmit = async (e) => {
+ e.preventDefault()
+ if (!exerciseId) return
+ const payload = buildVariantPayloadFromRow(variantDraft)
+ if (payload.variant_name.length < 3) {
+ toast.error('Variantenname mindestens 3 Zeichen')
+ return
+ }
+ setVariantBusy(true)
+ try {
+ const created = await api.createExerciseVariant(exerciseId, payload)
+ setVariantDraft(emptyVariantDraft())
+ await refreshVariants()
+ if (created?.id != null) setVariantEditSelection(created.id)
+ else setVariantEditSelection(null)
+ } catch (err) {
+ toast.error(err.message || String(err))
+ } finally {
+ setVariantBusy(false)
+ }
+ }
+
+ const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
+
+ const selectedVariantForEdit =
+ typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
+ const selectedVariantIdx = selectedVariantForEdit
+ ? variants.findIndex((v) => v.id === selectedVariantForEdit.id)
+ : -1
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ navigate('/exercises')}>
+ ← Übersicht
+
+ {isEdit && (
+ navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })}
+ >
+ Ansehen
+
+ )}
+
+
+
+
{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}
+
+
+
+
+ {isEdit && formData.exercise_kind !== 'combination' && (
+
+
+ Übungsvarianten
+
+ {variants.length === 0
+ ? 'keine'
+ : `${variants.length} ${variants.length === 1 ? 'Variante' : 'Varianten'}`}
+
+
+
+
+ Pro Durchgang nur eine Variante bearbeiten – weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
+ Training; „Voraussetzung“ nutzt ihr später für Progressions-Serien.
+
+
+ {variants.length > 0 && (
+
+
+ Variante auswählen
+
+ {
+ const val = e.target.value
+ if (val === '') setVariantEditSelection(null)
+ else if (val === 'new') setVariantEditSelection('new')
+ else setVariantEditSelection(parseInt(val, 10))
+ }}
+ >
+ — nicht bearbeiten —
+ {variants.map((v) => (
+
+ {(v.variant_name && String(v.variant_name).trim()) || `Variante #${v.id}`}
+
+ ))}
+ + Neue Variante anlegen…
+
+
+ )}
+
+ {variants.length === 0 && (
+
+ Noch keine Varianten – optional für andere Ausführung, Dauer oder Material in Planung und Training.
+
+ )}
+
+ {variants.length === 0 && variantEditSelection !== 'new' && (
+
setVariantEditSelection('new')}>
+ Erste Variante anlegen
+
+ )}
+
+ {variantEditSelection === 'new' && (
+
+ Neue Variante
+ {
+ setFormDirty(true)
+ setVariantDraft((d) => ({ ...d, ...patch }))
+ }}
+ prerequisiteOthers={variants}
+ rteMinHeight="110px"
+ inlineExerciseId={isEdit ? exerciseId : null}
+ linkedExerciseMedia={isEdit ? mediaList : []}
+ onExerciseMediaListChanged={refreshMedia}
+ />
+
+ {variantBusy ? 'Anlegen…' : 'Variante anlegen'}
+
+
+ )}
+
+ {selectedVariantForEdit && (
+
+
+
+ Pos. {selectedVariantIdx + 1} von {variants.length}
+
+ moveVariantRow(selectedVariantIdx, -1)}
+ >
+ Nach oben
+
+ = variants.length - 1}
+ onClick={() => moveVariantRow(selectedVariantIdx, 1)}
+ >
+ Nach unten
+
+ saveVariantRow(selectedVariantForEdit)}
+ >
+ {variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Speichern'}
+
+ deleteVariantRow(selectedVariantForEdit.id)}
+ >
+ Löschen
+
+
+
updateVariantField(selectedVariantForEdit.id, patch)}
+ prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
+ rteMinHeight="110px"
+ inlineExerciseId={isEdit ? exerciseId : null}
+ linkedExerciseMedia={isEdit ? mediaList : []}
+ onExerciseMediaListChanged={refreshMedia}
+ />
+
+ )}
+
+ {variants.length > 0 && variantEditSelection == null && (
+
+ Wähle eine Variante zum Bearbeiten oder „Neue Variante anlegen…“.
+
+ )}
+
+
+ )}
+
+ {isEdit && formData.exercise_kind !== 'combination' && (
+
+
+ Progressionsgraph
+ Übung → Übung
+
+
+
+
+
+ )}
+
+ {isEdit && (
+
+
Medien
+
+ Neue Uploads oder Embeds über die Textfeld-Symbolleiste („Medien im Text“ / „Embed im Text“). Hier
+ verwaltest du Verknüpfungen — Kachel in ein Textfeld ziehen, um sie an der Cursorposition einzufügen
+ (mittlere Darstellung).
+
+
+ Max. 10 Medien pro Übung.
+
+
+ setArchiveOpen(true)}>
+ Aus Archiv verknüpfen…
+
+
+ Medienbibliothek
+
+
+ {mediaList.length > 0 && (
+
+ {mediaList.map((m, idx) => {
+ const cap =
+ (m.title || '').trim() ||
+ (m.original_filename || '').trim() ||
+ (m.embed_url ? String(m.embed_url).replace(/^https?:\/\//i, '').slice(0, 80) : '')
+ const sub = [m.media_type, m.embed_platform].filter(Boolean).join(' · ') || 'Medium'
+ const payloadCaption = (
+ [m.title, m.original_filename].find((x) => typeof x === 'string' && x.trim()) || ''
+ ).trim()
+ return (
+
+
+ {!m.embed_url ? (
+
+ ) : (
+
+ {m.embed_platform || 'Embed'}
+
+ )}
+
{
+ try {
+ e.dataTransfer.setData(
+ SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
+ buildExerciseMediaDragPayload(m.id, payloadCaption),
+ )
+ e.dataTransfer.effectAllowed = 'copy'
+ } catch (_) {
+ /* ignore */
+ }
+ }}
+ >
+ ⣿ Ziehen
+
+
+
+
+
+ #{m.id} · {sub}
+
+
+
{cap || '—'}
+
+
+ setMediaFields((prev) => ({
+ ...prev,
+ [m.id]: {
+ title: e.target.value,
+ },
+ }))
+ }
+ />
+
+
+ {mediaList.length > 1 && (
+ <>
+ moveMediaRow(idx, -1)}
+ title="Nach oben"
+ >
+ ↑
+
+ = mediaList.length - 1}
+ onClick={() => moveMediaRow(idx, 1)}
+ title="Nach unten"
+ >
+ ↓
+
+ >
+ )}
+ saveMediaMeta(m.id)}
+ >
+ {mediaSavingId === m.id ? '…' : 'Speichern'}
+
+ handleDeleteMedia(m.id)}
+ >
+ Entfernen
+
+
+
+
+ )
+ })}
+
+ )}
+
+ Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über
+ Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
+
+ {archiveOpen && (
+
setArchiveOpen(false)}
+ onKeyDown={(e) => e.key === 'Escape' && setArchiveOpen(false)}
+ >
+
e.stopPropagation()}
+ >
+
Medienarchiv
+
setArchiveQ(e.target.value)}
+ style={{ marginBottom: '8px' }}
+ />
+ {archiveLoading &&
Laden…
}
+ {archiveError &&
{archiveError}
}
+ {!archiveLoading && !archiveError && archiveItems.length === 0 && (
+
Keine Treffer.
+ )}
+
+ {archiveItems.map((a) => {
+ const already = linkedArchiveAssetIds.has(a.id)
+ return (
+
+
+ {a.mime_type?.startsWith('image/') ? (
+
+ ) : a.mime_type?.startsWith('video/') ? (
+
+ ▶
+
+ ) : (
+
+ PDF
+
+ )}
+
+
+
+ {a.original_filename || `Asset #${a.id}`}
+
+
+ {a.visibility} · {a.mime_type || '—'}{' '}
+ {a.byte_size != null ? `· ${(a.byte_size / 1024).toFixed(0)} KB` : ''}
+
+
+ !already && attachFromArchive(a.id)}
+ >
+ {already ? 'Bereits verknüpft' : 'Verknüpfen'}
+
+
+ )
+ })}
+
+
+ setArchiveOpen(false)}>
+ Schließen
+
+
+
+
+ )}
+ {mediaPreview && (
+
setMediaPreview(null)}
+ onReport={
+ !mediaPreview.asset_legal_hold_active
+ ? () => {
+ setReportTarget(mediaPreview)
+ setMediaPreview(null)
+ }
+ : null
+ }
+ />
+ )}
+ {reportTarget && (
+ setReportTarget(null)}
+ />
+ )}
+
+ )}
+
+
setComboStationPickerIx(null)}
+ exerciseKindAny={['simple']}
+ multiSelect
+ enableQuickCreateDraft
+ onSelectExercises={(picked) => {
+ if (comboStationPickerIx === null) return
+ mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
+ setComboStationPickerIx(null)
+ }}
+ />
+
+
+ KI-Ausbaustufe: Backend laut Spec{' '}
+ POST /api/exercises/ai/suggest und{' '}
+ POST /api/exercises/{'{id}'}/ai/regenerate — z. B.{' '}
+ OPENROUTER_API_KEY, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
+ api.suggestExerciseAi).
+
+ setFormDirty(false)}
+ />
+
+ )
+}
+
+export default ExerciseFormPageRoot
diff --git a/frontend/src/components/exercises/ExercisesListPageRoot.jsx b/frontend/src/components/exercises/ExercisesListPageRoot.jsx
new file mode 100644
index 0000000..aee8e91
--- /dev/null
+++ b/frontend/src/components/exercises/ExercisesListPageRoot.jsx
@@ -0,0 +1,590 @@
+import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../../utils/api'
+import { useAuth } from '../../context/AuthContext'
+import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
+import PageSectionNav from '../PageSectionNav'
+import ExerciseListCard from './ExerciseListCard'
+import ExerciseListFilterModal from './ExerciseListFilterModal'
+import ExerciseListBulkModal from './ExerciseListBulkModal'
+import ExerciseListSearchBar from './ExerciseListSearchBar'
+import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
+import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
+import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
+import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
+import {
+ INITIAL_EXERCISE_LIST_FILTERS,
+ mergeExerciseListPrefsFromApi,
+ compactExerciseListPrefsPayload,
+} from '../../constants/exerciseListFilters'
+
+const ExerciseProgressionGraphPanel = lazy(() => import('../ExerciseProgressionGraphPanel'))
+
+const BULK_MAX_IDS = 500
+const EXERCISES_PAGE_TABS = [
+ { id: 'list', label: 'Liste' },
+ { id: 'progression', label: 'Progressionsgraphen' },
+]
+
+function ExercisesListPageRoot() {
+ const { user, checkAuth } = useAuth()
+ const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
+ const isSuperadmin = user?.role === 'superadmin'
+ const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
+
+ const [mineOnly, setMineOnly] = useState(() => {
+ try {
+ const sp = new URLSearchParams(window.location.search)
+ return sp.get('mine') === '1' || sp.get('created_by_me') === '1'
+ } catch {
+ return false
+ }
+ })
+
+ const [searchInput, setSearchInput] = useState('')
+ const [aiSearchInput, setAiSearchInput] = useState('')
+ const [debouncedSearch, setDebouncedSearch] = useState('')
+ const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
+ const [filters, setFilters] = useState(() => ({ ...INITIAL_EXERCISE_LIST_FILTERS }))
+ const [filterModalOpen, setFilterModalOpen] = useState(false)
+ const [savingExercisePrefs, setSavingExercisePrefs] = useState(false)
+ const [pageTab, setPageTab] = useState('list')
+ const prefsAppliedRef = useRef(false)
+
+ const [selectedIds, setSelectedIds] = useState(() => new Set())
+ const [bulkModalOpen, setBulkModalOpen] = useState(false)
+ const [bulkVisibility, setBulkVisibility] = useState('')
+ const [bulkStatus, setBulkStatus] = useState('')
+ const [bulkClubSelect, setBulkClubSelect] = useState('')
+ const [bulkClubManual, setBulkClubManual] = useState('')
+ const [bulkSubmitting, setBulkSubmitting] = useState(false)
+ const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false)
+ const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([])
+ const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false)
+ const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([])
+ const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false)
+ const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
+ const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
+ const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
+
+ useEffect(() => {
+ if (!user?.id) return
+ if (prefsAppliedRef.current) return
+ const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
+ setFilters(applyDashboardExerciseListUrl(merged))
+ try {
+ const sp = new URLSearchParams(window.location.search)
+ if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
+ } catch {
+ /* ignore */
+ }
+ prefsAppliedRef.current = true
+ }, [user?.id, user?.exercise_list_prefs])
+
+ useEffect(() => {
+ if (!user?.id) prefsAppliedRef.current = false
+ }, [user?.id])
+
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
+ return () => clearTimeout(t)
+ }, [searchInput])
+
+ useEffect(() => {
+ const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
+ return () => clearTimeout(t)
+ }, [aiSearchInput])
+
+ useEffect(() => {
+ if (!filterModalOpen) return
+ const onKey = (e) => {
+ if (e.key === 'Escape') setFilterModalOpen(false)
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [filterModalOpen])
+
+ const queryBase = useMemo(
+ () => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly),
+ [filters, debouncedSearch, debouncedAiSearch, mineOnly]
+ )
+
+ const {
+ catalogs,
+ catalogsReady,
+ exercises,
+ setExercises,
+ listFetching,
+ loadingMore,
+ hasMore,
+ loadMore,
+ } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
+
+ useEffect(() => {
+ setSelectedIds(new Set())
+ }, [queryBase])
+
+ const focusOptions = useMemo(
+ () =>
+ catalogs.focusAreas.map((fa) => ({
+ id: fa.id,
+ label: `${fa.icon || ''} ${fa.name || ''}`.trim(),
+ })),
+ [catalogs.focusAreas]
+ )
+ const styleOptions = useMemo(
+ () => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
+ [catalogs.styleDirections]
+ )
+ const trainingTypeOptions = useMemo(
+ () => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
+ [catalogs.trainingTypes]
+ )
+ const targetGroupOptions = useMemo(
+ () => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
+ [catalogs.targetGroups]
+ )
+ const skillOptions = useMemo(
+ () => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
+ [catalogs.skills]
+ )
+ const visibilityOptions = useMemo(
+ () => [
+ { id: 'private', label: 'Privat' },
+ { id: 'club', label: 'Verein' },
+ { id: 'official', label: 'Offiziell' },
+ ],
+ []
+ )
+ const statusOptions = useMemo(
+ () => [
+ { id: 'draft', label: 'Entwurf' },
+ { id: 'in_review', label: 'In Prüfung' },
+ { id: 'approved', label: 'Freigegeben' },
+ { id: 'archived', label: 'Archiviert' },
+ ],
+ []
+ )
+
+ const filterChips = useMemo(
+ () =>
+ buildExerciseListFilterChips({
+ mineOnly,
+ setMineOnly,
+ filters,
+ setFilters,
+ focusOptions,
+ styleOptions,
+ trainingTypeOptions,
+ targetGroupOptions,
+ skillOptions,
+ visibilityOptions,
+ statusOptions,
+ }),
+ [
+ mineOnly,
+ filters,
+ focusOptions,
+ styleOptions,
+ trainingTypeOptions,
+ targetGroupOptions,
+ skillOptions,
+ visibilityOptions,
+ statusOptions,
+ ]
+ )
+
+ /** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
+ const searchTitleSuggestions = useMemo(() => {
+ const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
+ return [...new Set(titles)].slice(0, 80)
+ }, [exercises])
+
+ const clubNameById = useMemo(() => {
+ const m = {}
+ for (const c of activeClubMemberships(user?.clubs)) {
+ if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
+ }
+ return m
+ }, [user?.clubs])
+
+ const effectiveClubId =
+ user?.effective_club_id != null && user.effective_club_id !== ''
+ ? Number(user.effective_club_id)
+ : user?.active_club_id != null && user.active_club_id !== ''
+ ? Number(user.active_club_id)
+ : null
+
+ const toggleSelect = useCallback((id) => {
+ setSelectedIds((prev) => {
+ const n = new Set(prev)
+ const nid = Number(id)
+ if (Number.isNaN(nid)) return prev
+ if (n.has(nid)) n.delete(nid)
+ else n.add(nid)
+ return n
+ })
+ }, [])
+
+ const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
+
+ const toggleSelectAllPage = useCallback(() => {
+ setSelectedIds((prev) => {
+ const n = new Set(prev)
+ const allSel =
+ exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
+ if (allSel) {
+ exercises.forEach((e) => n.delete(Number(e.id)))
+ } else {
+ exercises.forEach((e) => n.add(Number(e.id)))
+ }
+ return n
+ })
+ }, [exercises])
+
+ const allOnPageSelected = useMemo(
+ () => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
+ [exercises, selectedIds]
+ )
+
+ const bulkVisibilityOptions = useMemo(() => {
+ const base = [
+ { id: '', label: '— nicht ändern —' },
+ { id: 'private', label: 'Privat' },
+ { id: 'club', label: 'Verein' },
+ ]
+ if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
+ return base
+ }, [isSuperadmin])
+
+ const handleDelete = async (exercise) => {
+ if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
+ try {
+ await api.deleteExercise(exercise.id)
+ setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
+ } catch (err) {
+ alert('Fehler beim Löschen: ' + err.message)
+ }
+ }
+
+ const resetAllFilters = useCallback(() => {
+ setMineOnly(false)
+ setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS })
+ }, [])
+
+ const handleSaveExerciseFilterPrefs = useCallback(async () => {
+ const uid = user?.id
+ if (!uid) {
+ alert('Nicht angemeldet.')
+ return
+ }
+ setSavingExercisePrefs(true)
+ try {
+ const payload = compactExerciseListPrefsPayload(filters)
+ await api.updateProfile(uid, { exercise_list_prefs: payload })
+ await checkAuth()
+ alert('Standardfilter für die Übungsliste gespeichert.')
+ } catch (e) {
+ alert('Speichern fehlgeschlagen: ' + (e.message || String(e)))
+ } finally {
+ setSavingExercisePrefs(false)
+ }
+ }, [user?.id, filters, checkAuth])
+
+ const openBulkModal = () => {
+ setBulkVisibility('')
+ setBulkStatus('')
+ setBulkClubSelect('')
+ setBulkClubManual('')
+ setBulkPatchFocusAreas(false)
+ setBulkFocusAreaIds([])
+ setBulkPatchStyleDirections(false)
+ setBulkStyleDirectionIds([])
+ setBulkPatchTrainingTypes(false)
+ setBulkTrainingTypeIds([])
+ setBulkPatchTargetGroups(false)
+ setBulkTargetGroupIds([])
+ setBulkModalOpen(true)
+ }
+
+ const handleBulkSubmit = async () => {
+ const anyRelationPatch =
+ bulkPatchFocusAreas ||
+ bulkPatchStyleDirections ||
+ bulkPatchTrainingTypes ||
+ bulkPatchTargetGroups
+ if (!bulkVisibility && !bulkStatus && !anyRelationPatch) {
+ alert(
+ 'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).'
+ )
+ return
+ }
+ const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
+ if (ids.length === 0) {
+ alert('Keine Übungen ausgewählt.')
+ return
+ }
+ if (ids.length > BULK_MAX_IDS) {
+ alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
+ return
+ }
+ const payload = { exercise_ids: ids }
+ if (bulkVisibility) payload.visibility = bulkVisibility
+ if (bulkStatus) payload.status = bulkStatus
+ if (bulkPatchFocusAreas) {
+ payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
+ }
+ if (bulkPatchStyleDirections) {
+ payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
+ }
+ if (bulkPatchTrainingTypes) {
+ payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
+ }
+ if (bulkPatchTargetGroups) {
+ payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
+ }
+ if (bulkVisibility === 'club') {
+ const manual = String(bulkClubManual || '').trim()
+ if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
+ else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
+ payload.club_id = Number(bulkClubSelect)
+ }
+ }
+ setBulkSubmitting(true)
+ try {
+ const res = await api.bulkPatchExercisesMetadata(payload)
+ const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
+ let resolvedClubId = null
+ if (bulkVisibility === 'club') {
+ if (payload.club_id != null) resolvedClubId = payload.club_id
+ else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
+ }
+ const clubLabel =
+ resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
+
+ let nextPrimaryFocusName = null
+ if (bulkPatchFocusAreas) {
+ const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
+ if (faNums.length > 0) {
+ const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0]))
+ nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0])
+ }
+ }
+
+ setExercises((prev) =>
+ prev.map((e) => {
+ if (!updatedSet.has(Number(e.id))) return e
+ const next = { ...e }
+ if (bulkVisibility) {
+ next.visibility = bulkVisibility
+ next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
+ next.club_name = bulkVisibility === 'club' ? clubLabel : null
+ }
+ if (bulkStatus) next.status = bulkStatus
+ if (bulkPatchFocusAreas) {
+ if (nextPrimaryFocusName == null) delete next.focus_area
+ else next.focus_area = nextPrimaryFocusName
+ }
+ return next
+ })
+ )
+
+ let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
+ if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
+ if (Array.isArray(res.failed) && res.failed.length) {
+ msg +=
+ '\n\n' +
+ res.failed
+ .slice(0, 12)
+ .map((f) => `#${f.id}: ${f.detail}`)
+ .join('\n')
+ if (res.failed.length > 12) msg += '\n…'
+ }
+ alert(msg)
+ setBulkModalOpen(false)
+ clearSelection()
+ } catch (err) {
+ alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
+ } finally {
+ setBulkSubmitting(false)
+ }
+ }
+
+ if (!catalogsReady && pageTab === 'list') {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
Übungen
+ {pageTab === 'list' ? (
+
+ + Neu
+
+ ) : (
+
+ )}
+
+
+
+
+ {pageTab === 'progression' ? (
+
+
+
+ Lade Progressionsgraphen…
+
+
+ }
+ >
+
+
+ ) : (
+ <>
+ setMineOnly((v) => !v)}
+ onOpenFilter={() => setFilterModalOpen(true)}
+ filterChips={filterChips}
+ onResetAllFilters={resetAllFilters}
+ exerciseCount={exercises.length}
+ allOnPageSelected={allOnPageSelected}
+ onToggleSelectAllPage={toggleSelectAllPage}
+ />
+
+
+
+ setFilterModalOpen(false)}
+ filters={filters}
+ setFilters={setFilters}
+ focusOptions={focusOptions}
+ styleOptions={styleOptions}
+ trainingTypeOptions={trainingTypeOptions}
+ targetGroupOptions={targetGroupOptions}
+ skillOptions={skillOptions}
+ visibilityOptions={visibilityOptions}
+ statusOptions={statusOptions}
+ savingExercisePrefs={savingExercisePrefs}
+ onSaveStandard={handleSaveExerciseFilterPrefs}
+ onResetAll={resetAllFilters}
+ />
+
+ setBulkModalOpen(false)}
+ onSubmit={handleBulkSubmit}
+ bulkSubmitting={bulkSubmitting}
+ selectedCount={selectedIds.size}
+ bulkMaxIds={BULK_MAX_IDS}
+ user={user}
+ isPlatformAdmin={isPlatformAdmin}
+ statusOptions={statusOptions}
+ bulkVisibilityOptions={bulkVisibilityOptions}
+ focusOptions={focusOptions}
+ styleOptions={styleOptions}
+ trainingTypeOptions={trainingTypeOptions}
+ targetGroupOptions={targetGroupOptions}
+ bulkVisibility={bulkVisibility}
+ setBulkVisibility={setBulkVisibility}
+ bulkStatus={bulkStatus}
+ setBulkStatus={setBulkStatus}
+ bulkClubSelect={bulkClubSelect}
+ setBulkClubSelect={setBulkClubSelect}
+ bulkClubManual={bulkClubManual}
+ setBulkClubManual={setBulkClubManual}
+ bulkPatchFocusAreas={bulkPatchFocusAreas}
+ setBulkPatchFocusAreas={setBulkPatchFocusAreas}
+ bulkFocusAreaIds={bulkFocusAreaIds}
+ setBulkFocusAreaIds={setBulkFocusAreaIds}
+ bulkPatchStyleDirections={bulkPatchStyleDirections}
+ setBulkPatchStyleDirections={setBulkPatchStyleDirections}
+ bulkStyleDirectionIds={bulkStyleDirectionIds}
+ setBulkStyleDirectionIds={setBulkStyleDirectionIds}
+ bulkPatchTrainingTypes={bulkPatchTrainingTypes}
+ setBulkPatchTrainingTypes={setBulkPatchTrainingTypes}
+ bulkTrainingTypeIds={bulkTrainingTypeIds}
+ setBulkTrainingTypeIds={setBulkTrainingTypeIds}
+ bulkPatchTargetGroups={bulkPatchTargetGroups}
+ setBulkPatchTargetGroups={setBulkPatchTargetGroups}
+ bulkTargetGroupIds={bulkTargetGroupIds}
+ setBulkTargetGroupIds={setBulkTargetGroupIds}
+ />
+
+ {listFetching && exercises.length === 0 ? (
+
+ ) : exercises.length === 0 ? (
+
+
Keine Übungen gefunden.
+
+ ) : (
+ <>
+ {listFetching ? (
+ Aktualisiere Treffer…
+ ) : null}
+
+ {exercises.length} angezeigt
+ {hasMore ? ' · es gibt weitere Einträge' : ''}
+
+
+ {exercises.map((exercise) => (
+
+ ))}
+
+ {hasMore && (
+
+
+ {loadingMore ? 'Laden…' : 'Mehr laden'}
+
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ )
+}
+
+export default ExercisesListPageRoot
diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx
new file mode 100644
index 0000000..3a49e7c
--- /dev/null
+++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx
@@ -0,0 +1,2022 @@
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
+import { Link, useSearchParams } from 'react-router-dom'
+import api from '../../utils/api'
+import { useAuth } from '../../context/AuthContext'
+import { useToast } from '../../context/ToastContext'
+import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
+import ExercisePickerModal from '../ExercisePickerModal'
+import ExercisePeekModal from '../ExercisePeekModal'
+import PageSectionNav from '../PageSectionNav'
+import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImportModal'
+import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
+import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
+import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
+import {
+ defaultSection,
+ normalizeUnitToForm,
+ enrichSectionsWithVariants,
+ buildSectionsPayload,
+ hydrateExercisePlanningRow,
+ insertTrainingModuleIntoPlanningSections,
+} from '../../utils/trainingUnitSectionsForm'
+import {
+ addDaysIsoDate,
+ pad2,
+ toIsoLocal,
+ mondayIndex,
+ getCalendarGridRange,
+ shiftCalendarMonth,
+ enumerateIsoDays,
+ WEEKDAYS_DE,
+ toNumList,
+ sessionAssignDefaults,
+ normalizeGroupCoTrainerIds,
+ filterDirectoryExcludingLead,
+ frameworkLineageText,
+} from '../../utils/trainingPlanningPageHelpers'
+
+function TrainingPlanningPageRoot() {
+ const { user } = useAuth()
+ const toast = useToast()
+ const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
+ const [searchParams, setSearchParams] = useSearchParams()
+ const unitDeepLinkHandledRef = useRef(null)
+ const [groups, setGroups] = useState([])
+ const [selectedGroupId, setSelectedGroupId] = useState('')
+ const [units, setUnits] = useState([])
+ const [planTemplates, setPlanTemplates] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showModal, setShowModal] = useState(false)
+ const [editingUnit, setEditingUnit] = useState(null)
+ /** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
+ const [sectionsEditMode, setSectionsEditMode] = useState('planning')
+ const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
+ const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
+ const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
+ const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
+
+ const today = new Date().toISOString().split('T')[0]
+ const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
+
+ const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
+ const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
+ const [fwImportProgramId, setFwImportProgramId] = useState('')
+ const [fwImportDetail, setFwImportDetail] = useState(null)
+ const [fwImportLoading, setFwImportLoading] = useState(false)
+ const [fwImportSelectedSlots, setFwImportSelectedSlots] = useState(() => new Set())
+ const [fwImportSlotDates, setFwImportSlotDates] = useState({})
+ const [fwImportStartDate, setFwImportStartDate] = useState(today)
+ const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
+ const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
+
+ const [moduleApplyOpen, setModuleApplyOpen] = useState(false)
+ const [moduleApplyBusy, setModuleApplyBusy] = useState(false)
+ const [moduleApplyList, setModuleApplyList] = useState([])
+ const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
+ const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
+ const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0')
+ const [moduleApplyErr, setModuleApplyErr] = useState('')
+ const [moduleApplyPlacementLocked, setModuleApplyPlacementLocked] = useState(false)
+ const [moduleApplySearchQuery, setModuleApplySearchQuery] = useState('')
+ const [modulePickPreview, setModulePickPreview] = useState({
+ loading: false,
+ moduleId: '',
+ exercises: [],
+ notes: 0,
+ err: '',
+ })
+
+ const [startDate, setStartDate] = useState(today)
+ const [endDate, setEndDate] = useState(thirtyDaysLater)
+ const [planView, setPlanView] = useState('list')
+ const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7))
+ const [planScope, setPlanScope] = useState('group')
+ const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
+ const [clubDirectory, setClubDirectory] = useState([])
+ const [assignModalOpen, setAssignModalOpen] = useState(false)
+ const [assignDraft, setAssignDraft] = useState({
+ unit: null,
+ lead_trainer_profile_id: '',
+ session_assistants_inherit: true,
+ session_assistant_profile_ids: [],
+ })
+ const [assignSaving, setAssignSaving] = useState(false)
+
+ const [formData, setFormData] = useState({
+ group_id: '',
+ planned_date: '',
+ planned_time_start: '',
+ planned_time_end: '',
+ planned_focus: '',
+ actual_date: '',
+ actual_time_start: '',
+ actual_time_end: '',
+ attendance_count: '',
+ status: 'planned',
+ notes: '',
+ trainer_notes: '',
+ debrief_completed: false,
+ sections: [defaultSection()],
+ ...sessionAssignDefaults()
+ })
+ const planningFormRef = useRef(formData)
+ planningFormRef.current = formData
+
+ const moduleApplyFilteredList = useMemo(() => {
+ const q = moduleApplySearchQuery.trim().toLowerCase().replace(/\s+/g, ' ')
+ const words = q ? q.split(' ').filter(Boolean) : []
+ const list = Array.isArray(moduleApplyList) ? moduleApplyList : []
+ if (!words.length) return list
+ return list.filter((m) => {
+ const blob = [
+ m.title,
+ m.summary,
+ m.goal,
+ m.target_group_notes,
+ m.deployment_context_notes,
+ ]
+ .map((x) => String(x ?? '').toLowerCase())
+ .join('\n')
+ return words.every((w) => blob.includes(w))
+ })
+ }, [moduleApplySearchQuery, moduleApplyList])
+
+ const modulePlacementSummary = useMemo(() => {
+ const secs = Array.isArray(formData.sections) ? formData.sections : []
+ let si =
+ typeof moduleApplySectionIx === 'number'
+ ? moduleApplySectionIx
+ : parseInt(String(moduleApplySectionIx), 10)
+ if (!Number.isFinite(si)) si = 0
+ si = Math.max(0, Math.min(si, secs.length ? secs.length - 1 : 0))
+ const cap = secs[si]?.items?.length ?? 0
+ let beforeIx = cap
+ if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
+ const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
+ if (Number.isFinite(zi)) beforeIx = Math.min(Math.max(0, zi), cap)
+ }
+ const rawTitle = (secs[si]?.title || '').trim()
+ const secTitle = rawTitle || `Abschnitt ${si + 1}`
+ let positionDescription
+ if (cap <= 0) positionDescription = 'als erste Einträge dieses Abschnitts'
+ else if (beforeIx <= 0) positionDescription = 'vor dem ersten Eintrag dieses Abschnitts'
+ else if (beforeIx >= cap) positionDescription = 'nach dem letzten Eintrag dieses Abschnitts'
+ else positionDescription = `unmittelbar vor Eintrag ${beforeIx + 1} (${cap} Einträge im Abschnitt)`
+ return { secTitle, positionDescription }
+ }, [formData.sections, moduleApplySectionIx, moduleApplyInsertSlot])
+
+ useEffect(() => {
+ if (!moduleApplyOpen || !moduleApplyFilteredList.length) return
+ if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return
+ setModuleApplyModuleId(String(moduleApplyFilteredList[0].id))
+ }, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId])
+
+ const planningModalClubId = useMemo(() => {
+ const gid = Number(formData.group_id)
+ if (!Number.isFinite(gid) || gid < 1) return null
+ const g = groups.find((x) => Number(x.id) === gid)
+ if (!g || g.club_id == null || g.club_id === '') return null
+ const c = Number(g.club_id)
+ return Number.isFinite(c) ? c : null
+ }, [groups, formData.group_id])
+
+ const moduleApplyTargetItems = useMemo(() => {
+ const secs = formData.sections || []
+ if (!secs.length) return []
+ let ix =
+ typeof moduleApplySectionIx === 'number'
+ ? moduleApplySectionIx
+ : parseInt(String(moduleApplySectionIx), 10)
+ if (!Number.isFinite(ix)) ix = 0
+ if (ix < 0 || ix >= secs.length) return []
+ const sec = secs[ix]
+ return Array.isArray(sec?.items) ? sec.items : []
+ }, [formData.sections, moduleApplySectionIx])
+
+ const refreshPlanningSectionMeta = useCallback(async () => {
+ const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
+ setFormData((prev) => ({ ...prev, sections: next }))
+ }, [])
+
+ const loadPlanTemplates = useCallback(async () => {
+ try {
+ const tpl = await api.listTrainingPlanTemplates()
+ setPlanTemplates(tpl)
+ } catch (e) {
+ console.error('Vorlagen laden:', e)
+ }
+ }, [])
+
+ const loadData = useCallback(async () => {
+ try {
+ const groupsData = await api.listTrainingGroups({ status: 'active' })
+ setGroups(groupsData)
+ await loadPlanTemplates()
+
+ if (groupsData.length > 0) {
+ setSelectedGroupId((prev) => {
+ const prevStr = prev != null && prev !== '' ? String(prev) : ''
+ const stillThere = prevStr && groupsData.some((g) => String(g.id) === prevStr)
+ if (stillThere) return prevStr
+ const ownGroup = groupsData.find((g) => g.trainer_id === user?.id)
+ if (ownGroup) return String(ownGroup.id)
+ if (groupsData.length === 1) return String(groupsData[0].id)
+ return ''
+ })
+ } else {
+ setSelectedGroupId('')
+ }
+ } catch (err) {
+ console.error('Failed to load data:', err)
+ toast.error('Fehler beim Laden: ' + err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [user?.id, loadPlanTemplates])
+
+ const loadUnits = useCallback(async () => {
+ if (!selectedGroupId) return
+ let start = startDate
+ let end = endDate
+ if (planView === 'calendar') {
+ const r = getCalendarGridRange(calendarMonthStr)
+ start = r.gridStart
+ end = r.gridEnd
+ }
+ const gid = parseInt(selectedGroupId, 10)
+ const groupRow = groups.find((g) => g.id === gid)
+ const clubId = groupRow?.club_id
+ try {
+ const filters = {
+ start_date: start,
+ end_date: end
+ }
+ if (assignedToMeOnly) {
+ filters.assigned_to_me = true
+ }
+ if (planScope === 'club' && clubId) {
+ filters.club_id = clubId
+ } else {
+ filters.group_id = gid
+ }
+ const unitsData = await api.listTrainingUnits(filters)
+ setUnits(unitsData)
+ } catch (err) {
+ console.error('Failed to load units:', err)
+ }
+ }, [
+ selectedGroupId,
+ groups,
+ startDate,
+ endDate,
+ planView,
+ calendarMonthStr,
+ planScope,
+ assignedToMeOnly
+ ])
+
+ useEffect(() => {
+ loadData()
+ }, [loadData, tenantClubDepKey])
+
+ useEffect(() => {
+ if (selectedGroupId) {
+ loadUnits()
+ }
+ }, [selectedGroupId, loadUnits])
+
+ const selectedGroupClubIdMemo = useMemo(() => {
+ const g = groups.find((gr) => gr.id === parseInt(selectedGroupId, 10))
+ return g?.club_id != null ? Number(g.club_id) : null
+ }, [groups, selectedGroupId])
+
+ const canClubOrgTraining = useMemo(() => {
+ const r = (user?.role || '').toLowerCase()
+ if (r === 'admin' || r === 'superadmin') return true
+ if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
+ const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === selectedGroupClubIdMemo)
+ return Array.isArray(row?.roles) && row.roles.includes('club_admin')
+ }, [user?.role, user?.clubs, selectedGroupClubIdMemo])
+
+ const clubAdminClubIdSet = useMemo(() => {
+ const ids = []
+ for (const c of activeClubMemberships(user?.clubs)) {
+ if (Array.isArray(c.roles) && c.roles.includes('club_admin')) {
+ const id = Number(c.id)
+ if (Number.isFinite(id)) ids.push(id)
+ }
+ }
+ return new Set(ids)
+ }, [user?.clubs])
+
+ useEffect(() => {
+ const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
+ const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null
+ const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null
+
+ let assignModalClubId = null
+ if (assignModalOpen && assignDraft.unit?.group_id != null) {
+ const ug = Number(assignDraft.unit.group_id)
+ const gAssign = Number.isFinite(ug) ? groups.find((x) => x.id === ug) : null
+ if (gAssign?.club_id != null) assignModalClubId = Number(gAssign.club_id)
+ }
+
+ const loadClubId =
+ showModal && clubForModal != null && Number.isFinite(clubForModal)
+ ? clubForModal
+ : assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId)
+ ? assignModalClubId
+ : canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo)
+ ? selectedGroupClubIdMemo
+ : null
+
+ if (loadClubId == null || !Number.isFinite(loadClubId)) {
+ setClubDirectory([])
+ return undefined
+ }
+ let cancelled = false
+ ;(async () => {
+ try {
+ const d = await api.clubMembersDirectory(loadClubId)
+ if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
+ } catch (err) {
+ if (!cancelled) {
+ console.error('Mitgliederverzeichnis:', err)
+ setClubDirectory([])
+ }
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [
+ showModal,
+ assignModalOpen,
+ assignDraft.unit,
+ formData.group_id,
+ selectedGroupId,
+ groups,
+ canClubOrgTraining,
+ selectedGroupClubIdMemo,
+ ])
+
+ useEffect(() => {
+ if (!frameworkImportOpen) return
+ let cancelled = false
+ ;(async () => {
+ try {
+ const list = await api.listTrainingFrameworkPrograms()
+ if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
+ } catch (e) {
+ if (!cancelled) {
+ console.error('Rahmenprogramme laden:', e)
+ setFrameworkProgramsList([])
+ }
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [frameworkImportOpen])
+
+ const openFrameworkImportModal = useCallback(() => {
+ setFwImportProgramId('')
+ setFwImportDetail(null)
+ setFwImportSelectedSlots(new Set())
+ setFwImportSlotDates({})
+ setFwImportStartDate(new Date().toISOString().split('T')[0])
+ setFwImportIntervalDays(7)
+ setFrameworkImportOpen(true)
+ }, [])
+
+ const onFwImportProgramChange = async (idStr) => {
+ setFwImportProgramId(idStr)
+ if (!idStr) {
+ setFwImportDetail(null)
+ return
+ }
+ setFwImportLoading(true)
+ try {
+ const d = await api.getTrainingFrameworkProgram(parseInt(idStr, 10))
+ setFwImportDetail(d)
+ setFwImportSelectedSlots(new Set())
+ setFwImportSlotDates({})
+ } catch (e) {
+ toast.error(e.message || 'Rahmenprogramm laden fehlgeschlagen')
+ setFwImportDetail(null)
+ } finally {
+ setFwImportLoading(false)
+ }
+ }
+
+ const toggleFwImportSlot = (slot) => {
+ if (!slot?.blueprint_training_unit_id) return
+ const sid = slot.id
+ setFwImportSelectedSlots((prev) => {
+ const n = new Set(prev)
+ if (n.has(sid)) n.delete(sid)
+ else n.add(sid)
+ return n
+ })
+ }
+
+ const applyFwImportDateSuggestions = () => {
+ if (!fwImportDetail?.slots?.length) return
+ const sorted = [...fwImportDetail.slots].sort(
+ (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
+ )
+ let offset = 0
+ const iv = Math.max(0, Number(fwImportIntervalDays) || 0)
+ const next = {}
+ for (const s of sorted) {
+ if (!fwImportSelectedSlots.has(s.id)) continue
+ if (!s.blueprint_training_unit_id) continue
+ next[String(s.id)] = addDaysIsoDate(fwImportStartDate, offset)
+ offset += iv
+ }
+ setFwImportSlotDates((prev) => ({ ...prev, ...next }))
+ }
+
+ const submitFrameworkImport = async () => {
+ if (!selectedGroupId) {
+ toast.error('Bitte zuerst eine Trainingsgruppe wählen.')
+ return
+ }
+ const gid = parseInt(selectedGroupId, 10)
+ if (!fwImportDetail?.slots?.length) return
+ const sorted = [...fwImportDetail.slots].sort(
+ (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
+ )
+ const picks = sorted.filter(
+ (s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id
+ )
+ if (!picks.length) {
+ toast.error('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
+ return
+ }
+ for (const s of picks) {
+ const key = String(s.id)
+ const date = fwImportSlotDates[key] || fwImportStartDate
+ if (!date) {
+ toast.error('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
+ return
+ }
+ }
+ setFwImportSubmitting(true)
+ try {
+ for (const s of picks) {
+ const key = String(s.id)
+ const date = fwImportSlotDates[key] || fwImportStartDate
+ await api.createTrainingUnitFromFrameworkSlot({
+ group_id: gid,
+ planned_date: date,
+ framework_slot_id: s.id,
+ })
+ }
+ setFrameworkImportOpen(false)
+ await loadUnits()
+ } catch (e) {
+ toast.error(e.message || 'Übernahme fehlgeschlagen')
+ } finally {
+ setFwImportSubmitting(false)
+ }
+ }
+
+ const handleCreate = () => {
+ if (!selectedGroupId) {
+ toast.error('Bitte wähle zuerst eine Trainingsgruppe')
+ return
+ }
+ const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
+ setEditingUnit(null)
+ setDraftPlanTemplateId('')
+ setFormData({
+ group_id: selectedGroupId,
+ planned_date: today,
+ planned_time_start: group?.time_start?.slice(0, 5) || '',
+ planned_time_end: group?.time_end?.slice(0, 5) || '',
+ planned_focus: '',
+ actual_date: '',
+ actual_time_start: '',
+ actual_time_end: '',
+ attendance_count: '',
+ status: 'planned',
+ notes: '',
+ trainer_notes: '',
+ debrief_completed: false,
+ sections: [defaultSection('Hauptteil')],
+ ...sessionAssignDefaults()
+ })
+ setSectionsEditMode('planning')
+ setShowModal(true)
+ }
+
+ const handleCreateForDate = (isoDay) => {
+ if (!selectedGroupId) {
+ toast.error('Bitte wähle zuerst eine Trainingsgruppe')
+ return
+ }
+ const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
+ setEditingUnit(null)
+ setDraftPlanTemplateId('')
+ setFormData({
+ group_id: selectedGroupId,
+ planned_date: isoDay,
+ planned_time_start: group?.time_start?.slice(0, 5) || '',
+ planned_time_end: group?.time_end?.slice(0, 5) || '',
+ planned_focus: '',
+ actual_date: '',
+ actual_time_start: '',
+ actual_time_end: '',
+ attendance_count: '',
+ status: 'planned',
+ notes: '',
+ trainer_notes: '',
+ debrief_completed: false,
+ sections: [defaultSection('Hauptteil')],
+ ...sessionAssignDefaults()
+ })
+ setSectionsEditMode('planning')
+ setShowModal(true)
+ }
+
+ const applyTemplateFromSelect = async (templateId) => {
+ setDraftPlanTemplateId(templateId)
+ if (!templateId) return
+ try {
+ const tpl = await api.getTrainingPlanTemplate(parseInt(templateId, 10))
+ setFormData((fd) => ({
+ ...fd,
+ sections: (tpl.sections || []).length
+ ? tpl.sections.map((s) => ({
+ title: s.title,
+ guidance_notes: s.guidance_text || '',
+ items: []
+ }))
+ : [defaultSection()]
+ }))
+ } catch (err) {
+ toast.error('Vorlage laden: ' + err.message)
+ }
+ }
+
+ const handleEdit = useCallback(async (unit) => {
+ try {
+ const fullUnit = await api.getTrainingUnit(unit.id)
+ setEditingUnit(fullUnit)
+ setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '')
+ let sections = normalizeUnitToForm(fullUnit)
+ sections = await enrichSectionsWithVariants(sections)
+ setFormData({
+ group_id: fullUnit.group_id,
+ planned_date: fullUnit.planned_date || '',
+ planned_time_start: fullUnit.planned_time_start?.slice(0, 5) || '',
+ planned_time_end: fullUnit.planned_time_end?.slice(0, 5) || '',
+ planned_focus: fullUnit.planned_focus || '',
+ actual_date: fullUnit.actual_date || '',
+ actual_time_start: fullUnit.actual_time_start?.slice(0, 5) || '',
+ actual_time_end: fullUnit.actual_time_end?.slice(0, 5) || '',
+ attendance_count: fullUnit.attendance_count ?? '',
+ status: fullUnit.status || 'planned',
+ notes: fullUnit.notes || '',
+ trainer_notes: fullUnit.trainer_notes || '',
+ debrief_completed: Boolean(fullUnit.debrief_completed_at),
+ sections,
+ lead_trainer_profile_id:
+ fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
+ ? String(fullUnit.lead_trainer_profile_id)
+ : '',
+ session_assistants_inherit:
+ fullUnit.assistant_trainer_profile_ids == null ||
+ fullUnit.assistant_trainer_profile_ids === undefined,
+ session_assistant_profile_ids: (() => {
+ const efLead =
+ fullUnit.effective_lead_trainer_profile_id != null
+ ? Number(fullUnit.effective_lead_trainer_profile_id)
+ : null
+ let xs = toNumList(fullUnit.assistant_trainer_profile_ids)
+ if (efLead != null && Number.isFinite(efLead)) xs = xs.filter((id) => id !== efLead)
+ return xs
+ })(),
+ })
+ setSectionsEditMode(fullUnit.status === 'completed' ? 'debrief' : 'planning')
+ setShowModal(true)
+ } catch (err) {
+ toast.error('Fehler beim Laden: ' + err.message)
+ throw err
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!user?.id || loading) return
+ const uid = searchParams.get('unit')
+ if (!uid) {
+ unitDeepLinkHandledRef.current = null
+ return
+ }
+ if (unitDeepLinkHandledRef.current === uid) return
+ const idNum = parseInt(uid, 10)
+ if (!Number.isFinite(idNum)) return
+ unitDeepLinkHandledRef.current = uid
+ handleEdit({ id: idNum })
+ .then(() => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev)
+ next.delete('unit')
+ next.delete('debrief')
+ return next
+ },
+ { replace: true }
+ )
+ })
+ .catch(() => {
+ unitDeepLinkHandledRef.current = null
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev)
+ next.delete('unit')
+ next.delete('debrief')
+ return next
+ },
+ { replace: true }
+ )
+ })
+ }, [user?.id, loading, searchParams, handleEdit, setSearchParams])
+
+ const handleSaveAsTemplate = async () => {
+ const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
+ if (!name?.trim()) return
+ try {
+ await api.createTrainingPlanTemplate({
+ name: name.trim(),
+ sections: formData.sections.map((s) => ({
+ title: s.title || 'Abschnitt',
+ guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null
+ }))
+ })
+ await loadPlanTemplates()
+ toast.success('Vorlage gespeichert.')
+ } catch (err) {
+ toast.error('Speichern: ' + err.message)
+ }
+ }
+
+ const openModuleApplyModal = useCallback(async (placement) => {
+ setModuleApplyErr('')
+ setModuleApplySearchQuery('')
+ const placementLocked =
+ placement != null &&
+ typeof placement.sectionIndex === 'number' &&
+ typeof placement.insertBeforeIndex === 'number'
+ setModuleApplyPlacementLocked(placementLocked)
+ const secs = planningFormRef.current?.sections ?? []
+ let secIx = 0
+ let before = 0
+ if (secs.length) {
+ if (placement && typeof placement.sectionIndex === 'number') {
+ secIx = Math.min(Math.max(0, placement.sectionIndex), secs.length - 1)
+ const items = Array.isArray(secs[secIx]?.items) ? secs[secIx].items : []
+ const cap = items.length
+ if (typeof placement.insertBeforeIndex === 'number' && Number.isFinite(placement.insertBeforeIndex)) {
+ before = Math.min(Math.max(0, placement.insertBeforeIndex), cap)
+ } else before = cap
+ } else {
+ const items = Array.isArray(secs[0]?.items) ? secs[0].items : []
+ before = items.length
+ secIx = 0
+ }
+ }
+ setModuleApplySectionIx(secIx)
+ setModuleApplyInsertSlot(`before:${before}`)
+ setModuleApplyOpen(true)
+ try {
+ const list = await api.listTrainingModules()
+ const arr = Array.isArray(list) ? list : []
+ setModuleApplyList(arr)
+ setModuleApplyModuleId(arr.length ? String(arr[0].id) : '')
+ } catch (e) {
+ setModuleApplyErr(e.message || 'Module konnten nicht geladen werden')
+ setModuleApplyList([])
+ }
+ }, [])
+
+ const onModuleApplySectionIndexChange = useCallback((newIx) => {
+ setModuleApplySectionIx(newIx)
+ const secsNow = planningFormRef.current?.sections ?? []
+ const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
+ setModuleApplyInsertSlot(`before:${len}`)
+ }, [])
+
+ const handleApplyTrainingModuleConfirm = useCallback(async () => {
+ const mid = parseInt(moduleApplyModuleId, 10)
+ if (!Number.isFinite(mid)) {
+ toast.error('Bitte ein Trainingsmodul wählen.')
+ return
+ }
+ let secIx = parseInt(String(moduleApplySectionIx), 10)
+ if (!Number.isFinite(secIx)) secIx = 0
+
+ const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
+ if (!baseSections.length) {
+ toast.error('Keine Abschnitte im Formular.')
+ return
+ }
+ if (secIx < 0 || secIx >= baseSections.length) secIx = 0
+
+ const secItems = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items : []
+ const itemCap = secItems.length
+ let insertBefore = itemCap
+ if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
+ const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
+ if (Number.isFinite(zi)) insertBefore = Math.min(Math.max(0, zi), itemCap)
+ }
+
+ setModuleApplyBusy(true)
+ setModuleApplyErr('')
+ try {
+ const detail = await api.getTrainingModule(mid)
+ let nextSections = await insertTrainingModuleIntoPlanningSections({
+ sections: baseSections,
+ moduleDetail: detail,
+ sectionIndex: secIx,
+ insertBeforeItemIndex: insertBefore,
+ })
+ nextSections = await enrichSectionsWithVariants(nextSections)
+ setFormData((fd) => ({ ...fd, sections: nextSections }))
+ setModuleApplyOpen(false)
+ setModuleApplyPlacementLocked(false)
+ } catch (e) {
+ setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
+ } finally {
+ setModuleApplyBusy(false)
+ }
+ }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot])
+
+ useEffect(() => {
+ if (!moduleApplyOpen) {
+ setModulePickPreview({
+ loading: false,
+ moduleId: '',
+ exercises: [],
+ notes: 0,
+ err: '',
+ })
+ return undefined
+ }
+ const mid = parseInt(String(moduleApplyModuleId), 10)
+ if (!Number.isFinite(mid) || mid < 1) {
+ setModulePickPreview({
+ loading: false,
+ moduleId: '',
+ exercises: [],
+ notes: 0,
+ err: '',
+ })
+ return undefined
+ }
+ let cancelled = false
+ setModulePickPreview({
+ loading: true,
+ moduleId: String(mid),
+ exercises: [],
+ notes: 0,
+ err: '',
+ })
+ ;(async () => {
+ try {
+ const detail = await api.getTrainingModule(mid)
+ if (cancelled) return
+ const itemsSorted = [...(detail.items ?? [])].sort(
+ (a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
+ )
+ const uniqueEx = new Set()
+ let notes = 0
+ for (const row of itemsSorted) {
+ if ((row.item_type || '') !== 'note') {
+ const eid = row.exercise_id
+ if (eid) uniqueEx.add(Number(eid))
+ continue
+ }
+ const b = String(row.note_body ?? '').trim()
+ if (b === '---') continue
+ notes += 1
+ }
+ const titleById = new Map()
+ await Promise.all(
+ [...uniqueEx].map(async (eid) => {
+ try {
+ const ex = await api.getExercise(eid)
+ titleById.set(eid, (ex?.title || '').trim() || `Übung #${eid}`)
+ } catch {
+ titleById.set(eid, `Übung #${eid}`)
+ }
+ })
+ )
+ if (cancelled) return
+ const exTitlesInOrder = []
+ for (const row of itemsSorted) {
+ if ((row.item_type || '') !== 'exercise') continue
+ const eid = Number(row.exercise_id)
+ if (!Number.isFinite(eid)) continue
+ exTitlesInOrder.push(titleById.get(eid) || `Übung #${eid}`)
+ }
+ setModulePickPreview({
+ loading: false,
+ moduleId: String(mid),
+ exercises: exTitlesInOrder,
+ notes,
+ err: '',
+ })
+ } catch (e) {
+ if (!cancelled) {
+ setModulePickPreview({
+ loading: false,
+ moduleId: String(mid),
+ exercises: [],
+ notes: 0,
+ err: e?.message || 'Vorschau fehlgeschlagen',
+ })
+ }
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [moduleApplyOpen, moduleApplyModuleId])
+
+ const handleTakeLead = async (unit) => {
+ if (!user?.id) return
+ try {
+ await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id })
+ await loadUnits()
+ } catch (err) {
+ toast.error(err.message || 'Leitung konnte nicht übernommen werden')
+ }
+ }
+
+ const openTrainerAssignModal = (unit) => {
+ const effLead =
+ unit.effective_lead_trainer_profile_id != null
+ ? Number(unit.effective_lead_trainer_profile_id)
+ : null
+ let coIds = toNumList(unit.assistant_trainer_profile_ids)
+ if (effLead != null && Number.isFinite(effLead)) {
+ coIds = coIds.filter((id) => id !== effLead)
+ }
+ setAssignDraft({
+ unit,
+ lead_trainer_profile_id:
+ unit.lead_trainer_profile_id != null && unit.lead_trainer_profile_id !== ''
+ ? String(unit.lead_trainer_profile_id)
+ : '',
+ session_assistants_inherit:
+ unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined,
+ session_assistant_profile_ids: coIds,
+ })
+ setAssignModalOpen(true)
+ }
+
+ const saveTrainerAssignModal = async () => {
+ if (!assignDraft.unit) return
+ setAssignSaving(true)
+ try {
+ const payload = {}
+ const leadStr = String(assignDraft.lead_trainer_profile_id || '').trim()
+ if (leadStr) payload.lead_trainer_profile_id = parseInt(leadStr, 10)
+ else payload.lead_trainer_profile_id = null
+ if (assignDraft.session_assistants_inherit) {
+ payload.assistant_trainer_profile_ids = null
+ } else {
+ payload.assistant_trainer_profile_ids = [...assignDraft.session_assistant_profile_ids].sort((a, b) => a - b)
+ }
+ await api.updateTrainingUnit(assignDraft.unit.id, payload)
+ setAssignModalOpen(false)
+ setAssignDraft({
+ unit: null,
+ ...sessionAssignDefaults(),
+ })
+ await loadUnits()
+ } catch (err) {
+ toast.error(err.message || 'Zuweisung konnte nicht gespeichert werden')
+ } finally {
+ setAssignSaving(false)
+ }
+ }
+
+ const handleAssignLeadSelectChange = useCallback((v) => {
+ setAssignDraft((prev) => {
+ const exclude = []
+ const tr = String(v || '').trim()
+ if (tr !== '') {
+ const n = parseInt(tr, 10)
+ if (Number.isFinite(n)) exclude.push(n)
+ } else if (prev.unit?.effective_lead_trainer_profile_id != null) {
+ const ef = Number(prev.unit.effective_lead_trainer_profile_id)
+ if (Number.isFinite(ef)) exclude.push(ef)
+ }
+ const exSet = new Set(exclude)
+ const co = exclude.length
+ ? prev.session_assistant_profile_ids.filter((x) => !exSet.has(x))
+ : prev.session_assistant_profile_ids
+ return { ...prev, lead_trainer_profile_id: v, session_assistant_profile_ids: co }
+ })
+ }, [])
+
+ const handleAssignAssistantsInheritChange = useCallback((checked) => {
+ setAssignDraft((prev) => ({
+ ...prev,
+ session_assistants_inherit: checked,
+ }))
+ }, [])
+
+ const handleAssignCoTrainerToggle = useCallback((mid) => {
+ setAssignDraft((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 }
+ })
+ }, [])
+
+ const handleDelete = async (unit) => {
+ if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
+ try {
+ await api.deleteTrainingUnit(unit.id)
+ await loadUnits()
+ } catch (err) {
+ toast.error('Fehler beim Löschen: ' + err.message)
+ }
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!formData.group_id || !formData.planned_date) {
+ toast.error('Gruppe und Datum sind Pflichtfelder')
+ return
+ }
+ try {
+ const sectionsPayload = buildSectionsPayload(formData.sections)
+ const payload = {
+ planned_date: formData.planned_date,
+ planned_time_start: formData.planned_time_start || null,
+ planned_time_end: formData.planned_time_end || null,
+ planned_focus: formData.planned_focus || null,
+ actual_date: formData.actual_date || null,
+ actual_time_start: formData.actual_time_start || null,
+ actual_time_end: formData.actual_time_end || null,
+ attendance_count: formData.attendance_count ? parseInt(formData.attendance_count, 10) : null,
+ status: formData.status || 'planned',
+ notes: formData.notes || null,
+ trainer_notes: formData.trainer_notes || null,
+ sections: sectionsPayload
+ }
+ if (editingUnit) {
+ payload.debrief_completed =
+ (formData.status || '') === 'completed' ? !!formData.debrief_completed : false
+ }
+ const leadStr = String(formData.lead_trainer_profile_id || '').trim()
+ if (leadStr) {
+ payload.lead_trainer_profile_id = parseInt(leadStr, 10)
+ } else if (editingUnit) {
+ payload.lead_trainer_profile_id = null
+ }
+ if (formData.session_assistants_inherit) {
+ if (editingUnit) payload.assistant_trainer_profile_ids = null
+ } else {
+ payload.assistant_trainer_profile_ids = [...formData.session_assistant_profile_ids].sort(
+ (a, b) => a - b
+ )
+ }
+ if (!editingUnit) {
+ payload.group_id = parseInt(formData.group_id, 10)
+ if (draftPlanTemplateId) {
+ payload.plan_template_id = parseInt(draftPlanTemplateId, 10)
+ }
+ }
+
+ if (editingUnit) {
+ await api.updateTrainingUnit(editingUnit.id, payload)
+ } else {
+ await api.createTrainingUnit(payload)
+ }
+ setShowModal(false)
+ await loadUnits()
+ } catch (err) {
+ toast.error('Fehler beim Speichern: ' + err.message)
+ }
+ }
+
+ const updateFormField = (field, value) => {
+ setFormData((prev) => {
+ if (field !== 'lead_trainer_profile_id') {
+ const patch = { ...prev, [field]: value }
+ if (field === 'status' && value !== 'completed') {
+ patch.debrief_completed = false
+ }
+ return patch
+ }
+ const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim()
+ const strip = new Set()
+ if (ts !== '') {
+ const nid = parseInt(ts, 10)
+ if (Number.isFinite(nid)) strip.add(nid)
+ } else {
+ const gidParsed = parseInt(prev.group_id || selectedGroupId || '0', 10)
+ const gr =
+ Number.isFinite(gidParsed) && gidParsed >= 1
+ ? groups.find((xg) => xg.id === gidParsed)
+ : null
+ if (gr?.trainer_id != null) {
+ const ht = Number(gr.trainer_id)
+ if (Number.isFinite(ht)) strip.add(ht)
+ }
+ }
+ const assistants = prev.session_assistant_profile_ids.filter((id) => !strip.has(id))
+ return { ...prev, lead_trainer_profile_id: value, session_assistant_profile_ids: assistants }
+ })
+ }
+
+ const calendarGridDays = useMemo(() => {
+ const r = getCalendarGridRange(calendarMonthStr)
+ return enumerateIsoDays(r.gridStart, r.gridEnd)
+ }, [calendarMonthStr])
+
+ const unitsByPlannedDate = useMemo(() => {
+ const m = new Map()
+ for (const u of units) {
+ const raw = u.planned_date
+ if (!raw) continue
+ const key = String(raw).slice(0, 10)
+ if (!m.has(key)) m.set(key, [])
+ m.get(key).push(u)
+ }
+ return m
+ }, [units])
+
+ const calendarMonthTitle = useMemo(() => {
+ const p = calendarMonthStr.split('-').map(Number)
+ const y = p[0]
+ const mo = p[1]
+ if (!y || !mo) return ''
+ return new Date(y, mo - 1, 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
+ }, [calendarMonthStr])
+
+ const mayConfigureSessionAssignments = useCallback(
+ (unit) => {
+ if (!unit) return false
+ const pid = Number(user?.id)
+ if (!Number.isFinite(pid)) return false
+ const r = (user?.role || '').toLowerCase()
+ if (r === 'admin' || r === 'superadmin') return true
+
+ const gClub = unit.group_club_id != null ? Number(unit.group_club_id) : null
+ if (Number.isFinite(gClub) && clubAdminClubIdSet.has(gClub)) return true
+
+ const gid = Number(unit.group_id)
+ const g = groups.find((gr) => gr.id === gid)
+ if (!g) return false
+
+ const cb = unit.created_by != null ? Number(unit.created_by) : NaN
+ if (Number.isFinite(cb) && cb === pid) return true
+
+ const ht = g.trainer_id != null ? Number(g.trainer_id) : NaN
+ if (Number.isFinite(ht) && ht === pid) return true
+
+ return normalizeGroupCoTrainerIds(g.co_trainer_ids).includes(pid)
+ },
+ [user?.id, user?.role, groups, clubAdminClubIdSet]
+ )
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
+
+ const gidTrainerForm = parseInt(formData.group_id || selectedGroupId || '0', 10)
+ const groupForTrainerForm =
+ Number.isFinite(gidTrainerForm) && gidTrainerForm >= 1
+ ? groups.find((gr) => gr.id === gidTrainerForm)
+ : null
+
+ let formTrainerAssignLeadExcludeId = null
+ if (groupForTrainerForm?.trainer_id != null) formTrainerAssignLeadExcludeId = Number(groupForTrainerForm.trainer_id)
+ const leadDraftTrim = String(formData.lead_trainer_profile_id || '').trim()
+ if (leadDraftTrim !== '') {
+ const nl = parseInt(leadDraftTrim, 10)
+ if (Number.isFinite(nl)) formTrainerAssignLeadExcludeId = nl
+ }
+ if (editingUnit?.effective_lead_trainer_profile_id != null && leadDraftTrim === '') {
+ const el = Number(editingUnit.effective_lead_trainer_profile_id)
+ if (Number.isFinite(el)) formTrainerAssignLeadExcludeId = el
+ }
+
+ const clubDirectoryForCo = filterDirectoryExcludingLead(clubDirectory, formTrainerAssignLeadExcludeId)
+
+ let assignExcludeLeadPid = null
+ if (assignModalOpen && assignDraft.unit) {
+ const dl = String(assignDraft.lead_trainer_profile_id || '').trim()
+ if (dl !== '') {
+ const n = parseInt(dl, 10)
+ assignExcludeLeadPid = Number.isFinite(n) ? n : null
+ } else if (assignDraft.unit.effective_lead_trainer_profile_id != null) {
+ const n = Number(assignDraft.unit.effective_lead_trainer_profile_id)
+ assignExcludeLeadPid = Number.isFinite(n) ? n : null
+ }
+ }
+ const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid)
+
+ return (
+
+
Trainingsplanung
+
+
+
+ Ansicht
+
+
{
+ if (id === 'calendar') {
+ setPlanView('calendar')
+ setCalendarMonthStr((prev) => {
+ const fromList = (startDate || '').slice(0, 7)
+ if (/^\d{4}-\d{2}$/.test(fromList)) return fromList
+ return prev || new Date().toISOString().slice(0, 7)
+ })
+ } else {
+ setPlanView('list')
+ }
+ }}
+ items={[
+ { id: 'list', label: 'Liste' },
+ { id: 'calendar', label: 'Kalender' },
+ ]}
+ className="page-section-nav--inline planning-ansicht-nav"
+ />
+
+ {planView === 'list'
+ ? 'Zeitraum unten mit Von/Bis filtern.'
+ : 'Monat unten wechseln; Termine erscheinen im Raster.'}
+
+
+
+
+ Wähle eine Trainingsgruppe und lege Trainingseinheiten für den Zeitraum an (Inhalt: Abschnitte
+ und Übungen).
+
+
+
+
+ Mehrere Einheiten strukturieren auf einmal:{' '}
+
+ Trainingsrahmenprogramme
+ {' '}
+ (Ziele, Sessions, Vorlagen‑Ablauf).
+
+
+ Wiederverwendbare Blöcke innerhalb einer Einheit:{' '}
+
+ Trainingsmodule
+ {' '}
+ (übernahme als Kopie beim Bearbeiten einer Einheit).
+
+
+ {!loading && groups.length === 0 && (
+
+
Erst Verein & Gruppe anlegen
+
+ Ohne Trainingsgruppe kann hier nichts gebucht werden. Unter Vereine legst du einen Verein an
+ (kurzer Name genügt), optional eine Sparte, dann eine Trainingsgruppe . Wochentage, feste Zeiten oder
+ Eigenschaften sind optional und kannst du später ergänzen.
+
+
+ Zu Vereinen & Trainingsgruppen
+
+
+ )}
+
+
+
+
+ Trainingsgruppe
+ setSelectedGroupId(e.target.value)}
+ >
+ Bitte wählen
+ {groups.map((g) => (
+
+ {g.name} ({g.club_name})
+
+ ))}
+
+
+
+ {planView === 'list' ? (
+ <>
+
+ Von
+ setStartDate(e.target.value)}
+ />
+
+
+
+ Bis
+ setEndDate(e.target.value)}
+ />
+
+ >
+ ) : (
+
+ setCalendarMonthStr((prev) => shiftCalendarMonth(prev, -1))}
+ >
+ ←
+
+
+ {calendarMonthTitle}
+
+ setCalendarMonthStr((prev) => shiftCalendarMonth(prev, 1))}
+ >
+ →
+
+ setCalendarMonthStr(new Date().toISOString().slice(0, 7))}
+ >
+ Aktueller Monat
+
+
+ )}
+
+
+
+ Einblenden
+
+
+
+ setAssignedToMeOnly(e.target.checked)}
+ />
+ Nur meine Zuordnung (Leitung / Co)
+
+
+ Neue Termine gelten immer für die gewählte Gruppe. „Ganzer Verein“ zeigt zusätzlich Termine
+ anderer Gruppen desselben Vereins.
+
+
+ Mehr zu Ansicht & Trainerzuordnung
+
+
+ „Ganzer Verein“ bezieht sich auf denselben Verein wie die gewählte Gruppe. Neu angelegte Termine
+ beziehen sich weiterhin auf die Gruppe, die du oben gewählt hast.
+
+ {selectedGroupId ? (
+
+ Über Trainer oder Trainer zuweisen bearbeitest du Leitung und
+ Co je Einheit (berechtigt: Vereinsorganisation, Haupt-/Co‑Trainer der Gruppe sowie Erstellung
+ der Einheit). Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder; die Leitung erscheint
+ nicht unter Co‑Trainer.
+
+ ) : (
+
+ Wähle zuerst eine Gruppe — dann erweitert sich die Hilfe zu Trainer und Berechtigungen.
+
+ )}
+
+
+
+
+
+ {selectedGroup && (
+
+
+ {selectedGroup.location || 'Kein Ort angegeben'}
+ {selectedGroup.weekday && ` · ${selectedGroup.weekday}`}
+ {selectedGroup.time_start &&
+ ` · ${selectedGroup.time_start.slice(0, 5)} - ${selectedGroup.time_end?.slice(0, 5)}`}
+
+
+ )}
+
+
+
+
Neue Trainingseinheit
+
+ Datum, Zeiten und Ablauf (Abschnitte & Übungen) — optional{' '}
+ Trainingsvorlage oder Inhalte aus einem Rahmenprogramm im Dialog.
+
+ {!selectedGroupId && (
+
+ Wähle oben eine Trainingsgruppe, um fortzufahren.
+
+ )}
+ {groups.length === 0 && (
+
+ Es gibt noch keine aktive Trainingsgruppe — unter{' '}
+ Vereine anlegen oder aktivieren.
+
+ )}
+
+
+
+ Trainingseinheit planen…
+
+
+ Aus Rahmen übernehmen…
+
+
+
+
+
+ {!selectedGroupId ? (
+
+
+ Wähle oben eine Trainingsgruppe — danach kannst du unter{' '}
+ „Trainingseinheit planen…“ einen Termin anlegen.
+
+
+ ) : planView === 'calendar' ? (
+
+ {units.length === 0 ? (
+
+ Im sichtbaren Monatsbereich liegt noch keine Einheit. Über + in einem Tag legst du einen
+ neuen Termin mit Datum an.
+
+ ) : null}
+
+ {WEEKDAYS_DE.map((w) => (
+
+ {w}
+
+ ))}
+ {calendarGridDays.map((dayIso) => {
+ const inMonth = dayIso.slice(0, 7) === calendarMonthStr
+ const dayNum = parseInt(dayIso.slice(8, 10), 10)
+ const isTodayMarker = dayIso === today
+ const dayUnits = unitsByPlannedDate.get(dayIso) || []
+ return (
+
+
+ {dayNum}
+ handleCreateForDate(dayIso)}
+ disabled={!selectedGroupId}
+ style={{ padding: '2px 6px', flexShrink: 0 }}
+ >
+ +
+
+
+
+ {dayUnits.slice(0, 3).map((unit) => (
+
+ handleEdit(unit)}
+ title={[
+ planScope === 'club' && unit.group_name ? unit.group_name : '',
+ unit.planned_time_start?.slice(0, 5) || '',
+ unit.lead_trainer_name?.trim(),
+ unit.planned_focus?.trim(),
+ unit.status === 'completed'
+ ? 'Durchgeführt'
+ : unit.status === 'cancelled'
+ ? 'Abgesagt'
+ : 'Geplant',
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+ style={{
+ border: 'none',
+ cursor: 'pointer',
+ textAlign: 'left',
+ padding: '4px 5px',
+ borderRadius: '4px',
+ fontSize: '0.7rem',
+ lineHeight: 1.25,
+ width: '100%',
+ borderLeftWidth: '3px',
+ borderLeftStyle: 'solid',
+ borderLeftColor:
+ unit.status === 'completed'
+ ? '#2ea44f'
+ : unit.status === 'cancelled'
+ ? 'var(--danger)'
+ : 'var(--accent-dark)',
+ background: 'var(--surface2)',
+ color: 'var(--text1)',
+ }}
+ >
+
+ {unit.planned_time_start
+ ? `${unit.planned_time_start.slice(0, 5)}`
+ : 'Ganztags'}
+
+ {planScope === 'club' && (unit.group_name || '').trim() ? (
+
+ {(unit.group_name || '').trim().length > 22
+ ? `${(unit.group_name || '').trim().slice(0, 22)}…`
+ : unit.group_name}
+
+ ) : null}
+ {unit.lead_trainer_name?.trim() ? (
+
+ {unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] ||
+ unit.lead_trainer_name.trim()}
+
+ ) : null}
+ {unit.planned_focus?.trim() ? (
+
+ {(unit.planned_focus || '').trim().length > 24
+ ? `${(unit.planned_focus || '').trim().slice(0, 24)}…`
+ : unit.planned_focus}
+
+ ) : null}
+
+ {mayConfigureSessionAssignments(unit) ? (
+ {
+ ev.stopPropagation()
+ openTrainerAssignModal(unit)
+ }}
+ >
+ Trainer
+
+ ) : null}
+
+ ))}
+
+ {dayUnits.length > 3 ? (
+
+ +{dayUnits.length - 3} weitere
+
+ ) : null}
+
+ )
+ })}
+
+
+ ) : units.length === 0 ? (
+
+
+ Keine Trainingseinheiten in diesem Zeitraum. Unten unter „Neue Trainingseinheit“ einen
+ Termin anlegen — optional mit Vorlage im Dialog.
+
+
+ ) : (
+
+ {units.map((unit) => {
+ const lineage = unit.origin_framework_slot_id ? frameworkLineageText(unit) : null
+ const uid = user?.id != null ? Number(user.id) : null
+ const effLead =
+ unit.effective_lead_trainer_profile_id != null
+ ? Number(unit.effective_lead_trainer_profile_id)
+ : null
+ const showTakeLead =
+ unit.status === 'planned' && uid != null && effLead != null && effLead !== uid
+ return (
+
+
+
+
+ {unit.planned_date}
+ {unit.planned_time_start &&
+ ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}
+
+ {planScope === 'club' && (unit.group_name || '').trim() ? (
+
+ {unit.group_name}
+
+ ) : null}
+
+ Leitung: {(unit.lead_trainer_name || '').trim() || '—'}
+
+ {(() => {
+ const coRaw = unit.effective_assistant_trainer_profile_ids
+ const co = Array.isArray(coRaw)
+ ? coRaw.map(Number).filter((x) => Number.isFinite(x) && x >= 1)
+ : []
+ if (!co.length) return null
+ const src =
+ unit.assistant_trainer_profile_ids != null
+ ? 'Session-Zuweisung'
+ : 'über Trainingsgruppe'
+ return (
+
+ Co-Trainer ({src}): {co.length}
+
+ )
+ })()}
+ {unit.planned_focus && (
+
+ Fokus: {unit.planned_focus}
+
+ )}
+ {lineage ? (
+
+ Aus Rahmen:
+ {unit.origin_framework_program_id ? (
+
+ {lineage.fpTitle}
+
+ ) : (
+ {lineage.fpTitle}
+ )}
+ · {lineage.slotBit}
+
+ ) : null}
+
+
+ {unit.status === 'planned' && 'Geplant'}
+ {unit.status === 'completed' && 'Durchgeführt'}
+ {unit.status === 'cancelled' && 'Abgesagt'}
+
+ {unit.attendance_count !== null && unit.attendance_count !== undefined && (
+
+ {unit.attendance_count} Teilnehmer
+
+ )}
+
+
+
+
+
+ Plan & Ablauf
+
+
+ Im Training (Coach)
+
+ handleEdit(unit)}>
+ Bearbeiten
+
+ {mayConfigureSessionAssignments(unit) ? (
+ openTrainerAssignModal(unit)}
+ title="Nur organisatorisch: Leitung und Co für diese Einheit"
+ >
+ Trainer zuweisen
+
+ ) : null}
+ {showTakeLead ? (
+ handleTakeLead(unit)}>
+ Ich übernehme
+
+ ) : null}
+ handleDelete(unit)}
+ >
+ Löschen
+
+
+
+
+ {unit.notes && (
+
+ {unit.notes}
+
+ )}
+
+ )
+ })}
+
+ )}
+
+
{
+ if (!assignSaving) setAssignModalOpen(false)
+ }}
+ onCancel={() => setAssignModalOpen(false)}
+ onSave={saveTrainerAssignModal}
+ />
+
+ {
+ setModuleApplyOpen(false)
+ setModuleApplyPlacementLocked(false)
+ }}
+ />
+
+
+ setFwImportSlotDates((prev) => ({ ...prev, [slotId]: value }))
+ }
+ fwImportStartDate={fwImportStartDate}
+ onFwImportStartDateChange={setFwImportStartDate}
+ fwImportIntervalDays={fwImportIntervalDays}
+ onFwImportIntervalDaysChange={setFwImportIntervalDays}
+ fwImportSubmitting={fwImportSubmitting}
+ onApplyDateSuggestions={applyFwImportDateSuggestions}
+ onSubmit={submitFrameworkImport}
+ onClose={() => setFrameworkImportOpen(false)}
+ />
+
+ setShowModal(false)}
+ draftPlanTemplateId={draftPlanTemplateId}
+ onDraftTemplateSelect={applyTemplateFromSelect}
+ planTemplates={planTemplates}
+ clubDirectory={clubDirectory}
+ clubDirectoryForCo={clubDirectoryForCo}
+ planningModalClubId={planningModalClubId}
+ user={user}
+ onMetaRefresh={refreshPlanningSectionMeta}
+ sectionsEditMode={sectionsEditMode}
+ setSectionsEditMode={setSectionsEditMode}
+ onSaveAsTemplate={handleSaveAsTemplate}
+ onRequestTrainingModulePick={(ctx) => {
+ void openModuleApplyModal(ctx)
+ }}
+ onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
+ setExercisePickerTarget({
+ sIdx: sectionIndex,
+ iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
+ insertBeforeIndex:
+ typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
+ ? insertBeforeIndex
+ : undefined,
+ })
+ setExercisePickerOpen(true)
+ }}
+ onPeekExercise={(id, variantId, peekExtras) =>
+ setPlanningPeekCtx({
+ exerciseId: id,
+ variantId: variantId ?? null,
+ peekExtras: peekExtras ?? null,
+ })
+ }
+ />
+ {
+ setExercisePickerOpen(false)
+ setExercisePickerTarget(null)
+ }}
+ onSelectExercises={async (picked) => {
+ if (!exercisePickerTarget || !picked?.length) return
+ const rows = []
+ for (const ex of picked) {
+ const row = await hydrateExercisePlanningRow(ex)
+ if (row) rows.push(row)
+ }
+ if (!rows.length) return
+ const { sIdx, iIdx, insertBeforeIndex } = exercisePickerTarget
+ setFormData((prev) => ({
+ ...prev,
+ sections: prev.sections.map((s, si) => {
+ if (si !== sIdx) return s
+ const items = [...(s.items || [])]
+ if (typeof iIdx === 'number') {
+ const cur = items[iIdx]
+ if (!cur || cur.item_type !== 'exercise') return s
+ const [first, ...tail] = rows
+ items[iIdx] = {
+ ...cur,
+ exercise_id: first.exercise_id,
+ exercise_variant_id: first.exercise_variant_id,
+ exercise_title: first.exercise_title,
+ variants: first.variants,
+ }
+ if (tail.length) items.splice(iIdx + 1, 0, ...tail)
+ return { ...s, items }
+ }
+ const rawAt =
+ typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
+ ? insertBeforeIndex
+ : items.length
+ const at = Math.max(0, Math.min(rawAt, items.length))
+ items.splice(at, 0, ...rows)
+ return { ...s, items }
+ }),
+ }))
+ setExercisePickerOpen(false)
+ setExercisePickerTarget(null)
+ }}
+ />
+ setPlanningPeekCtx(null)}
+ />
+
+ )
+}
+
+export default TrainingPlanningPageRoot
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index b82904a..466bf5a 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -1,2447 +1,2 @@
-import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
-import { useNavigate, useParams, Link } from 'react-router-dom'
-import api, { buildExerciseApiPayload } from '../utils/api'
-import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
-import RichTextEditor from '../components/RichTextEditor'
-import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
-import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
-import MediaPreviewModal from '../components/MediaPreviewModal'
-import ReportContentModal from '../components/ReportContentModal'
-import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor'
-import ExercisePickerModal from '../components/ExercisePickerModal'
-import {
- SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
- buildExerciseMediaDragPayload,
-} from '../utils/exerciseInlineMediaRefs'
-import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
-import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
-import { useAuth } from '../context/AuthContext'
-import { useToast } from '../context/ToastContext'
-import {
- activeClubMemberships,
- getDefaultClubIdForGovernanceForms,
- getTenantClubDependencyKey,
-} from '../utils/activeClub'
-import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../constants/combinationArchetypes'
-import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
-import { GripVertical } from 'lucide-react'
-import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
-import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
-
-const INTENSITY_OPTIONS = [
- { value: '', label: '—' },
- { value: 'niedrig', label: 'niedrig' },
- { value: 'mittel', label: 'mittel' },
- { value: 'hoch', label: 'hoch' },
-]
-
-const VARIANT_DIFFICULTY = [
- { value: '', label: '—' },
- { value: 'easier', label: 'Einfacher' },
- { value: 'same', label: 'Gleich' },
- { value: 'harder', label: 'Schwerer' },
- { value: 'adapted', label: 'Angepasst' },
-]
-
-/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
-const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
-
-/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */
-const MAX_COMBO_CANDIDATES_PER_STATION = 3
-
-const comboTinyNumberInputSx = {
- width: '3.5rem',
- maxWidth: '100%',
- padding: '4px 6px',
- fontSize: '0.8125rem',
- textAlign: 'center',
-}
-
-function emptyComboSlotRow() {
- return {
- title: '',
- candidate_exercise_ids: [],
- exercise_title_by_id: {},
- advance_mode: 'timed',
- load_sec: '',
- consecutive_reps: '',
- rep_series_count: '1',
- intra_rep_rest_sec: '',
- transition_after_sec: '',
- }
-}
-
-function comboSlotsFromDetail(exercise) {
- const raw = exercise?.combination_slots
- const arch = exercise?.method_archetype != null ? String(exercise.method_archetype).trim() : ''
- const serienFallback = defaultRepSeriesCountForArchetype(arch)
- const mp =
- exercise?.method_profile &&
- typeof exercise.method_profile === 'object' &&
- !Array.isArray(exercise.method_profile)
- ? exercise.method_profile
- : {}
- const spvList = readSlotProfilesV1(mp)
- const byIx = new Map(spvList.map((r) => [Number(r.slot_index), r]))
-
- if (!Array.isArray(raw) || raw.length === 0) {
- return [emptyComboSlotRow()]
- }
- const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0))
- return sorted.map((s) => {
- const si = Number(s.slot_index)
- const st = byIx.get(si) || {}
- const cands = Array.isArray(s.candidate_exercise_ids)
- ? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
- : []
- const mode = normalizeAdvanceMode(st.advance_mode)
- let repSer = ''
- if (st.rep_series_count != null) repSer = String(st.rep_series_count)
- else if (mode === 'rep' || mode === 'manual') repSer = String(serienFallback)
- else repSer = '1'
- return {
- title: s.title != null ? String(s.title) : '',
- candidate_exercise_ids: cands,
- exercise_title_by_id: {},
- advance_mode: mode,
- load_sec: st.load_sec != null ? String(st.load_sec) : '',
- consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
- rep_series_count: repSer,
- intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
- transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '',
- }
- })
-}
-
-function emptyVariantDraft() {
- return {
- variant_name: '',
- description: '',
- execution_changes: '',
- duration_min: '',
- duration_max: '',
- equipment_lines: '',
- difficulty_adjustment: '',
- progression_level: 1,
- prerequisite_variant_id: '',
- }
-}
-
-function apiVariantToRow(v) {
- let lines = ''
- const eq = v.equipment_changes
- if (Array.isArray(eq)) {
- lines = eq.join('\n')
- } else if (typeof eq === 'string' && eq.trim()) {
- try {
- const p = JSON.parse(eq)
- lines = Array.isArray(p) ? p.join('\n') : eq
- } catch {
- lines = eq
- }
- }
- return {
- ...v,
- duration_min: v.duration_min ?? '',
- duration_max: v.duration_max ?? '',
- equipment_lines: lines,
- progression_level: v.progression_level ?? 1,
- prerequisite_variant_id: v.prerequisite_variant_id ?? '',
- difficulty_adjustment: v.difficulty_adjustment ?? '',
- }
-}
-
-function buildVariantPayloadFromRow(row) {
- const lines = (row.equipment_lines || '')
- .split(/[\n,]+/)
- .map((s) => s.trim())
- .filter(Boolean)
- const pl =
- row.progression_level === '' || row.progression_level == null
- ? 1
- : parseInt(row.progression_level, 10)
- const so =
- row.sequence_order === '' || row.sequence_order == null
- ? null
- : parseInt(row.sequence_order, 10)
- return {
- variant_name: (row.variant_name || '').trim(),
- description: (row.description || '').trim() || null,
- execution_changes: (row.execution_changes || '').trim() || null,
- duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10),
- duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10),
- equipment_changes: lines,
- difficulty_adjustment: row.difficulty_adjustment || null,
- progression_level: Number.isNaN(pl) ? 1 : pl,
- sequence_order: so !== null && Number.isNaN(so) ? null : so,
- prerequisite_variant_id:
- row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
- ? null
- : parseInt(row.prerequisite_variant_id, 10),
- }
-}
-
-/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
-function ExerciseVariantFields({
- row,
- onPatch,
- prerequisiteOthers,
- rteMinHeight = '110px',
- inlineExerciseId,
- linkedExerciseMedia = [],
- onExerciseMediaListChanged,
-}) {
- return (
- <>
-
- Variantenname *
- onPatch({ variant_name: e.target.value })}
- minLength={3}
- />
-
-
- Kurzbeschreibung
- onPatch({ description: e.target.value })}
- />
-
-
- Abweichungen zur Durchführung
- onPatch({ execution_changes: html })}
- placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
- minHeight={rteMinHeight}
- inlineExerciseId={inlineExerciseId}
- linkedExerciseMedia={linkedExerciseMedia}
- onExerciseMediaListChanged={onExerciseMediaListChanged}
- />
-
-
-
- Materialänderungen (eine Zeile pro Eintrag)
- onPatch({ equipment_lines: e.target.value })}
- placeholder="+ Pratzen"
- />
-
-
-
- Schwere relativ
- onPatch({ difficulty_adjustment: e.target.value })}
- >
- {VARIANT_DIFFICULTY.map((o) => (
-
- {o.label}
-
- ))}
-
-
-
- Progressions-Stufe (1–10)
-
- onPatch({
- progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
- })
- }
- />
-
-
- Voraussetzungs-Variante
-
- onPatch({
- prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
- })
- }
- >
- — keine —
- {prerequisiteOthers.map((o) => (
-
- {o.variant_name || `Variante #${o.id}`}
-
- ))}
-
-
-
- >
- )
-}
-
-function emptyForm() {
- return {
- title: '',
- summary: '',
- goal: '',
- execution: '',
- preparation: '',
- trainer_notes: '',
- equipmentLines: '',
- duration_min: '',
- duration_max: '',
- group_size_min: '',
- group_size_max: '',
- focus_areas_multi: [],
- training_styles_multi: [],
- training_types_multi: [],
- target_groups_multi: [],
- visibility: 'private',
- club_id: null,
- status: 'draft',
- skills: [],
- exercise_kind: 'simple',
- method_archetype: '',
- method_profile_json: '{}',
- combination_slots: [emptyComboSlotRow()],
- }
-}
-
-function detailToForm(exercise) {
- return {
- title: exercise.title || '',
- summary: exercise.summary || '',
- goal: exercise.goal || '',
- execution: exercise.execution || '',
- preparation: exercise.preparation || '',
- trainer_notes: exercise.trainer_notes || '',
- equipmentLines: (exercise.equipment || []).join('\n'),
- duration_min: exercise.duration_min ?? '',
- duration_max: exercise.duration_max ?? '',
- group_size_min: exercise.group_size_min ?? '',
- group_size_max: exercise.group_size_max ?? '',
- focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
- focus_area_id: f.focus_area_id,
- is_primary: !!f.is_primary,
- })),
- training_styles_multi: (exercise.training_styles || []).map((t) => ({
- training_style_id: t.training_style_id,
- is_primary: !!t.is_primary,
- })),
- training_types_multi: (exercise.training_types || []).map((t) => ({
- training_type_id: t.training_type_id,
- is_primary: !!t.is_primary,
- })),
- target_groups_multi: (exercise.target_groups || []).map((g) => ({
- target_group_id: g.target_group_id,
- is_primary: !!g.is_primary,
- })),
- visibility: exercise.visibility || 'private',
- club_id:
- String(exercise.visibility || '').trim().toLowerCase() === 'club' &&
- exercise.club_id != null &&
- exercise.club_id !== ''
- ? Number(exercise.club_id)
- : null,
- status: exercise.status || 'draft',
- skills:
- exercise.skills?.map((s) => ({
- skill_id: s.skill_id,
- is_primary: s.is_primary || false,
- intensity: s.intensity || '',
- required_level: normalizeSkillLevelSlug(s.required_level),
- target_level: normalizeSkillLevelSlug(s.target_level),
- })) || [],
- exercise_kind:
- String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
- ? 'combination'
- : 'simple',
- method_archetype: exercise.method_archetype != null ? String(exercise.method_archetype) : '',
- method_profile_json:
- typeof exercise.method_profile === 'object' &&
- exercise.method_profile != null &&
- !Array.isArray(exercise.method_profile)
- ? JSON.stringify(exercise.method_profile, null, 2)
- : '{}',
- combination_slots: comboSlotsFromDetail(exercise),
- }
-}
-
-function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
- const setPrimary = (idx) => {
- setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
- }
- const updateRow = (idx, patch) => {
- const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
- if (patch.is_primary === true) {
- next.forEach((r, i) => {
- if (i !== idx) r.is_primary = false
- })
- }
- setRows(next)
- }
- const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
- const removeRow = (idx) => {
- const next = rows.filter((_, i) => i !== idx)
- if (next.length && !next.some((r) => r.is_primary)) next[0].is_primary = true
- setRows(next)
- }
-
- return (
-
-
-
{title}
-
- + Eintrag
-
-
- {rows.length === 0 && (
-
{emptyLabel}
- )}
- {rows.map((row, idx) => (
-
- updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
- >
- — wählen —
- {options.map((o) => (
-
- {o.icon ? `${o.icon} ` : ''}
- {o.name}
- {o.abbreviation ? ` (${o.abbreviation})` : ''}
-
- ))}
-
-
- setPrimary(idx)}
- />
- primär
-
- removeRow(idx)}>
- ✕
-
-
- ))}
-
- )
-}
-
-function ExerciseFormPage() {
- const { id: routeId } = useParams()
- const navigate = useNavigate()
- const { user } = useAuth()
- const isSuperadmin = user?.role === 'superadmin'
- const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
- const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
-
- const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
- useEffect(() => {
- if (!isPlatformAdmin) {
- setClubsForGovernanceForms([])
- return undefined
- }
- let cancelled = false
- ;(async () => {
- try {
- const list = await api.listClubs()
- if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
- } catch {
- if (!cancelled) setClubsForGovernanceForms([])
- }
- })()
- return () => {
- cancelled = true
- }
- }, [isPlatformAdmin, tenantClubDepKey])
-
- const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
-
- /** Plattform-Admin: alle Vereine; sonst nur Mitgliedschafts-Vereine. */
- const visibilityClubChoices = useMemo(() => {
- if (isPlatformAdmin && clubsForGovernanceForms.length > 0) {
- return [...clubsForGovernanceForms].sort((a, b) =>
- String(a.name || '').localeCompare(String(b.name || ''), 'de'),
- )
- }
- return [...membershipClubRows].sort((a, b) =>
- String(a.name || '').localeCompare(String(b.name || ''), 'de'),
- )
- }, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows])
-
- const governanceDefaultClubId = useMemo(() => getDefaultClubIdForGovernanceForms(user), [user])
-
- const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
- const isEdit = exerciseId != null
-
- const [formData, setFormData] = useState(emptyForm)
- const [skillsCatalog, setSkillsCatalog] = useState([])
- const [focusAreas, setFocusAreas] = useState([])
- const [styleDirections, setStyleDirections] = useState([])
- const [trainingTypes, setTrainingTypes] = useState([])
- const [targetGroups, setTargetGroups] = useState([])
- const [mediaList, setMediaList] = useState([])
- const [loading, setLoading] = useState(!!isEdit)
- const [saving, setSaving] = useState(false)
- const [formDirty, setFormDirty] = useState(false)
- const [skillPick, setSkillPick] = useState('')
-
- const toast = useToast()
- const allowUnloadBlock = Boolean(formDirty && !loading && !saving)
- useBeforeUnloadWhen(allowUnloadBlock)
- const blocker = useUnsavedChangesBlocker(allowUnloadBlock)
- const [variants, setVariants] = useState([])
- const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
- const [variantSavingId, setVariantSavingId] = useState(null)
- const [variantBusy, setVariantBusy] = useState(false)
- const [variantEditSelection, setVariantEditSelection] = useState(null)
- const variantsDetailsRef = useRef(null)
-
- const [mediaFields, setMediaFields] = useState({})
- const [mediaSavingId, setMediaSavingId] = useState(null)
- const [archiveOpen, setArchiveOpen] = useState(false)
- const [archiveQ, setArchiveQ] = useState('')
- const [archiveLoading, setArchiveLoading] = useState(false)
- const [archiveItems, setArchiveItems] = useState([])
- const [archiveError, setArchiveError] = useState(null)
- const [mediaPreview, setMediaPreview] = useState(null)
- const [reportTarget, setReportTarget] = useState(null)
-
- useEffect(() => {
- const next = {}
- for (const m of mediaList) {
- next[m.id] = {
- title: m.title || '',
- }
- }
- setMediaFields(next)
- }, [mediaList])
-
- useEffect(() => {
- const onDragOverDoc = (e) => {
- const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : []
- if (!types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) return
- e.preventDefault()
- autoScrollForDragNearEdges(e)
- }
- document.addEventListener('dragover', onDragOverDoc)
- return () => document.removeEventListener('dragover', onDragOverDoc)
- }, [])
-
-
-
- useEffect(() => {
- if (!archiveOpen) return undefined
- let cancelled = false
- const t = setTimeout(async () => {
- setArchiveLoading(true)
- setArchiveError(null)
- try {
- const res = await api.listMediaAssets({
- q: archiveQ.trim() || undefined,
- limit: 40,
- })
- if (!cancelled) setArchiveItems((res.items || []).filter(a => !a.legal_hold_active))
- } catch (e) {
- if (!cancelled) setArchiveError(e.message || String(e))
- } finally {
- if (!cancelled) setArchiveLoading(false)
- }
- }, 280)
- return () => {
- cancelled = true
- clearTimeout(t)
- }
- }, [archiveOpen, archiveQ])
-
- useEffect(() => {
- let cancelled = false
- const boot = async () => {
- try {
- const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
- api.listSkills(),
- api.listFocusAreas(),
- api.listTrainingStyles(),
- api.listTrainingTypes(),
- api.listTargetGroups(),
- ])
- if (cancelled) return
- setSkillsCatalog(skillsData)
- setFocusAreas(faData)
- setStyleDirections(sdData)
- setTrainingTypes(ttData)
- setTargetGroups(tgData)
- } catch (e) {
- if (!cancelled) {
- console.error(e)
- toast.error(
- 'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
- (e.message || e),
- )
- }
- }
- }
- boot()
- return () => {
- cancelled = true
- }
- }, [toast])
-
- useEffect(() => {
- if (!isEdit) {
- setFormData(emptyForm())
- setMediaList([])
- setVariants([])
- setVariantDraft(emptyVariantDraft())
- setVariantEditSelection(null)
- setFormDirty(false)
- setLoading(false)
- return
- }
- let cancelled = false
- const load = async () => {
- setLoading(true)
- try {
- const exercise = await api.getExercise(exerciseId)
- if (cancelled) return
- setFormData(detailToForm(exercise))
- setMediaList(exercise.media || [])
- setVariants((exercise.variants || []).map(apiVariantToRow))
- setVariantDraft(emptyVariantDraft())
- setVariantEditSelection(null)
- setFormDirty(false)
- } catch (err) {
- if (!cancelled) {
- toast.error(err.message || 'Übung nicht ladbar')
- navigate('/exercises')
- }
- } finally {
- if (!cancelled) setLoading(false)
- }
- }
- load()
- return () => {
- cancelled = true
- }
- }, [isEdit, exerciseId, navigate, toast])
-
- useEffect(() => {
- if (variantEditSelection == null || variantEditSelection === 'new') return
- if (!variants.some((v) => v.id === variantEditSelection)) {
- setVariantEditSelection(null)
- }
- }, [variants, variantEditSelection])
-
- useEffect(() => {
- if (variantEditSelection != null && variantsDetailsRef.current) {
- variantsDetailsRef.current.open = true
- }
- }, [variantEditSelection])
-
- const updateFormField = (field, value) => {
- setFormDirty(true)
- setFormData((prev) => ({ ...prev, [field]: value }))
- }
-
- useEffect(() => {
- if (formData.visibility !== 'club') return
- const choices = visibilityClubChoices
- if (!choices.length) return
-
- const id =
- formData.club_id != null && formData.club_id !== '' ? Number(formData.club_id) : NaN
- const hasValid =
- Number.isFinite(id) && id > 0 && choices.some((c) => Number(c.id) === id)
-
- if (hasValid) return
-
- const fallback = governanceDefaultClubId
- const next =
- fallback != null &&
- Number.isFinite(Number(fallback)) &&
- choices.some((c) => Number(c.id) === Number(fallback))
- ? Number(fallback)
- : Number(choices[0].id)
-
- setFormData((prev) => {
- if (prev.visibility !== 'club') return prev
- if (prev.club_id != null && Number(prev.club_id) === next) return prev
- return { ...prev, club_id: next }
- })
- }, [
- formData.visibility,
- formData.club_id,
- visibilityClubChoices,
- governanceDefaultClubId,
- ])
-
- const [comboStationPickerIx, setComboStationPickerIx] = useState(null)
- const [comboDropTargetIx, setComboDropTargetIx] = useState(null)
-
- const reorderCombinationSlots = (fromI, toBeforeIx) => {
- setFormDirty(true)
- setFormData((prev) => {
- const rows = [...(prev.combination_slots || [])]
- if (fromI < 0 || fromI >= rows.length) return prev
- const [moved] = rows.splice(fromI, 1)
- let insertAt = toBeforeIx
- if (fromI < toBeforeIx) insertAt = toBeforeIx - 1
- insertAt = Math.max(0, Math.min(insertAt, rows.length))
- rows.splice(insertAt, 0, moved)
- return { ...prev, combination_slots: rows }
- })
- }
-
- const patchComboSlotRow = (idx, patch) => {
- setFormDirty(true)
- setFormData((prev) => {
- const rows = [...(prev.combination_slots || [])]
- if (!rows[idx]) return prev
- rows[idx] = { ...rows[idx], ...patch }
- return { ...prev, combination_slots: rows }
- })
- }
-
- const removeCandidateFromSlot = (slotIdx, exerciseId) => {
- setFormDirty(true)
- setFormData((prev) => {
- const rows = [...(prev.combination_slots || [])]
- const row = rows[slotIdx]
- if (!row) return prev
- const nextIds = (row.candidate_exercise_ids || []).filter((id) => Number(id) !== Number(exerciseId))
- const labels = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
- delete labels[Number(exerciseId)]
- rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
- return { ...prev, combination_slots: rows }
- })
- }
-
- const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
- if (!Array.isArray(pickedList) || !pickedList.length) return
- const rowNow = (formData.combination_slots || [])[slotIdx] || emptyComboSlotRow()
- const existingIds = Array.isArray(rowNow.candidate_exercise_ids)
- ? rowNow.candidate_exercise_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n))
- : []
- const ordered = [...existingIds]
- pickedList.forEach((ex) => {
- if (ex?.id == null) return
- const id = Number(ex.id)
- if (!Number.isFinite(id)) return
- if (!ordered.includes(id)) ordered.push(id)
- })
- let nextIds = ordered
- if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) {
- toast.info(
- `Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner Wechsel‑Pool. Überschüssige Auswahl wurde abgeschnitten.`,
- )
- nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
- }
- setFormDirty(true)
- setFormData((prev) => {
- const rows = [...(prev.combination_slots || [])]
- const row = rows[slotIdx] || emptyComboSlotRow()
- const labels =
- row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
- pickedList.forEach((ex) => {
- if (ex && ex.id != null) {
- const id = Number(ex.id)
- const t = (ex.title || '').trim()
- if (t) labels[id] = t
- }
- })
- rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
- return { ...prev, combination_slots: rows }
- })
- }
-
- const addSkillRow = () => {
- const id = skillPick ? parseInt(skillPick, 10) : null
- if (!id) {
- toast.error('Fähigkeit wählen')
- return
- }
- if (formData.skills.some((s) => s.skill_id === id)) {
- toast.info('Bereits zugeordnet')
- return
- }
- updateFormField('skills', [
- ...formData.skills,
- {
- skill_id: id,
- is_primary: formData.skills.length === 0,
- intensity: '',
- required_level: '',
- target_level: '',
- },
- ])
- setSkillPick('')
- }
-
- const setSkillPrimary = (idx) => {
- updateFormField(
- 'skills',
- formData.skills.map((s, i) => ({ ...s, is_primary: i === idx })),
- )
- }
-
- const updateSkillField = (idx, field, value) => {
- updateFormField(
- 'skills',
- formData.skills.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
- )
- }
-
- const removeSkillRow = (idx) => {
- const next = formData.skills.filter((_, i) => i !== idx)
- if (next.length && !next.some((s) => s.is_primary)) next[0].is_primary = true
- updateFormField('skills', next)
- }
-
- const performSaveAttempt = useCallback(
- async ({ fromUnsavedDialog = false } = {}) => {
- if (!formData.title || formData.title.trim().length < 3) {
- toast.error('Titel mindestens 3 Zeichen')
- return false
- }
- const payloadBase = {
- ...formData,
- equipment:
- typeof formData.equipmentLines === 'string'
- ? formData.equipmentLines
- .split(/[\n,]+/)
- .map((s) => s.trim())
- .filter(Boolean)
- : [],
- }
- let payload
- try {
- payload = buildExerciseApiPayload(payloadBase)
- } catch (err) {
- toast.error(err.message)
- return false
- }
- setSaving(true)
- try {
- if (isEdit) {
- const saveOnce = (extras = {}) =>
- api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
- try {
- await saveOnce()
- } catch (firstErr) {
- if (
- firstErr.status === 422 &&
- firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
- firstErr.payload?.media_assets
- ) {
- toast.error(
- 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
- 'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
- )
- throw firstErr
- }
- if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
- const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
- const miss = (firstErr.payload.assets_missing_copyright || []).length
- let msg = 'Die Übung ist oder wird offiziell. '
- if (promo > 0) {
- msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
- }
- if (miss > 0) {
- msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
- }
- msg += 'Fortfahren?'
- if (!window.confirm(msg)) throw firstErr
- let defaultCopyright = ''
- if (miss > 0) {
- defaultCopyright = window.prompt(
- 'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
- '© ',
- )
- if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
- toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.')
- throw firstErr
- }
- }
- await saveOnce({
- promote_attached_media_for_official: true,
- ...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
- })
- } else if (
- firstErr.status === 422 &&
- firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
- firstErr.payload?.media_assets
- ) {
- const miss = firstErr.payload.media_assets.length
- const msg =
- `Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` +
- `${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` +
- `Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?`
- if (!window.confirm(msg)) throw firstErr
- const defaultCopyright = window.prompt(
- 'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):',
- '© ',
- )
- if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
- toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.')
- throw firstErr
- }
- await saveOnce({
- default_club_media_copyright: String(defaultCopyright).trim(),
- })
- } else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
- toast.error(
- 'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
- )
- throw firstErr
- } else {
- throw firstErr
- }
- }
- const ex = await api.getExercise(exerciseId)
- setMediaList(ex.media || [])
- setVariants((ex.variants || []).map(apiVariantToRow))
- setFormDirty(false)
- toast.success('Gespeichert.')
- return true
- }
- const created = await api.createExercise(payload)
- setFormDirty(false)
- toast.success('Übung angelegt.')
- if (!fromUnsavedDialog) {
- navigate(`/exercises/${created.id}/edit`, { replace: true })
- }
- return true
- } catch (err) {
- toast.error('Fehler beim Speichern: ' + err.message)
- return false
- } finally {
- setSaving(false)
- }
- },
- [exerciseId, formData, isEdit, navigate, toast],
- )
-
- const handleSubmit = async (e) => {
- e.preventDefault()
- await performSaveAttempt({ fromUnsavedDialog: false })
- }
-
- const handleUnsavedDialogSave = async () => {
- const ok = await performSaveAttempt({ fromUnsavedDialog: true })
- if (ok) blocker.proceed()
- }
-
- const refreshMedia = async () => {
- if (!exerciseId) return
- const ex = await api.getExercise(exerciseId)
- setMediaList(ex.media || [])
- }
-
- const attachFromArchive = async (assetId) => {
- if (!exerciseId) return
- try {
- await api.attachExerciseMediaFromAsset(exerciseId, {
- media_asset_id: assetId,
- context: 'ablauf',
- title: '',
- description: '',
- is_primary: false,
- })
- setArchiveOpen(false)
- await refreshMedia()
- } catch (e) {
- toast.error(e.message || String(e))
- }
- }
-
- const linkedArchiveAssetIds = useMemo(
- () => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)),
- [mediaList],
- )
-
- const handleDeleteMedia = async (mid) => {
- if (
- !confirm(
- 'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.\n\n' +
- 'Hinweis: Wenn dieser Eintrag noch als Platzhalter im Fließtext steht, zeigt die Vorschau [Medium nicht verfügbar] oder das Speichern der Übung schlägt fehl, bis der Platzhalter entfernt ist.',
- )
- ) {
- return
- }
- try {
- const res = await api.deleteExerciseMedia(exerciseId, mid)
- await refreshMedia()
- const oid = res?.orphan_media_asset_id
- if (oid != null) {
- if (
- confirm(
- 'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
- )
- ) {
- await api.postMediaAssetLifecycle(oid, 'trash_soft')
- await refreshMedia()
- }
- }
- } catch (err) {
- toast.error(err.message)
- }
- }
-
- const moveMediaRow = async (idx, dir) => {
- if (!exerciseId) return
- const j = idx + dir
- if (j < 0 || j >= mediaList.length) return
- const next = [...mediaList]
- const tmp = next[idx]
- next[idx] = next[j]
- next[j] = tmp
- try {
- await api.reorderExerciseMedia(
- exerciseId,
- next.map((x) => x.id),
- )
- setMediaList(next)
- } catch (e) {
- toast.error(e.message || String(e))
- }
- }
-
- const saveMediaMeta = async (mid) => {
- if (!exerciseId) return
- const fld = mediaFields[mid]
- if (!fld) return
- setMediaSavingId(mid)
- try {
- await api.updateExerciseMedia(exerciseId, mid, {
- title: fld.title.trim() || null,
- })
- await refreshMedia()
- } catch (e) {
- toast.error(e.message || String(e))
- } finally {
- setMediaSavingId(null)
- }
- }
-
- const refreshVariants = async () => {
- if (!exerciseId) return
- const ex = await api.getExercise(exerciseId)
- setVariants((ex.variants || []).map(apiVariantToRow))
- }
-
- const updateVariantField = (id, patch) => {
- setFormDirty(true)
- setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
- }
-
- const saveVariantRow = async (row) => {
- const payload = buildVariantPayloadFromRow(row)
- if (payload.variant_name.length < 3) {
- toast.error('Variantenname mindestens 3 Zeichen')
- return
- }
- setVariantSavingId(row.id)
- try {
- await api.updateExerciseVariant(exerciseId, row.id, payload)
- await refreshVariants()
- } catch (e) {
- toast.error(e.message || String(e))
- } finally {
- setVariantSavingId(null)
- }
- }
-
- const deleteVariantRow = async (id) => {
- if (!confirm('Variante wirklich löschen?')) return
- setVariantBusy(true)
- try {
- await api.deleteExerciseVariant(exerciseId, id)
- if (variantEditSelection === id) setVariantEditSelection(null)
- await refreshVariants()
- } catch (e) {
- toast.error(e.message || String(e))
- } finally {
- setVariantBusy(false)
- }
- }
-
- const moveVariantRow = async (idx, dir) => {
- const j = idx + dir
- if (j < 0 || j >= variants.length) return
- const next = [...variants]
- const tmp = next[idx]
- next[idx] = next[j]
- next[j] = tmp
- const ids = next.map((x) => x.id)
- setVariantBusy(true)
- try {
- await api.reorderExerciseVariants(exerciseId, ids)
- await refreshVariants()
- } catch (e) {
- toast.error(e.message || String(e))
- } finally {
- setVariantBusy(false)
- }
- }
-
- const createVariantSubmit = async (e) => {
- e.preventDefault()
- if (!exerciseId) return
- const payload = buildVariantPayloadFromRow(variantDraft)
- if (payload.variant_name.length < 3) {
- toast.error('Variantenname mindestens 3 Zeichen')
- return
- }
- setVariantBusy(true)
- try {
- const created = await api.createExerciseVariant(exerciseId, payload)
- setVariantDraft(emptyVariantDraft())
- await refreshVariants()
- if (created?.id != null) setVariantEditSelection(created.id)
- else setVariantEditSelection(null)
- } catch (err) {
- toast.error(err.message || String(err))
- } finally {
- setVariantBusy(false)
- }
- }
-
- const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
-
- const selectedVariantForEdit =
- typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
- const selectedVariantIdx = selectedVariantForEdit
- ? variants.findIndex((v) => v.id === selectedVariantForEdit.id)
- : -1
-
- if (loading) {
- return (
-
- )
- }
-
- return (
-
-
- navigate('/exercises')}>
- ← Übersicht
-
- {isEdit && (
- navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })}
- >
- Ansehen
-
- )}
-
-
-
-
{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}
-
-
-
- Titel *
- updateFormField('title', e.target.value)}
- required
- minLength={3}
- />
-
-
-
- Kurzbeschreibung
- updateFormField('summary', html)}
- placeholder="Kurzbeschreibung (optional)"
- minHeight="80px"
- inlineExerciseId={isEdit ? exerciseId : null}
- linkedExerciseMedia={isEdit ? mediaList : []}
- onExerciseMediaListChanged={refreshMedia}
- />
-
-
-
-
Übungstyp
-
- Art
- {
- const nk = e.target.value
- setFormDirty(true)
- setFormData((prev) => ({
- ...prev,
- exercise_kind: nk,
- ...(nk === 'simple'
- ? {
- method_archetype: '',
- method_profile_json: '{}',
- combination_slots: [emptyComboSlotRow()],
- }
- : {}),
- }))
- }}
- >
- Einzelübung
- Kombinationsübung (Stationen / Pool)
-
-
- {formData.exercise_kind === 'combination' ? (
- <>
-
- Methoden-Archetyp (für Coach & Planung empfohlen)
- {
- const arch = (e.target.value || '').trim()
- const forced = ARCHETYPE_DEFAULT_REP_SERIES_COUNT[arch]
- setFormDirty(true)
- setFormData((prev) => {
- const slots = prev.combination_slots || []
- const nextSlots =
- forced !== undefined && forced !== null
- ? slots.map((row) =>
- normalizeAdvanceMode(row.advance_mode) !== 'timed'
- ? {
- ...row,
- rep_series_count: String(Math.max(1, Math.round(Number(forced)))),
- }
- : row,
- )
- : slots
- return { ...prev, method_archetype: arch, combination_slots: nextSlots }
- })
- }}
- >
- — noch nicht festgelegt —
- {COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
-
- {o.label}
-
- ))}
-
-
- {String(formData.method_archetype || '').trim() === 'station_parcour' ? (
-
- Parcours / Bahnsystem: typischerweise starten alle an Station 1 und durchlaufen der
- Reihe nach alle Punkte (Geschwindigkeit variabel). Die Stationsreihenfolge unten ist der Ablaufweg;
- Zeitangaben pro Station und Gesamtdurchläufe im Ablaufprofil strukturieren das
- spätere Coaching.
-
- ) : null}
-
-
- Stationen
-
- Ablauf = Reihenfolge · ziehen / Pfeile · Einzelübungen · max. {MAX_COMBO_CANDIDATES_PER_STATION}/Station
-
-
-
- Pro Station oft eine feste Übung; höchstens drei als kleiner Auswahl‑Pool.
- Unter Steuerung wählen: zeitlich, nach Wiederholungszahl oder ohne Arbeitsuhr (Coach führt).
-
- {(formData.combination_slots || []).map((row, idx) => {
- const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
- const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION
- const slotAdv = normalizeAdvanceMode(row.advance_mode)
- const serieLabel =
- slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
- const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '–' : '1'
- const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
- const serienCountUi = parseComboRepSeriesCountUi(row.rep_series_count)
- const showInterSeriesPause = showMultiSeries && serienCountUi >= 2
- const intraLabel = slotAdv === 'timed' ? 'Pause (s)' : 'Pause zw. Serien'
- const lbl =
- row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
- ? row.exercise_title_by_id
- : {}
- const isDropHere = comboDropTargetIx === idx
- return (
-
{
- if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- setComboDropTargetIx(idx)
- }}
- onDragLeave={() => setComboDropTargetIx((cur) => (cur === idx ? null : cur))}
- onDrop={(e) => {
- const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
- const fromI = parseInt(rawFrom, 10)
- e.preventDefault()
- setComboDropTargetIx(null)
- if (!Number.isFinite(fromI)) return
- reorderCombinationSlots(fromI, idx)
- }}
- style={{
- marginBottom: '12px',
- padding: '12px 14px',
- borderRadius: '12px',
- border: `1px solid ${isDropHere ? 'var(--accent)' : 'var(--border)'}`,
- background: 'var(--surface)',
- boxShadow: isDropHere ? '0 0 0 2px var(--accent-soft)' : 'none',
- }}
- >
-
-
{
- e.dataTransfer.effectAllowed = 'move'
- e.dataTransfer.setData(DND_EXERCISE_COMBO_STATION, String(idx))
- }}
- onDragEnd={() => setComboDropTargetIx(null)}
- aria-label={`Station ${idx + 1} ziehen`}
- title="Ziehen zum Sortieren"
- className="btn btn-secondary framework-ctrl framework-ctrl--xs"
- style={{ cursor: 'grab', padding: '6px 8px', touchAction: 'none' }}
- >
-
-
-
- reorderCombinationSlots(idx, idx - 1)}
- >
- ▲
-
- reorderCombinationSlots(idx, idx + 2)}
- >
- ▼
-
-
-
-
- Name (St. {idx + 1})
-
- patchComboSlotRow(idx, { title: e.target.value })}
- />
-
-
- setComboStationPickerIx(idx)}
- >
- + Übung
-
- {
- const prev = formData.combination_slots || []
- const next = prev.filter((_, j) => j !== idx)
- updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
- }}
- >
- Entfernen
-
-
-
-
-
- Übungen ({candIds.length}/{MAX_COMBO_CANDIDATES_PER_STATION})
-
- {candIds.length === 0 ? (
-
- Mindestens eine Übung — mit „+ Übung“ wählen.
-
- ) : (
-
- {candIds.map((id) => (
-
-
- {(lbl[id] || lbl[String(id)] || '').trim() || `Übung #${id}`}
-
- removeCandidateFromSlot(idx, id)}
- >
- ✗
-
-
- ))}
-
- )}
-
-
-
- Steuerung
-
- {
- const m = normalizeAdvanceMode(e.target.value)
- const patch = { advance_mode: m }
- if (m !== 'timed') patch.load_sec = ''
- if (m === 'rep' || m === 'manual') {
- const curSer = String(row.rep_series_count ?? '').trim()
- if (!curSer) {
- patch.rep_series_count = String(
- defaultRepSeriesCountForArchetype(formData.method_archetype || ''),
- )
- }
- }
- patchComboSlotRow(idx, patch)
- }}
- >
- Zeit (Arbeit in Sekunden)
- Wiederholungen (Ziel)
- Coach (Weiter nach Freigabe)
-
-
-
- {slotAdv === 'timed'
- ? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.'
- : slotAdv === 'rep'
- ? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen. Ab zwei Serien: Pause zwischen diesen Serien; sonst nur Wechsel zur nächsten Station.'
- : 'Coach: keine feste Arbeitsuhr — Fortschritt später per Tipp. Ab 2 Serien: Pause zwischen Serien; sonst nur Wechsel zur nächsten Station zeitlich planen.'}
-
-
- {showMultiSeries && serienCountUi < 2 ? (
-
- Wechsel (s) = Pause bis zur nächsten Station . Feld „Pause zw.
- Serien“ erscheint erst ab 2 Serien (sonst keine Pause zwischen zwei Blöcken nötig).
-
- ) : null}
-
- )
- })}
-
{
- if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- }}
- onDrop={(e) => {
- const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
- const fromI = parseInt(rawFrom, 10)
- e.preventDefault()
- setComboDropTargetIx(null)
- if (!Number.isFinite(fromI)) return
- const len = (formData.combination_slots || []).length
- reorderCombinationSlots(fromI, len)
- }}
- style={{
- padding: '10px',
- textAlign: 'center',
- fontSize: '11px',
- color: 'var(--text3)',
- border: '1px dashed var(--border)',
- borderRadius: '10px',
- marginBottom: '8px',
- }}
- >
- Hier ablegen zum Anhängen am Ende der Reihenfolge
-
-
updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
- >
- + Station
-
-
-
- Ablaufprofil (Runden & global)
- updateFormField('method_profile_json', s)}
- comboSlotsOutline={(formData.combination_slots || []).map((r, i) => ({
- slot_index: i,
- title: r.title || '',
- }))}
- omitPerSlotTiming
- />
-
- >
- ) : null}
-
-
-
- Ziel *
- updateFormField('goal', html)}
- placeholder="Trainingsziel"
- minHeight="120px"
- inlineExerciseId={isEdit ? exerciseId : null}
- linkedExerciseMedia={isEdit ? mediaList : []}
- onExerciseMediaListChanged={refreshMedia}
- />
-
-
-
- Durchführung *
- updateFormField('execution', html)}
- placeholder="Ablauf Schritt für Schritt"
- minHeight="180px"
- inlineExerciseId={isEdit ? exerciseId : null}
- linkedExerciseMedia={isEdit ? mediaList : []}
- onExerciseMediaListChanged={refreshMedia}
- />
-
-
-
- Vorbereitung / Aufbau
- updateFormField('preparation', html)}
- placeholder="Matten, Raum, …"
- minHeight="100px"
- inlineExerciseId={isEdit ? exerciseId : null}
- linkedExerciseMedia={isEdit ? mediaList : []}
- onExerciseMediaListChanged={refreshMedia}
- />
-
-
-
- Hinweise für Trainer
- updateFormField('trainer_notes', html)}
- placeholder="Sicherheit, Varianten-Hinweise, …"
- minHeight="100px"
- inlineExerciseId={isEdit ? exerciseId : null}
- linkedExerciseMedia={isEdit ? mediaList : []}
- onExerciseMediaListChanged={refreshMedia}
- />
-
-
-
- Material (eine Zeile oder kommagetrennt)
- updateFormField('equipmentLines', e.target.value)}
- placeholder="Matten
Pratzen"
- />
-
-
-
-
-
-
- updateFormField('focus_areas_multi', r)}
- options={focusAreas}
- idKey="focus_area_id"
- emptyLabel="Keine Zuordnung — optional „+ Eintrag“."
- />
-
- updateFormField('training_styles_multi', r)}
- options={styleDirections.map((sd) => ({
- ...sd,
- name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
- }))}
- idKey="training_style_id"
- emptyLabel="Keine Stilrichtung gewählt."
- />
-
- updateFormField('training_types_multi', r)}
- options={trainingTypes}
- idKey="training_type_id"
- emptyLabel="Kein Trainingsstil gewählt."
- />
-
- updateFormField('target_groups_multi', r)}
- options={targetGroups}
- idKey="target_group_id"
- emptyLabel="Keine Zielgruppe gewählt."
- />
-
-
-
Fähigkeiten (je Übung mehrere, mit Niveau)
-
- setSkillPick(e.target.value)}
- >
- Fähigkeit wählen…
- {availableSkills.map((s) => (
-
- {s.name} ({s.category})
-
- ))}
-
-
- Hinzufügen
-
-
- {formData.skills.map((row, idx) => {
- const sk = skillsCatalog.find((s) => s.id === row.skill_id)
- return (
-
-
- {sk?.name || `Skill #${row.skill_id}`}
- {sk?.category && (
-
- {sk.category}
-
- )}
-
-
- setSkillPrimary(idx)}
- />
- primär
-
-
updateSkillField(idx, 'intensity', e.target.value)}
- >
- {INTENSITY_OPTIONS.map((o) => (
-
- {o.label}
-
- ))}
-
-
updateSkillField(idx, 'required_level', e.target.value)}
- >
- {SKILL_LEVEL_OPTIONS.map((o) => (
-
- von {o.label}
-
- ))}
-
-
updateSkillField(idx, 'target_level', e.target.value)}
- >
- {SKILL_LEVEL_OPTIONS.map((o) => (
-
- bis {o.label}
-
- ))}
-
-
removeSkillRow(idx)}>
- Entf.
-
-
- )
- })}
-
-
-
-
- Sichtbarkeit
- updateFormField('visibility', e.target.value)}
- >
- Privat
- Verein
- {isSuperadmin ? Offiziell : null}
-
-
-
- Status
- updateFormField('status', e.target.value)}
- >
- Entwurf
- In Prüfung
- Freigegeben
- Archiviert
-
-
-
-
- {formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
-
-
Verein (Sichtbarkeit)
-
{
- const v = e.target.value
- updateFormField('club_id', v === '' ? null : Number(v))
- }}
- >
- {visibilityClubChoices.map((c) => (
-
- {(c.name || '').trim() || `Verein #${c.id}`}
-
- ))}
-
-
- Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
-
-
- ) : null}
-
-
-
- {saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'}
-
-
-
-
-
- {isEdit && formData.exercise_kind !== 'combination' && (
-
-
- Übungsvarianten
-
- {variants.length === 0
- ? 'keine'
- : `${variants.length} ${variants.length === 1 ? 'Variante' : 'Varianten'}`}
-
-
-
-
- Pro Durchgang nur eine Variante bearbeiten – weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
- Training; „Voraussetzung“ nutzt ihr später für Progressions-Serien.
-
-
- {variants.length > 0 && (
-
-
- Variante auswählen
-
- {
- const val = e.target.value
- if (val === '') setVariantEditSelection(null)
- else if (val === 'new') setVariantEditSelection('new')
- else setVariantEditSelection(parseInt(val, 10))
- }}
- >
- — nicht bearbeiten —
- {variants.map((v) => (
-
- {(v.variant_name && String(v.variant_name).trim()) || `Variante #${v.id}`}
-
- ))}
- + Neue Variante anlegen…
-
-
- )}
-
- {variants.length === 0 && (
-
- Noch keine Varianten – optional für andere Ausführung, Dauer oder Material in Planung und Training.
-
- )}
-
- {variants.length === 0 && variantEditSelection !== 'new' && (
-
setVariantEditSelection('new')}>
- Erste Variante anlegen
-
- )}
-
- {variantEditSelection === 'new' && (
-
- Neue Variante
- {
- setFormDirty(true)
- setVariantDraft((d) => ({ ...d, ...patch }))
- }}
- prerequisiteOthers={variants}
- rteMinHeight="110px"
- inlineExerciseId={isEdit ? exerciseId : null}
- linkedExerciseMedia={isEdit ? mediaList : []}
- onExerciseMediaListChanged={refreshMedia}
- />
-
- {variantBusy ? 'Anlegen…' : 'Variante anlegen'}
-
-
- )}
-
- {selectedVariantForEdit && (
-
-
-
- Pos. {selectedVariantIdx + 1} von {variants.length}
-
- moveVariantRow(selectedVariantIdx, -1)}
- >
- Nach oben
-
- = variants.length - 1}
- onClick={() => moveVariantRow(selectedVariantIdx, 1)}
- >
- Nach unten
-
- saveVariantRow(selectedVariantForEdit)}
- >
- {variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Speichern'}
-
- deleteVariantRow(selectedVariantForEdit.id)}
- >
- Löschen
-
-
-
updateVariantField(selectedVariantForEdit.id, patch)}
- prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
- rteMinHeight="110px"
- inlineExerciseId={isEdit ? exerciseId : null}
- linkedExerciseMedia={isEdit ? mediaList : []}
- onExerciseMediaListChanged={refreshMedia}
- />
-
- )}
-
- {variants.length > 0 && variantEditSelection == null && (
-
- Wähle eine Variante zum Bearbeiten oder „Neue Variante anlegen…“.
-
- )}
-
-
- )}
-
- {isEdit && formData.exercise_kind !== 'combination' && (
-
-
- Progressionsgraph
- Übung → Übung
-
-
-
-
-
- )}
-
- {isEdit && (
-
-
Medien
-
- Neue Uploads oder Embeds über die Textfeld-Symbolleiste („Medien im Text“ / „Embed im Text“). Hier
- verwaltest du Verknüpfungen — Kachel in ein Textfeld ziehen, um sie an der Cursorposition einzufügen
- (mittlere Darstellung).
-
-
- Max. 10 Medien pro Übung.
-
-
- setArchiveOpen(true)}>
- Aus Archiv verknüpfen…
-
-
- Medienbibliothek
-
-
- {mediaList.length > 0 && (
-
- {mediaList.map((m, idx) => {
- const cap =
- (m.title || '').trim() ||
- (m.original_filename || '').trim() ||
- (m.embed_url ? String(m.embed_url).replace(/^https?:\/\//i, '').slice(0, 80) : '')
- const sub = [m.media_type, m.embed_platform].filter(Boolean).join(' · ') || 'Medium'
- const payloadCaption = (
- [m.title, m.original_filename].find((x) => typeof x === 'string' && x.trim()) || ''
- ).trim()
- return (
-
-
- {!m.embed_url ? (
-
- ) : (
-
- {m.embed_platform || 'Embed'}
-
- )}
-
{
- try {
- e.dataTransfer.setData(
- SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
- buildExerciseMediaDragPayload(m.id, payloadCaption),
- )
- e.dataTransfer.effectAllowed = 'copy'
- } catch (_) {
- /* ignore */
- }
- }}
- >
- ⣿ Ziehen
-
-
-
-
-
- #{m.id} · {sub}
-
-
-
{cap || '—'}
-
-
- setMediaFields((prev) => ({
- ...prev,
- [m.id]: {
- title: e.target.value,
- },
- }))
- }
- />
-
-
- {mediaList.length > 1 && (
- <>
- moveMediaRow(idx, -1)}
- title="Nach oben"
- >
- ↑
-
- = mediaList.length - 1}
- onClick={() => moveMediaRow(idx, 1)}
- title="Nach unten"
- >
- ↓
-
- >
- )}
- saveMediaMeta(m.id)}
- >
- {mediaSavingId === m.id ? '…' : 'Speichern'}
-
- handleDeleteMedia(m.id)}
- >
- Entfernen
-
-
-
-
- )
- })}
-
- )}
-
- Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über
- Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
-
- {archiveOpen && (
-
setArchiveOpen(false)}
- onKeyDown={(e) => e.key === 'Escape' && setArchiveOpen(false)}
- >
-
e.stopPropagation()}
- >
-
Medienarchiv
-
setArchiveQ(e.target.value)}
- style={{ marginBottom: '8px' }}
- />
- {archiveLoading &&
Laden…
}
- {archiveError &&
{archiveError}
}
- {!archiveLoading && !archiveError && archiveItems.length === 0 && (
-
Keine Treffer.
- )}
-
- {archiveItems.map((a) => {
- const already = linkedArchiveAssetIds.has(a.id)
- return (
-
-
- {a.mime_type?.startsWith('image/') ? (
-
- ) : a.mime_type?.startsWith('video/') ? (
-
- ▶
-
- ) : (
-
- PDF
-
- )}
-
-
-
- {a.original_filename || `Asset #${a.id}`}
-
-
- {a.visibility} · {a.mime_type || '—'}{' '}
- {a.byte_size != null ? `· ${(a.byte_size / 1024).toFixed(0)} KB` : ''}
-
-
- !already && attachFromArchive(a.id)}
- >
- {already ? 'Bereits verknüpft' : 'Verknüpfen'}
-
-
- )
- })}
-
-
- setArchiveOpen(false)}>
- Schließen
-
-
-
-
- )}
- {mediaPreview && (
-
setMediaPreview(null)}
- onReport={
- !mediaPreview.asset_legal_hold_active
- ? () => {
- setReportTarget(mediaPreview)
- setMediaPreview(null)
- }
- : null
- }
- />
- )}
- {reportTarget && (
- setReportTarget(null)}
- />
- )}
-
- )}
-
-
setComboStationPickerIx(null)}
- exerciseKindAny={['simple']}
- multiSelect
- enableQuickCreateDraft
- onSelectExercises={(picked) => {
- if (comboStationPickerIx === null) return
- mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
- setComboStationPickerIx(null)
- }}
- />
-
-
- KI-Ausbaustufe: Backend laut Spec{' '}
- POST /api/exercises/ai/suggest und{' '}
- POST /api/exercises/{'{id}'}/ai/regenerate — z. B.{' '}
- OPENROUTER_API_KEY, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
- api.suggestExerciseAi).
-
- setFormDirty(false)}
- />
-
- )
-}
-
-export default ExerciseFormPage
+/** Routen-Einstieg: Implementierung in `components/exercises/ExerciseFormPageRoot.jsx` (Phase-3 Soft-Limit). */
+export { default } from '../components/exercises/ExerciseFormPageRoot'
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index b6a8e88..5e5c9ee 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -1,590 +1,2 @@
-import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
-import { Link } from 'react-router-dom'
-import api from '../utils/api'
-import { useAuth } from '../context/AuthContext'
-import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
-import PageSectionNav from '../components/PageSectionNav'
-import ExerciseListCard from '../components/exercises/ExerciseListCard'
-import ExerciseListFilterModal from '../components/exercises/ExerciseListFilterModal'
-import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal'
-import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar'
-import ExerciseListBulkToolbar from '../components/exercises/ExerciseListBulkToolbar'
-import { buildExerciseListFilterChips } from '../utils/exerciseListFilterChips'
-import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../utils/exerciseListQuery'
-import { useExerciseListCatalogsAndQuery } from '../hooks/useExerciseListCatalogsAndQuery'
-import {
- INITIAL_EXERCISE_LIST_FILTERS,
- mergeExerciseListPrefsFromApi,
- compactExerciseListPrefsPayload,
-} from '../constants/exerciseListFilters'
-
-const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel'))
-
-const BULK_MAX_IDS = 500
-const EXERCISES_PAGE_TABS = [
- { id: 'list', label: 'Liste' },
- { id: 'progression', label: 'Progressionsgraphen' },
-]
-
-function ExercisesListPage() {
- const { user, checkAuth } = useAuth()
- const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
- const isSuperadmin = user?.role === 'superadmin'
- const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
-
- const [mineOnly, setMineOnly] = useState(() => {
- try {
- const sp = new URLSearchParams(window.location.search)
- return sp.get('mine') === '1' || sp.get('created_by_me') === '1'
- } catch {
- return false
- }
- })
-
- const [searchInput, setSearchInput] = useState('')
- const [aiSearchInput, setAiSearchInput] = useState('')
- const [debouncedSearch, setDebouncedSearch] = useState('')
- const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
- const [filters, setFilters] = useState(() => ({ ...INITIAL_EXERCISE_LIST_FILTERS }))
- const [filterModalOpen, setFilterModalOpen] = useState(false)
- const [savingExercisePrefs, setSavingExercisePrefs] = useState(false)
- const [pageTab, setPageTab] = useState('list')
- const prefsAppliedRef = useRef(false)
-
- const [selectedIds, setSelectedIds] = useState(() => new Set())
- const [bulkModalOpen, setBulkModalOpen] = useState(false)
- const [bulkVisibility, setBulkVisibility] = useState('')
- const [bulkStatus, setBulkStatus] = useState('')
- const [bulkClubSelect, setBulkClubSelect] = useState('')
- const [bulkClubManual, setBulkClubManual] = useState('')
- const [bulkSubmitting, setBulkSubmitting] = useState(false)
- const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false)
- const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([])
- const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false)
- const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([])
- const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false)
- const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
- const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
- const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
-
- useEffect(() => {
- if (!user?.id) return
- if (prefsAppliedRef.current) return
- const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
- setFilters(applyDashboardExerciseListUrl(merged))
- try {
- const sp = new URLSearchParams(window.location.search)
- if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
- } catch {
- /* ignore */
- }
- prefsAppliedRef.current = true
- }, [user?.id, user?.exercise_list_prefs])
-
- useEffect(() => {
- if (!user?.id) prefsAppliedRef.current = false
- }, [user?.id])
-
- useEffect(() => {
- const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
- return () => clearTimeout(t)
- }, [searchInput])
-
- useEffect(() => {
- const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
- return () => clearTimeout(t)
- }, [aiSearchInput])
-
- useEffect(() => {
- if (!filterModalOpen) return
- const onKey = (e) => {
- if (e.key === 'Escape') setFilterModalOpen(false)
- }
- window.addEventListener('keydown', onKey)
- return () => window.removeEventListener('keydown', onKey)
- }, [filterModalOpen])
-
- const queryBase = useMemo(
- () => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly),
- [filters, debouncedSearch, debouncedAiSearch, mineOnly]
- )
-
- const {
- catalogs,
- catalogsReady,
- exercises,
- setExercises,
- listFetching,
- loadingMore,
- hasMore,
- loadMore,
- } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
-
- useEffect(() => {
- setSelectedIds(new Set())
- }, [queryBase])
-
- const focusOptions = useMemo(
- () =>
- catalogs.focusAreas.map((fa) => ({
- id: fa.id,
- label: `${fa.icon || ''} ${fa.name || ''}`.trim(),
- })),
- [catalogs.focusAreas]
- )
- const styleOptions = useMemo(
- () => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
- [catalogs.styleDirections]
- )
- const trainingTypeOptions = useMemo(
- () => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
- [catalogs.trainingTypes]
- )
- const targetGroupOptions = useMemo(
- () => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
- [catalogs.targetGroups]
- )
- const skillOptions = useMemo(
- () => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
- [catalogs.skills]
- )
- const visibilityOptions = useMemo(
- () => [
- { id: 'private', label: 'Privat' },
- { id: 'club', label: 'Verein' },
- { id: 'official', label: 'Offiziell' },
- ],
- []
- )
- const statusOptions = useMemo(
- () => [
- { id: 'draft', label: 'Entwurf' },
- { id: 'in_review', label: 'In Prüfung' },
- { id: 'approved', label: 'Freigegeben' },
- { id: 'archived', label: 'Archiviert' },
- ],
- []
- )
-
- const filterChips = useMemo(
- () =>
- buildExerciseListFilterChips({
- mineOnly,
- setMineOnly,
- filters,
- setFilters,
- focusOptions,
- styleOptions,
- trainingTypeOptions,
- targetGroupOptions,
- skillOptions,
- visibilityOptions,
- statusOptions,
- }),
- [
- mineOnly,
- filters,
- focusOptions,
- styleOptions,
- trainingTypeOptions,
- targetGroupOptions,
- skillOptions,
- visibilityOptions,
- statusOptions,
- ]
- )
-
- /** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
- const searchTitleSuggestions = useMemo(() => {
- const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
- return [...new Set(titles)].slice(0, 80)
- }, [exercises])
-
- const clubNameById = useMemo(() => {
- const m = {}
- for (const c of activeClubMemberships(user?.clubs)) {
- if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
- }
- return m
- }, [user?.clubs])
-
- const effectiveClubId =
- user?.effective_club_id != null && user.effective_club_id !== ''
- ? Number(user.effective_club_id)
- : user?.active_club_id != null && user.active_club_id !== ''
- ? Number(user.active_club_id)
- : null
-
- const toggleSelect = useCallback((id) => {
- setSelectedIds((prev) => {
- const n = new Set(prev)
- const nid = Number(id)
- if (Number.isNaN(nid)) return prev
- if (n.has(nid)) n.delete(nid)
- else n.add(nid)
- return n
- })
- }, [])
-
- const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
-
- const toggleSelectAllPage = useCallback(() => {
- setSelectedIds((prev) => {
- const n = new Set(prev)
- const allSel =
- exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
- if (allSel) {
- exercises.forEach((e) => n.delete(Number(e.id)))
- } else {
- exercises.forEach((e) => n.add(Number(e.id)))
- }
- return n
- })
- }, [exercises])
-
- const allOnPageSelected = useMemo(
- () => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
- [exercises, selectedIds]
- )
-
- const bulkVisibilityOptions = useMemo(() => {
- const base = [
- { id: '', label: '— nicht ändern —' },
- { id: 'private', label: 'Privat' },
- { id: 'club', label: 'Verein' },
- ]
- if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
- return base
- }, [isSuperadmin])
-
- const handleDelete = async (exercise) => {
- if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
- try {
- await api.deleteExercise(exercise.id)
- setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
- } catch (err) {
- alert('Fehler beim Löschen: ' + err.message)
- }
- }
-
- const resetAllFilters = useCallback(() => {
- setMineOnly(false)
- setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS })
- }, [])
-
- const handleSaveExerciseFilterPrefs = useCallback(async () => {
- const uid = user?.id
- if (!uid) {
- alert('Nicht angemeldet.')
- return
- }
- setSavingExercisePrefs(true)
- try {
- const payload = compactExerciseListPrefsPayload(filters)
- await api.updateProfile(uid, { exercise_list_prefs: payload })
- await checkAuth()
- alert('Standardfilter für die Übungsliste gespeichert.')
- } catch (e) {
- alert('Speichern fehlgeschlagen: ' + (e.message || String(e)))
- } finally {
- setSavingExercisePrefs(false)
- }
- }, [user?.id, filters, checkAuth])
-
- const openBulkModal = () => {
- setBulkVisibility('')
- setBulkStatus('')
- setBulkClubSelect('')
- setBulkClubManual('')
- setBulkPatchFocusAreas(false)
- setBulkFocusAreaIds([])
- setBulkPatchStyleDirections(false)
- setBulkStyleDirectionIds([])
- setBulkPatchTrainingTypes(false)
- setBulkTrainingTypeIds([])
- setBulkPatchTargetGroups(false)
- setBulkTargetGroupIds([])
- setBulkModalOpen(true)
- }
-
- const handleBulkSubmit = async () => {
- const anyRelationPatch =
- bulkPatchFocusAreas ||
- bulkPatchStyleDirections ||
- bulkPatchTrainingTypes ||
- bulkPatchTargetGroups
- if (!bulkVisibility && !bulkStatus && !anyRelationPatch) {
- alert(
- 'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).'
- )
- return
- }
- const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
- if (ids.length === 0) {
- alert('Keine Übungen ausgewählt.')
- return
- }
- if (ids.length > BULK_MAX_IDS) {
- alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
- return
- }
- const payload = { exercise_ids: ids }
- if (bulkVisibility) payload.visibility = bulkVisibility
- if (bulkStatus) payload.status = bulkStatus
- if (bulkPatchFocusAreas) {
- payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
- }
- if (bulkPatchStyleDirections) {
- payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
- }
- if (bulkPatchTrainingTypes) {
- payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
- }
- if (bulkPatchTargetGroups) {
- payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
- }
- if (bulkVisibility === 'club') {
- const manual = String(bulkClubManual || '').trim()
- if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
- else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
- payload.club_id = Number(bulkClubSelect)
- }
- }
- setBulkSubmitting(true)
- try {
- const res = await api.bulkPatchExercisesMetadata(payload)
- const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
- let resolvedClubId = null
- if (bulkVisibility === 'club') {
- if (payload.club_id != null) resolvedClubId = payload.club_id
- else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
- }
- const clubLabel =
- resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
-
- let nextPrimaryFocusName = null
- if (bulkPatchFocusAreas) {
- const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
- if (faNums.length > 0) {
- const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0]))
- nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0])
- }
- }
-
- setExercises((prev) =>
- prev.map((e) => {
- if (!updatedSet.has(Number(e.id))) return e
- const next = { ...e }
- if (bulkVisibility) {
- next.visibility = bulkVisibility
- next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
- next.club_name = bulkVisibility === 'club' ? clubLabel : null
- }
- if (bulkStatus) next.status = bulkStatus
- if (bulkPatchFocusAreas) {
- if (nextPrimaryFocusName == null) delete next.focus_area
- else next.focus_area = nextPrimaryFocusName
- }
- return next
- })
- )
-
- let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
- if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
- if (Array.isArray(res.failed) && res.failed.length) {
- msg +=
- '\n\n' +
- res.failed
- .slice(0, 12)
- .map((f) => `#${f.id}: ${f.detail}`)
- .join('\n')
- if (res.failed.length > 12) msg += '\n…'
- }
- alert(msg)
- setBulkModalOpen(false)
- clearSelection()
- } catch (err) {
- alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
- } finally {
- setBulkSubmitting(false)
- }
- }
-
- if (!catalogsReady && pageTab === 'list') {
- return (
-
- )
- }
-
- return (
-
-
-
Übungen
- {pageTab === 'list' ? (
-
- + Neu
-
- ) : (
-
- )}
-
-
-
-
- {pageTab === 'progression' ? (
-
-
-
- Lade Progressionsgraphen…
-
-
- }
- >
-
-
- ) : (
- <>
- setMineOnly((v) => !v)}
- onOpenFilter={() => setFilterModalOpen(true)}
- filterChips={filterChips}
- onResetAllFilters={resetAllFilters}
- exerciseCount={exercises.length}
- allOnPageSelected={allOnPageSelected}
- onToggleSelectAllPage={toggleSelectAllPage}
- />
-
-
-
- setFilterModalOpen(false)}
- filters={filters}
- setFilters={setFilters}
- focusOptions={focusOptions}
- styleOptions={styleOptions}
- trainingTypeOptions={trainingTypeOptions}
- targetGroupOptions={targetGroupOptions}
- skillOptions={skillOptions}
- visibilityOptions={visibilityOptions}
- statusOptions={statusOptions}
- savingExercisePrefs={savingExercisePrefs}
- onSaveStandard={handleSaveExerciseFilterPrefs}
- onResetAll={resetAllFilters}
- />
-
- setBulkModalOpen(false)}
- onSubmit={handleBulkSubmit}
- bulkSubmitting={bulkSubmitting}
- selectedCount={selectedIds.size}
- bulkMaxIds={BULK_MAX_IDS}
- user={user}
- isPlatformAdmin={isPlatformAdmin}
- statusOptions={statusOptions}
- bulkVisibilityOptions={bulkVisibilityOptions}
- focusOptions={focusOptions}
- styleOptions={styleOptions}
- trainingTypeOptions={trainingTypeOptions}
- targetGroupOptions={targetGroupOptions}
- bulkVisibility={bulkVisibility}
- setBulkVisibility={setBulkVisibility}
- bulkStatus={bulkStatus}
- setBulkStatus={setBulkStatus}
- bulkClubSelect={bulkClubSelect}
- setBulkClubSelect={setBulkClubSelect}
- bulkClubManual={bulkClubManual}
- setBulkClubManual={setBulkClubManual}
- bulkPatchFocusAreas={bulkPatchFocusAreas}
- setBulkPatchFocusAreas={setBulkPatchFocusAreas}
- bulkFocusAreaIds={bulkFocusAreaIds}
- setBulkFocusAreaIds={setBulkFocusAreaIds}
- bulkPatchStyleDirections={bulkPatchStyleDirections}
- setBulkPatchStyleDirections={setBulkPatchStyleDirections}
- bulkStyleDirectionIds={bulkStyleDirectionIds}
- setBulkStyleDirectionIds={setBulkStyleDirectionIds}
- bulkPatchTrainingTypes={bulkPatchTrainingTypes}
- setBulkPatchTrainingTypes={setBulkPatchTrainingTypes}
- bulkTrainingTypeIds={bulkTrainingTypeIds}
- setBulkTrainingTypeIds={setBulkTrainingTypeIds}
- bulkPatchTargetGroups={bulkPatchTargetGroups}
- setBulkPatchTargetGroups={setBulkPatchTargetGroups}
- bulkTargetGroupIds={bulkTargetGroupIds}
- setBulkTargetGroupIds={setBulkTargetGroupIds}
- />
-
- {listFetching && exercises.length === 0 ? (
-
- ) : exercises.length === 0 ? (
-
-
Keine Übungen gefunden.
-
- ) : (
- <>
- {listFetching ? (
- Aktualisiere Treffer…
- ) : null}
-
- {exercises.length} angezeigt
- {hasMore ? ' · es gibt weitere Einträge' : ''}
-
-
- {exercises.map((exercise) => (
-
- ))}
-
- {hasMore && (
-
-
- {loadingMore ? 'Laden…' : 'Mehr laden'}
-
-
- )}
- >
- )}
- >
- )}
-
- )
-}
-
-export default ExercisesListPage
+/** Routen-Einstieg: Implementierung in `components/exercises/ExercisesListPageRoot.jsx` (Phase-3 Soft-Limit). */
+export { default } from '../components/exercises/ExercisesListPageRoot'
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 2bb9bb4..d5ce4de 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -1,2022 +1,2 @@
-import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
-import { Link, useSearchParams } from 'react-router-dom'
-import api from '../utils/api'
-import { useAuth } from '../context/AuthContext'
-import { useToast } from '../context/ToastContext'
-import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
-import ExercisePickerModal from '../components/ExercisePickerModal'
-import ExercisePeekModal from '../components/ExercisePeekModal'
-import PageSectionNav from '../components/PageSectionNav'
-import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal'
-import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
-import TrainingPlanningTrainerAssignModal from '../components/planning/TrainingPlanningTrainerAssignModal'
-import TrainingPlanningUnitFormModal from '../components/planning/TrainingPlanningUnitFormModal'
-import {
- defaultSection,
- normalizeUnitToForm,
- enrichSectionsWithVariants,
- buildSectionsPayload,
- hydrateExercisePlanningRow,
- insertTrainingModuleIntoPlanningSections,
-} from '../utils/trainingUnitSectionsForm'
-import {
- addDaysIsoDate,
- pad2,
- toIsoLocal,
- mondayIndex,
- getCalendarGridRange,
- shiftCalendarMonth,
- enumerateIsoDays,
- WEEKDAYS_DE,
- toNumList,
- sessionAssignDefaults,
- normalizeGroupCoTrainerIds,
- filterDirectoryExcludingLead,
- frameworkLineageText,
-} from '../utils/trainingPlanningPageHelpers'
-
-function TrainingPlanningPage() {
- const { user } = useAuth()
- const toast = useToast()
- const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
- const [searchParams, setSearchParams] = useSearchParams()
- const unitDeepLinkHandledRef = useRef(null)
- const [groups, setGroups] = useState([])
- const [selectedGroupId, setSelectedGroupId] = useState('')
- const [units, setUnits] = useState([])
- const [planTemplates, setPlanTemplates] = useState([])
- const [loading, setLoading] = useState(true)
- const [showModal, setShowModal] = useState(false)
- const [editingUnit, setEditingUnit] = useState(null)
- /** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
- const [sectionsEditMode, setSectionsEditMode] = useState('planning')
- const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
- const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
- const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
- const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
-
- const today = new Date().toISOString().split('T')[0]
- const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
-
- const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
- const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
- const [fwImportProgramId, setFwImportProgramId] = useState('')
- const [fwImportDetail, setFwImportDetail] = useState(null)
- const [fwImportLoading, setFwImportLoading] = useState(false)
- const [fwImportSelectedSlots, setFwImportSelectedSlots] = useState(() => new Set())
- const [fwImportSlotDates, setFwImportSlotDates] = useState({})
- const [fwImportStartDate, setFwImportStartDate] = useState(today)
- const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
- const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
-
- const [moduleApplyOpen, setModuleApplyOpen] = useState(false)
- const [moduleApplyBusy, setModuleApplyBusy] = useState(false)
- const [moduleApplyList, setModuleApplyList] = useState([])
- const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
- const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
- const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0')
- const [moduleApplyErr, setModuleApplyErr] = useState('')
- const [moduleApplyPlacementLocked, setModuleApplyPlacementLocked] = useState(false)
- const [moduleApplySearchQuery, setModuleApplySearchQuery] = useState('')
- const [modulePickPreview, setModulePickPreview] = useState({
- loading: false,
- moduleId: '',
- exercises: [],
- notes: 0,
- err: '',
- })
-
- const [startDate, setStartDate] = useState(today)
- const [endDate, setEndDate] = useState(thirtyDaysLater)
- const [planView, setPlanView] = useState('list')
- const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7))
- const [planScope, setPlanScope] = useState('group')
- const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
- const [clubDirectory, setClubDirectory] = useState([])
- const [assignModalOpen, setAssignModalOpen] = useState(false)
- const [assignDraft, setAssignDraft] = useState({
- unit: null,
- lead_trainer_profile_id: '',
- session_assistants_inherit: true,
- session_assistant_profile_ids: [],
- })
- const [assignSaving, setAssignSaving] = useState(false)
-
- const [formData, setFormData] = useState({
- group_id: '',
- planned_date: '',
- planned_time_start: '',
- planned_time_end: '',
- planned_focus: '',
- actual_date: '',
- actual_time_start: '',
- actual_time_end: '',
- attendance_count: '',
- status: 'planned',
- notes: '',
- trainer_notes: '',
- debrief_completed: false,
- sections: [defaultSection()],
- ...sessionAssignDefaults()
- })
- const planningFormRef = useRef(formData)
- planningFormRef.current = formData
-
- const moduleApplyFilteredList = useMemo(() => {
- const q = moduleApplySearchQuery.trim().toLowerCase().replace(/\s+/g, ' ')
- const words = q ? q.split(' ').filter(Boolean) : []
- const list = Array.isArray(moduleApplyList) ? moduleApplyList : []
- if (!words.length) return list
- return list.filter((m) => {
- const blob = [
- m.title,
- m.summary,
- m.goal,
- m.target_group_notes,
- m.deployment_context_notes,
- ]
- .map((x) => String(x ?? '').toLowerCase())
- .join('\n')
- return words.every((w) => blob.includes(w))
- })
- }, [moduleApplySearchQuery, moduleApplyList])
-
- const modulePlacementSummary = useMemo(() => {
- const secs = Array.isArray(formData.sections) ? formData.sections : []
- let si =
- typeof moduleApplySectionIx === 'number'
- ? moduleApplySectionIx
- : parseInt(String(moduleApplySectionIx), 10)
- if (!Number.isFinite(si)) si = 0
- si = Math.max(0, Math.min(si, secs.length ? secs.length - 1 : 0))
- const cap = secs[si]?.items?.length ?? 0
- let beforeIx = cap
- if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
- const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
- if (Number.isFinite(zi)) beforeIx = Math.min(Math.max(0, zi), cap)
- }
- const rawTitle = (secs[si]?.title || '').trim()
- const secTitle = rawTitle || `Abschnitt ${si + 1}`
- let positionDescription
- if (cap <= 0) positionDescription = 'als erste Einträge dieses Abschnitts'
- else if (beforeIx <= 0) positionDescription = 'vor dem ersten Eintrag dieses Abschnitts'
- else if (beforeIx >= cap) positionDescription = 'nach dem letzten Eintrag dieses Abschnitts'
- else positionDescription = `unmittelbar vor Eintrag ${beforeIx + 1} (${cap} Einträge im Abschnitt)`
- return { secTitle, positionDescription }
- }, [formData.sections, moduleApplySectionIx, moduleApplyInsertSlot])
-
- useEffect(() => {
- if (!moduleApplyOpen || !moduleApplyFilteredList.length) return
- if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return
- setModuleApplyModuleId(String(moduleApplyFilteredList[0].id))
- }, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId])
-
- const planningModalClubId = useMemo(() => {
- const gid = Number(formData.group_id)
- if (!Number.isFinite(gid) || gid < 1) return null
- const g = groups.find((x) => Number(x.id) === gid)
- if (!g || g.club_id == null || g.club_id === '') return null
- const c = Number(g.club_id)
- return Number.isFinite(c) ? c : null
- }, [groups, formData.group_id])
-
- const moduleApplyTargetItems = useMemo(() => {
- const secs = formData.sections || []
- if (!secs.length) return []
- let ix =
- typeof moduleApplySectionIx === 'number'
- ? moduleApplySectionIx
- : parseInt(String(moduleApplySectionIx), 10)
- if (!Number.isFinite(ix)) ix = 0
- if (ix < 0 || ix >= secs.length) return []
- const sec = secs[ix]
- return Array.isArray(sec?.items) ? sec.items : []
- }, [formData.sections, moduleApplySectionIx])
-
- const refreshPlanningSectionMeta = useCallback(async () => {
- const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
- setFormData((prev) => ({ ...prev, sections: next }))
- }, [])
-
- const loadPlanTemplates = useCallback(async () => {
- try {
- const tpl = await api.listTrainingPlanTemplates()
- setPlanTemplates(tpl)
- } catch (e) {
- console.error('Vorlagen laden:', e)
- }
- }, [])
-
- const loadData = useCallback(async () => {
- try {
- const groupsData = await api.listTrainingGroups({ status: 'active' })
- setGroups(groupsData)
- await loadPlanTemplates()
-
- if (groupsData.length > 0) {
- setSelectedGroupId((prev) => {
- const prevStr = prev != null && prev !== '' ? String(prev) : ''
- const stillThere = prevStr && groupsData.some((g) => String(g.id) === prevStr)
- if (stillThere) return prevStr
- const ownGroup = groupsData.find((g) => g.trainer_id === user?.id)
- if (ownGroup) return String(ownGroup.id)
- if (groupsData.length === 1) return String(groupsData[0].id)
- return ''
- })
- } else {
- setSelectedGroupId('')
- }
- } catch (err) {
- console.error('Failed to load data:', err)
- toast.error('Fehler beim Laden: ' + err.message)
- } finally {
- setLoading(false)
- }
- }, [user?.id, loadPlanTemplates])
-
- const loadUnits = useCallback(async () => {
- if (!selectedGroupId) return
- let start = startDate
- let end = endDate
- if (planView === 'calendar') {
- const r = getCalendarGridRange(calendarMonthStr)
- start = r.gridStart
- end = r.gridEnd
- }
- const gid = parseInt(selectedGroupId, 10)
- const groupRow = groups.find((g) => g.id === gid)
- const clubId = groupRow?.club_id
- try {
- const filters = {
- start_date: start,
- end_date: end
- }
- if (assignedToMeOnly) {
- filters.assigned_to_me = true
- }
- if (planScope === 'club' && clubId) {
- filters.club_id = clubId
- } else {
- filters.group_id = gid
- }
- const unitsData = await api.listTrainingUnits(filters)
- setUnits(unitsData)
- } catch (err) {
- console.error('Failed to load units:', err)
- }
- }, [
- selectedGroupId,
- groups,
- startDate,
- endDate,
- planView,
- calendarMonthStr,
- planScope,
- assignedToMeOnly
- ])
-
- useEffect(() => {
- loadData()
- }, [loadData, tenantClubDepKey])
-
- useEffect(() => {
- if (selectedGroupId) {
- loadUnits()
- }
- }, [selectedGroupId, loadUnits])
-
- const selectedGroupClubIdMemo = useMemo(() => {
- const g = groups.find((gr) => gr.id === parseInt(selectedGroupId, 10))
- return g?.club_id != null ? Number(g.club_id) : null
- }, [groups, selectedGroupId])
-
- const canClubOrgTraining = useMemo(() => {
- const r = (user?.role || '').toLowerCase()
- if (r === 'admin' || r === 'superadmin') return true
- if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
- const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === selectedGroupClubIdMemo)
- return Array.isArray(row?.roles) && row.roles.includes('club_admin')
- }, [user?.role, user?.clubs, selectedGroupClubIdMemo])
-
- const clubAdminClubIdSet = useMemo(() => {
- const ids = []
- for (const c of activeClubMemberships(user?.clubs)) {
- if (Array.isArray(c.roles) && c.roles.includes('club_admin')) {
- const id = Number(c.id)
- if (Number.isFinite(id)) ids.push(id)
- }
- }
- return new Set(ids)
- }, [user?.clubs])
-
- useEffect(() => {
- const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
- const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null
- const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null
-
- let assignModalClubId = null
- if (assignModalOpen && assignDraft.unit?.group_id != null) {
- const ug = Number(assignDraft.unit.group_id)
- const gAssign = Number.isFinite(ug) ? groups.find((x) => x.id === ug) : null
- if (gAssign?.club_id != null) assignModalClubId = Number(gAssign.club_id)
- }
-
- const loadClubId =
- showModal && clubForModal != null && Number.isFinite(clubForModal)
- ? clubForModal
- : assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId)
- ? assignModalClubId
- : canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo)
- ? selectedGroupClubIdMemo
- : null
-
- if (loadClubId == null || !Number.isFinite(loadClubId)) {
- setClubDirectory([])
- return undefined
- }
- let cancelled = false
- ;(async () => {
- try {
- const d = await api.clubMembersDirectory(loadClubId)
- if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
- } catch (err) {
- if (!cancelled) {
- console.error('Mitgliederverzeichnis:', err)
- setClubDirectory([])
- }
- }
- })()
- return () => {
- cancelled = true
- }
- }, [
- showModal,
- assignModalOpen,
- assignDraft.unit,
- formData.group_id,
- selectedGroupId,
- groups,
- canClubOrgTraining,
- selectedGroupClubIdMemo,
- ])
-
- useEffect(() => {
- if (!frameworkImportOpen) return
- let cancelled = false
- ;(async () => {
- try {
- const list = await api.listTrainingFrameworkPrograms()
- if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
- } catch (e) {
- if (!cancelled) {
- console.error('Rahmenprogramme laden:', e)
- setFrameworkProgramsList([])
- }
- }
- })()
- return () => {
- cancelled = true
- }
- }, [frameworkImportOpen])
-
- const openFrameworkImportModal = useCallback(() => {
- setFwImportProgramId('')
- setFwImportDetail(null)
- setFwImportSelectedSlots(new Set())
- setFwImportSlotDates({})
- setFwImportStartDate(new Date().toISOString().split('T')[0])
- setFwImportIntervalDays(7)
- setFrameworkImportOpen(true)
- }, [])
-
- const onFwImportProgramChange = async (idStr) => {
- setFwImportProgramId(idStr)
- if (!idStr) {
- setFwImportDetail(null)
- return
- }
- setFwImportLoading(true)
- try {
- const d = await api.getTrainingFrameworkProgram(parseInt(idStr, 10))
- setFwImportDetail(d)
- setFwImportSelectedSlots(new Set())
- setFwImportSlotDates({})
- } catch (e) {
- toast.error(e.message || 'Rahmenprogramm laden fehlgeschlagen')
- setFwImportDetail(null)
- } finally {
- setFwImportLoading(false)
- }
- }
-
- const toggleFwImportSlot = (slot) => {
- if (!slot?.blueprint_training_unit_id) return
- const sid = slot.id
- setFwImportSelectedSlots((prev) => {
- const n = new Set(prev)
- if (n.has(sid)) n.delete(sid)
- else n.add(sid)
- return n
- })
- }
-
- const applyFwImportDateSuggestions = () => {
- if (!fwImportDetail?.slots?.length) return
- const sorted = [...fwImportDetail.slots].sort(
- (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
- )
- let offset = 0
- const iv = Math.max(0, Number(fwImportIntervalDays) || 0)
- const next = {}
- for (const s of sorted) {
- if (!fwImportSelectedSlots.has(s.id)) continue
- if (!s.blueprint_training_unit_id) continue
- next[String(s.id)] = addDaysIsoDate(fwImportStartDate, offset)
- offset += iv
- }
- setFwImportSlotDates((prev) => ({ ...prev, ...next }))
- }
-
- const submitFrameworkImport = async () => {
- if (!selectedGroupId) {
- toast.error('Bitte zuerst eine Trainingsgruppe wählen.')
- return
- }
- const gid = parseInt(selectedGroupId, 10)
- if (!fwImportDetail?.slots?.length) return
- const sorted = [...fwImportDetail.slots].sort(
- (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
- )
- const picks = sorted.filter(
- (s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id
- )
- if (!picks.length) {
- toast.error('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
- return
- }
- for (const s of picks) {
- const key = String(s.id)
- const date = fwImportSlotDates[key] || fwImportStartDate
- if (!date) {
- toast.error('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
- return
- }
- }
- setFwImportSubmitting(true)
- try {
- for (const s of picks) {
- const key = String(s.id)
- const date = fwImportSlotDates[key] || fwImportStartDate
- await api.createTrainingUnitFromFrameworkSlot({
- group_id: gid,
- planned_date: date,
- framework_slot_id: s.id,
- })
- }
- setFrameworkImportOpen(false)
- await loadUnits()
- } catch (e) {
- toast.error(e.message || 'Übernahme fehlgeschlagen')
- } finally {
- setFwImportSubmitting(false)
- }
- }
-
- const handleCreate = () => {
- if (!selectedGroupId) {
- toast.error('Bitte wähle zuerst eine Trainingsgruppe')
- return
- }
- const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
- setEditingUnit(null)
- setDraftPlanTemplateId('')
- setFormData({
- group_id: selectedGroupId,
- planned_date: today,
- planned_time_start: group?.time_start?.slice(0, 5) || '',
- planned_time_end: group?.time_end?.slice(0, 5) || '',
- planned_focus: '',
- actual_date: '',
- actual_time_start: '',
- actual_time_end: '',
- attendance_count: '',
- status: 'planned',
- notes: '',
- trainer_notes: '',
- debrief_completed: false,
- sections: [defaultSection('Hauptteil')],
- ...sessionAssignDefaults()
- })
- setSectionsEditMode('planning')
- setShowModal(true)
- }
-
- const handleCreateForDate = (isoDay) => {
- if (!selectedGroupId) {
- toast.error('Bitte wähle zuerst eine Trainingsgruppe')
- return
- }
- const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
- setEditingUnit(null)
- setDraftPlanTemplateId('')
- setFormData({
- group_id: selectedGroupId,
- planned_date: isoDay,
- planned_time_start: group?.time_start?.slice(0, 5) || '',
- planned_time_end: group?.time_end?.slice(0, 5) || '',
- planned_focus: '',
- actual_date: '',
- actual_time_start: '',
- actual_time_end: '',
- attendance_count: '',
- status: 'planned',
- notes: '',
- trainer_notes: '',
- debrief_completed: false,
- sections: [defaultSection('Hauptteil')],
- ...sessionAssignDefaults()
- })
- setSectionsEditMode('planning')
- setShowModal(true)
- }
-
- const applyTemplateFromSelect = async (templateId) => {
- setDraftPlanTemplateId(templateId)
- if (!templateId) return
- try {
- const tpl = await api.getTrainingPlanTemplate(parseInt(templateId, 10))
- setFormData((fd) => ({
- ...fd,
- sections: (tpl.sections || []).length
- ? tpl.sections.map((s) => ({
- title: s.title,
- guidance_notes: s.guidance_text || '',
- items: []
- }))
- : [defaultSection()]
- }))
- } catch (err) {
- toast.error('Vorlage laden: ' + err.message)
- }
- }
-
- const handleEdit = useCallback(async (unit) => {
- try {
- const fullUnit = await api.getTrainingUnit(unit.id)
- setEditingUnit(fullUnit)
- setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '')
- let sections = normalizeUnitToForm(fullUnit)
- sections = await enrichSectionsWithVariants(sections)
- setFormData({
- group_id: fullUnit.group_id,
- planned_date: fullUnit.planned_date || '',
- planned_time_start: fullUnit.planned_time_start?.slice(0, 5) || '',
- planned_time_end: fullUnit.planned_time_end?.slice(0, 5) || '',
- planned_focus: fullUnit.planned_focus || '',
- actual_date: fullUnit.actual_date || '',
- actual_time_start: fullUnit.actual_time_start?.slice(0, 5) || '',
- actual_time_end: fullUnit.actual_time_end?.slice(0, 5) || '',
- attendance_count: fullUnit.attendance_count ?? '',
- status: fullUnit.status || 'planned',
- notes: fullUnit.notes || '',
- trainer_notes: fullUnit.trainer_notes || '',
- debrief_completed: Boolean(fullUnit.debrief_completed_at),
- sections,
- lead_trainer_profile_id:
- fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
- ? String(fullUnit.lead_trainer_profile_id)
- : '',
- session_assistants_inherit:
- fullUnit.assistant_trainer_profile_ids == null ||
- fullUnit.assistant_trainer_profile_ids === undefined,
- session_assistant_profile_ids: (() => {
- const efLead =
- fullUnit.effective_lead_trainer_profile_id != null
- ? Number(fullUnit.effective_lead_trainer_profile_id)
- : null
- let xs = toNumList(fullUnit.assistant_trainer_profile_ids)
- if (efLead != null && Number.isFinite(efLead)) xs = xs.filter((id) => id !== efLead)
- return xs
- })(),
- })
- setSectionsEditMode(fullUnit.status === 'completed' ? 'debrief' : 'planning')
- setShowModal(true)
- } catch (err) {
- toast.error('Fehler beim Laden: ' + err.message)
- throw err
- }
- }, [])
-
- useEffect(() => {
- if (!user?.id || loading) return
- const uid = searchParams.get('unit')
- if (!uid) {
- unitDeepLinkHandledRef.current = null
- return
- }
- if (unitDeepLinkHandledRef.current === uid) return
- const idNum = parseInt(uid, 10)
- if (!Number.isFinite(idNum)) return
- unitDeepLinkHandledRef.current = uid
- handleEdit({ id: idNum })
- .then(() => {
- setSearchParams(
- (prev) => {
- const next = new URLSearchParams(prev)
- next.delete('unit')
- next.delete('debrief')
- return next
- },
- { replace: true }
- )
- })
- .catch(() => {
- unitDeepLinkHandledRef.current = null
- setSearchParams(
- (prev) => {
- const next = new URLSearchParams(prev)
- next.delete('unit')
- next.delete('debrief')
- return next
- },
- { replace: true }
- )
- })
- }, [user?.id, loading, searchParams, handleEdit, setSearchParams])
-
- const handleSaveAsTemplate = async () => {
- const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
- if (!name?.trim()) return
- try {
- await api.createTrainingPlanTemplate({
- name: name.trim(),
- sections: formData.sections.map((s) => ({
- title: s.title || 'Abschnitt',
- guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null
- }))
- })
- await loadPlanTemplates()
- toast.success('Vorlage gespeichert.')
- } catch (err) {
- toast.error('Speichern: ' + err.message)
- }
- }
-
- const openModuleApplyModal = useCallback(async (placement) => {
- setModuleApplyErr('')
- setModuleApplySearchQuery('')
- const placementLocked =
- placement != null &&
- typeof placement.sectionIndex === 'number' &&
- typeof placement.insertBeforeIndex === 'number'
- setModuleApplyPlacementLocked(placementLocked)
- const secs = planningFormRef.current?.sections ?? []
- let secIx = 0
- let before = 0
- if (secs.length) {
- if (placement && typeof placement.sectionIndex === 'number') {
- secIx = Math.min(Math.max(0, placement.sectionIndex), secs.length - 1)
- const items = Array.isArray(secs[secIx]?.items) ? secs[secIx].items : []
- const cap = items.length
- if (typeof placement.insertBeforeIndex === 'number' && Number.isFinite(placement.insertBeforeIndex)) {
- before = Math.min(Math.max(0, placement.insertBeforeIndex), cap)
- } else before = cap
- } else {
- const items = Array.isArray(secs[0]?.items) ? secs[0].items : []
- before = items.length
- secIx = 0
- }
- }
- setModuleApplySectionIx(secIx)
- setModuleApplyInsertSlot(`before:${before}`)
- setModuleApplyOpen(true)
- try {
- const list = await api.listTrainingModules()
- const arr = Array.isArray(list) ? list : []
- setModuleApplyList(arr)
- setModuleApplyModuleId(arr.length ? String(arr[0].id) : '')
- } catch (e) {
- setModuleApplyErr(e.message || 'Module konnten nicht geladen werden')
- setModuleApplyList([])
- }
- }, [])
-
- const onModuleApplySectionIndexChange = useCallback((newIx) => {
- setModuleApplySectionIx(newIx)
- const secsNow = planningFormRef.current?.sections ?? []
- const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
- setModuleApplyInsertSlot(`before:${len}`)
- }, [])
-
- const handleApplyTrainingModuleConfirm = useCallback(async () => {
- const mid = parseInt(moduleApplyModuleId, 10)
- if (!Number.isFinite(mid)) {
- toast.error('Bitte ein Trainingsmodul wählen.')
- return
- }
- let secIx = parseInt(String(moduleApplySectionIx), 10)
- if (!Number.isFinite(secIx)) secIx = 0
-
- const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
- if (!baseSections.length) {
- toast.error('Keine Abschnitte im Formular.')
- return
- }
- if (secIx < 0 || secIx >= baseSections.length) secIx = 0
-
- const secItems = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items : []
- const itemCap = secItems.length
- let insertBefore = itemCap
- if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
- const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
- if (Number.isFinite(zi)) insertBefore = Math.min(Math.max(0, zi), itemCap)
- }
-
- setModuleApplyBusy(true)
- setModuleApplyErr('')
- try {
- const detail = await api.getTrainingModule(mid)
- let nextSections = await insertTrainingModuleIntoPlanningSections({
- sections: baseSections,
- moduleDetail: detail,
- sectionIndex: secIx,
- insertBeforeItemIndex: insertBefore,
- })
- nextSections = await enrichSectionsWithVariants(nextSections)
- setFormData((fd) => ({ ...fd, sections: nextSections }))
- setModuleApplyOpen(false)
- setModuleApplyPlacementLocked(false)
- } catch (e) {
- setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
- } finally {
- setModuleApplyBusy(false)
- }
- }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot])
-
- useEffect(() => {
- if (!moduleApplyOpen) {
- setModulePickPreview({
- loading: false,
- moduleId: '',
- exercises: [],
- notes: 0,
- err: '',
- })
- return undefined
- }
- const mid = parseInt(String(moduleApplyModuleId), 10)
- if (!Number.isFinite(mid) || mid < 1) {
- setModulePickPreview({
- loading: false,
- moduleId: '',
- exercises: [],
- notes: 0,
- err: '',
- })
- return undefined
- }
- let cancelled = false
- setModulePickPreview({
- loading: true,
- moduleId: String(mid),
- exercises: [],
- notes: 0,
- err: '',
- })
- ;(async () => {
- try {
- const detail = await api.getTrainingModule(mid)
- if (cancelled) return
- const itemsSorted = [...(detail.items ?? [])].sort(
- (a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
- )
- const uniqueEx = new Set()
- let notes = 0
- for (const row of itemsSorted) {
- if ((row.item_type || '') !== 'note') {
- const eid = row.exercise_id
- if (eid) uniqueEx.add(Number(eid))
- continue
- }
- const b = String(row.note_body ?? '').trim()
- if (b === '---') continue
- notes += 1
- }
- const titleById = new Map()
- await Promise.all(
- [...uniqueEx].map(async (eid) => {
- try {
- const ex = await api.getExercise(eid)
- titleById.set(eid, (ex?.title || '').trim() || `Übung #${eid}`)
- } catch {
- titleById.set(eid, `Übung #${eid}`)
- }
- })
- )
- if (cancelled) return
- const exTitlesInOrder = []
- for (const row of itemsSorted) {
- if ((row.item_type || '') !== 'exercise') continue
- const eid = Number(row.exercise_id)
- if (!Number.isFinite(eid)) continue
- exTitlesInOrder.push(titleById.get(eid) || `Übung #${eid}`)
- }
- setModulePickPreview({
- loading: false,
- moduleId: String(mid),
- exercises: exTitlesInOrder,
- notes,
- err: '',
- })
- } catch (e) {
- if (!cancelled) {
- setModulePickPreview({
- loading: false,
- moduleId: String(mid),
- exercises: [],
- notes: 0,
- err: e?.message || 'Vorschau fehlgeschlagen',
- })
- }
- }
- })()
- return () => {
- cancelled = true
- }
- }, [moduleApplyOpen, moduleApplyModuleId])
-
- const handleTakeLead = async (unit) => {
- if (!user?.id) return
- try {
- await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id })
- await loadUnits()
- } catch (err) {
- toast.error(err.message || 'Leitung konnte nicht übernommen werden')
- }
- }
-
- const openTrainerAssignModal = (unit) => {
- const effLead =
- unit.effective_lead_trainer_profile_id != null
- ? Number(unit.effective_lead_trainer_profile_id)
- : null
- let coIds = toNumList(unit.assistant_trainer_profile_ids)
- if (effLead != null && Number.isFinite(effLead)) {
- coIds = coIds.filter((id) => id !== effLead)
- }
- setAssignDraft({
- unit,
- lead_trainer_profile_id:
- unit.lead_trainer_profile_id != null && unit.lead_trainer_profile_id !== ''
- ? String(unit.lead_trainer_profile_id)
- : '',
- session_assistants_inherit:
- unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined,
- session_assistant_profile_ids: coIds,
- })
- setAssignModalOpen(true)
- }
-
- const saveTrainerAssignModal = async () => {
- if (!assignDraft.unit) return
- setAssignSaving(true)
- try {
- const payload = {}
- const leadStr = String(assignDraft.lead_trainer_profile_id || '').trim()
- if (leadStr) payload.lead_trainer_profile_id = parseInt(leadStr, 10)
- else payload.lead_trainer_profile_id = null
- if (assignDraft.session_assistants_inherit) {
- payload.assistant_trainer_profile_ids = null
- } else {
- payload.assistant_trainer_profile_ids = [...assignDraft.session_assistant_profile_ids].sort((a, b) => a - b)
- }
- await api.updateTrainingUnit(assignDraft.unit.id, payload)
- setAssignModalOpen(false)
- setAssignDraft({
- unit: null,
- ...sessionAssignDefaults(),
- })
- await loadUnits()
- } catch (err) {
- toast.error(err.message || 'Zuweisung konnte nicht gespeichert werden')
- } finally {
- setAssignSaving(false)
- }
- }
-
- const handleAssignLeadSelectChange = useCallback((v) => {
- setAssignDraft((prev) => {
- const exclude = []
- const tr = String(v || '').trim()
- if (tr !== '') {
- const n = parseInt(tr, 10)
- if (Number.isFinite(n)) exclude.push(n)
- } else if (prev.unit?.effective_lead_trainer_profile_id != null) {
- const ef = Number(prev.unit.effective_lead_trainer_profile_id)
- if (Number.isFinite(ef)) exclude.push(ef)
- }
- const exSet = new Set(exclude)
- const co = exclude.length
- ? prev.session_assistant_profile_ids.filter((x) => !exSet.has(x))
- : prev.session_assistant_profile_ids
- return { ...prev, lead_trainer_profile_id: v, session_assistant_profile_ids: co }
- })
- }, [])
-
- const handleAssignAssistantsInheritChange = useCallback((checked) => {
- setAssignDraft((prev) => ({
- ...prev,
- session_assistants_inherit: checked,
- }))
- }, [])
-
- const handleAssignCoTrainerToggle = useCallback((mid) => {
- setAssignDraft((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 }
- })
- }, [])
-
- const handleDelete = async (unit) => {
- if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
- try {
- await api.deleteTrainingUnit(unit.id)
- await loadUnits()
- } catch (err) {
- toast.error('Fehler beim Löschen: ' + err.message)
- }
- }
-
- const handleSubmit = async (e) => {
- e.preventDefault()
- if (!formData.group_id || !formData.planned_date) {
- toast.error('Gruppe und Datum sind Pflichtfelder')
- return
- }
- try {
- const sectionsPayload = buildSectionsPayload(formData.sections)
- const payload = {
- planned_date: formData.planned_date,
- planned_time_start: formData.planned_time_start || null,
- planned_time_end: formData.planned_time_end || null,
- planned_focus: formData.planned_focus || null,
- actual_date: formData.actual_date || null,
- actual_time_start: formData.actual_time_start || null,
- actual_time_end: formData.actual_time_end || null,
- attendance_count: formData.attendance_count ? parseInt(formData.attendance_count, 10) : null,
- status: formData.status || 'planned',
- notes: formData.notes || null,
- trainer_notes: formData.trainer_notes || null,
- sections: sectionsPayload
- }
- if (editingUnit) {
- payload.debrief_completed =
- (formData.status || '') === 'completed' ? !!formData.debrief_completed : false
- }
- const leadStr = String(formData.lead_trainer_profile_id || '').trim()
- if (leadStr) {
- payload.lead_trainer_profile_id = parseInt(leadStr, 10)
- } else if (editingUnit) {
- payload.lead_trainer_profile_id = null
- }
- if (formData.session_assistants_inherit) {
- if (editingUnit) payload.assistant_trainer_profile_ids = null
- } else {
- payload.assistant_trainer_profile_ids = [...formData.session_assistant_profile_ids].sort(
- (a, b) => a - b
- )
- }
- if (!editingUnit) {
- payload.group_id = parseInt(formData.group_id, 10)
- if (draftPlanTemplateId) {
- payload.plan_template_id = parseInt(draftPlanTemplateId, 10)
- }
- }
-
- if (editingUnit) {
- await api.updateTrainingUnit(editingUnit.id, payload)
- } else {
- await api.createTrainingUnit(payload)
- }
- setShowModal(false)
- await loadUnits()
- } catch (err) {
- toast.error('Fehler beim Speichern: ' + err.message)
- }
- }
-
- const updateFormField = (field, value) => {
- setFormData((prev) => {
- if (field !== 'lead_trainer_profile_id') {
- const patch = { ...prev, [field]: value }
- if (field === 'status' && value !== 'completed') {
- patch.debrief_completed = false
- }
- return patch
- }
- const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim()
- const strip = new Set()
- if (ts !== '') {
- const nid = parseInt(ts, 10)
- if (Number.isFinite(nid)) strip.add(nid)
- } else {
- const gidParsed = parseInt(prev.group_id || selectedGroupId || '0', 10)
- const gr =
- Number.isFinite(gidParsed) && gidParsed >= 1
- ? groups.find((xg) => xg.id === gidParsed)
- : null
- if (gr?.trainer_id != null) {
- const ht = Number(gr.trainer_id)
- if (Number.isFinite(ht)) strip.add(ht)
- }
- }
- const assistants = prev.session_assistant_profile_ids.filter((id) => !strip.has(id))
- return { ...prev, lead_trainer_profile_id: value, session_assistant_profile_ids: assistants }
- })
- }
-
- const calendarGridDays = useMemo(() => {
- const r = getCalendarGridRange(calendarMonthStr)
- return enumerateIsoDays(r.gridStart, r.gridEnd)
- }, [calendarMonthStr])
-
- const unitsByPlannedDate = useMemo(() => {
- const m = new Map()
- for (const u of units) {
- const raw = u.planned_date
- if (!raw) continue
- const key = String(raw).slice(0, 10)
- if (!m.has(key)) m.set(key, [])
- m.get(key).push(u)
- }
- return m
- }, [units])
-
- const calendarMonthTitle = useMemo(() => {
- const p = calendarMonthStr.split('-').map(Number)
- const y = p[0]
- const mo = p[1]
- if (!y || !mo) return ''
- return new Date(y, mo - 1, 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
- }, [calendarMonthStr])
-
- const mayConfigureSessionAssignments = useCallback(
- (unit) => {
- if (!unit) return false
- const pid = Number(user?.id)
- if (!Number.isFinite(pid)) return false
- const r = (user?.role || '').toLowerCase()
- if (r === 'admin' || r === 'superadmin') return true
-
- const gClub = unit.group_club_id != null ? Number(unit.group_club_id) : null
- if (Number.isFinite(gClub) && clubAdminClubIdSet.has(gClub)) return true
-
- const gid = Number(unit.group_id)
- const g = groups.find((gr) => gr.id === gid)
- if (!g) return false
-
- const cb = unit.created_by != null ? Number(unit.created_by) : NaN
- if (Number.isFinite(cb) && cb === pid) return true
-
- const ht = g.trainer_id != null ? Number(g.trainer_id) : NaN
- if (Number.isFinite(ht) && ht === pid) return true
-
- return normalizeGroupCoTrainerIds(g.co_trainer_ids).includes(pid)
- },
- [user?.id, user?.role, groups, clubAdminClubIdSet]
- )
-
- if (loading) {
- return (
-
- )
- }
-
- const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
-
- const gidTrainerForm = parseInt(formData.group_id || selectedGroupId || '0', 10)
- const groupForTrainerForm =
- Number.isFinite(gidTrainerForm) && gidTrainerForm >= 1
- ? groups.find((gr) => gr.id === gidTrainerForm)
- : null
-
- let formTrainerAssignLeadExcludeId = null
- if (groupForTrainerForm?.trainer_id != null) formTrainerAssignLeadExcludeId = Number(groupForTrainerForm.trainer_id)
- const leadDraftTrim = String(formData.lead_trainer_profile_id || '').trim()
- if (leadDraftTrim !== '') {
- const nl = parseInt(leadDraftTrim, 10)
- if (Number.isFinite(nl)) formTrainerAssignLeadExcludeId = nl
- }
- if (editingUnit?.effective_lead_trainer_profile_id != null && leadDraftTrim === '') {
- const el = Number(editingUnit.effective_lead_trainer_profile_id)
- if (Number.isFinite(el)) formTrainerAssignLeadExcludeId = el
- }
-
- const clubDirectoryForCo = filterDirectoryExcludingLead(clubDirectory, formTrainerAssignLeadExcludeId)
-
- let assignExcludeLeadPid = null
- if (assignModalOpen && assignDraft.unit) {
- const dl = String(assignDraft.lead_trainer_profile_id || '').trim()
- if (dl !== '') {
- const n = parseInt(dl, 10)
- assignExcludeLeadPid = Number.isFinite(n) ? n : null
- } else if (assignDraft.unit.effective_lead_trainer_profile_id != null) {
- const n = Number(assignDraft.unit.effective_lead_trainer_profile_id)
- assignExcludeLeadPid = Number.isFinite(n) ? n : null
- }
- }
- const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid)
-
- return (
-
-
Trainingsplanung
-
-
-
- Ansicht
-
-
{
- if (id === 'calendar') {
- setPlanView('calendar')
- setCalendarMonthStr((prev) => {
- const fromList = (startDate || '').slice(0, 7)
- if (/^\d{4}-\d{2}$/.test(fromList)) return fromList
- return prev || new Date().toISOString().slice(0, 7)
- })
- } else {
- setPlanView('list')
- }
- }}
- items={[
- { id: 'list', label: 'Liste' },
- { id: 'calendar', label: 'Kalender' },
- ]}
- className="page-section-nav--inline planning-ansicht-nav"
- />
-
- {planView === 'list'
- ? 'Zeitraum unten mit Von/Bis filtern.'
- : 'Monat unten wechseln; Termine erscheinen im Raster.'}
-
-
-
-
- Wähle eine Trainingsgruppe und lege Trainingseinheiten für den Zeitraum an (Inhalt: Abschnitte
- und Übungen).
-
-
-
-
- Mehrere Einheiten strukturieren auf einmal:{' '}
-
- Trainingsrahmenprogramme
- {' '}
- (Ziele, Sessions, Vorlagen‑Ablauf).
-
-
- Wiederverwendbare Blöcke innerhalb einer Einheit:{' '}
-
- Trainingsmodule
- {' '}
- (übernahme als Kopie beim Bearbeiten einer Einheit).
-
-
- {!loading && groups.length === 0 && (
-
-
Erst Verein & Gruppe anlegen
-
- Ohne Trainingsgruppe kann hier nichts gebucht werden. Unter Vereine legst du einen Verein an
- (kurzer Name genügt), optional eine Sparte, dann eine Trainingsgruppe . Wochentage, feste Zeiten oder
- Eigenschaften sind optional und kannst du später ergänzen.
-
-
- Zu Vereinen & Trainingsgruppen
-
-
- )}
-
-
-
-
- Trainingsgruppe
- setSelectedGroupId(e.target.value)}
- >
- Bitte wählen
- {groups.map((g) => (
-
- {g.name} ({g.club_name})
-
- ))}
-
-
-
- {planView === 'list' ? (
- <>
-
- Von
- setStartDate(e.target.value)}
- />
-
-
-
- Bis
- setEndDate(e.target.value)}
- />
-
- >
- ) : (
-
- setCalendarMonthStr((prev) => shiftCalendarMonth(prev, -1))}
- >
- ←
-
-
- {calendarMonthTitle}
-
- setCalendarMonthStr((prev) => shiftCalendarMonth(prev, 1))}
- >
- →
-
- setCalendarMonthStr(new Date().toISOString().slice(0, 7))}
- >
- Aktueller Monat
-
-
- )}
-
-
-
- Einblenden
-
-
-
- setAssignedToMeOnly(e.target.checked)}
- />
- Nur meine Zuordnung (Leitung / Co)
-
-
- Neue Termine gelten immer für die gewählte Gruppe. „Ganzer Verein“ zeigt zusätzlich Termine
- anderer Gruppen desselben Vereins.
-
-
- Mehr zu Ansicht & Trainerzuordnung
-
-
- „Ganzer Verein“ bezieht sich auf denselben Verein wie die gewählte Gruppe. Neu angelegte Termine
- beziehen sich weiterhin auf die Gruppe, die du oben gewählt hast.
-
- {selectedGroupId ? (
-
- Über Trainer oder Trainer zuweisen bearbeitest du Leitung und
- Co je Einheit (berechtigt: Vereinsorganisation, Haupt-/Co‑Trainer der Gruppe sowie Erstellung
- der Einheit). Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder; die Leitung erscheint
- nicht unter Co‑Trainer.
-
- ) : (
-
- Wähle zuerst eine Gruppe — dann erweitert sich die Hilfe zu Trainer und Berechtigungen.
-
- )}
-
-
-
-
-
- {selectedGroup && (
-
-
- {selectedGroup.location || 'Kein Ort angegeben'}
- {selectedGroup.weekday && ` · ${selectedGroup.weekday}`}
- {selectedGroup.time_start &&
- ` · ${selectedGroup.time_start.slice(0, 5)} - ${selectedGroup.time_end?.slice(0, 5)}`}
-
-
- )}
-
-
-
-
Neue Trainingseinheit
-
- Datum, Zeiten und Ablauf (Abschnitte & Übungen) — optional{' '}
- Trainingsvorlage oder Inhalte aus einem Rahmenprogramm im Dialog.
-
- {!selectedGroupId && (
-
- Wähle oben eine Trainingsgruppe, um fortzufahren.
-
- )}
- {groups.length === 0 && (
-
- Es gibt noch keine aktive Trainingsgruppe — unter{' '}
- Vereine anlegen oder aktivieren.
-
- )}
-
-
-
- Trainingseinheit planen…
-
-
- Aus Rahmen übernehmen…
-
-
-
-
-
- {!selectedGroupId ? (
-
-
- Wähle oben eine Trainingsgruppe — danach kannst du unter{' '}
- „Trainingseinheit planen…“ einen Termin anlegen.
-
-
- ) : planView === 'calendar' ? (
-
- {units.length === 0 ? (
-
- Im sichtbaren Monatsbereich liegt noch keine Einheit. Über + in einem Tag legst du einen
- neuen Termin mit Datum an.
-
- ) : null}
-
- {WEEKDAYS_DE.map((w) => (
-
- {w}
-
- ))}
- {calendarGridDays.map((dayIso) => {
- const inMonth = dayIso.slice(0, 7) === calendarMonthStr
- const dayNum = parseInt(dayIso.slice(8, 10), 10)
- const isTodayMarker = dayIso === today
- const dayUnits = unitsByPlannedDate.get(dayIso) || []
- return (
-
-
- {dayNum}
- handleCreateForDate(dayIso)}
- disabled={!selectedGroupId}
- style={{ padding: '2px 6px', flexShrink: 0 }}
- >
- +
-
-
-
- {dayUnits.slice(0, 3).map((unit) => (
-
- handleEdit(unit)}
- title={[
- planScope === 'club' && unit.group_name ? unit.group_name : '',
- unit.planned_time_start?.slice(0, 5) || '',
- unit.lead_trainer_name?.trim(),
- unit.planned_focus?.trim(),
- unit.status === 'completed'
- ? 'Durchgeführt'
- : unit.status === 'cancelled'
- ? 'Abgesagt'
- : 'Geplant',
- ]
- .filter(Boolean)
- .join(' · ')}
- style={{
- border: 'none',
- cursor: 'pointer',
- textAlign: 'left',
- padding: '4px 5px',
- borderRadius: '4px',
- fontSize: '0.7rem',
- lineHeight: 1.25,
- width: '100%',
- borderLeftWidth: '3px',
- borderLeftStyle: 'solid',
- borderLeftColor:
- unit.status === 'completed'
- ? '#2ea44f'
- : unit.status === 'cancelled'
- ? 'var(--danger)'
- : 'var(--accent-dark)',
- background: 'var(--surface2)',
- color: 'var(--text1)',
- }}
- >
-
- {unit.planned_time_start
- ? `${unit.planned_time_start.slice(0, 5)}`
- : 'Ganztags'}
-
- {planScope === 'club' && (unit.group_name || '').trim() ? (
-
- {(unit.group_name || '').trim().length > 22
- ? `${(unit.group_name || '').trim().slice(0, 22)}…`
- : unit.group_name}
-
- ) : null}
- {unit.lead_trainer_name?.trim() ? (
-
- {unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] ||
- unit.lead_trainer_name.trim()}
-
- ) : null}
- {unit.planned_focus?.trim() ? (
-
- {(unit.planned_focus || '').trim().length > 24
- ? `${(unit.planned_focus || '').trim().slice(0, 24)}…`
- : unit.planned_focus}
-
- ) : null}
-
- {mayConfigureSessionAssignments(unit) ? (
- {
- ev.stopPropagation()
- openTrainerAssignModal(unit)
- }}
- >
- Trainer
-
- ) : null}
-
- ))}
-
- {dayUnits.length > 3 ? (
-
- +{dayUnits.length - 3} weitere
-
- ) : null}
-
- )
- })}
-
-
- ) : units.length === 0 ? (
-
-
- Keine Trainingseinheiten in diesem Zeitraum. Unten unter „Neue Trainingseinheit“ einen
- Termin anlegen — optional mit Vorlage im Dialog.
-
-
- ) : (
-
- {units.map((unit) => {
- const lineage = unit.origin_framework_slot_id ? frameworkLineageText(unit) : null
- const uid = user?.id != null ? Number(user.id) : null
- const effLead =
- unit.effective_lead_trainer_profile_id != null
- ? Number(unit.effective_lead_trainer_profile_id)
- : null
- const showTakeLead =
- unit.status === 'planned' && uid != null && effLead != null && effLead !== uid
- return (
-
-
-
-
- {unit.planned_date}
- {unit.planned_time_start &&
- ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}
-
- {planScope === 'club' && (unit.group_name || '').trim() ? (
-
- {unit.group_name}
-
- ) : null}
-
- Leitung: {(unit.lead_trainer_name || '').trim() || '—'}
-
- {(() => {
- const coRaw = unit.effective_assistant_trainer_profile_ids
- const co = Array.isArray(coRaw)
- ? coRaw.map(Number).filter((x) => Number.isFinite(x) && x >= 1)
- : []
- if (!co.length) return null
- const src =
- unit.assistant_trainer_profile_ids != null
- ? 'Session-Zuweisung'
- : 'über Trainingsgruppe'
- return (
-
- Co-Trainer ({src}): {co.length}
-
- )
- })()}
- {unit.planned_focus && (
-
- Fokus: {unit.planned_focus}
-
- )}
- {lineage ? (
-
- Aus Rahmen:
- {unit.origin_framework_program_id ? (
-
- {lineage.fpTitle}
-
- ) : (
- {lineage.fpTitle}
- )}
- · {lineage.slotBit}
-
- ) : null}
-
-
- {unit.status === 'planned' && 'Geplant'}
- {unit.status === 'completed' && 'Durchgeführt'}
- {unit.status === 'cancelled' && 'Abgesagt'}
-
- {unit.attendance_count !== null && unit.attendance_count !== undefined && (
-
- {unit.attendance_count} Teilnehmer
-
- )}
-
-
-
-
-
- Plan & Ablauf
-
-
- Im Training (Coach)
-
- handleEdit(unit)}>
- Bearbeiten
-
- {mayConfigureSessionAssignments(unit) ? (
- openTrainerAssignModal(unit)}
- title="Nur organisatorisch: Leitung und Co für diese Einheit"
- >
- Trainer zuweisen
-
- ) : null}
- {showTakeLead ? (
- handleTakeLead(unit)}>
- Ich übernehme
-
- ) : null}
- handleDelete(unit)}
- >
- Löschen
-
-
-
-
- {unit.notes && (
-
- {unit.notes}
-
- )}
-
- )
- })}
-
- )}
-
-
{
- if (!assignSaving) setAssignModalOpen(false)
- }}
- onCancel={() => setAssignModalOpen(false)}
- onSave={saveTrainerAssignModal}
- />
-
- {
- setModuleApplyOpen(false)
- setModuleApplyPlacementLocked(false)
- }}
- />
-
-
- setFwImportSlotDates((prev) => ({ ...prev, [slotId]: value }))
- }
- fwImportStartDate={fwImportStartDate}
- onFwImportStartDateChange={setFwImportStartDate}
- fwImportIntervalDays={fwImportIntervalDays}
- onFwImportIntervalDaysChange={setFwImportIntervalDays}
- fwImportSubmitting={fwImportSubmitting}
- onApplyDateSuggestions={applyFwImportDateSuggestions}
- onSubmit={submitFrameworkImport}
- onClose={() => setFrameworkImportOpen(false)}
- />
-
- setShowModal(false)}
- draftPlanTemplateId={draftPlanTemplateId}
- onDraftTemplateSelect={applyTemplateFromSelect}
- planTemplates={planTemplates}
- clubDirectory={clubDirectory}
- clubDirectoryForCo={clubDirectoryForCo}
- planningModalClubId={planningModalClubId}
- user={user}
- onMetaRefresh={refreshPlanningSectionMeta}
- sectionsEditMode={sectionsEditMode}
- setSectionsEditMode={setSectionsEditMode}
- onSaveAsTemplate={handleSaveAsTemplate}
- onRequestTrainingModulePick={(ctx) => {
- void openModuleApplyModal(ctx)
- }}
- onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
- setExercisePickerTarget({
- sIdx: sectionIndex,
- iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
- insertBeforeIndex:
- typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
- ? insertBeforeIndex
- : undefined,
- })
- setExercisePickerOpen(true)
- }}
- onPeekExercise={(id, variantId, peekExtras) =>
- setPlanningPeekCtx({
- exerciseId: id,
- variantId: variantId ?? null,
- peekExtras: peekExtras ?? null,
- })
- }
- />
- {
- setExercisePickerOpen(false)
- setExercisePickerTarget(null)
- }}
- onSelectExercises={async (picked) => {
- if (!exercisePickerTarget || !picked?.length) return
- const rows = []
- for (const ex of picked) {
- const row = await hydrateExercisePlanningRow(ex)
- if (row) rows.push(row)
- }
- if (!rows.length) return
- const { sIdx, iIdx, insertBeforeIndex } = exercisePickerTarget
- setFormData((prev) => ({
- ...prev,
- sections: prev.sections.map((s, si) => {
- if (si !== sIdx) return s
- const items = [...(s.items || [])]
- if (typeof iIdx === 'number') {
- const cur = items[iIdx]
- if (!cur || cur.item_type !== 'exercise') return s
- const [first, ...tail] = rows
- items[iIdx] = {
- ...cur,
- exercise_id: first.exercise_id,
- exercise_variant_id: first.exercise_variant_id,
- exercise_title: first.exercise_title,
- variants: first.variants,
- }
- if (tail.length) items.splice(iIdx + 1, 0, ...tail)
- return { ...s, items }
- }
- const rawAt =
- typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
- ? insertBeforeIndex
- : items.length
- const at = Math.max(0, Math.min(rawAt, items.length))
- items.splice(at, 0, ...rows)
- return { ...s, items }
- }),
- }))
- setExercisePickerOpen(false)
- setExercisePickerTarget(null)
- }}
- />
- setPlanningPeekCtx(null)}
- />
-
- )
-}
-
-export default TrainingPlanningPage
+/** Routen-Einstieg: Implementierung in `components/planning/TrainingPlanningPageRoot.jsx` (Phase-3 Soft-Limit). */
+export { default } from '../components/planning/TrainingPlanningPageRoot'