From 0a203aaf754ed7f0034f13a2bec35499489fa0fc Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 15 May 2026 07:37:51 +0200 Subject: [PATCH] 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. --- .../components/TrainingUnitSectionsEditor.jsx | 224 ++++++++++++++++-- .../src/utils/trainingUnitSectionsForm.js | 70 ++++++ 2 files changed, 277 insertions(+), 17 deletions(-) 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.

@@ -806,11 +912,13 @@ export default function TrainingUnitSectionsEditor({ type="button" className="btn btn-secondary" onClick={addStreamToLastParallelPhase} - disabled={!hasParallelPhase} + disabled={!hasParallelPhase || cannotAddMoreStreams} title={ - hasParallelPhase - ? 'Weiterer Stream in der letzten parallelen Phase (höchster Phasen-Index)' - : 'Zuerst eine parallele Phase anlegen' + !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 @@ -828,6 +936,29 @@ export default function TrainingUnitSectionsEditor({ 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 ( {enableSectionDragReorder ? ( @@ -846,14 +977,69 @@ export default function TrainingUnitSectionsEditor({ onDrop={(e) => onSectionBandDrop(e, sIdx)} /> ) : null} + {isFirstSectionOfParallelPhase && + enableParallelPhaseControls && + streamOrdersForParallelPhase.length ? ( +
+ {streamOrdersForParallelPhase.map((so) => { + const sel = + (parallelStreamTabByPhase[parallelPhaseOrder] ?? + streamOrdersForParallelPhase[0] ?? + 0) === so + const pv = parallelStreamVisual(so) + const lab = streamTabLabelFromIndices( + list, + sectionIndicesForParallelStream(list, parallelPhaseOrder, so), + ) + return ( + + ) + })} +
+ ) : null} + {!hideParallelSection ? (
- {enableSectionDragReorder ? ( + {allowSectionDragGrip ? ( moveSection(sIdx, -1)} - disabled={sIdx === 0} - style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }} + disabled={sectionMoveDisabledUp(sIdx)} + style={{ + padding: '4px 10px', + opacity: sectionMoveDisabledUp(sIdx) ? 0.35 : 1, + }} > ▲ @@ -899,10 +1088,10 @@ export default function TrainingUnitSectionsEditor({ type="button" aria-label="Abschnitt runter" onClick={() => moveSection(sIdx, 1)} - disabled={sIdx === list.length - 1} + disabled={sectionMoveDisabledDown(sIdx)} style={{ padding: '4px 10px', - opacity: sIdx === list.length - 1 ? 0.35 : 1, + opacity: sectionMoveDisabledDown(sIdx) ? 0.35 : 1, }} > ▼ @@ -1479,6 +1668,7 @@ export default function TrainingUnitSectionsEditor({
) : null}
+ ) : null}
) })} diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index 2177967..030ac9f 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -86,6 +86,76 @@ export function buildPlanTargetOptions(sections) { return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true })) } +/** Max. Streams pro paralleler Phase (UI + API-Schutz). */ +export const MAX_PARALLEL_STREAMS_PER_PHASE = 5 + +/** Farben pro Stream-Index (max. 5 unterschiedliche Farbzyklen). */ +export function parallelStreamVisual(streamOrderIndex) { + const n = Math.max(0, Number(streamOrderIndex) || 0) + const hues = [200, 135, 38, 285, 22] + const h = hues[n % hues.length] + return { + border: `hsl(${h} 50% 36%)`, + soft: `hsl(${h} 36% 94%)`, + tabBg: `hsl(${h} 34% 92%)`, + tabBgActive: `hsl(${h} 40% 82%)`, + } +} + +export function streamTabLabelFromIndices(sections, globalIndices) { + const first = globalIndices?.[0] + if (first === undefined || !sections?.[first]) return 'Stream' + const pl = sections[first].planLoc + const t = pl?.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : '' + if (t) return t + const so = pl?.parallelStreamOrderIndex ?? 0 + return `Stream ${so + 1}` +} + +/** Sortierte Stream-Indizes innerhalb einer parallelen Phase (für Reiter). */ +export function streamsForParallelPhaseOrders(sections, phaseOrderIndex) { + const set = new Set() + const po = Number(phaseOrderIndex) || 0 + for (const s of sections || []) { + const L = s?.planLoc + if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) { + set.add(L.parallelStreamOrderIndex ?? 0) + } + } + return [...set].sort((a, b) => a - b) +} + +/** Globale Abschnitts-Indizes eines Streams. */ +export function sectionIndicesForParallelStream(sections, phaseOrderIndex, streamOrderIndex) { + const out = [] + const po = Number(phaseOrderIndex) || 0 + const so = Number(streamOrderIndex) || 0 + ;(sections || []).forEach((s, i) => { + const L = s?.planLoc + if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po && (L.parallelStreamOrderIndex ?? 0) === so) { + out.push(i) + } + }) + return out +} + +/** Reihenfolge innerhalb eines Stream-Buckets (globale Indizes) ändern. */ +export function reorderWithinBucketIndices(prev, bucketGlobalIndicesSorted, oldPos, newPos) { + const sortedIdx = [...bucketGlobalIndicesSorted].sort((a, b) => a - b) + if (oldPos === newPos || oldPos < 0 || newPos < 0 || oldPos >= sortedIdx.length || newPos >= sortedIdx.length) { + return prev + } + const values = sortedIdx.map((gi) => prev[gi]) + const arr = [...values] + const [x] = arr.splice(oldPos, 1) + arr.splice(newPos, 0, x) + const next = [...prev] + sortedIdx.forEach((gi, k) => { + next[gi] = arr[k] + }) + return next +} + function normalizeCatalogMethodProfile(cp) { if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp } return {}