import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' import { GripVertical, Pencil } 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, streamTabLabelFromIndices, streamsForParallelPhaseOrders, sectionIndicesForParallelStream, reorderWithinBucketIndices, 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) } 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 ( Aus Modul: {lbl} ) } /** 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 (
Aus Modul {modBandTitle} {modOutline.exercises.length === 0 && modOutline.notes === 0 ? (

Ohne strukturierten Inhalt angezeigt.

) : (
    {modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
  1. {tx}
  2. ))}
)} {modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (

… und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '} {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}

) : null} {modOutline.notes > 0 ? (

sowie {modOutline.notes}{' '} {modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}

) : null}
) } 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 }) => 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 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 addWholeGroupPhase = () => { patch((prev) => { const nextPo = maxPhaseOrderIndexFromSections(prev) + 1 const pl = defaultPlanLocWholeGroup(nextPo) const base = defaultSection(`Abschnitt ${prev.length + 1}`) return [...prev, { ...base, planLoc: pl }] }) } const addParallelPhase = () => { patch((prev) => { const nextPo = maxPhaseOrderIndexFromSections(prev) + 1 const pl = defaultPlanLocParallel(nextPo, 0) const base = defaultSection(`Abschnitt ${prev.length + 1}`) return [...prev, { ...base, planLoc: pl }] }) } const addStreamToLastParallelPhase = () => { patch((prev) => { const par = (prev || []).filter((s) => s?.planLoc?.phaseKind === 'parallel') if (!par.length) return prev const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0)) const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP) const distinctStreams = new Set(inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) if (distinctStreams.size >= MAX_PARALLEL_STREAMS_PER_PHASE) return prev const maxS = Math.max(...inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) const newSo = maxS + 1 const tmpl = { ...inPhase[0].planLoc, parallelStreamOrderIndex: newSo, streamTitle: null, streamNotes: null, streamAssignedTrainerProfileIds: null, } const base = defaultSection(`Abschnitt ${prev.length + 1}`) return [...prev, { ...base, planLoc: tmpl }] }) } 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) => { const next = prev.filter((_, i) => i !== sIdx) return next.length ? next : [defaultSection()] }) } /** Ganzgruppe: global tauschen; parallele Phase: nur innerhalb desselben Streams sortieren. */ 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 const newPos = pos + dir if (newPos < 0 || newPos >= bucket.length) return p return reorderWithinBucketIndices(p, bucket, pos, newPos) } 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({}) /** { 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 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 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 fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1 if (typeof fromSi !== 'number') return if ( typeof onMoveSectionsAcrossSlots === 'function' && sectionToSlot >= 0 && fromSlot >= 0 ) { onMoveSectionsAcrossSlots({ fromSlot, fromSectionIdx: fromSi, toSlot: sectionToSlot, toSectionIdx: insertBeforeIdx, }) return } patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx)) } 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 (
) } const list = ensure(sections) const hasParallelPhase = useMemo( () => list.some((s) => s?.planLoc?.phaseKind === 'parallel'), [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]) const cannotAddMoreStreams = useMemo(() => { const par = list.filter((s) => s?.planLoc?.phaseKind === 'parallel') if (!par.length) return true const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0)) const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP) const distinct = new Set(inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) return distinct.size >= MAX_PARALLEL_STREAMS_PER_PHASE }, [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) return pos <= 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) return pos < 0 || pos >= bucket.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 (
{(!hideHeading || headingAccessory) ? (
{!hideHeading ? (

{heading}

) : headingAccessory ? ( ) : null} {headingAccessory ? (
{headingAccessory}
) : null}
) : null} {enableParallelPhaseControls ? (
Breakout: Phasen und parallele Streams

Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. Pro paralleler Phase erscheinen Reiter je Stream — nur der aktive Stream ist sichtbar (farbig am linken Rand). „Abschnitt hinzufügen“ übernimmt die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '} phases-Payload fürs Backend.

) : null} {list.map((sec, sIdx) => { 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 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 isFirstSectionOfParallelPhase = parallelPhaseOrder != null && firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx const streamVisual = enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0) : null const allowSectionDragGrip = enableSectionDragReorder && !(enableParallelPhaseControls && pl?.phaseKind === 'parallel') return ( {enableSectionDragReorder ? (
{ 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 ? (
{streamOrdersForParallelPhase.map((so) => { const sel = (parallelStreamTabByPhase[parallelPhaseOrder] ?? streamOrdersForParallelPhase[0] ?? 0) === so const pv = parallelStreamVisual(so) const lab = streamTabLabelFromIndices( list, sectionIndicesForParallelStream(list, parallelPhaseOrder, so), ) return ( ) })}
) : null} {!hideParallelSection ? (
{allowSectionDragGrip ? ( onSectionDragStart(e, sIdx)} role="button" tabIndex={0} aria-label="Abschnitt ziehen" title="Abschnitt ziehen" > ) : null} updateSectionField(sIdx, 'title', e.target.value)} placeholder="Abschnittstitel (z. B. Aufwärmen)" />
{enableParallelPhaseControls && sec.planLoc ? (

{sec.planLoc.phaseKind === 'whole_group' ? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}` : `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`}

) : null}