diff --git a/frontend/src/app.css b/frontend/src/app.css index 2b3891d..d512b48 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5974,6 +5974,22 @@ a.analysis-split__nav-item { background: color-mix(in srgb, var(--surface2) 55%, transparent); } +/* Stream-Tag / Planende: klar getrennte Drop-Ziele bei parallelen Phasen */ +.tu-stream-chip-pill--drop-active { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); +} + +.tu-section-dropband--whole-plan-end { + background: color-mix(in srgb, var(--accent) 14%, var(--surface2)); + border: 1px dashed color-mix(in srgb, var(--accent) 35%, transparent); + height: 14px; +} + +.tu-section-stream-append__drop { + margin: 0; + width: 100%; +} + .tu-sec-drag-grip { flex-shrink: 0; display: inline-flex; diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 0f45c04..6337b52 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -24,6 +24,8 @@ import { phaseRunsFromSections, swapAdjacentPhaseRuns, reorderBlocksImmutableWithPlanLoc, + reorderBlockIntoParallelStreamEnd, + globalInsertBeforeIndexForParallelStreamEnd, movePhaseRunUpByPhaseOrder, movePhaseRunDownByPhaseOrder, moveParallelPhaseRunToInsertBefore, @@ -281,6 +283,12 @@ export default function TrainingUnitSectionsEditor({ const sectionToSlot = slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1 + const list = ensure(sections) + const useStreamTagDropUx = + enableSectionDragReorder && + enableParallelPhaseControls && + list.some((s) => s?.planLoc?.phaseKind === 'parallel') + const updateSectionField = (sIdx, field, val) => { patch((prev) => prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s)) @@ -774,6 +782,78 @@ export default function TrainingUnitSectionsEditor({ }) } + const onStreamDropTargetDragOver = (e, phaseOrder, streamOrder) => { + if (!enableSectionDragReorder || !useStreamTagDropUx) return + if (!dtHasType(e, DND_TU_SECTION)) return + e.preventDefault() + e.stopPropagation() + try { + e.dataTransfer.dropEffect = 'move' + } catch { + /* ignore */ + } + setDropSectionBand({ + slot: sectionToSlot, + streamDrop: { po: Number(phaseOrder) || 0, so: Number(streamOrder) || 0 }, + }) + } + + const onStreamDropTargetDragLeave = (e) => { + if (e.currentTarget.contains(e.relatedTarget)) return + clearSectionDnD() + } + + const onStreamDropTargetDrop = (e, phaseOrder, streamOrder) => { + if (!enableSectionDragReorder || !useStreamTagDropUx) return + e.preventDefault() + e.stopPropagation() + clearSectionDnD() + let raw = '' + try { + raw = e.dataTransfer.getData(DND_TU_SECTION) + } catch { + return + } + if (!raw) return + let data + try { + data = JSON.parse(raw) + } catch { + return + } + if (data.phaseRunMove != null && data.phaseRunMove.phaseOrderIndex != null) { + return + } + const fromSi = data.fromSectionIdx + + if (typeof fromSi !== 'number') return + + const po = Number(phaseOrder) || 0 + const so = Number(streamOrder) || 0 + const toIdx = globalInsertBeforeIndexForParallelStreamEnd(list, po, so) + + const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 + if ( + typeof onMoveSectionsAcrossSlots === 'function' && + sectionToSlot >= 0 && + fromSlot >= 0 + ) { + onMoveSectionsAcrossSlots({ + fromSlot, + fromSectionIdx: fromSi, + toSlot: sectionToSlot, + toSectionIdx: toIdx, + }) + return + } + + patch((prev) => { + let next = reorderBlockIntoParallelStreamEnd(prev, fromSi, po, so) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } + const onItemDragStart = (e, sIdx, iIdx) => { if (!enableItemDragReorder) return e.stopPropagation() @@ -912,8 +992,6 @@ export default function TrainingUnitSectionsEditor({ ) } - const list = ensure(sections) - const planningPhaseRuns = useMemo(() => phaseRunsFromSections(list), [list]) const firstSectionIndexByParallelPhase = useMemo(() => { @@ -1082,12 +1160,6 @@ export default function TrainingUnitSectionsEditor({ const planMin = sectionPlannedMinutes(sec) const itemCount = sec.items?.length ?? 0 const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : [] - const bandActiveBefore = (bx) => - enableSectionDragReorder && - dropSectionBand && - dropSectionBand.slot === sectionToSlot && - dropSectionBand.beforeIdx === bx - const pl = sec?.planLoc const parallelPhaseOrder = enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null @@ -1101,6 +1173,22 @@ export default function TrainingUnitSectionsEditor({ enableParallelPhaseControls && pl?.phaseKind === 'parallel' && (pl.parallelStreamOrderIndex ?? 0) !== activeParallelStream + const showSectionDropBandBefore = + pl?.phaseKind !== 'parallel' || !hideParallelSection + + const bandActiveBefore = (bx) => + enableSectionDragReorder && + dropSectionBand && + dropSectionBand.slot === sectionToSlot && + dropSectionBand.beforeIdx === bx && + !dropSectionBand.streamDrop + + const streamChipDropActive = (po, so) => + useStreamTagDropUx && + dropSectionBand?.slot === sectionToSlot && + dropSectionBand?.streamDrop?.po === po && + dropSectionBand?.streamDrop?.so === so + const isFirstSectionOfParallelPhase = parallelPhaseOrder != null && firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx @@ -1112,7 +1200,7 @@ export default function TrainingUnitSectionsEditor({ return ( - {enableSectionDragReorder ? ( + {enableSectionDragReorder && showSectionDropBandBefore ? (
onStreamDropTargetDragOver(e, parallelPhaseOrder, so) + : undefined + } + onDragLeave={ + useStreamTagDropUx ? onStreamDropTargetDragLeave : undefined + } + onDrop={ + useStreamTagDropUx + ? (e) => onStreamDropTargetDrop(e, parallelPhaseOrder, so) + : undefined + } > {editingStream ? ( ) : null} {!hideParallelSection ? ( + <>
) : null}
+ {useStreamTagDropUx && pl?.phaseKind === 'parallel' && parallelPhaseOrder != null + ? (() => { + const bucket = sectionIndicesForParallelStream( + list, + parallelPhaseOrder, + pl.parallelStreamOrderIndex ?? 0 + ) + if (!bucket.length || bucket[bucket.length - 1] !== sIdx) return null + const pvA = parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0) + const soA = pl.parallelStreamOrderIndex ?? 0 + const sd = dropSectionBand?.streamDrop + const appendBandActive = + !!sd && + dropSectionBand?.slot === sectionToSlot && + sd.po === parallelPhaseOrder && + sd.so === soA + return ( +
+
+ onStreamDropTargetDragOver(e, parallelPhaseOrder, soA) + } + onDragLeave={onStreamDropTargetDragLeave} + onDrop={(e) => + onStreamDropTargetDrop(e, parallelPhaseOrder, soA) + } + /> +
+ ) + })() + : null} + ) : null} ) @@ -2105,14 +2267,22 @@ export default function TrainingUnitSectionsEditor({
{ if (!dtHasType(e, DND_TU_SECTION)) return onSectionBandDragOver(e, list.length) diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index 81c280b..e79c131 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -842,6 +842,57 @@ export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) { return arr } +/** + * Abschnitt ans Ende eines parallelen Streams setzen (planLoc wie dieser Stream). + * Leerer Stream: Einfügen hinter den letzten Abschnitt der zugehörigen parallelen Phase, planLoc vom Referenz-Abschnitt mit angepasstem streamIndex. + */ +export function reorderBlockIntoParallelStreamEnd(prev, fromI, phaseOrderIndex, streamOrderIndex) { + const po = Number(phaseOrderIndex) || 0 + const so = Number(streamOrderIndex) || 0 + const len = prev?.length ?? 0 + if (fromI < 0 || fromI >= len) return prev + const arr = [...prev] + const [moved] = arr.splice(fromI, 1) + const streamIdx = sectionIndicesForParallelStream(arr, po, so) + let insertAt + let planLocTemplate + + if (streamIdx.length) { + const last = Math.max(...streamIdx) + planLocTemplate = { ...arr[last].planLoc } + insertAt = last + 1 + } else { + const phaseIdx = indicesOfParallelPhase(arr, po) + if (!phaseIdx.length) return prev + const ref = arr[phaseIdx[phaseIdx.length - 1]] + planLocTemplate = { + ...ref.planLoc, + parallelStreamOrderIndex: so, + streamTitle: null, + streamNotes: null, + streamAssignedTrainerProfileIds: null, + } + insertAt = phaseIdx[phaseIdx.length - 1] + 1 + } + + if (fromI < insertAt) insertAt -= 1 + insertAt = Math.max(0, Math.min(insertAt, arr.length)) + arr.splice(insertAt, 0, { ...moved, planLoc: { ...planLocTemplate } }) + return arr +} + +/** Globales insertBeforeIndex, um einen Abschnitt hinter den letzten des Streams einzufügen (z. B. Rahmen-Slots). */ +export function globalInsertBeforeIndexForParallelStreamEnd(sections, phaseOrderIndex, streamOrderIndex) { + const arr = sections || [] + const po = Number(phaseOrderIndex) || 0 + const so = Number(streamOrderIndex) || 0 + const streamIdx = sectionIndicesForParallelStream(arr, po, so) + if (streamIdx.length) return Math.max(...streamIdx) + 1 + const phaseIdx = indicesOfParallelPhase(arr, po) + if (!phaseIdx.length) return arr.length + return Math.max(...phaseIdx) + 1 +} + /** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */ export function indicesOfParallelPhase(sections, phaseOrderIndex) { const po = Number(phaseOrderIndex) || 0