shinkan-jinkendo/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
Lars 8afdd811db
Some checks failed
Deploy Development / deploy (push) Failing after 23s
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 33s
Test Suite / playwright-tests (push) Successful in 1m19s
Refactor full-page editor navigation and update return context handling
- Adjusted `PageFormEditorChrome` to set `showReturn` to false by default, making the back button optional.
- Removed `PageReturnButton` from `TrainingFrameworkProgramEditPage`, `TrainingModuleEditPage`, and `TrainingPlanTemplateEditPage` to streamline navigation.
- Updated documentation to reflect changes in editor actions and return context behavior for improved clarity.
2026-05-20 07:56:22 +02:00

1349 lines
48 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<button key={si} type="button" {...attrs} className={baseClass} onClick={() => setMobileSlotIdx(si)} title={slotChipLabel(slot, si)}>
{slotChipLabel(slot, si)}
</button>
)
})
const renderFrameworkSlotCard = (si) => {
const slot = form.slots[si]
if (!slot) return null
return (
<div
key={si}
className={`card framework-slot-card${!desktopLayout ? ' framework-slot-card--mobile-single' : ''}`}
onDragOver={desktopLayout ? onSlotColumnDragOver : undefined}
onDrop={desktopLayout ? (e) => onSlotColumnDrop(e, si) : undefined}
>
<div className="framework-slot-card__head">
{desktopLayout ? (
<span
role="button"
tabIndex={0}
className="framework-slot-card__drag-handle"
draggable
onDragStart={(e) => onSlotDragStart(e, si)}
aria-label="Slot ziehen: Reihenfolge ändern"
title="Slot ziehen (Reihenfolge)"
>
</span>
) : null}
<div className="framework-slot-card__head-main">
<span className="framework-slot-card__slot-label">Session {si + 1}</span>
<input
className="form-input framework-slot-card__title-input"
value={slot.title}
onChange={(e) => slotField(si, 'title', e.target.value)}
placeholder={`z.B. Woche ${si + 1}`}
/>
</div>
<div className="framework-slot-card__slot-actions">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveSlot(si, -1)}
aria-label="Slot nach links / oben"
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveSlot(si, 1)}
aria-label="Slot nach rechts / unten"
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeSlot(si)}
>
×
</button>
</div>
</div>
<details className="framework-slot-details">
<summary className="framework-slot-details__summary">Notizen (Session)</summary>
<div className="form-row">
<label className="form-label">Notizen</label>
<textarea
className="form-input"
rows={2}
value={slot.notes}
onChange={(e) => slotField(si, 'notes', e.target.value)}
/>
</div>
</details>
<div className="framework-slot-card__plan-editor" style={{ marginTop: '0.65rem', minHeight: '120px' }}>
<TrainingUnitSectionsEditor
heading={`Ablauf · Session ${si + 1}`}
sections={slot.sections}
betweenInsertMenus={false}
showExecutionExtras={false}
wideExerciseGrid
slotIndex={si}
enableParallelPhaseControls
onMoveSectionsAcrossSlots={moveSectionsAcrossFrameworkSlots}
onSectionsChange={(updater) => {
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) =>
ii !== si
? sl
: {
...sl,
sections: updater(
sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]
),
}
),
}))
}}
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) =>
setSectionPickerCtx({
slotIdx: si,
sectionIndex,
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
insertBeforeIndex:
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: undefined,
})
}
onPeekExercise={(id, variantId) =>
setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
}
/>
</div>
</div>
)
}
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner" />
<p>Laden</p>
</div>
)
}
return (
<div className="app-page">
<div className="framework-edit">
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
<details className="framework-edit-intro">
<summary className="framework-edit-intro__summary">
Kurz erklärt: Was ist ein Rahmenprogramm?
</summary>
<div className="framework-edit-intro__body">
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
Zielen und SessionSlots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
<strong>GruppenPlanung</strong> (Übernahme). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
<strong>Abschnitte</strong>, optional <strong>Ganzgruppen- und parallele Phasen (Breakout)</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>. Abschnitte kannst du per Ziehen auch in eine andere Session legen.
</div>
</details>
<div className="framework-edit__tabbar">
<PageSectionNav
ariaLabel="Bereiche"
value={frameworkTab}
onChange={setFrameworkTab}
items={[
{ id: 'meta', label: 'Stammdaten' },
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
]}
className="page-section-nav--embedded framework-edit__section-nav"
/>
</div>
<div
className={
'framework-edit__panel framework-edit__panel--meta card' +
(panelActive('meta') ? ' framework-edit__panel--active' : '')
}
style={{ marginBottom: '1rem', ...(panelVisibilityStyle('meta') || {}) }}
>
<h3 className="card-title">Stammdaten</h3>
<div className="form-row">
<label className="form-label">Titel *</label>
<input
className="form-input"
value={form.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="z.B. Vorbereitung Gürtelprüfung — 8 Wochen"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich (optional)</label>
<select
className="form-input"
value={form.focus_area_id}
onChange={(e) => updateField('focus_area_id', e.target.value)}
>
<option value=""> keiner </option>
{focusAreas.map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name}
</option>
))}
</select>
<p className="form-sub">Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.</p>
</div>
<div className="form-row">
<label className="form-label">Stilrichtung (optional)</label>
<select
className="form-input"
value={form.style_direction_id}
onChange={(e) => updateField('style_direction_id', e.target.value)}
>
<option value=""> keine </option>
{styleDirections.map((sd) => (
<option key={sd.id} value={String(sd.id)}>
{sd.name}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Trainingsarten (optional, Mehrfachwahl)</label>
<div className="framework-catalog-checkgrid">
{trainingTypesFiltered.length === 0 ? (
<p className="form-sub" style={{ marginTop: 0 }}>
Keine Einträge im Katalog oder Fokusbereich wählen, um zu filtern.
</p>
) : (
trainingTypesFiltered.map((t) => (
<label key={t.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(form.training_type_ids || []).includes(String(t.id))}
onChange={() => toggleTrainingTypeId(t.id)}
/>
<span>{t.name}</span>
</label>
))
)}
</div>
</div>
<div className="form-row">
<label className="form-label">Zielgruppen (optional, Mehrfachwahl)</label>
<div className="framework-catalog-checkgrid">
{targetGroupsCatalog.length === 0 ? (
<p className="form-sub" style={{ marginTop: 0 }}>
Keine Zielgruppen im Katalog.
</p>
) : (
targetGroupsCatalog.map((tg) => (
<label key={tg.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(form.target_group_ids || []).includes(String(tg.id))}
onChange={() => toggleTargetGroupId(tg.id)}
/>
<span>{tg.name}</span>
</label>
))
)}
</div>
</div>
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
<div>
<label className="form-label">Zeitraum von</label>
<input
type="date"
className="form-input"
value={form.planned_period_start}
onChange={(e) => updateField('planned_period_start', e.target.value)}
/>
</div>
<div>
<label className="form-label">Zeitraum bis</label>
<input
type="date"
className="form-input"
value={form.planned_period_end}
onChange={(e) => updateField('planned_period_end', e.target.value)}
/>
</div>
</div>
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={form.visibility}
onChange={(e) => updateField('visibility', e.target.value)}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
</div>
<div>
<label className="form-label">Verein (optional)</label>
<select
className="form-input"
value={form.club_id}
onChange={(e) => updateField('club_id', e.target.value)}
>
<option value=""> keiner </option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
</div>
</div>
</div>
<div
className={
'framework-edit__panel framework-edit__panel--plan' +
(panelActive('plan') ? ' framework-edit__panel--active' : '')
}
style={{ marginBottom: '1rem', ...(panelVisibilityStyle('plan') || {}) }}
>
<div className="framework-edit__plan-stack">
<div className="card framework-plan-goals">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.6rem',
}}
>
<h3 className="card-title" style={{ marginBottom: 0 }}>
Entwicklungsziele
</h3>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addGoal}>
+ Ziel
</button>
</div>
<p className="framework-goal-chips__hint">
Ziele als Tags Tippen zum Bearbeiten; am Desktop zeigt der <strong>Titel</strong>-Tooltip die Notizen mit. Am
Handy: <strong></strong> oder Bearbeiten.
</p>
<div className="framework-goal-chips">
{(form.goals || []).map((g, gi) => (
<div key={gi} className="framework-popmenu-anchor framework-goal-chip-wrap">
<button
type="button"
className={
'framework-goal-chip' + (editingGoalIdx === gi ? ' framework-goal-chip--active' : '')
}
title={goalHoverText(g)}
onClick={() => {
setEditingGoalIdx(gi)
setGoalMenuGi(null)
}}
>
<span className="framework-goal-chip__text">
{(g.title || '').trim() || `Ziel ${gi + 1}`}
</span>
</button>
<button
type="button"
className="framework-goal-chip__kebab"
aria-label="Ziel-Menü"
aria-expanded={goalMenuGi === gi}
onClick={(e) => {
e.stopPropagation()
setGoalMenuGi((prev) => (prev === gi ? null : gi))
}}
>
</button>
{goalMenuGi === gi ? (
<ul className="framework-popmenu" role="menu">
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
setEditingGoalIdx(gi)
setGoalMenuGi(null)
}}
>
Bearbeiten
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveGoal(gi, -1)
setGoalMenuGi(null)
}}
>
Nach vorn in der Liste
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveGoal(gi, 1)
setGoalMenuGi(null)
}}
>
Nach hinten in der Liste
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item framework-popmenu__item--danger"
onClick={() => removeGoal(gi)}
>
Entfernen
</button>
</li>
</ul>
) : null}
</div>
))}
</div>
{editingGoalIdx != null && form.goals[editingGoalIdx] != null ? (
<div className="framework-goal-editor">
<div className="form-row">
<label className="form-label">Titel *</label>
<input
className="form-input"
value={form.goals[editingGoalIdx].title}
onChange={(e) =>
setForm((prev) => ({
...prev,
goals: prev.goals.map((x, i) =>
i === editingGoalIdx ? { ...x, title: e.target.value } : x
),
}))
}
/>
</div>
<div className="form-row" style={{ marginBottom: 10 }}>
<label className="form-label">Notizen</label>
<textarea
className="form-input"
rows={2}
value={form.goals[editingGoalIdx].notes}
onChange={(e) =>
setForm((prev) => ({
...prev,
goals: prev.goals.map((x, i) =>
i === editingGoalIdx ? { ...x, notes: e.target.value } : x
),
}))
}
/>
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => setEditingGoalIdx(null)}
>
Fertig
</button>
</div>
) : null}
</div>
<div className="card framework-plan-slots" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 className="card-title" style={{ marginBottom: 0 }}>
SessionSlots & Ablauf
</h3>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addSlot}>
+ Slot
</button>
</div>
{form.slots.length === 0 ? (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
Noch keine Slots mit <strong>+ Slot</strong> legst du Spalten an (z.B. Woche 1). In jedem Slot
strukturierst du wie in der Trainingsplanung: Abschnitte, Übungen, Anmerkungen.
</p>
) : (
<p
className="framework-slots-hint"
style={{ fontSize: '0.8rem', color: 'var(--text3)', marginBottom: '10px', lineHeight: 1.45 }}
>
{desktopLayout ? (
<>
Reihenfolge der Spalten per Griff () ziehen oder mit / verschieben; Inhalt eines Slots wie
in der Planung bearbeiten (Abschnitte, Übungen, Anmerkungen).
</>
) : (
<>
Sessions oben oder unten per Chips wählen ein Slot nutzt die volle Breite. Reihenfolge mit /;
bearbeiten wie in der Planung.
</>
)}
</p>
)}
{form.slots.length > 0 ? (
!desktopLayout ? (
<div className="framework-slots-board-outer framework-slots-board-outer--mobile-single">
<div
className="framework-slot-chips-bar framework-slot-chips-bar--top"
role="tablist"
aria-label="Sessions"
>
{slotChipButtons({ tabSemantics: true })}
</div>
<div className="framework-slot-mobile-panel" tabIndex={-1}>
{renderFrameworkSlotCard(mobileSlotIdx)}
</div>
<div
className="framework-slot-chips-bar framework-slot-chips-bar--bottom"
role="group"
aria-label="Sessions (Anwahl unten)"
>
{slotChipButtons({ tabSemantics: false })}
</div>
</div>
) : (
<div className="framework-slots-board-outer framework-slots-board-outer--desktop">
<div className="framework-slots-board framework-slots-board--desktop-wide">
{form.slots.map((_, si) => renderFrameworkSlotCard(si))}
</div>
</div>
)
) : null}
</div>
</div>
</div>
<FormActionBar
isNew={isNew}
saving={saving}
onSave={handleSave}
onSaveAndClose={handleSaveAndClose}
onCancel={goBack}
cancelLabel="Abbrechen"
/>
{!isNew ? (
<button type="button" className="btn btn-secondary" onClick={handleDelete} style={{ marginTop: '10px' }}>
Löschen
</button>
) : null}
</div>
<ExercisePickerModal
open={sectionPickerCtx != null}
multiSelect
enableQuickCreateDraft
onClose={() => setSectionPickerCtx(null)}
onSelectExercises={async (picked) => {
if (!sectionPickerCtx || !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 { slotIdx, sectionIndex: sIdx, itemIndex: iIdx, insertBeforeIndex } = sectionPickerCtx
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) => {
if (ii !== slotIdx) return sl
const baseSecs = sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]
return {
...sl,
sections: baseSecs.map((sec, si) => {
if (si !== sIdx) return sec
const items = [...(sec.items || [])]
if (typeof iIdx === 'number') {
const cur = items[iIdx]
if (!cur || cur.item_type !== 'exercise') return sec
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 { ...sec, 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 { ...sec, items }
}),
}
}),
}))
setSectionPickerCtx(null)
}}
/>
<ExercisePeekModal
key={peekCtx != null ? String(peekCtx.exerciseId) : 'fw-peek-closed'}
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId || 0}
variantId={peekCtx?.variantId ?? undefined}
onClose={() => setPeekCtx(null)}
/>
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
/>
</div>
)
}