shinkan-jinkendo/frontend/src/components/TrainingUnitSectionsEditor.jsx
Lars 0a203aaf75
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m10s
Enhance TrainingUnitSectionsEditor with parallel phase management features
- Introduced logic to limit the number of streams per parallel phase, ensuring compliance with the defined maximum.
- Added utility functions for managing stream indices and visual representation of streams.
- Implemented section movement within parallel streams, allowing for reordering while maintaining stream integrity.
- Updated UI components to reflect changes in stream handling, including disabling buttons when limits are reached.
- Enhanced state management for parallel stream tabs, improving user experience in navigating between streams.
2026-05-15 07:37:51 +02:00

1963 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { GripVertical, Pencil } 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,
streamTabLabelFromIndices,
streamsForParallelPhaseOrders,
sectionIndicesForParallelStream,
reorderWithinBucketIndices,
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 PlanungsOverride vs. Katalog (Archetyp steht bereits links). */
function compactComboPlanningCaption(it) {
const overridden =
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
return overridden ? 'Planung angepasst' : 'wie Katalog'
}
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
function planningModulePalette(moduleId) {
const id = normalizedPlanningModuleChainId(moduleId)
const n = id != null && id >= 1 ? Math.floor(Number(id)) : 1
const golden = ((n >>> 0) * 2654435761 + n * 73856093) >>> 0
const h = golden % 360
const border = `hsl(${h} 52% 36%)`
const soft = `hsl(${h} 42% 94%)`
return { border, soft, hue: h }
}
function PlanningModuleRowTag({ moduleId, title }) {
const p = planningModulePalette(moduleId)
const lbl = truncatePreview(title || `Modul #${moduleId}`, 34).trim()
const fullTitle = ((title || '').trim() || `Modul #${moduleId}`).trim()
return (
<span
className="tu-planning-mod-tag"
style={{ borderColor: p.border, backgroundColor: p.soft }}
title={`${fullTitle} (Bibliotheks-ID ${moduleId})`}
>
<span className="tu-planning-mod-tag__dot" style={{ background: p.border }} aria-hidden />
<span className="tu-planning-mod-tag__text">Aus Modul: {lbl}</span>
</span>
)
}
/** Eindeutige Module im Abschnitt mit Zählerständen für die Legende. */
function sectionModuleLegendModel(items) {
const map = new Map()
for (const row of items || []) {
const id = normalizedPlanningModuleChainId(row.source_training_module_id)
if (id == null) continue
if (!map.has(id)) {
map.set(id, {
id,
title: (((row.source_module_title || '').trim() || '') || `Modul #${id}`).trim(),
exercises: 0,
notes: 0,
})
}
const agg = map.get(id)
if ((row.item_type || '') === 'note') {
const bod = ((row.note_body || '').trim() || '').trim()
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
agg.notes += 1
} else {
agg.exercises += 1
}
}
return [...map.values()].sort((a, b) => a.id - b.id)
}
function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
if (!showModuleBand || !modOutline) return null
return (
<div className="tu-module-bundle-head" role="group" aria-label={`Modul ${modBandTitle}`}>
<div className="tu-module-bundle-head__stripe" aria-hidden />
<div className="tu-module-bundle-head__main">
<span className="tu-module-bundle-head__kicker">Aus Modul</span>
<strong className="tu-module-bundle-head__title">{modBandTitle}</strong>
{modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
<p className="tu-module-bundle-head__empty">Ohne strukturierten Inhalt angezeigt.</p>
) : (
<ol className="tu-module-bundle-head__list" start={1}>
{modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
<li key={`mo-li-${modBandTitle}-${ox}`}>{tx}</li>
))}
</ol>
)}
{modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
<p className="tu-module-bundle-head__more">
und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
{modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
</p>
) : null}
{modOutline.notes > 0 ? (
<p className="tu-module-bundle-head__meta">
sowie {modOutline.notes}{' '}
{modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
</p>
) : null}
</div>
</div>
)
}
function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
const b = [...blocks]
if (fromI < 0 || fromI >= b.length) return blocks
const [moved] = b.splice(fromI, 1)
let insertAt = toBeforeIdx
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
insertAt = Math.max(0, Math.min(insertAt, b.length))
b.splice(insertAt, 0, moved)
return b
}
/**
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
* @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number }) => 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 addParallelPhase = () => {
patch((prev) => {
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
const pl = defaultPlanLocParallel(nextPo, 0)
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: pl }]
})
}
const addStreamToLastParallelPhase = () => {
patch((prev) => {
const par = (prev || []).filter((s) => s?.planLoc?.phaseKind === 'parallel')
if (!par.length) return prev
const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0))
const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP)
const distinctStreams = new Set(inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
if (distinctStreams.size >= MAX_PARALLEL_STREAMS_PER_PHASE) return prev
const maxS = Math.max(...inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
const newSo = maxS + 1
const tmpl = {
...inPhase[0].planLoc,
parallelStreamOrderIndex: newSo,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: tmpl }]
})
}
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({})
/** { 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 hasParallelPhase = useMemo(
() => list.some((s) => s?.planLoc?.phaseKind === 'parallel'),
[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])
const cannotAddMoreStreams = useMemo(() => {
const par = list.filter((s) => s?.planLoc?.phaseKind === 'parallel')
if (!par.length) return true
const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0))
const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP)
const distinct = new Set(inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
return distinct.size >= MAX_PARALLEL_STREAMS_PER_PHASE
}, [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}
{enableParallelPhaseControls ? (
<div
className="card"
style={{
marginBottom: '1rem',
padding: '12px 14px',
background: 'var(--surface2)',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
borderRadius: '10px',
}}
>
<div style={{ fontSize: '0.88rem', fontWeight: 600, marginBottom: '6px', color: 'var(--text1)' }}>
Breakout: Phasen und parallele Streams
</div>
<p style={{ margin: '0 0 12px', fontSize: '0.8rem', color: 'var(--text2)', lineHeight: 1.5 }}>
Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. Pro paralleler Phase erscheinen
Reiter je Stream nur der aktive Stream ist sichtbar (farbig am linken Rand). Abschnitt hinzufügen übernimmt
die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '}
<code style={{ fontSize: '0.78em' }}>phases</code>-Payload fürs Backend.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={addWholeGroupPhase}>
Neue Ganzgruppen-Phase
</button>
<button type="button" className="btn btn-secondary" onClick={addParallelPhase}>
Neue parallele Phase
</button>
<button
type="button"
className="btn btn-secondary"
onClick={addStreamToLastParallelPhase}
disabled={!hasParallelPhase || cannotAddMoreStreams}
title={
!hasParallelPhase
? 'Zuerst eine parallele Phase anlegen'
: cannotAddMoreStreams
? `Höchstens ${MAX_PARALLEL_STREAMS_PER_PHASE} Streams pro Phase`
: 'Weiterer Stream in der letzten parallelen Phase (höchster Phasen-Index)'
}
>
Stream hinzufügen
</button>
</div>
</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
role="tablist"
aria-label={`Parallele Streams · Phase ${parallelPhaseOrder}`}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '10px',
padding: '8px 10px',
background: 'var(--surface2)',
borderRadius: '10px',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
}}
>
{streamOrdersForParallelPhase.map((so) => {
const sel =
(parallelStreamTabByPhase[parallelPhaseOrder] ??
streamOrdersForParallelPhase[0] ??
0) === so
const pv = parallelStreamVisual(so)
const lab = streamTabLabelFromIndices(
list,
sectionIndicesForParallelStream(list, parallelPhaseOrder, so),
)
return (
<button
key={`p${parallelPhaseOrder}-tab-s${so}`}
type="button"
role="tab"
aria-selected={sel}
className="btn framework-ctrl framework-ctrl--xs"
style={{
margin: 0,
border: sel ? `2px solid ${pv.border}` : `1px solid ${pv.border}`,
background: sel ? pv.tabBgActive : pv.tabBg,
color: 'var(--text1)',
fontWeight: sel ? 600 : 500,
}}
onClick={() =>
setParallelStreamTabByPhase((prev) => ({ ...prev, [parallelPhaseOrder]: so }))
}
>
{lab}
</button>
)
})}
</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'
? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}`
: `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`}
</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}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={addSection}
>
+ Abschnitt hinzufügen
</button>
{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>
)
}