shinkan-jinkendo/frontend/src/components/TrainingUnitSectionsEditor.jsx
Lars 3dc4c9c79e
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 56s
feat(exercises): update to version 0.8.100 and enhance combination exercise handling
- Bumped app version to 0.8.100, reflecting recent updates.
- Improved validation logic for combination exercises in the backend, ensuring proper handling of exercise variants.
- Enhanced frontend components, including the ExercisePickerModal, to support filtering and displaying combination exercises.
- Updated API payloads and utility functions to accommodate new exercise types and their properties.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 06:30:53 +02:00

1307 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { Fragment, useCallback, useEffect, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import {
defaultSection,
exerciseRow,
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
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
/** 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,
}) {
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) => [...prev, defaultSection(`Abschnitt ${prev.length + 1}`)])
}
const removeSection = (sIdx) => {
patch((prev) => {
const next = prev.filter((_, i) => i !== sIdx)
return next.length ? next : [defaultSection()]
})
}
const moveSection = (sIdx, dir) => {
patch((prev) => {
const p = [...prev]
const ta = sIdx + dir
if (ta < 0 || ta >= p.length) return p
;[p[sIdx], p[ta]] = [p[ta], p[sIdx]]
return p
})
}
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)
/** { 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)
/** { 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])
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)
return (
<div
className={
'training-unit-sections-editor' +
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '')
}
>
{(!hideHeading || headingAccessory) ? (
<div
className="tu-editor-heading-toolbar"
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: '10px',
marginBottom: '0.75rem',
}}
>
{!hideHeading ? (
<h3 style={{ margin: 0, fontSize: '1rem', flex: '1 1 200px', minWidth: 0 }}>
{heading}
</h3>
) : headingAccessory ? (
<span style={{ flex: '1 1 auto', minWidth: 0 }} />
) : null}
{headingAccessory ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'flex-end',
alignItems: 'center',
}}
>
{headingAccessory}
</div>
) : null}
</div>
) : null}
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx
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}
<div
className="tu-section-shell"
style={{
marginBottom: '1rem',
padding: '0.75rem',
background: 'var(--surface2)',
borderRadius: '10px',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '0.5rem',
alignItems: 'flex-start',
}}
>
{enableSectionDragReorder ? (
<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={sIdx === 0}
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
>
▲
</button>
<button
type="button"
aria-label="Abschnitt runter"
onClick={() => moveSection(sIdx, 1)}
disabled={sIdx === list.length - 1}
style={{
padding: '4px 10px',
opacity: sIdx === list.length - 1 ? 0.35 : 1,
}}
>
▼
</button>
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeSection(sIdx)}
>
Abschnitt entfernen
</button>
</div>
<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 …)"
/>
{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)
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{!planningCompactLegend &&
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={`${rowCommon} tu-item-row--exercise${fromModClass}`}
{...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)
}
>
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>
{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>
</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}
{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>
)
}