feat: enhance exercise selection and training unit management
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m8s

- Updated ExercisePeekModal to support exercise variants, improving user experience when selecting exercises.
- Enhanced ExercisePickerModal with multi-select functionality, allowing users to select multiple exercises at once.
- Introduced drag-and-drop support for reordering training unit sections in TrainingUnitSectionsEditor, enhancing organization and usability.
- Improved TrainingFrameworkProgramEditPage and TrainingPlanningPage to manage exercise selection and section movement across slots more effectively.
- Added new CSS styles in app.css for better visual feedback during drag-and-drop actions and improved layout consistency.
This commit is contained in:
Lars 2026-05-05 14:42:46 +02:00
parent 354216cb4f
commit 2bfe67879f
6 changed files with 958 additions and 411 deletions

View File

@ -3462,6 +3462,57 @@ a.analysis-split__nav-item {
justify-content: flex-end;
}
.tu-section-shell {
contain: layout;
}
.tu-section-dropband {
height: 10px;
margin: 0 2px 4px;
border-radius: 6px;
flex-shrink: 0;
box-sizing: border-box;
}
.tu-section-dropband--end {
margin-top: 2px;
margin-bottom: 10px;
}
.tu-section-dropband--active {
background: color-mix(in srgb, var(--accent) 26%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
}
.tu-sec-drag-grip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 4px;
cursor: grab;
color: var(--text3);
user-select: none;
touch-action: none;
border-radius: 6px;
}
.tu-sec-drag-grip:active {
cursor: grabbing;
}
.tu-item-append-drop {
min-height: 16px;
margin: 2px -2px 6px;
border-radius: 6px;
box-sizing: border-box;
}
.tu-item-append-drop--active {
outline: 2px dashed var(--accent);
outline-offset: 1px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.framework-slot-card__head {
display: flex;
flex-direction: row;

View File

@ -31,11 +31,22 @@ function TagMini({ exercise }) {
)
}
export default function ExercisePeekModal({ open, exerciseId, onClose, titleFallback }) {
export default function ExercisePeekModal({
open,
exerciseId,
variantId,
onClose,
titleFallback,
}) {
const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null)
const [exercise, setExercise] = useState(null)
const variant =
variantId != null && variantId !== '' && exercise?.variants?.length
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
: null
useEffect(() => {
if (!open) {
setExercise(null)
@ -62,7 +73,7 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall
return () => {
cancelled = true
}
}, [open, exerciseId])
}, [open, exerciseId, variantId])
if (!open) return null
@ -100,6 +111,37 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall
{!loading && err && <p style={{ color: 'var(--danger)' }}>{err}</p>}
{!loading && exercise && (
<>
{variant ? (
<div
style={{
marginBottom: '0.75rem',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text3)', marginBottom: 4 }}>
Variante
</div>
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>
{variant.variant_name || `Variante #${variant.id}`}
</div>
{variant.description ? (
<div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text2)' }}>
<HtmlBlock html={variant.description} />
</div>
) : null}
{variant.execution_changes ? (
<div style={{ marginTop: 10 }}>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>
Durchführung (Variante)
</h4>
<HtmlBlock html={variant.execution_changes} />
</div>
) : null}
</div>
) : null}
{exercise.summary && (
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
<HtmlBlock html={exercise.summary} />

View File

@ -22,7 +22,13 @@ const INITIAL_FILTERS = {
status_any: [],
}
export default function ExercisePickerModal({ open, onClose, onSelectExercise }) {
export default function ExercisePickerModal({
open,
onClose,
onSelectExercise,
multiSelect = false,
onSelectExercises = null,
}) {
const [catalogs, setCatalogs] = useState({
focusAreas: [],
styleDirections: [],
@ -42,6 +48,13 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([])
const toggleMultiPick = (ex) => {
setMultiPicked((prev) =>
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
)
}
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
@ -96,6 +109,7 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
setList([])
setOffset(0)
setHasMore(false)
setMultiPicked([])
}
}, [open])
@ -230,7 +244,9 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 className="admin-modal-sheet__title">Übung auswählen</h3>
<h3 className="admin-modal-sheet__title">
{multiSelect ? 'Übungen auswählen' : 'Übung auswählen'}
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
@ -391,29 +407,16 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{list.map((ex) => (
<li key={ex.id}>
<button
type="button"
onClick={() => {
onSelectExercise(ex)
onClose()
}}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
{list.map((ex) => {
const picked = multiPicked.some((p) => p.id === ex.id)
const rowInner = (
<>
<strong style={{ display: 'block' }}>{ex.title}</strong>
{(ex.summary || '').trim().length > 0 && (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{(ex.summary || '').length > 120 ? `${(ex.summary || '').slice(0, 120)}` : ex.summary}
{(ex.summary || '').length > 120
? `${(ex.summary || '').slice(0, 120)}`
: ex.summary}
</span>
)}
{ex.focus_area && (
@ -421,9 +424,64 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
{ex.focus_area}
</span>
)}
</button>
</li>
))}
</>
)
if (multiSelect) {
return (
<li key={ex.id}>
<label
className="tu-ex-picker-multi-row"
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
boxSizing: 'border-box',
}}
>
<input
type="checkbox"
checked={picked}
onChange={() => toggleMultiPick(ex)}
style={{ marginTop: '0.35rem', flexShrink: 0 }}
aria-label={ex.title ? `Auswahl: ${ex.title}` : 'Auswahl'}
/>
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
</label>
</li>
)
}
return (
<li key={ex.id}>
<button
type="button"
onClick={() => {
onSelectExercise(ex)
onClose()
}}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
{rowInner}
</button>
</li>
)
})}
</ul>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
@ -432,6 +490,49 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
</button>
</div>
)}
{multiSelect && typeof onSelectExercises === 'function' ? (
<div
className="exercise-picker-multi-footer"
style={{
position: 'sticky',
bottom: 0,
marginTop: 16,
paddingTop: 12,
borderTop: '1px solid var(--border)',
background: 'var(--surface)',
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span style={{ fontSize: '0.92rem', color: 'var(--text2)' }}>
{multiPicked.length} ausgewählt
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<button
type="button"
className="btn btn-secondary"
onClick={() => setMultiPicked([])}
disabled={!multiPicked.length}
>
Auswahl leeren
</button>
<button
type="button"
className="btn btn-primary"
disabled={!multiPicked.length}
onClick={() => {
onSelectExercises([...multiPicked])
onClose()
}}
>
Übernehmen
</button>
</div>
</div>
) : null}
</>
)}
</div>

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { Fragment, useCallback, useEffect, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import {
defaultSection,
@ -8,6 +8,14 @@ import {
} 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()
@ -15,8 +23,20 @@ function truncatePreview(text, max = 160) {
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,
@ -28,6 +48,9 @@ export default function TrainingUnitSectionsEditor({
hideHeading = false,
wideExerciseGrid = false,
enableItemDragReorder = true,
enableSectionDragReorder = true,
slotIndex = null,
onMoveSectionsAcrossSlots = null,
}) {
const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()]
@ -39,6 +62,9 @@ export default function TrainingUnitSectionsEditor({
[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))
@ -119,6 +145,9 @@ export default function TrainingUnitSectionsEditor({
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) => {
@ -128,6 +157,78 @@ export default function TrainingUnitSectionsEditor({
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()
@ -150,6 +251,7 @@ export default function TrainingUnitSectionsEditor({
const onItemDragOverRow = (e, sIdx, iIdx) => {
if (!enableItemDragReorder) return
if (!dtHasType(e, DND_TU_ITEM)) return
e.preventDefault()
e.stopPropagation()
try {
@ -256,380 +358,474 @@ export default function TrainingUnitSectionsEditor({
) : 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 (
<div
key={`sec-${sIdx}`}
style={{
marginBottom: '1rem',
padding: '0.75rem',
background: 'var(--surface2)',
borderRadius: '10px',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
}}
>
<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={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '0.5rem',
marginBottom: '1rem',
padding: '0.75rem',
background: 'var(--surface2)',
borderRadius: '10px',
border: '1px solid var(--border, rgba(0,0,0,0.08))',
}}
>
<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' }}>
<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"
aria-label="Abschnitt hoch"
onClick={() => moveSection(sIdx, -1)}
disabled={sIdx === 0}
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeSection(sIdx)}
>
</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,
}}
>
Abschnitt entfernen
</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>
)}
<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' : '')
{(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
? {
onDragOver: (e) => onItemDragOverRow(e, sIdx, iIdx),
onDrop: (e) => onItemDropRow(e, sIdx, iIdx),
}
: {}
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 || '',
})
}
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}
>
<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())
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))}
>
Vorschau
</button>
) : null}
</span>
</div>
<div className="tu-ex-meta-line">
<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)
)
}}
disabled={!it.exercise_id || variantOpts.length === 0}
{enableItemDragReorder ? (
<span
className="tu-row-grip"
draggable
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
onDragEnd={clearDragChrome}
role="button"
tabIndex={0}
aria-label="Eintrag ziehen"
>
<option value="">
{variantOpts.length === 0 ? 'Keine Varianten' : 'Stammübung'}
</option>
{variantOpts.map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
<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>
<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>
<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)
<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 || '',
})
}
placeholder="Min"
title="Geplante Dauer (Minuten)"
/>
>
<Pencil size={15} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className="tu-item-row__remove"
title="Übung entfernen"
aria-label="Übung entfernen"
title="Entfernen"
aria-label="Zwischen-Anmerkung 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">
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"
className="form-input tu-ex-duration"
min={1}
value={it.actual_duration_min}
value={it.planned_duration_min}
onChange={(e) =>
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
}
placeholder="IST min"
placeholder="Min"
title="Geplante Dauer (Minuten)"
/>
<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>
)
})}
<button
type="button"
className="tu-item-row__remove"
title="Übung entfernen"
aria-label="Übung entfernen"
onClick={() => removeItem(sIdx, iIdx)}
>
</button>
</div>
</div>
<div style={{ marginTop: '0.65rem', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addItem(sIdx, 'exercise')}
>
+ Übung
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addItem(sIdx, 'note')}
>
+ Anmerkung
</button>
{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={() => addItem(sIdx, 'exercise')}
>
+ Übung
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
onRequestExercisePick?.({ sectionIndex: sIdx, multi: true })
}
>
+ mehrere Übungen
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => addItem(sIdx, 'note')}
>
+ Anmerkung
</button>
</div>
</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"

