2026-05-05 13:11:17 +02:00
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
2026-05-05 11:11:52 +02:00
|
|
|
|
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
|
2026-05-05 09:02:13 +02:00
|
|
|
|
import api from '../utils/api'
|
|
|
|
|
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
|
|
|
|
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
2026-05-05 13:39:30 +02:00
|
|
|
|
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
|
|
|
|
|
import {
|
|
|
|
|
|
defaultSection,
|
|
|
|
|
|
normalizeUnitToForm,
|
|
|
|
|
|
enrichSectionsWithVariants,
|
|
|
|
|
|
buildSectionsPayload,
|
2026-05-05 14:47:38 +02:00
|
|
|
|
hydrateExercisePlanningRow,
|
2026-05-05 13:39:30 +02:00
|
|
|
|
} from '../utils/trainingUnitSectionsForm'
|
|
|
|
|
|
|
|
|
|
|
|
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
2026-05-05 12:19:03 +02:00
|
|
|
|
/** Unter dieser Breite: 2 Tabs (Stammdaten | Plan); darüber: alles untereinander */
|
2026-05-05 12:08:03 +02:00
|
|
|
|
const FRAMEWORK_DESKTOP_MIN_PX = 900
|
|
|
|
|
|
|
2026-05-05 12:19:03 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 14:22:37 +02:00
|
|
|
|
function slotChipLabel(slot, idx) {
|
|
|
|
|
|
const t = (slot?.title || '').trim()
|
|
|
|
|
|
return t || `Session ${idx + 1}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 09:02:13 +02:00
|
|
|
|
function emptyGoal() {
|
|
|
|
|
|
return { title: '', notes: '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function emptySlot() {
|
2026-05-05 13:39:30 +02:00
|
|
|
|
return { title: '', notes: '', sections: [defaultSection('Ablauf')] }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function enrichFrameworkSlotSections(slots) {
|
|
|
|
|
|
const out = []
|
|
|
|
|
|
for (const s of slots || []) {
|
|
|
|
|
|
const sec = normalizeUnitToForm({ sections: s.sections, exercises: s.exercises })
|
|
|
|
|
|
out.push({
|
|
|
|
|
|
...s,
|
|
|
|
|
|
sections: await enrichSectionsWithVariants(sec),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
2026-05-05 09:02:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 12:52:51 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 09:02:13 +02:00
|
|
|
|
function defaultForm() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: '',
|
|
|
|
|
|
description: '',
|
2026-05-05 13:11:17 +02:00
|
|
|
|
focus_area_id: '',
|
|
|
|
|
|
style_direction_id: '',
|
|
|
|
|
|
training_type_ids: [],
|
|
|
|
|
|
target_group_ids: [],
|
2026-05-05 09:02:13 +02:00
|
|
|
|
planned_period_start: '',
|
|
|
|
|
|
planned_period_end: '',
|
|
|
|
|
|
visibility: 'private',
|
|
|
|
|
|
club_id: '',
|
|
|
|
|
|
goals: [emptyGoal()],
|
|
|
|
|
|
slots: [],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function serverFrameworkToForm(fw) {
|
|
|
|
|
|
const goalsIn = Array.isArray(fw.goals) && fw.goals.length ? fw.goals : [emptyGoal()]
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: fw.title || '',
|
|
|
|
|
|
description: fw.description || '',
|
2026-05-05 13:11:17 +02:00
|
|
|
|
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))
|
|
|
|
|
|
: [],
|
2026-05-05 09:02:13 +02:00
|
|
|
|
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 || '',
|
2026-05-05 13:39:30 +02:00
|
|
|
|
sections: normalizeUnitToForm({ sections: s.sections, exercises: s.exercises }),
|
2026-05-05 09:02:13 +02:00
|
|
|
|
})),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-05-05 13:39:30 +02:00
|
|
|
|
const secList = s.sections && s.sections.length ? s.sections : [defaultSection('Ablauf')]
|
|
|
|
|
|
const sectionsPayload = buildSectionsPayload(secList)
|
2026-05-05 09:02:13 +02:00
|
|
|
|
return {
|
|
|
|
|
|
sort_order: si,
|
|
|
|
|
|
title: (s.title || '').trim() || null,
|
|
|
|
|
|
notes: (s.notes || '').trim() || null,
|
2026-05-05 13:39:30 +02:00
|
|
|
|
sections: sectionsPayload,
|
2026-05-05 09:02:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-05 13:11:17 +02:00
|
|
|
|
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)
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-05 13:11:17 +02:00
|
|
|
|
focus_area_id: focusAreaId,
|
|
|
|
|
|
style_direction_id: styleDirectionId,
|
|
|
|
|
|
training_type_ids,
|
|
|
|
|
|
target_group_ids,
|
2026-05-05 09:02:13 +02:00
|
|
|
|
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() {
|
2026-05-05 11:11:52 +02:00
|
|
|
|
const { id: idParam } = useParams()
|
|
|
|
|
|
const location = useLocation()
|
2026-05-05 09:02:13 +02:00
|
|
|
|
const navigate = useNavigate()
|
2026-05-05 11:11:52 +02:00
|
|
|
|
/** Route `…/framework-programs/new` hat kein dynamisches `:id` — useParams ist dann leer. */
|
|
|
|
|
|
const isNew = /\/framework-programs\/new\/?$/.test(location.pathname)
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
|
|
|
|
|
const [loading, setLoading] = useState(!isNew)
|
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
const [form, setForm] = useState(defaultForm())
|
|
|
|
|
|
const [clubs, setClubs] = useState([])
|
2026-05-05 13:11:17 +02:00
|
|
|
|
const [focusAreas, setFocusAreas] = useState([])
|
|
|
|
|
|
const [styleDirections, setStyleDirections] = useState([])
|
|
|
|
|
|
const [trainingTypesCatalog, setTrainingTypesCatalog] = useState([])
|
|
|
|
|
|
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
|
2026-05-05 13:39:30 +02:00
|
|
|
|
const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
|
2026-05-05 14:42:46 +02:00
|
|
|
|
const [peekCtx, setPeekCtx] = useState(null)
|
2026-05-05 12:52:51 +02:00
|
|
|
|
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
|
|
|
|
|
|
const [goalMenuGi, setGoalMenuGi] = useState(null)
|
2026-05-05 12:19:03 +02:00
|
|
|
|
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
|
2026-05-05 11:53:45 +02:00
|
|
|
|
const [frameworkTab, setFrameworkTab] = useState('meta')
|
|
|
|
|
|
const [desktopLayout, setDesktopLayout] = useState(
|
2026-05-05 12:08:03 +02:00
|
|
|
|
typeof window !== 'undefined'
|
|
|
|
|
|
? window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`).matches
|
|
|
|
|
|
: false
|
2026-05-05 11:53:45 +02:00
|
|
|
|
)
|
2026-05-05 14:22:37 +02:00
|
|
|
|
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
|
|
|
|
|
|
const [mobileSlotIdx, setMobileSlotIdx] = useState(0)
|
2026-05-05 11:53:45 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-05 12:08:03 +02:00
|
|
|
|
const mq = window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`)
|
2026-05-05 11:53:45 +02:00
|
|
|
|
const apply = () => setDesktopLayout(!!mq.matches)
|
|
|
|
|
|
apply()
|
|
|
|
|
|
mq.addEventListener('change', apply)
|
|
|
|
|
|
return () => mq.removeEventListener('change', apply)
|
|
|
|
|
|
}, [])
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
2026-05-05 12:52:51 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-05-05 09:02:13 +02:00
|
|
|
|
const loadMeta = useCallback(async () => {
|
|
|
|
|
|
try {
|
2026-05-05 13:11:17 +02:00
|
|
|
|
const [cl, fa, sd, tt, tg] = await Promise.all([
|
2026-05-05 09:02:13 +02:00
|
|
|
|
api.listClubs(),
|
2026-05-05 13:11:17 +02:00
|
|
|
|
api.listFocusAreas({ status: 'active' }),
|
|
|
|
|
|
api.listStyleDirections({ status: 'active' }),
|
|
|
|
|
|
api.listTrainingTypes({ status: 'active' }),
|
|
|
|
|
|
api.listTargetGroups({ status: 'active' }),
|
2026-05-05 09:02:13 +02:00
|
|
|
|
])
|
|
|
|
|
|
setClubs(Array.isArray(cl) ? cl : [])
|
2026-05-05 13:11:17 +02:00
|
|
|
|
setFocusAreas(Array.isArray(fa) ? fa : [])
|
|
|
|
|
|
setStyleDirections(Array.isArray(sd) ? sd : [])
|
|
|
|
|
|
setTrainingTypesCatalog(Array.isArray(tt) ? tt : [])
|
|
|
|
|
|
setTargetGroupsCatalog(Array.isArray(tg) ? tg : [])
|
2026-05-05 09:02:13 +02:00
|
|
|
|
} catch {
|
|
|
|
|
|
setClubs([])
|
2026-05-05 13:11:17 +02:00
|
|
|
|
setFocusAreas([])
|
|
|
|
|
|
setStyleDirections([])
|
|
|
|
|
|
setTrainingTypesCatalog([])
|
|
|
|
|
|
setTargetGroupsCatalog([])
|
2026-05-05 09:02:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadMeta()
|
|
|
|
|
|
}, [loadMeta])
|
|
|
|
|
|
|
2026-05-05 14:22:37 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setMobileSlotIdx(0)
|
|
|
|
|
|
}, [idParam, isNew])
|
|
|
|
|
|
|
2026-05-05 09:02:13 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isNew) {
|
|
|
|
|
|
setForm(defaultForm())
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-05 11:11:52 +02:00
|
|
|
|
const fid = parseInt(idParam, 10)
|
2026-05-05 09:02:13 +02:00
|
|
|
|
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)
|
2026-05-05 13:39:30 +02:00
|
|
|
|
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
|
2026-05-05 09:02:13 +02:00
|
|
|
|
setForm(next)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
alert(e.message || 'Laden fehlgeschlagen')
|
|
|
|
|
|
navigate('/planning/framework-programs')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
2026-05-05 11:11:52 +02:00
|
|
|
|
}, [isNew, idParam, navigate, location.pathname])
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
|
|
|
|
|
const updateField = (key, val) => {
|
2026-05-05 13:11:17 +02:00
|
|
|
|
setForm((prev) => ({ ...prev, [key]: val }))
|
2026-05-05 09:02:13 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const moveGoal = (idx, dir) => {
|
2026-05-05 12:52:51 +02:00
|
|
|
|
setEditingGoalIdx(null)
|
|
|
|
|
|
setGoalMenuGi(null)
|
2026-05-05 09:02:13 +02:00
|
|
|
|
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 }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 12:52:51 +02:00
|
|
|
|
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) => {
|
2026-05-05 09:02:13 +02:00
|
|
|
|
setForm((prev) => {
|
|
|
|
|
|
const g = prev.goals.filter((_, i) => i !== idx)
|
|
|
|
|
|
return { ...prev, goals: g.length ? g : [emptyGoal()] }
|
|
|
|
|
|
})
|
2026-05-05 12:52:51 +02:00
|
|
|
|
setEditingGoalIdx(null)
|
|
|
|
|
|
setGoalMenuGi(null)
|
|
|
|
|
|
}
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
|
|
|
|
|
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]]
|
2026-05-05 14:22:37 +02:00
|
|
|
|
setMobileSlotIdx((mi) => {
|
|
|
|
|
|
if (mi === idx) return j
|
|
|
|
|
|
if (mi === j) return idx
|
|
|
|
|
|
return mi
|
|
|
|
|
|
})
|
2026-05-05 09:02:13 +02:00
|
|
|
|
return { ...prev, slots: sl }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 14:22:37 +02:00
|
|
|
|
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
|
|
|
|
|
|
})
|
2026-05-05 09:02:13 +02:00
|
|
|
|
setForm((prev) => ({ ...prev, slots: prev.slots.filter((_, i) => i !== idx) }))
|
2026-05-05 14:22:37 +02:00
|
|
|
|
}
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
|
|
|
|
|
const slotField = (sIdx, key, val) => {
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
slots: prev.slots.map((s, i) => (i === sIdx ? { ...s, [key]: val } : s)),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
|
if (!(form.title || '').trim()) {
|
|
|
|
|
|
alert('Titel ist Pflichtfeld.')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let payload
|
|
|
|
|
|
try {
|
|
|
|
|
|
payload = buildApiPayload(form)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
alert(e.message || 'Validierung')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!payload.title) {
|
|
|
|
|
|
alert('Titel ist Pflichtfeld.')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setSaving(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (isNew) {
|
|
|
|
|
|
const created = await api.createTrainingFrameworkProgram(payload)
|
|
|
|
|
|
navigate(`/planning/framework-programs/${created.id}`, { replace: true })
|
|
|
|
|
|
} else {
|
2026-05-05 11:11:52 +02:00
|
|
|
|
const fid = parseInt(idParam, 10)
|
2026-05-05 09:02:13 +02:00
|
|
|
|
await api.updateTrainingFrameworkProgram(fid, payload)
|
|
|
|
|
|
const refreshed = await api.getTrainingFrameworkProgram(fid)
|
|
|
|
|
|
let next = serverFrameworkToForm(refreshed)
|
2026-05-05 13:39:30 +02:00
|
|
|
|
next = { ...next, slots: await enrichFrameworkSlotSections(next.slots) }
|
2026-05-05 09:02:13 +02:00
|
|
|
|
setForm(next)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
alert(e.message || 'Speichern fehlgeschlagen')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDelete() {
|
|
|
|
|
|
if (isNew) return
|
2026-05-05 11:11:52 +02:00
|
|
|
|
const fid = parseInt(idParam, 10)
|
2026-05-05 09:02:13 +02:00
|
|
|
|
if (!confirm('Dieses Rahmenprogramm wirklich löschen?')) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteTrainingFrameworkProgram(fid)
|
|
|
|
|
|
navigate('/planning/framework-programs')
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
alert(e.message || 'Löschen fehlgeschlagen')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 12:19:03 +02:00
|
|
|
|
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)
|
2026-05-05 13:39:30 +02:00
|
|
|
|
if (!slotRaw) return
|
|
|
|
|
|
const { slotIdx } = JSON.parse(slotRaw)
|
|
|
|
|
|
if (slotIdx !== targetSi) {
|
|
|
|
|
|
setForm((prev) => ({ ...prev, slots: reorderArray([...prev.slots], slotIdx, targetSi) }))
|
2026-05-05 12:19:03 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const panelActive = (key) => {
|
|
|
|
|
|
if (desktopLayout) return true
|
|
|
|
|
|
if (key === 'meta') return frameworkTab === 'meta'
|
|
|
|
|
|
if (key === 'plan') return frameworkTab === 'plan'
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-05-05 11:53:45 +02:00
|
|
|
|
|
2026-05-05 12:08:03 +02:00
|
|
|
|
/** Schmale Ansicht: Sichtbarkeit per Inline (falls globales CSS nicht greift / altes Bundle) */
|
|
|
|
|
|
const panelVisibilityStyle = (key) =>
|
|
|
|
|
|
desktopLayout ? undefined : { display: panelActive(key) ? 'block' : 'none' }
|
|
|
|
|
|
|
2026-05-05 13:11:17 +02:00
|
|
|
|
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)) }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 14:42:46 +02:00
|
|
|
|
const moveSectionsAcrossFrameworkSlots = useCallback(
|
|
|
|
|
|
({ fromSlot, fromSectionIdx, toSlot, toSectionIdx }) => {
|
|
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
return { ...prev, slots }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const toSecs = slots[toSlot].sections
|
|
|
|
|
|
const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length))
|
|
|
|
|
|
toSecs.splice(ia, 0, block)
|
|
|
|
|
|
return { ...prev, slots }
|
|
|
|
|
|
})
|
|
|
|
|
|
},
|
|
|
|
|
|
[]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-05 14:22:37 +02:00
|
|
|
|
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}
|
|
|
|
|
|
showExecutionExtras={false}
|
|
|
|
|
|
wideExerciseGrid
|
2026-05-05 14:42:46 +02:00
|
|
|
|
slotIndex={si}
|
|
|
|
|
|
onMoveSectionsAcrossSlots={moveSectionsAcrossFrameworkSlots}
|
2026-05-05 14:22:37 +02:00
|
|
|
|
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')]
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}}
|
2026-05-05 14:42:46 +02:00
|
|
|
|
onRequestExercisePick={({ sectionIndex, itemIndex, multi }) =>
|
2026-05-05 14:22:37 +02:00
|
|
|
|
setSectionPickerCtx({
|
|
|
|
|
|
slotIdx: si,
|
|
|
|
|
|
sectionIndex,
|
2026-05-05 14:42:46 +02:00
|
|
|
|
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
|
|
|
|
|
|
multi: !!multi,
|
2026-05-05 14:22:37 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-05-05 14:42:46 +02:00
|
|
|
|
onPeekExercise={(id, variantId) =>
|
|
|
|
|
|
setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
|
|
|
|
|
|
}
|
2026-05-05 14:22:37 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 09:02:13 +02:00
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
2026-05-05 12:39:15 +02:00
|
|
|
|
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
<div className="spinner" />
|
|
|
|
|
|
<p>Laden…</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-05 12:39:15 +02:00
|
|
|
|
<div className="app-page">
|
2026-05-05 11:53:45 +02:00
|
|
|
|
<div className="framework-edit">
|
2026-05-05 09:02:13 +02:00
|
|
|
|
<p style={{ marginBottom: '0.75rem' }}>
|
|
|
|
|
|
<Link to="/planning/framework-programs" style={{ color: 'var(--accent-dark)' }}>
|
|
|
|
|
|
← Alle Rahmenprogramme
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
2026-05-05 11:53:45 +02:00
|
|
|
|
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
|
|
|
|
|
|
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
|
2026-05-05 13:11:17 +02:00
|
|
|
|
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
|
2026-05-05 13:39:30 +02:00
|
|
|
|
Zielen und Session‑Slots. <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>, Übungen mit Varianten und Dauer, <strong>Zwischen‑Anmerkungen</strong>.
|
2026-05-05 11:53:45 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-05 12:08:03 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className="framework-edit__tabbar"
|
|
|
|
|
|
role="tablist"
|
|
|
|
|
|
aria-label="Bereiche"
|
|
|
|
|
|
style={
|
|
|
|
|
|
desktopLayout
|
|
|
|
|
|
? { display: 'none' }
|
|
|
|
|
|
: {
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
gap: 6,
|
|
|
|
|
|
marginBottom: 14,
|
|
|
|
|
|
padding: '6px 0 12px',
|
|
|
|
|
|
borderBottom: '2px solid var(--accent)',
|
|
|
|
|
|
flexWrap: 'nowrap',
|
|
|
|
|
|
overflowX: 'auto',
|
|
|
|
|
|
position: 'sticky',
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
zIndex: 6,
|
|
|
|
|
|
background: 'var(--bg)',
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-05-05 11:53:45 +02:00
|
|
|
|
{[
|
|
|
|
|
|
{ id: 'meta', label: 'Stammdaten' },
|
2026-05-05 12:19:03 +02:00
|
|
|
|
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
|
2026-05-05 11:53:45 +02:00
|
|
|
|
].map((t) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={t.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={frameworkTab === t.id}
|
|
|
|
|
|
className={'framework-edit__tab' + (frameworkTab === t.id ? ' framework-edit__tab--active' : '')}
|
|
|
|
|
|
onClick={() => setFrameworkTab(t.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
2026-05-05 11:53:45 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className={
|
|
|
|
|
|
'framework-edit__panel framework-edit__panel--meta card' +
|
|
|
|
|
|
(panelActive('meta') ? ' framework-edit__panel--active' : '')
|
|
|
|
|
|
}
|
2026-05-05 12:08:03 +02:00
|
|
|
|
style={{ marginBottom: '1rem', ...(panelVisibilityStyle('meta') || {}) }}
|
2026-05-05 11:53:45 +02:00
|
|
|
|
>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
<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">
|
2026-05-05 13:11:17 +02:00
|
|
|
|
<label className="form-label">Fokusbereich (optional)</label>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
2026-05-05 13:11:17 +02:00
|
|
|
|
value={form.focus_area_id}
|
|
|
|
|
|
onChange={(e) => updateField('focus_area_id', e.target.value)}
|
2026-05-05 09:02:13 +02:00
|
|
|
|
>
|
2026-05-05 13:11:17 +02:00
|
|
|
|
<option value="">— keiner —</option>
|
|
|
|
|
|
{focusAreas.map((fa) => (
|
|
|
|
|
|
<option key={fa.id} value={String(fa.id)}>
|
|
|
|
|
|
{fa.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
2026-05-05 09:02:13 +02:00
|
|
|
|
</select>
|
2026-05-05 13:11:17 +02:00
|
|
|
|
<p className="form-sub">Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.</p>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
</div>
|
2026-05-05 13:11:17 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
2026-05-05 09:02:13 +02:00
|
|
|
|
</div>
|
2026-05-05 13:11:17 +02:00
|
|
|
|
</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>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
2026-05-05 12:39:15 +02:00
|
|
|
|
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-05 12:39:15 +02:00
|
|
|
|
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-05 11:53:45 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className={
|
2026-05-05 12:19:03 +02:00
|
|
|
|
'framework-edit__panel framework-edit__panel--plan' +
|
|
|
|
|
|
(panelActive('plan') ? ' framework-edit__panel--active' : '')
|
2026-05-05 11:53:45 +02:00
|
|
|
|
}
|
2026-05-05 12:19:03 +02:00
|
|
|
|
style={{ marginBottom: '1rem', ...(panelVisibilityStyle('plan') || {}) }}
|
2026-05-05 11:53:45 +02:00
|
|
|
|
>
|
2026-05-05 12:19:03 +02:00
|
|
|
|
<div className="framework-edit__plan-stack">
|
|
|
|
|
|
<div className="card framework-plan-goals">
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
2026-05-05 12:39:15 +02:00
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
gap: '8px',
|
2026-05-05 12:19:03 +02:00
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
2026-05-05 12:52:51 +02:00
|
|
|
|
marginBottom: '0.6rem',
|
2026-05-05 12:19:03 +02:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<h3 className="card-title" style={{ marginBottom: 0 }}>
|
|
|
|
|
|
Entwicklungsziele
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addGoal}>
|
|
|
|
|
|
+ Ziel
|
2026-05-05 09:02:13 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-05-05 12:52:51 +02:00
|
|
|
|
<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">
|
2026-05-05 12:19:03 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-05-05 12:52:51 +02:00
|
|
|
|
className={
|
|
|
|
|
|
'framework-goal-chip' + (editingGoalIdx === gi ? ' framework-goal-chip--active' : '')
|
|
|
|
|
|
}
|
|
|
|
|
|
title={goalHoverText(g)}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setEditingGoalIdx(gi)
|
|
|
|
|
|
setGoalMenuGi(null)
|
|
|
|
|
|
}}
|
2026-05-05 12:19:03 +02:00
|
|
|
|
>
|
2026-05-05 12:52:51 +02:00
|
|
|
|
<span className="framework-goal-chip__text">
|
|
|
|
|
|
{(g.title || '').trim() || `Ziel ${gi + 1}`}
|
|
|
|
|
|
</span>
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-05-05 12:52:51 +02:00
|
|
|
|
className="framework-goal-chip__kebab"
|
|
|
|
|
|
aria-label="Ziel-Menü"
|
|
|
|
|
|
aria-expanded={goalMenuGi === gi}
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
setGoalMenuGi((prev) => (prev === gi ? null : gi))
|
|
|
|
|
|
}}
|
2026-05-05 12:19:03 +02:00
|
|
|
|
>
|
2026-05-05 12:52:51 +02:00
|
|
|
|
⋮
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</button>
|
2026-05-05 12:52:51 +02:00
|
|
|
|
{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}
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</div>
|
2026-05-05 12:52:51 +02:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{editingGoalIdx != null && form.goals[editingGoalIdx] != null ? (
|
|
|
|
|
|
<div className="framework-goal-editor">
|
2026-05-05 12:19:03 +02:00
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Titel *</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
2026-05-05 12:52:51 +02:00
|
|
|
|
value={form.goals[editingGoalIdx].title}
|
2026-05-05 12:19:03 +02:00
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
2026-05-05 12:52:51 +02:00
|
|
|
|
goals: prev.goals.map((x, i) =>
|
|
|
|
|
|
i === editingGoalIdx ? { ...x, title: e.target.value } : x
|
|
|
|
|
|
),
|
2026-05-05 12:19:03 +02:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-05-05 12:52:51 +02:00
|
|
|
|
<div className="form-row" style={{ marginBottom: 10 }}>
|
2026-05-05 12:19:03 +02:00
|
|
|
|
<label className="form-label">Notizen</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
rows={2}
|
2026-05-05 12:52:51 +02:00
|
|
|
|
value={form.goals[editingGoalIdx].notes}
|
2026-05-05 12:19:03 +02:00
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
2026-05-05 12:52:51 +02:00
|
|
|
|
goals: prev.goals.map((x, i) =>
|
|
|
|
|
|
i === editingGoalIdx ? { ...x, notes: e.target.value } : x
|
|
|
|
|
|
),
|
2026-05-05 12:19:03 +02:00
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-05-05 12:52:51 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
|
|
|
|
|
onClick={() => setEditingGoalIdx(null)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Fertig
|
|
|
|
|
|
</button>
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</div>
|
2026-05-05 12:52:51 +02:00
|
|
|
|
) : null}
|
2026-05-05 09:02:13 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-05 12:19:03 +02:00
|
|
|
|
<div className="card framework-plan-slots" style={{ marginBottom: '1.5rem' }}>
|
2026-05-05 12:39:15 +02:00
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
2026-05-05 12:19:03 +02:00
|
|
|
|
<h3 className="card-title" style={{ marginBottom: 0 }}>
|
2026-05-05 13:39:30 +02:00
|
|
|
|
Session‑Slots & Ablauf
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</h3>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addSlot}>
|
|
|
|
|
|
+ Slot
|
2026-05-05 09:02:13 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-05-05 12:19:03 +02:00
|
|
|
|
|
|
|
|
|
|
{form.slots.length === 0 ? (
|
|
|
|
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
|
2026-05-05 13:39:30 +02:00
|
|
|
|
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.
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p
|
|
|
|
|
|
className="framework-slots-hint"
|
|
|
|
|
|
style={{ fontSize: '0.8rem', color: 'var(--text3)', marginBottom: '10px', lineHeight: 1.45 }}
|
|
|
|
|
|
>
|
2026-05-05 14:22:37 +02:00
|
|
|
|
{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.
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</p>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-05 14:22:37 +02:00
|
|
|
|
{form.slots.length > 0 ? (
|
|
|
|
|
|
!desktopLayout ? (
|
|
|
|
|
|
<div className="framework-slots-board-outer framework-slots-board-outer--mobile-single">
|
2026-05-05 12:19:03 +02:00
|
|
|
|
<div
|
2026-05-05 14:22:37 +02:00
|
|
|
|
className="framework-slot-chips-bar framework-slot-chips-bar--top"
|
|
|
|
|
|
role="tablist"
|
|
|
|
|
|
aria-label="Sessions"
|
2026-05-05 12:19:03 +02:00
|
|
|
|
>
|
2026-05-05 14:22:37 +02:00
|
|
|
|
{slotChipButtons({ tabSemantics: true })}
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</div>
|
2026-05-05 14:22:37 +02:00
|
|
|
|
<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}
|
2026-05-05 09:02:13 +02:00
|
|
|
|
</div>
|
2026-05-05 12:19:03 +02:00
|
|
|
|
</div>
|
2026-05-05 12:08:03 +02:00
|
|
|
|
</div>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
|
|
|
|
|
<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
|
2026-05-05 13:39:30 +02:00
|
|
|
|
open={sectionPickerCtx != null}
|
2026-05-05 14:42:46 +02:00
|
|
|
|
multiSelect={!!sectionPickerCtx?.multi}
|
2026-05-05 13:39:30 +02:00
|
|
|
|
onClose={() => setSectionPickerCtx(null)}
|
2026-05-05 14:42:46 +02:00
|
|
|
|
onSelectExercises={
|
|
|
|
|
|
sectionPickerCtx?.multi
|
|
|
|
|
|
? async (picked) => {
|
|
|
|
|
|
if (!sectionPickerCtx || !picked?.length) return
|
|
|
|
|
|
const { slotIdx, sectionIndex: sIdx } = sectionPickerCtx
|
|
|
|
|
|
const rows = []
|
|
|
|
|
|
for (const ex of picked) {
|
2026-05-05 14:47:38 +02:00
|
|
|
|
const row = await hydrateExercisePlanningRow(ex)
|
2026-05-05 14:42:46 +02:00
|
|
|
|
if (row) rows.push(row)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!rows.length) return
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
slots: prev.slots.map((sl, ii) =>
|
|
|
|
|
|
ii !== slotIdx
|
|
|
|
|
|
? sl
|
|
|
|
|
|
: {
|
|
|
|
|
|
...sl,
|
|
|
|
|
|
sections: (
|
|
|
|
|
|
sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]
|
|
|
|
|
|
).map((sec, si) =>
|
|
|
|
|
|
si !== sIdx ? sec : { ...sec, items: [...(sec.items || []), ...rows] }
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
}))
|
|
|
|
|
|
setSectionPickerCtx(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
2026-05-05 13:39:30 +02:00
|
|
|
|
onSelectExercise={async (exercise) => {
|
|
|
|
|
|
if (!sectionPickerCtx) return
|
2026-05-05 14:42:46 +02:00
|
|
|
|
if (sectionPickerCtx.multi) return
|
|
|
|
|
|
if (typeof sectionPickerCtx.itemIndex !== 'number') return
|
2026-05-05 13:39:30 +02:00
|
|
|
|
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx
|
2026-05-05 14:47:38 +02:00
|
|
|
|
const row = await hydrateExercisePlanningRow(exercise)
|
2026-05-05 14:42:46 +02:00
|
|
|
|
if (!row) return
|
2026-05-05 13:39:30 +02:00
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
slots: prev.slots.map((sl, ii) =>
|
|
|
|
|
|
ii !== slotIdx
|
|
|
|
|
|
? sl
|
|
|
|
|
|
: {
|
|
|
|
|
|
...sl,
|
|
|
|
|
|
sections: (sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]).map(
|
|
|
|
|
|
(sec, si) =>
|
|
|
|
|
|
si !== sIdx
|
|
|
|
|
|
? sec
|
|
|
|
|
|
: {
|
|
|
|
|
|
...sec,
|
2026-05-05 14:42:46 +02:00
|
|
|
|
items: (sec.items || []).map((r2, ji) =>
|
2026-05-05 13:39:30 +02:00
|
|
|
|
ji !== iIdx
|
2026-05-05 14:42:46 +02:00
|
|
|
|
? r2
|
|
|
|
|
|
: r2.item_type !== 'exercise'
|
|
|
|
|
|
? r2
|
2026-05-05 13:39:30 +02:00
|
|
|
|
: {
|
2026-05-05 14:42:46 +02:00
|
|
|
|
...r2,
|
|
|
|
|
|
exercise_id: row.exercise_id,
|
|
|
|
|
|
exercise_variant_id: row.exercise_variant_id,
|
|
|
|
|
|
exercise_title: row.exercise_title,
|
|
|
|
|
|
variants: row.variants,
|
2026-05-05 13:39:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
}))
|
|
|
|
|
|
setSectionPickerCtx(null)
|
2026-05-05 09:02:13 +02:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-05-05 14:42:46 +02:00
|
|
|
|
<ExercisePeekModal
|
|
|
|
|
|
open={peekCtx != null}
|
|
|
|
|
|
exerciseId={peekCtx?.exerciseId || 0}
|
|
|
|
|
|
variantId={peekCtx?.variantId ?? undefined}
|
|
|
|
|
|
onClose={() => setPeekCtx(null)}
|
|
|
|
|
|
/>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|