feat: enhance exercise selection and training unit management
- 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:
parent
354216cb4f
commit
2bfe67879f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user