Implement visual enhancements and logic for section drop bands in TrainingUnitSectionsEditor
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m20s

- Added CSS styles for visual representation of section drop bands, differentiating between split and whole group regions.
- Introduced a new function to determine the appropriate drop band class based on section types, improving user experience during section reordering.
- Enhanced drag-and-drop functionality to support parallel phase management, allowing for more intuitive section placement within the editor.
- Updated utility functions to handle the movement of parallel phase runs, ensuring proper plan location adjustments during reordering.
This commit is contained in:
Lars 2026-05-15 08:37:16 +02:00
parent a0a0be8bef
commit 514b64682c
3 changed files with 211 additions and 13 deletions

View File

@ -5935,6 +5935,45 @@ a.analysis-split__nav-item {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
} }
/* Planungseditor: Einfügebänder — Split vs. Ganzgruppe (inaktiv sichtbar, aktiv weiterhin Akzent-Ring) */
.tu-section-dropband--region-split {
height: 12px;
background: hsl(200 38% 92%);
border: 1px dashed hsl(200 42% 58%);
}
.tu-section-dropband--region-whole {
height: 12px;
background: color-mix(in srgb, var(--accent) 10%, var(--surface2));
border: 1px dashed color-mix(in srgb, var(--accent) 32%, transparent);
}
.tu-section-dropband--region-split-to-whole {
height: 12px;
background: linear-gradient(
90deg,
hsl(200 38% 92%) 0%,
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 100%
);
border: 1px dashed hsl(200 36% 50%);
}
.tu-section-dropband--region-whole-to-split {
height: 12px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 0%,
hsl(200 38% 92%) 100%
);
border: 1px dashed hsl(200 36% 50%);
}
.tu-section-dropband--region-neutral {
height: 11px;
border: 1px dashed color-mix(in srgb, var(--border) 70%, transparent);
background: color-mix(in srgb, var(--surface2) 55%, transparent);
}
.tu-sec-drag-grip { .tu-sec-drag-grip {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;

View File

@ -26,6 +26,9 @@ import {
reorderBlocksImmutableWithPlanLoc, reorderBlocksImmutableWithPlanLoc,
movePhaseRunUpByPhaseOrder, movePhaseRunUpByPhaseOrder,
movePhaseRunDownByPhaseOrder, movePhaseRunDownByPhaseOrder,
moveParallelPhaseRunToInsertBefore,
afterSectionReorderParallelGuard,
indicesOfParallelPhase,
exerciseRow, exerciseRow,
noteRow, noteRow,
sectionPlannedMinutes, sectionPlannedMinutes,
@ -75,6 +78,31 @@ function dtHasType(e, mime) {
return Array.from(t).includes(mime) return Array.from(t).includes(mime)
} }
/** Visuelle Zuordnung der Einfügezeile zu Split- vs. Ganzgruppen-Bereich (nur Darstellung). */
function sectionDropBandRegionClass(sections, beforeIdx, enableParallel) {
if (!enableParallel) return ''
const n = sections?.length ?? 0
const below = beforeIdx < n ? sections[beforeIdx] : null
const above = beforeIdx > 0 ? sections[beforeIdx - 1] : null
const po = (s) => s?.planLoc?.phaseOrderIndex ?? 0
const aboveP = above?.planLoc?.phaseKind === 'parallel'
const belowP = below?.planLoc?.phaseKind === 'parallel'
const aboveW = above?.planLoc?.phaseKind === 'whole_group'
const belowW = below?.planLoc?.phaseKind === 'whole_group'
if (aboveP && belowP && po(above) === po(below)) {
return ' tu-section-dropband--region-split'
}
if (aboveW && belowW) return ' tu-section-dropband--region-whole'
if (aboveP && belowW) return ' tu-section-dropband--region-split-to-whole'
if (aboveW && belowP) return ' tu-section-dropband--region-whole-to-split'
if (!above && belowP) return ' tu-section-dropband--region-split'
if (!below && aboveP) return ' tu-section-dropband--region-split-to-whole'
if (!above && belowW) return ' tu-section-dropband--region-whole'
if (!below && aboveW) return ' tu-section-dropband--region-whole'
return ' tu-section-dropband--region-neutral'
}
function truncatePreview(text, max = 160) { function truncatePreview(text, max = 160) {
const t = (text || '').replace(/\s+/g, ' ').trim() const t = (text || '').replace(/\s+/g, ' ').trim()
if (t.length <= max) return t if (t.length <= max) return t
@ -298,7 +326,13 @@ export default function TrainingUnitSectionsEditor({
streamAssignedTrainerProfileIds: null, streamAssignedTrainerProfileIds: null,
} }
const base = defaultSection(`Abschnitt ${prev.length + 1}`) const base = defaultSection(`Abschnitt ${prev.length + 1}`)
return [...prev, { ...base, planLoc: tmpl }] const inPhaseIdx = indicesOfParallelPhase(prev, po)
const insertAfter = inPhaseIdx.length ? Math.max(...inPhaseIdx) : prev.length - 1
return [
...prev.slice(0, insertAfter + 1),
{ ...base, planLoc: tmpl },
...prev.slice(insertAfter + 1),
]
}) })
} }
@ -633,6 +667,25 @@ export default function TrainingUnitSectionsEditor({
const clearSectionDnD = () => setDropSectionBand(null) const clearSectionDnD = () => setDropSectionBand(null)
const onParallelPhaseDragStart = (e, phaseOrderIndex) => {
if (!enableSectionDragReorder || !enableParallelPhaseControls) return
e.stopPropagation()
try {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(
DND_TU_SECTION,
JSON.stringify({
fromSlot: sectionToSlot,
fromSectionIdx: null,
phaseRunMove: { phaseOrderIndex },
})
)
} catch {
/* ignore */
}
setDropSectionBand(null)
}
const onSectionDragStart = (e, sIdx) => { const onSectionDragStart = (e, sIdx) => {
if (!enableSectionDragReorder) return if (!enableSectionDragReorder) return
e.stopPropagation() e.stopPropagation()
@ -683,7 +736,19 @@ export default function TrainingUnitSectionsEditor({
return return
} }
const fromSi = data.fromSectionIdx const fromSi = data.fromSectionIdx
const phaseRunMove = data.phaseRunMove
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) {
patch((prev) => {
const po = Number(phaseRunMove.phaseOrderIndex) || 0
let next = moveParallelPhaseRunToInsertBefore(prev, po, insertBeforeIdx)
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
})
return
}
if (typeof fromSi !== 'number') return if (typeof fromSi !== 'number') return
if ( if (
@ -701,10 +766,11 @@ export default function TrainingUnitSectionsEditor({
} }
patch((prev) => { patch((prev) => {
if (enableParallelPhaseControls) { let next = enableParallelPhaseControls
return reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx) ? reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx)
} : reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)
return reorderBlocksImmutable(prev, fromSi, insertBeforeIdx) if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
return next
}) })
} }
@ -1048,7 +1114,11 @@ export default function TrainingUnitSectionsEditor({
<Fragment key={`secFrag-${sIdx}`}> <Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder ? ( {enableSectionDragReorder ? (
<div <div
className={'tu-section-dropband' + (bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')} className={
'tu-section-dropband' +
sectionDropBandRegionClass(list, sIdx, enableParallelPhaseControls) +
(bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')
}
title="Abschnitt hier einfügen" title="Abschnitt hier einfügen"
onDragOver={(e) => { onDragOver={(e) => {
if (!enableSectionDragReorder) return if (!enableSectionDragReorder) return
@ -1083,6 +1153,19 @@ export default function TrainingUnitSectionsEditor({
marginBottom: '10px', marginBottom: '10px',
}} }}
> >
{enableSectionDragReorder ? (
<span
className="tu-sec-drag-grip"
draggable
onDragStart={(e) => onParallelPhaseDragStart(e, parallelPhaseOrder)}
role="button"
tabIndex={0}
aria-label="Parallele Phase ziehen"
title="Gesamte parallele Phase an neue Planposition ziehen"
>
<GripVertical size={16} strokeWidth={2} aria-hidden />
</span>
) : null}
<label <label
className="form-label" className="form-label"
style={{ fontSize: '0.78rem', marginBottom: 0, flex: '0 0 auto' }} style={{ fontSize: '0.78rem', marginBottom: 0, flex: '0 0 auto' }}
@ -2022,6 +2105,7 @@ export default function TrainingUnitSectionsEditor({
<div <div
className={ className={
'tu-section-dropband tu-section-dropband--end' + 'tu-section-dropband tu-section-dropband--end' +
sectionDropBandRegionClass(list, list.length, enableParallelPhaseControls) +
(dropSectionBand && (dropSectionBand &&
dropSectionBand.slot === sectionToSlot && dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === list.length dropSectionBand.beforeIdx === list.length

View File

@ -799,8 +799,8 @@ export function movePhaseRunDownByPhaseOrder(prev, phaseOrderIndex) {
} }
/** /**
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen * Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen.
* (z. B. Ganzgruppe in parallelen Stream). * Regel: Einfügen vor Abschnitt X übernimmt X.planLoc; am Listenende nach Parallel neue Ganzgruppen-Phase.
*/ */
export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) { export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
const arr = [...prev] const arr = [...prev]
@ -809,12 +809,32 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
let insertAt = toBeforeIdx let insertAt = toBeforeIdx
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1 if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
insertAt = Math.max(0, Math.min(insertAt, arr.length)) insertAt = Math.max(0, Math.min(insertAt, arr.length))
const below = arr[insertAt]
const above = arr[insertAt - 1] const below = insertAt < arr.length ? arr[insertAt] : undefined
const ref = below ?? above const above = insertAt > 0 ? arr[insertAt - 1] : undefined
let planLocNext = null
if (below?.planLoc?.phaseKind) {
planLocNext = { ...below.planLoc }
} else if (insertAt === arr.length) {
if (!above) {
planLocNext = defaultPlanLocWholeGroup(0)
} else if (above.planLoc?.phaseKind === 'parallel') {
const mx = maxPhaseOrderIndexFromSections(arr)
planLocNext = defaultPlanLocWholeGroup(mx + 1)
} else if (above.planLoc?.phaseKind === 'whole_group') {
planLocNext = { ...above.planLoc }
}
} else if (above?.planLoc?.phaseKind === 'whole_group') {
planLocNext = { ...above.planLoc }
} else if (above?.planLoc?.phaseKind === 'parallel') {
const mx = maxPhaseOrderIndexFromSections(arr)
planLocNext = defaultPlanLocWholeGroup(mx + 1)
}
let nextMoved = { ...moved } let nextMoved = { ...moved }
if (ref?.planLoc?.phaseKind) { if (planLocNext) {
nextMoved = { ...moved, planLoc: { ...ref.planLoc } } nextMoved = { ...moved, planLoc: planLocNext }
} else { } else {
nextMoved = stripPlanLoc(moved) nextMoved = stripPlanLoc(moved)
} }
@ -822,6 +842,61 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
return arr return arr
} }
/** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */
export function indicesOfParallelPhase(sections, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const out = []
;(sections || []).forEach((s, i) => {
const L = s?.planLoc
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) out.push(i)
})
return out.sort((a, b) => a - b)
}
/** Gesamten Parallel-Block an neue Position (insertBefore globale Liste) schieben. */
export function moveParallelPhaseRunToInsertBefore(prev, phaseOrderIndex, toBeforeIdx) {
const po = Number(phaseOrderIndex) || 0
const indices = indicesOfParallelPhase(prev, po)
if (!indices.length) return prev
const indexSet = new Set(indices)
const blocks = indices.map((i) => prev[i])
const without = prev.filter((_, i) => !indexSet.has(i))
let ins = toBeforeIdx
for (const i of indices) {
if (i < toBeforeIdx) ins -= 1
}
ins = Math.max(0, Math.min(ins, without.length))
return [...without.slice(0, ins), ...blocks, ...without.slice(ins)]
}
/**
* Nach Drag&Drop: wenn aus einer Parallelphase noch 1 Stream übrig ist (vorher 2), Rückfrage wie beim Stream-Löschen.
*/
export function afterSectionReorderParallelGuard(prev, next) {
const seenPo = new Set()
for (const s of prev || []) {
const L = s?.planLoc
if (L?.phaseKind !== 'parallel') continue
seenPo.add(L.phaseOrderIndex ?? 0)
}
let out = next
for (const po of seenPo) {
const prevN = streamsForParallelPhaseOrders(prev, po).length
if (prevN < 2) continue
const nowN = streamsForParallelPhaseOrders(out, po).length
if (nowN <= 1) {
if (
window.confirm(
'In dieser parallelen Phase ist nur noch eine Gruppe übrig. Parallelaufbau auflösen und alle zugehörigen Abschnitte als gemeinsame Ganzgruppen-Phase führen?'
)
) {
out = dissolveParallelPhaseToWholeGroup(out, po)
}
}
}
return out
}
function buildPhasesPayloadFromFlat(sections) { function buildPhasesPayloadFromFlat(sections) {
const norm = inheritPlanLocForPhasedSave(sections) const norm = inheritPlanLocForPhasedSave(sections)
const phases = [] const phases = []