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) => (
- {tx}
))}
)}
{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}