feat(training-unit-editor): enhance combination slots handling and UI improvements
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 1m1s

- Added support for `compactPlanningView` and `omitGlobalKeyValueBlock` in the CombinationCoachSlots component to improve display options.
- Updated the TrainingUnitSectionsEditor to fetch and manage combination slots more effectively, including new state management for modal interactions.
- Introduced a new utility function `comboSlotsOutlineForProfileEditor` to streamline the display of combination slots in the editor.
- Enhanced UI elements for better user experience when managing combination exercises and their associated slots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-13 09:58:59 +02:00
parent 13efce6e36
commit 805ad3c5a5
3 changed files with 154 additions and 14 deletions

View File

@ -52,7 +52,13 @@ function summarizeSlotProfilesRow(r) {
return bits.join(' · ')
}
export default function CombinationCoachSlots({ combinationSlots, methodArchetype, methodProfile }) {
export default function CombinationCoachSlots({
combinationSlots,
methodArchetype,
methodProfile,
compactPlanningView = false,
omitGlobalKeyValueBlock = false,
}) {
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
const candidateIds = useMemo(() => {
@ -153,7 +159,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
letterSpacing: '0.04em',
}}
>
Kombination · Stationen &amp; Einzelübungen
{compactPlanningView ? 'Stationen & Einzelübungen (Katalog)' : 'Kombination · Stationen & Einzelübungen'}
</h3>
{archDisplay ? (
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>
@ -165,11 +171,13 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
) : null}
</p>
) : null}
{compactPlanningView ? null : (
<p style={{ margin: '0 0 14px', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.48 }}>
{archetypeCoachHint(archeKey)}
</p>
)}
{methodProfile && typeof methodProfile === 'object' && !Array.isArray(methodProfile) && Object.keys(methodProfile).length ? (
{methodProfile && typeof methodProfile === 'object' && !Array.isArray(methodProfile) && Object.keys(methodProfile).length && !omitGlobalKeyValueBlock ? (
<div
style={{
margin: '0 0 14px',
@ -275,6 +283,16 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
Übung #{cid}: {err}
</p>
) : ex ? (
compactPlanningView ? (
<>
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
<p style={{ margin: 0 }}>
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
Im Katalog öffnen
</Link>
</p>
</>
) : (
<>
<p style={{ margin: '0 0 6px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
{ex.summary ? (
@ -312,6 +330,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
</Link>
</p>
</>
)
) : (
<p style={{ margin: 0, fontSize: '0.86rem', color: 'var(--text2)' }}>
{candTitleFallback || `Übung #${cid}`}

View File

@ -1,14 +1,17 @@
import React, { Fragment, useCallback, useEffect, useState } from 'react'
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
import { comboPlanningProfileJsonForEditor } from '../utils/comboPlanningMethodProfile'
import CombinationCoachSlots from './CombinationCoachSlots'
import { comboPlanningProfileJsonForEditor, effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
import { combinationArchetypeLabel } from '../constants/combinationArchetypes'
import {
comboSlotsOutlineForProfileEditor,
defaultSection,
exerciseRow,
noteRow,
sectionPlannedMinutes,
} from '../utils/trainingUnitSectionsForm'
import api from '../utils/api'
import { isCompactTagLegendMode } from '../config/planningModuleUx'
import { useAuth } from '../context/AuthContext'
@ -307,6 +310,8 @@ export default function TrainingUnitSectionsEditor({
const [textEdit, setTextEdit] = useState(null)
/** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
const [comboPlanningModal, setComboPlanningModal] = useState(null)
/** Katalog-Stationen, falls Zeile noch keine `combination_slots` (vor Enrich o. Ä.) */
const [modalComboSlotsFetched, setModalComboSlotsFetched] = useState(null)
/** { sIdx: number, beforeIx: number } Einfüge-Popup („+“ zwischen Zeilen) */
const [insertChooser, setInsertChooser] = useState(null)
const [draggingPos, setDraggingPos] = useState(null)
@ -354,6 +359,38 @@ export default function TrainingUnitSectionsEditor({
if (!ok) setComboPlanningModal(null)
}, [sections, comboPlanningModal])
useEffect(() => {
if (!comboPlanningModal) {
setModalComboSlotsFetched(null)
return
}
const L = ensure(sections)
const { sIdx, iIdx } = comboPlanningModal
const cand = L[sIdx]?.items?.[iIdx]
if (
!cand ||
String(cand.exercise_kind || '').toLowerCase().trim() !== 'combination' ||
!cand.exercise_id
) {
setModalComboSlotsFetched(null)
return
}
const cached = cand.combination_slots
if (Array.isArray(cached) && cached.length > 0) {
setModalComboSlotsFetched(cached)
return
}
let cancelled = false
setModalComboSlotsFetched([])
api.getExercise(cand.exercise_id).then((ex) => {
if (cancelled) return
setModalComboSlotsFetched(Array.isArray(ex?.combination_slots) ? ex.combination_slots : [])
})
return () => {
cancelled = true
}
}, [sections, comboPlanningModal])
const closeInsertChooser = useCallback(() => setInsertChooser(null), [])
const insertSlotKeyPrefix =
@ -571,10 +608,10 @@ export default function TrainingUnitSectionsEditor({
const list = ensure(sections)
let comboPlanningModalItem = null
let comboPlanningModalSX = null
let comboPlanningModalIX = null
if (comboPlanningModal) {
const comboPlanningModalDerived = useMemo(() => {
if (!comboPlanningModal) {
return { item: null, sIdx: null, iIdx: null }
}
const { sIdx, iIdx } = comboPlanningModal
const cand = list[sIdx]?.items?.[iIdx]
if (
@ -582,11 +619,34 @@ export default function TrainingUnitSectionsEditor({
String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' &&
cand.exercise_id
) {
comboPlanningModalItem = cand
comboPlanningModalSX = sIdx
comboPlanningModalIX = iIdx
return { item: cand, sIdx, iIdx }
}
}
return { item: null, sIdx: null, iIdx: null }
}, [list, comboPlanningModal])
const comboPlanningModalItem = comboPlanningModalDerived.item
const comboPlanningModalSX = comboPlanningModalDerived.sIdx
const comboPlanningModalIX = comboPlanningModalDerived.iIdx
const comboPlanningResolvedSlots = useMemo(() => {
if (!comboPlanningModalItem) return []
const c = comboPlanningModalItem.combination_slots
if (Array.isArray(c) && c.length > 0) return c
return Array.isArray(modalComboSlotsFetched) ? modalComboSlotsFetched : []
}, [comboPlanningModalItem, modalComboSlotsFetched])
const comboPlanningSlotsOutline = useMemo(
() => comboSlotsOutlineForProfileEditor(comboPlanningResolvedSlots),
[comboPlanningResolvedSlots]
)
const comboPlanningEffectiveProfile = useMemo(() => {
if (!comboPlanningModalItem) return {}
return effectiveComboMethodProfile(
comboPlanningModalItem.catalog_method_profile || {},
comboPlanningModalItem.planning_method_profile
)
}, [comboPlanningModalItem])
return (
<div
@ -1412,6 +1472,43 @@ export default function TrainingUnitSectionsEditor({
Aus Katalog kopieren
</button>
</div>
<p
style={{
margin: '0 0 12px',
fontSize: '0.8rem',
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
Stationen und Einzelübungen entsprechen der Kombination im Katalog. Einzelübungen hier auszutauschen ist
derzeit nicht vorgesehen (würde die Katalog-Übung ändern). Die Bereiche unten überschreiben nur diesen
Termin, sofern du von den Katalogvorgaben abweichst.
</p>
{comboPlanningResolvedSlots.length > 0 ? (
<div style={{ marginBottom: 16 }}>
<CombinationCoachSlots
combinationSlots={comboPlanningResolvedSlots}
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
methodProfile={comboPlanningEffectiveProfile}
compactPlanningView
omitGlobalKeyValueBlock
/>
</div>
) : (
<p style={{ margin: '0 0 14px', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Stationen werden geladen oder die Kombination hat im Katalog keine Stationsliste.
</p>
)}
<h5
style={{
margin: '0 0 10px',
fontSize: '0.95rem',
fontWeight: 700,
color: 'var(--text1)',
}}
>
Zeiten und Steuerung für diesen Termin
</h5>
<CombinationMethodProfileEditor
plannerMode
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
@ -1429,6 +1526,7 @@ export default function TrainingUnitSectionsEditor({
/* Ungültiges JSON — Hinweis im Editor */
}
}}
comboSlotsOutline={comboPlanningSlotsOutline}
/>
<div className="tu-textedit-actions" style={{ marginTop: '0.95rem', paddingTop: '0.25rem' }}>
<button

View File

@ -1,4 +1,5 @@
import api from './api'
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
export function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
@ -116,6 +117,7 @@ export async function hydrateExercisePlanningRow(exercise) {
row.catalog_method_archetype =
typeof full.method_archetype === 'string' ? full.method_archetype.trim() : ''
row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile)
row.combination_slots = Array.isArray(full.combination_slots) ? full.combination_slots : []
}
}
row.planning_method_profile = null
@ -245,9 +247,10 @@ export async function enrichSectionsWithVariants(sections) {
unique.map(async (id) => {
try {
const ex = await api.getExercise(id)
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
cache.set(id, {
title: ex.title || '',
exercise_kind: String(ex.exercise_kind || 'simple').toLowerCase().trim(),
exercise_kind: ek,
variants: Array.isArray(ex.variants) ? ex.variants : [],
visibility: ex.visibility || 'private',
club_id: ex.club_id ?? null,
@ -255,6 +258,7 @@ export async function enrichSectionsWithVariants(sections) {
status: ex.status || 'draft',
method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
method_profile: normalizeCatalogMethodProfile(ex.method_profile),
combination_slots: ek === 'combination' && Array.isArray(ex.combination_slots) ? ex.combination_slots : [],
})
} catch {
cache.set(id, {
@ -267,6 +271,7 @@ export async function enrichSectionsWithVariants(sections) {
status: 'draft',
method_archetype: '',
method_profile: {},
combination_slots: [],
})
}
})
@ -301,11 +306,29 @@ export async function enrichSectionsWithVariants(sections) {
exercise_club_id: c.club_id,
exercise_created_by: c.created_by,
exercise_status: c.status,
...(isCombo ? { combination_slots: c.combination_slots || [] } : {}),
}
}),
}))
}
/**
* Outline für CombinationMethodProfileEditor: proSlotZeiten nur sichtbar, wenn Stationen übergeben werden.
*/
export function comboSlotsOutlineForProfileEditor(combinationSlots) {
if (!Array.isArray(combinationSlots) || combinationSlots.length === 0) return null
const sorted = sortCombinationSlotsForDisplay(combinationSlots)
return sorted.map((s, i) => {
const rawIx = s.slot_index
const si =
rawIx === '' || rawIx == null ? null : typeof rawIx === 'number' ? rawIx : parseInt(String(rawIx), 10)
return {
slot_index: Number.isFinite(si) ? si : i,
title: (s.title != null ? String(s.title) : '').trim(),
}
})
}
export function parseMin(v) {
if (v === '' || v === null || v === undefined) return null
const n = parseInt(String(v), 10)