shinkan-jinkendo/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
Lars bfaf532ab2
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s
feat(training-units): enhance section editing with insert functionality
- Added new CSS styles for insert slots and buttons to improve UI for adding items between sections.
- Implemented functionality in the TrainingUnitSectionsEditor to allow users to insert notes and exercises at specified positions within sections.
- Updated the TrainingFrameworkProgramEditPage to support the new insert functionality, ensuring seamless integration with existing features.
- Enhanced state management to handle insert positions effectively, improving user experience during section editing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:05:22 +02:00

1153 lines
41 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

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

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

import React, { useCallback, useEffect, useMemo, 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 {
defaultSection,
normalizeUnitToForm,
enrichSectionsWithVariants,
buildSectionsPayload,
hydrateExercisePlanningRow,
} 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 sec = normalizeUnitToForm({ sections: s.sections, exercises: s.exercises })
out.push({
...s,
sections: await enrichSectionsWithVariants(sec),
})
}
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 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 }),
})),
}
}
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 sectionsPayload = buildSectionsPayload(secList)
return {
sort_order: si,
title: (s.title || '').trim() || null,
notes: (s.notes || '').trim() || null,
sections: sectionsPayload,
}
})
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)
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) {
alert(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 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 {
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)
}
} catch (e) {
alert(e.message || 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
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) {
alert(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(
({ 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 }
})
},
[]
)
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}
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 SessionSlots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
<strong>GruppenPlanung</strong> (Übernahme). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>.
</div>
</details>
<div className="framework-edit__tabbar">
<PageSectionNav
ariaLabel="Bereiche"
value={frameworkTab}
onChange={setFrameworkTab}
items={[
{ id: 'meta', label: 'Stammdaten' },
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
]}
className="page-section-nav--embedded framework-edit__section-nav"
/>
</div>
<div
className={
'framework-edit__panel framework-edit__panel--meta card' +
(panelActive('meta') ? ' framework-edit__panel--active' : '')
}
style={{ marginBottom: '1rem', ...(panelVisibilityStyle('meta') || {}) }}
>
<h3 className="card-title">Stammdaten</h3>
<div className="form-row">
<label className="form-label">Titel *</label>
<input
className="form-input"
value={form.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="z.B. Vorbereitung Gürtelprüfung — 8 Wochen"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich (optional)</label>
<select
className="form-input"
value={form.focus_area_id}
onChange={(e) => updateField('focus_area_id', e.target.value)}
>
<option value=""> keiner </option>
{focusAreas.map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{fa.name}
</option>
))}
</select>
<p className="form-sub">Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.</p>
</div>
<div className="form-row">
<label className="form-label">Stilrichtung (optional)</label>
<select
className="form-input"
value={form.style_direction_id}
onChange={(e) => updateField('style_direction_id', e.target.value)}
>
<option value=""> keine </option>
{styleDirections.map((sd) => (
<option key={sd.id} value={String(sd.id)}>
{sd.name}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Trainingsarten (optional, Mehrfachwahl)</label>
<div className="framework-catalog-checkgrid">
{trainingTypesFiltered.length === 0 ? (
<p className="form-sub" style={{ marginTop: 0 }}>
Keine Einträge im Katalog oder Fokusbereich wählen, um zu filtern.
</p>
) : (
trainingTypesFiltered.map((t) => (
<label key={t.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(form.training_type_ids || []).includes(String(t.id))}
onChange={() => toggleTrainingTypeId(t.id)}
/>
<span>{t.name}</span>
</label>
))
)}
</div>
</div>
<div className="form-row">
<label className="form-label">Zielgruppen (optional, Mehrfachwahl)</label>
<div className="framework-catalog-checkgrid">
{targetGroupsCatalog.length === 0 ? (
<p className="form-sub" style={{ marginTop: 0 }}>
Keine Zielgruppen im Katalog.
</p>
) : (
targetGroupsCatalog.map((tg) => (
<label key={tg.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(form.target_group_ids || []).includes(String(tg.id))}
onChange={() => toggleTargetGroupId(tg.id)}
/>
<span>{tg.name}</span>
</label>
))
)}
</div>
</div>
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
<div>
<label className="form-label">Zeitraum von</label>
<input
type="date"
className="form-input"
value={form.planned_period_start}
onChange={(e) => updateField('planned_period_start', e.target.value)}
/>
</div>
<div>
<label className="form-label">Zeitraum bis</label>
<input
type="date"
className="form-input"
value={form.planned_period_end}
onChange={(e) => updateField('planned_period_end', e.target.value)}
/>
</div>
</div>
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={form.visibility}
onChange={(e) => updateField('visibility', e.target.value)}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
</div>
<div>
<label className="form-label">Verein (optional)</label>
<select
className="form-input"
value={form.club_id}
onChange={(e) => updateField('club_id', e.target.value)}
>
<option value=""> keiner </option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
</div>
</div>
</div>
<div
className={
'framework-edit__panel framework-edit__panel--plan' +
(panelActive('plan') ? ' framework-edit__panel--active' : '')
}
style={{ marginBottom: '1rem', ...(panelVisibilityStyle('plan') || {}) }}
>
<div className="framework-edit__plan-stack">
<div className="card framework-plan-goals">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.6rem',
}}
>
<h3 className="card-title" style={{ marginBottom: 0 }}>
Entwicklungsziele
</h3>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addGoal}>
+ Ziel
</button>
</div>
<p className="framework-goal-chips__hint">
Ziele als Tags Tippen zum Bearbeiten; am Desktop zeigt der <strong>Titel</strong>-Tooltip die Notizen mit. Am
Handy: <strong></strong> oder Bearbeiten.
</p>
<div className="framework-goal-chips">
{(form.goals || []).map((g, gi) => (
<div key={gi} className="framework-popmenu-anchor framework-goal-chip-wrap">
<button
type="button"
className={
'framework-goal-chip' + (editingGoalIdx === gi ? ' framework-goal-chip--active' : '')
}
title={goalHoverText(g)}
onClick={() => {
setEditingGoalIdx(gi)
setGoalMenuGi(null)
}}
>
<span className="framework-goal-chip__text">
{(g.title || '').trim() || `Ziel ${gi + 1}`}
</span>
</button>
<button
type="button"
className="framework-goal-chip__kebab"
aria-label="Ziel-Menü"
aria-expanded={goalMenuGi === gi}
onClick={(e) => {
e.stopPropagation()
setGoalMenuGi((prev) => (prev === gi ? null : gi))
}}
>
</button>
{goalMenuGi === gi ? (
<ul className="framework-popmenu" role="menu">
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
setEditingGoalIdx(gi)
setGoalMenuGi(null)
}}
>
Bearbeiten
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveGoal(gi, -1)
setGoalMenuGi(null)
}}
>
Nach vorn in der Liste
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveGoal(gi, 1)
setGoalMenuGi(null)
}}
>
Nach hinten in der Liste
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item framework-popmenu__item--danger"
onClick={() => removeGoal(gi)}
>
Entfernen
</button>
</li>
</ul>
) : null}
</div>
))}
</div>
{editingGoalIdx != null && form.goals[editingGoalIdx] != null ? (
<div className="framework-goal-editor">
<div className="form-row">
<label className="form-label">Titel *</label>
<input
className="form-input"
value={form.goals[editingGoalIdx].title}
onChange={(e) =>
setForm((prev) => ({
...prev,
goals: prev.goals.map((x, i) =>
i === editingGoalIdx ? { ...x, title: e.target.value } : x
),
}))
}
/>
</div>
<div className="form-row" style={{ marginBottom: 10 }}>
<label className="form-label">Notizen</label>
<textarea
className="form-input"
rows={2}
value={form.goals[editingGoalIdx].notes}
onChange={(e) =>
setForm((prev) => ({
...prev,
goals: prev.goals.map((x, i) =>
i === editingGoalIdx ? { ...x, notes: e.target.value } : x
),
}))
}
/>
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => setEditingGoalIdx(null)}
>
Fertig
</button>
</div>
) : null}
</div>
<div className="card framework-plan-slots" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 className="card-title" style={{ marginBottom: 0 }}>
SessionSlots & Ablauf
</h3>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addSlot}>
+ Slot
</button>
</div>
{form.slots.length === 0 ? (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
Noch keine Slots mit <strong>+ Slot</strong> legst du Spalten an (z.B. Woche 1). In jedem Slot
strukturierst du wie in der Trainingsplanung: Abschnitte, Übungen, Anmerkungen.
</p>
) : (
<p
className="framework-slots-hint"
style={{ fontSize: '0.8rem', color: 'var(--text3)', marginBottom: '10px', lineHeight: 1.45 }}
>
{desktopLayout ? (
<>
Reihenfolge der Spalten per Griff () ziehen oder mit / verschieben; Inhalt eines Slots wie
in der Planung bearbeiten (Abschnitte, Übungen, Anmerkungen).
</>
) : (
<>
Sessions oben oder unten per Chips wählen ein Slot nutzt die volle Breite. Reihenfolge mit /;
bearbeiten wie in der Planung.
</>
)}
</p>
)}
{form.slots.length > 0 ? (
!desktopLayout ? (
<div className="framework-slots-board-outer framework-slots-board-outer--mobile-single">
<div
className="framework-slot-chips-bar framework-slot-chips-bar--top"
role="tablist"
aria-label="Sessions"
>
{slotChipButtons({ tabSemantics: true })}
</div>
<div className="framework-slot-mobile-panel" tabIndex={-1}>
{renderFrameworkSlotCard(mobileSlotIdx)}
</div>
<div
className="framework-slot-chips-bar framework-slot-chips-bar--bottom"
role="group"
aria-label="Sessions (Anwahl unten)"
>
{slotChipButtons({ tabSemantics: false })}
</div>
</div>
) : (
<div className="framework-slots-board-outer framework-slots-board-outer--desktop">
<div className="framework-slots-board framework-slots-board--desktop-wide">
{form.slots.map((_, si) => renderFrameworkSlotCard(si))}
</div>
</div>
)
) : null}
</div>
</div>
</div>
<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
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId || 0}
variantId={peekCtx?.variantId ?? undefined}
onClose={() => setPeekCtx(null)}
/>
</div>
)
}