All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
Test Suite / pytest-backend (pull_request) Successful in 34s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 12s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m8s
- Updated the onMoveSectionsAcrossSlots function to support additional parameters for improved section movement across slots, including handling for parallel phase indices and insertion points. - Refined logic for moving sections between slots, ensuring proper handling of parallel streams and enhancing the overall section management experience.
1337 lines
48 KiB
JavaScript
1337 lines
48 KiB
JavaScript
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 { useToast } from '../context/ToastContext'
|
||
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()
|
||
/** 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('/planning/framework-programs', { 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')
|
||
navigate('/planning/framework-programs')
|
||
} 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 } = {}) => {
|
||
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 (!fromUnsavedDialog) {
|
||
navigate(`/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.')
|
||
return true
|
||
} catch (e) {
|
||
toast.error(e.message || 'Speichern fehlgeschlagen')
|
||
return false
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
await performFrameworkSave({ fromUnsavedDialog: false })
|
||
}
|
||
|
||
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)
|
||
navigate('/planning/framework-programs')
|
||
} 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">
|
||
<p style={{ marginBottom: '0.75rem' }}>
|
||
<Link to="/planning/framework-programs" style={{ color: 'var(--accent-dark)' }}>
|
||
← Alle Rahmenprogramme
|
||
</Link>
|
||
</p>
|
||
|
||
<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 Session‑Slots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
|
||
<strong>Gruppen‑Planung</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>Zwischen‑Anmerkungen</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 }}>
|
||
Session‑Slots & 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>
|
||
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'center' }}>
|
||
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave}>
|
||
{saving ? 'Speichern…' : isNew ? 'Anlegen' : 'Speichern'}
|
||
</button>
|
||
<Link to="/planning/framework-programs" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||
Abbrechen
|
||
</Link>
|
||
{!isNew ? (
|
||
<button type="button" className="btn btn-secondary" onClick={handleDelete}>
|
||
Löschen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</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>
|
||
)
|
||
}
|