2026-05-13 09:58:59 +02:00
|
|
|
|
import React, { Fragment, useCallback, useEffect, useMemo, 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-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
|
|
|
|
|
|
|
|
|
|
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-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]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 }]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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}`)
|
|
|
|
|
|
return [...prev, { ...base, planLoc: tmpl }]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 07:37:51 +02:00
|
|
|
|
/** Ganzgruppe: global tauschen; parallele Phase: nur innerhalb desselben Streams sortieren. */
|
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
|
|
|
|
|
|
const newPos = pos + dir
|
|
|
|
|
|
if (newPos < 0 || newPos >= bucket.length) return p
|
|
|
|
|
|
return reorderWithinBucketIndices(p, bucket, pos, newPos)
|
|
|
|
|
|
}
|
|
|
|
|
|
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-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)
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-05 13:39:30 +02:00
|
|
|
|
const list = ensure(sections)
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-05 14:42:46 +02:00
|
|
|
|
const bandActiveBefore = (bx) =>
|
|
|
|
|
|
enableSectionDragReorder &&
|
|
|
|
|
|
dropSectionBand &&
|
|
|
|
|
|
dropSectionBand.slot === sectionToSlot &&
|
|
|
|
|
|
dropSectionBand.beforeIdx === bx
|
|
|
|
|
|
|
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
|
|
|
|
|
|
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')
|
|
|
|
|
|
|
2026-05-05 13:39:30 +02:00
|
|
|
|
return (
|
2026-05-05 14:42:46 +02:00
|
|
|
|
<Fragment key={`secFrag-${sIdx}`}>
|
|
|
|
|
|
{enableSectionDragReorder ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={'tu-section-dropband' + (bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')}
|
|
|
|
|
|
title="Abschnitt hier einfügen"
|
|
|
|
|
|
onDragOver={(e) => {
|
|
|
|
|
|
if (!enableSectionDragReorder) return
|
|
|
|
|
|
if (!dtHasType(e, DND_TU_SECTION)) return
|
|
|
|
|
|
onSectionBandDragOver(e, sIdx)
|
|
|
|
|
|
}}
|
|
|
|
|
|
onDragLeave={(e) => {
|
|
|
|
|
|
if (e.currentTarget.contains(e.relatedTarget)) return
|
|
|
|
|
|
clearSectionDnD()
|
|
|
|
|
|
}}
|
|
|
|
|
|
onDrop={(e) => onSectionBandDrop(e, sIdx)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
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',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<label
|
|
|
|
|
|
className="form-label"
|
|
|
|
|
|
style={{ fontSize: '0.78rem', marginBottom: 0, flex: '0 0 auto' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Phase
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ flex: '2 1 160px', maxWidth: '280px', marginBottom: 0 }}
|
|
|
|
|
|
value={
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
const hi = firstSectionIndexByParallelPhase.get(parallelPhaseOrder)
|
|
|
|
|
|
const t = hi != null ? list[hi]?.planLoc?.phaseTitle : null
|
|
|
|
|
|
return t != null ? String(t) : ''
|
|
|
|
|
|
})()
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(e) => updateParallelPhaseTitleAll(parallelPhaseOrder, e.target.value)}
|
|
|
|
|
|
placeholder={`Bezeichnung (z. B. Drill runden · Phase ${parallelPhaseOrder})`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<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) : ''
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={`p${parallelPhaseOrder}-chip-s${so}`}
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={sel}
|
|
|
|
|
|
className={sel ? 'tu-stream-chip tu-stream-chip--active' : 'tu-stream-chip'}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'inline-flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: '6px',
|
|
|
|
|
|
padding: '4px 6px 4px 8px',
|
|
|
|
|
|
borderRadius: '999px',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
border: sel ? `2px solid ${pv.border}` : `1px solid ${pv.border}`,
|
|
|
|
|
|
background: sel ? pv.tabBgActive : pv.tabBg,
|
|
|
|
|
|
maxWidth: '100%',
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setParallelStreamTabByPhase((prev) => ({ ...prev, [parallelPhaseOrder]: so }))
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
minWidth: '5.5rem',
|
|
|
|
|
|
maxWidth: '11rem',
|
|
|
|
|
|
margin: 0,
|
|
|
|
|
|
padding: '4px 8px',
|
|
|
|
|
|
fontSize: '0.8rem',
|
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
|
flex: '1 1 auto',
|
|
|
|
|
|
}}
|
|
|
|
|
|
value={streamName}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
updateParallelStreamTitleAll(parallelPhaseOrder, so, e.target.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
placeholder={`Gruppe ${so + 1}`}
|
|
|
|
|
|
aria-label={`Name Stream ${so + 1}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<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-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 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' +
|
|
|
|
|
|
(dropSectionBand &&
|
|
|
|
|
|
dropSectionBand.slot === sectionToSlot &&
|
|
|
|
|
|
dropSectionBand.beforeIdx === list.length
|
|
|
|
|
|
? ' tu-section-dropband--active'
|
|
|
|
|
|
: '')
|
|
|
|
|
|
}
|
|
|
|
|
|
title="Abschnitt am Ende einfügen"
|
|
|
|
|
|
onDragOver={(e) => {
|
|
|
|
|
|
if (!dtHasType(e, DND_TU_SECTION)) return
|
|
|
|
|
|
onSectionBandDragOver(e, list.length)
|
|
|
|
|
|
}}
|
|
|
|
|
|
onDragLeave={(e) => {
|
|
|
|
|
|
if (e.currentTarget.contains(e.relatedTarget)) return
|
|
|
|
|
|
clearSectionDnD()
|
|
|
|
|
|
}}
|
|
|
|
|
|
onDrop={(e) => onSectionBandDrop(e, list.length)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
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={addWholeGroupPhase}>
|
|
|
|
|
|
Neue Ganzgruppen-Phase
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
|
|
|
|
|
onClick={addParallelPhaseTwoStreams}
|
|
|
|
|
|
>
|
|
|
|
|
|
Zwei Gruppen parallel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
|
|
|
|
|
onClick={addWholeGroupSection}
|
|
|
|
|
|
title="Neuer Abschnitt für die gemeinsame Gruppe (nicht für einen parallelen Stream)"
|
|
|
|
|
|
>
|
|
|
|
|
|
+ Ganzgruppen-Abschnitt
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
|
|
|
|
|
onClick={addSection}
|
|
|
|
|
|
title="Letzte Zuordnung übernehmen (Phase / Stream)"
|
|
|
|
|
|
>
|
|
|
|
|
|
+ Abschnitt hinzufügen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|