import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { GripVertical, Pencil, X } from 'lucide-react' import CombinationMethodProfileEditor from './CombinationMethodProfileEditor' import CombinationPlanBracket from './CombinationPlanBracket' import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' import { cloneJsonSerializablePlanningProfile, comboSlotsOutlineForProfileEditor, defaultSection, defaultPlanLocWholeGroup, defaultPlanLocParallel, maxPhaseOrderIndexFromSections, buildPlanTargetOptions, planLocKey, MAX_PARALLEL_STREAMS_PER_PHASE, parallelStreamVisual, streamsForParallelPhaseOrders, sectionIndicesForParallelStream, reorderWithinBucketIndices, reorderWithoutIndices, parallelStreamBucketHasContent, dissolveParallelPhaseToWholeGroup, phaseRunsFromSections, swapAdjacentPhaseRuns, reorderBlocksImmutableWithPlanLoc, reorderSectionBeforeParallelRunAsWholeGroup, reorderSectionAsFirstInParallelPhase, reorderBlockIntoParallelStreamEnd, globalInsertBeforeIndexForParallelStreamEnd, movePhaseRunUpByPhaseOrder, movePhaseRunDownByPhaseOrder, moveParallelPhaseRunToInsertBefore, afterSectionReorderParallelGuard, indicesOfParallelPhase, exerciseRow, noteRow, sectionPlannedMinutes, } from '../utils/trainingUnitSectionsForm' import api from '../utils/api' import { isCompactTagLegendMode } from '../config/planningModuleUx' import { useAuth } from '../context/AuthContext' function stripPlanLocFromSection(s) { if (!s || typeof s !== 'object') return s const { planLoc: _ignored, ...rest } = s return rest } function planSelectOptionsForSection(sections, sIdx, baseOpts) { const sec = sections[sIdx] const k = planLocKey(sec?.planLoc) if (k && !baseOpts.some((o) => o.key === k)) { const pl = sec.planLoc const label = pl.phaseKind === 'parallel' ? `Parallel · Phase ${pl.phaseOrderIndex ?? 0} · Stream ${pl.parallelStreamOrderIndex ?? 0}` : `Ganzgruppe · Phase ${pl.phaseOrderIndex ?? 0}` return [...baseOpts, { key: k, label, template: { ...pl } }].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }) ) } return baseOpts } const DND_TU_ITEM = 'application/x-shinkan-training-unit-item' const DND_TU_SECTION = 'application/x-shinkan-training-section-v1' /** Optische Trennlinie: wird als normale Zwischen-Anmerkung gespeichert (Inhalt nur dieser Marker). */ const SECTION_INSERT_SEPARATOR_BODY = '---' function normalizedPlanningModuleChainId(raw) { if (raw == null || raw === '') return null const n = typeof raw === 'number' ? raw : Number(raw) return Number.isFinite(n) && n >= 1 ? n : null } function dtHasType(e, mime) { const t = e?.dataTransfer?.types if (!t || !mime) return false if (typeof t.contains === 'function' && t.contains(mime)) return true return Array.from(t).includes(mime) } /** Visuelle Zuordnung der Einfügezeile zu Split- vs. Ganzgruppen-Bereich (nur Darstellung). */ function sectionDropBandRegionClass(sections, beforeIdx, enableParallel) { if (!enableParallel) return '' const n = sections?.length ?? 0 const below = beforeIdx < n ? sections[beforeIdx] : null const above = beforeIdx > 0 ? sections[beforeIdx - 1] : null const po = (s) => s?.planLoc?.phaseOrderIndex ?? 0 const aboveP = above?.planLoc?.phaseKind === 'parallel' const belowP = below?.planLoc?.phaseKind === 'parallel' const aboveW = above?.planLoc?.phaseKind === 'whole_group' const belowW = below?.planLoc?.phaseKind === 'whole_group' if (aboveP && belowP && po(above) === po(below)) { return ' tu-section-dropband--region-split' } if (aboveW && belowW) return ' tu-section-dropband--region-whole' if (aboveP && belowW) return ' tu-section-dropband--region-split-to-whole' if (aboveW && belowP) return ' tu-section-dropband--region-whole-to-split' if (!above && belowP) return ' tu-section-dropband--region-split' if (!below && aboveP) return ' tu-section-dropband--region-split-to-whole' if (!above && belowW) return ' tu-section-dropband--region-whole' if (!below && aboveW) return ' tu-section-dropband--region-whole' return ' tu-section-dropband--region-neutral' } function truncatePreview(text, max = 160) { const t = (text || '').replace(/\s+/g, ' ').trim() if (t.length <= max) return t return `${t.slice(0, max - 1)}…` } /** Liest den zusammenhängenden Lauf eines Moduls im Abschnitt (ab erstem Item mit dieser Herkunfts-ID). */ function gatherPlanningModuleOutline(items, startIdx, moduleId) { const exercises = [] let notes = 0 for (let j = startIdx; j < (items?.length ?? 0); j++) { const row = items[j] if (normalizedPlanningModuleChainId(row.source_training_module_id) !== moduleId) break if (row.item_type === 'note') { const bod = (row.note_body || '').trim() if (bod === SECTION_INSERT_SEPARATOR_BODY) continue notes += 1 continue } const t = (row.exercise_title || '').trim() || (row.exercise_id ? `Übung #${row.exercise_id}` : 'Übung') exercises.push(t) } return { exercises, notes } } const MODULE_OUTLINE_PREVIEW_MAX = 8 /** Statuszeile: nur Planungs‑Override vs. Katalog (Archetyp steht bereits links). */ function compactComboPlanningCaption(it) { const overridden = it.planning_method_profile != null && typeof it.planning_method_profile === 'object' && !Array.isArray(it.planning_method_profile) return overridden ? 'Planung angepasst' : 'wie Katalog' } /** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */ function planningModulePalette(moduleId) { const id = normalizedPlanningModuleChainId(moduleId) const n = id != null && id >= 1 ? Math.floor(Number(id)) : 1 const golden = ((n >>> 0) * 2654435761 + n * 73856093) >>> 0 const h = golden % 360 const border = `hsl(${h} 52% 36%)` const soft = `hsl(${h} 42% 94%)` return { border, soft, hue: h } } function PlanningModuleRowTag({ moduleId, title }) { const p = planningModulePalette(moduleId) const lbl = truncatePreview(title || `Modul #${moduleId}`, 34).trim() const fullTitle = ((title || '').trim() || `Modul #${moduleId}`).trim() return ( Aus Modul: {lbl} ) } /** Eindeutige Module im Abschnitt mit Zählerständen für die Legende. */ function sectionModuleLegendModel(items) { const map = new Map() for (const row of items || []) { const id = normalizedPlanningModuleChainId(row.source_training_module_id) if (id == null) continue if (!map.has(id)) { map.set(id, { id, title: (((row.source_module_title || '').trim() || '') || `Modul #${id}`).trim(), exercises: 0, notes: 0, }) } const agg = map.get(id) if ((row.item_type || '') === 'note') { const bod = ((row.note_body || '').trim() || '').trim() if (bod === SECTION_INSERT_SEPARATOR_BODY) continue agg.notes += 1 } else { agg.exercises += 1 } } return [...map.values()].sort((a, b) => a.id - b.id) } function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) { if (!showModuleBand || !modOutline) return null return (
Aus Modul {modBandTitle} {modOutline.exercises.length === 0 && modOutline.notes === 0 ? (

Ohne strukturierten Inhalt angezeigt.

) : (
    {modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
  1. {tx}
  2. ))}
)} {modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (

… und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '} {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}

) : null} {modOutline.notes > 0 ? (

sowie {modOutline.notes}{' '} {modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}

) : null}
) } function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) { const b = [...blocks] if (fromI < 0 || fromI >= b.length) return blocks const [moved] = b.splice(fromI, 1) let insertAt = toBeforeIdx if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1 insertAt = Math.max(0, Math.min(insertAt, b.length)) b.splice(insertAt, 0, moved) return b } /** * @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState * @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number, toParallelStream?: { po: number, so: number } }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben */ export default function TrainingUnitSectionsEditor({ sections, onSectionsChange, onRequestExercisePick, onRequestTrainingModulePick, onPeekExercise, showExecutionExtras = false, heading = 'Abschnitte & Übungen', hideHeading = false, headingAccessory = null, wideExerciseGrid = false, enableItemDragReorder = true, enableSectionDragReorder = true, slotIndex = null, onMoveSectionsAcrossSlots = null, /** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */ betweenInsertMenus = true, /** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */ enableParallelPhaseControls = false, }) { const { user } = useAuth() const planningCompactLegend = isCompactTagLegendMode( user?.training_planning_prefs?.module_display_mode ) const ensure = (prev) => prev && prev.length ? prev : [defaultSection()] const patch = useCallback( (updater) => { onSectionsChange((prev) => updater(ensure(prev))) }, [onSectionsChange] ) const sectionToSlot = slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1 const list = ensure(sections) const useStreamTagDropUx = enableSectionDragReorder && enableParallelPhaseControls && list.some((s) => s?.planLoc?.phaseKind === 'parallel') const updateSectionField = (sIdx, field, val) => { patch((prev) => prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s)) ) } const addSection = () => { patch((prev) => { const base = defaultSection(`Abschnitt ${prev.length + 1}`) const last = prev[prev.length - 1] const next = last?.planLoc ? { ...base, planLoc: { ...last.planLoc } } : base return [...prev, next] }) } const addParallelPhaseTwoStreams = () => { patch((prev) => { const nextPo = maxPhaseOrderIndexFromSections(prev) + 1 const pl0 = defaultPlanLocParallel(nextPo, 0) const pl1 = defaultPlanLocParallel(nextPo, 1) const base0 = defaultSection(`Abschnitt ${prev.length + 1}`) const base1 = defaultSection(`Abschnitt ${prev.length + 2}`) return [...prev, { ...base0, planLoc: pl0 }, { ...base1, planLoc: pl1 }] }) } const addStreamToParallelPhase = (phaseOrder) => { patch((prev) => { const po = Number(phaseOrder) || 0 const par = (prev || []).filter( (s) => s?.planLoc?.phaseKind === 'parallel' && (s.planLoc.phaseOrderIndex ?? 0) === po ) if (!par.length) return prev const distinct = new Set(par.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) if (distinct.size >= MAX_PARALLEL_STREAMS_PER_PHASE) return prev const maxS = Math.max(...par.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) const newSo = maxS + 1 const tmpl = { ...par[0].planLoc, parallelStreamOrderIndex: newSo, streamTitle: null, streamNotes: null, streamAssignedTrainerProfileIds: null, } const base = defaultSection(`Abschnitt ${prev.length + 1}`) const inPhaseIdx = indicesOfParallelPhase(prev, po) const insertAfter = inPhaseIdx.length ? Math.max(...inPhaseIdx) : prev.length - 1 return [ ...prev.slice(0, insertAfter + 1), { ...base, planLoc: tmpl }, ...prev.slice(insertAfter + 1), ] }) } const addWholeGroupSection = () => { patch((prev) => { const L = ensure(prev) const wgs = L.filter((s) => s?.planLoc?.phaseKind === 'whole_group') let pl if (wgs.length) { const maxPo = Math.max(...wgs.map((s) => s.planLoc.phaseOrderIndex ?? 0)) const sample = wgs.find((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxPo) pl = { ...sample.planLoc } } else { const nextPo = maxPhaseOrderIndexFromSections(L) + 1 pl = defaultPlanLocWholeGroup(nextPo) } const base = defaultSection(`Abschnitt ${L.length + 1}`) return [...L, { ...base, planLoc: pl }] }) } const addSectionToParallelStream = (phaseOrder, streamOrder) => { patch((prev) => { const L = ensure(prev) const po = Number(phaseOrder) || 0 const so = Number(streamOrder) || 0 const idxs = sectionIndicesForParallelStream(L, po, so) const tmpl = idxs.length ? L[idxs[0]].planLoc : defaultPlanLocParallel(po, so) const pl = { ...tmpl, phaseKind: 'parallel', phaseOrderIndex: po, parallelStreamOrderIndex: so, } const base = defaultSection(`Abschnitt ${L.length + 1}`) if (!idxs.length) { return [...L, { ...base, planLoc: pl }] } const insertAfter = Math.max(...idxs) return [...L.slice(0, insertAfter + 1), { ...base, planLoc: pl }, ...L.slice(insertAfter + 1)] }) } const updateParallelPhaseTitleAll = (phaseOrder, title) => { const po = Number(phaseOrder) || 0 const v = title.trim() ? title.trim() : null patch((prev) => prev.map((s) => { const L = s?.planLoc if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s return { ...s, planLoc: { ...L, phaseTitle: v } } }) ) } const updateParallelStreamTitleAll = (phaseOrder, streamOrder, title) => { const po = Number(phaseOrder) || 0 const so = Number(streamOrder) || 0 const v = title.trim() ? title.trim() : null patch((prev) => prev.map((s) => { const L = s?.planLoc if ( L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po || (L.parallelStreamOrderIndex ?? 0) !== so ) { return s } return { ...s, planLoc: { ...L, streamTitle: v } } }) ) } const removeParallelStream = (phaseOrder, streamOrder) => { const po = Number(phaseOrder) || 0 const so = Number(streamOrder) || 0 const idxs = sectionIndicesForParallelStream(list, po, so) if (!idxs.length) return if ( parallelStreamBucketHasContent(list, idxs, SECTION_INSERT_SEPARATOR_BODY) && !window.confirm( 'In diesem Stream sind Übungen oder Anmerkungen geplant. Stream wirklich löschen?' ) ) { return } patch((prev) => { const L = ensure(prev) const beforeOrders = streamsForParallelPhaseOrders(L, po) const rm = sectionIndicesForParallelStream(L, po, so) if (!rm.length) return prev let next = reorderWithoutIndices(L, rm) const afterOrders = streamsForParallelPhaseOrders(next, po) if (beforeOrders.length >= 2 && afterOrders.length <= 1) { if ( window.confirm( 'Nur noch eine Gruppe in dieser Phase übrig. Parallelen Aufbau auflösen und alle Abschnitte als gemeinsame Ganzgruppen-Phase weiterführen?' ) ) { next = dissolveParallelPhaseToWholeGroup(next, po) } } return next }) } const applySectionPlanTarget = (sIdx, rawKey) => { patch((prev) => { if (!rawKey) { return prev.map((s, i) => (i === sIdx ? stripPlanLocFromSection(s) : s)) } const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev)) const hit = opts.find((o) => o.key === rawKey) if (!hit) return prev const tpl = { ...hit.template } return prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s)) }) } const removeSection = (sIdx) => { patch((prev) => { let next = prev.filter((_, i) => i !== sIdx) next = next.length ? next : [defaultSection()] if (enableParallelPhaseControls) { next = afterSectionReorderParallelGuard(prev, next) } return next }) } /** Ganzgruppe: global tauschen; parallele Phase: innerhalb Stream oder ganze Parallel-Phase am Stück */ const moveSection = (sIdx, dir) => { patch((prev) => { const p = ensure(prev) const sec = p[sIdx] const L = sec?.planLoc if (L?.phaseKind === 'parallel') { const po = L.phaseOrderIndex ?? 0 const so = L.parallelStreamOrderIndex ?? 0 const bucket = sectionIndicesForParallelStream(p, po, so) const pos = bucket.indexOf(sIdx) if (pos < 0) return p if (dir < 0 && pos > 0) { const newPos = pos + dir return reorderWithinBucketIndices(p, bucket, pos, newPos) } if (dir > 0 && pos < bucket.length - 1) { const newPos = pos + dir return reorderWithinBucketIndices(p, bucket, pos, newPos) } if (dir < 0 && pos === 0) { const runs = phaseRunsFromSections(p) const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po) if (rIdx <= 0) return p return swapAdjacentPhaseRuns(p, rIdx - 1) } if (dir > 0 && pos === bucket.length - 1) { const runs = phaseRunsFromSections(p) const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po) if (rIdx < 0 || rIdx >= runs.length - 1) return p return swapAdjacentPhaseRuns(p, rIdx) } return p } const arr = [...p] const ta = sIdx + dir if (ta < 0 || ta >= arr.length) return arr ;[arr[sIdx], arr[ta]] = [arr[ta], arr[sIdx]] return arr }) } const insertItemAt = useCallback( (sIdx, beforeIx, row) => { patch((prev) => prev.map((s, i) => { if (i !== sIdx) return s const items = [...(s.items || [])] const ix = Math.max( 0, Math.min(Number(beforeIx) || 0, items.length) ) items.splice(ix, 0, row) return { ...s, items } }) ) }, [patch] ) const addItem = (sIdx, kind) => { patch((prev) => prev.map((s, i) => { if (i !== sIdx) return s const items = [...(s.items || [])] items.push(kind === 'note' ? noteRow() : exerciseRow()) return { ...s, items } }) ) } const removeItem = (sIdx, iIdx) => { patch((prev) => prev.map((s, si) => si !== sIdx ? s : { ...s, items: (s.items || []).filter((_, ii) => ii !== iIdx) } ) ) } const moveItem = (sIdx, iIdx, dir) => { patch((prev) => prev.map((s, si) => { if (si !== sIdx) return s const items = [...(s.items || [])] const ta = iIdx + dir if (ta < 0 || ta >= items.length) return s ;[items[iIdx], items[ta]] = [items[ta], items[iIdx]] return { ...s, items } }) ) } const updateItem = (sIdx, iIdx, field, val) => { patch((prev) => prev.map((s, si) => si !== sIdx ? s : { ...s, items: (s.items || []).map((row, ii) => ii === iIdx ? { ...row, [field]: val } : row ), } ) ) } const [textEdit, setTextEdit] = useState(null) /** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */ const [comboPlanningModal, setComboPlanningModal] = useState(null) /** Katalog-Stationen, falls Zeile noch keine `combination_slots` (vor Enrich o. Ä.) */ const [modalComboSlotsFetched, setModalComboSlotsFetched] = useState(null) /** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */ const [insertChooser, setInsertChooser] = useState(null) const [draggingPos, setDraggingPos] = useState(null) const [dropTargetPos, setDropTargetPos] = useState(null) const [dropSectionBand, setDropSectionBand] = useState(null) /** Aktiver Reiter pro paralleler Phase (phaseOrder → streamOrder). */ const [parallelStreamTabByPhase, setParallelStreamTabByPhase] = useState({}) /** `${phaseOrder}:${streamOrder}` während Stream-Name bearbeitet wird */ const [streamNameEditKey, setStreamNameEditKey] = useState(null) const [streamNameDraft, setStreamNameDraft] = useState('') const [phaseTitleEditPo, setPhaseTitleEditPo] = useState(null) const [phaseTitleDraft, setPhaseTitleDraft] = useState('') const skipStreamNameBlurSave = useRef(false) const skipPhaseTitleBlurSave = useRef(false) /** { slot: number, beforeIdx: number } */ useEffect(() => { if (!textEdit) return const onKey = (e) => { if (e.key === 'Escape') setTextEdit(null) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [textEdit]) useEffect(() => { if (!insertChooser) return const onKey = (e) => { if (e.key === 'Escape') setInsertChooser(null) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [insertChooser]) useEffect(() => { if (!comboPlanningModal) return const onKey = (e) => { if (e.key === 'Escape') setComboPlanningModal(null) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [comboPlanningModal]) useEffect(() => { if (!comboPlanningModal) return const L = ensure(sections) const { sIdx, iIdx } = comboPlanningModal const row = L[sIdx]?.items?.[iIdx] const ok = row && String(row.exercise_kind || '').toLowerCase().trim() === 'combination' && row.exercise_id if (!ok) setComboPlanningModal(null) }, [sections, comboPlanningModal]) useEffect(() => { if (!comboPlanningModal) { setModalComboSlotsFetched(null) return } const L = ensure(sections) const { sIdx, iIdx } = comboPlanningModal const cand = L[sIdx]?.items?.[iIdx] if ( !cand || String(cand.exercise_kind || '').toLowerCase().trim() !== 'combination' || !cand.exercise_id ) { setModalComboSlotsFetched(null) return } const cached = cand.combination_slots if (Array.isArray(cached) && cached.length > 0) { setModalComboSlotsFetched(cached) return } let cancelled = false setModalComboSlotsFetched([]) api.getExercise(cand.exercise_id).then((ex) => { if (cancelled) return setModalComboSlotsFetched(Array.isArray(ex?.combination_slots) ? ex.combination_slots : []) }) return () => { cancelled = true } }, [sections, comboPlanningModal]) const closeInsertChooser = useCallback(() => setInsertChooser(null), []) const insertSlotKeyPrefix = slotIndex !== null && slotIndex !== undefined ? `sl${slotIndex}-` : '' const clearSectionDnD = () => setDropSectionBand(null) const onParallelPhaseDragStart = (e, phaseOrderIndex) => { if (!enableSectionDragReorder || !enableParallelPhaseControls) return e.stopPropagation() try { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData( DND_TU_SECTION, JSON.stringify({ fromSlot: sectionToSlot, fromSectionIdx: null, phaseRunMove: { phaseOrderIndex }, }) ) } catch { /* ignore */ } setDropSectionBand(null) } const onSectionDragStart = (e, sIdx) => { if (!enableSectionDragReorder) return e.stopPropagation() try { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData( DND_TU_SECTION, JSON.stringify({ fromSlot: sectionToSlot, fromSectionIdx: sIdx, }) ) } catch { /* ignore */ } setDropSectionBand(null) } const onSectionBandDragOver = (e, beforeIdx) => { if (!enableSectionDragReorder) return if (!dtHasType(e, DND_TU_SECTION)) return e.preventDefault() e.stopPropagation() try { e.dataTransfer.dropEffect = 'move' } catch { /* ignore */ } setDropSectionBand({ slot: sectionToSlot, beforeIdx }) } const onPhaseAboveSplitDragOver = (e, po) => { if (!enableSectionDragReorder || !enableParallelPhaseControls) return if (!dtHasType(e, DND_TU_SECTION)) return e.preventDefault() e.stopPropagation() try { e.dataTransfer.dropEffect = 'move' } catch { /* ignore */ } setDropSectionBand({ slot: sectionToSlot, phaseAboveSplitPo: Number(po) || 0 }) } const onPhaseBelowSplitDragOver = (e, po) => { if (!enableSectionDragReorder || !enableParallelPhaseControls) return if (!dtHasType(e, DND_TU_SECTION)) return e.preventDefault() e.stopPropagation() try { e.dataTransfer.dropEffect = 'move' } catch { /* ignore */ } setDropSectionBand({ slot: sectionToSlot, phaseBelowSplitPo: Number(po) || 0 }) } const applyParsedSectionDrop = (data) => { const phaseRunMove = data.phaseRunMove const fromSi = data.fromSectionIdx const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) { return { kind: 'phaseRun', phaseRunMove, fromSlot } } if (typeof fromSi !== 'number') return null if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && fromSlot >= 0 ) { return { kind: 'crossSlot', fromSi, fromSlot } } return { kind: 'local', fromSi } } const onPhaseAboveSplitDrop = (e, po) => { if (!enableSectionDragReorder || !enableParallelPhaseControls) return e.preventDefault() e.stopPropagation() clearSectionDnD() let raw = '' try { raw = e.dataTransfer.getData(DND_TU_SECTION) } catch { return } if (!raw) return let data try { data = JSON.parse(raw) } catch { return } const targetPo = Number(po) || 0 const parsed = applyParsedSectionDrop(data) if (!parsed) return if (parsed.kind === 'phaseRun') { const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0 if (dragPo === targetPo) return patch((prev) => { const idxs = indicesOfParallelPhase(prev, targetPo) const fg = idxs.length ? idxs[0] : -1 if (fg < 0) return prev let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) return next }) return } if (parsed.kind === 'crossSlot') return const { fromSi } = parsed patch((prev) => { let next = reorderSectionBeforeParallelRunAsWholeGroup(prev, fromSi, targetPo) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) return next }) } const onPhaseBelowSplitDrop = (e, po) => { if (!enableSectionDragReorder || !enableParallelPhaseControls) return e.preventDefault() e.stopPropagation() clearSectionDnD() let raw = '' try { raw = e.dataTransfer.getData(DND_TU_SECTION) } catch { return } if (!raw) return let data try { data = JSON.parse(raw) } catch { return } const targetPo = Number(po) || 0 const parsed = applyParsedSectionDrop(data) if (!parsed) return if (parsed.kind === 'phaseRun') { const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0 if (dragPo === targetPo) return patch((prev) => { const idxs = indicesOfParallelPhase(prev, targetPo) const fg = idxs.length ? idxs[0] : -1 if (fg < 0) return prev let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) return next }) return } if (parsed.kind === 'crossSlot') return const { fromSi } = parsed patch((prev) => { let next = reorderSectionAsFirstInParallelPhase(prev, fromSi, targetPo) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) return next }) } const onSectionBandDrop = (e, insertBeforeIdx) => { if (!enableSectionDragReorder) return e.preventDefault() e.stopPropagation() clearSectionDnD() let raw = '' try { raw = e.dataTransfer.getData(DND_TU_SECTION) } catch { return } if (!raw) return let data try { data = JSON.parse(raw) } catch { return } const fromSi = data.fromSectionIdx const phaseRunMove = data.phaseRunMove const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) { patch((prev) => { const po = Number(phaseRunMove.phaseOrderIndex) || 0 let next = moveParallelPhaseRunToInsertBefore(prev, po, insertBeforeIdx) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) return next }) return } if (typeof fromSi !== 'number') return if (enableParallelPhaseControls) { const fromPl = list[fromSi]?.planLoc if (fromPl?.phaseKind === 'parallel' && insertBeforeIdx >= 0 && insertBeforeIdx < list.length) { const po = fromPl.phaseOrderIndex ?? 0 const fromSo = fromPl.parallelStreamOrderIndex ?? 0 const tabSo = parallelStreamTabByPhase[po] ?? streamsForParallelPhaseOrders(list, po)[0] ?? 0 const targetPl = list[insertBeforeIdx]?.planLoc if ( targetPl?.phaseKind === 'parallel' && (targetPl.phaseOrderIndex ?? 0) === po && (targetPl.parallelStreamOrderIndex ?? 0) !== fromSo && tabSo === fromSo ) { return } } } if ( enableParallelPhaseControls && (insertBeforeIdx === fromSi || insertBeforeIdx === fromSi + 1) ) { return } if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && fromSlot >= 0 ) { onMoveSectionsAcrossSlots({ fromSlot, fromSectionIdx: fromSi, toSlot: sectionToSlot, toSectionIdx: insertBeforeIdx, }) return } patch((prev) => { let next = enableParallelPhaseControls ? reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx) : reorderBlocksImmutable(prev, fromSi, insertBeforeIdx) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) return next }) } const onStreamDropTargetDragOver = (e, phaseOrder, streamOrder) => { if (!enableSectionDragReorder || !useStreamTagDropUx) return if (!dtHasType(e, DND_TU_SECTION)) return e.preventDefault() e.stopPropagation() try { e.dataTransfer.dropEffect = 'move' } catch { /* ignore */ } setDropSectionBand({ slot: sectionToSlot, streamDrop: { po: Number(phaseOrder) || 0, so: Number(streamOrder) || 0 }, }) } const onStreamDropTargetDragLeave = (e) => { if (e.currentTarget.contains(e.relatedTarget)) return clearSectionDnD() } const onStreamDropTargetDrop = (e, phaseOrder, streamOrder) => { if (!enableSectionDragReorder || !useStreamTagDropUx) return e.preventDefault() e.stopPropagation() clearSectionDnD() let raw = '' try { raw = e.dataTransfer.getData(DND_TU_SECTION) } catch { return } if (!raw) return let data try { data = JSON.parse(raw) } catch { return } if (data.phaseRunMove != null && data.phaseRunMove.phaseOrderIndex != null) { return } const fromSi = data.fromSectionIdx if (typeof fromSi !== 'number') return const po = Number(phaseOrder) || 0 const so = Number(streamOrder) || 0 const toIdx = globalInsertBeforeIndexForParallelStreamEnd(list, po, so) const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && fromSlot >= 0 ) { onMoveSectionsAcrossSlots({ fromSlot, fromSectionIdx: fromSi, toSlot: sectionToSlot, toSectionIdx: toIdx, toParallelStream: { po, so }, }) return } patch((prev) => { let next = reorderBlockIntoParallelStreamEnd(prev, fromSi, po, so) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) return next }) } const onItemDragStart = (e, sIdx, iIdx) => { if (!enableItemDragReorder) return e.stopPropagation() try { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData( DND_TU_ITEM, JSON.stringify({ sectionIndex: sIdx, itemIndex: iIdx }) ) } catch { /* ignore */ } setDraggingPos({ sIdx, iIdx }) } const clearDragChrome = () => { setDraggingPos(null) setDropTargetPos(null) } const onItemDragOverRow = (e, sIdx, iIdx) => { if (!enableItemDragReorder) return if (!dtHasType(e, DND_TU_ITEM)) return e.preventDefault() e.stopPropagation() try { e.dataTransfer.dropEffect = 'move' } catch { /* ignore */ } setDropTargetPos({ sIdx, iIdx }) } const onItemDropRow = (e, toSIdx, toIdx) => { if (!enableItemDragReorder) return e.preventDefault() e.stopPropagation() let raw = '' try { raw = e.dataTransfer.getData(DND_TU_ITEM) } catch { clearDragChrome() return } if (!raw) { clearDragChrome() return } let data try { data = JSON.parse(raw) } catch { clearDragChrome() return } const fromS = data.sectionIndex const fromI = data.itemIndex if (typeof fromS !== 'number' || typeof fromI !== 'number') { clearDragChrome() return } if (fromS === toSIdx && fromI === toIdx) { clearDragChrome() return } patch((prev) => { const list = ensure(prev) if ( fromS < 0 || fromS >= list.length || toSIdx < 0 || toSIdx >= list.length || typeof toIdx !== 'number' ) { return prev } const fromItems = [...(list[fromS].items || [])] if (fromI < 0 || fromI >= fromItems.length) return prev const moved = fromItems[fromI] fromItems.splice(fromI, 1) if (fromS === toSIdx) { let insertAt = toIdx if (fromI < toIdx) insertAt = toIdx - 1 const bounded = Math.max(0, Math.min(insertAt, fromItems.length)) fromItems.splice(bounded, 0, moved) return list.map((sec, i) => (i === fromS ? { ...sec, items: fromItems } : sec)) } const toItems = [...(list[toSIdx].items || [])] const insertAt = Math.max(0, Math.min(toIdx, toItems.length)) toItems.splice(insertAt, 0, moved) return list.map((sec, i) => { if (i === fromS) return { ...sec, items: fromItems } if (i === toSIdx) return { ...sec, items: toItems } return sec }) }) clearDragChrome() } const applyTextEdit = () => { if (!textEdit) return const { kind, sIdx, iIdx, draft } = textEdit if (kind === 'zwischen-note') { updateItem(sIdx, iIdx, 'note_body', draft) } else if (kind === 'exercise-notes') { updateItem(sIdx, iIdx, 'notes', draft) } setTextEdit(null) } const renderBetweenInsertBand = (sIdx, beforeIx, itemCount) => { const posLabel = beforeIx === 0 ? 'vor dem ersten Eintrag' : beforeIx >= itemCount ? 'am Ende des Abschnitts' : `vor Eintrag ${beforeIx + 1}` return (
) } const planningPhaseRuns = useMemo(() => phaseRunsFromSections(list), [list]) const firstSectionIndexByParallelPhase = useMemo(() => { const m = new Map() list.forEach((s, i) => { const L = s?.planLoc if (L?.phaseKind !== 'parallel') return const po = L.phaseOrderIndex ?? 0 if (!m.has(po)) m.set(po, i) }) return m }, [list]) const parallelPhaseOrdersPresent = useMemo(() => { const set = new Set() for (const s of list) { if (s?.planLoc?.phaseKind === 'parallel') set.add(s.planLoc.phaseOrderIndex ?? 0) } return [...set].sort((a, b) => a - b) }, [list]) useEffect(() => { if (!enableParallelPhaseControls || !parallelPhaseOrdersPresent.length) return setParallelStreamTabByPhase((prev) => { const next = { ...prev } let changed = false for (const po of parallelPhaseOrdersPresent) { const orders = streamsForParallelPhaseOrders(list, po) if (!orders.length) continue if (next[po] === undefined) { next[po] = orders[0] changed = true } else if (!orders.includes(next[po])) { next[po] = orders[0] changed = true } } for (const k of Object.keys(next)) { const poi = Number(k) if (!Number.isFinite(poi) || !parallelPhaseOrdersPresent.includes(poi)) { delete next[k] changed = true } } return changed ? next : prev }) }, [list, parallelPhaseOrdersPresent, enableParallelPhaseControls]) const sectionMoveDisabledUp = (sIdx) => { const sec = list[sIdx] const L = sec?.planLoc if (L?.phaseKind === 'parallel') { const po = L.phaseOrderIndex ?? 0 const so = L.parallelStreamOrderIndex ?? 0 const bucket = sectionIndicesForParallelStream(list, po, so) const pos = bucket.indexOf(sIdx) if (pos > 0) return false const rIdx = planningPhaseRuns.findIndex( (r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po ) return rIdx <= 0 } return sIdx === 0 } const sectionMoveDisabledDown = (sIdx) => { const sec = list[sIdx] const L = sec?.planLoc if (L?.phaseKind === 'parallel') { const po = L.phaseOrderIndex ?? 0 const so = L.parallelStreamOrderIndex ?? 0 const bucket = sectionIndicesForParallelStream(list, po, so) const pos = bucket.indexOf(sIdx) if (pos >= 0 && pos < bucket.length - 1) return false const rIdx = planningPhaseRuns.findIndex( (r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po ) return rIdx < 0 || rIdx >= planningPhaseRuns.length - 1 } return sIdx === list.length - 1 } const comboPlanningModalDerived = useMemo(() => { if (!comboPlanningModal) { return { item: null, sIdx: null, iIdx: null } } const { sIdx, iIdx } = comboPlanningModal const cand = list[sIdx]?.items?.[iIdx] if ( cand && String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' && cand.exercise_id ) { return { item: cand, sIdx, iIdx } } return { item: null, sIdx: null, iIdx: null } }, [list, comboPlanningModal]) const comboPlanningModalItem = comboPlanningModalDerived.item const comboPlanningModalSX = comboPlanningModalDerived.sIdx const comboPlanningModalIX = comboPlanningModalDerived.iIdx const comboPlanningResolvedSlots = useMemo(() => { if (!comboPlanningModalItem) return [] const c = comboPlanningModalItem.combination_slots if (Array.isArray(c) && c.length > 0) return c return Array.isArray(modalComboSlotsFetched) ? modalComboSlotsFetched : [] }, [comboPlanningModalItem, modalComboSlotsFetched]) const comboPlanningSlotsOutline = useMemo( () => comboSlotsOutlineForProfileEditor(comboPlanningResolvedSlots), [comboPlanningResolvedSlots] ) const comboPlanningEffectiveProfile = useMemo(() => { if (!comboPlanningModalItem) return {} return effectiveComboMethodProfile( comboPlanningModalItem.catalog_method_profile || {}, comboPlanningModalItem.planning_method_profile ) }, [comboPlanningModalItem]) return (
{(!hideHeading || headingAccessory) ? (
{!hideHeading ? (

{heading}

) : headingAccessory ? ( ) : null} {headingAccessory ? (
{headingAccessory}
) : null}
) : null} {list.map((sec, sIdx) => { const planMin = sectionPlannedMinutes(sec) const itemCount = sec.items?.length ?? 0 const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : [] const pl = sec?.planLoc const parallelPhaseOrder = enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null const streamOrdersForParallelPhase = parallelPhaseOrder != null ? streamsForParallelPhaseOrders(list, parallelPhaseOrder) : [] const activeParallelStream = parallelPhaseOrder != null ? parallelStreamTabByPhase[parallelPhaseOrder] ?? streamOrdersForParallelPhase[0] ?? 0 : null const hideParallelSection = enableParallelPhaseControls && pl?.phaseKind === 'parallel' && (pl.parallelStreamOrderIndex ?? 0) !== activeParallelStream const firstGlobalIdxThisPhase = parallelPhaseOrder != null ? firstSectionIndexByParallelPhase.get(parallelPhaseOrder) : null const firstVisibleIdxActiveStream = parallelPhaseOrder != null && streamOrdersForParallelPhase.length ? sectionIndicesForParallelStream( list, parallelPhaseOrder, activeParallelStream )[0] : null const hideDropBandBeforeOrphanFirstVisible = parallelPhaseOrder != null && firstVisibleIdxActiveStream != null && sIdx === firstVisibleIdxActiveStream && firstGlobalIdxThisPhase != null && sIdx !== firstGlobalIdxThisPhase const isFirstSectionOfParallelPhase = parallelPhaseOrder != null && firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx const hideDropBeforeFirstParallelBecauseDedicatedSlot = enableParallelPhaseControls && isFirstSectionOfParallelPhase && pl?.phaseKind === 'parallel' const showSectionDropBandBefore = (pl?.phaseKind !== 'parallel' || !hideParallelSection) && !hideDropBandBeforeOrphanFirstVisible && !hideDropBeforeFirstParallelBecauseDedicatedSlot const bandActiveBefore = (bx) => enableSectionDragReorder && dropSectionBand && dropSectionBand.slot === sectionToSlot && dropSectionBand.beforeIdx === bx && !dropSectionBand.streamDrop && dropSectionBand.phaseAboveSplitPo == null && dropSectionBand.phaseBelowSplitPo == null const streamChipDropActive = (po, so) => useStreamTagDropUx && dropSectionBand?.slot === sectionToSlot && dropSectionBand?.streamDrop?.po === po && dropSectionBand?.streamDrop?.so === so const phaseAboveSplitDnd = parallelPhaseOrder != null && dropSectionBand?.slot === sectionToSlot && dropSectionBand?.phaseAboveSplitPo === parallelPhaseOrder const phaseBelowSplitDnd = parallelPhaseOrder != null && dropSectionBand?.slot === sectionToSlot && dropSectionBand?.phaseBelowSplitPo === parallelPhaseOrder const streamVisual = enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0) : null const allowSectionDragGrip = enableSectionDragReorder return ( {enableSectionDragReorder && showSectionDropBandBefore ? (
{ if (!enableSectionDragReorder) return if (!dtHasType(e, DND_TU_SECTION)) return onSectionBandDragOver(e, sIdx) }} onDragLeave={(e) => { if (e.currentTarget.contains(e.relatedTarget)) return clearSectionDnD() }} onDrop={(e) => onSectionBandDrop(e, sIdx)} /> ) : null} {isFirstSectionOfParallelPhase && enableParallelPhaseControls && streamOrdersForParallelPhase.length ? ( {enableSectionDragReorder ? (
onPhaseAboveSplitDragOver(e, parallelPhaseOrder)} onDragLeave={(e) => { if (e.currentTarget.contains(e.relatedTarget)) return clearSectionDnD() }} onDrop={(e) => onPhaseAboveSplitDrop(e, parallelPhaseOrder)} /> ) : null}
{enableSectionDragReorder ? ( onParallelPhaseDragStart(e, parallelPhaseOrder)} role="button" tabIndex={0} aria-label="Parallele Phase ziehen" title="Gesamte parallele Phase an neue Planposition ziehen" > ) : null} {(() => { const hi = firstSectionIndexByParallelPhase.get(parallelPhaseOrder) const phaseTitleStr = hi != null && list[hi]?.planLoc?.phaseTitle != null ? String(list[hi].planLoc.phaseTitle) : '' const editingPhase = phaseTitleEditPo === parallelPhaseOrder return editingPhase ? ( setPhaseTitleDraft(e.target.value)} onBlur={() => { if (!skipPhaseTitleBlurSave.current) { updateParallelPhaseTitleAll(parallelPhaseOrder, phaseTitleDraft) } skipPhaseTitleBlurSave.current = false setPhaseTitleEditPo(null) }} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() if (e.key === 'Escape') { skipPhaseTitleBlurSave.current = true setPhaseTitleEditPo(null) e.currentTarget.blur() } }} placeholder="Bezeichnung der Phase (z. B. Drill-Runde)" /> ) : ( <> {(phaseTitleStr || '').trim() || `Phase ${parallelPhaseOrder} · Namen per Stift bearbeiten`} ) })()} {(() => { const prRunIdx = planningPhaseRuns.findIndex( (r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === parallelPhaseOrder ) const chipPhaseUpDis = prRunIdx <= 0 const chipPhaseDownDis = prRunIdx < 0 || prRunIdx >= planningPhaseRuns.length - 1 return (
) })()}
{enableSectionDragReorder ? (
onPhaseBelowSplitDragOver(e, parallelPhaseOrder)} onDragLeave={(e) => { if (e.currentTarget.contains(e.relatedTarget)) return clearSectionDnD() }} onDrop={(e) => onPhaseBelowSplitDrop(e, parallelPhaseOrder)} /> ) : null}
{streamOrdersForParallelPhase.map((so) => { const sel = (parallelStreamTabByPhase[parallelPhaseOrder] ?? streamOrdersForParallelPhase[0] ?? 0) === so const pv = parallelStreamVisual(so) const si = sectionIndicesForParallelStream(list, parallelPhaseOrder, so) const titleSource = si.length ? list[si[0]]?.planLoc?.streamTitle : null const streamName = titleSource != null ? String(titleSource) : '' const editKey = `${parallelPhaseOrder}:${so}` const editingStream = streamNameEditKey === editKey return (
onStreamDropTargetDragOver(e, parallelPhaseOrder, so) : undefined } onDragLeave={ useStreamTagDropUx ? onStreamDropTargetDragLeave : undefined } onDrop={ useStreamTagDropUx ? (e) => onStreamDropTargetDrop(e, parallelPhaseOrder, so) : undefined } > {editingStream ? ( setStreamNameDraft(e.target.value)} onBlur={() => { if (!skipStreamNameBlurSave.current) { updateParallelStreamTitleAll(parallelPhaseOrder, so, streamNameDraft) } skipStreamNameBlurSave.current = false setStreamNameEditKey(null) }} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() if (e.key === 'Escape') { skipStreamNameBlurSave.current = true setStreamNameEditKey(null) e.currentTarget.blur() } }} onClick={(e) => e.stopPropagation()} placeholder={`Gruppe ${so + 1}`} aria-label={`Name Gruppe ${so + 1}`} /> ) : ( )}
) })}
) : null} {!hideParallelSection ? ( <>
{allowSectionDragGrip ? ( onSectionDragStart(e, sIdx)} role="button" tabIndex={0} aria-label="Abschnitt ziehen" title="Abschnitt ziehen" > ) : null} updateSectionField(sIdx, 'title', e.target.value)} placeholder="Abschnittstitel (z. B. Aufwärmen)" />
{enableParallelPhaseControls && sec.planLoc ? (

{sec.planLoc.phaseKind === 'whole_group' ? (() => { const pt = sec.planLoc.phaseTitle const po = sec.planLoc.phaseOrderIndex ?? 0 return pt != null && String(pt).trim() ? `Ganzgruppe: ${String(pt).trim()} (Phase ${po})` : `Ganzgruppen-Phase ${po}` })() : (() => { const pt = sec.planLoc.phaseTitle const st = sec.planLoc.streamTitle const po = sec.planLoc.phaseOrderIndex ?? 0 const so = sec.planLoc.parallelStreamOrderIndex ?? 0 const phaseLbl = pt != null && String(pt).trim() ? String(pt).trim() : `Phase ${po}` const streamLbl = st != null && String(st).trim() ? String(st).trim() : `Gruppe ${so + 1}` return `Parallel · ${phaseLbl} · ${streamLbl}` })()}

) : null}