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
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:
parent
a0a0be8bef
commit
514b64682c
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user