From 2e761161ef5bdc5cc2139aeab8613d6466666586 Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 15 May 2026 07:50:16 +0200
Subject: [PATCH] Enhance TrainingUnitSectionsEditor with new section
management features
- Introduced functions to add and manage parallel phases and sections, allowing for more flexible training unit configurations.
- Implemented logic to handle the addition of whole group sections and parallel streams, improving the user experience in the editor.
- Added utility functions for reordering sections and checking for content within parallel stream buckets.
- Updated state management to ensure proper handling of section titles and removal of streams, enhancing overall functionality.
---
.../components/TrainingUnitSectionsEditor.jsx | 430 +++++++++++++-----
.../src/utils/trainingUnitSectionsForm.js | 46 +-
2 files changed, 355 insertions(+), 121 deletions(-)
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 505dbee..8785f7f 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -1,5 +1,5 @@
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
-import { GripVertical, Pencil } from 'lucide-react'
+import { GripVertical, Pencil, X } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
import CombinationPlanBracket from './CombinationPlanBracket'
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
@@ -15,10 +15,12 @@ import {
planLocKey,
MAX_PARALLEL_STREAMS_PER_PHASE,
parallelStreamVisual,
- streamTabLabelFromIndices,
streamsForParallelPhaseOrders,
sectionIndicesForParallelStream,
reorderWithinBucketIndices,
+ reorderWithoutIndices,
+ parallelStreamBucketHasContent,
+ dissolveParallelPhaseToWholeGroup,
exerciseRow,
noteRow,
sectionPlannedMinutes,
@@ -270,27 +272,30 @@ export default function TrainingUnitSectionsEditor({
})
}
- const addParallelPhase = () => {
+ const addParallelPhaseTwoStreams = () => {
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 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 addStreamToLastParallelPhase = () => {
+ const addStreamToParallelPhase = (phaseOrder) => {
patch((prev) => {
- const par = (prev || []).filter((s) => s?.planLoc?.phaseKind === 'parallel')
+ 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 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 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 = {
- ...inPhase[0].planLoc,
+ ...par[0].planLoc,
parallelStreamOrderIndex: newSo,
streamTitle: null,
streamNotes: null,
@@ -301,6 +306,110 @@ export default function TrainingUnitSectionsEditor({
})
}
+ 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) {
@@ -713,11 +822,6 @@ export default function TrainingUnitSectionsEditor({
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) => {
@@ -737,15 +841,6 @@ export default function TrainingUnitSectionsEditor({
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) => {
@@ -881,51 +976,6 @@ export default function TrainingUnitSectionsEditor({
) : null}
) : null}
- {enableParallelPhaseControls ? (
-
-
- Breakout: Phasen und parallele Streams
-
-
- 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.
-
-
-
- Neue Ganzgruppen-Phase
-
-
- Neue parallele Phase
-
-
- Stream hinzufügen
-
-
-
- ) : null}
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
@@ -981,51 +1031,142 @@ export default function TrainingUnitSectionsEditor({
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 (
-
- setParallelStreamTabByPhase((prev) => ({ ...prev, [parallelPhaseOrder]: so }))
- }
- >
- {lab}
-
- )
- })}
+
+
+ Phase
+
+ {
+ const hi = firstSectionIndexByParallelPhase.get(parallelPhaseOrder)
+ const t = hi != null ? list[hi]?.planLoc?.phaseTitle : null
+ return t != null ? String(t) : ''
+ })()
+ }
+ onChange={(e) => updateParallelPhaseTitleAll(parallelPhaseOrder, e.target.value)}
+ placeholder={`Bezeichnung (z. B. Drill runden · Phase ${parallelPhaseOrder})`}
+ />
+ 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
+
+
+ addSectionToParallelStream(parallelPhaseOrder, activeParallelStream ?? 0)
+ }
+ title="Neuer Abschnitt im gerade gewählten Stream"
+ >
+ + Abschnitt in diesem Stream
+
+
+
+ {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) : ''
+ return (
+
+ setParallelStreamTabByPhase((prev) => ({ ...prev, [parallelPhaseOrder]: so }))
+ }
+ >
+
+ updateParallelStreamTitleAll(parallelPhaseOrder, so, e.target.value)
+ }
+ onClick={(e) => e.stopPropagation()}
+ placeholder={`Gruppe ${so + 1}`}
+ aria-label={`Name Stream ${so + 1}`}
+ />
+ {
+ e.stopPropagation()
+ removeParallelStream(parallelPhaseOrder, so)
+ }}
+ >
+
+
+
+ )
+ })}
+
) : null}
{!hideParallelSection ? (
@@ -1115,8 +1256,24 @@ export default function TrainingUnitSectionsEditor({
}}
>
{sec.planLoc.phaseKind === 'whole_group'
- ? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}`
- : `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`}
+ ? (() => {
+ const pt = sec.planLoc.phaseTitle
+ const po = sec.planLoc.phaseOrderIndex ?? 0
+ return pt != null && String(pt).trim()
+ ? `Ganzgruppe: ${String(pt).trim()} (Phase ${po})`
+ : `Ganzgruppen-Phase ${po}`
+ })()
+ : (() => {
+ const pt = sec.planLoc.phaseTitle
+ const st = sec.planLoc.streamTitle
+ const po = sec.planLoc.phaseOrderIndex ?? 0
+ const so = sec.planLoc.parallelStreamOrderIndex ?? 0
+ const phaseLbl =
+ pt != null && String(pt).trim() ? String(pt).trim() : `Phase ${po}`
+ const streamLbl =
+ st != null && String(st).trim() ? String(st).trim() : `Gruppe ${so + 1}`
+ return `Parallel · ${phaseLbl} · ${streamLbl}`
+ })()}
) : null}
) : null}
-
- + Abschnitt hinzufügen
-
+ {enableParallelPhaseControls ? (
+ <>
+
+ Neue Ganzgruppen-Phase
+
+
+ Zwei Gruppen parallel
+
+
+ + Ganzgruppen-Abschnitt
+
+ >
+ ) : null}
+
+ + Abschnitt hinzufügen
+
+
{insertChooser ? (
!set.has(i))
+ return next.length ? next : [defaultSection()]
+}
+
+/**
+ * Ob in den Abschnitten eines Stream-Buckets planerisch etwas steht (Übungen, Text-Anmerkungen).
+ * Trennlinien-Marker (---) zählen nicht als Inhalt.
+ */
+export function parallelStreamBucketHasContent(sections, globalIndices, separatorBody = '---') {
+ for (const gi of globalIndices || []) {
+ const sec = (sections || [])[gi]
+ if (!sec) continue
+ for (const it of sec.items || []) {
+ if ((it.item_type || '') === 'note') {
+ const b = (it.note_body || '').trim()
+ if (b && b !== separatorBody) return true
+ } else {
+ if (it.exercise_id) return true
+ if ((it.exercise_title || '').trim()) return true
+ }
+ }
+ }
+ return false
+}
+
+/** Parallele Phase auflösen: alle Abschnitte dieser Phase werden Ganzgruppe (gleicher phaseOrderIndex). */
+export function dissolveParallelPhaseToWholeGroup(sections, phaseOrderIndex) {
+ const po = Number(phaseOrderIndex) || 0
+ return (sections || []).map((s) => {
+ const L = s?.planLoc
+ if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s
+ return {
+ ...s,
+ planLoc: {
+ ...defaultPlanLocWholeGroup(po),
+ phaseTitle: L.phaseTitle ?? null,
+ phaseGuidanceNotes: L.phaseGuidanceNotes ?? null,
+ },
+ }
+ })
+}
+
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) {