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
- 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.
810 lines
30 KiB
JavaScript
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>
|
|
)
|
|
}
|