shinkan-jinkendo/frontend/src/components/TrainingUnitSectionsEditor.jsx

2277 lines
88 KiB
React
Raw Normal View History

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,
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)
}
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 (
<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 }) => 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 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 addWholeGroupPhase = () => {
patch((prev) => {
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
const pl = defaultPlanLocWholeGroup(nextPo)
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: pl }]
})
}
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}`)
return [...prev, { ...base, planLoc: tmpl }]
})
}
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) => {
const next = prev.filter((_, i) => i !== sIdx)
return next.length ? next : [defaultSection()]
})
}
/** Ganzgruppe: global tauschen; parallele Phase: nur innerhalb desselben Streams sortieren. */
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
const newPos = pos + dir
if (newPos < 0 || newPos >= bucket.length) return p
return reorderWithinBucketIndices(p, bucket, pos, newPos)
}
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 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 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 fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
if (typeof fromSi !== 'number') return
if (
typeof onMoveSectionsAcrossSlots === 'function' &&
sectionToSlot >= 0 &&
fromSlot >= 0
) {
onMoveSectionsAcrossSlots({
fromSlot,
fromSectionIdx: fromSi,
toSlot: sectionToSlot,
toSectionIdx: insertBeforeIdx,
})
return
}
patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx))
}
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 list = ensure(sections)
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)
return pos <= 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)
return pos < 0 || pos >= bucket.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 bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx
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 isFirstSectionOfParallelPhase =
parallelPhaseOrder != null &&
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
const streamVisual =
enableParallelPhaseControls && pl?.phaseKind === 'parallel'
? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0)
: null
const allowSectionDragGrip =
enableSectionDragReorder && !(enableParallelPhaseControls && pl?.phaseKind === 'parallel')
return (
<Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder ? (
<div
className={'tu-section-dropband' + (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 ? (
<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',
}}
>
<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>
</>
)
})()}
<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>
<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}`}
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%',
}}
>
{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>
) : 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>
) : null}
</Fragment>
)
})}
{enableSectionDragReorder ? (
<div
className={
'tu-section-dropband tu-section-dropband--end' +
(dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === list.length
? ' tu-section-dropband--active'
: '')
}
title="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={addWholeGroupPhase}>
Neue Ganzgruppen-Phase
</button>
<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 (nicht für einen parallelen Stream)"
>
+ Ganzgruppen-Abschnitt
</button>
</>
) : null}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addSection}
title="Letzte Zuordnung übernehmen (Phase / Stream)"
>
+ 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>
)
}