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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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,29 +407,16 @@ 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) => {
|
||||||
<li key={ex.id}>
|
const picked = multiPicked.some((p) => p.id === ex.id)
|
||||||
<button
|
const rowInner = (
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong style={{ display: 'block' }}>{ex.title}</strong>
|
<strong style={{ display: 'block' }}>{ex.title}</strong>
|
||||||
{(ex.summary || '').trim().length > 0 && (
|
{(ex.summary || '').trim().length > 0 && (
|
||||||
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{ex.focus_area && (
|
{ex.focus_area && (
|
||||||
|
|
@ -421,9 +424,64 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
|
||||||
{ex.focus_area}
|
{ex.focus_area}
|
||||||
</span>
|
</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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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,380 +358,474 @@ 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 (
|
||||||
<div
|
<Fragment key={`secFrag-${sIdx}`}>
|
||||||
key={`sec-${sIdx}`}
|
{enableSectionDragReorder ? (
|
||||||
style={{
|
<div
|
||||||
marginBottom: '1rem',
|
className={'tu-section-dropband' + (bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')}
|
||||||
padding: '0.75rem',
|
title="Abschnitt hier einfügen"
|
||||||
background: 'var(--surface2)',
|
onDragOver={(e) => {
|
||||||
borderRadius: '10px',
|
if (!enableSectionDragReorder) return
|
||||||
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
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
|
<div
|
||||||
|
className="tu-section-shell"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
marginBottom: '1rem',
|
||||||
flexWrap: 'wrap',
|
padding: '0.75rem',
|
||||||
gap: '0.5rem',
|
background: 'var(--surface2)',
|
||||||
marginBottom: '0.5rem',
|
borderRadius: '10px',
|
||||||
|
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<div
|
||||||
className="form-input"
|
style={{
|
||||||
style={{ flex: '2 1 180px', marginBottom: 0 }}
|
display: 'flex',
|
||||||
value={sec.title}
|
flexWrap: 'wrap',
|
||||||
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
|
gap: '0.5rem',
|
||||||
placeholder="Abschnittstitel (z. B. Aufwärmen)"
|
marginBottom: '0.5rem',
|
||||||
/>
|
alignItems: 'flex-start',
|
||||||
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
|
}}
|
||||||
|
>
|
||||||
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Abschnitt hoch"
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
onClick={() => moveSection(sIdx, -1)}
|
onClick={() => removeSection(sIdx)}
|
||||||
disabled={sIdx === 0}
|
|
||||||
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
|
|
||||||
>
|
>
|
||||||
▲
|
Abschnitt entfernen
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<textarea
|
||||||
type="button"
|
className="form-input"
|
||||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
rows={2}
|
||||||
onClick={() => removeSection(sIdx)}
|
value={sec.guidance_notes}
|
||||||
>
|
onChange={(e) =>
|
||||||
Abschnitt entfernen
|
updateSectionField(sIdx, 'guidance_notes', e.target.value)
|
||||||
</button>
|
}
|
||||||
</div>
|
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||||||
<textarea
|
/>
|
||||||
className="form-input"
|
{planMin > 0 && (
|
||||||
rows={2}
|
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||||
value={sec.guidance_notes}
|
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||||||
onChange={(e) => updateSectionField(sIdx, 'guidance_notes', e.target.value)}
|
</p>
|
||||||
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) => {
|
{(sec.items || []).map((it, iIdx) => {
|
||||||
const dropHere =
|
const dropHere =
|
||||||
enableItemDragReorder &&
|
enableItemDragReorder &&
|
||||||
dropTargetPos?.sIdx === sIdx &&
|
dropTargetPos?.sIdx === sIdx &&
|
||||||
dropTargetPos?.iIdx === iIdx
|
dropTargetPos?.iIdx === iIdx
|
||||||
const dragHere =
|
const dragHere =
|
||||||
enableItemDragReorder &&
|
enableItemDragReorder &&
|
||||||
draggingPos?.sIdx === sIdx &&
|
draggingPos?.sIdx === sIdx &&
|
||||||
draggingPos?.iIdx === iIdx
|
draggingPos?.iIdx === iIdx
|
||||||
const rowCommon =
|
const rowCommon =
|
||||||
'tu-item-row' +
|
'tu-item-row' +
|
||||||
(dropHere ? ' tu-item-row--drop-target' : '') +
|
(dropHere ? ' tu-item-row--drop-target' : '') +
|
||||||
(dragHere ? ' tu-item-row--dragging' : '')
|
(dragHere ? ' tu-item-row--dragging' : '')
|
||||||
|
|
||||||
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),
|
||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
if (it.item_type === 'note') {
|
if (it.item_type === 'note') {
|
||||||
const notePv = truncatePreview(it.note_body || '', 260)
|
const notePv = truncatePreview(it.note_body || '', 260)
|
||||||
const noteHasText = Boolean((it.note_body || '').trim())
|
const noteHasText = Boolean((it.note_body || '').trim())
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`note-${sIdx}-${iIdx}`}
|
key={`note-${sIdx}-${iIdx}`}
|
||||||
className={`${rowCommon} tu-item-row--note`}
|
className={`${rowCommon} tu-item-row--note`}
|
||||||
{...dndRowProps}
|
{...dndRowProps}
|
||||||
>
|
|
||||||
{enableItemDragReorder ? (
|
|
||||||
<span
|
|
||||||
className="tu-row-grip"
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
|
||||||
onDragEnd={clearDragChrome}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Eintrag ziehen"
|
|
||||||
>
|
|
||||||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<div className="tu-item-row__nudge">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Eintrag nach oben"
|
|
||||||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
|
||||||
disabled={iIdx === 0}
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Eintrag nach unten"
|
|
||||||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
|
||||||
disabled={iIdx === sec.items.length - 1}
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="tu-item-row__body tu-item-row__body--note">
|
|
||||||
<span className="tu-item-row__meta-label">Zwischen-Anmerkung</span>
|
|
||||||
<p
|
|
||||||
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
|
|
||||||
title={noteHasText ? (it.note_body || '').trim() : undefined}
|
|
||||||
>
|
|
||||||
{noteHasText ? notePv : '—'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tu-icon-btn"
|
|
||||||
title="Zwischen-Anmerkung bearbeiten"
|
|
||||||
aria-label="Zwischen-Anmerkung bearbeiten"
|
|
||||||
onClick={() =>
|
|
||||||
setTextEdit({
|
|
||||||
kind: 'zwischen-note',
|
|
||||||
sIdx,
|
|
||||||
iIdx,
|
|
||||||
draft: it.note_body || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
{enableItemDragReorder ? (
|
||||||
</button>
|
<span
|
||||||
<button
|
className="tu-row-grip"
|
||||||
type="button"
|
draggable
|
||||||
className="tu-item-row__remove"
|
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||||||
title="Entfernen"
|
onDragEnd={clearDragChrome}
|
||||||
aria-label="Zwischen-Anmerkung entfernen"
|
role="button"
|
||||||
onClick={() => removeItem(sIdx, iIdx)}
|
tabIndex={0}
|
||||||
>
|
aria-label="Eintrag ziehen"
|
||||||
✗
|
|
||||||
</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}
|
|
||||||
>
|
>
|
||||||
<option value="">
|
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||||||
{variantOpts.length === 0 ? 'Keine Varianten' : 'Stammübung'}
|
</span>
|
||||||
</option>
|
) : null}
|
||||||
{variantOpts.map((v) => (
|
<div className="tu-item-row__nudge">
|
||||||
<option key={v.id} value={v.id}>
|
<button
|
||||||
{v.variant_name || `Variante #${v.id}`}
|
type="button"
|
||||||
</option>
|
aria-label="Eintrag nach oben"
|
||||||
))}
|
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||||||
</select>
|
disabled={iIdx === 0}
|
||||||
<div className="tu-ex-annot">
|
>
|
||||||
<span
|
▲
|
||||||
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
|
</button>
|
||||||
title={annotHasText ? (it.notes || '').trim() : undefined}
|
<button
|
||||||
>
|
type="button"
|
||||||
{annotHasText ? annotPrev : '—'}
|
aria-label="Eintrag nach unten"
|
||||||
</span>
|
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||||||
<button
|
disabled={iIdx === sec.items.length - 1}
|
||||||
type="button"
|
>
|
||||||
className="tu-icon-btn"
|
▼
|
||||||
title="Anmerkung zur Übung"
|
</button>
|
||||||
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>
|
<div className="tu-item-row__body tu-item-row__body--note">
|
||||||
<div className="tu-item-row__side">
|
<span className="tu-item-row__meta-label">Zwischen-Anmerkung</span>
|
||||||
<input
|
<p
|
||||||
type="number"
|
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||||||
className="form-input tu-ex-duration"
|
title={noteHasText ? (it.note_body || '').trim() : undefined}
|
||||||
min={1}
|
>
|
||||||
value={it.planned_duration_min}
|
{noteHasText ? notePv : '—'}
|
||||||
onChange={(e) =>
|
</p>
|
||||||
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tu-item-row__remove"
|
className="tu-item-row__remove"
|
||||||
title="Übung entfernen"
|
title="Entfernen"
|
||||||
aria-label="Übung entfernen"
|
aria-label="Zwischen-Anmerkung entfernen"
|
||||||
onClick={() => removeItem(sIdx, iIdx)}
|
onClick={() => removeItem(sIdx, iIdx)}
|
||||||
>
|
>
|
||||||
✗
|
✗
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{showExecutionExtras ? (
|
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||||||
<label className="tu-ex-run-block form-label">
|
const exTitle =
|
||||||
Ist-Dauer / Anpassungen
|
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
||||||
<span className="tu-ex-run-block__controls">
|
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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-input"
|
className="form-input tu-ex-duration"
|
||||||
min={1}
|
min={1}
|
||||||
value={it.actual_duration_min}
|
value={it.planned_duration_min}
|
||||||
onChange={(e) =>
|
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
|
<button
|
||||||
className="form-input"
|
type="button"
|
||||||
rows={2}
|
className="tu-item-row__remove"
|
||||||
value={it.modifications || ''}
|
title="Übung entfernen"
|
||||||
onChange={(e) =>
|
aria-label="Übung entfernen"
|
||||||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
onClick={() => removeItem(sIdx, iIdx)}
|
||||||
}
|
>
|
||||||
placeholder="Abweichungen beim Durchführen"
|
✗
|
||||||
/>
|
</button>
|
||||||
</span>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div style={{ marginTop: '0.65rem', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
{showExecutionExtras ? (
|
||||||
<button
|
<label className="tu-ex-run-block form-label">
|
||||||
type="button"
|
Ist-Dauer / Anpassungen
|
||||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
<span className="tu-ex-run-block__controls">
|
||||||
onClick={() => addItem(sIdx, 'exercise')}
|
<input
|
||||||
>
|
type="number"
|
||||||
+ Übung
|
className="form-input"
|
||||||
</button>
|
min={1}
|
||||||
<button
|
value={it.actual_duration_min}
|
||||||
type="button"
|
onChange={(e) =>
|
||||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||||||
onClick={() => addItem(sIdx, 'note')}
|
}
|
||||||
>
|
placeholder="IST min"
|
||||||
+ Anmerkung
|
/>
|
||||||
</button>
|
<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>
|
||||||
</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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user