diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 6577d1b..505dbee 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -13,6 +13,12 @@ import {
maxPhaseOrderIndexFromSections,
buildPlanTargetOptions,
planLocKey,
+ MAX_PARALLEL_STREAMS_PER_PHASE,
+ parallelStreamVisual,
+ streamTabLabelFromIndices,
+ streamsForParallelPhaseOrders,
+ sectionIndicesForParallelStream,
+ reorderWithinBucketIndices,
exerciseRow,
noteRow,
sectionPlannedMinutes,
@@ -279,6 +285,8 @@ export default function TrainingUnitSectionsEditor({
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 = {
@@ -313,13 +321,27 @@ export default function TrainingUnitSectionsEditor({
})
}
+ /** Ganzgruppe: global tauschen; parallele Phase: nur innerhalb desselben Streams sortieren. */
const moveSection = (sIdx, dir) => {
patch((prev) => {
- const p = [...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 >= p.length) return p
- ;[p[sIdx], p[ta]] = [p[ta], p[sIdx]]
- return p
+ if (ta < 0 || ta >= arr.length) return arr
+ ;[arr[sIdx], arr[ta]] = [arr[ta], arr[sIdx]]
+ return arr
})
}
@@ -399,6 +421,8 @@ export default function TrainingUnitSectionsEditor({
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(() => {
@@ -694,6 +718,87 @@ export default function TrainingUnitSectionsEditor({
[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 }
@@ -791,8 +896,9 @@ export default function TrainingUnitSectionsEditor({
Breakout: Phasen und parallele Streams
- Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. „Abschnitt hinzufügen“ unten
- übernimmt die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '}
+ 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{' '}
phases-Payload fürs Backend.