2026-05-05 09:02:13 +02:00
|
|
|
|
import React, { useCallback, useEffect, 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'
|
|
|
|
|
|
|
|
|
|
|
|
function emptyGoal() {
|
|
|
|
|
|
return { title: '', notes: '' }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function emptyExercise() {
|
|
|
|
|
|
return { exercise_id: '', exercise_variant_id: '', exercise_title: '', variants: [] }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function emptySlot() {
|
|
|
|
|
|
return { title: '', notes: '', training_unit_id: '', exercises: [] }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function defaultForm() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: '',
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
plan_mode: 'library',
|
|
|
|
|
|
group_id: '',
|
|
|
|
|
|
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 || '',
|
|
|
|
|
|
plan_mode: fw.plan_mode || 'library',
|
|
|
|
|
|
group_id: fw.group_id != null ? String(fw.group_id) : '',
|
|
|
|
|
|
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 || '',
|
|
|
|
|
|
training_unit_id: s.training_unit_id != null ? String(s.training_unit_id) : '',
|
|
|
|
|
|
exercises: (s.exercises || []).map((ex) => ({
|
|
|
|
|
|
exercise_id: ex.exercise_id,
|
|
|
|
|
|
exercise_variant_id: ex.exercise_variant_id != null ? String(ex.exercise_variant_id) : '',
|
|
|
|
|
|
exercise_title: ex.exercise_title || '',
|
|
|
|
|
|
variants: [],
|
|
|
|
|
|
})),
|
|
|
|
|
|
})),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function enrichSlotExercisesWithVariants(formSlots) {
|
|
|
|
|
|
const ids = new Set()
|
|
|
|
|
|
for (const s of formSlots || []) {
|
|
|
|
|
|
for (const it of s.exercises || []) {
|
|
|
|
|
|
if (it.exercise_id) ids.add(Number(it.exercise_id))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const cache = new Map()
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
|
[...ids].map(async (id) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ex = await api.getExercise(id)
|
|
|
|
|
|
cache.set(id, {
|
|
|
|
|
|
title: ex.title || '',
|
|
|
|
|
|
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
cache.set(id, { title: '', variants: [] })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
return (formSlots || []).map((s) => ({
|
|
|
|
|
|
...s,
|
|
|
|
|
|
exercises: (s.exercises || []).map((it) => {
|
|
|
|
|
|
if (!it.exercise_id) return it
|
|
|
|
|
|
const c = cache.get(Number(it.exercise_id))
|
|
|
|
|
|
if (!c) return it
|
|
|
|
|
|
return {
|
|
|
|
|
|
...it,
|
|
|
|
|
|
exercise_title: it.exercise_title || c.title,
|
|
|
|
|
|
variants: it.variants?.length ? it.variants : c.variants,
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 tu =
|
|
|
|
|
|
form.plan_mode === 'concrete' && s.training_unit_id
|
|
|
|
|
|
? parseInt(s.training_unit_id, 10)
|
|
|
|
|
|
: null
|
|
|
|
|
|
const exercises = (s.exercises || [])
|
|
|
|
|
|
.map((ex, j) => {
|
|
|
|
|
|
if (!ex.exercise_id) return null
|
|
|
|
|
|
const vid = ex.exercise_variant_id
|
|
|
|
|
|
return {
|
|
|
|
|
|
exercise_id: parseInt(ex.exercise_id, 10),
|
|
|
|
|
|
exercise_variant_id:
|
|
|
|
|
|
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
|
|
|
|
|
|
order_index: j,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
return {
|
|
|
|
|
|
sort_order: si,
|
|
|
|
|
|
title: (s.title || '').trim() || null,
|
|
|
|
|
|
notes: (s.notes || '').trim() || null,
|
|
|
|
|
|
training_unit_id: tu,
|
|
|
|
|
|
exercises,
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const groupId =
|
|
|
|
|
|
form.plan_mode === 'library'
|
|
|
|
|
|
? null
|
|
|
|
|
|
: form.group_id && !Number.isNaN(parseInt(form.group_id, 10))
|
|
|
|
|
|
? parseInt(form.group_id, 10)
|
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
plan_mode: form.plan_mode,
|
|
|
|
|
|
group_id: groupId,
|
|
|
|
|
|
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 [groups, setGroups] = useState([])
|
|
|
|
|
|
const [clubs, setClubs] = useState([])
|
|
|
|
|
|
const [units, setUnits] = useState([])
|
|
|
|
|
|
const [pickerSlotIdx, setPickerSlotIdx] = useState(null)
|
|
|
|
|
|
const [peekId, setPeekId] = useState(null)
|
2026-05-05 11:53:45 +02:00
|
|
|
|
/** Nur schmal: welcher Block sichtbar — Desktop zeigt Stammdaten + zwei Spalten Ziele|Slots */
|
|
|
|
|
|
const [frameworkTab, setFrameworkTab] = useState('meta')
|
|
|
|
|
|
const [desktopLayout, setDesktopLayout] = useState(
|
|
|
|
|
|
typeof window !== 'undefined' ? window.matchMedia('(min-width: 1024px)').matches : false
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const mq = window.matchMedia('(min-width: 1024px)')
|
|
|
|
|
|
const apply = () => setDesktopLayout(!!mq.matches)
|
|
|
|
|
|
apply()
|
|
|
|
|
|
mq.addEventListener('change', apply)
|
|
|
|
|
|
return () => mq.removeEventListener('change', apply)
|
|
|
|
|
|
}, [])
|
2026-05-05 09:02:13 +02:00
|
|
|
|
|
|
|
|
|
|
const loadMeta = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [gr, cl] = await Promise.all([
|
|
|
|
|
|
api.listTrainingGroups({ status: 'active' }),
|
|
|
|
|
|
api.listClubs(),
|
|
|
|
|
|
])
|
|
|
|
|
|
setGroups(Array.isArray(gr) ? gr : [])
|
|
|
|
|
|
setClubs(Array.isArray(cl) ? cl : [])
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setGroups([])
|
|
|
|
|
|
setClubs([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadMeta()
|
|
|
|
|
|
}, [loadMeta])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (form.plan_mode !== 'concrete' || !form.group_id) {
|
|
|
|
|
|
setUnits([])
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
;(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const today = new Date()
|
|
|
|
|
|
const start = new Date(today)
|
|
|
|
|
|
start.setFullYear(start.getFullYear() - 1)
|
|
|
|
|
|
const end = new Date(today)
|
|
|
|
|
|
end.setFullYear(end.getFullYear() + 1)
|
|
|
|
|
|
const u = await api.listTrainingUnits({
|
|
|
|
|
|
group_id: parseInt(form.group_id, 10),
|
|
|
|
|
|
start_date: start.toISOString().slice(0, 10),
|
|
|
|
|
|
end_date: end.toISOString().slice(0, 10),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!cancelled) setUnits(Array.isArray(u) ? u : [])
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
if (!cancelled) setUnits([])
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [form.plan_mode, form.group_id])
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) }
|
|
|
|
|
|
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) => {
|
|
|
|
|
|
setForm((prev) => {
|
|
|
|
|
|
const n = { ...prev, [key]: val }
|
|
|
|
|
|
if (key === 'plan_mode' && val === 'library') {
|
|
|
|
|
|
n.group_id = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
return n
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const moveGoal = (idx, dir) => {
|
|
|
|
|
|
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 = () => setForm((prev) => ({ ...prev, goals: [...prev.goals, emptyGoal()] }))
|
|
|
|
|
|
const removeGoal = (idx) =>
|
|
|
|
|
|
setForm((prev) => {
|
|
|
|
|
|
const g = prev.goals.filter((_, i) => i !== idx)
|
|
|
|
|
|
return { ...prev, goals: g.length ? g : [emptyGoal()] }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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]]
|
|
|
|
|
|
return { ...prev, slots: sl }
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const addSlot = () => setForm((prev) => ({ ...prev, slots: [...prev.slots, emptySlot()] }))
|
|
|
|
|
|
const removeSlot = (idx) =>
|
|
|
|
|
|
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 addExerciseToSlot = (sIdx) => {
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
slots: prev.slots.map((s, i) =>
|
|
|
|
|
|
i === sIdx ? { ...s, exercises: [...(s.exercises || []), emptyExercise()] } : s
|
|
|
|
|
|
),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const moveExercise = (sIdx, eIdx, dir) => {
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
slots: prev.slots.map((s, i) => {
|
|
|
|
|
|
if (i !== sIdx) return s
|
|
|
|
|
|
const j = eIdx + dir
|
|
|
|
|
|
const ex = [...(s.exercises || [])]
|
|
|
|
|
|
if (j < 0 || j >= ex.length) return s
|
|
|
|
|
|
;[ex[eIdx], ex[j]] = [ex[j], ex[eIdx]]
|
|
|
|
|
|
return { ...s, exercises: ex }
|
|
|
|
|
|
}),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const removeExercise = (sIdx, eIdx) => {
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
slots: prev.slots.map((s, i) => {
|
|
|
|
|
|
if (i !== sIdx) return s
|
|
|
|
|
|
return { ...s, exercises: (s.exercises || []).filter((_, ei) => ei !== eIdx) }
|
|
|
|
|
|
}),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const setExerciseChoice = async (sIdx, eIdx, exercise) => {
|
|
|
|
|
|
const vid = ''
|
|
|
|
|
|
let variants = Array.isArray(exercise.variants) ? exercise.variants : []
|
|
|
|
|
|
let title = exercise.title || ''
|
|
|
|
|
|
if (!variants.length) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const full = await api.getExercise(exercise.id)
|
|
|
|
|
|
variants = Array.isArray(full.variants) ? full.variants : []
|
|
|
|
|
|
title = full.title || title
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
variants = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
slots: prev.slots.map((s, i) => {
|
|
|
|
|
|
if (i !== sIdx) return s
|
|
|
|
|
|
const exRows = [...(s.exercises || [])]
|
|
|
|
|
|
const row = exRows[eIdx] || emptyExercise()
|
|
|
|
|
|
exRows[eIdx] = {
|
|
|
|
|
|
...row,
|
|
|
|
|
|
exercise_id: exercise.id,
|
|
|
|
|
|
exercise_variant_id: vid,
|
|
|
|
|
|
exercise_title: title,
|
|
|
|
|
|
variants,
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ...s, exercises: exRows }
|
|
|
|
|
|
}),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const exerciseField = (sIdx, eIdx, key, val) => {
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
slots: prev.slots.map((s, i) => {
|
|
|
|
|
|
if (i !== sIdx) return s
|
|
|
|
|
|
return {
|
|
|
|
|
|
...s,
|
|
|
|
|
|
exercises: (s.exercises || []).map((ex, ei) =>
|
|
|
|
|
|
ei === eIdx ? { ...ex, [key]: val } : ex
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) }
|
|
|
|
|
|
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 11:53:45 +02:00
|
|
|
|
const panelActive = (key) => desktopLayout || frameworkTab === key
|
|
|
|
|
|
|
2026-05-05 09:02:13 +02:00
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
|
|
|
|
<div className="spinner" />
|
|
|
|
|
|
<p>Laden…</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: '2rem' }}>
|
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 }}>
|
|
|
|
|
|
<strong style={{ color: 'var(--text1)' }}>Stand dieser Funktion:</strong> Der Rahmen speichert Ziele, Slots
|
|
|
|
|
|
und Übungslisten — noch <strong>ohne</strong> die feinere{' '}
|
|
|
|
|
|
<strong>Trainingsplan‑Struktur pro Einheit</strong> (Abschnitte wie in der Einheitenplanung) und{' '}
|
|
|
|
|
|
<strong>ohne Übernahme</strong> in die nächste konkrete Trainingseinheit (geplanter Schritt „Warenkorb“).
|
|
|
|
|
|
<strong> Ziele</strong> lassen sich noch nicht einzelnen Einheiten oder Übungen zuordnen;{' '}
|
|
|
|
|
|
<strong>Progressionsketten</strong> aus dem Übungs‑Graph sind hier noch nicht eingebunden — dafür sind
|
|
|
|
|
|
API‑/Daten‑Erweiterungen und ein Folgerelease vorgesehen.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="framework-edit__tabbar" role="tablist" aria-label="Bereiche">
|
|
|
|
|
|
{[
|
|
|
|
|
|
{ id: 'meta', label: 'Stammdaten' },
|
|
|
|
|
|
{ id: 'goals', label: 'Ziele' },
|
|
|
|
|
|
{ id: 'slots', label: 'Slots & Übungen' },
|
|
|
|
|
|
].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' : '')
|
|
|
|
|
|
}
|
|
|
|
|
|
style={{ marginBottom: '1rem' }}
|
|
|
|
|
|
>
|
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">
|
|
|
|
|
|
<label className="form-label">Modus</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={form.plan_mode}
|
|
|
|
|
|
onChange={(e) => updateField('plan_mode', e.target.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="library">Bibliothek (zeitlos, ohne Gruppe)</option>
|
|
|
|
|
|
<option value="concrete">Konkret (optional Gruppe & Verknüpfung zu Einheiten)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<p style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
|
|
|
|
|
Bibliothek: keine Trainingsgruppe und keine Slot‑Zuordnung zu Terminen. Konkret: optional Gruppe wählen und
|
|
|
|
|
|
Slots mit geplanten Trainingseinheiten verknüpfen.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{form.plan_mode === 'concrete' && (
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Trainingsgruppe (optional)</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={form.group_id}
|
|
|
|
|
|
onChange={(e) => updateField('group_id', e.target.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">— keine —</option>
|
|
|
|
|
|
{groups.map((g) => (
|
|
|
|
|
|
<option key={g.id} value={String(g.id)}>
|
|
|
|
|
|
{g.name} ({g.club_name || 'Verein'})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
|
|
|
|
|
<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="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
|
|
|
|
|
<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="framework-edit__goals-slots">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={
|
|
|
|
|
|
'framework-edit__panel framework-edit__panel--goals card' +
|
|
|
|
|
|
(panelActive('goals') ? ' framework-edit__panel--active' : '')
|
|
|
|
|
|
}
|
|
|
|
|
|
style={{ marginBottom: '1rem' }}
|
|
|
|
|
|
>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
|
|
|
|
|
<h3 className="card-title" style={{ marginBottom: 0 }}>
|
|
|
|
|
|
Entwicklungsziele
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={addGoal}>
|
|
|
|
|
|
+ Ziel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{(form.goals || []).map((g, gi) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={gi}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
|
padding: '12px',
|
|
|
|
|
|
marginBottom: '10px',
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => moveGoal(gi, -1)}>
|
|
|
|
|
|
↑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => moveGoal(gi, 1)}>
|
|
|
|
|
|
↓
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => removeGoal(gi)}>
|
|
|
|
|
|
Entfernen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Titel *</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={g.title}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
goals: prev.goals.map((x, i) => (i === gi ? { ...x, title: e.target.value } : x)),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Notizen</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
value={g.notes}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setForm((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
goals: prev.goals.map((x, i) => (i === gi ? { ...x, notes: e.target.value } : x)),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-05 11:53:45 +02:00
|
|
|
|
<div
|
|
|
|
|
|
className={
|
|
|
|
|
|
'framework-edit__panel framework-edit__panel--slots card' +
|
|
|
|
|
|
(panelActive('slots') ? ' framework-edit__panel--active' : '')
|
|
|
|
|
|
}
|
|
|
|
|
|
style={{ marginBottom: '1.5rem' }}
|
|
|
|
|
|
>
|
2026-05-05 09:02:13 +02:00
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
|
|
|
|
|
<h3 className="card-title" style={{ marginBottom: 0 }}>
|
|
|
|
|
|
Session‑Slots & Übungen
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" 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 z. B. „Woche 1 / Einheit A“ an und ordnest Übungen zu.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{form.slots.map((slot, si) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={si}
|
|
|
|
|
|
className="card"
|
|
|
|
|
|
style={{ marginBottom: '12px', background: 'var(--surface)', borderStyle: 'dashed' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '10px' }}>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => moveSlot(si, -1)}>
|
|
|
|
|
|
Slot ↑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => moveSlot(si, 1)}>
|
|
|
|
|
|
Slot ↓
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => removeSlot(si)}>
|
|
|
|
|
|
Slot entfernen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Slot‑Titel</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={slot.title}
|
|
|
|
|
|
onChange={(e) => slotField(si, 'title', e.target.value)}
|
|
|
|
|
|
placeholder="z. B. Woche 2 — Technik"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
{form.plan_mode === 'concrete' && (
|
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
|
<label className="form-label">Trainingseinheit (optional)</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={slot.training_unit_id}
|
|
|
|
|
|
onChange={(e) => slotField(si, 'training_unit_id', e.target.value)}
|
|
|
|
|
|
disabled={!form.group_id}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">— keine —</option>
|
|
|
|
|
|
{units.map((u) => (
|
|
|
|
|
|
<option key={u.id} value={String(u.id)}>
|
|
|
|
|
|
{u.planned_date}
|
|
|
|
|
|
{u.planned_time_start ? ` ${String(u.planned_time_start).slice(0, 5)}` : ''}
|
|
|
|
|
|
{u.planned_focus ? ` · ${u.planned_focus}` : ''}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
{!form.group_id ? (
|
|
|
|
|
|
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
|
|
|
|
|
Wähle oben eine Trainingsgruppe, um geplante Einheiten zu laden.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ marginTop: '12px' }}>
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
|
|
|
|
|
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>Übungen</span>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => addExerciseToSlot(si)}>
|
|
|
|
|
|
+ Übung
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{(slot.exercises || []).length === 0 ? (
|
|
|
|
|
|
<p style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
|
|
|
|
|
|
Über <strong>Übung hinzufügen</strong> auswählen.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{(slot.exercises || []).map((ex, ei) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={ei}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'grid',
|
|
|
|
|
|
gap: '8px',
|
|
|
|
|
|
padding: '10px',
|
|
|
|
|
|
marginBottom: '8px',
|
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => moveExercise(si, ei, -1)}>
|
|
|
|
|
|
↑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => moveExercise(si, ei, 1)}>
|
|
|
|
|
|
↓
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => removeExercise(si, ei)}>
|
|
|
|
|
|
Entfernen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-primary"
|
|
|
|
|
|
onClick={() => setPickerSlotIdx({ slotIdx: si, exerciseIdx: ei })}
|
|
|
|
|
|
>
|
|
|
|
|
|
Übung wählen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{ex.exercise_id ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
onClick={() => setPeekId(Number(ex.exercise_id))}
|
|
|
|
|
|
>
|
|
|
|
|
|
Vorschau
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ fontSize: '0.9rem' }}>
|
|
|
|
|
|
{ex.exercise_id ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<strong>{ex.exercise_title || `Übung #${ex.exercise_id}`}</strong>
|
|
|
|
|
|
<span style={{ color: 'var(--text2)' }}> (ID {ex.exercise_id})</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span style={{ color: 'var(--text3)' }}>Keine Übung gewählt</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{ex.exercise_id && (ex.variants || []).length > 0 ? (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">Variante</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
value={ex.exercise_variant_id}
|
|
|
|
|
|
onChange={(e) => exerciseField(si, ei, 'exercise_variant_id', e.target.value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">— Standard / keine —</option>
|
|
|
|
|
|
{(ex.variants || []).map((v) => (
|
|
|
|
|
|
<option key={v.id} value={String(v.id)}>
|
|
|
|
|
|
{v.variant_name || v.name || `Variante ${v.id}`}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-05-05 11:53:45 +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
|
|
|
|
|
|
open={pickerSlotIdx != null}
|
|
|
|
|
|
onClose={() => setPickerSlotIdx(null)}
|
|
|
|
|
|
onSelectExercise={(exercise) => {
|
|
|
|
|
|
if (!pickerSlotIdx) return
|
|
|
|
|
|
setExerciseChoice(pickerSlotIdx.slotIdx, pickerSlotIdx.exerciseIdx, exercise)
|
|
|
|
|
|
setPickerSlotIdx(null)
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<ExercisePeekModal open={peekId != null} exerciseId={peekId || 0} onClose={() => setPeekId(null)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|