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. -

-
- - - -
-
- ) : 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 ( - - ) - })} +
+ + { + 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})`} + /> + + +
+
+ {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}`} + /> + +
+ ) + })} +
) : 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}