All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m10s
- Introduced logic to limit the number of streams per parallel phase, ensuring compliance with the defined maximum. - Added utility functions for managing stream indices and visual representation of streams. - Implemented section movement within parallel streams, allowing for reordering while maintaining stream integrity. - Updated UI components to reflect changes in stream handling, including disabling buttons when limits are reached. - Enhanced state management for parallel stream tabs, improving user experience in navigating between streams.
1963 lines
75 KiB
JavaScript
1963 lines
75 KiB
JavaScript
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { GripVertical, Pencil } from 'lucide-react'
|
||
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
|
||
import CombinationPlanBracket from './CombinationPlanBracket'
|
||
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||
import {
|
||
cloneJsonSerializablePlanningProfile,
|
||
comboSlotsOutlineForProfileEditor,
|
||
defaultSection,
|
||
defaultPlanLocWholeGroup,
|
||
defaultPlanLocParallel,
|
||
maxPhaseOrderIndexFromSections,
|
||
buildPlanTargetOptions,
|
||
planLocKey,
|
||
MAX_PARALLEL_STREAMS_PER_PHASE,
|
||
parallelStreamVisual,
|
||
streamTabLabelFromIndices,
|
||
streamsForParallelPhaseOrders,
|
||
sectionIndicesForParallelStream,
|
||
reorderWithinBucketIndices,
|
||
exerciseRow,
|
||
noteRow,
|
||
sectionPlannedMinutes,
|
||
} from '../utils/trainingUnitSectionsForm'
|
||
import api from '../utils/api'
|
||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||
import { useAuth } from '../context/AuthContext'
|
||
|
||
function stripPlanLocFromSection(s) {
|
||
if (!s || typeof s !== 'object') return s
|
||
const { planLoc: _ignored, ...rest } = s
|
||
return rest
|
||
}
|
||
|
||
function planSelectOptionsForSection(sections, sIdx, baseOpts) {
|
||
const sec = sections[sIdx]
|
||
const k = planLocKey(sec?.planLoc)
|
||
if (k && !baseOpts.some((o) => o.key === k)) {
|
||
const pl = sec.planLoc
|
||
const label =
|
||
pl.phaseKind === 'parallel'
|
||
? `Parallel · Phase ${pl.phaseOrderIndex ?? 0} · Stream ${pl.parallelStreamOrderIndex ?? 0}`
|
||
: `Ganzgruppe · Phase ${pl.phaseOrderIndex ?? 0}`
|
||
return [...baseOpts, { key: k, label, template: { ...pl } }].sort((a, b) =>
|
||
a.key.localeCompare(b.key, undefined, { numeric: true })
|
||
)
|
||
}
|
||
return baseOpts
|
||
}
|
||
|
||
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
|
||
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
||
|
||
/** Optische Trennlinie: wird als normale Zwischen-Anmerkung gespeichert (Inhalt nur dieser Marker). */
|
||
const SECTION_INSERT_SEPARATOR_BODY = '---'
|
||
|
||
function normalizedPlanningModuleChainId(raw) {
|
||
if (raw == null || raw === '') return null
|
||
const n = typeof raw === 'number' ? raw : Number(raw)
|
||
return Number.isFinite(n) && n >= 1 ? n : null
|
||
}
|
||
|
||
function dtHasType(e, mime) {
|
||
const t = e?.dataTransfer?.types
|
||
if (!t || !mime) return false
|
||
if (typeof t.contains === 'function' && t.contains(mime)) return true
|
||
return Array.from(t).includes(mime)
|
||
}
|
||
|
||
function truncatePreview(text, max = 160) {
|
||
const t = (text || '').replace(/\s+/g, ' ').trim()
|
||
if (t.length <= max) return t
|
||
return `${t.slice(0, max - 1)}…`
|
||
}
|
||
|
||
/** Liest den zusammenhängenden Lauf eines Moduls im Abschnitt (ab erstem Item mit dieser Herkunfts-ID). */
|
||
function gatherPlanningModuleOutline(items, startIdx, moduleId) {
|
||
const exercises = []
|
||
let notes = 0
|
||
for (let j = startIdx; j < (items?.length ?? 0); j++) {
|
||
const row = items[j]
|
||
if (normalizedPlanningModuleChainId(row.source_training_module_id) !== moduleId) break
|
||
if (row.item_type === 'note') {
|
||
const bod = (row.note_body || '').trim()
|
||
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
|
||
notes += 1
|
||
continue
|
||
}
|
||
const t =
|
||
(row.exercise_title || '').trim() ||
|
||
(row.exercise_id ? `Übung #${row.exercise_id}` : 'Übung')
|
||
exercises.push(t)
|
||
}
|
||
return { exercises, notes }
|
||
}
|
||
|
||
const MODULE_OUTLINE_PREVIEW_MAX = 8
|
||
|
||
/** Statuszeile: nur Planungs‑Override vs. Katalog (Archetyp steht bereits links). */
|
||
function compactComboPlanningCaption(it) {
|
||
const overridden =
|
||
it.planning_method_profile != null &&
|
||
typeof it.planning_method_profile === 'object' &&
|
||
!Array.isArray(it.planning_method_profile)
|
||
return overridden ? 'Planung angepasst' : 'wie Katalog'
|
||
}
|
||
|
||
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
|
||
function planningModulePalette(moduleId) {
|
||
const id = normalizedPlanningModuleChainId(moduleId)
|
||
const n = id != null && id >= 1 ? Math.floor(Number(id)) : 1
|
||
const golden = ((n >>> 0) * 2654435761 + n * 73856093) >>> 0
|
||
const h = golden % 360
|
||
const border = `hsl(${h} 52% 36%)`
|
||
const soft = `hsl(${h} 42% 94%)`
|
||
return { border, soft, hue: h }
|
||
}
|
||
|
||
function PlanningModuleRowTag({ moduleId, title }) {
|
||
const p = planningModulePalette(moduleId)
|
||
const lbl = truncatePreview(title || `Modul #${moduleId}`, 34).trim()
|
||
const fullTitle = ((title || '').trim() || `Modul #${moduleId}`).trim()
|
||
return (
|
||
<span
|
||
className="tu-planning-mod-tag"
|
||
style={{ borderColor: p.border, backgroundColor: p.soft }}
|
||
title={`${fullTitle} (Bibliotheks-ID ${moduleId})`}
|
||
>
|
||
<span className="tu-planning-mod-tag__dot" style={{ background: p.border }} aria-hidden />
|
||
<span className="tu-planning-mod-tag__text">Aus Modul: {lbl}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** Eindeutige Module im Abschnitt mit Zählerständen für die Legende. */
|
||
function sectionModuleLegendModel(items) {
|
||
const map = new Map()
|
||
for (const row of items || []) {
|
||
const id = normalizedPlanningModuleChainId(row.source_training_module_id)
|
||
if (id == null) continue
|
||
if (!map.has(id)) {
|
||
map.set(id, {
|
||
id,
|
||
title: (((row.source_module_title || '').trim() || '') || `Modul #${id}`).trim(),
|
||
exercises: 0,
|
||
notes: 0,
|
||
})
|
||
}
|
||
const agg = map.get(id)
|
||
if ((row.item_type || '') === 'note') {
|
||
const bod = ((row.note_body || '').trim() || '').trim()
|
||
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
|
||
agg.notes += 1
|
||
} else {
|
||
agg.exercises += 1
|
||
}
|
||
}
|
||
return [...map.values()].sort((a, b) => a.id - b.id)
|
||
}
|
||
|
||
function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
|
||
if (!showModuleBand || !modOutline) return null
|
||
return (
|
||
<div className="tu-module-bundle-head" role="group" aria-label={`Modul ${modBandTitle}`}>
|
||
<div className="tu-module-bundle-head__stripe" aria-hidden />
|
||
<div className="tu-module-bundle-head__main">
|
||
<span className="tu-module-bundle-head__kicker">Aus Modul</span>
|
||
<strong className="tu-module-bundle-head__title">{modBandTitle}</strong>
|
||
{modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
|
||
<p className="tu-module-bundle-head__empty">Ohne strukturierten Inhalt angezeigt.</p>
|
||
) : (
|
||
<ol className="tu-module-bundle-head__list" start={1}>
|
||
{modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
|
||
<li key={`mo-li-${modBandTitle}-${ox}`}>{tx}</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
{modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
|
||
<p className="tu-module-bundle-head__more">
|
||
… und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
|
||
{modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
|
||
</p>
|
||
) : null}
|
||
{modOutline.notes > 0 ? (
|
||
<p className="tu-module-bundle-head__meta">
|
||
sowie {modOutline.notes}{' '}
|
||
{modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
|
||
const b = [...blocks]
|
||
if (fromI < 0 || fromI >= b.length) return blocks
|
||
const [moved] = b.splice(fromI, 1)
|
||
let insertAt = toBeforeIdx
|
||
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
|
||
insertAt = Math.max(0, Math.min(insertAt, b.length))
|
||
b.splice(insertAt, 0, moved)
|
||
return b
|
||
}
|
||
|
||
/**
|
||
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
|
||
* @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben
|
||
*/
|
||
export default function TrainingUnitSectionsEditor({
|
||
sections,
|
||
onSectionsChange,
|
||
onRequestExercisePick,
|
||
onRequestTrainingModulePick,
|
||
onPeekExercise,
|
||
showExecutionExtras = false,
|
||
heading = 'Abschnitte & Übungen',
|
||
hideHeading = false,
|
||
headingAccessory = null,
|
||
wideExerciseGrid = false,
|
||
enableItemDragReorder = true,
|
||
enableSectionDragReorder = true,
|
||
slotIndex = null,
|
||
onMoveSectionsAcrossSlots = null,
|
||
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
|
||
betweenInsertMenus = true,
|
||
/** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */
|
||
enableParallelPhaseControls = false,
|
||
}) {
|
||
const { user } = useAuth()
|
||
const planningCompactLegend = isCompactTagLegendMode(
|
||
user?.training_planning_prefs?.module_display_mode
|
||
)
|
||
|
||
const ensure = (prev) =>
|
||
prev && prev.length ? prev : [defaultSection()]
|
||
|
||
const patch = useCallback(
|
||
(updater) => {
|
||
onSectionsChange((prev) => updater(ensure(prev)))
|
||
},
|
||
[onSectionsChange]
|
||
)
|
||
|
||
const sectionToSlot =
|
||
slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1
|
||
|
||
const updateSectionField = (sIdx, field, val) => {
|
||
patch((prev) =>
|
||
prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s))
|
||
)
|
||
}
|
||
|
||
const addSection = () => {
|
||
patch((prev) => {
|
||
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
|
||
const last = prev[prev.length - 1]
|
||
const next = last?.planLoc ? { ...base, planLoc: { ...last.planLoc } } : base
|
||
return [...prev, next]
|
||
})
|
||
}
|
||
|
||
const addWholeGroupPhase = () => {
|
||
patch((prev) => {
|
||
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
|
||
const pl = defaultPlanLocWholeGroup(nextPo)
|
||
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
|
||
return [...prev, { ...base, planLoc: pl }]
|
||
})
|
||
}
|
||
|
||
const addParallelPhase = () => {
|
||
patch((prev) => {
|
||
const nextPo = maxPhaseOrderIndexFromSections(prev) + 1
|
||
const pl = defaultPlanLocParallel(nextPo, 0)
|
||
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
|
||
return [...prev, { ...base, planLoc: pl }]
|
||
})
|
||
}
|
||
|
||
const addStreamToLastParallelPhase = () => {
|
||
patch((prev) => {
|
||
const par = (prev || []).filter((s) => s?.planLoc?.phaseKind === 'parallel')
|
||
if (!par.length) return prev
|
||
const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0))
|
||
const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP)
|
||
const distinctStreams = new Set(inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
|
||
if (distinctStreams.size >= MAX_PARALLEL_STREAMS_PER_PHASE) return prev
|
||
const maxS = Math.max(...inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
|
||
const newSo = maxS + 1
|
||
const tmpl = {
|
||
...inPhase[0].planLoc,
|
||
parallelStreamOrderIndex: newSo,
|
||
streamTitle: null,
|
||
streamNotes: null,
|
||
streamAssignedTrainerProfileIds: null,
|
||
}
|
||
const base = defaultSection(`Abschnitt ${prev.length + 1}`)
|
||
return [...prev, { ...base, planLoc: tmpl }]
|
||
})
|
||
}
|
||
|
||
const applySectionPlanTarget = (sIdx, rawKey) => {
|
||
patch((prev) => {
|
||
if (!rawKey) {
|
||
return prev.map((s, i) => (i === sIdx ? stripPlanLocFromSection(s) : s))
|
||
}
|
||
const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev))
|
||
const hit = opts.find((o) => o.key === rawKey)
|
||
if (!hit) return prev
|
||
const tpl = { ...hit.template }
|
||
return prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s))
|
||
})
|
||
}
|
||
|
||
const removeSection = (sIdx) => {
|
||
patch((prev) => {
|
||
const next = prev.filter((_, i) => i !== sIdx)
|
||
return next.length ? next : [defaultSection()]
|
||
})
|
||
}
|
||
|
||
/** Ganzgruppe: global tauschen; parallele Phase: nur innerhalb desselben Streams sortieren. */
|
||
const moveSection = (sIdx, dir) => {
|
||
patch((prev) => {
|
||
const p = ensure(prev)
|
||
const sec = p[sIdx]
|
||
const L = sec?.planLoc
|
||
if (L?.phaseKind === 'parallel') {
|
||
const po = L.phaseOrderIndex ?? 0
|
||
const so = L.parallelStreamOrderIndex ?? 0
|
||
const bucket = sectionIndicesForParallelStream(p, po, so)
|
||
const pos = bucket.indexOf(sIdx)
|
||
if (pos < 0) return p
|
||
const newPos = pos + dir
|
||
if (newPos < 0 || newPos >= bucket.length) return p
|
||
return reorderWithinBucketIndices(p, bucket, pos, newPos)
|
||
}
|
||
const arr = [...p]
|
||
const ta = sIdx + dir
|
||
if (ta < 0 || ta >= arr.length) return arr
|
||
;[arr[sIdx], arr[ta]] = [arr[ta], arr[sIdx]]
|
||
return arr
|
||
})
|
||
}
|
||
|
||
const insertItemAt = useCallback(
|
||
(sIdx, beforeIx, row) => {
|
||
patch((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
const ix = Math.max(
|
||
0,
|
||
Math.min(Number(beforeIx) || 0, items.length)
|
||
)
|
||
items.splice(ix, 0, row)
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
},
|
||
[patch]
|
||
)
|
||
|
||
const addItem = (sIdx, kind) => {
|
||
patch((prev) =>
|
||
prev.map((s, i) => {
|
||
if (i !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
items.push(kind === 'note' ? noteRow() : exerciseRow())
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
}
|
||
|
||
const removeItem = (sIdx, iIdx) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) =>
|
||
si !== sIdx ? s : { ...s, items: (s.items || []).filter((_, ii) => ii !== iIdx) }
|
||
)
|
||
)
|
||
}
|
||
|
||
const moveItem = (sIdx, iIdx, dir) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) => {
|
||
if (si !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
const ta = iIdx + dir
|
||
if (ta < 0 || ta >= items.length) return s
|
||
;[items[iIdx], items[ta]] = [items[ta], items[iIdx]]
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
}
|
||
|
||
const updateItem = (sIdx, iIdx, field, val) => {
|
||
patch((prev) =>
|
||
prev.map((s, si) =>
|
||
si !== sIdx
|
||
? s
|
||
: {
|
||
...s,
|
||
items: (s.items || []).map((row, ii) =>
|
||
ii === iIdx ? { ...row, [field]: val } : row
|
||
),
|
||
}
|
||
)
|
||
)
|
||
}
|
||
|
||
const [textEdit, setTextEdit] = useState(null)
|
||
/** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
|
||
const [comboPlanningModal, setComboPlanningModal] = useState(null)
|
||
/** Katalog-Stationen, falls Zeile noch keine `combination_slots` (vor Enrich o. Ä.) */
|
||
const [modalComboSlotsFetched, setModalComboSlotsFetched] = useState(null)
|
||
/** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
|
||
const [insertChooser, setInsertChooser] = useState(null)
|
||
const [draggingPos, setDraggingPos] = useState(null)
|
||
const [dropTargetPos, setDropTargetPos] = useState(null)
|
||
|
||
const [dropSectionBand, setDropSectionBand] = useState(null)
|
||
/** Aktiver Reiter pro paralleler Phase (phaseOrder → streamOrder). */
|
||
const [parallelStreamTabByPhase, setParallelStreamTabByPhase] = useState({})
|
||
/** { slot: number, beforeIdx: number } */
|
||
|
||
useEffect(() => {
|
||
if (!textEdit) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setTextEdit(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [textEdit])
|
||
|
||
useEffect(() => {
|
||
if (!insertChooser) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setInsertChooser(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [insertChooser])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) return
|
||
const onKey = (e) => {
|
||
if (e.key === 'Escape') setComboPlanningModal(null)
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [comboPlanningModal])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) return
|
||
const L = ensure(sections)
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const row = L[sIdx]?.items?.[iIdx]
|
||
const ok =
|
||
row &&
|
||
String(row.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||
row.exercise_id
|
||
if (!ok) setComboPlanningModal(null)
|
||
}, [sections, comboPlanningModal])
|
||
|
||
useEffect(() => {
|
||
if (!comboPlanningModal) {
|
||
setModalComboSlotsFetched(null)
|
||
return
|
||
}
|
||
const L = ensure(sections)
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const cand = L[sIdx]?.items?.[iIdx]
|
||
if (
|
||
!cand ||
|
||
String(cand.exercise_kind || '').toLowerCase().trim() !== 'combination' ||
|
||
!cand.exercise_id
|
||
) {
|
||
setModalComboSlotsFetched(null)
|
||
return
|
||
}
|
||
const cached = cand.combination_slots
|
||
if (Array.isArray(cached) && cached.length > 0) {
|
||
setModalComboSlotsFetched(cached)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
setModalComboSlotsFetched([])
|
||
api.getExercise(cand.exercise_id).then((ex) => {
|
||
if (cancelled) return
|
||
setModalComboSlotsFetched(Array.isArray(ex?.combination_slots) ? ex.combination_slots : [])
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [sections, comboPlanningModal])
|
||
|
||
const closeInsertChooser = useCallback(() => setInsertChooser(null), [])
|
||
|
||
const insertSlotKeyPrefix =
|
||
slotIndex !== null && slotIndex !== undefined ? `sl${slotIndex}-` : ''
|
||
|
||
const clearSectionDnD = () => setDropSectionBand(null)
|
||
|
||
const onSectionDragStart = (e, sIdx) => {
|
||
if (!enableSectionDragReorder) return
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData(
|
||
DND_TU_SECTION,
|
||
JSON.stringify({
|
||
fromSlot: sectionToSlot,
|
||
fromSectionIdx: sIdx,
|
||
})
|
||
)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropSectionBand(null)
|
||
}
|
||
|
||
const onSectionBandDragOver = (e, beforeIdx) => {
|
||
if (!enableSectionDragReorder) return
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move'
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropSectionBand({ slot: sectionToSlot, beforeIdx })
|
||
}
|
||
|
||
const onSectionBandDrop = (e, insertBeforeIdx) => {
|
||
if (!enableSectionDragReorder) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
clearSectionDnD()
|
||
let raw = ''
|
||
try {
|
||
raw = e.dataTransfer.getData(DND_TU_SECTION)
|
||
} catch {
|
||
return
|
||
}
|
||
if (!raw) return
|
||
let data
|
||
try {
|
||
data = JSON.parse(raw)
|
||
} catch {
|
||
return
|
||
}
|
||
const fromSi = data.fromSectionIdx
|
||
const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
|
||
if (typeof fromSi !== 'number') return
|
||
|
||
if (
|
||
typeof onMoveSectionsAcrossSlots === 'function' &&
|
||
sectionToSlot >= 0 &&
|
||
fromSlot >= 0
|
||
) {
|
||
onMoveSectionsAcrossSlots({
|
||
fromSlot,
|
||
fromSectionIdx: fromSi,
|
||
toSlot: sectionToSlot,
|
||
toSectionIdx: insertBeforeIdx,
|
||
})
|
||
return
|
||
}
|
||
|
||
patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx))
|
||
}
|
||
|
||
const onItemDragStart = (e, sIdx, iIdx) => {
|
||
if (!enableItemDragReorder) return
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData(
|
||
DND_TU_ITEM,
|
||
JSON.stringify({ sectionIndex: sIdx, itemIndex: iIdx })
|
||
)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDraggingPos({ sIdx, iIdx })
|
||
}
|
||
|
||
const clearDragChrome = () => {
|
||
setDraggingPos(null)
|
||
setDropTargetPos(null)
|
||
}
|
||
|
||
const onItemDragOverRow = (e, sIdx, iIdx) => {
|
||
if (!enableItemDragReorder) return
|
||
if (!dtHasType(e, DND_TU_ITEM)) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
try {
|
||
e.dataTransfer.dropEffect = 'move'
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setDropTargetPos({ sIdx, iIdx })
|
||
}
|
||
|
||
const onItemDropRow = (e, toSIdx, toIdx) => {
|
||
if (!enableItemDragReorder) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
let raw = ''
|
||
try {
|
||
raw = e.dataTransfer.getData(DND_TU_ITEM)
|
||
} catch {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
if (!raw) {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
let data
|
||
try {
|
||
data = JSON.parse(raw)
|
||
} catch {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
const fromS = data.sectionIndex
|
||
const fromI = data.itemIndex
|
||
if (typeof fromS !== 'number' || typeof fromI !== 'number') {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
if (fromS === toSIdx && fromI === toIdx) {
|
||
clearDragChrome()
|
||
return
|
||
}
|
||
|
||
patch((prev) => {
|
||
const list = ensure(prev)
|
||
if (
|
||
fromS < 0 ||
|
||
fromS >= list.length ||
|
||
toSIdx < 0 ||
|
||
toSIdx >= list.length ||
|
||
typeof toIdx !== 'number'
|
||
) {
|
||
return prev
|
||
}
|
||
|
||
const fromItems = [...(list[fromS].items || [])]
|
||
if (fromI < 0 || fromI >= fromItems.length) return prev
|
||
|
||
const moved = fromItems[fromI]
|
||
fromItems.splice(fromI, 1)
|
||
|
||
if (fromS === toSIdx) {
|
||
let insertAt = toIdx
|
||
if (fromI < toIdx) insertAt = toIdx - 1
|
||
const bounded = Math.max(0, Math.min(insertAt, fromItems.length))
|
||
fromItems.splice(bounded, 0, moved)
|
||
return list.map((sec, i) => (i === fromS ? { ...sec, items: fromItems } : sec))
|
||
}
|
||
|
||
const toItems = [...(list[toSIdx].items || [])]
|
||
const insertAt = Math.max(0, Math.min(toIdx, toItems.length))
|
||
toItems.splice(insertAt, 0, moved)
|
||
return list.map((sec, i) => {
|
||
if (i === fromS) return { ...sec, items: fromItems }
|
||
if (i === toSIdx) return { ...sec, items: toItems }
|
||
return sec
|
||
})
|
||
})
|
||
clearDragChrome()
|
||
}
|
||
|
||
const applyTextEdit = () => {
|
||
if (!textEdit) return
|
||
const { kind, sIdx, iIdx, draft } = textEdit
|
||
if (kind === 'zwischen-note') {
|
||
updateItem(sIdx, iIdx, 'note_body', draft)
|
||
} else if (kind === 'exercise-notes') {
|
||
updateItem(sIdx, iIdx, 'notes', draft)
|
||
}
|
||
setTextEdit(null)
|
||
}
|
||
|
||
const renderBetweenInsertBand = (sIdx, beforeIx, itemCount) => {
|
||
const posLabel =
|
||
beforeIx === 0
|
||
? 'vor dem ersten Eintrag'
|
||
: beforeIx >= itemCount
|
||
? 'am Ende des Abschnitts'
|
||
: `vor Eintrag ${beforeIx + 1}`
|
||
return (
|
||
<div className="tu-insert-slot">
|
||
<button
|
||
type="button"
|
||
className="tu-insert-slot__btn"
|
||
aria-haspopup="dialog"
|
||
aria-label={`Inhalt einfügen (${posLabel})`}
|
||
title={`Hier einfügen (${posLabel})`}
|
||
onClick={() => setInsertChooser({ sIdx, beforeIx })}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const list = ensure(sections)
|
||
|
||
const hasParallelPhase = useMemo(
|
||
() => list.some((s) => s?.planLoc?.phaseKind === 'parallel'),
|
||
[list]
|
||
)
|
||
|
||
const firstSectionIndexByParallelPhase = useMemo(() => {
|
||
const m = new Map()
|
||
list.forEach((s, i) => {
|
||
const L = s?.planLoc
|
||
if (L?.phaseKind !== 'parallel') return
|
||
const po = L.phaseOrderIndex ?? 0
|
||
if (!m.has(po)) m.set(po, i)
|
||
})
|
||
return m
|
||
}, [list])
|
||
|
||
const parallelPhaseOrdersPresent = useMemo(() => {
|
||
const set = new Set()
|
||
for (const s of list) {
|
||
if (s?.planLoc?.phaseKind === 'parallel') set.add(s.planLoc.phaseOrderIndex ?? 0)
|
||
}
|
||
return [...set].sort((a, b) => a - b)
|
||
}, [list])
|
||
|
||
const cannotAddMoreStreams = useMemo(() => {
|
||
const par = list.filter((s) => s?.planLoc?.phaseKind === 'parallel')
|
||
if (!par.length) return true
|
||
const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0))
|
||
const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP)
|
||
const distinct = new Set(inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0))
|
||
return distinct.size >= MAX_PARALLEL_STREAMS_PER_PHASE
|
||
}, [list])
|
||
|
||
useEffect(() => {
|
||
if (!enableParallelPhaseControls || !parallelPhaseOrdersPresent.length) return
|
||
setParallelStreamTabByPhase((prev) => {
|
||
const next = { ...prev }
|
||
let changed = false
|
||
for (const po of parallelPhaseOrdersPresent) {
|
||
const orders = streamsForParallelPhaseOrders(list, po)
|
||
if (!orders.length) continue
|
||
if (next[po] === undefined) {
|
||
next[po] = orders[0]
|
||
changed = true
|
||
} else if (!orders.includes(next[po])) {
|
||
next[po] = orders[0]
|
||
changed = true
|
||
}
|
||
}
|
||
for (const k of Object.keys(next)) {
|
||
const poi = Number(k)
|
||
if (!Number.isFinite(poi) || !parallelPhaseOrdersPresent.includes(poi)) {
|
||
delete next[k]
|
||
changed = true
|
||
}
|
||
}
|
||
return changed ? next : prev
|
||
})
|
||
}, [list, parallelPhaseOrdersPresent, enableParallelPhaseControls])
|
||
|
||
const sectionMoveDisabledUp = (sIdx) => {
|
||
const sec = list[sIdx]
|
||
const L = sec?.planLoc
|
||
if (L?.phaseKind === 'parallel') {
|
||
const po = L.phaseOrderIndex ?? 0
|
||
const so = L.parallelStreamOrderIndex ?? 0
|
||
const bucket = sectionIndicesForParallelStream(list, po, so)
|
||
const pos = bucket.indexOf(sIdx)
|
||
return pos <= 0
|
||
}
|
||
return sIdx === 0
|
||
}
|
||
|
||
const sectionMoveDisabledDown = (sIdx) => {
|
||
const sec = list[sIdx]
|
||
const L = sec?.planLoc
|
||
if (L?.phaseKind === 'parallel') {
|
||
const po = L.phaseOrderIndex ?? 0
|
||
const so = L.parallelStreamOrderIndex ?? 0
|
||
const bucket = sectionIndicesForParallelStream(list, po, so)
|
||
const pos = bucket.indexOf(sIdx)
|
||
return pos < 0 || pos >= bucket.length - 1
|
||
}
|
||
return sIdx === list.length - 1
|
||
}
|
||
|
||
const comboPlanningModalDerived = useMemo(() => {
|
||
if (!comboPlanningModal) {
|
||
return { item: null, sIdx: null, iIdx: null }
|
||
}
|
||
const { sIdx, iIdx } = comboPlanningModal
|
||
const cand = list[sIdx]?.items?.[iIdx]
|
||
if (
|
||
cand &&
|
||
String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||
cand.exercise_id
|
||
) {
|
||
return { item: cand, sIdx, iIdx }
|
||
}
|
||
return { item: null, sIdx: null, iIdx: null }
|
||
}, [list, comboPlanningModal])
|
||
|
||
const comboPlanningModalItem = comboPlanningModalDerived.item
|
||
const comboPlanningModalSX = comboPlanningModalDerived.sIdx
|
||
const comboPlanningModalIX = comboPlanningModalDerived.iIdx
|
||
|
||
const comboPlanningResolvedSlots = useMemo(() => {
|
||
if (!comboPlanningModalItem) return []
|
||
const c = comboPlanningModalItem.combination_slots
|
||
if (Array.isArray(c) && c.length > 0) return c
|
||
return Array.isArray(modalComboSlotsFetched) ? modalComboSlotsFetched : []
|
||
}, [comboPlanningModalItem, modalComboSlotsFetched])
|
||
|
||
const comboPlanningSlotsOutline = useMemo(
|
||
() => comboSlotsOutlineForProfileEditor(comboPlanningResolvedSlots),
|
||
[comboPlanningResolvedSlots]
|
||
)
|
||
|
||
const comboPlanningEffectiveProfile = useMemo(() => {
|
||
if (!comboPlanningModalItem) return {}
|
||
return effectiveComboMethodProfile(
|
||
comboPlanningModalItem.catalog_method_profile || {},
|
||
comboPlanningModalItem.planning_method_profile
|
||
)
|
||
}, [comboPlanningModalItem])
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
'training-unit-sections-editor' +
|
||
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '') +
|
||
(enableItemDragReorder ? ' training-unit-sections-editor--item-drag' : '')
|
||
}
|
||
>
|
||
{(!hideHeading || headingAccessory) ? (
|
||
<div
|
||
className="tu-editor-heading-toolbar"
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
marginBottom: '0.75rem',
|
||
}}
|
||
>
|
||
{!hideHeading ? (
|
||
<h3 style={{ margin: 0, fontSize: '1rem', flex: '1 1 200px', minWidth: 0 }}>
|
||
{heading}
|
||
</h3>
|
||
) : headingAccessory ? (
|
||
<span style={{ flex: '1 1 auto', minWidth: 0 }} />
|
||
) : null}
|
||
{headingAccessory ? (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '8px',
|
||
justifyContent: 'flex-end',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
{headingAccessory}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{enableParallelPhaseControls ? (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
marginBottom: '1rem',
|
||
padding: '12px 14px',
|
||
background: 'var(--surface2)',
|
||
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||
borderRadius: '10px',
|
||
}}
|
||
>
|
||
<div style={{ fontSize: '0.88rem', fontWeight: 600, marginBottom: '6px', color: 'var(--text1)' }}>
|
||
Breakout: Phasen und parallele Streams
|
||
</div>
|
||
<p style={{ margin: '0 0 12px', fontSize: '0.8rem', color: 'var(--text2)', lineHeight: 1.5 }}>
|
||
Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. Pro paralleler Phase erscheinen
|
||
Reiter je Stream — nur der aktive Stream ist sichtbar (farbig am linken Rand). „Abschnitt hinzufügen“ übernimmt
|
||
die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '}
|
||
<code style={{ fontSize: '0.78em' }}>phases</code>-Payload fürs Backend.
|
||
</p>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||
<button type="button" className="btn btn-secondary" onClick={addWholeGroupPhase}>
|
||
Neue Ganzgruppen-Phase
|
||
</button>
|
||
<button type="button" className="btn btn-secondary" onClick={addParallelPhase}>
|
||
Neue parallele Phase
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={addStreamToLastParallelPhase}
|
||
disabled={!hasParallelPhase || cannotAddMoreStreams}
|
||
title={
|
||
!hasParallelPhase
|
||
? 'Zuerst eine parallele Phase anlegen'
|
||
: cannotAddMoreStreams
|
||
? `Höchstens ${MAX_PARALLEL_STREAMS_PER_PHASE} Streams pro Phase`
|
||
: 'Weiterer Stream in der letzten parallelen Phase (höchster Phasen-Index)'
|
||
}
|
||
>
|
||
Stream hinzufügen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{list.map((sec, sIdx) => {
|
||
const planMin = sectionPlannedMinutes(sec)
|
||
const itemCount = sec.items?.length ?? 0
|
||
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
|
||
const bandActiveBefore = (bx) =>
|
||
enableSectionDragReorder &&
|
||
dropSectionBand &&
|
||
dropSectionBand.slot === sectionToSlot &&
|
||
dropSectionBand.beforeIdx === bx
|
||
|
||
const pl = sec?.planLoc
|
||
const parallelPhaseOrder =
|
||
enableParallelPhaseControls && pl?.phaseKind === 'parallel' ? pl.phaseOrderIndex ?? 0 : null
|
||
const streamOrdersForParallelPhase =
|
||
parallelPhaseOrder != null ? streamsForParallelPhaseOrders(list, parallelPhaseOrder) : []
|
||
const activeParallelStream =
|
||
parallelPhaseOrder != null
|
||
? parallelStreamTabByPhase[parallelPhaseOrder] ?? streamOrdersForParallelPhase[0] ?? 0
|
||
: null
|
||
const hideParallelSection =
|
||
enableParallelPhaseControls &&
|
||
pl?.phaseKind === 'parallel' &&
|
||
(pl.parallelStreamOrderIndex ?? 0) !== activeParallelStream
|
||
const isFirstSectionOfParallelPhase =
|
||
parallelPhaseOrder != null &&
|
||
firstSectionIndexByParallelPhase.get(parallelPhaseOrder) === sIdx
|
||
const streamVisual =
|
||
enableParallelPhaseControls && pl?.phaseKind === 'parallel'
|
||
? parallelStreamVisual(pl.parallelStreamOrderIndex ?? 0)
|
||
: null
|
||
const allowSectionDragGrip =
|
||
enableSectionDragReorder && !(enableParallelPhaseControls && pl?.phaseKind === 'parallel')
|
||
|
||
return (
|
||
<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}
|
||
{isFirstSectionOfParallelPhase &&
|
||
enableParallelPhaseControls &&
|
||
streamOrdersForParallelPhase.length ? (
|
||
<div
|
||
role="tablist"
|
||
aria-label={`Parallele Streams · Phase ${parallelPhaseOrder}`}
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '6px',
|
||
marginBottom: '10px',
|
||
padding: '8px 10px',
|
||
background: 'var(--surface2)',
|
||
borderRadius: '10px',
|
||
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||
}}
|
||
>
|
||
{streamOrdersForParallelPhase.map((so) => {
|
||
const sel =
|
||
(parallelStreamTabByPhase[parallelPhaseOrder] ??
|
||
streamOrdersForParallelPhase[0] ??
|
||
0) === so
|
||
const pv = parallelStreamVisual(so)
|
||
const lab = streamTabLabelFromIndices(
|
||
list,
|
||
sectionIndicesForParallelStream(list, parallelPhaseOrder, so),
|
||
)
|
||
return (
|
||
<button
|
||
key={`p${parallelPhaseOrder}-tab-s${so}`}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={sel}
|
||
className="btn framework-ctrl framework-ctrl--xs"
|
||
style={{
|
||
margin: 0,
|
||
border: sel ? `2px solid ${pv.border}` : `1px solid ${pv.border}`,
|
||
background: sel ? pv.tabBgActive : pv.tabBg,
|
||
color: 'var(--text1)',
|
||
fontWeight: sel ? 600 : 500,
|
||
}}
|
||
onClick={() =>
|
||
setParallelStreamTabByPhase((prev) => ({ ...prev, [parallelPhaseOrder]: so }))
|
||
}
|
||
>
|
||
{lab}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
) : null}
|
||
{!hideParallelSection ? (
|
||
<div
|
||
className="tu-section-shell"
|
||
style={{
|
||
marginBottom: '1rem',
|
||
padding: '0.75rem',
|
||
background: streamVisual ? streamVisual.soft : 'var(--surface2)',
|
||
borderRadius: '10px',
|
||
border: streamVisual
|
||
? `1px solid ${streamVisual.border}`
|
||
: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||
borderLeft: streamVisual ? `5px solid ${streamVisual.border}` : undefined,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '0.5rem',
|
||
marginBottom: '0.5rem',
|
||
alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
{allowSectionDragGrip ? (
|
||
<span
|
||
className="tu-sec-drag-grip"
|
||
draggable
|
||
onDragStart={(e) => onSectionDragStart(e, sIdx)}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Abschnitt ziehen"
|
||
title="Abschnitt ziehen"
|
||
>
|
||
<GripVertical size={16} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<input
|
||
className="form-input"
|
||
style={{ flex: '2 1 180px', marginBottom: 0 }}
|
||
value={sec.title}
|
||
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
|
||
placeholder="Abschnittstitel (z. B. Aufwärmen)"
|
||
/>
|
||
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
|
||
<button
|
||
type="button"
|
||
aria-label="Abschnitt hoch"
|
||
onClick={() => moveSection(sIdx, -1)}
|
||
disabled={sectionMoveDisabledUp(sIdx)}
|
||
style={{
|
||
padding: '4px 10px',
|
||
opacity: sectionMoveDisabledUp(sIdx) ? 0.35 : 1,
|
||
}}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Abschnitt runter"
|
||
onClick={() => moveSection(sIdx, 1)}
|
||
disabled={sectionMoveDisabledDown(sIdx)}
|
||
style={{
|
||
padding: '4px 10px',
|
||
opacity: sectionMoveDisabledDown(sIdx) ? 0.35 : 1,
|
||
}}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => removeSection(sIdx)}
|
||
>
|
||
Abschnitt entfernen
|
||
</button>
|
||
</div>
|
||
{enableParallelPhaseControls && sec.planLoc ? (
|
||
<p
|
||
style={{
|
||
fontSize: '0.75rem',
|
||
color: 'var(--text2)',
|
||
margin: '0 0 8px',
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{sec.planLoc.phaseKind === 'whole_group'
|
||
? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}`
|
||
: `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`}
|
||
</p>
|
||
) : null}
|
||
<textarea
|
||
className="form-input"
|
||
rows={2}
|
||
value={sec.guidance_notes}
|
||
onChange={(e) =>
|
||
updateSectionField(sIdx, 'guidance_notes', e.target.value)
|
||
}
|
||
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||
/>
|
||
{enableParallelPhaseControls ? (
|
||
<div className="form-row" style={{ marginTop: '10px', marginBottom: '2px' }}>
|
||
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
||
Zuordnung
|
||
</label>
|
||
<select
|
||
className="form-input"
|
||
value={planLocKey(sec.planLoc)}
|
||
onChange={(e) => applySectionPlanTarget(sIdx, e.target.value)}
|
||
>
|
||
<option value="">Standard — eine Ganzgruppe (klassischer Ablauf)</option>
|
||
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => (
|
||
<option key={o.key} value={o.key}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
) : null}
|
||
{planMin > 0 && (
|
||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||
</p>
|
||
)}
|
||
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null}
|
||
|
||
{(sec.items || []).map((it, iIdx) => {
|
||
const dropHere =
|
||
enableItemDragReorder &&
|
||
dropTargetPos?.sIdx === sIdx &&
|
||
dropTargetPos?.iIdx === iIdx
|
||
const dragHere =
|
||
enableItemDragReorder &&
|
||
draggingPos?.sIdx === sIdx &&
|
||
draggingPos?.iIdx === iIdx
|
||
const rowCommon =
|
||
'tu-item-row' +
|
||
(dropHere ? ' tu-item-row--drop-target' : '') +
|
||
(dragHere ? ' tu-item-row--dragging' : '')
|
||
|
||
const dndRowProps = enableItemDragReorder
|
||
? {
|
||
onDragOverCapture: (ev) => onItemDragOverRow(ev, sIdx, iIdx),
|
||
onDrop: (ev) => onItemDropRow(ev, sIdx, iIdx),
|
||
}
|
||
: {}
|
||
|
||
const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
|
||
const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
|
||
const showModuleBand =
|
||
curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
|
||
const modBandTitle =
|
||
(it.source_module_title || '').trim() ||
|
||
(curMn != null ? `Modul #${curMn}` : '')
|
||
|
||
const modOutline =
|
||
!planningCompactLegend &&
|
||
showModuleBand &&
|
||
curMn != null
|
||
? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
|
||
: null
|
||
const fromModClass =
|
||
curMn != null
|
||
? planningCompactLegend
|
||
? ' tu-item-row--from-module-soft'
|
||
: ' tu-item-row--from-module'
|
||
: ''
|
||
const modBorderVarStyle =
|
||
planningCompactLegend && curMn != null
|
||
? { '--tu-mod-border': planningModulePalette(curMn).border }
|
||
: undefined
|
||
|
||
if (it.item_type === 'note') {
|
||
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
|
||
const notePv = truncatePreview(it.note_body || '', 260)
|
||
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
|
||
return (
|
||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||
{!planningCompactLegend &&
|
||
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
|
||
<div
|
||
className={
|
||
`${rowCommon} tu-item-row--note` +
|
||
(isSepLine ? ' tu-item-row--separator-note' : '') +
|
||
fromModClass
|
||
}
|
||
{...dndRowProps}
|
||
style={modBorderVarStyle}
|
||
>
|
||
{enableItemDragReorder ? (
|
||
<span
|
||
className="tu-row-grip"
|
||
draggable
|
||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||
onDragEnd={clearDragChrome}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Eintrag ziehen"
|
||
>
|
||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<div className="tu-item-row__nudge">
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach oben"
|
||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
disabled={iIdx === 0}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach unten"
|
||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
disabled={iIdx === sec.items.length - 1}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="tu-item-row__body tu-item-row__body--note">
|
||
{!isSepLine && planningCompactLegend && curMn ? (
|
||
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
|
||
) : null}
|
||
<span className="tu-item-row__meta-label">
|
||
{isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'}
|
||
</span>
|
||
{isSepLine ? (
|
||
<div
|
||
className="tu-item-row__separator-line"
|
||
role="separator"
|
||
aria-label="Trennlinie im Ablauf"
|
||
/>
|
||
) : (
|
||
<p
|
||
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||
title={noteHasText ? (it.note_body || '').trim() : undefined}
|
||
>
|
||
{noteHasText ? notePv : '—'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
title={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
|
||
aria-label={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
|
||
onClick={() =>
|
||
setTextEdit({
|
||
kind: 'zwischen-note',
|
||
sIdx,
|
||
iIdx,
|
||
draft: it.note_body || '',
|
||
})
|
||
}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="tu-item-row__remove"
|
||
title="Entfernen"
|
||
aria-label={isSepLine ? 'Trennung entfernen' : 'Zwischen-Anmerkung entfernen'}
|
||
onClick={() => removeItem(sIdx, iIdx)}
|
||
>
|
||
✗
|
||
</button>
|
||
</div>
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
|
||
</Fragment>
|
||
)
|
||
}
|
||
|
||
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||
const exTitle =
|
||
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
||
const isCombination =
|
||
String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||
const annotPrev = truncatePreview(it.notes || '', 220)
|
||
const annotHasText = Boolean((it.notes || '').trim())
|
||
const hasVariants = !isCombination && variantOpts.length > 0 && it.exercise_id
|
||
const variantIdPeek =
|
||
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
||
? undefined
|
||
: Number(it.exercise_variant_id)
|
||
|
||
const stripArchRaw =
|
||
isCombination && it.exercise_id ? String(it.catalog_method_archetype || '').trim() : ''
|
||
const stripMpEff =
|
||
isCombination && it.exercise_id
|
||
? effectiveComboMethodProfile(
|
||
it.catalog_method_profile || {},
|
||
it.planning_method_profile,
|
||
)
|
||
: null
|
||
|
||
return (
|
||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||
{!planningCompactLegend &&
|
||
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
|
||
<div
|
||
className={`${rowCommon} tu-item-row--exercise${fromModClass}${
|
||
isCombination && it.exercise_id ? ' tu-item-row--combo' : ''
|
||
}`}
|
||
{...dndRowProps}
|
||
style={modBorderVarStyle}
|
||
>
|
||
<div className="tu-item-row__mainline">
|
||
{enableItemDragReorder ? (
|
||
<span
|
||
className="tu-row-grip"
|
||
draggable
|
||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||
onDragEnd={clearDragChrome}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Eintrag ziehen"
|
||
>
|
||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<div className="tu-item-row__nudge">
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach oben"
|
||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
disabled={iIdx === 0}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach unten"
|
||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
disabled={iIdx === sec.items.length - 1}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="tu-item-row__body tu-item-row__body--exercise">
|
||
<div className="tu-ex-title-line">
|
||
{exTitle ? (
|
||
<strong className="tu-ex-title">{exTitle}</strong>
|
||
) : (
|
||
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
|
||
)}
|
||
{isCombination ? (
|
||
<span
|
||
className="exercise-tag"
|
||
style={{
|
||
marginLeft: 8,
|
||
fontSize: '11px',
|
||
alignSelf: 'center',
|
||
background: 'var(--accent-soft)',
|
||
color: 'var(--accent-dark)',
|
||
}}
|
||
>
|
||
Kombination
|
||
</span>
|
||
) : null}
|
||
{planningCompactLegend && curMn ? (
|
||
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
|
||
) : null}
|
||
<span className="tu-ex-inline-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
onRequestExercisePick?.({
|
||
sectionIndex: sIdx,
|
||
itemIndex: iIdx,
|
||
})
|
||
}
|
||
>
|
||
{exTitle ? 'Wechseln' : 'Übung suchen…'}
|
||
</button>
|
||
{it.exercise_id && onPeekExercise ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
onPeekExercise(
|
||
Number(it.exercise_id),
|
||
variantIdPeek,
|
||
isCombination
|
||
? {
|
||
catalog_method_profile: it.catalog_method_profile,
|
||
planning_method_profile: it.planning_method_profile,
|
||
}
|
||
: undefined,
|
||
)
|
||
}
|
||
>
|
||
Vorschau
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
</div>
|
||
<div className="tu-ex-meta-line">
|
||
{hasVariants ? (
|
||
<select
|
||
className={`form-input tu-ex-variant-select${
|
||
wideExerciseGrid ? ' tu-ex-variant-select--wide' : ''
|
||
}`}
|
||
value={
|
||
it.exercise_variant_id === '' ||
|
||
it.exercise_variant_id == null
|
||
? ''
|
||
: String(it.exercise_variant_id)
|
||
}
|
||
onChange={(e) => {
|
||
const raw = e.target.value
|
||
updateItem(
|
||
sIdx,
|
||
iIdx,
|
||
'exercise_variant_id',
|
||
raw === '' ? '' : parseInt(raw, 10)
|
||
)
|
||
}}
|
||
title="Übungsvariante"
|
||
>
|
||
<option value="">Stammübung</option>
|
||
{variantOpts.map((v) => (
|
||
<option key={v.id} value={v.id}>
|
||
{v.variant_name || `Variante #${v.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
<div className="tu-ex-annot">
|
||
<span
|
||
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||
title={annotHasText ? (it.notes || '').trim() : undefined}
|
||
>
|
||
{annotHasText ? annotPrev : '—'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
title="Anmerkung zur Übung"
|
||
aria-label="Anmerkung zur Übung bearbeiten"
|
||
onClick={() =>
|
||
setTextEdit({
|
||
kind: 'exercise-notes',
|
||
sIdx,
|
||
iIdx,
|
||
draft: it.notes || '',
|
||
})
|
||
}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="tu-item-row__side">
|
||
<input
|
||
type="number"
|
||
className="form-input tu-ex-duration"
|
||
min={1}
|
||
value={it.planned_duration_min}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
||
}
|
||
placeholder="Min"
|
||
title="Geplante Dauer (Minuten)"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="tu-item-row__remove"
|
||
title="Übung entfernen"
|
||
aria-label="Übung entfernen"
|
||
onClick={() => removeItem(sIdx, iIdx)}
|
||
>
|
||
✗
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{isCombination && it.exercise_id ? (
|
||
<div className="tu-combo-planning-strip">
|
||
<div className="tu-combo-planning-strip__toolbar">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
aria-haspopup="dialog"
|
||
aria-label="Ablaufprofil Kombination für diese Planung bearbeiten"
|
||
onClick={() => setComboPlanningModal({ sIdx, iIdx })}
|
||
>
|
||
Ablauf bearbeiten…
|
||
</button>
|
||
</div>
|
||
{(it.combination_slots || []).length > 0 ? (
|
||
<div className="tu-combo-planning-strip__bracket-wrap">
|
||
<CombinationPlanBracket
|
||
className="combo-plan-bracket--planning-embed"
|
||
methodArchetype={stripArchRaw}
|
||
methodProfile={stripMpEff || {}}
|
||
combinationSlots={sortCombinationSlotsForDisplay(it.combination_slots)}
|
||
planningAdjusted={
|
||
it.planning_method_profile != null &&
|
||
typeof it.planning_method_profile === 'object' &&
|
||
!Array.isArray(it.planning_method_profile)
|
||
}
|
||
candidateInteraction={onPeekExercise ? 'button' : 'none'}
|
||
onCandidatePeek={
|
||
onPeekExercise
|
||
? (exId) => onPeekExercise(Number(exId), null, undefined)
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="tu-combo-planning-strip__meta tu-combo-planning-strip__meta--fallback"
|
||
title="Stationen aus dem Katalog — nach ersten Laden oder wenn die Kombination noch keine Slots hat."
|
||
>
|
||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', fontStyle: 'italic', margin: 0 }}>
|
||
Stationen werden geladen oder die Kombination hat im Katalog noch keine Stationsliste …
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{showExecutionExtras ? (
|
||
<div className="tu-ex-debrief">
|
||
<div className="tu-ex-debrief__grow">
|
||
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span>
|
||
<textarea
|
||
className="form-input tu-ex-debrief__textarea"
|
||
rows={3}
|
||
value={it.modifications || ''}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||
}
|
||
placeholder="Was lief anders? Anpassungen für spätere Planung…"
|
||
/>
|
||
</div>
|
||
<div className="tu-ex-debrief__ist">
|
||
<span className="tu-item-row__meta-label">Ist (Min)</span>
|
||
<input
|
||
type="number"
|
||
className="form-input tu-ex-duration"
|
||
min={1}
|
||
value={it.actual_duration_min}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||
}
|
||
placeholder="IST"
|
||
title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
{enableItemDragReorder ? (
|
||
<div
|
||
className={`tu-item-append-drop${
|
||
dropTargetPos?.sIdx === sIdx && dropTargetPos?.iIdx === itemCount
|
||
? ' tu-item-append-drop--active'
|
||
: ''
|
||
}`}
|
||
title="Hierhin ziehen, um nach unten einzufügen"
|
||
onDragOverCapture={(e) => onItemDragOverRow(e, sIdx, itemCount)}
|
||
onDrop={(e) => onItemDropRow(e, sIdx, itemCount)}
|
||
/>
|
||
) : null}
|
||
|
||
<div style={{ marginTop: '0.65rem' }}>
|
||
{betweenInsertMenus ? (
|
||
<p style={{ margin: 0, fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45, maxWidth: '42rem' }}>
|
||
Über die +-Zeilen zwischen den Einträgen fügst du an der gewünschten Stelle Inhalte ein. Reihenfolge
|
||
weiter per Ziehen oder den Pfeiltasten ändern.
|
||
</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
||
>
|
||
+ Übung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => addItem(sIdx, 'note')}
|
||
>
|
||
+ Anmerkung
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{moduleLegend.length ? (
|
||
<div
|
||
className="tu-section-mod-legend"
|
||
aria-label="Liste der eingebundenen Trainingsmodule in diesem Abschnitt"
|
||
>
|
||
<div className="tu-section-mod-legend__caption">Übernommene Module im Abschnitt</div>
|
||
<ul className="tu-section-mod-legend__list">
|
||
{moduleLegend.map((e) => {
|
||
const pal = planningModulePalette(e.id)
|
||
return (
|
||
<li key={`mod-leg-${sIdx}-${e.id}`} className="tu-section-mod-legend__item">
|
||
<span
|
||
className="tu-section-mod-legend__swatch"
|
||
style={{ background: pal.border }}
|
||
title={`Farbe wie an den Zeilen (Modul #${e.id})`}
|
||
aria-hidden
|
||
/>
|
||
<span className="tu-section-mod-legend__text">
|
||
<span className="tu-section-mod-legend__title">
|
||
{(e.title || '').trim() || `Modul #${e.id}`}
|
||
</span>
|
||
<span className="tu-section-mod-legend__meta">
|
||
ID {e.id} · {e.exercises}{' '}
|
||
{e.exercises === 1 ? 'Übung' : 'Übungen'}
|
||
{e.notes > 0 ? (
|
||
<>
|
||
{' '}
|
||
· {e.notes} {e.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
|
||
</>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
{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}
|
||
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={addSection}
|
||
>
|
||
+ Abschnitt hinzufügen
|
||
</button>
|
||
|
||
{insertChooser ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) closeInsertChooser()
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-insert-chooser-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<h4 id="tu-insert-chooser-title" className="tu-textedit-title">
|
||
An dieser Stelle einfügen
|
||
</h4>
|
||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.86rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||
Die neue Zeile erscheint genau hier; Reihenfolge kannst du wie gewohnt per Ziehen oder Pfeilen
|
||
ändern.
|
||
</p>
|
||
<div className="tu-insert-chooser-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
closeInsertChooser()
|
||
onRequestExercisePick?.({
|
||
sectionIndex: sIdx,
|
||
insertBeforeIndex: beforeIx,
|
||
})
|
||
}}
|
||
>
|
||
Übung auswählen …
|
||
</button>
|
||
{onRequestTrainingModulePick ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const ctx = { ...insertChooser }
|
||
closeInsertChooser()
|
||
onRequestTrainingModulePick({
|
||
sectionIndex: ctx.sIdx,
|
||
insertBeforeIndex: ctx.beforeIx,
|
||
})
|
||
}}
|
||
>
|
||
Trainingsmodul …
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
insertItemAt(sIdx, beforeIx, noteRow())
|
||
closeInsertChooser()
|
||
}}
|
||
>
|
||
Zwischen-Anmerkung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
const r = noteRow()
|
||
r.note_body = SECTION_INSERT_SEPARATOR_BODY
|
||
insertItemAt(sIdx, beforeIx, r)
|
||
closeInsertChooser()
|
||
}}
|
||
>
|
||
Trennlinie
|
||
</button>
|
||
<button type="button" className="btn btn-secondary tu-insert-chooser-actions__full" onClick={closeInsertChooser}>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{comboPlanningModalItem != null &&
|
||
comboPlanningModalSX != null &&
|
||
comboPlanningModalIX != null ? (
|
||
<div
|
||
className="admin-modal-backdrop combo-planning-edit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) setComboPlanningModal(null)
|
||
}}
|
||
>
|
||
<div
|
||
className="admin-modal-sheet combo-planning-edit-sheet"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-combo-planning-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="admin-modal-sheet__header">
|
||
<div style={{ minWidth: 0 }}>
|
||
<h3 id="tu-combo-planning-title" className="admin-modal-sheet__title">
|
||
{(comboPlanningModalItem.exercise_title || '').trim() ||
|
||
`Kombination #${comboPlanningModalItem.exercise_id}`}
|
||
</h3>
|
||
<p style={{ margin: '6px 0 0', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.4 }}>
|
||
Planung für diesen Termin · {compactComboPlanningCaption(comboPlanningModalItem)}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary admin-modal-sheet__close"
|
||
onClick={() => setComboPlanningModal(null)}
|
||
>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
<div className="admin-modal-sheet__body">
|
||
<div className="combo-planning-edit-toolbar">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', null)
|
||
}
|
||
>
|
||
Planung wie Katalog
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
title="Bearbeitbare Kopie der Katalog-Vorgaben für diese Einheit setzen"
|
||
onClick={() =>
|
||
updateItem(comboPlanningModalSX, comboPlanningModalIX, 'planning_method_profile', {
|
||
...(comboPlanningModalItem.catalog_method_profile || {}),
|
||
})
|
||
}
|
||
>
|
||
Aus Katalog kopieren …
|
||
</button>
|
||
</div>
|
||
<p className="combo-planning-edit-hint">
|
||
Vorschau unten entspricht der effektiven Planung (Katalog oder Anpassung). Stationen und Einzelübungen
|
||
kommen aus dem Katalog; hier änderst du nur Zeiten, Runden und Steuerung für diese Einheit.
|
||
</p>
|
||
{comboPlanningResolvedSlots.length > 0 ? (
|
||
<div style={{ marginBottom: 18 }}>
|
||
<CombinationPlanBracket
|
||
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
||
methodProfile={comboPlanningEffectiveProfile}
|
||
combinationSlots={comboPlanningResolvedSlots}
|
||
planningAdjusted={
|
||
comboPlanningModalItem.planning_method_profile != null &&
|
||
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
|
||
!Array.isArray(comboPlanningModalItem.planning_method_profile)
|
||
}
|
||
candidateInteraction={onPeekExercise ? 'button' : 'none'}
|
||
onCandidatePeek={
|
||
onPeekExercise
|
||
? (exId) => onPeekExercise(Number(exId), null, undefined)
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<p style={{ margin: '0 0 18px', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||
Stationen werden geladen … oder die Kombination hat im Katalog keine Stationsliste.
|
||
</p>
|
||
)}
|
||
<div className="combo-planning-edit-card">
|
||
<h4 className="combo-planning-edit-card__title">Globale und stationsbezogene Anpassungen</h4>
|
||
<CombinationMethodProfileEditor
|
||
plannerMode
|
||
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
||
methodProfileJson={comboPlanningProfileJsonForEditor(
|
||
comboPlanningModalItem.catalog_method_profile || {},
|
||
comboPlanningModalItem.planning_method_profile
|
||
)}
|
||
onChangeMethodProfileJson={(json) => {
|
||
try {
|
||
const obj = JSON.parse(json || '{}')
|
||
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
||
const cleaned = cloneJsonSerializablePlanningProfile(obj) ?? {}
|
||
updateItem(
|
||
comboPlanningModalSX,
|
||
comboPlanningModalIX,
|
||
'planning_method_profile',
|
||
Object.keys(cleaned).length ? cleaned : null,
|
||
)
|
||
}
|
||
} catch {
|
||
/* Ungültiges JSON — Hinweis im Editor */
|
||
}
|
||
}}
|
||
comboSlotsOutline={comboPlanningSlotsOutline}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{textEdit ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) setTextEdit(null)
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-textedit-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<h4 id="tu-textedit-title" className="tu-textedit-title">
|
||
{textEdit.kind === 'zwischen-note'
|
||
? 'Zwischen-Anmerkung'
|
||
: 'Anmerkung zur Übung'}
|
||
</h4>
|
||
<textarea
|
||
className="form-input tu-textedit-textarea"
|
||
rows={5}
|
||
value={textEdit.draft}
|
||
onChange={(e) =>
|
||
setTextEdit((prev) => (prev ? { ...prev, draft: e.target.value } : prev))
|
||
}
|
||
placeholder={
|
||
textEdit.kind === 'zwischen-note'
|
||
? 'Hinweise zwischen Übungen …'
|
||
: 'Kurze Anmerkung zur Übung'
|
||
}
|
||
/>
|
||
<div className="tu-textedit-actions">
|
||
<button type="button" className="btn btn-primary" onClick={applyTextEdit}>
|
||
Übernehmen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setTextEdit(null)}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|