View File

@ -6,6 +6,7 @@ import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import {
defaultSection,
exerciseRow,
normalizeUnitToForm,
enrichSectionsWithVariants,
buildSectionsPayload,
@ -51,7 +52,27 @@ async function enrichFrameworkSlotSections(slots) {
return out
}
/** Native-Tooltip für Ziel-Chips (Hover); kurz halten für OS-Tooltip-Limits */
async function hydrateExerciseForSlotRow(exercise) {
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
let title = exercise?.title || ''
const id = exercise?.id
if (!id) return null
if (!variants.length) {
try {
const full = await api.getExercise(id)
variants = Array.isArray(full?.variants) ? full.variants : []
title = full?.title || title
} catch {
variants = []
}
}
const row = exerciseRow()
row.exercise_id = id
row.exercise_variant_id = ''
row.exercise_title = title
row.variants = variants
return row
}
function goalHoverText(g) {
const t = (g.title || '').trim() || 'Ohne Titel'
const n = (g.notes || '').trim()
@ -182,7 +203,7 @@ export default function TrainingFrameworkProgramEditPage() {
const [trainingTypesCatalog, setTrainingTypesCatalog] = useState([])
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
const [peekId, setPeekId] = useState(null)
const [peekCtx, setPeekCtx] = useState(null)
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
const [goalMenuGi, setGoalMenuGi] = useState(null)
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
@ -470,6 +491,52 @@ export default function TrainingFrameworkProgramEditPage() {
})
}
const moveSectionsAcrossFrameworkSlots = useCallback(
({ fromSlot, fromSectionIdx, toSlot, toSectionIdx }) => {
setForm((prev) => {
const slots = prev.slots.map((sl) => ({
...sl,
sections: [...((sl.sections && sl.sections.length) ? sl.sections : [defaultSection('Ablauf')])],
}))
if (
typeof fromSlot !== 'number' ||
typeof toSlot !== 'number' ||
fromSlot < 0 ||
toSlot < 0 ||
fromSlot >= slots.length ||
toSlot >= slots.length
) {
return prev
}
const fromSecs = slots[fromSlot].sections
if (
typeof fromSectionIdx !== 'number' ||
fromSectionIdx < 0 ||
fromSectionIdx >= fromSecs.length
) {
return prev
}
const [block] = fromSecs.splice(fromSectionIdx, 1)
if (fromSlot === toSlot) {
let insertAt = toSectionIdx
if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1
insertAt = Math.max(0, Math.min(insertAt, fromSecs.length))
fromSecs.splice(insertAt, 0, block)
return { ...prev, slots }
}
const toSecs = slots[toSlot].sections
const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length))
toSecs.splice(ia, 0, block)
return { ...prev, slots }
})
},
[]
)
const slotChipButtons = (opts) =>
form.slots.map((slot, si) => {
const isActive = si === mobileSlotIdx
@ -564,6 +631,8 @@ export default function TrainingFrameworkProgramEditPage() {
sections={slot.sections}
showExecutionExtras={false}
wideExerciseGrid
slotIndex={si}
onMoveSectionsAcrossSlots={moveSectionsAcrossFrameworkSlots}
onSectionsChange={(updater) => {
setForm((prev) => ({
...prev,
@ -579,14 +648,17 @@ export default function TrainingFrameworkProgramEditPage() {
),
}))
}}
onRequestExercisePick={({ sectionIndex, itemIndex }) =>
onRequestExercisePick={({ sectionIndex, itemIndex, multi }) =>
setSectionPickerCtx({
slotIdx: si,
sectionIndex,
itemIndex,
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
multi: !!multi,
})
}
onPeekExercise={(id) => setPeekId(id)}
onPeekExercise={(id, variantId) =>
setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
}
/>
</div>
</div>
@ -1057,21 +1129,45 @@ export default function TrainingFrameworkProgramEditPage() {
<ExercisePickerModal
open={sectionPickerCtx != null}
multiSelect={!!sectionPickerCtx?.multi}
onClose={() => setSectionPickerCtx(null)}
onSelectExercises={
sectionPickerCtx?.multi
? async (picked) => {
if (!sectionPickerCtx || !picked?.length) return
const { slotIdx, sectionIndex: sIdx } = sectionPickerCtx
const rows = []
for (const ex of picked) {
const row = await hydrateExerciseForSlotRow(ex)
if (row) rows.push(row)
}
if (!rows.length) return
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) =>
ii !== slotIdx
? sl
: {
...sl,
sections: (
sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]
).map((sec, si) =>
si !== sIdx ? sec : { ...sec, items: [...(sec.items || []), ...rows] }
),
}
),
}))
setSectionPickerCtx(null)
}
: undefined
}
onSelectExercise={async (exercise) => {
if (!sectionPickerCtx) return
if (sectionPickerCtx.multi) return
if (typeof sectionPickerCtx.itemIndex !== 'number') return
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx
let variants = Array.isArray(exercise.variants) ? exercise.variants : []
let title = exercise.title || ''
if (!variants.length) {
try {
const full = await api.getExercise(exercise.id)
variants = Array.isArray(full.variants) ? full.variants : []
title = full.title || title
} catch {
variants = []
}
}
const row = await hydrateExerciseForSlotRow(exercise)
if (!row) return
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) =>
@ -1085,17 +1181,17 @@ export default function TrainingFrameworkProgramEditPage() {
? sec
: {
...sec,
items: (sec.items || []).map((row, ji) =>
items: (sec.items || []).map((r2, ji) =>
ji !== iIdx
? row
: row.item_type !== 'exercise'
? row
? r2
: r2.item_type !== 'exercise'
? r2
: {
...row,
exercise_id: exercise.id,
exercise_variant_id: '',
exercise_title: title,
variants,
...r2,
exercise_id: row.exercise_id,
exercise_variant_id: row.exercise_variant_id,
exercise_title: row.exercise_title,
variants: row.variants,
}
),
}
@ -1107,7 +1203,12 @@ export default function TrainingFrameworkProgramEditPage() {
}}
/>
<ExercisePeekModal open={peekId != null} exerciseId={peekId || 0} onClose={() => setPeekId(null)} />
<ExercisePeekModal
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId || 0}
variantId={peekCtx?.variantId ?? undefined}
onClose={() => setPeekCtx(null)}
/>
</div>
)
}

