- Simplified the exercise selection logic in TrainingUnitSectionsEditor, TrainingFrameworkProgramEditPage, and TrainingPlanningPage by removing the multi-select option. - Updated the ExercisePickerModal to always enable multi-select, enhancing user experience when selecting exercises. - Refactored the handling of selected exercises to improve clarity and maintainability of the code.
911 lines
32 KiB
JavaScript
911 lines
32 KiB
JavaScript
import React, { Fragment, useCallback, useEffect, useState } from 'react'
|
|
import { GripVertical, Pencil } from 'lucide-react'
|
|
import {
|
|
defaultSection,
|
|
exerciseRow,
|
|
noteRow,
|
|
sectionPlannedMinutes,
|
|
} from '../utils/trainingUnitSectionsForm'
|
|
|
|
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
|
|
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
|
|
|
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)}…`
|
|
}
|
|
|
|
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,
|
|
onPeekExercise,
|
|
showExecutionExtras = false,
|
|
heading = 'Abschnitte & Übungen',
|
|
hideHeading = false,
|
|
headingAccessory = null,
|
|
wideExerciseGrid = false,
|
|
enableItemDragReorder = true,
|
|
enableSectionDragReorder = true,
|
|
slotIndex = null,
|
|
onMoveSectionsAcrossSlots = null,
|
|
}) {
|
|
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 addItem = (sIdx, kind) => {
|
|
patch((prev) =>
|
|
prev.map((s, i) =>
|
|
i !== sIdx
|
|
? s
|
|
: {
|
|
...s,
|
|
items: [...(s.items || []), kind === 'note' ? noteRow() : exerciseRow()],
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
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)
|
|
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])
|
|
|
|
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 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 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>
|
|
)}
|
|
|
|
{(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),
|
|
}
|
|
: {}
|
|
|
|
if (it.item_type === 'note') {
|
|
const notePv = truncatePreview(it.note_body || '', 260)
|
|
const noteHasText = Boolean((it.note_body || '').trim())
|
|
return (
|
|
<div
|
|
key={`note-${sIdx}-${iIdx}`}
|
|
className={`${rowCommon} tu-item-row--note`}
|
|
{...dndRowProps}
|
|
>
|
|
{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">
|
|
<span className="tu-item-row__meta-label">Zwischen-Anmerkung</span>
|
|
<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="Zwischen-Anmerkung bearbeiten"
|
|
aria-label="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="Zwischen-Anmerkung entfernen"
|
|
onClick={() => removeItem(sIdx, iIdx)}
|
|
>
|
|
✗
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
|
const exTitle =
|
|
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
|
const annotPrev = truncatePreview(it.notes || '', 220)
|
|
const annotHasText = Boolean((it.notes || '').trim())
|
|
const hasVariants = variantOpts.length > 0 && it.exercise_id
|
|
const variantIdPeek =
|
|
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
|
? undefined
|
|
: Number(it.exercise_variant_id)
|
|
|
|
return (
|
|
<div
|
|
key={`ex-${sIdx}-${iIdx}`}
|
|
className={`${rowCommon} tu-item-row--exercise`}
|
|
{...dndRowProps}
|
|
>
|
|
<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>
|
|
)}
|
|
<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 ? (
|
|
<label className="tu-ex-run-block form-label">
|
|
Ist-Dauer / Anpassungen
|
|
<span className="tu-ex-run-block__controls">
|
|
<input
|
|
type="number"
|
|
className="form-input"
|
|
min={1}
|
|
value={it.actual_duration_min}
|
|
onChange={(e) =>
|
|
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
|
}
|
|
placeholder="IST min"
|
|
/>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={it.modifications || ''}
|
|
onChange={(e) =>
|
|
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
|
}
|
|
placeholder="Abweichungen beim Durchführen"
|
|
/>
|
|
</span>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{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', 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>
|
|
</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>
|
|
|
|
{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>
|
|
)
|
|
}
|