Module und Kombinationsübnungen in Version 0.8 #31
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.104"
|
APP_VERSION = "0.8.105"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260512057"
|
DB_SCHEMA_VERSION = "20260512057"
|
||||||
|
|
||||||
|
|
@ -35,6 +35,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.105",
|
||||||
|
"date": "2026-05-12",
|
||||||
|
"changes": [
|
||||||
|
"Übungsbearbeitung Kombi: Stationen mit Pool per Modal (nur Einzelübungen), Zeiten pro Station in derselben Karte, Drag&Drop + Pfeile statt Index; API schreibt slot_index aus Reihenfolge; Gesamtdurchläufe bei Zirkel/Sequenz/Parcours/Parallel klar beschriftet.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.104",
|
"version": "0.8.104",
|
||||||
"date": "2026-05-12",
|
"date": "2026-05-12",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export default function CombinationMethodProfileEditor({
|
||||||
plannerMode = false,
|
plannerMode = false,
|
||||||
allowExpertJson = false,
|
allowExpertJson = false,
|
||||||
comboSlotsOutline = null,
|
comboSlotsOutline = null,
|
||||||
|
omitPerSlotTiming = false,
|
||||||
}) {
|
}) {
|
||||||
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
|
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
|
||||||
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
|
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
|
||||||
|
|
@ -62,7 +63,7 @@ export default function CombinationMethodProfileEditor({
|
||||||
}, [comboSlotsOutline])
|
}, [comboSlotsOutline])
|
||||||
|
|
||||||
const showSlotTiming =
|
const showSlotTiming =
|
||||||
ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
|
!omitPerSlotTiming && ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
|
||||||
|
|
||||||
const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
|
const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
|
||||||
import MediaPreviewModal from '../components/MediaPreviewModal'
|
import MediaPreviewModal from '../components/MediaPreviewModal'
|
||||||
import ReportContentModal from '../components/ReportContentModal'
|
import ReportContentModal from '../components/ReportContentModal'
|
||||||
import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor'
|
import CombinationMethodProfileEditor from '../components/CombinationMethodProfileEditor'
|
||||||
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
import {
|
import {
|
||||||
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
||||||
buildExerciseMediaDragPayload,
|
buildExerciseMediaDragPayload,
|
||||||
|
|
@ -16,6 +17,8 @@ import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
|
||||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
|
import { COMBINATION_ARCHETYPE_OPTIONS } from '../constants/combinationArchetypes'
|
||||||
|
import { readSlotProfilesV1 } from '../utils/combinationMethodProfileUi'
|
||||||
|
import { GripVertical } from 'lucide-react'
|
||||||
|
|
||||||
const INTENSITY_OPTIONS = [
|
const INTENSITY_OPTIONS = [
|
||||||
{ value: '', label: '—' },
|
{ value: '', label: '—' },
|
||||||
|
|
@ -32,16 +35,52 @@ const VARIANT_DIFFICULTY = [
|
||||||
{ value: 'adapted', label: 'Angepasst' },
|
{ value: 'adapted', label: 'Angepasst' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
|
||||||
|
const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
|
||||||
|
|
||||||
|
function emptyComboSlotRow() {
|
||||||
|
return {
|
||||||
|
title: '',
|
||||||
|
candidate_exercise_ids: [],
|
||||||
|
exercise_title_by_id: {},
|
||||||
|
load_sec: '',
|
||||||
|
consecutive_reps: '',
|
||||||
|
intra_rep_rest_sec: '',
|
||||||
|
transition_after_sec: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function comboSlotsFromDetail(exercise) {
|
function comboSlotsFromDetail(exercise) {
|
||||||
const raw = exercise?.combination_slots
|
const raw = exercise?.combination_slots
|
||||||
|
const mp =
|
||||||
|
exercise?.method_profile &&
|
||||||
|
typeof exercise.method_profile === 'object' &&
|
||||||
|
!Array.isArray(exercise.method_profile)
|
||||||
|
? exercise.method_profile
|
||||||
|
: {}
|
||||||
|
const spvList = readSlotProfilesV1(mp)
|
||||||
|
const byIx = new Map(spvList.map((r) => [Number(r.slot_index), r]))
|
||||||
|
|
||||||
if (!Array.isArray(raw) || raw.length === 0) {
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
return [{ slot_index: 0, title: '', idsText: '' }]
|
return [emptyComboSlotRow()]
|
||||||
}
|
}
|
||||||
return raw.map((s, i) => ({
|
const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0))
|
||||||
slot_index: s.slot_index != null ? Number(s.slot_index) : i,
|
return sorted.map((s) => {
|
||||||
title: s.title != null ? String(s.title) : '',
|
const si = Number(s.slot_index)
|
||||||
idsText: Array.isArray(s.candidate_exercise_ids) ? s.candidate_exercise_ids.join(', ') : '',
|
const st = byIx.get(si) || {}
|
||||||
}))
|
const cands = Array.isArray(s.candidate_exercise_ids)
|
||||||
|
? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
|
||||||
|
: []
|
||||||
|
return {
|
||||||
|
title: s.title != null ? String(s.title) : '',
|
||||||
|
candidate_exercise_ids: cands,
|
||||||
|
exercise_title_by_id: {},
|
||||||
|
load_sec: st.load_sec != null ? String(st.load_sec) : '',
|
||||||
|
consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
|
||||||
|
intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
|
||||||
|
transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '',
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyVariantDraft() {
|
function emptyVariantDraft() {
|
||||||
|
|
@ -266,7 +305,7 @@ function emptyForm() {
|
||||||
exercise_kind: 'simple',
|
exercise_kind: 'simple',
|
||||||
method_archetype: '',
|
method_archetype: '',
|
||||||
method_profile_json: '{}',
|
method_profile_json: '{}',
|
||||||
combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
|
combination_slots: [emptyComboSlotRow()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -569,6 +608,69 @@ function ExerciseFormPage() {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [comboStationPickerIx, setComboStationPickerIx] = useState(null)
|
||||||
|
const [comboDropTargetIx, setComboDropTargetIx] = useState(null)
|
||||||
|
|
||||||
|
const reorderCombinationSlots = (fromI, toBeforeIx) => {
|
||||||
|
setFormDirty(true)
|
||||||
|
setFormData((prev) => {
|
||||||
|
const rows = [...(prev.combination_slots || [])]
|
||||||
|
if (fromI < 0 || fromI >= rows.length) return prev
|
||||||
|
const [moved] = rows.splice(fromI, 1)
|
||||||
|
let insertAt = toBeforeIx
|
||||||
|
if (fromI < toBeforeIx) insertAt = toBeforeIx - 1
|
||||||
|
insertAt = Math.max(0, Math.min(insertAt, rows.length))
|
||||||
|
rows.splice(insertAt, 0, moved)
|
||||||
|
return { ...prev, combination_slots: rows }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchComboSlotRow = (idx, patch) => {
|
||||||
|
setFormDirty(true)
|
||||||
|
setFormData((prev) => {
|
||||||
|
const rows = [...(prev.combination_slots || [])]
|
||||||
|
if (!rows[idx]) return prev
|
||||||
|
rows[idx] = { ...rows[idx], ...patch }
|
||||||
|
return { ...prev, combination_slots: rows }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCandidateFromSlot = (slotIdx, exerciseId) => {
|
||||||
|
setFormDirty(true)
|
||||||
|
setFormData((prev) => {
|
||||||
|
const rows = [...(prev.combination_slots || [])]
|
||||||
|
const row = rows[slotIdx]
|
||||||
|
if (!row) return prev
|
||||||
|
const nextIds = (row.candidate_exercise_ids || []).filter((id) => Number(id) !== Number(exerciseId))
|
||||||
|
const labels = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
|
||||||
|
delete labels[Number(exerciseId)]
|
||||||
|
rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
|
||||||
|
return { ...prev, combination_slots: rows }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
|
||||||
|
if (!Array.isArray(pickedList) || !pickedList.length) return
|
||||||
|
setFormDirty(true)
|
||||||
|
setFormData((prev) => {
|
||||||
|
const rows = [...(prev.combination_slots || [])]
|
||||||
|
const row = rows[slotIdx] || emptyComboSlotRow()
|
||||||
|
const nextSet = new Set((row.candidate_exercise_ids || []).map((n) => Number(n)))
|
||||||
|
const labels =
|
||||||
|
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
|
||||||
|
pickedList.forEach((ex) => {
|
||||||
|
if (ex && ex.id != null) {
|
||||||
|
const id = Number(ex.id)
|
||||||
|
nextSet.add(id)
|
||||||
|
const t = (ex.title || '').trim()
|
||||||
|
if (t) labels[id] = t
|
||||||
|
}
|
||||||
|
})
|
||||||
|
rows[slotIdx] = { ...row, candidate_exercise_ids: [...nextSet], exercise_title_by_id: labels }
|
||||||
|
return { ...prev, combination_slots: rows }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const addSkillRow = () => {
|
const addSkillRow = () => {
|
||||||
const id = skillPick ? parseInt(skillPick, 10) : null
|
const id = skillPick ? parseInt(skillPick, 10) : null
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|
@ -1004,7 +1106,7 @@ function ExerciseFormPage() {
|
||||||
? {
|
? {
|
||||||
method_archetype: '',
|
method_archetype: '',
|
||||||
method_profile_json: '{}',
|
method_profile_json: '{}',
|
||||||
combination_slots: [{ slot_index: 0, title: '', idsText: '' }],
|
combination_slots: [emptyComboSlotRow()],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}))
|
}))
|
||||||
|
|
@ -1031,119 +1133,290 @@ function ExerciseFormPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '10px' }}>
|
{String(formData.method_archetype || '').trim() === 'station_parcour' ? (
|
||||||
<strong style={{ fontSize: '14px' }}>Stationen</strong>
|
<p
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 10px', lineHeight: 1.45 }}>
|
style={{
|
||||||
Zuerst die Stationen anlegen — dann erscheinen die Zeiten darunter auch <strong>pro Slot</strong> im Ablaufprofil.
|
fontSize: '12px',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
margin: '4px 0 10px',
|
||||||
|
lineHeight: 1.48,
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Parcours / Bahnsystem:</strong> typischerweise starten alle an Station 1 und durchlaufen der
|
||||||
|
Reihe nach alle Punkte (Geschwindigkeit variabel). Die Stationsreihenfolge unten ist der Ablaufweg;
|
||||||
|
Zeitangaben pro Station und <strong>Gesamtdurchläufe</strong> im Ablaufprofil strukturieren das
|
||||||
|
spätere Coaching.
|
||||||
</p>
|
</p>
|
||||||
{(formData.combination_slots || []).map((row, idx) => (
|
) : null}
|
||||||
<div
|
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '12px' }}>
|
||||||
key={`cs-${idx}`}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
style={{
|
<strong style={{ fontSize: '14px' }}>Stationen und Übungs‑Pool</strong>
|
||||||
display: 'grid',
|
<span style={{ fontSize: '11px', color: 'var(--text3)' }}>
|
||||||
gridTemplateColumns: 'minmax(0, 72px) minmax(0, 1fr) minmax(0, 1.2fr) auto',
|
Reihenfolge = Ablauf · ziehen oder Pfeile · nur Einzelübungen wählbar
|
||||||
gap: '8px',
|
</span>
|
||||||
marginBottom: '8px',
|
</div>
|
||||||
alignItems: 'end',
|
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 12px', lineHeight: 1.48 }}>
|
||||||
}}
|
Jede Station: Titel (optional), am Ort wählbare <strong>Einzelübungen</strong> sowie die typischen Zeiten für genau diese Station (Belastungsdauer, Wiederholungsbündel, Pausen).
|
||||||
>
|
</p>
|
||||||
<div className="form-row">
|
{(formData.combination_slots || []).map((row, idx) => {
|
||||||
<label className="form-label" style={{ fontSize: '12px' }}>
|
const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
|
||||||
Idx.
|
const lbl =
|
||||||
</label>
|
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
|
||||||
<input
|
? row.exercise_title_by_id
|
||||||
type="number"
|
: {}
|
||||||
min={0}
|
const isDropHere = comboDropTargetIx === idx
|
||||||
max={99}
|
return (
|
||||||
className="form-input"
|
<div
|
||||||
value={row.slot_index}
|
key={`combo-slot-${idx}`}
|
||||||
onChange={(e) => {
|
onDragOver={(e) => {
|
||||||
const next = [...(formData.combination_slots || [])]
|
if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
|
||||||
const v = e.target.value
|
e.preventDefault()
|
||||||
next[idx] = {
|
e.dataTransfer.dropEffect = 'move'
|
||||||
...row,
|
setComboDropTargetIx(idx)
|
||||||
slot_index: v === '' ? '' : parseInt(v, 10),
|
}}
|
||||||
}
|
onDragLeave={() => setComboDropTargetIx((cur) => (cur === idx ? null : cur))}
|
||||||
updateFormField('combination_slots', next)
|
onDrop={(e) => {
|
||||||
}}
|
const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
|
||||||
/>
|
const fromI = parseInt(rawFrom, 10)
|
||||||
</div>
|
e.preventDefault()
|
||||||
<div className="form-row">
|
setComboDropTargetIx(null)
|
||||||
<label className="form-label" style={{ fontSize: '12px' }}>
|
if (!Number.isFinite(fromI)) return
|
||||||
Titel
|
reorderCombinationSlots(fromI, idx)
|
||||||
</label>
|
}}
|
||||||
<input
|
style={{
|
||||||
type="text"
|
marginBottom: '12px',
|
||||||
className="form-input"
|
padding: '12px 14px',
|
||||||
value={row.title || ''}
|
borderRadius: '12px',
|
||||||
onChange={(e) => {
|
border: `1px solid ${isDropHere ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
const next = [...(formData.combination_slots || [])]
|
background: 'var(--surface)',
|
||||||
next[idx] = { ...row, title: e.target.value }
|
boxShadow: isDropHere ? '0 0 0 2px var(--accent-soft)' : 'none',
|
||||||
updateFormField('combination_slots', next)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label" style={{ fontSize: '12px' }}>
|
|
||||||
Übungs-IDs
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
value={row.idsText || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...(formData.combination_slots || [])]
|
|
||||||
next[idx] = { ...row, idsText: e.target.value }
|
|
||||||
updateFormField('combination_slots', next)
|
|
||||||
}}
|
|
||||||
placeholder="z. B. 12, 34, 56"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
style={{ fontSize: '12px', padding: '6px 8px' }}
|
|
||||||
onClick={() => {
|
|
||||||
const prev = formData.combination_slots || []
|
|
||||||
const next = prev.filter((_, j) => j !== idx)
|
|
||||||
updateFormField(
|
|
||||||
'combination_slots',
|
|
||||||
next.length ? next : [{ slot_index: 0, title: '', idsText: '' }],
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Entf.
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-start', marginBottom: '12px' }}>
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
))}
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData(DND_EXERCISE_COMBO_STATION, String(idx))
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setComboDropTargetIx(null)}
|
||||||
|
aria-label={`Station ${idx + 1} ziehen`}
|
||||||
|
title="Ziehen zum Sortieren"
|
||||||
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
|
style={{ cursor: 'grab', padding: '6px 8px', touchAction: 'none' }}
|
||||||
|
>
|
||||||
|
<GripVertical size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
|
aria-label="Station nach oben"
|
||||||
|
disabled={idx === 0}
|
||||||
|
onClick={() => reorderCombinationSlots(idx, idx - 1)}
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
|
aria-label="Station nach unten"
|
||||||
|
disabled={idx === (formData.combination_slots || []).length - 1}
|
||||||
|
onClick={() => reorderCombinationSlots(idx, idx + 2)}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ flex: '1 1 200px', marginBottom: 0 }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '12px' }}>
|
||||||
|
Station {idx + 1} — Titel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={row.title || ''}
|
||||||
|
placeholder={`z. B. Station ${idx + 1}`}
|
||||||
|
onChange={(e) => patchComboSlotRow(idx, { title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
|
onClick={() => setComboStationPickerIx(idx)}
|
||||||
|
>
|
||||||
|
Einzelübungen wählen…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn framework-ctrl framework-ctrl--xs"
|
||||||
|
style={{ fontSize: '12px' }}
|
||||||
|
title="Station entfernen"
|
||||||
|
onClick={() => {
|
||||||
|
const prev = formData.combination_slots || []
|
||||||
|
const next = prev.filter((_, j) => j !== idx)
|
||||||
|
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Station entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<span className="form-label" style={{ fontSize: '11px', display: 'block', marginBottom: '6px' }}>
|
||||||
|
Gewählte Einzelübungen (Pool für diese Station)
|
||||||
|
</span>
|
||||||
|
{candIds.length === 0 ? (
|
||||||
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>Noch keine Übung gewählt — mindestens eine erforderlich zum Speichern.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
|
{candIds.map((id) => (
|
||||||
|
<li
|
||||||
|
key={`${idx}-c-${id}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '14rem' }} title={`#${id}`}>
|
||||||
|
{(lbl[id] || lbl[String(id)] || '').trim() || `Übung #${id}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tu-icon-btn"
|
||||||
|
aria-label={`Übung ${id} entfernen`}
|
||||||
|
title="Entfernen"
|
||||||
|
onClick={() => removeCandidateFromSlot(idx, id)}
|
||||||
|
>
|
||||||
|
✗
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||||
|
gap: '10px',
|
||||||
|
alignItems: 'end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||||
|
Belastung an Station (s)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="optional"
|
||||||
|
value={row.load_sec || ''}
|
||||||
|
onChange={(e) => patchComboSlotRow(idx, { load_sec: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||||
|
Wdh. ohne Wechsel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="oft 1"
|
||||||
|
value={row.consecutive_reps || ''}
|
||||||
|
onChange={(e) => patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||||
|
Pause zwischen Wdh. (s)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="optional"
|
||||||
|
value={row.intra_rep_rest_sec || ''}
|
||||||
|
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '11px' }}>
|
||||||
|
Pause / nächste Station (s)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="optional"
|
||||||
|
value={row.transition_after_sec || ''}
|
||||||
|
onChange={(e) => patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
|
||||||
|
const fromI = parseInt(rawFrom, 10)
|
||||||
|
e.preventDefault()
|
||||||
|
setComboDropTargetIx(null)
|
||||||
|
if (!Number.isFinite(fromI)) return
|
||||||
|
const len = (formData.combination_slots || []).length
|
||||||
|
reorderCombinationSlots(fromI, len)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: 'var(--text3)',
|
||||||
|
border: '1px dashed var(--border)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hier ablegen zum Anhängen am Ende der Reihenfolge
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
style={{ fontSize: '12px', marginTop: '4px' }}
|
||||||
onClick={() => {
|
onClick={() => updateFormField('combination_slots', [...(formData.combination_slots || []), emptyComboSlotRow()])}
|
||||||
const cur = formData.combination_slots || []
|
|
||||||
const ixList = cur
|
|
||||||
.map((r) =>
|
|
||||||
typeof r.slot_index === 'number' && !Number.isNaN(r.slot_index) ? r.slot_index : null,
|
|
||||||
)
|
|
||||||
.filter((n) => n != null)
|
|
||||||
const nextIx = ixList.length ? Math.max(...ixList) + 1 : 0
|
|
||||||
updateFormField('combination_slots', [
|
|
||||||
...cur,
|
|
||||||
{ slot_index: nextIx, title: '', idsText: '' },
|
|
||||||
])
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
+ Station
|
+ Station hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Ablaufprofil (Zeiten & Runden)</label>
|
<label className="form-label">Ablaufprofil (Globale Zeiten & Gesamtdurchläufe)</label>
|
||||||
<CombinationMethodProfileEditor
|
<CombinationMethodProfileEditor
|
||||||
methodArchetype={formData.method_archetype || ''}
|
methodArchetype={formData.method_archetype || ''}
|
||||||
methodProfileJson={formData.method_profile_json || '{}'}
|
methodProfileJson={formData.method_profile_json || '{}'}
|
||||||
onChangeMethodProfileJson={(s) => updateFormField('method_profile_json', s)}
|
onChangeMethodProfileJson={(s) => updateFormField('method_profile_json', s)}
|
||||||
comboSlotsOutline={formData.combination_slots || []}
|
comboSlotsOutline={(formData.combination_slots || []).map((r, i) => ({
|
||||||
|
slot_index: i,
|
||||||
|
title: r.title || '',
|
||||||
|
}))}
|
||||||
|
omitPerSlotTiming
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -1871,6 +2144,18 @@ function ExerciseFormPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ExercisePickerModal
|
||||||
|
open={comboStationPickerIx !== null}
|
||||||
|
onClose={() => setComboStationPickerIx(null)}
|
||||||
|
exerciseKindAny={['simple']}
|
||||||
|
multiSelect
|
||||||
|
enableQuickCreateDraft
|
||||||
|
onSelectExercises={(picked) => {
|
||||||
|
if (comboStationPickerIx === null) return
|
||||||
|
mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
|
||||||
|
setComboStationPickerIx(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{reportTarget && (
|
{reportTarget && (
|
||||||
<ReportContentModal
|
<ReportContentModal
|
||||||
targetType="media_asset"
|
targetType="media_asset"
|
||||||
|
|
|
||||||
|
|
@ -507,28 +507,54 @@ export function buildExerciseApiPayload(formData, extras = {}) {
|
||||||
|
|
||||||
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
|
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
|
||||||
const combination_slots = []
|
const combination_slots = []
|
||||||
|
|
||||||
|
function parseTimingField(raw) {
|
||||||
|
if (raw === '' || raw == null || raw === undefined) return undefined
|
||||||
|
const n = parseInt(String(raw), 10)
|
||||||
|
return Number.isFinite(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < slotRows.length; i += 1) {
|
for (let i = 0; i < slotRows.length; i += 1) {
|
||||||
const row = slotRows[i] || {}
|
const row = slotRows[i] || {}
|
||||||
const ix =
|
let ids = Array.isArray(row.candidate_exercise_ids)
|
||||||
row.slot_index === '' || row.slot_index == null ? i : parseInt(row.slot_index, 10)
|
? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
|
||||||
if (Number.isNaN(ix) || ix < 0 || ix > 99) {
|
: []
|
||||||
throw new Error(`Station Index ungültig (Zeile ${i + 1}).`)
|
|
||||||
|
/** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
|
||||||
|
if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
|
||||||
|
ids = row.idsText
|
||||||
|
.split(/[\s,;]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => parseInt(s, 10))
|
||||||
|
.filter((n) => Number.isFinite(n))
|
||||||
}
|
}
|
||||||
const idsText = typeof row.idsText === 'string' ? row.idsText : ''
|
|
||||||
const candidate_exercise_ids = idsText
|
|
||||||
.split(/[\s,;]+/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((s) => parseInt(s, 10))
|
|
||||||
.filter((n) => Number.isFinite(n))
|
|
||||||
combination_slots.push({
|
combination_slots.push({
|
||||||
slot_index: ix,
|
slot_index: i,
|
||||||
title: (typeof row.title === 'string' && row.title.trim()) || null,
|
title: (typeof row.title === 'string' && row.title.trim()) || null,
|
||||||
candidate_exercise_ids,
|
candidate_exercise_ids: ids,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slot_profiles_v1_next = []
|
||||||
|
for (let i = 0; i < slotRows.length; i += 1) {
|
||||||
|
const row = slotRows[i] || {}
|
||||||
|
const o = { slot_index: i }
|
||||||
|
const load = parseTimingField(row.load_sec)
|
||||||
|
const crs = parseTimingField(row.consecutive_reps)
|
||||||
|
const intra = parseTimingField(row.intra_rep_rest_sec)
|
||||||
|
const tran = parseTimingField(row.transition_after_sec)
|
||||||
|
if (load !== undefined && load >= 0) o.load_sec = Math.round(load)
|
||||||
|
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
|
||||||
|
if (intra !== undefined && intra >= 0) o.intra_rep_rest_sec = Math.round(intra)
|
||||||
|
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
|
||||||
|
if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
|
||||||
|
}
|
||||||
|
|
||||||
payload.method_archetype = (formData.method_archetype || '').trim() || null
|
payload.method_archetype = (formData.method_archetype || '').trim() || null
|
||||||
|
if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
|
||||||
|
else delete mpObj.slot_profiles_v1
|
||||||
payload.method_profile = mpObj
|
payload.method_profile = mpObj
|
||||||
payload.combination_slots = combination_slots
|
payload.combination_slots = combination_slots
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ function parseProfileJson(raw) {
|
||||||
/** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */
|
/** Pro Archetyp: UI-Feldbeschreibungen (Werte werden in method_profile geschrieben) */
|
||||||
export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
|
export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
|
||||||
sequence_linear: [
|
sequence_linear: [
|
||||||
|
{
|
||||||
|
key: 'rounds',
|
||||||
|
kind: 'int',
|
||||||
|
label: 'Anzahl Gesamtdurchläufe (komplette Sequenz, alle Stationen nacheinander)',
|
||||||
|
min: 1,
|
||||||
|
max: 999,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'hint_step_duration_sec',
|
key: 'hint_step_duration_sec',
|
||||||
kind: 'int',
|
kind: 'int',
|
||||||
|
|
@ -37,6 +44,14 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
circuit_rotate_time: [
|
circuit_rotate_time: [
|
||||||
|
{
|
||||||
|
key: 'rounds',
|
||||||
|
kind: 'int',
|
||||||
|
label:
|
||||||
|
'Anzahl Gesamtdurchläufe (jede Station pro Sportler mehrfach beim Umlauf, z. B. 4 Stationen × 2 = zwei komplette Runden)',
|
||||||
|
min: 1,
|
||||||
|
max: 999,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'work_seconds',
|
key: 'work_seconds',
|
||||||
kind: 'int',
|
kind: 'int',
|
||||||
|
|
@ -58,15 +73,15 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
|
||||||
min: 0,
|
min: 0,
|
||||||
max: INT_MAX,
|
max: INT_MAX,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
circuit_all_parallel: [
|
||||||
{
|
{
|
||||||
key: 'rounds',
|
key: 'rounds',
|
||||||
kind: 'int',
|
kind: 'int',
|
||||||
label: 'Runden (optional, wenn alle Station je Runde angefahren werden)',
|
label: 'Anzahl Durchläufe (wenn alle parallel dieselbe Rundenlogik haben, optional)',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 999,
|
max: 999,
|
||||||
},
|
},
|
||||||
],
|
|
||||||
circuit_all_parallel: [
|
|
||||||
{
|
{
|
||||||
key: 'explain_before_seconds',
|
key: 'explain_before_seconds',
|
||||||
kind: 'int',
|
kind: 'int',
|
||||||
|
|
@ -81,6 +96,13 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
station_parcour: [
|
station_parcour: [
|
||||||
|
{
|
||||||
|
key: 'rounds',
|
||||||
|
kind: 'int',
|
||||||
|
label: 'Anzahl Durchläufe des Parcours (Start Station 1, alle Bahnpunkte, Wiederholung bei Bedarf)',
|
||||||
|
min: 1,
|
||||||
|
max: 999,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'allow_free_visit_order',
|
key: 'allow_free_visit_order',
|
||||||
kind: 'bool',
|
kind: 'bool',
|
||||||
|
|
@ -121,7 +143,7 @@ export const METHOD_PROFILE_GUI_FIELDS = Object.freeze({
|
||||||
{
|
{
|
||||||
key: 'interval_rounds',
|
key: 'interval_rounds',
|
||||||
kind: 'int',
|
kind: 'int',
|
||||||
label: 'Anzahl Wiederholungen / Runden der Domäne (optional)',
|
label: 'Anzahl Wiederholungen der Intervalldomäne (komplette Zyklen Arbeit/Pause)',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 999,
|
max: 999,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user