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; 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 { .framework-slot-card__head {
display: flex; display: flex;
flex-direction: row; 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 [loading, setLoading] = useState(false)
const [err, setErr] = useState(null) const [err, setErr] = useState(null)
const [exercise, setExercise] = 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(() => { useEffect(() => {
if (!open) { if (!open) {
setExercise(null) setExercise(null)
@ -62,7 +73,7 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall
return () => { return () => {
cancelled = true cancelled = true
} }
}, [open, exerciseId]) }, [open, exerciseId, variantId])
if (!open) return null 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 && err && <p style={{ color: 'var(--danger)' }}>{err}</p>}
{!loading && exercise && ( {!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 && ( {exercise.summary && (
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}> <div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
<HtmlBlock html={exercise.summary} /> <HtmlBlock html={exercise.summary} />

View File

@ -22,7 +22,13 @@ const INITIAL_FILTERS = {
status_any: [], status_any: [],
} }
export default function ExercisePickerModal({ open, onClose, onSelectExercise }) { export default function ExercisePickerModal({
open,
onClose,
onSelectExercise,
multiSelect = false,
onSelectExercises = null,
}) {
const [catalogs, setCatalogs] = useState({ const [catalogs, setCatalogs] = useState({
focusAreas: [], focusAreas: [],
styleDirections: [], styleDirections: [],
@ -42,6 +48,13 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false) 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(() => { useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350) const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
@ -96,6 +109,7 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
setList([]) setList([])
setOffset(0) setOffset(0)
setHasMore(false) setHasMore(false)
setMultiPicked([])
} }
}, [open]) }, [open])
@ -230,7 +244,9 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="admin-modal-sheet__header"> <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}> <button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen Schließen
</button> </button>
@ -391,7 +407,58 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''} {list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
</p> </p>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{list.map((ex) => ( {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}
</span>
)}
{ex.focus_area && (
<span className="exercise-tag exercise-tag--accent" style={{ marginTop: 6 }}>
{ex.focus_area}
</span>
)}
</>
)
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}> <li key={ex.id}>
<button <button
type="button" type="button"
@ -410,20 +477,11 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
<strong style={{ display: 'block' }}>{ex.title}</strong> {rowInner}
{(ex.summary || '').trim().length > 0 && (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{(ex.summary || '').length > 120 ? `${(ex.summary || '').slice(0, 120)}` : ex.summary}
</span>
)}
{ex.focus_area && (
<span className="exercise-tag exercise-tag--accent" style={{ marginTop: 6 }}>
{ex.focus_area}
</span>
)}
</button> </button>
</li> </li>
))} )
})}
</ul> </ul>
{hasMore && ( {hasMore && (
<div style={{ textAlign: 'center', marginTop: 12 }}> <div style={{ textAlign: 'center', marginTop: 12 }}>
@ -432,6 +490,49 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
</button> </button>
</div> </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> </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 { GripVertical, Pencil } from 'lucide-react'
import { import {
defaultSection, defaultSection,
@ -8,6 +8,14 @@ import {
} from '../utils/trainingUnitSectionsForm' } from '../utils/trainingUnitSectionsForm'
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item' 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) { function truncatePreview(text, max = 160) {
const t = (text || '').replace(/\s+/g, ' ').trim() const t = (text || '').replace(/\s+/g, ' ').trim()
@ -15,8 +23,20 @@ function truncatePreview(text, max = 160) {
return `${t.slice(0, max - 1)}` 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 {(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({ export default function TrainingUnitSectionsEditor({
sections, sections,
@ -28,6 +48,9 @@ export default function TrainingUnitSectionsEditor({
hideHeading = false, hideHeading = false,
wideExerciseGrid = false, wideExerciseGrid = false,
enableItemDragReorder = true, enableItemDragReorder = true,
enableSectionDragReorder = true,
slotIndex = null,
onMoveSectionsAcrossSlots = null,
}) { }) {
const ensure = (prev) => const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()] prev && prev.length ? prev : [defaultSection()]
@ -39,6 +62,9 @@ export default function TrainingUnitSectionsEditor({
[onSectionsChange] [onSectionsChange]
) )
const sectionToSlot =
slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1
const updateSectionField = (sIdx, field, val) => { const updateSectionField = (sIdx, field, val) => {
patch((prev) => patch((prev) =>
prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s)) prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s))
@ -119,6 +145,9 @@ export default function TrainingUnitSectionsEditor({
const [draggingPos, setDraggingPos] = useState(null) const [draggingPos, setDraggingPos] = useState(null)
const [dropTargetPos, setDropTargetPos] = useState(null) const [dropTargetPos, setDropTargetPos] = useState(null)
const [dropSectionBand, setDropSectionBand] = useState(null)
/** { slot: number, beforeIdx: number } */
useEffect(() => { useEffect(() => {
if (!textEdit) return if (!textEdit) return
const onKey = (e) => { const onKey = (e) => {
@ -128,6 +157,78 @@ export default function TrainingUnitSectionsEditor({
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [textEdit]) }, [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) => { const onItemDragStart = (e, sIdx, iIdx) => {
if (!enableItemDragReorder) return if (!enableItemDragReorder) return
e.stopPropagation() e.stopPropagation()
@ -150,6 +251,7 @@ export default function TrainingUnitSectionsEditor({
const onItemDragOverRow = (e, sIdx, iIdx) => { const onItemDragOverRow = (e, sIdx, iIdx) => {
if (!enableItemDragReorder) return if (!enableItemDragReorder) return
if (!dtHasType(e, DND_TU_ITEM)) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
try { try {
@ -256,9 +358,33 @@ export default function TrainingUnitSectionsEditor({
) : null} ) : null}
{list.map((sec, sIdx) => { {list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec) const planMin = sectionPlannedMinutes(sec)
const itemCount = sec.items?.length ?? 0
const bandActiveBefore = (bx) =>
enableSectionDragReorder &&
dropSectionBand &&
dropSectionBand.slot === sectionToSlot &&
dropSectionBand.beforeIdx === bx
return ( return (
<Fragment key={`secFrag-${sIdx}`}>
{enableSectionDragReorder ? (
<div <div
key={`sec-${sIdx}`} 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={{ style={{
marginBottom: '1rem', marginBottom: '1rem',
padding: '0.75rem', padding: '0.75rem',
@ -273,8 +399,22 @@ export default function TrainingUnitSectionsEditor({
flexWrap: 'wrap', flexWrap: 'wrap',
gap: '0.5rem', gap: '0.5rem',
marginBottom: '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 <input
className="form-input" className="form-input"
style={{ flex: '2 1 180px', marginBottom: 0 }} style={{ flex: '2 1 180px', marginBottom: 0 }}
@ -317,7 +457,9 @@ export default function TrainingUnitSectionsEditor({
className="form-input" className="form-input"
rows={2} rows={2}
value={sec.guidance_notes} value={sec.guidance_notes}
onChange={(e) => updateSectionField(sIdx, 'guidance_notes', e.target.value)} onChange={(e) =>
updateSectionField(sIdx, 'guidance_notes', e.target.value)
}
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)" placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
/> />
{planMin > 0 && ( {planMin > 0 && (
@ -342,8 +484,8 @@ export default function TrainingUnitSectionsEditor({
const dndRowProps = enableItemDragReorder const dndRowProps = enableItemDragReorder
? { ? {
onDragOver: (e) => onItemDragOverRow(e, sIdx, iIdx), onDragOverCapture: (ev) => onItemDragOverRow(ev, sIdx, iIdx),
onDrop: (e) => onItemDropRow(e, sIdx, iIdx), onDrop: (ev) => onItemDropRow(ev, sIdx, iIdx),
} }
: {} : {}
@ -430,6 +572,11 @@ export default function TrainingUnitSectionsEditor({
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '') it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
const annotPrev = truncatePreview(it.notes || '', 220) const annotPrev = truncatePreview(it.notes || '', 220)
const annotHasText = Boolean((it.notes || '').trim()) 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 ( return (
<div <div
@ -493,7 +640,9 @@ export default function TrainingUnitSectionsEditor({
<button <button
type="button" type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs" className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => onPeekExercise(Number(it.exercise_id))} onClick={() =>
onPeekExercise(Number(it.exercise_id), variantIdPeek)
}
> >
Vorschau Vorschau
</button> </button>
@ -501,12 +650,14 @@ export default function TrainingUnitSectionsEditor({
</span> </span>
</div> </div>
<div className="tu-ex-meta-line"> <div className="tu-ex-meta-line">
{hasVariants ? (
<select <select
className={`form-input tu-ex-variant-select${ className={`form-input tu-ex-variant-select${
wideExerciseGrid ? ' tu-ex-variant-select--wide' : '' wideExerciseGrid ? ' tu-ex-variant-select--wide' : ''
}`} }`}
value={ value={
it.exercise_variant_id === '' || it.exercise_variant_id == null it.exercise_variant_id === '' ||
it.exercise_variant_id == null
? '' ? ''
: String(it.exercise_variant_id) : String(it.exercise_variant_id)
} }
@ -519,17 +670,16 @@ export default function TrainingUnitSectionsEditor({
raw === '' ? '' : parseInt(raw, 10) raw === '' ? '' : parseInt(raw, 10)
) )
}} }}
disabled={!it.exercise_id || variantOpts.length === 0} title="Übungsvariante"
> >
<option value=""> <option value="">Stammübung</option>
{variantOpts.length === 0 ? 'Keine Varianten' : 'Stammübung'}
</option>
{variantOpts.map((v) => ( {variantOpts.map((v) => (
<option key={v.id} value={v.id}> <option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`} {v.variant_name || `Variante #${v.id}`}
</option> </option>
))} ))}
</select> </select>
) : null}
<div className="tu-ex-annot"> <div className="tu-ex-annot">
<span <span
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`} className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
@ -610,6 +760,19 @@ export default function TrainingUnitSectionsEditor({
) )
})} })}
{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' }}> <div style={{ marginTop: '0.65rem', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
<button <button
type="button" type="button"
@ -618,6 +781,15 @@ export default function TrainingUnitSectionsEditor({
> >
+ Übung + Übung
</button> </button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() =>
onRequestExercisePick?.({ sectionIndex: sIdx, multi: true })
}
>
+ mehrere Übungen
</button>
<button <button
type="button" type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs" className="btn btn-secondary framework-ctrl framework-ctrl--xs"
@ -627,9 +799,33 @@ export default function TrainingUnitSectionsEditor({
</button> </button>
</div> </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 <button
type="button" type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs" 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 TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import { import {
defaultSection, defaultSection,
exerciseRow,
normalizeUnitToForm, normalizeUnitToForm,
enrichSectionsWithVariants, enrichSectionsWithVariants,
buildSectionsPayload, buildSectionsPayload,
@ -51,7 +52,27 @@ async function enrichFrameworkSlotSections(slots) {
return out 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) { function goalHoverText(g) {
const t = (g.title || '').trim() || 'Ohne Titel' const t = (g.title || '').trim() || 'Ohne Titel'
const n = (g.notes || '').trim() const n = (g.notes || '').trim()
@ -182,7 +203,7 @@ export default function TrainingFrameworkProgramEditPage() {
const [trainingTypesCatalog, setTrainingTypesCatalog] = useState([]) const [trainingTypesCatalog, setTrainingTypesCatalog] = useState([])
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([]) const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
const [sectionPickerCtx, setSectionPickerCtx] = useState(null) const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
const [peekId, setPeekId] = useState(null) const [peekCtx, setPeekCtx] = useState(null)
const [editingGoalIdx, setEditingGoalIdx] = useState(null) const [editingGoalIdx, setEditingGoalIdx] = useState(null)
const [goalMenuGi, setGoalMenuGi] = useState(null) const [goalMenuGi, setGoalMenuGi] = useState(null)
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */ /** 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) => const slotChipButtons = (opts) =>
form.slots.map((slot, si) => { form.slots.map((slot, si) => {
const isActive = si === mobileSlotIdx const isActive = si === mobileSlotIdx
@ -564,6 +631,8 @@ export default function TrainingFrameworkProgramEditPage() {
sections={slot.sections} sections={slot.sections}
showExecutionExtras={false} showExecutionExtras={false}
wideExerciseGrid wideExerciseGrid
slotIndex={si}
onMoveSectionsAcrossSlots={moveSectionsAcrossFrameworkSlots}
onSectionsChange={(updater) => { onSectionsChange={(updater) => {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
@ -579,14 +648,17 @@ export default function TrainingFrameworkProgramEditPage() {
), ),
})) }))
}} }}
onRequestExercisePick={({ sectionIndex, itemIndex }) => onRequestExercisePick={({ sectionIndex, itemIndex, multi }) =>
setSectionPickerCtx({ setSectionPickerCtx({
slotIdx: si, slotIdx: si,
sectionIndex, 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>
</div> </div>
@ -1057,21 +1129,45 @@ export default function TrainingFrameworkProgramEditPage() {
<ExercisePickerModal <ExercisePickerModal
open={sectionPickerCtx != null} open={sectionPickerCtx != null}
multiSelect={!!sectionPickerCtx?.multi}
onClose={() => setSectionPickerCtx(null)} 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) => { onSelectExercise={async (exercise) => {
if (!sectionPickerCtx) return if (!sectionPickerCtx) return
if (sectionPickerCtx.multi) return
if (typeof sectionPickerCtx.itemIndex !== 'number') return
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx
let variants = Array.isArray(exercise.variants) ? exercise.variants : [] const row = await hydrateExerciseForSlotRow(exercise)
let title = exercise.title || '' if (!row) return
if (!variants.length) {
try {
const full = await api.getExercise(exercise.id)
variants = Array.isArray(full.variants) ? full.variants : []
title = full.title || title
} catch {
variants = []
}
}
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
slots: prev.slots.map((sl, ii) => slots: prev.slots.map((sl, ii) =>
@ -1085,17 +1181,17 @@ export default function TrainingFrameworkProgramEditPage() {
? sec ? sec
: { : {
...sec, ...sec,
items: (sec.items || []).map((row, ji) => items: (sec.items || []).map((r2, ji) =>
ji !== iIdx ji !== iIdx
? row ? r2
: row.item_type !== 'exercise' : r2.item_type !== 'exercise'
? row ? r2
: { : {
...row, ...r2,
exercise_id: exercise.id, exercise_id: row.exercise_id,
exercise_variant_id: '', exercise_variant_id: row.exercise_variant_id,
exercise_title: title, exercise_title: row.exercise_title,
variants, 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> </div>
) )
} }

View File

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