import React, { useCallback, useEffect, 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' /** Unter dieser Breite: 2 Tabs (Stammdaten | Plan); darüber: alles untereinander */ const FRAMEWORK_DESKTOP_MIN_PX = 900 const DND_FW_EX = 'application/x-shinkan-framework-exercise' const DND_FW_SLOT = 'application/x-shinkan-framework-slot' 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 emptyGoal() { return { title: '', notes: '' } } function emptyExercise() { return { exercise_id: '', exercise_variant_id: '', exercise_title: '', variants: [] } } function emptySlot() { return { title: '', notes: '', training_unit_id: '', exercises: [] } } /** Native-Tooltip für Ziel-Chips (Hover); kurz halten für OS-Tooltip-Limits */ 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: '', 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() { 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 [groups, setGroups] = useState([]) const [clubs, setClubs] = useState([]) const [units, setUnits] = useState([]) const [pickerSlotIdx, setPickerSlotIdx] = useState(null) const [peekId, setPeekId] = useState(null) const [editingGoalIdx, setEditingGoalIdx] = useState(null) const [goalMenuGi, setGoalMenuGi] = useState(null) const [exerciseMenu, setExerciseMenu] = 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 ) 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 setExerciseMenu(null) setGoalMenuGi(null) } document.addEventListener('pointerdown', onPointerDown, true) return () => document.removeEventListener('pointerdown', onPointerDown, true) }, []) 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 } 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 enrichSlotExercisesWithVariants(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) => { const n = { ...prev, [key]: val } if (key === 'plan_mode' && val === 'library') { n.group_id = '' } return n }) } 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]] 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 { const fid = parseInt(idParam, 10) 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 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 insertExerciseBefore = (fromS, fromE, toS, toE) => { setForm((prev) => { const slots = prev.slots.map((s) => ({ ...s, exercises: [...(s.exercises || [])] })) if (fromS < 0 || fromS >= slots.length || toS < 0 || toS >= slots.length) return prev const fromList = slots[fromS].exercises if (fromE < 0 || fromE >= fromList.length) return prev const [moved] = fromList.splice(fromE, 1) let insertAt = toE if (fromS === toS && fromE < toE) insertAt -= 1 insertAt = Math.max(0, Math.min(insertAt, slots[toS].exercises.length)) slots[toS].exercises.splice(insertAt, 0, moved) return { ...prev, slots } }) } const appendExerciseToSlot = (fromS, fromE, toS) => { setForm((prev) => { const slots = prev.slots.map((s) => ({ ...s, exercises: [...(s.exercises || [])] })) if (fromS < 0 || fromS >= slots.length || toS < 0 || toS >= slots.length) return prev const fromList = slots[fromS].exercises if (fromE < 0 || fromE >= fromList.length) return prev const [moved] = fromList.splice(fromE, 1) slots[toS].exercises.push(moved) return { ...prev, slots } }) } const onExerciseDragStart = (e, fromS, fromE) => { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData(DND_FW_EX, JSON.stringify({ fromS, fromE })) setExerciseMenu(null) } const onExerciseDragOver = (e) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' } 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) { const { slotIdx } = JSON.parse(slotRaw) if (slotIdx !== targetSi) { setForm((prev) => ({ ...prev, slots: reorderArray([...prev.slots], slotIdx, targetSi) })) } return } const exRaw = e.dataTransfer.getData(DND_FW_EX) if (exRaw) { const { fromS, fromE } = JSON.parse(exRaw) appendExerciseToSlot(fromS, fromE, 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' } if (loading) { return (

Laden…

) } return (

← Alle Rahmenprogramme

{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}

Stand dieser Funktion: Der Rahmen speichert Ziele, Slots und pro Slot eine Übungsliste (Stückliste). Eine volle Einheiten‑Struktur wie in der Trainingsplanung (Abschnitte, Notizen, Mikrovorlage pro Slot) ist im Konzept optional ( CURR‑010: training_plan_template_id pro Slot) — in der DB derzeit{' '} noch nicht umgesetzt.{' '} Übernahme in konkrete Trainingseinheiten mit Referenz auf den Rahmen (Kopie, editierbar){' '} ist Stufe 3 / Lineage vorgesehen. Die Slot‑Spalten unten sind die geplanten Session‑Positionen ohne feste Termine am Rahmen.

{[ { id: 'meta', label: 'Stammdaten' }, { id: 'plan', label: 'Plan (Ziele & Sessions)' }, ].map((t) => ( ))}

Stammdaten

updateField('title', e.target.value)} placeholder="z. B. Vorbereitung Gürtelprüfung — 8 Wochen" />