shinkan-jinkendo/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx

1141 lines
41 KiB
React
Raw Normal View History

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}
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 }) =>
setSectionPickerCtx({
slotIdx: si,
sectionIndex,
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
})
}
onPeekExercise={(id, variantId) =>
setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
}
/>
</div>
</div>
)
}
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner" />
<p>Laden…</p>
</div>
)
}
return (
<div className="app-page">
<div className="framework-edit">
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/framework-programs" style={{ color: 'var(--accent-dark)' }}>
← Alle Rahmenprogramme
</Link>
</p>
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
<details className="framework-edit-intro">
<summary className="framework-edit-intro__summary">
Kurz erklärt: Was ist ein Rahmenprogramm?
</summary>
<div className="framework-edit-intro__body">
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
Zielen und Session‑Slots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
<strong>Gruppen‑Planung</strong> („Übernahme“). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>Zwischen‑Anmerkungen</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 }}>
Session‑Slots & Ablauf
</h3>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={addSlot}>
+ Slot
</button>
</div>
{form.slots.length === 0 ? (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
Noch keine Slots — mit <strong>+ Slot</strong> legst du Spalten an (z. B. „Woche 1“). In jedem Slot
strukturierst du wie in der Trainingsplanung: Abschnitte, Übungen, Anmerkungen.
</p>
) : (
<p
className="framework-slots-hint"
style={{ fontSize: '0.8rem', color: 'var(--text3)', marginBottom: '10px', lineHeight: 1.45 }}
>
{desktopLayout ? (
<>
Reihenfolge der Spalten per Griff (⋮⋮) ziehen oder mit ↑ / ↓ verschieben; Inhalt eines Slots wie
in der Planung bearbeiten (Abschnitte, Übungen, Anmerkungen).
</>
) : (
<>
Sessions oben oder unten per Chips wählen — ein Slot nutzt die volle Breite. Reihenfolge mit ↑ / ↓;
bearbeiten wie in der Planung.
</>
)}
</p>
)}
{form.slots.length > 0 ? (
!desktopLayout ? (
<div className="framework-slots-board-outer framework-slots-board-outer--mobile-single">
<div
className="framework-slot-chips-bar framework-slot-chips-bar--top"
role="tablist"
aria-label="Sessions"
>
{slotChipButtons({ tabSemantics: true })}
</div>
<div className="framework-slot-mobile-panel" tabIndex={-1}>
{renderFrameworkSlotCard(mobileSlotIdx)}
</div>
<div
className="framework-slot-chips-bar framework-slot-chips-bar--bottom"
role="group"
aria-label="Sessions (Anwahl unten)"
>
{slotChipButtons({ tabSemantics: false })}
</div>
</div>
) : (
<div className="framework-slots-board-outer framework-slots-board-outer--desktop">
<div className="framework-slots-board framework-slots-board--desktop-wide">
{form.slots.map((_, si) => renderFrameworkSlotCard(si))}
</div>
</div>
)
) : null}
</div>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'center' }}>
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave}>
{saving ? 'Speichern…' : isNew ? 'Anlegen' : 'Speichern'}
</button>
<Link to="/planning/framework-programs" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Abbrechen
</Link>
{!isNew ? (
<button type="button" className="btn btn-secondary" onClick={handleDelete}>
Löschen
</button>
) : null}
</div>
</div>
<ExercisePickerModal
open={sectionPickerCtx != null}
multiSelect
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 } = 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 }
}
return { ...sec, items: [...items, ...rows] }
}),
}
}),
}))
setSectionPickerCtx(null)
}}
/>
<ExercisePeekModal
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId || 0}
variantId={peekCtx?.variantId ?? undefined}
onClose={() => setPeekCtx(null)}
/>
</div>
)
}