import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams, useLocation } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePeekModal from '../components/ExercisePeekModal' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import PageSectionNav from '../components/PageSectionNav' import FormActionBar from '../components/FormActionBar' import { useToast } from '../context/ToastContext' import { useNavReturn } from '../hooks/useNavReturn' import { FRAMEWORK_PROGRAMS_LIST_PATH, buildFrameworkProgramsListReturnContext, preserveAppReturnOnNavigate, } from '../utils/navReturnContext' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { defaultSection, normalizeUnitToForm, enrichSectionsWithVariants, buildPlanPayloadForSave, hydrateExercisePlanningRow, reorderBlockIntoParallelStreamEnd, indicesOfParallelPhase, reorderSectionBeforeParallelRunAsWholeGroup, reorderSectionAsFirstInParallelStream, } from '../utils/trainingUnitSectionsForm' const DND_FW_SLOT = 'application/x-shinkan-framework-slot' /** Unter dieser Breite: 2 Tabs (Stammdaten | Plan); darüber: alles untereinander */ const FRAMEWORK_DESKTOP_MIN_PX = 900 function reorderArray(arr, from, to) { if (from === to || from < 0 || from >= arr.length) return [...arr] const next = [...arr] const [it] = next.splice(from, 1) const t = Math.max(0, Math.min(to, next.length)) next.splice(t, 0, it) return next } function slotChipLabel(slot, idx) { const t = (slot?.title || '').trim() return t || `Session ${idx + 1}` } function emptyGoal() { return { title: '', notes: '' } } function emptySlot() { return { title: '', notes: '', sections: [defaultSection('Ablauf')] } } async function enrichFrameworkSlotSections(slots) { const out = [] for (const s of slots || []) { const baseSecs = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')] out.push({ ...s, sections: await enrichSectionsWithVariants(baseSecs), }) } return out } function goalHoverText(g) { const t = (g.title || '').trim() || 'Ohne Titel' const n = (g.notes || '').trim() if (!n) return t const combined = `${t} — ${n}` return combined.length > 280 ? `${combined.slice(0, 277)}…` : combined } function defaultForm() { return { title: '', description: '', focus_area_id: '', style_direction_id: '', training_type_ids: [], target_group_ids: [], planned_period_start: '', planned_period_end: '', visibility: 'private', club_id: '', goals: [emptyGoal()], slots: [], } } function frameworkDraftSnapshot(fm) { const goalsNorm = (fm.goals || []).map((g) => ({ t: (g.title || '').trim(), n: (g.notes || '').trim(), })) const slotsNorm = (fm.slots || []).map((s) => ({ title: (s.title || '').trim(), notes: (s.notes || '').trim(), sections: s.sections, })) return JSON.stringify({ title: (fm.title || '').trim(), description: (fm.description || '').trim(), focus_area_id: fm.focus_area_id || '', style_direction_id: fm.style_direction_id || '', training_type_ids: [...(fm.training_type_ids || [])].map(String).sort(), target_group_ids: [...(fm.target_group_ids || [])].map(String).sort(), planned_period_start: fm.planned_period_start || '', planned_period_end: fm.planned_period_end || '', visibility: (fm.visibility || '').trim(), club_id: (fm.club_id || '').trim(), goals: goalsNorm, slots: slotsNorm, }) } function serverFrameworkToForm(fw) { const goalsIn = Array.isArray(fw.goals) && fw.goals.length ? fw.goals : [emptyGoal()] return { title: fw.title || '', description: fw.description || '', focus_area_id: fw.focus_area_id != null ? String(fw.focus_area_id) : '', style_direction_id: fw.style_direction_id != null ? String(fw.style_direction_id) : '', training_type_ids: Array.isArray(fw.training_type_ids) ? fw.training_type_ids.map((x) => String(x)) : [], target_group_ids: Array.isArray(fw.target_group_ids) ? fw.target_group_ids.map((x) => String(x)) : [], planned_period_start: fw.planned_period_start || '', planned_period_end: fw.planned_period_end || '', visibility: fw.visibility || 'private', club_id: fw.club_id != null ? String(fw.club_id) : '', goals: goalsIn.map((g) => ({ title: g.title || '', notes: g.notes || '', })), slots: (fw.slots || []).map((s) => ({ title: s.title || '', notes: s.notes || '', sections: normalizeUnitToForm({ sections: s.sections, exercises: s.exercises, phases: s.phases, }), })), } } function buildApiPayload(form) { const goals = (form.goals || []) .map((g, i) => ({ sort_order: i, title: (g.title || '').trim(), notes: (g.notes || '').trim() || null, })) .filter((g) => g.title) if (goals.length === 0) { throw new Error('Mindestens ein Entwicklungsziel mit Titel ist erforderlich.') } const slots = (form.slots || []).map((s, si) => { const secList = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')] const plan = buildPlanPayloadForSave(secList) const base = { sort_order: si, title: (s.title || '').trim() || null, notes: (s.notes || '').trim() || null, } if (plan.phases) { return { ...base, phases: plan.phases } } return { ...base, sections: plan.sections } }) const focusAreaId = form.focus_area_id && !Number.isNaN(parseInt(form.focus_area_id, 10)) ? parseInt(form.focus_area_id, 10) : null const styleDirectionId = form.style_direction_id && !Number.isNaN(parseInt(form.style_direction_id, 10)) ? parseInt(form.style_direction_id, 10) : null const training_type_ids = (form.training_type_ids || []) .map((x) => parseInt(String(x), 10)) .filter((n) => !Number.isNaN(n) && n > 0) const target_group_ids = (form.target_group_ids || []) .map((x) => parseInt(String(x), 10)) .filter((n) => !Number.isNaN(n) && n > 0) const clubId = form.club_id && !Number.isNaN(parseInt(form.club_id, 10)) ? parseInt(form.club_id, 10) : null return { title: (form.title || '').trim(), description: (form.description || '').trim() || null, focus_area_id: focusAreaId, style_direction_id: styleDirectionId, training_type_ids, target_group_ids, planned_period_start: form.planned_period_start || null, planned_period_end: form.planned_period_end || null, visibility: form.visibility || 'private', club_id: clubId, goals, slots, } } export default function TrainingFrameworkProgramEditPage() { const { id: idParam } = useParams() const location = useLocation() const navigate = useNavigate() const frameworkListReturn = useMemo(() => buildFrameworkProgramsListReturnContext(), []) const { goBack } = useNavReturn(frameworkListReturn) /** Route `…/framework-programs/new` hat kein dynamisches `:id` — useParams ist dann leer. */ const isNew = /\/framework-programs\/new\/?$/.test(location.pathname) const [loading, setLoading] = useState(!isNew) const [saving, setSaving] = useState(false) const [form, setForm] = useState(defaultForm()) const [clubs, setClubs] = useState([]) const [focusAreas, setFocusAreas] = useState([]) const [styleDirections, setStyleDirections] = useState([]) const [trainingTypesCatalog, setTrainingTypesCatalog] = useState([]) const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([]) const [sectionPickerCtx, setSectionPickerCtx] = useState(null) const [peekCtx, setPeekCtx] = useState(null) const [editingGoalIdx, setEditingGoalIdx] = useState(null) const [goalMenuGi, setGoalMenuGi] = useState(null) /** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */ const [frameworkTab, setFrameworkTab] = useState('meta') const [desktopLayout, setDesktopLayout] = useState( typeof window !== 'undefined' ? window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`).matches : false ) /** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */ const [mobileSlotIdx, setMobileSlotIdx] = useState(0) const toast = useToast() const baselineRef = useRef(null) const latestFormRef = useRef(form) latestFormRef.current = form const [baselineReady, setBaselineReady] = useState(false) const [bypassDirty, setBypassDirty] = useState(false) const dirtySignature = frameworkDraftSnapshot(form) useEffect(() => { baselineRef.current = null setBaselineReady(false) setBypassDirty(false) }, [idParam, isNew]) useEffect(() => { if (loading) return const handle = window.setTimeout(() => { baselineRef.current = frameworkDraftSnapshot(latestFormRef.current) setBaselineReady(true) }, 120) return () => clearTimeout(handle) }, [loading, idParam, isNew]) const formDirtyEffective = baselineReady && baselineRef.current != null && !bypassDirty && !loading && dirtySignature !== baselineRef.current const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving)) useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving)) useEffect(() => { const mq = window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`) const apply = () => setDesktopLayout(!!mq.matches) apply() mq.addEventListener('change', apply) return () => mq.removeEventListener('change', apply) }, []) useEffect(() => { const onPointerDown = (e) => { const t = e.target if (t.closest?.('.framework-popmenu-anchor')) return setGoalMenuGi(null) } document.addEventListener('pointerdown', onPointerDown, true) return () => document.removeEventListener('pointerdown', onPointerDown, true) }, []) const loadMeta = useCallback(async () => { try { const [cl, fa, sd, tt, tg] = await Promise.all([ api.listClubs(), api.listFocusAreas({ status: 'active' }), api.listStyleDirections({ status: 'active' }), api.listTrainingTypes({ status: 'active' }), api.listTargetGroups({ status: 'active' }), ]) setClubs(Array.isArray(cl) ? cl : []) setFocusAreas(Array.isArray(fa) ? fa : []) setStyleDirections(Array.isArray(sd) ? sd : []) setTrainingTypesCatalog(Array.isArray(tt) ? tt : []) setTargetGroupsCatalog(Array.isArray(tg) ? tg : []) } catch { setClubs([]) setFocusAreas([]) setStyleDirections([]) setTrainingTypesCatalog([]) setTargetGroupsCatalog([]) } }, []) useEffect(() => { loadMeta() }, [loadMeta]) useEffect(() => { setMobileSlotIdx(0) }, [idParam, isNew]) useEffect(() => { if (isNew) { setForm(defaultForm()) setLoading(false) return } const fid = parseInt(idParam, 10) if (Number.isNaN(fid)) { navigate(FRAMEWORK_PROGRAMS_LIST_PATH, { replace: true }) return } setLoading(true) let cancelled = false ;(async () => { try { const fw = await api.getTrainingFrameworkProgram(fid) if (cancelled) return let next = serverFrameworkToForm(fw) next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) } setForm(next) } catch (e) { toast.error(e.message || 'Laden fehlgeschlagen') goBack() } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [isNew, idParam, navigate, location.pathname]) const updateField = (key, val) => { setForm((prev) => ({ ...prev, [key]: val })) } const moveGoal = (idx, dir) => { setEditingGoalIdx(null) setGoalMenuGi(null) setForm((prev) => { const j = idx + dir if (j < 0 || j >= prev.goals.length) return prev const g = [...prev.goals] ;[g[idx], g[j]] = [g[j], g[idx]] return { ...prev, goals: g } }) } const addGoal = () => { let newIdx = 0 setForm((prev) => { const goals = [...prev.goals, emptyGoal()] newIdx = goals.length - 1 return { ...prev, goals } }) setEditingGoalIdx(newIdx) setGoalMenuGi(null) } const removeGoal = (idx) => { setForm((prev) => { const g = prev.goals.filter((_, i) => i !== idx) return { ...prev, goals: g.length ? g : [emptyGoal()] } }) setEditingGoalIdx(null) setGoalMenuGi(null) } const moveSlot = (idx, dir) => { setForm((prev) => { const j = idx + dir if (j < 0 || j >= prev.slots.length) return prev const sl = [...prev.slots] ;[sl[idx], sl[j]] = [sl[j], sl[idx]] setMobileSlotIdx((mi) => { if (mi === idx) return j if (mi === j) return idx return mi }) return { ...prev, slots: sl } }) } const addSlot = () => { setForm((prev) => { const slots = [...prev.slots, emptySlot()] setMobileSlotIdx(slots.length - 1) return { ...prev, slots } }) } const removeSlot = (idx) => { const n = form.slots.length setMobileSlotIdx((mi) => { if (idx < mi) return Math.max(0, mi - 1) if (idx === mi) return Math.min(mi, Math.max(0, n - 2)) return mi }) setForm((prev) => ({ ...prev, slots: prev.slots.filter((_, i) => i !== idx) })) } const slotField = (sIdx, key, val) => { setForm((prev) => ({ ...prev, slots: prev.slots.map((s, i) => (i === sIdx ? { ...s, [key]: val } : s)), })) } const performFrameworkSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { if (!(form.title || '').trim()) { toast.error('Titel ist Pflichtfeld.') return false } let payload try { payload = buildApiPayload(form) } catch (e) { toast.error(e.message || 'Validierung') return false } if (!payload.title) { toast.error('Titel ist Pflichtfeld.') return false } setSaving(true) try { if (isNew) { const created = await api.createTrainingFrameworkProgram(payload) toast.success('Rahmenprogramm angelegt.') if (closeAfter) { goBack() } else if (!fromUnsavedDialog) { preserveAppReturnOnNavigate(navigate, location, `/planning/framework-programs/${created.id}`, { replace: true, }) } return true } const fid = parseInt(idParam, 10) await api.updateTrainingFrameworkProgram(fid, payload) const refreshed = await api.getTrainingFrameworkProgram(fid) let next = serverFrameworkToForm(refreshed) next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) } setForm(next) baselineRef.current = frameworkDraftSnapshot(next) setBypassDirty(false) setBaselineReady(true) toast.success('Gespeichert.') if (closeAfter) goBack() return true } catch (e) { toast.error(e.message || 'Speichern fehlgeschlagen') return false } finally { setSaving(false) } } const handleSave = async () => { await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: false }) } const handleSaveAndClose = async () => { await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: true }) } const handleUnsavedDialogSave = async () => { const ok = await performFrameworkSave({ fromUnsavedDialog: true }) if (ok) blocker.proceed() } async function handleDelete() { if (isNew) return const fid = parseInt(idParam, 10) if (!confirm('Dieses Rahmenprogramm wirklich löschen?')) return try { await api.deleteTrainingFrameworkProgram(fid) goBack() } catch (e) { toast.error(e.message || 'Löschen fehlgeschlagen') } } const onSlotDragStart = (e, slotIdx) => { e.stopPropagation() e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData(DND_FW_SLOT, JSON.stringify({ slotIdx })) } const onSlotColumnDragOver = (e) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' } const onSlotColumnDrop = (e, targetSi) => { e.preventDefault() const slotRaw = e.dataTransfer.getData(DND_FW_SLOT) if (!slotRaw) return const { slotIdx } = JSON.parse(slotRaw) if (slotIdx !== targetSi) { setForm((prev) => ({ ...prev, slots: reorderArray([...prev.slots], slotIdx, targetSi) })) } } const panelActive = (key) => { if (desktopLayout) return true if (key === 'meta') return frameworkTab === 'meta' if (key === 'plan') return frameworkTab === 'plan' return false } /** Schmale Ansicht: Sichtbarkeit per Inline (falls globales CSS nicht greift / altes Bundle) */ const panelVisibilityStyle = (key) => desktopLayout ? undefined : { display: panelActive(key) ? 'block' : 'none' } const trainingTypesFiltered = useMemo(() => { if (!form.focus_area_id) return trainingTypesCatalog return trainingTypesCatalog.filter( (t) => !t.focus_area_id || String(t.focus_area_id) === String(form.focus_area_id) ) }, [trainingTypesCatalog, form.focus_area_id]) useEffect(() => { if (!form.focus_area_id || trainingTypesCatalog.length === 0) return const allowed = new Set(trainingTypesFiltered.map((t) => String(t.id))) setForm((prev) => { const cur = prev.training_type_ids || [] const next = cur.filter((id) => allowed.has(String(id))) if (next.length === cur.length) return prev return { ...prev, training_type_ids: next } }) }, [form.focus_area_id, trainingTypesCatalog.length, trainingTypesFiltered]) const toggleTrainingTypeId = (tid) => { const idStr = String(tid) setForm((prev) => { const s = new Set(prev.training_type_ids || []) if (s.has(idStr)) s.delete(idStr) else s.add(idStr) return { ...prev, training_type_ids: [...s].sort((a, b) => Number(a) - Number(b)) } }) } const toggleTargetGroupId = (gid) => { const idStr = String(gid) setForm((prev) => { const s = new Set(prev.target_group_ids || []) if (s.has(idStr)) s.delete(idStr) else s.add(idStr) return { ...prev, target_group_ids: [...s].sort((a, b) => Number(a) - Number(b)) } }) } const moveSectionsAcrossFrameworkSlots = useCallback( (payload) => { const { fromSlot, fromSectionIdx, toSlot, toSectionIdx, toParallelStream, parallelPhaseRunOrderIndex, insertBeforeParallelInTarget, firstInParallelStreamInTarget, } = payload setForm((prev) => { const slots = prev.slots.map((sl) => ({ ...sl, sections: [...((sl.sections && sl.sections.length) ? sl.sections : [defaultSection('Ablauf')])], })) if ( typeof fromSlot !== 'number' || typeof toSlot !== 'number' || fromSlot < 0 || toSlot < 0 || fromSlot >= slots.length || toSlot >= slots.length ) { return prev } const fromSecs = slots[fromSlot].sections const toSecs = slots[toSlot].sections const applyParallelStreamEnd = toParallelStream != null && toParallelStream.po != null && toParallelStream.so != null ? (secs, insertedAt) => { const po = Number(toParallelStream.po) || 0 const so = Number(toParallelStream.so) || 0 return reorderBlockIntoParallelStreamEnd(secs, insertedAt, po, so) } : null /** Gesamten Parallel-Lauf aus fromSlot an toSectionIdx in toSlot legen */ if (parallelPhaseRunOrderIndex != null && parallelPhaseRunOrderIndex !== '') { const po = Number(parallelPhaseRunOrderIndex) || 0 const idxs = indicesOfParallelPhase(fromSecs, po) if (!idxs.length) { return prev } const blocks = idxs.map((i) => fromSecs[i]) for (const i of [...idxs].sort((a, b) => b - a)) { fromSecs.splice(i, 1) } if (fromSlot === toSlot) { let insertAt = Number(toSectionIdx) || 0 for (const i of idxs) { if (i < insertAt) insertAt -= 1 } insertAt = Math.max(0, Math.min(insertAt, fromSecs.length)) fromSecs.splice(insertAt, 0, ...blocks) slots[fromSlot].sections = fromSecs return { ...prev, slots } } const insertAt = Math.max(0, Math.min(Number(toSectionIdx) || 0, toSecs.length)) toSecs.splice(insertAt, 0, ...blocks) slots[toSlot].sections = toSecs return { ...prev, slots } } if ( typeof fromSectionIdx !== 'number' || fromSectionIdx < 0 || fromSectionIdx >= fromSecs.length ) { return prev } const [block] = fromSecs.splice(fromSectionIdx, 1) if (fromSlot === toSlot) { let insertAt = toSectionIdx if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1 insertAt = Math.max(0, Math.min(insertAt, fromSecs.length)) fromSecs.splice(insertAt, 0, block) if (applyParallelStreamEnd) { slots[fromSlot].sections = applyParallelStreamEnd(fromSecs, insertAt) } else { slots[fromSlot].sections = fromSecs } return { ...prev, slots } } if (insertBeforeParallelInTarget != null && insertBeforeParallelInTarget !== '') { const tpo = Number(insertBeforeParallelInTarget) || 0 const pIdxs = indicesOfParallelPhase(toSecs, tpo) const ins = pIdxs.length ? pIdxs[0] : toSecs.length toSecs.splice(ins, 0, block) slots[toSlot].sections = reorderSectionBeforeParallelRunAsWholeGroup(toSecs, ins, tpo) return { ...prev, slots } } if ( firstInParallelStreamInTarget != null && firstInParallelStreamInTarget.po != null && firstInParallelStreamInTarget.so != null ) { const fpo = Number(firstInParallelStreamInTarget.po) || 0 const fso = Number(firstInParallelStreamInTarget.so) || 0 const nextSecs = [...toSecs, block] const movedFromI = nextSecs.length - 1 slots[toSlot].sections = reorderSectionAsFirstInParallelStream( nextSecs, movedFromI, fpo, fso ) return { ...prev, slots } } const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length)) toSecs.splice(ia, 0, block) if (applyParallelStreamEnd) { slots[toSlot].sections = applyParallelStreamEnd(toSecs, ia) } else { slots[toSlot].sections = toSecs } return { ...prev, slots } }) }, [] ) const slotChipButtons = (opts) => form.slots.map((slot, si) => { const isActive = si === mobileSlotIdx const sel = opts?.tabSemantics const baseClass = `framework-slot-chip${isActive ? ' framework-slot-chip--active' : ''}` const attrs = sel ? { role: 'tab', id: `fw-slot-chip-${si}`, 'aria-selected': isActive } : { 'aria-pressed': isActive } return ( ) }) const renderFrameworkSlotCard = (si) => { const slot = form.slots[si] if (!slot) return null return (
onSlotColumnDrop(e, si) : undefined} >
{desktopLayout ? ( onSlotDragStart(e, si)} aria-label="Slot ziehen: Reihenfolge ändern" title="Slot ziehen (Reihenfolge)" > ⋮⋮ ) : null}
Session {si + 1} slotField(si, 'title', e.target.value)} placeholder={`z. B. Woche ${si + 1}`} />
Notizen (Session)