2026-05-15 08:02:18 +02:00
import React , { Fragment , useCallback , useEffect , useMemo , useRef , useState } from 'react'
2026-05-15 07:50:16 +02:00
import { GripVertical , Pencil , X } from 'lucide-react'
2026-05-13 07:21:33 +02:00
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
2026-05-13 14:24:55 +02:00
import CombinationPlanBracket from './CombinationPlanBracket'
2026-05-13 09:58:59 +02:00
import { comboPlanningProfileJsonForEditor , effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
2026-05-14 09:05:15 +02:00
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
2026-05-05 13:39:30 +02:00
import {
2026-05-13 15:28:37 +02:00
cloneJsonSerializablePlanningProfile ,
2026-05-13 09:58:59 +02:00
comboSlotsOutlineForProfileEditor ,
2026-05-05 13:39:30 +02:00
defaultSection ,
2026-05-15 07:25:59 +02:00
defaultPlanLocWholeGroup ,
defaultPlanLocParallel ,
maxPhaseOrderIndexFromSections ,
buildPlanTargetOptions ,
planLocKey ,
2026-05-15 07:37:51 +02:00
MAX _PARALLEL _STREAMS _PER _PHASE ,
parallelStreamVisual ,
streamsForParallelPhaseOrders ,
sectionIndicesForParallelStream ,
reorderWithinBucketIndices ,
2026-05-15 07:50:16 +02:00
reorderWithoutIndices ,
parallelStreamBucketHasContent ,
dissolveParallelPhaseToWholeGroup ,
2026-05-15 08:20:43 +02:00
phaseRunsFromSections ,
swapAdjacentPhaseRuns ,
reorderBlocksImmutableWithPlanLoc ,
2026-05-15 09:10:54 +02:00
reorderBlockIntoParallelStreamEnd ,
globalInsertBeforeIndexForParallelStreamEnd ,
2026-05-15 08:20:43 +02:00
movePhaseRunUpByPhaseOrder ,
movePhaseRunDownByPhaseOrder ,
2026-05-15 08:37:16 +02:00
moveParallelPhaseRunToInsertBefore ,
afterSectionReorderParallelGuard ,
indicesOfParallelPhase ,
2026-05-05 13:39:30 +02:00
exerciseRow ,
noteRow ,
sectionPlannedMinutes ,
} from '../utils/trainingUnitSectionsForm'
2026-05-13 09:58:59 +02:00
import api from '../utils/api'
2026-05-12 22:36:19 +02:00
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
2026-05-05 13:39:30 +02:00
2026-05-15 07:25:59 +02:00
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
}
2026-05-05 14:29:17 +02:00
const DND _TU _ITEM = 'application/x-shinkan-training-unit-item'
2026-05-05 14:42:46 +02:00
const DND _TU _SECTION = 'application/x-shinkan-training-section-v1'
2026-05-12 22:05:22 +02:00
/** Optische Trennlinie: wird als normale Zwischen-Anmerkung gespeichert (Inhalt nur dieser Marker). */
const SECTION _INSERT _SEPARATOR _BODY = '---'
2026-05-12 21:53:41 +02:00
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
}
2026-05-05 14:42:46 +02:00
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 )
}
2026-05-05 14:29:17 +02:00
2026-05-15 08:37:16 +02:00
/** 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'
}
2026-05-05 14:29:17 +02:00
function truncatePreview ( text , max = 160 ) {
const t = ( text || '' ) . replace ( /\s+/g , ' ' ) . trim ( )
if ( t . length <= max ) return t
return ` ${ t . slice ( 0 , max - 1 ) } … `
}
2026-05-12 22:17:44 +02:00
/** 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
2026-05-13 15:49:14 +02:00
/** Statuszeile: nur Planungs‑ Override vs. Katalog (Archetyp steht bereits links). */
2026-05-13 09:06:10 +02:00
function compactComboPlanningCaption ( it ) {
const overridden =
it . planning _method _profile != null &&
typeof it . planning _method _profile === 'object' &&
! Array . isArray ( it . planning _method _profile )
2026-05-13 15:49:14 +02:00
return overridden ? 'Planung angepasst' : 'wie Katalog'
2026-05-13 09:06:10 +02:00
}
2026-05-12 22:30:55 +02:00
/** 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 )
}
2026-05-12 22:17:44 +02:00
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 >
)
}
2026-05-05 14:42:46 +02:00
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
}
2026-05-05 13:39:30 +02:00
/ * *
* @ param { ( updater : ( prev : Array ) => Array ) => void } props . onSectionsChange — wie React setState
2026-05-05 14:42:46 +02:00
* @ param { ( p : { fromSlot : number , fromSectionIdx : number , toSlot : number , toSectionIdx : number } ) => void } [ props . onMoveSectionsAcrossSlots ] — Rahmenprogramm : Abschnitt zwischen Slots verschieben
2026-05-05 13:39:30 +02:00
* /
export default function TrainingUnitSectionsEditor ( {
sections ,
onSectionsChange ,
onRequestExercisePick ,
2026-05-12 22:05:22 +02:00
onRequestTrainingModulePick ,
2026-05-05 13:39:30 +02:00
onPeekExercise ,
showExecutionExtras = false ,
heading = 'Abschnitte & Übungen' ,
hideHeading = false ,
2026-05-05 14:47:38 +02:00
headingAccessory = null ,
2026-05-05 14:22:37 +02:00
wideExerciseGrid = false ,
2026-05-05 14:29:17 +02:00
enableItemDragReorder = true ,
2026-05-05 14:42:46 +02:00
enableSectionDragReorder = true ,
slotIndex = null ,
onMoveSectionsAcrossSlots = null ,
2026-05-12 22:05:22 +02:00
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
betweenInsertMenus = true ,
2026-05-15 07:25:59 +02:00
/** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */
enableParallelPhaseControls = false ,
2026-05-05 13:39:30 +02:00
} ) {
2026-05-12 22:36:19 +02:00
const { user } = useAuth ( )
const planningCompactLegend = isCompactTagLegendMode (
user ? . training _planning _prefs ? . module _display _mode
)
2026-05-05 13:39:30 +02:00
const ensure = ( prev ) =>
prev && prev . length ? prev : [ defaultSection ( ) ]
const patch = useCallback (
( updater ) => {
onSectionsChange ( ( prev ) => updater ( ensure ( prev ) ) )
} ,
[ onSectionsChange ]
)
2026-05-05 14:42:46 +02:00
const sectionToSlot =
slotIndex !== null && slotIndex !== undefined ? Number ( slotIndex ) : - 1
2026-05-15 09:10:54 +02:00
const list = ensure ( sections )
const useStreamTagDropUx =
enableSectionDragReorder &&
enableParallelPhaseControls &&
list . some ( ( s ) => s ? . planLoc ? . phaseKind === 'parallel' )
2026-05-05 13:39:30 +02:00
const updateSectionField = ( sIdx , field , val ) => {
patch ( ( prev ) =>
prev . map ( ( s , i ) => ( i === sIdx ? { ... s , [ field ] : val } : s ) )
)
}
const addSection = ( ) => {
2026-05-15 07:25:59 +02:00
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 ]
} )
}
2026-05-15 07:50:16 +02:00
const addParallelPhaseTwoStreams = ( ) => {
2026-05-15 07:25:59 +02:00
patch ( ( prev ) => {
const nextPo = maxPhaseOrderIndexFromSections ( prev ) + 1
2026-05-15 07:50:16 +02:00
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 } ]
2026-05-15 07:25:59 +02:00
} )
}
2026-05-15 07:50:16 +02:00
const addStreamToParallelPhase = ( phaseOrder ) => {
2026-05-15 07:25:59 +02:00
patch ( ( prev ) => {
2026-05-15 07:50:16 +02:00
const po = Number ( phaseOrder ) || 0
const par = ( prev || [ ] ) . filter (
( s ) => s ? . planLoc ? . phaseKind === 'parallel' && ( s . planLoc . phaseOrderIndex ? ? 0 ) === po
)
2026-05-15 07:25:59 +02:00
if ( ! par . length ) return prev
2026-05-15 07:50:16 +02:00
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 ) )
2026-05-15 07:25:59 +02:00
const newSo = maxS + 1
const tmpl = {
2026-05-15 07:50:16 +02:00
... par [ 0 ] . planLoc ,
2026-05-15 07:25:59 +02:00
parallelStreamOrderIndex : newSo ,
streamTitle : null ,
streamNotes : null ,
streamAssignedTrainerProfileIds : null ,
}
const base = defaultSection ( ` Abschnitt ${ prev . length + 1 } ` )
2026-05-15 08:37:16 +02:00
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 ) ,
]
2026-05-15 07:25:59 +02:00
} )
}
2026-05-15 07:50:16 +02:00
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
} )
}
2026-05-15 07:25:59 +02:00
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 ) )
} )
2026-05-05 13:39:30 +02:00
}
const removeSection = ( sIdx ) => {
patch ( ( prev ) => {
const next = prev . filter ( ( _ , i ) => i !== sIdx )
return next . length ? next : [ defaultSection ( ) ]
} )
}
2026-05-15 08:20:43 +02:00
/** Ganzgruppe: global tauschen; parallele Phase: innerhalb Stream oder ganze Parallel-Phase am Stück */
2026-05-05 13:39:30 +02:00
const moveSection = ( sIdx , dir ) => {
patch ( ( prev ) => {
2026-05-15 07:37:51 +02:00
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
2026-05-15 08:20:43 +02:00
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
2026-05-15 07:37:51 +02:00
}
const arr = [ ... p ]
2026-05-05 13:39:30 +02:00
const ta = sIdx + dir
2026-05-15 07:37:51 +02:00
if ( ta < 0 || ta >= arr . length ) return arr
; [ arr [ sIdx ] , arr [ ta ] ] = [ arr [ ta ] , arr [ sIdx ] ]
return arr
2026-05-05 13:39:30 +02:00
} )
}
2026-05-12 22:05:22 +02:00
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 ]
)
2026-05-05 13:39:30 +02:00
const addItem = ( sIdx , kind ) => {
patch ( ( prev ) =>
2026-05-12 22:05:22 +02:00
prev . map ( ( s , i ) => {
if ( i !== sIdx ) return s
const items = [ ... ( s . items || [ ] ) ]
items . push ( kind === 'note' ? noteRow ( ) : exerciseRow ( ) )
return { ... s , items }
} )
2026-05-05 13:39:30 +02:00
)
}
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
) ,
}
)
)
}
2026-05-05 14:29:17 +02:00
const [ textEdit , setTextEdit ] = useState ( null )
2026-05-13 09:06:10 +02:00
/** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
const [ comboPlanningModal , setComboPlanningModal ] = useState ( null )
2026-05-13 09:58:59 +02:00
/** Katalog-Stationen, falls Zeile noch keine `combination_slots` (vor Enrich o. Ä.) */
const [ modalComboSlotsFetched , setModalComboSlotsFetched ] = useState ( null )
2026-05-12 22:05:22 +02:00
/** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
const [ insertChooser , setInsertChooser ] = useState ( null )
2026-05-05 14:29:17 +02:00
const [ draggingPos , setDraggingPos ] = useState ( null )
const [ dropTargetPos , setDropTargetPos ] = useState ( null )
2026-05-05 14:42:46 +02:00
const [ dropSectionBand , setDropSectionBand ] = useState ( null )
2026-05-15 07:37:51 +02:00
/** Aktiver Reiter pro paralleler Phase (phaseOrder → streamOrder). */
const [ parallelStreamTabByPhase , setParallelStreamTabByPhase ] = useState ( { } )
2026-05-15 08:02:18 +02:00
/** `${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 )
2026-05-05 14:42:46 +02:00
/** { slot: number, beforeIdx: number } */
2026-05-05 14:29:17 +02:00
useEffect ( ( ) => {
if ( ! textEdit ) return
const onKey = ( e ) => {
if ( e . key === 'Escape' ) setTextEdit ( null )
}
window . addEventListener ( 'keydown' , onKey )
return ( ) => window . removeEventListener ( 'keydown' , onKey )
} , [ textEdit ] )
2026-05-12 22:05:22 +02:00
useEffect ( ( ) => {
if ( ! insertChooser ) return
const onKey = ( e ) => {
if ( e . key === 'Escape' ) setInsertChooser ( null )
}
window . addEventListener ( 'keydown' , onKey )
return ( ) => window . removeEventListener ( 'keydown' , onKey )
} , [ insertChooser ] )
2026-05-13 09:06:10 +02:00
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 ] )
2026-05-13 09:58:59 +02:00
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 ] )
2026-05-12 22:05:22 +02:00
const closeInsertChooser = useCallback ( ( ) => setInsertChooser ( null ) , [ ] )
const insertSlotKeyPrefix =
slotIndex !== null && slotIndex !== undefined ? ` sl ${ slotIndex } - ` : ''
2026-05-05 14:42:46 +02:00
const clearSectionDnD = ( ) => setDropSectionBand ( null )
2026-05-15 08:37:16 +02:00
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 )
}
2026-05-05 14:42:46 +02:00
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
2026-05-15 08:37:16 +02:00
const phaseRunMove = data . phaseRunMove
2026-05-05 14:42:46 +02:00
const fromSlot = typeof data . fromSlot === 'number' ? data . fromSlot : - 1
2026-05-15 08:37:16 +02:00
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
}
2026-05-05 14:42:46 +02:00
if ( typeof fromSi !== 'number' ) return
if (
typeof onMoveSectionsAcrossSlots === 'function' &&
sectionToSlot >= 0 &&
fromSlot >= 0
) {
onMoveSectionsAcrossSlots ( {
fromSlot ,
fromSectionIdx : fromSi ,
toSlot : sectionToSlot ,
toSectionIdx : insertBeforeIdx ,
} )
return
}
2026-05-15 08:20:43 +02:00
patch ( ( prev ) => {
2026-05-15 08:37:16 +02:00
let next = enableParallelPhaseControls
? reorderBlocksImmutableWithPlanLoc ( prev , fromSi , insertBeforeIdx )
: reorderBlocksImmutable ( prev , fromSi , insertBeforeIdx )
if ( enableParallelPhaseControls ) next = afterSectionReorderParallelGuard ( prev , next )
return next
2026-05-15 08:20:43 +02:00
} )
2026-05-05 14:42:46 +02:00
}
2026-05-15 09:10:54 +02:00
const onStreamDropTargetDragOver = ( e , phaseOrder , streamOrder ) => {
if ( ! enableSectionDragReorder || ! useStreamTagDropUx ) return
if ( ! dtHasType ( e , DND _TU _SECTION ) ) return
e . preventDefault ( )
e . stopPropagation ( )
try {
e . dataTransfer . dropEffect = 'move'
} catch {
/* ignore */
}
setDropSectionBand ( {
slot : sectionToSlot ,
streamDrop : { po : Number ( phaseOrder ) || 0 , so : Number ( streamOrder ) || 0 } ,
} )
}
const onStreamDropTargetDragLeave = ( e ) => {
if ( e . currentTarget . contains ( e . relatedTarget ) ) return
clearSectionDnD ( )
}
const onStreamDropTargetDrop = ( e , phaseOrder , streamOrder ) => {
if ( ! enableSectionDragReorder || ! useStreamTagDropUx ) return
e . preventDefault ( )
e . stopPropagation ( )
clearSectionDnD ( )
let raw = ''
try {
raw = e . dataTransfer . getData ( DND _TU _SECTION )
} catch {
return
}
if ( ! raw ) return
let data
try {
data = JSON . parse ( raw )
} catch {
return
}
if ( data . phaseRunMove != null && data . phaseRunMove . phaseOrderIndex != null ) {
return
}
const fromSi = data . fromSectionIdx
if ( typeof fromSi !== 'number' ) return
const po = Number ( phaseOrder ) || 0
const so = Number ( streamOrder ) || 0
const toIdx = globalInsertBeforeIndexForParallelStreamEnd ( list , po , so )
const fromSlot = typeof data . fromSlot === 'number' ? data . fromSlot : - 1
if (
typeof onMoveSectionsAcrossSlots === 'function' &&
sectionToSlot >= 0 &&
fromSlot >= 0
) {
onMoveSectionsAcrossSlots ( {
fromSlot ,
fromSectionIdx : fromSi ,
toSlot : sectionToSlot ,
toSectionIdx : toIdx ,
} )
return
}
patch ( ( prev ) => {
let next = reorderBlockIntoParallelStreamEnd ( prev , fromSi , po , so )
if ( enableParallelPhaseControls ) next = afterSectionReorderParallelGuard ( prev , next )
return next
} )
}
2026-05-05 14:29:17 +02:00
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
2026-05-05 14:42:46 +02:00
if ( ! dtHasType ( e , DND _TU _ITEM ) ) return
2026-05-05 14:29:17 +02:00
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 )
}
2026-05-12 22:05:22 +02:00
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 >
)
}
2026-05-15 08:20:43 +02:00
const planningPhaseRuns = useMemo ( ( ) => phaseRunsFromSections ( list ) , [ list ] )
2026-05-15 07:37:51 +02:00
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 )
2026-05-15 08:20:43 +02:00
if ( pos > 0 ) return false
const rIdx = planningPhaseRuns . findIndex (
( r ) => r . phaseKind === 'parallel' && r . phaseOrderIndex === po
)
return rIdx <= 0
2026-05-15 07:37:51 +02:00
}
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 )
2026-05-15 08:20:43 +02:00
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
2026-05-15 07:37:51 +02:00
}
return sIdx === list . length - 1
}
2026-05-13 09:58:59 +02:00
const comboPlanningModalDerived = useMemo ( ( ) => {
if ( ! comboPlanningModal ) {
return { item : null , sIdx : null , iIdx : null }
}
2026-05-13 09:06:10 +02:00
const { sIdx , iIdx } = comboPlanningModal
const cand = list [ sIdx ] ? . items ? . [ iIdx ]
if (
cand &&
String ( cand . exercise _kind || '' ) . toLowerCase ( ) . trim ( ) === 'combination' &&
cand . exercise _id
) {
2026-05-13 09:58:59 +02:00
return { item : cand , sIdx , iIdx }
2026-05-13 09:06:10 +02:00
}
2026-05-13 09:58:59 +02:00
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 ] )
2026-05-13 09:06:10 +02:00
2026-05-05 13:39:30 +02:00
return (
2026-05-05 14:29:17 +02:00
< div
className = {
'training-unit-sections-editor' +
2026-05-14 09:05:15 +02:00
( wideExerciseGrid ? ' training-unit-sections-editor--wide' : '' ) +
( enableItemDragReorder ? ' training-unit-sections-editor--item-drag' : '' )
2026-05-05 14:29:17 +02:00
}
>
2026-05-05 14:47:38 +02:00
{ ( ! 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 >
2026-05-05 13:39:30 +02:00
) : null }
{ list . map ( ( sec , sIdx ) => {
const planMin = sectionPlannedMinutes ( sec )
2026-05-05 14:42:46 +02:00
const itemCount = sec . items ? . length ? ? 0
2026-05-12 22:36:19 +02:00
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel ( sec . items ) : [ ]
2026-05-15 07:37:51 +02:00
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
2026-05-15 10:14:17 +02:00
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
2026-05-15 09:10:54 +02:00
const showSectionDropBandBefore =
2026-05-15 10:14:17 +02:00
( pl ? . phaseKind !== 'parallel' || ! hideParallelSection ) &&
! hideDropBandBeforeOrphanFirstVisible
2026-05-15 09:10:54 +02:00
const bandActiveBefore = ( bx ) =>
enableSectionDragReorder &&
dropSectionBand &&
dropSectionBand . slot === sectionToSlot &&
dropSectionBand . beforeIdx === bx &&
! dropSectionBand . streamDrop
const streamChipDropActive = ( po , so ) =>
useStreamTagDropUx &&
dropSectionBand ? . slot === sectionToSlot &&
dropSectionBand ? . streamDrop ? . po === po &&
dropSectionBand ? . streamDrop ? . so === so
2026-05-15 07:37:51 +02:00
const isFirstSectionOfParallelPhase =
parallelPhaseOrder != null &&
firstSectionIndexByParallelPhase . get ( parallelPhaseOrder ) === sIdx
const streamVisual =
enableParallelPhaseControls && pl ? . phaseKind === 'parallel'
? parallelStreamVisual ( pl . parallelStreamOrderIndex ? ? 0 )
: null
2026-05-15 08:20:43 +02:00
const allowSectionDragGrip = enableSectionDragReorder
2026-05-15 07:37:51 +02:00
2026-05-05 13:39:30 +02:00
return (
2026-05-05 14:42:46 +02:00
< Fragment key = { ` secFrag- ${ sIdx } ` } >
2026-05-15 09:10:54 +02:00
{ enableSectionDragReorder && showSectionDropBandBefore ? (
2026-05-05 14:42:46 +02:00
< div
2026-05-15 08:37:16 +02:00
className = {
'tu-section-dropband' +
sectionDropBandRegionClass ( list , sIdx , enableParallelPhaseControls ) +
( bandActiveBefore ( sIdx ) ? ' tu-section-dropband--active' : '' )
}
2026-05-05 14:42:46 +02:00
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 }
2026-05-15 07:37:51 +02:00
{ isFirstSectionOfParallelPhase &&
enableParallelPhaseControls &&
streamOrdersForParallelPhase . length ? (
< div
style = { {
2026-05-15 07:50:16 +02:00
marginBottom : '12px' ,
padding : '10px 12px' ,
2026-05-15 07:37:51 +02:00
background : 'var(--surface2)' ,
borderRadius : '10px' ,
border : '1px solid var(--border, rgba(0,0,0,0.08))' ,
} }
>
2026-05-15 07:50:16 +02:00
< div
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '8px' ,
alignItems : 'center' ,
marginBottom : '10px' ,
} }
>
2026-05-15 08:37:16 +02:00
{ 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 }
2026-05-15 07:50:16 +02:00
< label
className = "form-label"
style = { { fontSize : '0.78rem' , marginBottom : 0 , flex : '0 0 auto' } }
>
Phase
< / label >
2026-05-15 08:02:18 +02:00
{ ( ( ) => {
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 >
< / >
)
} ) ( ) }
2026-05-15 08:20:43 +02:00
{ ( ( ) => {
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 >
)
} ) ( ) }
2026-05-15 07:50:16 +02:00
< 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 ) : ''
2026-05-15 08:02:18 +02:00
const editKey = ` ${ parallelPhaseOrder } : ${ so } `
const editingStream = streamNameEditKey === editKey
2026-05-15 07:50:16 +02:00
return (
< div
key = { ` p ${ parallelPhaseOrder } -chip-s ${ so } ` }
2026-05-15 09:10:54 +02:00
className = {
'tu-stream-chip-pill' +
( streamChipDropActive ( parallelPhaseOrder , so )
? ' tu-stream-chip-pill--drop-active'
: '' )
}
2026-05-15 07:50:16 +02:00
style = { {
display : 'inline-flex' ,
alignItems : 'center' ,
2026-05-15 08:02:18 +02:00
gap : '4px' ,
2026-05-15 07:50:16 +02:00
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%' ,
} }
2026-05-15 09:10:54 +02:00
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
}
2026-05-15 07:50:16 +02:00
>
2026-05-15 08:02:18 +02:00
{ 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"
2026-05-15 07:50:16 +02:00
style = { {
2026-05-15 08:02:18 +02:00
flex : '0 0 auto' ,
padding : '4px' ,
color : 'var(--text2)' ,
2026-05-15 07:50:16 +02:00
borderRadius : '8px' ,
} }
2026-05-15 08:02:18 +02:00
title = "Gruppennamen bearbeiten"
aria - label = "Gruppennamen bearbeiten"
onClick = { ( e ) => {
e . stopPropagation ( )
setStreamNameEditKey ( editKey )
setStreamNameDraft ( streamName )
} }
>
< Pencil size = { 15 } strokeWidth = { 2 } aria - hidden / >
< / button >
2026-05-15 07:50:16 +02:00
< 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 >
2026-05-15 07:37:51 +02:00
< / div >
) : null }
{ ! hideParallelSection ? (
2026-05-15 09:10:54 +02:00
< >
2026-05-05 13:39:30 +02:00
< div
2026-05-05 14:42:46 +02:00
className = "tu-section-shell"
2026-05-05 13:39:30 +02:00
style = { {
2026-05-05 14:42:46 +02:00
marginBottom : '1rem' ,
padding : '0.75rem' ,
2026-05-15 07:37:51 +02:00
background : streamVisual ? streamVisual . soft : 'var(--surface2)' ,
2026-05-05 14:42:46 +02:00
borderRadius : '10px' ,
2026-05-15 07:37:51 +02:00
border : streamVisual
? ` 1px solid ${ streamVisual . border } `
: '1px solid var(--border, rgba(0,0,0,0.08))' ,
borderLeft : streamVisual ? ` 5px solid ${ streamVisual . border } ` : undefined ,
2026-05-05 13:39:30 +02:00
} }
>
2026-05-05 14:42:46 +02:00
< div
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '0.5rem' ,
marginBottom : '0.5rem' ,
alignItems : 'flex-start' ,
} }
>
2026-05-15 07:37:51 +02:00
{ allowSectionDragGrip ? (
2026-05-05 14:42:46 +02:00
< 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 ) }
2026-05-15 07:37:51 +02:00
disabled = { sectionMoveDisabledUp ( sIdx ) }
style = { {
padding : '4px 10px' ,
opacity : sectionMoveDisabledUp ( sIdx ) ? 0.35 : 1 ,
} }
2026-05-05 14:42:46 +02:00
>
▲
< / button >
< button
type = "button"
aria - label = "Abschnitt runter"
onClick = { ( ) => moveSection ( sIdx , 1 ) }
2026-05-15 07:37:51 +02:00
disabled = { sectionMoveDisabledDown ( sIdx ) }
2026-05-05 14:42:46 +02:00
style = { {
padding : '4px 10px' ,
2026-05-15 07:37:51 +02:00
opacity : sectionMoveDisabledDown ( sIdx ) ? 0.35 : 1 ,
2026-05-05 14:42:46 +02:00
} }
>
▼
< / button >
< / div >
2026-05-05 13:39:30 +02:00
< button
type = "button"
2026-05-05 14:42:46 +02:00
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick = { ( ) => removeSection ( sIdx ) }
2026-05-05 13:39:30 +02:00
>
2026-05-05 14:42:46 +02:00
Abschnitt entfernen
2026-05-05 13:39:30 +02:00
< / button >
< / div >
2026-05-15 07:25:59 +02:00
{ enableParallelPhaseControls && sec . planLoc ? (
< p
style = { {
fontSize : '0.75rem' ,
color : 'var(--text2)' ,
margin : '0 0 8px' ,
fontWeight : 500 ,
} }
>
{ sec . planLoc . phaseKind === 'whole_group'
2026-05-15 07:50:16 +02:00
? ( ( ) => {
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 } `
} ) ( ) }
2026-05-15 07:25:59 +02:00
< / p >
) : null }
2026-05-05 14:42:46 +02:00
< 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 …)"
/ >
2026-05-15 07:25:59 +02:00
{ 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 }
2026-05-05 14:42:46 +02:00
{ planMin > 0 && (
< p style = { { fontSize : '0.78rem' , color : 'var(--text2)' , marginTop : '0.35rem' } } >
Geplant in diesem Abschnitt : ca . { planMin } Min . ( Übungen )
< / p >
) }
2026-05-12 22:05:22 +02:00
{ betweenInsertMenus ? renderBetweenInsertBand ( sIdx , 0 , itemCount ) : null }
2026-05-05 14:42:46 +02:00
{ ( 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 ) ,
}
: { }
2026-05-12 21:53:41 +02:00
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 } ` : '' )
2026-05-12 22:17:44 +02:00
const modOutline =
2026-05-12 22:36:19 +02:00
! planningCompactLegend &&
2026-05-12 22:30:55 +02:00
showModuleBand &&
curMn != null
? gatherPlanningModuleOutline ( sec . items , iIdx , curMn )
: null
const fromModClass =
curMn != null
2026-05-12 22:36:19 +02:00
? planningCompactLegend
2026-05-12 22:30:55 +02:00
? ' tu-item-row--from-module-soft'
: ' tu-item-row--from-module'
: ''
const modBorderVarStyle =
2026-05-12 22:36:19 +02:00
planningCompactLegend && curMn != null
2026-05-12 22:30:55 +02:00
? { '--tu-mod-border' : planningModulePalette ( curMn ) . border }
: undefined
2026-05-12 22:17:44 +02:00
2026-05-05 14:42:46 +02:00
if ( it . item _type === 'note' ) {
2026-05-12 22:05:22 +02:00
const isSepLine = ( it . note _body || '' ) . trim ( ) === SECTION _INSERT _SEPARATOR _BODY
2026-05-05 14:42:46 +02:00
const notePv = truncatePreview ( it . note _body || '' , 260 )
2026-05-12 22:05:22 +02:00
const noteHasText = Boolean ( ( it . note _body || '' ) . trim ( ) ) && ! isSepLine
2026-05-05 14:42:46 +02:00
return (
2026-05-12 22:05:22 +02:00
< Fragment key = { ` ${ insertSlotKeyPrefix } sec- ${ sIdx } -blk- ${ iIdx } ` } >
2026-05-12 22:36:19 +02:00
{ ! planningCompactLegend &&
2026-05-12 22:30:55 +02:00
renderModulePlanningHead ( modBandTitle , modOutline , showModuleBand ) }
2026-05-12 22:05:22 +02:00
< div
className = {
` ${ rowCommon } tu-item-row--note ` +
2026-05-12 22:17:44 +02:00
( isSepLine ? ' tu-item-row--separator-note' : '' ) +
fromModClass
2026-05-12 22:05:22 +02:00
}
{ ... dndRowProps }
2026-05-12 22:30:55 +02:00
style = { modBorderVarStyle }
2026-05-12 22:05:22 +02:00
>
2026-05-05 14:42:46 +02:00
{ 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" >
2026-05-12 22:36:19 +02:00
{ ! isSepLine && planningCompactLegend && curMn ? (
2026-05-12 22:30:55 +02:00
< PlanningModuleRowTag moduleId = { curMn } title = { modBandTitle } / >
) : null }
2026-05-12 22:05:22 +02:00
< 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 >
) }
2026-05-05 14:42:46 +02:00
< / div >
2026-05-05 13:39:30 +02:00
< button
type = "button"
2026-05-05 14:42:46 +02:00
className = "tu-icon-btn"
2026-05-12 22:05:22 +02:00
title = { isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten' }
aria - label = { isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten' }
2026-05-05 14:42:46 +02:00
onClick = { ( ) =>
setTextEdit ( {
kind : 'zwischen-note' ,
sIdx ,
iIdx ,
draft : it . note _body || '' ,
} )
}
2026-05-05 13:39:30 +02:00
>
2026-05-05 14:42:46 +02:00
< Pencil size = { 15 } strokeWidth = { 2 } aria - hidden / >
2026-05-05 14:29:17 +02:00
< / button >
< button
type = "button"
2026-05-05 14:42:46 +02:00
className = "tu-item-row__remove"
title = "Entfernen"
2026-05-12 22:05:22 +02:00
aria - label = { isSepLine ? 'Trennung entfernen' : 'Zwischen-Anmerkung entfernen' }
2026-05-05 14:42:46 +02:00
onClick = { ( ) => removeItem ( sIdx , iIdx ) }
2026-05-05 14:29:17 +02:00
>
2026-05-05 14:42:46 +02:00
✗
2026-05-05 13:39:30 +02:00
< / button >
< / div >
2026-05-12 22:05:22 +02:00
{ betweenInsertMenus ? renderBetweenInsertBand ( sIdx , iIdx + 1 , itemCount ) : null }
2026-05-12 21:53:41 +02:00
< / Fragment >
2026-05-05 14:42:46 +02:00
)
}
const variantOpts = Array . isArray ( it . variants ) ? it . variants : [ ]
const exTitle =
it . exercise _title || ( it . exercise _id ? ` Übung # ${ it . exercise _id } ` : '' )
2026-05-13 06:30:53 +02:00
const isCombination =
String ( it . exercise _kind || 'simple' ) . toLowerCase ( ) . trim ( ) === 'combination'
2026-05-05 14:42:46 +02:00
const annotPrev = truncatePreview ( it . notes || '' , 220 )
const annotHasText = Boolean ( ( it . notes || '' ) . trim ( ) )
2026-05-13 06:30:53 +02:00
const hasVariants = ! isCombination && variantOpts . length > 0 && it . exercise _id
2026-05-05 14:42:46 +02:00
const variantIdPeek =
it . exercise _variant _id === '' || it . exercise _variant _id == null
? undefined
: Number ( it . exercise _variant _id )
2026-05-13 14:11:53 +02:00
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
2026-05-05 14:42:46 +02:00
return (
2026-05-12 22:05:22 +02:00
< Fragment key = { ` ${ insertSlotKeyPrefix } sec- ${ sIdx } -blk- ${ iIdx } ` } >
2026-05-12 22:36:19 +02:00
{ ! planningCompactLegend &&
2026-05-12 22:30:55 +02:00
renderModulePlanningHead ( modBandTitle , modOutline , showModuleBand ) }
< div
2026-05-14 09:05:15 +02:00
className = { ` ${ rowCommon } tu-item-row--exercise ${ fromModClass } ${
isCombination && it . exercise _id ? ' tu-item-row--combo' : ''
} ` }
2026-05-12 22:30:55 +02:00
{ ... dndRowProps }
style = { modBorderVarStyle }
>
2026-05-05 14:42:46 +02:00
< 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 >
) }
2026-05-13 06:30:53 +02:00
{ isCombination ? (
< span
className = "exercise-tag"
style = { {
marginLeft : 8 ,
fontSize : '11px' ,
alignSelf : 'center' ,
background : 'var(--accent-soft)' ,
color : 'var(--accent-dark)' ,
} }
>
Kombination
< / span >
) : null }
2026-05-12 22:36:19 +02:00
{ planningCompactLegend && curMn ? (
2026-05-12 22:30:55 +02:00
< PlanningModuleRowTag moduleId = { curMn } title = { modBandTitle } / >
) : null }
2026-05-05 14:42:46 +02:00
< span className = "tu-ex-inline-actions" >
2026-05-05 14:29:17 +02:00
< button
type = "button"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
2026-05-05 14:42:46 +02:00
onClick = { ( ) =>
onRequestExercisePick ? . ( {
sectionIndex : sIdx ,
itemIndex : iIdx ,
} )
}
2026-05-05 14:29:17 +02:00
>
2026-05-05 14:42:46 +02:00
{ exTitle ? 'Wechseln' : 'Übung suchen…' }
2026-05-05 14:29:17 +02:00
< / button >
2026-05-05 14:42:46 +02:00
{ it . exercise _id && onPeekExercise ? (
< button
type = "button"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick = { ( ) =>
2026-05-13 14:11:53 +02:00
onPeekExercise (
Number ( it . exercise _id ) ,
variantIdPeek ,
isCombination
? {
catalog _method _profile : it . catalog _method _profile ,
planning _method _profile : it . planning _method _profile ,
}
: undefined ,
)
2026-05-05 14:42:46 +02:00
}
>
Vorschau
< / button >
) : null }
2026-05-05 14:29:17 +02:00
< / span >
2026-05-05 14:42:46 +02:00
< / 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 >
2026-05-05 14:29:17 +02:00
< / div >
< / div >
2026-05-05 14:42:46 +02:00
< div className = "tu-item-row__side" >
2026-05-05 14:29:17 +02:00
< input
type = "number"
2026-05-05 14:42:46 +02:00
className = "form-input tu-ex-duration"
2026-05-05 14:29:17 +02:00
min = { 1 }
2026-05-05 14:42:46 +02:00
value = { it . planned _duration _min }
2026-05-05 14:29:17 +02:00
onChange = { ( e ) =>
2026-05-05 14:42:46 +02:00
updateItem ( sIdx , iIdx , 'planned_duration_min' , e . target . value )
2026-05-05 14:29:17 +02:00
}
2026-05-05 14:42:46 +02:00
placeholder = "Min"
title = "Geplante Dauer (Minuten)"
2026-05-05 14:29:17 +02:00
/ >
2026-05-05 14:42:46 +02:00
< button
type = "button"
className = "tu-item-row__remove"
title = "Übung entfernen"
aria - label = "Übung entfernen"
onClick = { ( ) => removeItem ( sIdx , iIdx ) }
>
✗
< / button >
< / div >
< / div >
2026-05-13 07:21:33 +02:00
{ isCombination && it . exercise _id ? (
2026-05-14 09:05:15 +02:00
< 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
}
/ >
2026-05-13 14:11:53 +02:00
< / div >
2026-05-14 09:05:15 +02:00
) : (
< 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 …
2026-05-13 14:11:53 +02:00
< / div >
2026-05-14 09:05:15 +02:00
< / div >
) }
2026-05-13 07:21:33 +02:00
< / div >
) : null }
2026-05-05 14:42:46 +02:00
{ showExecutionExtras ? (
2026-05-06 07:55:35 +02:00
< 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 >
2026-05-05 14:42:46 +02:00
< input
type = "number"
2026-05-06 07:55:35 +02:00
className = "form-input tu-ex-duration"
2026-05-05 14:42:46 +02:00
min = { 1 }
value = { it . actual _duration _min }
onChange = { ( e ) =>
updateItem ( sIdx , iIdx , 'actual_duration_min' , e . target . value )
}
2026-05-06 07:55:35 +02:00
placeholder = "IST"
title = "Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
2026-05-05 14:42:46 +02:00
/ >
2026-05-06 07:55:35 +02:00
< / div >
< / div >
2026-05-05 14:42:46 +02:00
) : null }
< / div >
2026-05-12 22:05:22 +02:00
{ betweenInsertMenus ? renderBetweenInsertBand ( sIdx , iIdx + 1 , itemCount ) : null }
2026-05-12 21:53:41 +02:00
< / Fragment >
2026-05-05 14:42:46 +02:00
)
} ) }
{ 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 }
2026-05-12 22:05:22 +02:00
< 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 >
) }
2026-05-05 14:42:46 +02:00
< / div >
2026-05-12 22:30:55 +02:00
{ 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 }
2026-05-05 13:39:30 +02:00
< / div >
2026-05-15 09:10:54 +02:00
{ 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 }
< / >
2026-05-15 07:37:51 +02:00
) : null }
2026-05-05 14:42:46 +02:00
< / Fragment >
2026-05-05 13:39:30 +02:00
)
} ) }
2026-05-05 14:42:46 +02:00
{ enableSectionDragReorder ? (
< div
className = {
'tu-section-dropband tu-section-dropband--end' +
2026-05-15 09:10:54 +02:00
( useStreamTagDropUx ? ' tu-section-dropband--whole-plan-end' : '' ) +
( ! useStreamTagDropUx
? sectionDropBandRegionClass ( list , list . length , enableParallelPhaseControls )
: '' ) +
2026-05-05 14:42:46 +02:00
( dropSectionBand &&
dropSectionBand . slot === sectionToSlot &&
2026-05-15 09:10:54 +02:00
dropSectionBand . beforeIdx === list . length &&
! dropSectionBand . streamDrop
2026-05-05 14:42:46 +02:00
? ' tu-section-dropband--active'
: '' )
}
2026-05-15 09:10:54 +02:00
title = {
useStreamTagDropUx
? 'Hier ablegen: neuer Abschnitt für die Ganzgruppe (am Planende)'
: 'Abschnitt am Ende einfügen'
}
2026-05-05 14:42:46 +02:00
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 }
2026-05-15 07:50:16 +02:00
< div
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '8px' ,
alignItems : 'center' ,
marginTop : '0.75rem' ,
} }
2026-05-05 13:39:30 +02:00
>
2026-05-15 07:50:16 +02:00
{ enableParallelPhaseControls ? (
< >
< button
type = "button"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick = { addParallelPhaseTwoStreams }
2026-05-15 08:02:18 +02:00
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'
}
2026-05-15 07:50:16 +02:00
>
2026-05-15 08:02:18 +02:00
Gruppen splitten
2026-05-15 07:50:16 +02:00
< / button >
< button
type = "button"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick = { addWholeGroupSection }
2026-05-15 08:20:43 +02:00
title = "Neuer Abschnitt für die gemeinsame Gruppe (legt bei Bedarf eine neue Ganzgruppen-Phase an)"
2026-05-15 07:50:16 +02:00
>
2026-05-15 08:20:43 +02:00
+ Abschnitt ( Ganzgruppe )
2026-05-15 07:50:16 +02:00
< / button >
< / >
2026-05-15 08:20:43 +02:00
) : (
< button
type = "button"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick = { addSection }
title = "Abschnitt am Ende anfügen"
>
+ Abschnitt hinzufügen
< / button >
) }
2026-05-15 07:50:16 +02:00
< / div >
2026-05-05 14:29:17 +02:00
2026-05-12 22:05:22 +02:00
{ 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 }
2026-05-13 09:06:10 +02:00
{ comboPlanningModalItem != null &&
comboPlanningModalSX != null &&
comboPlanningModalIX != null ? (
< div
2026-05-13 14:25:58 +02:00
className = "admin-modal-backdrop combo-planning-edit-backdrop"
2026-05-13 09:06:10 +02:00
role = "presentation"
onMouseDown = { ( e ) => {
if ( e . target === e . currentTarget ) setComboPlanningModal ( null )
} }
>
< div
2026-05-13 14:25:58 +02:00
className = "admin-modal-sheet combo-planning-edit-sheet"
2026-05-13 09:06:10 +02:00
role = "dialog"
aria - modal = "true"
aria - labelledby = "tu-combo-planning-title"
onMouseDown = { ( e ) => e . stopPropagation ( ) }
>
2026-05-13 14:25:58 +02:00
< 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 >
2026-05-13 09:06:10 +02:00
< button
type = "button"
2026-05-13 14:25:58 +02:00
className = "btn btn-secondary admin-modal-sheet__close"
onClick = { ( ) => setComboPlanningModal ( null ) }
2026-05-13 09:06:10 +02:00
>
2026-05-13 14:25:58 +02:00
Schließen
2026-05-13 09:06:10 +02:00
< / button >
< / div >
2026-05-13 14:25:58 +02:00
< 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 )
2026-05-13 14:24:55 +02:00
}
2026-05-13 14:25:58 +02:00
>
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 >
2026-05-13 09:58:59 +02:00
< / div >
2026-05-13 14:25:58 +02:00
< 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 .
2026-05-13 09:58:59 +02:00
< / p >
2026-05-13 14:25:58 +02:00
{ 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 )
}
2026-05-13 21:51:52 +02:00
candidateInteraction = { onPeekExercise ? 'button' : 'none' }
onCandidatePeek = {
onPeekExercise
? ( exId ) => onPeekExercise ( Number ( exId ) , null , undefined )
: undefined
}
2026-05-13 14:25:58 +02:00
/ >
< / 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 >
2026-05-13 09:06:10 +02:00
) }
2026-05-13 14:25:58 +02:00
< 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 ) ) {
2026-05-13 15:28:37 +02:00
const cleaned = cloneJsonSerializablePlanningProfile ( obj ) ? ? { }
updateItem (
comboPlanningModalSX ,
comboPlanningModalIX ,
'planning_method_profile' ,
Object . keys ( cleaned ) . length ? cleaned : null ,
)
2026-05-13 14:25:58 +02:00
}
} catch {
/* Ungültiges JSON — Hinweis im Editor */
}
} }
comboSlotsOutline = { comboPlanningSlotsOutline }
/ >
< / div >
2026-05-13 09:06:10 +02:00
< / div >
< / div >
< / div >
) : null }
2026-05-05 14:29:17 +02:00
{ 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 }
2026-05-05 13:39:30 +02:00
< / div >
)
}