shinkan-jinkendo/frontend/src/pages/TrainingUnitEditPage.jsx
Lars 4588ef4c7e
Some checks failed
Deploy Development / deploy (push) Failing after 24s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 7s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s
Refactor navigation components and enhance return context handling
- Replaced `PageReturnLink` with `PageReturnButton` for consistent back navigation across various pages.
- Updated multiple components, including `ExercisePeekModal`, `PageFormEditorChrome`, and `ExerciseDetailPage`, to utilize the new return context features.
- Enhanced CSS styles for the new return button to improve visual consistency.
- Improved navigation logic in `TrainingFrameworkProgramEditPage` and `TrainingModuleEditPage` to ensure seamless user experience when navigating back to previous locations.
2026-05-20 07:42:46 +02:00

810 lines
30 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitFormShell from '../components/planning/TrainingUnitFormShell'
import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
import TrainingPublishToFrameworkModal from '../components/planning/TrainingPublishToFrameworkModal'
import SaveExercisesAsModuleModal from '../components/planning/SaveExercisesAsModuleModal'
import {
defaultSection,
enrichSectionsWithVariants,
formSectionsFromPlanTemplateRows,
hydrateExercisePlanningRow,
insertTrainingModuleIntoPlanningSections,
normalizeUnitToForm,
templateSectionsPayloadFromFormSections,
} from '../utils/trainingUnitSectionsForm'
import {
filterDirectoryExcludingLead,
} from '../utils/trainingPlanningPageHelpers'
import {
createEmptyTrainingUnitFormData,
buildTrainingUnitSavePayload,
trainingUnitToFormFields,
trainingUnitFormSnapshot,
validateTrainingUnitFormForSave,
} from '../utils/trainingUnitEditorCore'
import PageFormEditorChrome from '../components/PageFormEditorChrome'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { buildPlanUnitEditPath } from '../utils/planningUnitRoutes'
import {
PLANNING_HUB_PATH,
buildCurrentLocationReturnContext,
goNavReturn,
} from '../utils/navReturnContext'
export default function TrainingUnitEditPage() {
const { id: routeId } = useParams()
const isNew = !routeId || routeId === 'new'
const unitId = !isNew ? parseInt(routeId, 10) : NaN
const navigate = useNavigate()
const location = useLocation()
const [searchParams] = useSearchParams()
const { user } = useAuth()
const toast = useToast()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [groups, setGroups] = useState([])
const [planTemplates, setPlanTemplates] = useState([])
const [editingUnit, setEditingUnit] = useState(null)
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
const [clubDirectory, setClubDirectory] = useState([])
const [formData, setFormData] = useState(() => createEmptyTrainingUnitFormData())
const formRef = useRef(formData)
formRef.current = formData
const baselineRef = useRef(null)
const [baselineReady, setBaselineReady] = useState(false)
const [bypassDirty, setBypassDirty] = useState(false)
const dirtySignature = trainingUnitFormSnapshot(formRef.current, {
editingUnit,
draftPlanTemplateId,
})
useEffect(() => {
baselineRef.current = null
setBaselineReady(false)
setBypassDirty(false)
}, [isNew, unitId])
useEffect(() => {
if (loading) return undefined
const handle = window.setTimeout(() => {
baselineRef.current = trainingUnitFormSnapshot(formRef.current, {
editingUnit,
draftPlanTemplateId,
})
setBaselineReady(true)
}, 120)
return () => clearTimeout(handle)
// Baseline nur nach initialem Laden — nicht bei Template-/Form-Änderungen im Editor
// eslint-disable-next-line react-hooks/exhaustive-deps -- editingUnit/draftPlanTemplateId bewusst ausgeschlossen
}, [loading, isNew, unitId])
const formDirtyEffective =
baselineReady &&
baselineRef.current != null &&
!bypassDirty &&
!loading &&
dirtySignature !== baselineRef.current
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
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 [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
const goBack = useCallback(() => {
goNavReturn(navigate, location, {
path: PLANNING_HUB_PATH,
label: 'Zurück zur Planung',
})
}, [location, navigate])
const moduleSaveReturnContext = useMemo(
() => buildCurrentLocationReturnContext(location, 'Zurück zur Trainingseinheit'),
[location]
)
const planningClubId = 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?.club_id) return null
const c = Number(g.club_id)
return Number.isFinite(c) ? c : null
}, [groups, formData.group_id])
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])
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])
useEffect(() => {
if (!moduleApplyOpen || !moduleApplyFilteredList.length) return
if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return
setModuleApplyModuleId(String(moduleApplyFilteredList[0].id))
}, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId])
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])
useEffect(() => {
if (planningClubId == null) {
setClubDirectory([])
return undefined
}
let cancelled = false
;(async () => {
try {
const d = await api.clubMembersDirectory(planningClubId)
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
} catch {
if (!cancelled) setClubDirectory([])
}
})()
return () => {
cancelled = true
}
}, [planningClubId])
const clubDirectoryForCo = useMemo(() => {
let exclude = null
const leadTrim = String(formData.lead_trainer_profile_id || '').trim()
if (leadTrim) {
const n = parseInt(leadTrim, 10)
if (Number.isFinite(n)) exclude = n
} else if (editingUnit?.effective_lead_trainer_profile_id != null) {
exclude = Number(editingUnit.effective_lead_trainer_profile_id)
} else {
const gid = parseInt(formData.group_id || '0', 10)
const g = groups.find((gr) => gr.id === gid)
if (g?.trainer_id != null) exclude = Number(g.trainer_id)
}
return filterDirectoryExcludingLead(clubDirectory, exclude)
}, [clubDirectory, formData.lead_trainer_profile_id, formData.group_id, editingUnit, groups])
const loadCatalogs = useCallback(async () => {
const [groupsData, tpl] = await Promise.all([
api.listTrainingGroups({ status: 'active' }),
api.listTrainingPlanTemplates(),
])
setGroups(Array.isArray(groupsData) ? groupsData : [])
setPlanTemplates(Array.isArray(tpl) ? tpl : [])
return Array.isArray(groupsData) ? groupsData : []
}, [])
useEffect(() => {
let cancelled = false
async function init() {
setLoading(true)
try {
const groupsData = await loadCatalogs()
if (cancelled) return
if (isNew) {
const qGroup = searchParams.get('group') || ''
const qDate = searchParams.get('date') || new Date().toISOString().slice(0, 10)
const qTemplate = searchParams.get('template') || ''
const gid =
qGroup ||
(groupsData.length === 1 ? String(groupsData[0].id) : '') ||
(groupsData.find((g) => g.trainer_id === user?.id)
? String(groupsData.find((g) => g.trainer_id === user?.id).id)
: '')
const group = groupsData.find((g) => String(g.id) === String(gid))
setEditingUnit(null)
setDraftPlanTemplateId(qTemplate)
setSectionsEditMode(searchParams.get('mode') === 'debrief' ? 'debrief' : 'planning')
setFormData(
createEmptyTrainingUnitFormData({
groupId: gid,
plannedDate: qDate,
timeStart: group?.time_start?.slice(0, 5) || '',
timeEnd: group?.time_end?.slice(0, 5) || '',
})
)
if (qTemplate) {
const tpl = await api.getTrainingPlanTemplate(parseInt(qTemplate, 10))
if (!cancelled && tpl?.sections?.length) {
setFormData((fd) => ({
...fd,
sections: formSectionsFromPlanTemplateRows(tpl.sections),
}))
}
}
} else if (Number.isFinite(unitId)) {
const fullUnit = await api.getTrainingUnit(unitId)
if (cancelled) return
setEditingUnit(fullUnit)
setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '')
let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections)
if (cancelled) return
setFormData(trainingUnitToFormFields(fullUnit, sections))
const modeParam = searchParams.get('mode')
setSectionsEditMode(
modeParam === 'debrief' || fullUnit.status === 'completed' ? 'debrief' : 'planning'
)
}
} catch (e) {
if (!cancelled) toast.error(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
}
init()
return () => {
cancelled = true
}
}, [isNew, unitId, searchParams, loadCatalogs, user?.id, tenantClubDepKey, toast])
const updateFormField = useCallback(
(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 || '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)
}
}
return {
...prev,
lead_trainer_profile_id: value,
session_assistant_profile_ids: prev.session_assistant_profile_ids.filter((id) => !strip.has(id)),
}
})
},
[groups]
)
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
? formSectionsFromPlanTemplateRows(tpl.sections)
: [defaultSection()],
}))
} catch (err) {
toast.error('Vorlage laden: ' + err.message)
}
}
const reloadUnitAfterSave = useCallback(
async (savedId) => {
const fullUnit = await api.getTrainingUnit(savedId)
const nextDraftTemplateId = fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : ''
setEditingUnit(fullUnit)
setDraftPlanTemplateId(nextDraftTemplateId)
let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections)
const nextForm = trainingUnitToFormFields(fullUnit, sections)
setFormData(nextForm)
baselineRef.current = trainingUnitFormSnapshot(nextForm, {
editingUnit: fullUnit,
draftPlanTemplateId: nextDraftTemplateId,
})
setBypassDirty(false)
if (!isNew && savedId !== unitId) {
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
}
},
[isNew, unitId, navigate, location.state]
)
const handleSubmit = useCallback(
async (e, { closeAfter = true } = {}) => {
e?.preventDefault?.()
const v = validateTrainingUnitFormForSave(formData)
if (!v.ok) {
toast.error(v.message)
return false
}
setSaving(true)
try {
const payload = buildTrainingUnitSavePayload(formData, {
editingUnit,
draftPlanTemplateId,
})
let savedUnit
if (editingUnit) {
savedUnit = await api.updateTrainingUnit(editingUnit.id, payload)
} else {
savedUnit = await api.createTrainingUnit(payload)
}
toast.success('Gespeichert.')
if (closeAfter) {
goBack()
} else if (savedUnit?.id) {
await reloadUnitAfterSave(savedUnit.id)
}
return true
} catch (err) {
toast.error('Fehler beim Speichern: ' + err.message)
return false
} finally {
setSaving(false)
}
},
[formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave]
)
const handleUnsavedDialogSave = async () => {
const ok = await handleSubmit(null, { closeAfter: false })
if (ok) blocker.proceed()
}
const actionConfig = useMemo(
() => ({
formId: 'planning-unit-form',
saving,
isNew: !editingUnit,
onSave: (e) => handleSubmit(e, { closeAfter: false }),
onSaveAndClose: (e) => handleSubmit(e, { closeAfter: true }),
onCancel: goBack,
showSave: true,
showSaveAndClose: true,
}),
[saving, editingUnit, handleSubmit, goBack]
)
const hubBackPath = PLANNING_HUB_PATH
const pageTitle = editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'
const handleSaveAsTemplate = async (opts = {}) => {
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
if (!name?.trim()) return
const descRaw = window.prompt('Kurzbeschreibung (optional, leer lassen zum Überspringen):')
const visibility =
typeof opts.visibility === 'string' && opts.visibility.trim()
? String(opts.visibility).trim().toLowerCase()
: 'private'
let club_id = opts.club_id != null && opts.club_id !== '' ? Number(opts.club_id) : null
if (visibility === 'club') {
if (!Number.isFinite(club_id) || club_id < 1) club_id = planningClubId
if (!Number.isFinite(club_id) || club_id < 1) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
return
}
} else {
club_id = null
}
try {
await api.createTrainingPlanTemplate({
name: name.trim(),
description: descRaw?.trim() ? descRaw.trim() : null,
visibility,
club_id: visibility === 'club' ? club_id : null,
sections: templateSectionsPayloadFromFormSections(formData.sections),
})
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 = formRef.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 : []
before =
typeof placement.insertBeforeIndex === 'number'
? Math.min(Math.max(0, placement.insertBeforeIndex), items.length)
: items.length
} else {
before = Array.isArray(secs[0]?.items) ? secs[0].items.length : 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 = formRef.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)
const baseSections = formRef.current?.sections ?? []
if (!baseSections.length) return
if (!Number.isFinite(secIx) || secIx < 0 || secIx >= baseSections.length) secIx = 0
const itemCap = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items.length : 0
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)
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, toast])
const refreshPlanningSectionMeta = useCallback(async () => {
const next = await enrichSectionsWithVariants(formRef.current.sections)
setFormData((prev) => ({ ...prev, sections: next }))
}, [])
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner" />
<p>Laden </p>
</div>
)
}
return (
<PageFormEditorChrome
testId="planning-unit-form"
title={pageTitle}
fallbackPath={hubBackPath}
fallbackLabel="Zurück zur Planung"
actionConfig={actionConfig}
>
<TrainingUnitFormShell
editingUnit={editingUnit}
formData={formData}
updateFormField={updateFormField}
setFormData={setFormData}
onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })}
onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })}
draftPlanTemplateId={draftPlanTemplateId}
onDraftTemplateSelect={applyTemplateFromSelect}
planTemplates={planTemplates}
clubDirectory={clubDirectory}
clubDirectoryForCo={clubDirectoryForCo}
planningClubId={planningClubId}
user={user}
onMetaRefresh={refreshPlanningSectionMeta}
sectionsEditMode={sectionsEditMode}
setSectionsEditMode={setSectionsEditMode}
onSaveAsTemplate={handleSaveAsTemplate}
onRequestPublishToFramework={() => editingUnit?.id && setPublishFrameworkOpen(true)}
onRequestSaveAsModule={() => editingUnit?.id && setSaveModuleOpen(true)}
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 })
}
/>
<TrainingPlanningModuleApplyModal
open={moduleApplyOpen}
busy={moduleApplyBusy}
err={moduleApplyErr}
placementLocked={moduleApplyPlacementLocked}
placementSummary={modulePlacementSummary}
sections={formData.sections}
sectionIx={moduleApplySectionIx}
onSectionIndexChange={onModuleApplySectionIndexChange}
insertSlot={moduleApplyInsertSlot}
onInsertSlotChange={setModuleApplyInsertSlot}
targetItems={moduleApplyTargetItems}
searchQuery={moduleApplySearchQuery}
onSearchQueryChange={setModuleApplySearchQuery}
filteredList={moduleApplyFilteredList}
fullList={moduleApplyList}
selectedModuleId={moduleApplyModuleId}
onSelectModuleId={setModuleApplyModuleId}
modulePickPreview={modulePickPreview}
onConfirm={handleApplyTrainingModuleConfirm}
onCancel={() => {
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
/>
<TrainingPublishToFrameworkModal
open={publishFrameworkOpen}
onClose={() => setPublishFrameworkOpen(false)}
onSuccess={() => setPublishFrameworkOpen(false)}
unitId={editingUnit?.id}
planningModalClubId={planningClubId}
returnContext={moduleSaveReturnContext}
/>
<SaveExercisesAsModuleModal
open={saveModuleOpen}
onClose={() => setSaveModuleOpen(false)}
onSuccess={() => setSaveModuleOpen(false)}
unitId={editingUnit?.id}
planningModalClubId={planningClubId}
returnContext={moduleSaveReturnContext}
/>
<ExercisePickerModal
open={exercisePickerOpen}
multiSelect
enableQuickCreateDraft
onClose={() => {
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 [first, ...tail] = rows
items[iIdx] = { ...items[iIdx], ...first, item_type: 'exercise' }
if (tail.length) items.splice(iIdx + 1, 0, ...tail)
return { ...s, items }
}
const at = Math.min(
typeof insertBeforeIndex === 'number' ? insertBeforeIndex : items.length,
items.length
)
items.splice(at, 0, ...rows)
return { ...s, items }
}),
}))
setExercisePickerOpen(false)
setExercisePickerTarget(null)
}}
/>
<ExercisePeekModal
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
detail="Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?"
/>
</PageFormEditorChrome>
)
}