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);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import {
|
|||
reorderBlocksImmutableWithPlanLoc,
|
||||
movePhaseRunUpByPhaseOrder,
|
||||
movePhaseRunDownByPhaseOrder,
|
||||
moveParallelPhaseRunToInsertBefore,
|
||||
afterSectionReorderParallelGuard,
|
||||
indicesOfParallelPhase,
|
||||
exerciseRow,
|
||||
noteRow,
|
||||
sectionPlannedMinutes,
|
||||
|
|
@ -75,6 +78,31 @@ function dtHasType(e, 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) {
|
||||
const t = (text || '').replace(/\s+/g, ' ').trim()
|
||||
if (t.length <= max) return t
|
||||
|
|
@ -298,7 +326,13 @@ export default function TrainingUnitSectionsEditor({
|
|||
streamAssignedTrainerProfileIds: null,
|
||||
}
|
||||
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 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) => {
|
||||
if (!enableSectionDragReorder) return
|
||||
e.stopPropagation()
|
||||
|
|
@ -683,7 +736,19 @@ export default function TrainingUnitSectionsEditor({
|
|||
return
|
||||
}
|
||||
const fromSi = data.fromSectionIdx
|
||||
const phaseRunMove = data.phaseRunMove
|
||||
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 (
|
||||
|
|
@ -701,10 +766,11 @@ export default function TrainingUnitSectionsEditor({
|
|||
}
|
||||
|
||||
patch((prev) => {
|
||||
if (enableParallelPhaseControls) {
|
||||
return reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx)
|
||||
}
|
||||
return reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)
|
||||
let next = enableParallelPhaseControls
|
||||
? reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx)
|
||||
: reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)
|
||||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1048,7 +1114,11 @@ export default function TrainingUnitSectionsEditor({
|
|||
<Fragment key={`secFrag-${sIdx}`}>
|
||||
{enableSectionDragReorder ? (
|
||||
<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"
|
||||
onDragOver={(e) => {
|
||||
if (!enableSectionDragReorder) return
|
||||
|
|
@ -1083,6 +1153,19 @@ export default function TrainingUnitSectionsEditor({
|
|||
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
|
||||
className="form-label"
|
||||
style={{ fontSize: '0.78rem', marginBottom: 0, flex: '0 0 auto' }}
|
||||
|
|
@ -2022,6 +2105,7 @@ export default function TrainingUnitSectionsEditor({
|
|||
<div
|
||||
className={
|
||||
'tu-section-dropband tu-section-dropband--end' +
|
||||
sectionDropBandRegionClass(list, list.length, enableParallelPhaseControls) +
|
||||
(dropSectionBand &&
|
||||
dropSectionBand.slot === sectionToSlot &&
|
||||
dropSectionBand.beforeIdx === list.length
|
||||
|
|
|
|||
|
|
@ -799,8 +799,8 @@ export function movePhaseRunDownByPhaseOrder(prev, phaseOrderIndex) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen
|
||||
* (z. B. Ganzgruppe in parallelen Stream).
|
||||
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen.
|
||||
* Regel: Einfügen vor Abschnitt X übernimmt X.planLoc; am Listenende nach Parallel → neue Ganzgruppen-Phase.
|
||||
*/
|
||||
export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
|
||||
const arr = [...prev]
|
||||
|
|
@ -809,12 +809,32 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
|
|||
let insertAt = toBeforeIdx
|
||||
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
|
||||
insertAt = Math.max(0, Math.min(insertAt, arr.length))
|
||||
const below = arr[insertAt]
|
||||
const above = arr[insertAt - 1]
|
||||
const ref = below ?? above
|
||||
|
||||
const below = insertAt < arr.length ? arr[insertAt] : undefined
|
||||
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 }
|
||||
if (ref?.planLoc?.phaseKind) {
|
||||
nextMoved = { ...moved, planLoc: { ...ref.planLoc } }
|
||||
if (planLocNext) {
|
||||
nextMoved = { ...moved, planLoc: planLocNext }
|
||||
} else {
|
||||
nextMoved = stripPlanLoc(moved)
|
||||
}
|
||||
|
|
@ -822,6 +842,61 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
|
|||
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) {
|
||||
const norm = inheritPlanLocForPhasedSave(sections)
|
||||
const phases = []
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user