All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m12s
- Added a check to ensure that the source slot is not the same as the target slot when moving sections across slots, enhancing the logic for section management and preventing unnecessary operations.
2874 lines
111 KiB
JavaScript
2874 lines
111 KiB
JavaScript
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { GripVertical, Pencil, X } from 'lucide-react'
|
||
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
|
||
import CombinationPlanBracket from './CombinationPlanBracket'
|
||
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||
import {
|
||
cloneJsonSerializablePlanningProfile,
|
||
comboSlotsOutlineForProfileEditor,
|
||
defaultSection,
|
||
defaultPlanLocWholeGroup,
|
||
defaultPlanLocParallel,
|
||
maxPhaseOrderIndexFromSections,
|
||
buildPlanTargetOptions,
|
||
planLocKey,
|
||
MAX_PARALLEL_STREAMS_PER_PHASE,
|
||
parallelStreamVisual,
|
||
streamsForParallelPhaseOrders,
|
||
sectionIndicesForParallelStream,
|
||
reorderWithinBucketIndices,
|
||
reorderWithoutIndices,
|
||
parallelStreamBucketHasContent,
|
||
dissolveParallelPhaseToWholeGroup,
|
||
phaseRunsFromSections,
|
||
swapAdjacentPhaseRuns,
|
||
reorderBlocksImmutableWithPlanLoc,
|
||
reorderSectionBeforeParallelRunAsWholeGroup,
|
||
reorderSectionAsFirstInParallelStream,
|
||
reorderBlockIntoParallelStreamEnd,
|
||
globalInsertBeforeIndexForParallelStreamEnd,
|
||
movePhaseRunUpByPhaseOrder,
|
||
movePhaseRunDownByPhaseOrder,
|
||
moveParallelPhaseRunToInsertBefore,
|
||
afterSectionReorderParallelGuard,
|
||
indicesOfParallelPhase,
|
||
exerciseRow,
|
||
noteRow,
|
||
sectionPlannedMinutes,
|
||
} from '../utils/trainingUnitSectionsForm'
|
||
import api from '../utils/api'
|
||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||
import { useAuth } from '../context/AuthContext'
|
||
|
||
function stripPlanLocFromSection(s) {
|
||
if (!s || typeof s !== 'object') return s
|
||
const { planLoc: _ignored, ...rest } = s
|
||
return rest
|
||
}
|
||
|
||
function planSelectOptionsForSection(sections, sIdx, baseOpts) {
|
||
const sec = sections[sIdx]
|
||
const k = planLocKey(sec?.planLoc)
|
||
if (k && !baseOpts.some((o) => o.key === k)) {
|
||
const pl = sec.planLoc
|
||
const label =
|
||
pl.phaseKind === 'parallel'
|
||
? `Parallel · Phase ${pl.phaseOrderIndex ?? 0} · Stream ${pl.parallelStreamOrderIndex ?? 0}`
|
||
: `Ganzgruppe · Phase ${pl.phaseOrderIndex ?? 0}`
|
||
return [...baseOpts, { key: k, label, template: { ...pl } }].sort((a, b) =>
|
||
a.key.localeCompare(b.key, undefined, { numeric: true })
|
||
)
|
||
}
|
||
return baseOpts
|
||
}
|
||
|
||
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
|
||
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
||
|
||
/** Optische Trennlinie: wird als normale Zwischen-Anmerkung gespeichert (Inhalt nur dieser Marker). */
|
||
const SECTION_INSERT_SEPARATOR_BODY = '---'
|
||
|
||
function normalizedPlanningModuleChainId(raw) {
|
||
if (raw == null || raw === '') return null
|
||
const n = typeof raw === 'number' ? raw : Number(raw)
|
||
return Number.isFinite(n) && n >= 1 ? n : null
|
||
}
|
||
|
||
function dtHasType(e, mime) {
|
||
const t = e?.dataTransfer?.types
|
||
if (!t || !mime) return false
|
||
if (typeof t.contains === 'function' && t.contains(mime)) return true
|
||
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
|
||
return `${t.slice(0, max - 1)}…`
|
||
}
|
||
|
||
/** Liest den zusammenhängenden Lauf eines Moduls im Abschnitt (ab erstem Item mit dieser Herkunfts-ID). */
|
||
function gatherPlanningModuleOutline(items, startIdx, moduleId) {
|
||
const exercises = []
|
||
let notes = 0
|
||
for (let j = startIdx; j < (items?.length ?? 0); j++) {
|
||
const row = items[j]
|
||
if (normalizedPlanningModuleChainId(row.source_training_module_id) !== moduleId) break
|
||
if (row.item_type === 'note') {
|
||
const bod = (row.note_body || '').trim()
|
||
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
|
||
notes += 1
|
||
continue
|
||
}
|
||
const t =
|
||
(row.exercise_title || '').trim() ||
|
||
(row.exercise_id ? `Übung #${row.exercise_id}` : 'Übung')
|
||
exercises.push(t)
|
||
}
|
||
return { exercises, notes }
|
||
}
|
||
|
||
const MODULE_OUTLINE_PREVIEW_MAX = 8
|
||
|
||
/** Statuszeile: nur Planungs‑Override vs. Katalog (Archetyp steht bereits links). */
|
||
function compactComboPlanningCaption(it) {
|
||
const overridden =
|
||
it.planning_method_profile != null &&
|
||
typeof it.planning_method_profile === 'object' &&
|
||
!Array.isArray(it.planning_method_profile)
|
||
return overridden ? 'Planung angepasst' : 'wie Katalog'
|
||
}
|
||
|
||
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
|
||
function planningModulePalette(moduleId) {
|
||
const id = normalizedPlanningModuleChainId(moduleId)
|
||
const n = id != null && id >= 1 ? Math.floor(Number(id)) : 1
|
||
const golden = ((n >>> 0) * 2654435761 + n * 73856093) >>> 0
|
||
const h = golden % 360
|
||
const border = `hsl(${h} 52% 36%)`
|
||
const soft = `hsl(${h} 42% 94%)`
|
||
return { border, soft, hue: h }
|
||
}
|
||
|
||
function PlanningModuleRowTag({ moduleId, title }) {
|
||
const p = planningModulePalette(moduleId)
|
||
const lbl = truncatePreview(title || `Modul #${moduleId}`, 34).trim()
|
||
const fullTitle = ((title || '').trim() || `Modul #${moduleId}`).trim()
|
||
return (
|
||
<span
|
||
className="tu-planning-mod-tag"
|
||
style={{ borderColor: p.border, backgroundColor: p.soft }}
|
||
title={`${fullTitle} (Bibliotheks-ID ${moduleId})`}
|
||
>
|
||
<span className="tu-planning-mod-tag__dot" style={{ background: p.border }} aria-hidden />
|
||
<span className="tu-planning-mod-tag__text">Aus Modul: {lbl}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** Eindeutige Module im Abschnitt mit Zählerständen für die Legende. */
|
||
function sectionModuleLegendModel(items) {
|
||
const map = new Map()
|
||
for (const row of items || []) {
|
||
const id = normalizedPlanningModuleChainId(row.source_training_module_id)
|
||
if (id == null) continue
|
||
if (!map.has(id)) {
|
||
map.set(id, {
|
||
id,
|
||
title: (((row.source_module_title || '').trim() || '') || `Modul #${id}`).trim(),
|
||
exercises: 0,
|
||
notes: 0,
|
||
})
|
||
}
|
||
const agg = map.get(id)
|
||
if ((row.item_type || '') === 'note') {
|
||
const bod = ((row.note_body || '').trim() || '').trim()
|
||
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
|
||
agg.notes += 1
|
||
} else {
|
||
agg.exercises += 1
|
||
}
|
||
}
|
||
return [...map.values()].sort((a, b) => a.id - b.id)
|
||
}
|
||
|
||
function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
|
||
if (!showModuleBand || !modOutline) return null
|
||
return (
|
||
<div className="tu-module-bundle-head" role="group" aria-label={`Modul ${modBandTitle}`}>
|
||
<div className="tu-module-bundle-head__stripe" aria-hidden />
|
||
<div className="tu-module-bundle-head__main">
|
||
<span className="tu-module-bundle-head__kicker">Aus Modul</span>
|
||
<strong className="tu-module-bundle-head__title">{modBandTitle}</strong>
|
||
{modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
|
||
<p className="tu-module-bundle-head__empty">Ohne strukturierten Inhalt angezeigt.</p>
|
||
) : (
|
||
<ol className="tu-module-bundle-head__list" start={1}>
|
||
{modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
|
||
<li key={`mo-li-${modBandTitle}-${ox}`}>{tx}</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
{modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
|
||
<p className="tu-module-bundle-head__more">
|
||
… und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
|
||
{modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
|
||
</p>
|
||
) : null}
|
||
{modOutline.notes > 0 ? (
|
||
<p className="tu-module-bundle-head__meta">
|
||
sowie {modOutline.notes}{' '}
|
||
{modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
|
||
const b = [...blocks]
|
||
if (fromI < 0 || fromI >= b.length) return blocks
|
||
const [moved] = b.splice(fromI, 1)
|
||
let insertAt = toBeforeIdx
|
||
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
|
||
insertAt = Math.max(0, Math.min(insertAt, b.length))
|
||
b.splice(insertAt, 0, moved)
|
||
return b
|
||
}
|
||
|
||
/**
|
||
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
|
||
* @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number, toParallelStream?: { po: number, so: number } }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben
|
||
*/
|
||
export default function TrainingUnitSectionsEditor({
|
||
sections,
|
||
onSectionsChange,
|
||
onRequestExercisePick,
|
||
onRequestTrainingModulePick,
|
||
onPeekExercise,
|
||
showExecutionExtras = false,
|
||
heading = 'Abschnitte & Übungen',
|
||
hideHeading = false,
|
||
headingAccessory = null,
|
||
wideExerciseGrid = false,
|
||
enableItemDragReorder = true,
|
||
enableSectionDragReorder = true,
|
||
slotIndex = null,
|
||
onMoveSectionsAcrossSlots = null,
|
||
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
|
||
betweenInsertMenus = true,
|
||
/** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */
|
||
enableParallelPhaseControls = false,
|
||
}) {
|
||
const { user } = useAuth()
|
||
const planningCompactLegend = isCompactTagLegendMode(
|
||
user?.training_planning_prefs?.module_display_mode
|
||
)
|
||
|
||
const ensure = (prev) =>
|
||
prev && prev.length ? prev : [defaultSection()]
|
||
|
||
const patch = useCallback(
|
||
(updater) => {
|
||
onSectionsChange((prev) => updater(ensure(prev)))
|
||
},
|
||
[onSectionsChange]
|
||
)
|
||
|
||
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))
|
||
)
|
||
}
|
||
|
||
const addSection = () => {
|
||
patch((prev) => {
|
||
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
|
||
const last = prev[prev.length - 1]
|
||
const next = last?.planLoc ? { ...base, planLoc: { ...last.planLoc } } : base
|
||
return [...prev, next]
|
||
})
|
||
}
|
||
|
||
const addParallelPhaseTwoStreams = () => {
|
||
patch((prev) => {
|
||
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
|
||
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 addStreamToParallelPhase = (phaseOrder) => {
|
||
patch((prev) => {
|
||
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 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 = {
|
||
...par[0].planLoc,
|
||
parallelStreamOrderIndex: newSo,
|
||
streamTitle: null,
|
||
streamNotes: null,
|
||
streamAssignedTrainerProfileIds: null,
|
||
}
|
||
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
|
||
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),
|
||
]
|
||
})
|
||
}
|
||
|
||
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) {
|
||
return prev.map((s, i) => (i === sIdx ? stripPlanLocFromSection(s) : s))
|
||
}
|
||
const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev))
|
||
const hit = opts.find((o) => o.key === rawKey)
|
||
if (!hit) return prev
|
||
const tpl = { ...hit.template }
|
||
return prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s))
|
||
})
|
||
}
|
||
|
||
const removeSection = (sIdx) => {
|
||
patch((prev) => {
|
||
let next = prev.filter((_, i) => i !== sIdx)
|
||
next = next.length ? next : [defaultSection()]
|
||
if (enableParallelPhaseControls) {
|
||
next = afterSectionReorderParallelGuard(prev, next)
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
/** Ganzgruppe: global tauschen; parallele Phase: innerhalb Stream oder ganze Parallel-Phase am Stück */
|
||
const moveSection = (sIdx, dir) => {
|
||
patch((prev) => {
|
||
const p = ensure(prev)
|
||
const sec = p[sIdx]
|
||
const L = sec?.planLoc
|
||
if (L?.phaseKind === 'parallel') {
|
||
const po = L.phaseOrderIndex ?? 0
|
||
const so = L.parallelStreamOrderIndex ?? 0
|
||
const bucket = sectionIndicesForParallelStream(p, po, so)
|
||
const pos = bucket.indexOf(sIdx)
|
||
if (pos < 0) return p
|
||
if (dir < 0 && pos > 0) {
|
||
const newPos = pos + dir
|
||
return reorderWithinBucketIndices(p, bucket, pos, newPos)
|
||
}
|
||
if (dir > 0 && pos < bucket.length - 1) {
|
||
const newPos = pos + dir
|
||
return reorderWithinBucketIndices(p, bucket, pos, newPos)
|
||
}
|
||
if (dir < 0 && pos === 0) {
|
||
const runs = phaseRunsFromSections(p)
|
||
const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po)
|
||
if (rIdx <= 0) return p
|
||
return swapAdjacentPhaseRuns(p, rIdx - 1)
|
||
}
|
||
if (dir > 0 && pos === bucket.length - 1) {
|
||
const runs = phaseRunsFromSections(p)
|
||
const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po)
|
||
if (rIdx < 0 || rIdx >= runs.length - 1) return p
|
||
return swapAdjacentPhaseRuns(p, rIdx)
|
||
}
|
||
return p
|
||
}
|
||
const arr = [...p]
|
||
const ta = sIdx + dir
|
||
if (ta < 0 || ta >= arr.length) return arr
|
||
;[arr[sIdx], arr[ta]] = [arr[ta], arr[sIdx]]
|
||
return arr
|
||
})
|
||
}
|
||
|
||
const insertItemAt = useCallback(
|
||
(sIdx, beforeIx, row) => {
|
||
patch((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
const ix = Math.max(
|
||
0,
|
||
Math.min(Number(beforeIx) || 0, items.length)
|
||
)
|
||
items.splice(ix, 0, row)
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
},
|
||
[patch]
|
||
)
|
||
|
||
const addItem = (sIdx, kind) => {
|
||
patch((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
items.push(kind === 'note' ? noteRow() : exerciseRow())
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
}
|
||
|
||
const removeItem = (sIdx, iIdx) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) =>
|
||
si !== sIdx ? s : { ...s, items: (s.items || []).filter((_, ii) => ii !== iIdx) }
|
||
)
|
||
)
|
||
}
|
||
|
||
const moveItem = (sIdx, iIdx, dir) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) => {
|
||
if (si !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
const ta = iIdx + dir
|
||
if (ta < 0 || ta >= items.length) return s
|
||
;[items[iIdx], items[ta]] = [items[ta], items[iIdx]]
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
}
|
||
|
||
const updateItem = (sIdx, iIdx, field, val) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) =>
|
||
si !== sIdx
|
||
? s
|
||
: {
|
||
...s,
|
||
items: (s.items || []).map((row, ii) =>
|
||
ii === iIdx ? { ...row, [field]: val } : row
|
||
),
|
||
}
|
||
)
|
||
)
|
||
}
|
||
|
||
const [textEdit, setTextEdit] = useState(null)
|
||
/** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
|
||
const [comboPlanningModal, setComboPlanningModal] = useState(null)
|
||
/** Katalog-Stationen, falls Zeile noch keine `combination_slots` (vor Enrich o. Ä.) */
|
||
const [modalComboSlotsFetched, setModalComboSlotsFetched] = useState(null)
|
||
/** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
|
||
const [insertChooser, setInsertChooser] = useState(null)
|
||
const [draggingPos, setDraggingPos] = useState(null)
|
||
const [dropTargetPos, setDropTargetPos] = useState(null)
|
||
|
||
const [dropSectionBand, setDropSectionBand] = useState(null)
|
||
/** Aktiver Reiter pro paralleler Phase (phaseOrder → streamOrder). */
|
||
const [parallelStreamTabByPhase, setParallelStreamTabByPhase] = useState({})
|
||
/** `${phaseOrder}:${streamOrder}` während Stream-Name bearbeitet wird */
|
||
const [streamNameEditKey, setStreamNameEditKey] = useState(null)
|
||
const [streamNameDraft, setStreamNameDraft] = useState('')
|
||
const [phaseTitleEditPo, setPhaseTitleEditPo] = useState(null)
|
||
const [phaseTitleDraft, setPhaseTitleDraft] = useState('')
|
||
const skipStreamNameBlurSave = useRef(false)
|
||
const skipPhaseTitleBlurSave = useRef(false)
|
||
/** { slot: number, beforeIdx: number } */
|
||
|
||
useEffect(() => {
|
||
if (!textEdit) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setTextEdit(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [textEdit])
|
||
|
||
useEffect(() => {
|
||
if (!insertChooser) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setInsertChooser(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [insertChooser])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setComboPlanningModal(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [comboPlanningModal])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) return
|
||
const L = ensure(sections)
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const row = L[sIdx]?.items?.[iIdx]
|
||
const ok =
|
||
row &&
|
||
String(row.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||
row.exercise_id
|
||
if (!ok) setComboPlanningModal(null)
|
||
}, [sections, comboPlanningModal])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) {
|
||
setModalComboSlotsFetched(null)
|
||
return
|
||
}
|
||
const L = ensure(sections)
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const cand = L[sIdx]?.items?.[iIdx]
|
||
if (
|
||
!cand ||
|
||
String(cand.exercise_kind || '').toLowerCase().trim() !== 'combination' ||
|
||
!cand.exercise_id
|
||
) {
|
||
setModalComboSlotsFetched(null)
|
||
return
|
||
}
|
||
const cached = cand.combination_slots
|
||
if (Array.isArray(cached) && cached.length > 0) {
|
||
setModalComboSlotsFetched(cached)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
setModalComboSlotsFetched([])
|
||
api.getExercise(cand.exercise_id).then((ex) => {
|
||
if (cancelled) return
|
||
setModalComboSlotsFetched(Array.isArray(ex?.combination_slots) ? ex.combination_slots : [])
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [sections, comboPlanningModal])
|
||
|
||
const closeInsertChooser = useCallback(() => setInsertChooser(null), [])
|
||
|
||
const insertSlotKeyPrefix =
|
||
slotIndex !== null && slotIndex !== undefined ? `sl${slotIndex}-` : ''
|
||
|
||
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()
|
||
try {
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData(
|
||
DND_TU_SECTION,
|
||
JSON.stringify({
|
||
fromSlot: sectionToSlot,
|
||
fromSectionIdx: sIdx,
|
||
})
|
||
)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropSectionBand(null)
|
||
}
|
||
|
||
const onSectionBandDragOver = (e, beforeIdx) => {
|
||
if (!enableSectionDragReorder) return
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move'
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropSectionBand({ slot: sectionToSlot, beforeIdx })
|
||
}
|
||
|
||
const onPhaseAboveSplitDragOver = (e, po) => {
|
||
if (!enableSectionDragReorder || !enableParallelPhaseControls) return
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move'
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropSectionBand({ slot: sectionToSlot, phaseAboveSplitPo: Number(po) || 0 })
|
||
}
|
||
|
||
const onPhaseBelowSplitDragOver = (e, po, so) => {
|
||
if (!enableSectionDragReorder || !enableParallelPhaseControls) return
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move'
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropSectionBand({
|
||
slot: sectionToSlot,
|
||
phaseBelowSplit: { po: Number(po) || 0, so: Number(so) || 0 },
|
||
})
|
||
}
|
||
|
||
const applyParsedSectionDrop = (data) => {
|
||
const phaseRunMove = data.phaseRunMove
|
||
const fromSi = data.fromSectionIdx
|
||
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
|
||
|
||
if (phaseRunMove != null && phaseRunMove.phaseOrderIndex != null) {
|
||
return { kind: 'phaseRun', phaseRunMove, fromSlot }
|
||
}
|
||
if (typeof fromSi !== 'number') return null
|
||
|
||
if (
|
||
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||
sectionToSlot >= 0 &&
|
||
fromSlot >= 0 &&
|
||
fromSlot !== sectionToSlot
|
||
) {
|
||
return { kind: 'crossSlot', fromSi, fromSlot }
|
||
}
|
||
|
||
return { kind: 'local', fromSi }
|
||
}
|
||
|
||
const onPhaseAboveSplitDrop = (e, po) => {
|
||
if (!enableSectionDragReorder || !enableParallelPhaseControls) 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
|
||
}
|
||
const targetPo = Number(po) || 0
|
||
const parsed = applyParsedSectionDrop(data)
|
||
if (!parsed) return
|
||
|
||
if (parsed.kind === 'phaseRun') {
|
||
const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0
|
||
if (dragPo === targetPo) return
|
||
patch((prev) => {
|
||
const idxs = indicesOfParallelPhase(prev, targetPo)
|
||
const fg = idxs.length ? idxs[0] : -1
|
||
if (fg < 0) return prev
|
||
let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg)
|
||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||
return next
|
||
})
|
||
return
|
||
}
|
||
|
||
if (parsed.kind === 'crossSlot') return
|
||
|
||
const { fromSi } = parsed
|
||
patch((prev) => {
|
||
let next = reorderSectionBeforeParallelRunAsWholeGroup(prev, fromSi, targetPo)
|
||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const onPhaseBelowSplitDrop = (e, po, so) => {
|
||
if (!enableSectionDragReorder || !enableParallelPhaseControls) 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
|
||
}
|
||
const targetPo = Number(po) || 0
|
||
const targetSo = Number(so) || 0
|
||
const parsed = applyParsedSectionDrop(data)
|
||
if (!parsed) return
|
||
|
||
if (parsed.kind === 'phaseRun') {
|
||
const dragPo = Number(parsed.phaseRunMove.phaseOrderIndex) || 0
|
||
if (dragPo === targetPo) return
|
||
patch((prev) => {
|
||
const idxs = indicesOfParallelPhase(prev, targetPo)
|
||
const fg = idxs.length ? idxs[0] : -1
|
||
if (fg < 0) return prev
|
||
let next = moveParallelPhaseRunToInsertBefore(prev, dragPo, fg)
|
||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||
return next
|
||
})
|
||
return
|
||
}
|
||
|
||
if (parsed.kind === 'crossSlot') return
|
||
|
||
const { fromSi } = parsed
|
||
patch((prev) => {
|
||
let next = reorderSectionAsFirstInParallelStream(prev, fromSi, targetPo, targetSo)
|
||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const onSectionBandDrop = (e, insertBeforeIdx) => {
|
||
if (!enableSectionDragReorder) 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
|
||
}
|
||
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 (enableParallelPhaseControls) {
|
||
const fromPl = list[fromSi]?.planLoc
|
||
if (fromPl?.phaseKind === 'parallel' && insertBeforeIdx >= 0 && insertBeforeIdx < list.length) {
|
||
const po = fromPl.phaseOrderIndex ?? 0
|
||
const fromSo = fromPl.parallelStreamOrderIndex ?? 0
|
||
const tabSo =
|
||
parallelStreamTabByPhase[po] ?? streamsForParallelPhaseOrders(list, po)[0] ?? 0
|
||
const targetPl = list[insertBeforeIdx]?.planLoc
|
||
if (
|
||
targetPl?.phaseKind === 'parallel' &&
|
||
(targetPl.phaseOrderIndex ?? 0) === po &&
|
||
(targetPl.parallelStreamOrderIndex ?? 0) !== fromSo &&
|
||
tabSo === fromSo
|
||
) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
if (
|
||
enableParallelPhaseControls &&
|
||
(insertBeforeIdx === fromSi || insertBeforeIdx === fromSi + 1)
|
||
) {
|
||
return
|
||
}
|
||
|
||
if (
|
||
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||
sectionToSlot >= 0 &&
|
||
fromSlot >= 0 &&
|
||
fromSlot !== sectionToSlot
|
||
) {
|
||
onMoveSectionsAcrossSlots({
|
||
fromSlot,
|
||
fromSectionIdx: fromSi,
|
||
toSlot: sectionToSlot,
|
||
toSectionIdx: insertBeforeIdx,
|
||
})
|
||
return
|
||
}
|
||
|
||
patch((prev) => {
|
||
let next = enableParallelPhaseControls
|
||
? reorderBlocksImmutableWithPlanLoc(prev, fromSi, insertBeforeIdx)
|
||
: reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)
|
||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||
return next
|
||
})
|
||
}
|
||
|
||
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 &&
|
||
fromSlot !== sectionToSlot
|
||
) {
|
||
onMoveSectionsAcrossSlots({
|
||
fromSlot,
|
||
fromSectionIdx: fromSi,
|
||
toSlot: sectionToSlot,
|
||
toSectionIdx: toIdx,
|
||
toParallelStream: { po, so },
|
||
})
|
||
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()
|
||
try {
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData(
|
||
DND_TU_ITEM,
|
||
JSON.stringify({ sectionIndex: sIdx, itemIndex: iIdx })
|
||
)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDraggingPos({ sIdx, iIdx })
|
||
}
|
||
|
||
const clearDragChrome = () => {
|
||
setDraggingPos(null)
|
||
setDropTargetPos(null)
|
||
}
|
||
|
||
const onItemDragOverRow = (e, sIdx, iIdx) => {
|
||
if (!enableItemDragReorder) return
|
||
if (!dtHasType(e, DND_TU_ITEM)) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move'
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropTargetPos({ sIdx, iIdx })
|
||
}
|
||
|
||
const onItemDropRow = (e, toSIdx, toIdx) => {
|
||
if (!enableItemDragReorder) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
let raw = ''
|
||
try {
|
||
raw = e.dataTransfer.getData(DND_TU_ITEM)
|
||
} catch {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
if (!raw) {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
let data
|
||
try {
|
||
data = JSON.parse(raw)
|
||
} catch {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
const fromS = data.sectionIndex
|
||
const fromI = data.itemIndex
|
||
if (typeof fromS !== 'number' || typeof fromI !== 'number') {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
if (fromS === toSIdx && fromI === toIdx) {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
|
||
patch((prev) => {
|
||
const list = ensure(prev)
|
||
if (
|
||
fromS < 0 ||
|
||
fromS >= list.length ||
|
||
toSIdx < 0 ||
|
||
toSIdx >= list.length ||
|
||
typeof toIdx !== 'number'
|
||
) {
|
||
return prev
|
||
}
|
||
|
||
const fromItems = [...(list[fromS].items || [])]
|
||
if (fromI < 0 || fromI >= fromItems.length) return prev
|
||
|
||
const moved = fromItems[fromI]
|
||
fromItems.splice(fromI, 1)
|
||
|
||
if (fromS === toSIdx) {
|
||
let insertAt = toIdx
|
||
if (fromI < toIdx) insertAt = toIdx - 1
|
||
const bounded = Math.max(0, Math.min(insertAt, fromItems.length))
|
||
fromItems.splice(bounded, 0, moved)
|
||
return list.map((sec, i) => (i === fromS ? { ...sec, items: fromItems } : sec))
|
||
}
|
||
|
||
const toItems = [...(list[toSIdx].items || [])]
|
||
const insertAt = Math.max(0, Math.min(toIdx, toItems.length))
|
||
toItems.splice(insertAt, 0, moved)
|
||
return list.map((sec, i) => {
|
||
if (i === fromS) return { ...sec, items: fromItems }
|
||
if (i === toSIdx) return { ...sec, items: toItems }
|
||
return sec
|
||
})
|
||
})
|
||
clearDragChrome()
|
||
}
|
||
|
||
const applyTextEdit = () => {
|
||
if (!textEdit) return
|
||
const { kind, sIdx, iIdx, draft } = textEdit
|
||
if (kind === 'zwischen-note') {
|
||
updateItem(sIdx, iIdx, 'note_body', draft)
|
||
} else if (kind === 'exercise-notes') {
|
||
updateItem(sIdx, iIdx, 'notes', draft)
|
||
}
|
||
setTextEdit(null)
|
||
}
|
||
|
||
const renderBetweenInsertBand = (sIdx, beforeIx, itemCount) => {
|
||
const posLabel =
|
||
beforeIx === 0
|
||
? 'vor dem ersten Eintrag'
|
||
: beforeIx >= itemCount
|
||
? 'am Ende des Abschnitts'
|
||
: `vor Eintrag ${beforeIx + 1}`
|
||
return (
|
||
<div className="tu-insert-slot">
|
||
<button
|
||
type="button"
|
||
className="tu-insert-slot__btn"
|
||
aria-haspopup="dialog"
|
||
aria-label={`Inhalt einfügen (${posLabel})`}
|
||
title={`Hier einfügen (${posLabel})`}
|
||
onClick={() => setInsertChooser({ sIdx, beforeIx })}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const planningPhaseRuns = useMemo(() => phaseRunsFromSections(list), [list])
|
||
|
||
const firstSectionIndexByParallelPhase = useMemo(() => {
|
||
const m = new Map()
|
||
list.forEach((s, i) => {
|
||
const L = s?.planLoc
|
||
if (L?.phaseKind !== 'parallel') return
|
||
const po = L.phaseOrderIndex ?? 0
|
||
if (!m.has(po)) m.set(po, i)
|
||
})
|
||
return m
|
||
}, [list])
|
||
|
||
const parallelPhaseOrdersPresent = useMemo(() => {
|
||
const set = new Set()
|
||
for (const s of list) {
|
||
if (s?.planLoc?.phaseKind === 'parallel') set.add(s.planLoc.phaseOrderIndex ?? 0)
|
||
}
|
||
return [...set].sort((a, b) => a - b)
|
||
}, [list])
|
||
|
||
useEffect(() => {
|
||
if (!enableParallelPhaseControls || !parallelPhaseOrdersPresent.length) return
|
||
setParallelStreamTabByPhase((prev) => {
|
||
const next = { ...prev }
|
||
let changed = false
|
||
for (const po of parallelPhaseOrdersPresent) {
|
||
const orders = streamsForParallelPhaseOrders(list, po)
|
||
if (!orders.length) continue
|
||
if (next[po] === undefined) {
|
||
next[po] = orders[0]
|
||
changed = true
|
||
} else if (!orders.includes(next[po])) {
|
||
next[po] = orders[0]
|
||
changed = true
|
||
}
|
||
}
|
||
for (const k of Object.keys(next)) {
|
||
const poi = Number(k)
|
||
if (!Number.isFinite(poi) || !parallelPhaseOrdersPresent.includes(poi)) {
|
||
delete next[k]
|
||
changed = true
|
||
}
|
||
}
|
||
return changed ? next : prev
|
||
})
|
||
}, [list, parallelPhaseOrdersPresent, enableParallelPhaseControls])
|
||
|
||
const sectionMoveDisabledUp = (sIdx) => {
|
||
const sec = list[sIdx]
|
||
const L = sec?.planLoc
|
||
if (L?.phaseKind === 'parallel') {
|
||
const po = L.phaseOrderIndex ?? 0
|
||
const so = L.parallelStreamOrderIndex ?? 0
|
||
const bucket = sectionIndicesForParallelStream(list, po, so)
|
||
const pos = bucket.indexOf(sIdx)
|
||
if (pos > 0) return false
|
||
const rIdx = planningPhaseRuns.findIndex(
|
||
(r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po
|
||
)
|
||
return rIdx <= 0
|
||
}
|
||
return sIdx === 0
|
||
}
|
||
|
||
const sectionMoveDisabledDown = (sIdx) => {
|
||
const sec = list[sIdx]
|
||
const L = sec?.planLoc
|
||
if (L?.phaseKind === 'parallel') {
|
||
const po = L.phaseOrderIndex ?? 0
|
||
const so = L.parallelStreamOrderIndex ?? 0
|
||
const bucket = sectionIndicesForParallelStream(list, po, so)
|
||
const pos = bucket.indexOf(sIdx)
|
||
if (pos >= 0 && pos < bucket.length - 1) return false
|
||
const rIdx = planningPhaseRuns.findIndex(
|
||
(r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po
|
||
)
|
||
return rIdx < 0 || rIdx >= planningPhaseRuns.length - 1
|
||
}
|
||
return sIdx === list.length - 1
|
||
}
|
||
|
||
const comboPlanningModalDerived = useMemo(() => {
|
||
if (!comboPlanningModal) {
|
||
return { item: null, sIdx: null, iIdx: null }
|
||
}
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const cand = list[sIdx]?.items?.[iIdx]
|
||
if (
|
||
cand &&
|
||
String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||
cand.exercise_id
|
||
) {
|
||
return { item: cand, sIdx, iIdx }
|
||
}
|
||
return { item: null, sIdx: null, iIdx: null }
|
||
}, [list, comboPlanningModal])
|
||
|
||
const comboPlanningModalItem = comboPlanningModalDerived.item
|
||
const comboPlanningModalSX = comboPlanningModalDerived.sIdx
|
||
const comboPlanningModalIX = comboPlanningModalDerived.iIdx
|
||
|
||
const comboPlanningResolvedSlots = useMemo(() => {
|
||
if (!comboPlanningModalItem) return []
|
||
const c = comboPlanningModalItem.combination_slots
|
||
if (Array.isArray(c) && c.length > 0) return c
|
||
return Array.isArray(modalComboSlotsFetched) ? modalComboSlotsFetched : []
|
||
}, [comboPlanningModalItem, modalComboSlotsFetched])
|
||
|
||
const comboPlanningSlotsOutline = useMemo(
|
||
() => comboSlotsOutlineForProfileEditor(comboPlanningResolvedSlots),
|
||
[comboPlanningResolvedSlots]
|
||
)
|
||
|
||
const comboPlanningEffectiveProfile = useMemo(() => {
|
||
if (!comboPlanningModalItem) return {}
|
||
return effectiveComboMethodProfile(
|
||
comboPlanningModalItem.catalog_method_profile || {},
|
||
comboPlanningModalItem.planning_method_profile
|
||
)
|
||
}, [comboPlanningModalItem])
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
'training-unit-sections-editor' +
|
||
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '') +
|
||
(enableItemDragReorder ? ' training-unit-sections-editor--item-drag' : '')
|
||
}
|
||
>
|
||
{(!hideHeading || headingAccessory) ? (
|
||
<div
|
||
className="tu-editor-heading-toolbar"
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
marginBottom: '0.75rem',
|
||
}}
|
||
>
|
||
{!hideHeading ? (
|
||
<h3 style={{ margin: 0, fontSize: '1rem', flex: '1 1 200px', minWidth: 0 }}>
|
||
{heading}
|
||
</h3>
|
||
) : headingAccessory ? (
|
||
<span style={{ flex: '1 1 auto', minWidth: 0 }} />
|
||
) : null}
|
||
{headingAccessory ? (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '8px',
|
||
justifyContent: 'flex-end',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
{headingAccessory}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{list.map((sec, sIdx) => {
|
||
const planMin = sectionPlannedMinutes(sec)
|
||
const itemCount = sec.items?.length ?? 0
|
||
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
|
||
const pl = sec?.planLoc
|
||
const parallelPhaseOrder =
|
||
enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null
|
||
const streamOrdersForParallelPhase =
|
||
parallelPhaseOrder != null ? streamsForParallelPhaseOrders(list, parallelPhaseOrder) : []
|
||
const activeParallelStream =
|
||
parallelPhaseOrder != null
|
||
? parallelStreamTabByPhase[parallelPhaseOrder] ?? streamOrdersForParallelPhase[0] ?? 0
|
||
: null
|
||
const hideParallelSection =
|
||
enableParallelPhaseControls &&
|
||
pl?.phaseKind === 'parallel' &&
|
||
(pl.parallelStreamOrderIndex ?? 0) !== activeParallelStream
|
||
const firstGlobalIdxThisPhase =
|
||
parallelPhaseOrder != null
|
||
? firstSectionIndexByParallelPhase.get(parallelPhaseOrder)
|
||
: null
|
||
const firstVisibleIdxActiveStream =
|
||
parallelPhaseOrder != null && streamOrdersForParallelPhase.length
|
||
? sectionIndicesForParallelStream(
|
||
list,
|
||
parallelPhaseOrder,
|
||
activeParallelStream
|
||
)[0]
|
||
: null
|
||
const hideDropBandBeforeOrphanFirstVisible =
|
||
parallelPhaseOrder != null &&
|
||
firstVisibleIdxActiveStream != null &&
|
||
sIdx === firstVisibleIdxActiveStream &&
|
||
firstGlobalIdxThisPhase != null &&
|
||
sIdx !== firstGlobalIdxThisPhase
|
||
|
||
const isFirstSectionOfParallelPhase =
|
||
parallelPhaseOrder != null &&
|
||
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
|
||
const hideDropBeforeFirstParallelBecauseDedicatedSlot =
|
||
enableParallelPhaseControls &&
|
||
isFirstSectionOfParallelPhase &&
|
||
pl?.phaseKind === 'parallel'
|
||
const showSectionDropBandBefore =
|
||
(pl?.phaseKind !== 'parallel' || !hideParallelSection) &&
|
||
!hideDropBandBeforeOrphanFirstVisible &&
|
||
!hideDropBeforeFirstParallelBecauseDedicatedSlot
|
||
|
||
const bandActiveBefore = (bx) =>
|
||
enableSectionDragReorder &&
|
||
dropSectionBand &&
|
||
dropSectionBand.slot === sectionToSlot &&
|
||
dropSectionBand.beforeIdx === bx &&
|
||
!dropSectionBand.streamDrop &&
|
||
dropSectionBand.phaseAboveSplitPo == null &&
|
||
!dropSectionBand.phaseBelowSplit
|
||
|
||
const streamChipDropActive = (po, so) =>
|
||
useStreamTagDropUx &&
|
||
dropSectionBand?.slot === sectionToSlot &&
|
||
dropSectionBand?.streamDrop?.po === po &&
|
||
dropSectionBand?.streamDrop?.so === so
|
||
|
||
const phaseAboveSplitDnd =
|
||
parallelPhaseOrder != null &&
|
||
dropSectionBand?.slot === sectionToSlot &&
|
||
dropSectionBand?.phaseAboveSplitPo === parallelPhaseOrder
|
||
const phaseBelowSplitDnd =
|
||
parallelPhaseOrder != null &&
|
||
dropSectionBand?.slot === sectionToSlot &&
|
||
dropSectionBand?.phaseBelowSplit?.po === parallelPhaseOrder &&
|
||
dropSectionBand?.phaseBelowSplit?.so === activeParallelStream
|
||
|
||
const streamVisual =
|
||
enableParallelPhaseControls && pl?.phaseKind === 'parallel'
|
||
? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0)
|
||
: null
|
||
const allowSectionDragGrip = enableSectionDragReorder
|
||
|
||
return (
|
||
<Fragment key={`secFrag-${sIdx}`}>
|
||
{enableSectionDragReorder && showSectionDropBandBefore ? (
|
||
<div
|
||
className={
|
||
'tu-section-dropband' +
|
||
sectionDropBandRegionClass(list, sIdx, enableParallelPhaseControls) +
|
||
(bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')
|
||
}
|
||
title="Abschnitt hier einfügen"
|
||
onDragOver={(e) => {
|
||
if (!enableSectionDragReorder) return
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
onSectionBandDragOver(e, sIdx)
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||
clearSectionDnD()
|
||
}}
|
||
onDrop={(e) => onSectionBandDrop(e, sIdx)}
|
||
/>
|
||
) : null}
|
||
{isFirstSectionOfParallelPhase &&
|
||
enableParallelPhaseControls &&
|
||
streamOrdersForParallelPhase.length ? (
|
||
<Fragment key={`phase-edge-${parallelPhaseOrder}`}>
|
||
{enableSectionDragReorder ? (
|
||
<div
|
||
className={
|
||
'tu-section-dropband tu-phase-drop--above-split tu-section-dropband--phase-parallel-slot' +
|
||
sectionDropBandRegionClass(
|
||
list,
|
||
firstGlobalIdxThisPhase ?? sIdx,
|
||
enableParallelPhaseControls
|
||
) +
|
||
(phaseAboveSplitDnd ? ' tu-section-dropband--active' : '')
|
||
}
|
||
title="Oberhalb der Split-Phase: Abschnitt in die Ganzgruppe ziehen"
|
||
aria-label="Dropzone Ganzgruppe oberhalb der Split-Phase"
|
||
onDragOver={(e) => onPhaseAboveSplitDragOver(e, parallelPhaseOrder)}
|
||
onDragLeave={(e) => {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||
clearSectionDnD()
|
||
}}
|
||
onDrop={(e) => onPhaseAboveSplitDrop(e, parallelPhaseOrder)}
|
||
/>
|
||
) : null}
|
||
<div
|
||
style={{
|
||
marginBottom: '12px',
|
||
padding: '10px 12px',
|
||
background: 'var(--surface2)',
|
||
borderRadius: '10px',
|
||
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '8px',
|
||
alignItems: 'center',
|
||
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' }}
|
||
>
|
||
Phase
|
||
</label>
|
||
{(() => {
|
||
const hi = firstSectionIndexByParallelPhase.get(parallelPhaseOrder)
|
||
const phaseTitleStr =
|
||
hi != null && list[hi]?.planLoc?.phaseTitle != null
|
||
? String(list[hi].planLoc.phaseTitle)
|
||
: ''
|
||
const editingPhase = phaseTitleEditPo === parallelPhaseOrder
|
||
return editingPhase ? (
|
||
<input
|
||
className="form-input"
|
||
style={{ flex: '2 1 200px', maxWidth: '320px', marginBottom: 0 }}
|
||
autoFocus
|
||
value={phaseTitleDraft}
|
||
onChange={(e) => setPhaseTitleDraft(e.target.value)}
|
||
onBlur={() => {
|
||
if (!skipPhaseTitleBlurSave.current) {
|
||
updateParallelPhaseTitleAll(parallelPhaseOrder, phaseTitleDraft)
|
||
}
|
||
skipPhaseTitleBlurSave.current = false
|
||
setPhaseTitleEditPo(null)
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') e.currentTarget.blur()
|
||
if (e.key === 'Escape') {
|
||
skipPhaseTitleBlurSave.current = true
|
||
setPhaseTitleEditPo(null)
|
||
e.currentTarget.blur()
|
||
}
|
||
}}
|
||
placeholder="Bezeichnung der Phase (z. B. Drill-Runde)"
|
||
/>
|
||
) : (
|
||
<>
|
||
<span
|
||
style={{
|
||
flex: '2 1 200px',
|
||
maxWidth: '320px',
|
||
fontSize: '0.86rem',
|
||
fontWeight: 600,
|
||
color: 'var(--text1)',
|
||
lineHeight: 1.35,
|
||
padding: '6px 4px',
|
||
}}
|
||
>
|
||
{(phaseTitleStr || '').trim() ||
|
||
`Phase ${parallelPhaseOrder} · Namen per Stift bearbeiten`}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
style={{ padding: '6px', color: 'var(--text2)' }}
|
||
aria-label="Phasen-Bezeichnung bearbeiten"
|
||
title="Namen bearbeiten"
|
||
onClick={() => {
|
||
setPhaseTitleEditPo(parallelPhaseOrder)
|
||
setPhaseTitleDraft(phaseTitleStr)
|
||
}}
|
||
>
|
||
<Pencil size={16} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
</>
|
||
)
|
||
})()}
|
||
{(() => {
|
||
const prRunIdx = planningPhaseRuns.findIndex(
|
||
(r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === parallelPhaseOrder
|
||
)
|
||
const chipPhaseUpDis = prRunIdx <= 0
|
||
const chipPhaseDownDis =
|
||
prRunIdx < 0 || prRunIdx >= planningPhaseRuns.length - 1
|
||
return (
|
||
<div
|
||
style={{
|
||
display: 'inline-flex',
|
||
gap: '4px',
|
||
alignItems: 'center',
|
||
marginRight: '2px',
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
disabled={chipPhaseUpDis}
|
||
aria-label="Parallelen Block nach oben"
|
||
title="Gesamten parallelen Block im Plan nach oben schieben"
|
||
onClick={() =>
|
||
patch((p) => movePhaseRunUpByPhaseOrder(p, parallelPhaseOrder))
|
||
}
|
||
style={{ padding: '4px 10px' }}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
disabled={chipPhaseDownDis}
|
||
aria-label="Parallelen Block nach unten"
|
||
title="Gesamten parallelen Block im Plan nach unten schieben"
|
||
onClick={() =>
|
||
patch((p) => movePhaseRunDownByPhaseOrder(p, parallelPhaseOrder))
|
||
}
|
||
style={{ padding: '4px 10px' }}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
)
|
||
})()}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => addStreamToParallelPhase(parallelPhaseOrder)}
|
||
disabled={streamOrdersForParallelPhase.length >= MAX_PARALLEL_STREAMS_PER_PHASE}
|
||
title={
|
||
streamOrdersForParallelPhase.length >= MAX_PARALLEL_STREAMS_PER_PHASE
|
||
? `Höchstens ${MAX_PARALLEL_STREAMS_PER_PHASE} Streams`
|
||
: 'Weitere parallele Gruppe'
|
||
}
|
||
>
|
||
+ Stream
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
addSectionToParallelStream(parallelPhaseOrder, activeParallelStream ?? 0)
|
||
}
|
||
title="Neuer Abschnitt im gerade gewählten Stream"
|
||
>
|
||
+ Abschnitt in diesem Stream
|
||
</button>
|
||
</div>
|
||
<div
|
||
role="tablist"
|
||
aria-label={`Streams · Phase ${parallelPhaseOrder}`}
|
||
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'stretch' }}
|
||
>
|
||
{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) : ''
|
||
const editKey = `${parallelPhaseOrder}:${so}`
|
||
const editingStream = streamNameEditKey === editKey
|
||
return (
|
||
<div
|
||
key={`p${parallelPhaseOrder}-chip-s${so}`}
|
||
className={
|
||
'tu-stream-chip-pill' +
|
||
(streamChipDropActive(parallelPhaseOrder, so)
|
||
? ' tu-stream-chip-pill--drop-active'
|
||
: '')
|
||
}
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
padding: '4px 6px 4px 8px',
|
||
borderRadius: '999px',
|
||
border: sel ? `2px solid ${pv.border}` : `1px solid ${pv.border}`,
|
||
background: sel ? pv.tabBgActive : pv.tabBg,
|
||
maxWidth: '100%',
|
||
}}
|
||
title={
|
||
useStreamTagDropUx
|
||
? 'Gruppe wählen oder Abschnitt hierher ziehen'
|
||
: undefined
|
||
}
|
||
onDragOver={
|
||
useStreamTagDropUx
|
||
? (e) => onStreamDropTargetDragOver(e, parallelPhaseOrder, so)
|
||
: undefined
|
||
}
|
||
onDragLeave={
|
||
useStreamTagDropUx ? onStreamDropTargetDragLeave : undefined
|
||
}
|
||
onDrop={
|
||
useStreamTagDropUx
|
||
? (e) => onStreamDropTargetDrop(e, parallelPhaseOrder, so)
|
||
: undefined
|
||
}
|
||
>
|
||
{editingStream ? (
|
||
<input
|
||
className="form-input"
|
||
autoFocus
|
||
style={{
|
||
minWidth: '7rem',
|
||
maxWidth: '12rem',
|
||
margin: 0,
|
||
padding: '4px 8px',
|
||
fontSize: '0.8rem',
|
||
borderRadius: '8px',
|
||
}}
|
||
value={streamNameDraft}
|
||
onChange={(e) => setStreamNameDraft(e.target.value)}
|
||
onBlur={() => {
|
||
if (!skipStreamNameBlurSave.current) {
|
||
updateParallelStreamTitleAll(parallelPhaseOrder, so, streamNameDraft)
|
||
}
|
||
skipStreamNameBlurSave.current = false
|
||
setStreamNameEditKey(null)
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') e.currentTarget.blur()
|
||
if (e.key === 'Escape') {
|
||
skipStreamNameBlurSave.current = true
|
||
setStreamNameEditKey(null)
|
||
e.currentTarget.blur()
|
||
}
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
placeholder={`Gruppe ${so + 1}`}
|
||
aria-label={`Name Gruppe ${so + 1}`}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={sel}
|
||
style={{
|
||
flex: '1 1 auto',
|
||
minWidth: '5.5rem',
|
||
maxWidth: '12rem',
|
||
margin: 0,
|
||
padding: '6px 10px',
|
||
fontSize: '0.85rem',
|
||
fontWeight: sel ? 600 : 500,
|
||
border: 'none',
|
||
background: 'transparent',
|
||
color: 'var(--text1)',
|
||
textAlign: 'left',
|
||
cursor: 'pointer',
|
||
borderRadius: '8px',
|
||
}}
|
||
onClick={() =>
|
||
setParallelStreamTabByPhase((prev) => ({
|
||
...prev,
|
||
[parallelPhaseOrder]: so,
|
||
}))
|
||
}
|
||
>
|
||
{(streamName || '').trim() || `Gruppe ${so + 1}`}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
style={{
|
||
flex: '0 0 auto',
|
||
padding: '4px',
|
||
color: 'var(--text2)',
|
||
borderRadius: '8px',
|
||
}}
|
||
title="Gruppennamen bearbeiten"
|
||
aria-label="Gruppennamen bearbeiten"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setStreamNameEditKey(editKey)
|
||
setStreamNameDraft(streamName)
|
||
}}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
style={{
|
||
flex: '0 0 auto',
|
||
padding: '4px',
|
||
color: 'var(--text2)',
|
||
borderRadius: '8px',
|
||
}}
|
||
title="Stream entfernen"
|
||
aria-label={`Stream ${so + 1} entfernen`}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
removeParallelStream(parallelPhaseOrder, so)
|
||
}}
|
||
>
|
||
<X size={16} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
{enableSectionDragReorder ? (
|
||
<div
|
||
className={
|
||
'tu-section-dropband tu-phase-drop--below-split tu-section-dropband--phase-parallel-slot' +
|
||
sectionDropBandRegionClass(
|
||
list,
|
||
firstVisibleIdxActiveStream ?? firstGlobalIdxThisPhase ?? sIdx,
|
||
enableParallelPhaseControls
|
||
) +
|
||
(phaseBelowSplitDnd ? ' tu-section-dropband--active' : '')
|
||
}
|
||
title="Erster Abschnitt in dieser Gruppe (hier vorne einfügen)"
|
||
aria-label="Dropzone: erster Slot der gewählten Splitgruppe unter dem Split-Kopf"
|
||
onDragOver={(e) =>
|
||
onPhaseBelowSplitDragOver(
|
||
e,
|
||
parallelPhaseOrder,
|
||
activeParallelStream ?? 0
|
||
)
|
||
}
|
||
onDragLeave={(e) => {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||
clearSectionDnD()
|
||
}}
|
||
onDrop={(e) =>
|
||
onPhaseBelowSplitDrop(
|
||
e,
|
||
parallelPhaseOrder,
|
||
activeParallelStream ?? 0
|
||
)
|
||
}
|
||
/>
|
||
) : null}
|
||
</Fragment>
|
||
) : null}
|
||
{!hideParallelSection ? (
|
||
<>
|
||
<div
|
||
className="tu-section-shell"
|
||
style={{
|
||
marginBottom: '1rem',
|
||
padding: '0.75rem',
|
||
background: streamVisual ? streamVisual.soft : 'var(--surface2)',
|
||
borderRadius: '10px',
|
||
border: streamVisual
|
||
? `1px solid ${streamVisual.border}`
|
||
: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||
borderLeft: streamVisual ? `5px solid ${streamVisual.border}` : undefined,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '0.5rem',
|
||
marginBottom: '0.5rem',
|
||
alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
{allowSectionDragGrip ? (
|
||
<span
|
||
className="tu-sec-drag-grip"
|
||
draggable
|
||
onDragStart={(e) => onSectionDragStart(e, sIdx)}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Abschnitt ziehen"
|
||
title="Abschnitt ziehen"
|
||
>
|
||
<GripVertical size={16} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<input
|
||
className="form-input"
|
||
style={{ flex: '2 1 180px', marginBottom: 0 }}
|
||
value={sec.title}
|
||
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
|
||
placeholder="Abschnittstitel (z. B. Aufwärmen)"
|
||
/>
|
||
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
|
||
<button
|
||
type="button"
|
||
aria-label="Abschnitt hoch"
|
||
onClick={() => moveSection(sIdx, -1)}
|
||
disabled={sectionMoveDisabledUp(sIdx)}
|
||
style={{
|
||
padding: '4px 10px',
|
||
opacity: sectionMoveDisabledUp(sIdx) ? 0.35 : 1,
|
||
}}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Abschnitt runter"
|
||
onClick={() => moveSection(sIdx, 1)}
|
||
disabled={sectionMoveDisabledDown(sIdx)}
|
||
style={{
|
||
padding: '4px 10px',
|
||
opacity: sectionMoveDisabledDown(sIdx) ? 0.35 : 1,
|
||
}}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => removeSection(sIdx)}
|
||
>
|
||
Abschnitt entfernen
|
||
</button>
|
||
</div>
|
||
{enableParallelPhaseControls && sec.planLoc ? (
|
||
<p
|
||
style={{
|
||
fontSize: '0.75rem',
|
||
color: 'var(--text2)',
|
||
margin: '0 0 8px',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{sec.planLoc.phaseKind === 'whole_group'
|
||
? (() => {
|
||
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}`
|
||
})()}
|
||
</p>
|
||
) : null}
|
||
<textarea
|
||
className="form-input"
|
||
rows={2}
|
||
value={sec.guidance_notes}
|
||
onChange={(e) =>
|
||
updateSectionField(sIdx, 'guidance_notes', e.target.value)
|
||
}
|
||
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||
/>
|
||
{enableParallelPhaseControls ? (
|
||
<div className="form-row" style={{ marginTop: '10px', marginBottom: '2px' }}>
|
||
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
||
Zuordnung
|
||
</label>
|
||
<select
|
||
className="form-input"
|
||
value={planLocKey(sec.planLoc)}
|
||
onChange={(e) => applySectionPlanTarget(sIdx, e.target.value)}
|
||
>
|
||
<option value="">Standard — eine Ganzgruppe (klassischer Ablauf)</option>
|
||
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => (
|
||
<option key={o.key} value={o.key}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
) : null}
|
||
{planMin > 0 && (
|
||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||
</p>
|
||
)}
|
||
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null}
|
||
|
||
{(sec.items || []).map((it, iIdx) => {
|
||
const dropHere =
|
||
enableItemDragReorder &&
|
||
dropTargetPos?.sIdx === sIdx &&
|
||
dropTargetPos?.iIdx === iIdx
|
||
const dragHere =
|
||
enableItemDragReorder &&
|
||
draggingPos?.sIdx === sIdx &&
|
||
draggingPos?.iIdx === iIdx
|
||
const rowCommon =
|
||
'tu-item-row' +
|
||
(dropHere ? ' tu-item-row--drop-target' : '') +
|
||
(dragHere ? ' tu-item-row--dragging' : '')
|
||
|
||
const dndRowProps = enableItemDragReorder
|
||
? {
|
||
onDragOverCapture: (ev) => onItemDragOverRow(ev, sIdx, iIdx),
|
||
onDrop: (ev) => onItemDropRow(ev, sIdx, iIdx),
|
||
}
|
||
: {}
|
||
|
||
const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
|
||
const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
|
||
const showModuleBand =
|
||
curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
|
||
const modBandTitle =
|
||
(it.source_module_title || '').trim() ||
|
||
(curMn != null ? `Modul #${curMn}` : '')
|
||
|
||
const modOutline =
|
||
!planningCompactLegend &&
|
||
showModuleBand &&
|
||
curMn != null
|
||
? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
|
||
: null
|
||
const fromModClass =
|
||
curMn != null
|
||
? planningCompactLegend
|
||
? ' tu-item-row--from-module-soft'
|
||
: ' tu-item-row--from-module'
|
||
: ''
|
||
const modBorderVarStyle =
|
||
planningCompactLegend && curMn != null
|
||
? { '--tu-mod-border': planningModulePalette(curMn).border }
|
||
: undefined
|
||
|
||
if (it.item_type === 'note') {
|
||
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
|
||
const notePv = truncatePreview(it.note_body || '', 260)
|
||
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
|
||
return (
|
||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||
{!planningCompactLegend &&
|
||
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
|
||
<div
|
||
className={
|
||
`${rowCommon} tu-item-row--note` +
|
||
(isSepLine ? ' tu-item-row--separator-note' : '') +
|
||
fromModClass
|
||
}
|
||
{...dndRowProps}
|
||
style={modBorderVarStyle}
|
||
>
|
||
{enableItemDragReorder ? (
|
||
<span
|
||
className="tu-row-grip"
|
||
draggable
|
||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||
onDragEnd={clearDragChrome}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Eintrag ziehen"
|
||
>
|
||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<div className="tu-item-row__nudge">
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach oben"
|
||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
disabled={iIdx === 0}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach unten"
|
||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
disabled={iIdx === sec.items.length - 1}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="tu-item-row__body tu-item-row__body--note">
|
||
{!isSepLine && planningCompactLegend && curMn ? (
|
||
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
|
||
) : null}
|
||
<span className="tu-item-row__meta-label">
|
||
{isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'}
|
||
</span>
|
||
{isSepLine ? (
|
||
<div
|
||
className="tu-item-row__separator-line"
|
||
role="separator"
|
||
aria-label="Trennlinie im Ablauf"
|
||
/>
|
||
) : (
|
||
<p
|
||
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||
title={noteHasText ? (it.note_body || '').trim() : undefined}
|
||
>
|
||
{noteHasText ? notePv : '—'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
title={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
|
||
aria-label={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
|
||
onClick={() =>
|
||
setTextEdit({
|
||
kind: 'zwischen-note',
|
||
sIdx,
|
||
iIdx,
|
||
draft: it.note_body || '',
|
||
})
|
||
}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="tu-item-row__remove"
|
||
title="Entfernen"
|
||
aria-label={isSepLine ? 'Trennung entfernen' : 'Zwischen-Anmerkung entfernen'}
|
||
onClick={() => removeItem(sIdx, iIdx)}
|
||
>
|
||
✗
|
||
</button>
|
||
</div>
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
|
||
</Fragment>
|
||
)
|
||
}
|
||
|
||
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||
const exTitle =
|
||
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
||
const isCombination =
|
||
String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||
const annotPrev = truncatePreview(it.notes || '', 220)
|
||
const annotHasText = Boolean((it.notes || '').trim())
|
||
const hasVariants = !isCombination && variantOpts.length > 0 && it.exercise_id
|
||
const variantIdPeek =
|
||
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
||
? undefined
|
||
: Number(it.exercise_variant_id)
|
||
|
||
const stripArchRaw =
|
||
isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
|
||
const stripMpEff =
|
||
isCombination && it.exercise_id
|
||
? effectiveComboMethodProfile(
|
||
it.catalog_method_profile || {},
|
||
it.planning_method_profile,
|
||
)
|
||
: null
|
||
|
||
return (
|
||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||
{!planningCompactLegend &&
|
||
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
|
||
<div
|
||
className={`${rowCommon} tu-item-row--exercise${fromModClass}${
|
||
isCombination && it.exercise_id ? ' tu-item-row--combo' : ''
|
||
}`}
|
||
{...dndRowProps}
|
||
style={modBorderVarStyle}
|
||
>
|
||
<div className="tu-item-row__mainline">
|
||
{enableItemDragReorder ? (
|
||
<span
|
||
className="tu-row-grip"
|
||
draggable
|
||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||
onDragEnd={clearDragChrome}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Eintrag ziehen"
|
||
>
|
||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<div className="tu-item-row__nudge">
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach oben"
|
||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
disabled={iIdx === 0}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach unten"
|
||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
disabled={iIdx === sec.items.length - 1}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="tu-item-row__body tu-item-row__body--exercise">
|
||
<div className="tu-ex-title-line">
|
||
{exTitle ? (
|
||
<strong className="tu-ex-title">{exTitle}</strong>
|
||
) : (
|
||
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
|
||
)}
|
||
{isCombination ? (
|
||
<span
|
||
className="exercise-tag"
|
||
style={{
|
||
marginLeft: 8,
|
||
fontSize: '11px',
|
||
alignSelf: 'center',
|
||
background: 'var(--accent-soft)',
|
||
color: 'var(--accent-dark)',
|
||
}}
|
||
>
|
||
Kombination
|
||
</span>
|
||
) : null}
|
||
{planningCompactLegend && curMn ? (
|
||
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
|
||
) : null}
|
||
<span className="tu-ex-inline-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
onRequestExercisePick?.({
|
||
sectionIndex: sIdx,
|
||
itemIndex: iIdx,
|
||
})
|
||
}
|
||
>
|
||
{exTitle ? 'Wechseln' : 'Übung suchen…'}
|
||
</button>
|
||
{it.exercise_id && onPeekExercise ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
onPeekExercise(
|
||
Number(it.exercise_id),
|
||
variantIdPeek,
|
||
isCombination
|
||
? {
|
||
catalog_method_profile: it.catalog_method_profile,
|
||
planning_method_profile: it.planning_method_profile,
|
||
}
|
||
: undefined,
|
||
)
|
||
}
|
||
>
|
||
Vorschau
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
</div>
|
||
<div className="tu-ex-meta-line">
|
||
{hasVariants ? (
|
||
<select
|
||
className={`form-input tu-ex-variant-select${
|
||
wideExerciseGrid ? ' tu-ex-variant-select--wide' : ''
|
||
}`}
|
||
value={
|
||
it.exercise_variant_id === '' ||
|
||
it.exercise_variant_id == null
|
||
? ''
|
||
: String(it.exercise_variant_id)
|
||
}
|
||
onChange={(e) => {
|
||
const raw = e.target.value
|
||
updateItem(
|
||
sIdx,
|
||
iIdx,
|
||
'exercise_variant_id',
|
||
raw === '' ? '' : parseInt(raw, 10)
|
||
)
|
||
}}
|
||
title="Übungsvariante"
|
||
>
|
||
<option value="">Stammübung</option>
|
||
{variantOpts.map((v) => (
|
||
<option key={v.id} value={v.id}>
|
||
{v.variant_name || `Variante #${v.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
<div className="tu-ex-annot">
|
||
<span
|
||
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||
title={annotHasText ? (it.notes || '').trim() : undefined}
|
||
>
|
||
{annotHasText ? annotPrev : '—'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
title="Anmerkung zur Übung"
|
||
aria-label="Anmerkung zur Übung bearbeiten"
|
||
onClick={() =>
|
||
setTextEdit({
|
||
kind: 'exercise-notes',
|
||
sIdx,
|
||
iIdx,
|
||
draft: it.notes || '',
|
||
})
|
||
}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="tu-item-row__side">
|
||
<input
|
||
type="number"
|
||
className="form-input tu-ex-duration"
|
||
min={1}
|
||
value={it.planned_duration_min}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
||
}
|
||
placeholder="Min"
|
||
title="Geplante Dauer (Minuten)"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="tu-item-row__remove"
|
||
title="Übung entfernen"
|
||
aria-label="Übung entfernen"
|
||
onClick={() => removeItem(sIdx, iIdx)}
|
||
>
|
||
✗
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isCombination && it.exercise_id ? (
|
||
<div className="tu-combo-planning-strip">
|
||
<div className="tu-combo-planning-strip__toolbar">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
aria-haspopup="dialog"
|
||
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
|
||
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
|
||
>
|
||
Ablauf bearbeiten…
|
||
</button>
|
||
</div>
|
||
{(it.combination_slots || []).length > 0 ? (
|
||
<div className="tu-combo-planning-strip__bracket-wrap">
|
||
<CombinationPlanBracket
|
||
className="combo-plan-bracket--planning-embed"
|
||
methodArchetype={stripArchRaw}
|
||
methodProfile={stripMpEff || {}}
|
||
combinationSlots={sortCombinationSlotsForDisplay(it.combination_slots)}
|
||
planningAdjusted={
|
||
it.planning_method_profile != null &&
|
||
typeof it.planning_method_profile === 'object' &&
|
||
!Array.isArray(it.planning_method_profile)
|
||
}
|
||
candidateInteraction={onPeekExercise ? 'button' : 'none'}
|
||
onCandidatePeek={
|
||
onPeekExercise
|
||
? (exId) => onPeekExercise(Number(exId), null, undefined)
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="tu-combo-planning-strip__meta tu-combo-planning-strip__meta--fallback"
|
||
title="Stationen aus dem Katalog — nach ersten Laden oder wenn die Kombination noch keine Slots hat."
|
||
>
|
||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic', margin: 0 }}>
|
||
Stationen werden geladen oder die Kombination hat im Katalog noch keine Stationsliste …
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{showExecutionExtras ? (
|
||
<div className="tu-ex-debrief">
|
||
<div className="tu-ex-debrief__grow">
|
||
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span>
|
||
<textarea
|
||
className="form-input tu-ex-debrief__textarea"
|
||
rows={3}
|
||
value={it.modifications || ''}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||
}
|
||
placeholder="Was lief anders? Anpassungen für spätere Planung…"
|
||
/>
|
||
</div>
|
||
<div className="tu-ex-debrief__ist">
|
||
<span className="tu-item-row__meta-label">Ist (Min)</span>
|
||
<input
|
||
type="number"
|
||
className="form-input tu-ex-duration"
|
||
min={1}
|
||
value={it.actual_duration_min}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||
}
|
||
placeholder="IST"
|
||
title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
{enableItemDragReorder ? (
|
||
<div
|
||
className={`tu-item-append-drop${
|
||
dropTargetPos?.sIdx === sIdx && dropTargetPos?.iIdx === itemCount
|
||
? ' tu-item-append-drop--active'
|
||
: ''
|
||
}`}
|
||
title="Hierhin ziehen, um nach unten einzufügen"
|
||
onDragOverCapture={(e) => onItemDragOverRow(e, sIdx, itemCount)}
|
||
onDrop={(e) => onItemDropRow(e, sIdx, itemCount)}
|
||
/>
|
||
) : null}
|
||
|
||
<div style={{ marginTop: '0.65rem' }}>
|
||
{betweenInsertMenus ? (
|
||
<p style={{ margin: 0, fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45, maxWidth: '42rem' }}>
|
||
Über die +-Zeilen zwischen den Einträgen fügst du an der gewünschten Stelle Inhalte ein. Reihenfolge
|
||
weiter per Ziehen oder den Pfeiltasten ändern.
|
||
</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
||
>
|
||
+ Übung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => addItem(sIdx, 'note')}
|
||
>
|
||
+ Anmerkung
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{moduleLegend.length ? (
|
||
<div
|
||
className="tu-section-mod-legend"
|
||
aria-label="Liste der eingebundenen Trainingsmodule in diesem Abschnitt"
|
||
>
|
||
<div className="tu-section-mod-legend__caption">Übernommene Module im Abschnitt</div>
|
||
<ul className="tu-section-mod-legend__list">
|
||
{moduleLegend.map((e) => {
|
||
const pal = planningModulePalette(e.id)
|
||
return (
|
||
<li key={`mod-leg-${sIdx}-${e.id}`} className="tu-section-mod-legend__item">
|
||
<span
|
||
className="tu-section-mod-legend__swatch"
|
||
style={{ background: pal.border }}
|
||
title={`Farbe wie an den Zeilen (Modul #${e.id})`}
|
||
aria-hidden
|
||
/>
|
||
<span className="tu-section-mod-legend__text">
|
||
<span className="tu-section-mod-legend__title">
|
||
{(e.title || '').trim() || `Modul #${e.id}`}
|
||
</span>
|
||
<span className="tu-section-mod-legend__meta">
|
||
ID {e.id} · {e.exercises}{' '}
|
||
{e.exercises === 1 ? 'Übung' : 'Übungen'}
|
||
{e.notes > 0 ? (
|
||
<>
|
||
{' '}
|
||
· {e.notes} {e.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
|
||
</>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{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 (
|
||
<div
|
||
className="tu-section-stream-append"
|
||
style={{
|
||
marginBottom: '1rem',
|
||
padding: '0.45rem 0.65rem',
|
||
borderRadius: '10px',
|
||
border: `1px dashed ${pvA.border}`,
|
||
background: pvA.soft,
|
||
borderLeft: `5px solid ${pvA.border}`,
|
||
boxSizing: 'border-box',
|
||
}}
|
||
>
|
||
<div
|
||
className={
|
||
'tu-section-stream-append__drop tu-section-dropband tu-section-dropband--region-split' +
|
||
(appendBandActive ? ' tu-section-dropband--active' : '')
|
||
}
|
||
title="Abschnitt am Ende dieser Gruppe einfügen (per Ziehen)"
|
||
aria-label="Dropzone: Abschnitt ans Ende dieser parallelen Gruppe"
|
||
onDragOver={(e) =>
|
||
onStreamDropTargetDragOver(e, parallelPhaseOrder, soA)
|
||
}
|
||
onDragLeave={onStreamDropTargetDragLeave}
|
||
onDrop={(e) =>
|
||
onStreamDropTargetDrop(e, parallelPhaseOrder, soA)
|
||
}
|
||
/>
|
||
</div>
|
||
)
|
||
})()
|
||
: null}
|
||
</>
|
||
) : null}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
{enableSectionDragReorder ? (
|
||
<div
|
||
className={
|
||
'tu-section-dropband tu-section-dropband--end' +
|
||
(useStreamTagDropUx ? ' tu-section-dropband--whole-plan-end' : '') +
|
||
(!useStreamTagDropUx
|
||
? sectionDropBandRegionClass(list, list.length, enableParallelPhaseControls)
|
||
: '') +
|
||
(dropSectionBand &&
|
||
dropSectionBand.slot === sectionToSlot &&
|
||
dropSectionBand.beforeIdx === list.length &&
|
||
!dropSectionBand.streamDrop
|
||
? ' tu-section-dropband--active'
|
||
: '')
|
||
}
|
||
title={
|
||
useStreamTagDropUx
|
||
? 'Hier ablegen: neuer Abschnitt für die Ganzgruppe (am Planende)'
|
||
: 'Abschnitt am Ende einfügen'
|
||
}
|
||
onDragOver={(e) => {
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
onSectionBandDragOver(e, list.length)
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||
clearSectionDnD()
|
||
}}
|
||
onDrop={(e) => onSectionBandDrop(e, list.length)}
|
||
/>
|
||
) : null}
|
||
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '8px',
|
||
alignItems: 'center',
|
||
marginTop: '0.75rem',
|
||
}}
|
||
>
|
||
{enableParallelPhaseControls ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={addParallelPhaseTwoStreams}
|
||
disabled={
|
||
list.length > 0 &&
|
||
list[list.length - 1]?.planLoc?.phaseKind === 'parallel'
|
||
}
|
||
title={
|
||
list.length > 0 && list[list.length - 1]?.planLoc?.phaseKind === 'parallel'
|
||
? 'Splitten direkt nach einem parallelen Block ist un\u00fcblich. Zuerst eine Ganzgruppen-Phase oder einen Ganzgruppen-Abschnitt anf\u00fcgen, dann erneut splitten.'
|
||
: 'Zwei parallele Gruppen mit je einem Abschnitt anlegen'
|
||
}
|
||
>
|
||
Gruppen splitten
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={addWholeGroupSection}
|
||
title="Neuer Abschnitt für die gemeinsame Gruppe (legt bei Bedarf eine neue Ganzgruppen-Phase an)"
|
||
>
|
||
+ Abschnitt (Ganzgruppe)
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={addSection}
|
||
title="Abschnitt am Ende anfügen"
|
||
>
|
||
+ Abschnitt hinzufügen
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{insertChooser ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) closeInsertChooser()
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-insert-chooser-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<h4 id="tu-insert-chooser-title" className="tu-textedit-title">
|
||
An dieser Stelle einfügen
|
||
</h4>
|
||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.86rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||
Die neue Zeile erscheint genau hier; Reihenfolge kannst du wie gewohnt per Ziehen oder Pfeilen
|
||
ändern.
|
||
</p>
|
||
<div className="tu-insert-chooser-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
closeInsertChooser()
|
||
onRequestExercisePick?.({
|
||
sectionIndex: sIdx,
|
||
insertBeforeIndex: beforeIx,
|
||
})
|
||
}}
|
||
>
|
||
Übung auswählen …
|
||
</button>
|
||
{onRequestTrainingModulePick ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const ctx = { ...insertChooser }
|
||
closeInsertChooser()
|
||
onRequestTrainingModulePick({
|
||
sectionIndex: ctx.sIdx,
|
||
insertBeforeIndex: ctx.beforeIx,
|
||
})
|
||
}}
|
||
>
|
||
Trainingsmodul …
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
insertItemAt(sIdx, beforeIx, noteRow())
|
||
closeInsertChooser()
|
||
}}
|
||
>
|
||
Zwischen-Anmerkung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
const r = noteRow()
|
||
r.note_body = SECTION_INSERT_SEPARATOR_BODY
|
||
insertItemAt(sIdx, beforeIx, r)
|
||
closeInsertChooser()
|
||
}}
|
||
>
|
||
Trennlinie
|
||
</button>
|
||
<button type="button" className="btn btn-secondary tu-insert-chooser-actions__full" onClick={closeInsertChooser}>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{comboPlanningModalItem != null &&
|
||
comboPlanningModalSX != null &&
|
||
comboPlanningModalIX != null ? (
|
||
<div
|
||
className="admin-modal-backdrop combo-planning-edit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) setComboPlanningModal(null)
|
||
}}
|
||
>
|
||
<div
|
||
className="admin-modal-sheet combo-planning-edit-sheet"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-combo-planning-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="admin-modal-sheet__header">
|
||
<div style={{ minWidth: 0 }}>
|
||
<h3 id="tu-combo-planning-title" className="admin-modal-sheet__title">
|
||
{(comboPlanningModalItem.exercise_title || '').trim() ||
|
||
`Kombination #${comboPlanningModalItem.exercise_id}`}
|
||
</h3>
|
||
<p style={{ margin: '6px 0 0', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.4 }}>
|
||
Planung für diesen Termin · {compactComboPlanningCaption(comboPlanningModalItem)}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary admin-modal-sheet__close"
|
||
onClick={() => setComboPlanningModal(null)}
|
||
>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
<div className="admin-modal-sheet__body">
|
||
<div className="combo-planning-edit-toolbar">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', null)
|
||
}
|
||
>
|
||
Planung wie Katalog
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
title="Bearbeitbare Kopie der Katalog-Vorgaben für diese Einheit setzen"
|
||
onClick={() =>
|
||
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', {
|
||
...(comboPlanningModalItem.catalog_method_profile || {}),
|
||
})
|
||
}
|
||
>
|
||
Aus Katalog kopieren …
|
||
</button>
|
||
</div>
|
||
<p className="combo-planning-edit-hint">
|
||
Vorschau unten entspricht der effektiven Planung (Katalog oder Anpassung). Stationen und Einzelübungen
|
||
kommen aus dem Katalog; hier änderst du nur Zeiten, Runden und Steuerung für diese Einheit.
|
||
</p>
|
||
{comboPlanningResolvedSlots.length > 0 ? (
|
||
<div style={{ marginBottom: 18 }}>
|
||
<CombinationPlanBracket
|
||
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
||
methodProfile={comboPlanningEffectiveProfile}
|
||
combinationSlots={comboPlanningResolvedSlots}
|
||
planningAdjusted={
|
||
comboPlanningModalItem.planning_method_profile != null &&
|
||
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
|
||
!Array.isArray(comboPlanningModalItem.planning_method_profile)
|
||
}
|
||
candidateInteraction={onPeekExercise ? 'button' : 'none'}
|
||
onCandidatePeek={
|
||
onPeekExercise
|
||
? (exId) => onPeekExercise(Number(exId), null, undefined)
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<p style={{ margin: '0 0 18px', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||
Stationen werden geladen … oder die Kombination hat im Katalog keine Stationsliste.
|
||
</p>
|
||
)}
|
||
<div className="combo-planning-edit-card">
|
||
<h4 className="combo-planning-edit-card__title">Globale und stationsbezogene Anpassungen</h4>
|
||
<CombinationMethodProfileEditor
|
||
plannerMode
|
||
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
||
methodProfileJson={comboPlanningProfileJsonForEditor(
|
||
comboPlanningModalItem.catalog_method_profile || {},
|
||
comboPlanningModalItem.planning_method_profile
|
||
)}
|
||
onChangeMethodProfileJson={(json) => {
|
||
try {
|
||
const obj = JSON.parse(json || '{}')
|
||
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
||
const cleaned = cloneJsonSerializablePlanningProfile(obj) ?? {}
|
||
updateItem(
|
||
comboPlanningModalSX,
|
||
comboPlanningModalIX,
|
||
'planning_method_profile',
|
||
Object.keys(cleaned).length ? cleaned : null,
|
||
)
|
||
}
|
||
} catch {
|
||
/* Ungültiges JSON — Hinweis im Editor */
|
||
}
|
||
}}
|
||
comboSlotsOutline={comboPlanningSlotsOutline}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{textEdit ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) setTextEdit(null)
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-textedit-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<h4 id="tu-textedit-title" className="tu-textedit-title">
|
||
{textEdit.kind === 'zwischen-note'
|
||
? 'Zwischen-Anmerkung'
|
||
: 'Anmerkung zur Übung'}
|
||
</h4>
|
||
<textarea
|
||
className="form-input tu-textedit-textarea"
|
||
rows={5}
|
||
value={textEdit.draft}
|
||
onChange={(e) =>
|
||
setTextEdit((prev) => (prev ? { ...prev, draft: e.target.value } : prev))
|
||
}
|
||
placeholder={
|
||
textEdit.kind === 'zwischen-note'
|
||
? 'Hinweise zwischen Übungen …'
|
||
: 'Kurze Anmerkung zur Übung'
|
||
}
|
||
/>
|
||
<div className="tu-textedit-actions">
|
||
<button type="button" className="btn btn-primary" onClick={applyTextEdit}>
|
||
Übernehmen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setTextEdit(null)}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|