shinkan-jinkendo/frontend/src/components/TrainingUnitSectionsEditor.jsx
Lars 72e8f31cff
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
Enhance TrainingUnitSectionsEditor with new drag-and-drop functionality for parallel phases
- Added new event handlers for drag-and-drop operations to manage sections above and below split headers in parallel phases.
- Implemented utility functions to reorder sections as whole groups or as the first entry in parallel phases, improving section management.
- Updated CSS styles to visually represent new drop zones for sections, enhancing user experience during reordering.
- Refactored existing logic to accommodate new features and ensure proper handling of section placements within parallel streams.
2026-05-15 10:59:30 +02:00

2849 lines
110 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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, { 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 PlanungsOverride 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 (
<span
className="tu-planning-mod-tag"
style={{ borderColor: p.border, backgroundColor: p.soft }}
title={`${fullTitle} (Bibliotheks-ID ${moduleId})`}
>
<span className="tu-planning-mod-tag__dot" style={{ background: p.border }} aria-hidden />
<span className="tu-planning-mod-tag__text">Aus Modul: {lbl}</span>
</span>
)
}
/** 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 (
<div className="tu-module-bundle-head" role="group" aria-label={`Modul ${modBandTitle}`}>
<div className="tu-module-bundle-head__stripe" aria-hidden />
<div className="tu-module-bundle-head__main">
<span className="tu-module-bundle-head__kicker">Aus Modul</span>
<strong className="tu-module-bundle-head__title">{modBandTitle}</strong>
{modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
<p className="tu-module-bundle-head__empty">Ohne strukturierten Inhalt angezeigt.</p>
) : (
<ol className="tu-module-bundle-head__list" start={1}>
{modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
<li key={`mo-li-${modBandTitle}-${ox}`}>{tx}</li>
))}
</ol>
)}
{modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
<p className="tu-module-bundle-head__more">
und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
{modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
</p>
) : null}
{modOutline.notes > 0 ? (
<p className="tu-module-bundle-head__meta">
sowie {modOutline.notes}{' '}
{modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
</p>
) : null}
</div>
</div>
)
}
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 (
<div className="tu-insert-slot">
<button
type="button"
className="tu-insert-slot__btn"
aria-haspopup="dialog"
aria-label={`Inhalt einfügen (${posLabel})`}
title={`Hier einfügen (${posLabel})`}
onClick={() => setInsertChooser({ sIdx, beforeIx })}
>
+
</button>
</div>
)
}
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 (
<div
className={
'training-unit-sections-editor' +
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '') +
(enableItemDragReorder ? ' training-unit-sections-editor--item-drag' : '')
}
>
{(!hideHeading || headingAccessory) ? (
<div
className="tu-editor-heading-toolbar"
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: '10px',
marginBottom: '0.75rem',
}}
>
{!hideHeading ? (
<h3 style={{ margin: 0, fontSize: '1rem', flex: '1 1 200px', minWidth: 0 }}>
{heading}
</h3>
) : headingAccessory ? (
<span style={{ flex: '1 1 auto', minWidth: 0 }} />
) : null}
{headingAccessory ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
{headingAccessory}
</div>
) : null}
</div>
) : 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 (
<Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder && showSectionDropBandBefore ? (
<div
className={
'tu-section-dropband' +
sectionDropBandRegionClass(list, sIdx, enableParallelPhaseControls) +
(bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')
}
title="Abschnitt hier einfügen"
onDragOver={(e) => {
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 ? (
<Fragment key={`phase-edge-${parallelPhaseOrder}`}>
{enableSectionDragReorder ? (
<div
className={
'tu-section-dropband tu-phase-drop--above-split tu-section-dropband--phase-parallel-slot' +
sectionDropBandRegionClass(
list,
firstGlobalIdxThisPhase ?? sIdx,
enableParallelPhaseControls
) +
(phaseAboveSplitDnd ? ' tu-section-dropband--active' : '')
}
title="Oberhalb der Split-Phase: Abschnitt in die Ganzgruppe ziehen"
aria-label="Dropzone Ganzgruppe oberhalb der Split-Phase"
onDragOver={(e) => onPhaseAboveSplitDragOver(e, parallelPhaseOrder)}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
clearSectionDnD()
}}
onDrop={(e) => onPhaseAboveSplitDrop(e, parallelPhaseOrder)}
/>
) : null}
<div
style={{
marginBottom: '12px',
padding: '10px 12px',
background: 'var(--surface2)',
borderRadius: '10px',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginBottom: '10px',
}}
>
{enableSectionDragReorder ? (
<span
className="tu-sec-drag-grip"
draggable
onDragStart={(e) => onParallelPhaseDragStart(e, parallelPhaseOrder)}
role="button"
tabIndex={0}
aria-label="Parallele Phase ziehen"
title="Gesamte parallele Phase an neue Planposition ziehen"
>
<GripVertical size={16} strokeWidth={2} aria-hidden />
</span>
) : null}
<label
className="form-label"
style={{ fontSize: '0.78rem', marginBottom: 0, flex: '0 0 auto' }}
>
Phase
</label>
{(() => {
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 ? (
<input
className="form-input"
style={{ flex: '2 1 200px', maxWidth: '320px', marginBottom: 0 }}
autoFocus
value={phaseTitleDraft}
onChange={(e) => 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)"
/>
) : (
<>
<span
style={{
flex: '2 1 200px',
maxWidth: '320px',
fontSize: '0.86rem',
fontWeight: 600,
color: 'var(--text1)',
lineHeight: 1.35,
padding: '6px 4px',
}}
>
{(phaseTitleStr || '').trim() ||
`Phase ${parallelPhaseOrder} · Namen per Stift bearbeiten`}
</span>
<button
type="button"
className="tu-icon-btn"
style={{ padding: '6px', color: 'var(--text2)' }}
aria-label="Phasen-Bezeichnung bearbeiten"
title="Namen bearbeiten"
onClick={() => {
setPhaseTitleEditPo(parallelPhaseOrder)
setPhaseTitleDraft(phaseTitleStr)
}}
>
<Pencil size={16} strokeWidth={2} aria-hidden />
</button>
</>
)
})()}
{(() => {
const prRunIdx = planningPhaseRuns.findIndex(
(r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === parallelPhaseOrder
)
const chipPhaseUpDis = prRunIdx <= 0
const chipPhaseDownDis =
prRunIdx < 0 || prRunIdx >= planningPhaseRuns.length - 1
return (
<div
style={{
display: 'inline-flex',
gap: '4px',
alignItems: 'center',
marginRight: '2px',
}}
>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={chipPhaseUpDis}
aria-label="Parallelen Block nach oben"
title="Gesamten parallelen Block im Plan nach oben schieben"
onClick={() =>
patch((p) => movePhaseRunUpByPhaseOrder(p, parallelPhaseOrder))
}
style={{ padding: '4px 10px' }}
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={chipPhaseDownDis}
aria-label="Parallelen Block nach unten"
title="Gesamten parallelen Block im Plan nach unten schieben"
onClick={() =>
patch((p) => movePhaseRunDownByPhaseOrder(p, parallelPhaseOrder))
}
style={{ padding: '4px 10px' }}
>
</button>
</div>
)
})()}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addStreamToParallelPhase(parallelPhaseOrder)}
disabled={streamOrdersForParallelPhase.length >= MAX_PARALLEL_STREAMS_PER_PHASE}
title={
streamOrdersForParallelPhase.length >= MAX_PARALLEL_STREAMS_PER_PHASE
? `Höchstens ${MAX_PARALLEL_STREAMS_PER_PHASE} Streams`
: 'Weitere parallele Gruppe'
}
>
+ Stream
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
addSectionToParallelStream(parallelPhaseOrder, activeParallelStream ?? 0)
}
title="Neuer Abschnitt im gerade gewählten Stream"
>
+ Abschnitt in diesem Stream
</button>
</div>
{enableSectionDragReorder ? (
<div
className={
'tu-section-dropband tu-phase-drop--below-split tu-section-dropband--phase-parallel-slot' +
(phaseBelowSplitDnd ? ' tu-section-dropband--active' : '')
}
title="Erster Abschnitt dieser Split-Phase (vorne einfügen)"
aria-label="Dropzone erster Abschnitt unter dem Split-Kopf"
onDragOver={(e) => onPhaseBelowSplitDragOver(e, parallelPhaseOrder)}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
clearSectionDnD()
}}
onDrop={(e) => onPhaseBelowSplitDrop(e, parallelPhaseOrder)}
/>
) : null}
<div
role="tablist"
aria-label={`Streams · Phase ${parallelPhaseOrder}`}
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'stretch' }}
>
{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 (
<div
key={`p${parallelPhaseOrder}-chip-s${so}`}
className={
'tu-stream-chip-pill' +
(streamChipDropActive(parallelPhaseOrder, so)
? ' tu-stream-chip-pill--drop-active'
: '')
}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 6px 4px 8px',
borderRadius: '999px',
border: sel ? `2px solid ${pv.border}` : `1px solid ${pv.border}`,
background: sel ? pv.tabBgActive : pv.tabBg,
maxWidth: '100%',
}}
title={
useStreamTagDropUx
? 'Gruppe wählen oder Abschnitt hierher ziehen'
: undefined
}
onDragOver={
useStreamTagDropUx
? (e) => onStreamDropTargetDragOver(e, parallelPhaseOrder, so)
: undefined
}
onDragLeave={
useStreamTagDropUx ? onStreamDropTargetDragLeave : undefined
}
onDrop={
useStreamTagDropUx
? (e) => onStreamDropTargetDrop(e, parallelPhaseOrder, so)
: undefined
}
>
{editingStream ? (
<input
className="form-input"
autoFocus
style={{
minWidth: '7rem',
maxWidth: '12rem',
margin: 0,
padding: '4px 8px',
fontSize: '0.8rem',
borderRadius: '8px',
}}
value={streamNameDraft}
onChange={(e) => 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}`}
/>
) : (
<button
type="button"
role="tab"
aria-selected={sel}
style={{
flex: '1 1 auto',
minWidth: '5.5rem',
maxWidth: '12rem',
margin: 0,
padding: '6px 10px',
fontSize: '0.85rem',
fontWeight: sel ? 600 : 500,
border: 'none',
background: 'transparent',
color: 'var(--text1)',
textAlign: 'left',
cursor: 'pointer',
borderRadius: '8px',
}}
onClick={() =>
setParallelStreamTabByPhase((prev) => ({
...prev,
[parallelPhaseOrder]: so,
}))
}
>
{(streamName || '').trim() || `Gruppe ${so + 1}`}
</button>
)}
<button
type="button"
className="tu-icon-btn"
style={{
flex: '0 0 auto',
padding: '4px',
color: 'var(--text2)',
borderRadius: '8px',
}}
title="Gruppennamen bearbeiten"
aria-label="Gruppennamen bearbeiten"
onClick={(e) => {
e.stopPropagation()
setStreamNameEditKey(editKey)
setStreamNameDraft(streamName)
}}
>
<Pencil size={15} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className="tu-icon-btn"
style={{
flex: '0 0 auto',
padding: '4px',
color: 'var(--text2)',
borderRadius: '8px',
}}
title="Stream entfernen"
aria-label={`Stream ${so + 1} entfernen`}
onClick={(e) => {
e.stopPropagation()
removeParallelStream(parallelPhaseOrder, so)
}}
>
<X size={16} strokeWidth={2} aria-hidden />
</button>
</div>
)
})}
</div>
</div>
</Fragment>
) : null}
{!hideParallelSection ? (
<>
<div
className="tu-section-shell"
style={{
marginBottom: '1rem',
padding: '0.75rem',
background: streamVisual ? streamVisual.soft : 'var(--surface2)',
borderRadius: '10px',
border: streamVisual
? `1px solid ${streamVisual.border}`
: '1px solid var(--border, rgba(0,0,0,0.08))',
borderLeft: streamVisual ? `5px solid ${streamVisual.border}` : undefined,
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '0.5rem',
alignItems: 'flex-start',
}}
>
{allowSectionDragGrip ? (
<span
className="tu-sec-drag-grip"
draggable
onDragStart={(e) => onSectionDragStart(e, sIdx)}
role="button"
tabIndex={0}
aria-label="Abschnitt ziehen"
title="Abschnitt ziehen"
>
<GripVertical size={16} strokeWidth={2} aria-hidden />
</span>
) : null}
<input
className="form-input"
style={{ flex: '2 1 180px', marginBottom: 0 }}
value={sec.title}
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
placeholder="Abschnittstitel (z. B. Aufwärmen)"
/>
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
<button
type="button"
aria-label="Abschnitt hoch"
onClick={() => moveSection(sIdx, -1)}
disabled={sectionMoveDisabledUp(sIdx)}
style={{
padding: '4px 10px',
opacity: sectionMoveDisabledUp(sIdx) ? 0.35 : 1,
}}
>
</button>
<button
type="button"
aria-label="Abschnitt runter"
onClick={() => moveSection(sIdx, 1)}
disabled={sectionMoveDisabledDown(sIdx)}
style={{
padding: '4px 10px',
opacity: sectionMoveDisabledDown(sIdx) ? 0.35 : 1,
}}
>
</button>
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeSection(sIdx)}
>
Abschnitt entfernen
</button>
</div>
{enableParallelPhaseControls && sec.planLoc ? (
<p
style={{
fontSize: '0.75rem',
color: 'var(--text2)',
margin: '0 0 8px',
fontWeight: 500,
}}
>
{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}`
})()}
</p>
) : null}
<textarea
className="form-input"
rows={2}
value={sec.guidance_notes}
onChange={(e) =>
updateSectionField(sIdx, 'guidance_notes', e.target.value)
}
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
/>
{enableParallelPhaseControls ? (
<div className="form-row" style={{ marginTop: '10px', marginBottom: '2px' }}>
<label className="form-label" style={{ fontSize: '0.78rem' }}>
Zuordnung
</label>
<select
className="form-input"
value={planLocKey(sec.planLoc)}
onChange={(e) => applySectionPlanTarget(sIdx, e.target.value)}
>
<option value="">Standard eine Ganzgruppe (klassischer Ablauf)</option>
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
</div>
) : null}
{planMin > 0 && (
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
</p>
)}
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null}
{(sec.items || []).map((it, iIdx) => {
const dropHere =
enableItemDragReorder &&
dropTargetPos?.sIdx === sIdx &&
dropTargetPos?.iIdx === iIdx
const dragHere =
enableItemDragReorder &&
draggingPos?.sIdx === sIdx &&
draggingPos?.iIdx === iIdx
const rowCommon =
'tu-item-row' +
(dropHere ? ' tu-item-row--drop-target' : '') +
(dragHere ? ' tu-item-row--dragging' : '')
const dndRowProps = enableItemDragReorder
? {
onDragOverCapture: (ev) => onItemDragOverRow(ev, sIdx, iIdx),
onDrop: (ev) => onItemDropRow(ev, sIdx, iIdx),
}
: {}
const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
const showModuleBand =
curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
const modBandTitle =
(it.source_module_title || '').trim() ||
(curMn != null ? `Modul #${curMn}` : '')
const modOutline =
!planningCompactLegend &&
showModuleBand &&
curMn != null
? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
: null
const fromModClass =
curMn != null
? planningCompactLegend
? ' tu-item-row--from-module-soft'
: ' tu-item-row--from-module'
: ''
const modBorderVarStyle =
planningCompactLegend && curMn != null
? { '--tu-mod-border': planningModulePalette(curMn).border }
: undefined
if (it.item_type === 'note') {
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
const notePv = truncatePreview(it.note_body || '', 260)
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={
`${rowCommon} tu-item-row--note` +
(isSepLine ? ' tu-item-row--separator-note' : '') +
fromModClass
}
{...dndRowProps}
style={modBorderVarStyle}
>
{enableItemDragReorder ? (
<span
className="tu-row-grip"
draggable
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
onDragEnd={clearDragChrome}
role="button"
tabIndex={0}
aria-label="Eintrag ziehen"
>
<GripVertical size={15} strokeWidth={2} aria-hidden />
</span>
) : null}
<div className="tu-item-row__nudge">
<button
type="button"
aria-label="Eintrag nach oben"
onClick={() => moveItem(sIdx, iIdx, -1)}
disabled={iIdx === 0}
>
</button>
<button
type="button"
aria-label="Eintrag nach unten"
onClick={() => moveItem(sIdx, iIdx, 1)}
disabled={iIdx === sec.items.length - 1}
>
</button>
</div>
<div className="tu-item-row__body tu-item-row__body--note">
{!isSepLine && planningCompactLegend && curMn ? (
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
) : null}
<span className="tu-item-row__meta-label">
{isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'}
</span>
{isSepLine ? (
<div
className="tu-item-row__separator-line"
role="separator"
aria-label="Trennlinie im Ablauf"
/>
) : (
<p
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
title={noteHasText ? (it.note_body || '').trim() : undefined}
>
{noteHasText ? notePv : '—'}
</p>
)}
</div>
<button
type="button"
className="tu-icon-btn"
title={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
aria-label={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
onClick={() =>
setTextEdit({
kind: 'zwischen-note',
sIdx,
iIdx,
draft: it.note_body || '',
})
}
>
<Pencil size={15} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className="tu-item-row__remove"
title="Entfernen"
aria-label={isSepLine ? 'Trennung entfernen' : 'Zwischen-Anmerkung entfernen'}
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
</div>
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
</Fragment>
)
}
const variantOpts = Array.isArray(it.variants) ? it.variants : []
const exTitle =
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
const isCombination =
String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
const annotPrev = truncatePreview(it.notes || '', 220)
const annotHasText = Boolean((it.notes || '').trim())
const hasVariants = !isCombination && variantOpts.length > 0 && it.exercise_id
const variantIdPeek =
it.exercise_variant_id === '' || it.exercise_variant_id == null
? undefined
: Number(it.exercise_variant_id)
const stripArchRaw =
isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
const stripMpEff =
isCombination && it.exercise_id
? effectiveComboMethodProfile(
it.catalog_method_profile || {},
it.planning_method_profile,
)
: null
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={`${rowCommon} tu-item-row--exercise${fromModClass}${
isCombination && it.exercise_id ? ' tu-item-row--combo' : ''
}`}
{...dndRowProps}
style={modBorderVarStyle}
>
<div className="tu-item-row__mainline">
{enableItemDragReorder ? (
<span
className="tu-row-grip"
draggable
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
onDragEnd={clearDragChrome}
role="button"
tabIndex={0}
aria-label="Eintrag ziehen"
>
<GripVertical size={15} strokeWidth={2} aria-hidden />
</span>
) : null}
<div className="tu-item-row__nudge">
<button
type="button"
aria-label="Eintrag nach oben"
onClick={() => moveItem(sIdx, iIdx, -1)}
disabled={iIdx === 0}
>
</button>
<button
type="button"
aria-label="Eintrag nach unten"
onClick={() => moveItem(sIdx, iIdx, 1)}
disabled={iIdx === sec.items.length - 1}
>
</button>
</div>
<div className="tu-item-row__body tu-item-row__body--exercise">
<div className="tu-ex-title-line">
{exTitle ? (
<strong className="tu-ex-title">{exTitle}</strong>
) : (
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
)}
{isCombination ? (
<span
className="exercise-tag"
style={{
marginLeft: 8,
fontSize: '11px',
alignSelf: 'center',
background: 'var(--accent-soft)',
color: 'var(--accent-dark)',
}}
>
Kombination
</span>
) : null}
{planningCompactLegend && curMn ? (
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
) : null}
<span className="tu-ex-inline-actions">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
onRequestExercisePick?.({
sectionIndex: sIdx,
itemIndex: iIdx,
})
}
>
{exTitle ? 'Wechseln' : 'Übung suchen…'}
</button>
{it.exercise_id && onPeekExercise ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
onPeekExercise(
Number(it.exercise_id),
variantIdPeek,
isCombination
? {
catalog_method_profile: it.catalog_method_profile,
planning_method_profile: it.planning_method_profile,
}
: undefined,
)
}
>
Vorschau
</button>
) : null}
</span>
</div>
<div className="tu-ex-meta-line">
{hasVariants ? (
<select
className={`form-input tu-ex-variant-select${
wideExerciseGrid ? ' tu-ex-variant-select--wide' : ''
}`}
value={
it.exercise_variant_id === '' ||
it.exercise_variant_id == null
? ''
: String(it.exercise_variant_id)
}
onChange={(e) => {
const raw = e.target.value
updateItem(
sIdx,
iIdx,
'exercise_variant_id',
raw === '' ? '' : parseInt(raw, 10)
)
}}
title="Übungsvariante"
>
<option value="">Stammübung</option>
{variantOpts.map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
) : null}
<div className="tu-ex-annot">
<span
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
title={annotHasText ? (it.notes || '').trim() : undefined}
>
{annotHasText ? annotPrev : '—'}
</span>
<button
type="button"
className="tu-icon-btn"
title="Anmerkung zur Übung"
aria-label="Anmerkung zur Übung bearbeiten"
onClick={() =>
setTextEdit({
kind: 'exercise-notes',
sIdx,
iIdx,
draft: it.notes || '',
})
}
>
<Pencil size={15} strokeWidth={2} aria-hidden />
</button>
</div>
</div>
</div>
<div className="tu-item-row__side">
<input
type="number"
className="form-input tu-ex-duration"
min={1}
value={it.planned_duration_min}
onChange={(e) =>
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
}
placeholder="Min"
title="Geplante Dauer (Minuten)"
/>
<button
type="button"
className="tu-item-row__remove"
title="Übung entfernen"
aria-label="Übung entfernen"
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
</div>
</div>
{isCombination && it.exercise_id ? (
<div className="tu-combo-planning-strip">
<div className="tu-combo-planning-strip__toolbar">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-haspopup="dialog"
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
>
Ablauf bearbeiten
</button>
</div>
{(it.combination_slots || []).length > 0 ? (
<div className="tu-combo-planning-strip__bracket-wrap">
<CombinationPlanBracket
className="combo-plan-bracket--planning-embed"
methodArchetype={stripArchRaw}
methodProfile={stripMpEff || {}}
combinationSlots={sortCombinationSlotsForDisplay(it.combination_slots)}
planningAdjusted={
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
}
candidateInteraction={onPeekExercise ? 'button' : 'none'}
onCandidatePeek={
onPeekExercise
? (exId) => onPeekExercise(Number(exId), null, undefined)
: undefined
}
/>
</div>
) : (
<div
className="tu-combo-planning-strip__meta tu-combo-planning-strip__meta--fallback"
title="Stationen aus dem Katalog — nach ersten Laden oder wenn die Kombination noch keine Slots hat."
>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic', margin: 0 }}>
Stationen werden geladen oder die Kombination hat im Katalog noch keine Stationsliste
</div>
</div>
)}
</div>
) : null}
{showExecutionExtras ? (
<div className="tu-ex-debrief">
<div className="tu-ex-debrief__grow">
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span>
<textarea
className="form-input tu-ex-debrief__textarea"
rows={3}
value={it.modifications || ''}
onChange={(e) =>
updateItem(sIdx, iIdx, 'modifications', e.target.value)
}
placeholder="Was lief anders? Anpassungen für spätere Planung…"
/>
</div>
<div className="tu-ex-debrief__ist">
<span className="tu-item-row__meta-label">Ist (Min)</span>
<input
type="number"
className="form-input tu-ex-duration"
min={1}
value={it.actual_duration_min}
onChange={(e) =>
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
}
placeholder="IST"
title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
/>
</div>
</div>
) : null}
</div>
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
</Fragment>
)
})}
{enableItemDragReorder ? (
<div
className={`tu-item-append-drop${
dropTargetPos?.sIdx === sIdx && dropTargetPos?.iIdx === itemCount
? ' tu-item-append-drop--active'
: ''
}`}
title="Hierhin ziehen, um nach unten einzufügen"
onDragOverCapture={(e) => onItemDragOverRow(e, sIdx, itemCount)}
onDrop={(e) => onItemDropRow(e, sIdx, itemCount)}
/>
) : null}
<div style={{ marginTop: '0.65rem' }}>
{betweenInsertMenus ? (
<p style={{ margin: 0, fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45, maxWidth: '42rem' }}>
Über die +-Zeilen zwischen den Einträgen fügst du an der gewünschten Stelle Inhalte ein. Reihenfolge
weiter per Ziehen oder den Pfeiltasten ändern.
</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
>
+ Übung
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addItem(sIdx, 'note')}
>
+ Anmerkung
</button>
</div>
)}
</div>
{moduleLegend.length ? (
<div
className="tu-section-mod-legend"
aria-label="Liste der eingebundenen Trainingsmodule in diesem Abschnitt"
>
<div className="tu-section-mod-legend__caption">Übernommene Module im Abschnitt</div>
<ul className="tu-section-mod-legend__list">
{moduleLegend.map((e) => {
const pal = planningModulePalette(e.id)
return (
<li key={`mod-leg-${sIdx}-${e.id}`} className="tu-section-mod-legend__item">
<span
className="tu-section-mod-legend__swatch"
style={{ background: pal.border }}
title={`Farbe wie an den Zeilen (Modul #${e.id})`}
aria-hidden
/>
<span className="tu-section-mod-legend__text">
<span className="tu-section-mod-legend__title">
{(e.title || '').trim() || `Modul #${e.id}`}
</span>
<span className="tu-section-mod-legend__meta">
ID {e.id} · {e.exercises}{' '}
{e.exercises === 1 ? 'Übung' : 'Übungen'}
{e.notes > 0 ? (
<>
{' '}
· {e.notes} {e.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
</>
) : null}
</span>
</span>
</li>
)
})}
</ul>
</div>
) : null}
</div>
{useStreamTagDropUx && pl?.phaseKind === 'parallel' && parallelPhaseOrder != null
? (() => {
const bucket = sectionIndicesForParallelStream(
list,
parallelPhaseOrder,
pl.parallelStreamOrderIndex ?? 0
)
if (!bucket.length || bucket[bucket.length - 1] !== sIdx) return null
const pvA = parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0)
const soA = pl.parallelStreamOrderIndex ?? 0
const sd = dropSectionBand?.streamDrop
const appendBandActive =
!!sd &&
dropSectionBand?.slot === sectionToSlot &&
sd.po === parallelPhaseOrder &&
sd.so === soA
return (
<div
className="tu-section-stream-append"
style={{
marginBottom: '1rem',
padding: '0.45rem 0.65rem',
borderRadius: '10px',
border: `1px dashed ${pvA.border}`,
background: pvA.soft,
borderLeft: `5px solid ${pvA.border}`,
boxSizing: 'border-box',
}}
>
<div
className={
'tu-section-stream-append__drop tu-section-dropband tu-section-dropband--region-split' +
(appendBandActive ? ' tu-section-dropband--active' : '')
}
title="Abschnitt am Ende dieser Gruppe einfügen (per Ziehen)"
aria-label="Dropzone: Abschnitt ans Ende dieser parallelen Gruppe"
onDragOver={(e) =>
onStreamDropTargetDragOver(e, parallelPhaseOrder, soA)
}
onDragLeave={onStreamDropTargetDragLeave}
onDrop={(e) =>
onStreamDropTargetDrop(e, parallelPhaseOrder, soA)
}
/>
</div>
)
})()
: null}
</>
) : null}
</Fragment>
)
})}
{enableSectionDragReorder ? (
<div
className={
'tu-section-dropband tu-section-dropband--end' +
(useStreamTagDropUx ? ' tu-section-dropband--whole-plan-end' : '') +
(!useStreamTagDropUx
? sectionDropBandRegionClass(list, list.length, enableParallelPhaseControls)
: '') +
(dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === list.length &&
!dropSectionBand.streamDrop
? ' tu-section-dropband--active'
: '')
}
title={
useStreamTagDropUx
? 'Hier ablegen: neuer Abschnitt für die Ganzgruppe (am Planende)'
: 'Abschnitt am Ende einfügen'
}
onDragOver={(e) => {
if (!dtHasType(e, DND_TU_SECTION)) return
onSectionBandDragOver(e, list.length)
}}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget)) return
clearSectionDnD()
}}
onDrop={(e) => onSectionBandDrop(e, list.length)}
/>
) : null}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginTop: '0.75rem',
}}
>
{enableParallelPhaseControls ? (
<>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addParallelPhaseTwoStreams}
disabled={
list.length > 0 &&
list[list.length - 1]?.planLoc?.phaseKind === 'parallel'
}
title={
list.length > 0 && list[list.length - 1]?.planLoc?.phaseKind === 'parallel'
? 'Splitten direkt nach einem parallelen Block ist un\u00fcblich. Zuerst eine Ganzgruppen-Phase oder einen Ganzgruppen-Abschnitt anf\u00fcgen, dann erneut splitten.'
: 'Zwei parallele Gruppen mit je einem Abschnitt anlegen'
}
>
Gruppen splitten
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addWholeGroupSection}
title="Neuer Abschnitt für die gemeinsame Gruppe (legt bei Bedarf eine neue Ganzgruppen-Phase an)"
>
+ Abschnitt (Ganzgruppe)
</button>
</>
) : (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addSection}
title="Abschnitt am Ende anfügen"
>
+ Abschnitt hinzufügen
</button>
)}
</div>
{insertChooser ? (
<div
className="tu-textedit-backdrop"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeInsertChooser()
}}
>
<div
className="tu-textedit-panel"
role="dialog"
aria-modal="true"
aria-labelledby="tu-insert-chooser-title"
onMouseDown={(e) => e.stopPropagation()}
>
<h4 id="tu-insert-chooser-title" className="tu-textedit-title">
An dieser Stelle einfügen
</h4>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.86rem', color: 'var(--text2)', lineHeight: 1.45 }}>
Die neue Zeile erscheint genau hier; Reihenfolge kannst du wie gewohnt per Ziehen oder Pfeilen
ändern.
</p>
<div className="tu-insert-chooser-actions">
<button
type="button"
className="btn btn-primary tu-insert-chooser-actions__full"
onClick={() => {
const { sIdx, beforeIx } = insertChooser
closeInsertChooser()
onRequestExercisePick?.({
sectionIndex: sIdx,
insertBeforeIndex: beforeIx,
})
}}
>
Übung auswählen
</button>
{onRequestTrainingModulePick ? (
<button
type="button"
className="btn btn-secondary tu-insert-chooser-actions__full"
onClick={() => {
const ctx = { ...insertChooser }
closeInsertChooser()
onRequestTrainingModulePick({
sectionIndex: ctx.sIdx,
insertBeforeIndex: ctx.beforeIx,
})
}}
>
Trainingsmodul
</button>
) : null}
<button
type="button"
className="btn btn-secondary tu-insert-chooser-actions__full"
onClick={() => {
const { sIdx, beforeIx } = insertChooser
insertItemAt(sIdx, beforeIx, noteRow())
closeInsertChooser()
}}
>
Zwischen-Anmerkung
</button>
<button
type="button"
className="btn btn-secondary tu-insert-chooser-actions__full"
onClick={() => {
const { sIdx, beforeIx } = insertChooser
const r = noteRow()
r.note_body = SECTION_INSERT_SEPARATOR_BODY
insertItemAt(sIdx, beforeIx, r)
closeInsertChooser()
}}
>
Trennlinie
</button>
<button type="button" className="btn btn-secondary tu-insert-chooser-actions__full" onClick={closeInsertChooser}>
Abbrechen
</button>
</div>
</div>
</div>
) : null}
{comboPlanningModalItem != null &&
comboPlanningModalSX != null &&
comboPlanningModalIX != null ? (
<div
className="admin-modal-backdrop combo-planning-edit-backdrop"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setComboPlanningModal(null)
}}
>
<div
className="admin-modal-sheet combo-planning-edit-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="tu-combo-planning-title"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<div style={{ minWidth: 0 }}>
<h3 id="tu-combo-planning-title" className="admin-modal-sheet__title">
{(comboPlanningModalItem.exercise_title || '').trim() ||
`Kombination #${comboPlanningModalItem.exercise_id}`}
</h3>
<p style={{ margin: '6px 0 0', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.4 }}>
Planung für diesen Termin · {compactComboPlanningCaption(comboPlanningModalItem)}
</p>
</div>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setComboPlanningModal(null)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body">
<div className="combo-planning-edit-toolbar">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', null)
}
>
Planung wie Katalog
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
title="Bearbeitbare Kopie der Katalog-Vorgaben für diese Einheit setzen"
onClick={() =>
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', {
...(comboPlanningModalItem.catalog_method_profile || {}),
})
}
>
Aus Katalog kopieren
</button>
</div>
<p className="combo-planning-edit-hint">
Vorschau unten entspricht der effektiven Planung (Katalog oder Anpassung). Stationen und Einzelübungen
kommen aus dem Katalog; hier änderst du nur Zeiten, Runden und Steuerung für diese Einheit.
</p>
{comboPlanningResolvedSlots.length > 0 ? (
<div style={{ marginBottom: 18 }}>
<CombinationPlanBracket
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
methodProfile={comboPlanningEffectiveProfile}
combinationSlots={comboPlanningResolvedSlots}
planningAdjusted={
comboPlanningModalItem.planning_method_profile != null &&
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
!Array.isArray(comboPlanningModalItem.planning_method_profile)
}
candidateInteraction={onPeekExercise ? 'button' : 'none'}
onCandidatePeek={
onPeekExercise
? (exId) => onPeekExercise(Number(exId), null, undefined)
: undefined
}
/>
</div>
) : (
<p style={{ margin: '0 0 18px', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Stationen werden geladen oder die Kombination hat im Katalog keine Stationsliste.
</p>
)}
<div className="combo-planning-edit-card">
<h4 className="combo-planning-edit-card__title">Globale und stationsbezogene Anpassungen</h4>
<CombinationMethodProfileEditor
plannerMode
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
methodProfileJson={comboPlanningProfileJsonForEditor(
comboPlanningModalItem.catalog_method_profile || {},
comboPlanningModalItem.planning_method_profile
)}
onChangeMethodProfileJson={(json) => {
try {
const obj = JSON.parse(json || '{}')
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
const cleaned = cloneJsonSerializablePlanningProfile(obj) ?? {}
updateItem(
comboPlanningModalSX,
comboPlanningModalIX,
'planning_method_profile',
Object.keys(cleaned).length ? cleaned : null,
)
}
} catch {
/* Ungültiges JSON — Hinweis im Editor */
}
}}
comboSlotsOutline={comboPlanningSlotsOutline}
/>
</div>
</div>
</div>
</div>
) : null}
{textEdit ? (
<div
className="tu-textedit-backdrop"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setTextEdit(null)
}}
>
<div
className="tu-textedit-panel"
role="dialog"
aria-modal="true"
aria-labelledby="tu-textedit-title"
onMouseDown={(e) => e.stopPropagation()}
>
<h4 id="tu-textedit-title" className="tu-textedit-title">
{textEdit.kind === 'zwischen-note'
? 'Zwischen-Anmerkung'
: 'Anmerkung zur Übung'}
</h4>
<textarea
className="form-input tu-textedit-textarea"
rows={5}
value={textEdit.draft}
onChange={(e) =>
setTextEdit((prev) => (prev ? { ...prev, draft: e.target.value } : prev))
}
placeholder={
textEdit.kind === 'zwischen-note'
? 'Hinweise zwischen Übungen …'
: 'Kurze Anmerkung zur Übung'
}
/>
<div className="tu-textedit-actions">
<button type="button" className="btn btn-primary" onClick={applyTextEdit}>
Übernehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setTextEdit(null)}
>
Abbrechen
</button>
</div>
</div>
</div>
) : null}
</div>
)
}