View File

@ -7,11 +7,34 @@ import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import {
defaultSection,
exerciseRow,
normalizeUnitToForm,
enrichSectionsWithVariants,
buildSectionsPayload,
} from '../utils/trainingUnitSectionsForm'
async function hydrateExerciseForPickerRow(exercise) {
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
let title = exercise?.title || ''
const id = exercise?.id
if (!id) return null
if (!variants.length) {
try {
const full = await api.getExercise(id)
variants = Array.isArray(full?.variants) ? full.variants : []
title = full?.title || title
} catch {
variants = []
}
}
const row = exerciseRow()
row.exercise_id = id
row.exercise_variant_id = ''
row.exercise_title = title
row.variants = variants
return row
}
function TrainingPlanningPage() {
const { user } = useAuth()
const [groups, setGroups] = useState([])
@ -25,7 +48,7 @@ function TrainingPlanningPage() {
const [quickTemplateId, setQuickTemplateId] = useState('')
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekExerciseId, setPlanningPeekExerciseId] = useState(null)
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
const today = new Date().toISOString().split('T')[0]
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
@ -718,11 +741,17 @@ function TrainingPlanningPage() {
sections: updater(prev.sections),
}))
}
onRequestExercisePick={({ sectionIndex, itemIndex }) => {
setExercisePickerTarget({ sIdx: sectionIndex, iIdx: itemIndex })
onRequestExercisePick={({ sectionIndex, itemIndex, multi }) => {
setExercisePickerTarget({
sIdx: sectionIndex,
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
multi: !!multi,
})
setExercisePickerOpen(true)
}}
onPeekExercise={(id) => setPlanningPeekExerciseId(id)}
onPeekExercise={(id, variantId) =>
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null })
}
showExecutionExtras={!!editingUnit}
/>
@ -826,13 +855,39 @@ function TrainingPlanningPage() {
)}
<ExercisePickerModal
open={exercisePickerOpen}
multiSelect={!!exercisePickerTarget?.multi}
onClose={() => {
setExercisePickerOpen(false)
setExercisePickerTarget(null)
}}
onSelectExercise={(ex) => {
if (!exercisePickerTarget) return
onSelectExercises={
exercisePickerTarget?.multi
? async (picked) => {
if (!exercisePickerTarget || !picked?.length) return
const { sIdx } = exercisePickerTarget
const rows = []
for (const ex of picked) {
const row = await hydrateExerciseForPickerRow(ex)
if (row) rows.push(row)
}
if (!rows.length) return
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, si) =>
si !== sIdx ? s : { ...s, items: [...(s.items || []), ...rows] }
),
}))
setExercisePickerOpen(false)
setExercisePickerTarget(null)
}
: undefined
}
onSelectExercise={async (ex) => {
if (!exercisePickerTarget || exercisePickerTarget.multi) return
const row = await hydrateExerciseForPickerRow(ex)
if (!row) return
const { sIdx, iIdx } = exercisePickerTarget
if (typeof iIdx !== 'number') return
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, si) =>
@ -840,30 +895,31 @@ function TrainingPlanningPage() {
? s
: {
...s,
items: s.items.map((row, ii) =>
items: s.items.map((r2, ii) =>
ii !== iIdx
? row
: row.item_type !== 'exercise'
? row
? r2
: r2.item_type !== 'exercise'
? r2
: {
...row,
exercise_id: ex.id,
exercise_variant_id: '',
exercise_title: ex.title || '',
variants: Array.isArray(ex.variants) ? ex.variants : [],
...r2,
exercise_id: row.exercise_id,
exercise_variant_id: row.exercise_variant_id,
exercise_title: row.exercise_title,
variants: row.variants,
}
)
),
}
)
),
}))
setExercisePickerOpen(false)
setExercisePickerTarget(null)
}}
/>
<ExercisePeekModal
open={planningPeekExerciseId != null}
exerciseId={planningPeekExerciseId}
onClose={() => setPlanningPeekExerciseId(null)}
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
</div>
)