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
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:
parent
13efce6e36
commit
805ad3c5a5
|
|
@ -52,7 +52,13 @@ function summarizeSlotProfilesRow(r) {
|
||||||
return bits.join(' · ')
|
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 slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
|
||||||
|
|
||||||
const candidateIds = useMemo(() => {
|
const candidateIds = useMemo(() => {
|
||||||
|
|
@ -153,7 +159,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
||||||
letterSpacing: '0.04em',
|
letterSpacing: '0.04em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Kombination · Stationen & Einzelübungen
|
{compactPlanningView ? 'Stationen & Einzelübungen (Katalog)' : 'Kombination · Stationen & Einzelübungen'}
|
||||||
</h3>
|
</h3>
|
||||||
{archDisplay ? (
|
{archDisplay ? (
|
||||||
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>
|
<p style={{ margin: '0 0 6px', fontSize: '0.95rem', fontWeight: 700 }}>
|
||||||
|
|
@ -165,11 +171,13 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{compactPlanningView ? null : (
|
||||||
<p style={{ margin: '0 0 14px', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.48 }}>
|
<p style={{ margin: '0 0 14px', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.48 }}>
|
||||||
{archetypeCoachHint(archeKey)}
|
{archetypeCoachHint(archeKey)}
|
||||||
</p>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
margin: '0 0 14px',
|
margin: '0 0 14px',
|
||||||
|
|
@ -275,6 +283,16 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
||||||
Übung #{cid}: {err}
|
Übung #{cid}: {err}
|
||||||
</p>
|
</p>
|
||||||
) : ex ? (
|
) : 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>
|
<p style={{ margin: '0 0 6px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
|
||||||
{ex.summary ? (
|
{ex.summary ? (
|
||||||
|
|
@ -312,6 +330,7 @@ export default function CombinationCoachSlots({ combinationSlots, methodArchetyp
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<p style={{ margin: 0, fontSize: '0.86rem', color: 'var(--text2)' }}>
|
<p style={{ margin: 0, fontSize: '0.86rem', color: 'var(--text2)' }}>
|
||||||
{candTitleFallback || `Übung #${cid}`}
|
{candTitleFallback || `Übung #${cid}`}
|
||||||
|
|
|
||||||
|
|
@ -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 { GripVertical, Pencil } from 'lucide-react'
|
||||||
import CombinationMethodProfileEditor from './CombinationMethodProfileEditor'
|
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 { combinationArchetypeLabel } from '../constants/combinationArchetypes'
|
||||||
import {
|
import {
|
||||||
|
comboSlotsOutlineForProfileEditor,
|
||||||
defaultSection,
|
defaultSection,
|
||||||
exerciseRow,
|
exerciseRow,
|
||||||
noteRow,
|
noteRow,
|
||||||
sectionPlannedMinutes,
|
sectionPlannedMinutes,
|
||||||
} from '../utils/trainingUnitSectionsForm'
|
} from '../utils/trainingUnitSectionsForm'
|
||||||
|
import api from '../utils/api'
|
||||||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
|
@ -307,6 +310,8 @@ export default function TrainingUnitSectionsEditor({
|
||||||
const [textEdit, setTextEdit] = useState(null)
|
const [textEdit, setTextEdit] = useState(null)
|
||||||
/** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
|
/** Kombi: Ablaufprofil in Modal statt einzuklappender Karte */
|
||||||
const [comboPlanningModal, setComboPlanningModal] = useState(null)
|
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) */
|
/** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
|
||||||
const [insertChooser, setInsertChooser] = useState(null)
|
const [insertChooser, setInsertChooser] = useState(null)
|
||||||
const [draggingPos, setDraggingPos] = useState(null)
|
const [draggingPos, setDraggingPos] = useState(null)
|
||||||
|
|
@ -354,6 +359,38 @@ export default function TrainingUnitSectionsEditor({
|
||||||
if (!ok) setComboPlanningModal(null)
|
if (!ok) setComboPlanningModal(null)
|
||||||
}, [sections, comboPlanningModal])
|
}, [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 closeInsertChooser = useCallback(() => setInsertChooser(null), [])
|
||||||
|
|
||||||
const insertSlotKeyPrefix =
|
const insertSlotKeyPrefix =
|
||||||
|
|
@ -571,10 +608,10 @@ export default function TrainingUnitSectionsEditor({
|
||||||
|
|
||||||
const list = ensure(sections)
|
const list = ensure(sections)
|
||||||
|
|
||||||
let comboPlanningModalItem = null
|
const comboPlanningModalDerived = useMemo(() => {
|
||||||
let comboPlanningModalSX = null
|
if (!comboPlanningModal) {
|
||||||
let comboPlanningModalIX = null
|
return { item: null, sIdx: null, iIdx: null }
|
||||||
if (comboPlanningModal) {
|
}
|
||||||
const { sIdx, iIdx } = comboPlanningModal
|
const { sIdx, iIdx } = comboPlanningModal
|
||||||
const cand = list[sIdx]?.items?.[iIdx]
|
const cand = list[sIdx]?.items?.[iIdx]
|
||||||
if (
|
if (
|
||||||
|
|
@ -582,11 +619,34 @@ export default function TrainingUnitSectionsEditor({
|
||||||
String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
String(cand.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||||||
cand.exercise_id
|
cand.exercise_id
|
||||||
) {
|
) {
|
||||||
comboPlanningModalItem = cand
|
return { item: cand, sIdx, iIdx }
|
||||||
comboPlanningModalSX = sIdx
|
|
||||||
comboPlanningModalIX = 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1412,6 +1472,43 @@ export default function TrainingUnitSectionsEditor({
|
||||||
Aus Katalog kopieren …
|
Aus Katalog kopieren …
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<CombinationMethodProfileEditor
|
||||||
plannerMode
|
plannerMode
|
||||||
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
methodArchetype={(comboPlanningModalItem.catalog_method_archetype || '').trim()}
|
||||||
|
|
@ -1429,6 +1526,7 @@ export default function TrainingUnitSectionsEditor({
|
||||||
/* Ungültiges JSON — Hinweis im Editor */
|
/* Ungültiges JSON — Hinweis im Editor */
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
comboSlotsOutline={comboPlanningSlotsOutline}
|
||||||
/>
|
/>
|
||||||
<div className="tu-textedit-actions" style={{ marginTop: '0.95rem', paddingTop: '0.25rem' }}>
|
<div className="tu-textedit-actions" style={{ marginTop: '0.95rem', paddingTop: '0.25rem' }}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import api from './api'
|
import api from './api'
|
||||||
|
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||||||
|
|
||||||
export function defaultSection(title = 'Hauptteil') {
|
export function defaultSection(title = 'Hauptteil') {
|
||||||
return { title, guidance_notes: '', items: [] }
|
return { title, guidance_notes: '', items: [] }
|
||||||
|
|
@ -116,6 +117,7 @@ export async function hydrateExercisePlanningRow(exercise) {
|
||||||
row.catalog_method_archetype =
|
row.catalog_method_archetype =
|
||||||
typeof full.method_archetype === 'string' ? full.method_archetype.trim() : ''
|
typeof full.method_archetype === 'string' ? full.method_archetype.trim() : ''
|
||||||
row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile)
|
row.catalog_method_profile = normalizeCatalogMethodProfile(full.method_profile)
|
||||||
|
row.combination_slots = Array.isArray(full.combination_slots) ? full.combination_slots : []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row.planning_method_profile = null
|
row.planning_method_profile = null
|
||||||
|
|
@ -245,9 +247,10 @@ export async function enrichSectionsWithVariants(sections) {
|
||||||
unique.map(async (id) => {
|
unique.map(async (id) => {
|
||||||
try {
|
try {
|
||||||
const ex = await api.getExercise(id)
|
const ex = await api.getExercise(id)
|
||||||
|
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
|
||||||
cache.set(id, {
|
cache.set(id, {
|
||||||
title: ex.title || '',
|
title: ex.title || '',
|
||||||
exercise_kind: String(ex.exercise_kind || 'simple').toLowerCase().trim(),
|
exercise_kind: ek,
|
||||||
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
||||||
visibility: ex.visibility || 'private',
|
visibility: ex.visibility || 'private',
|
||||||
club_id: ex.club_id ?? null,
|
club_id: ex.club_id ?? null,
|
||||||
|
|
@ -255,6 +258,7 @@ export async function enrichSectionsWithVariants(sections) {
|
||||||
status: ex.status || 'draft',
|
status: ex.status || 'draft',
|
||||||
method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
|
method_archetype: typeof ex.method_archetype === 'string' ? ex.method_archetype.trim() : '',
|
||||||
method_profile: normalizeCatalogMethodProfile(ex.method_profile),
|
method_profile: normalizeCatalogMethodProfile(ex.method_profile),
|
||||||
|
combination_slots: ek === 'combination' && Array.isArray(ex.combination_slots) ? ex.combination_slots : [],
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
cache.set(id, {
|
cache.set(id, {
|
||||||
|
|
@ -267,6 +271,7 @@ export async function enrichSectionsWithVariants(sections) {
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
method_archetype: '',
|
method_archetype: '',
|
||||||
method_profile: {},
|
method_profile: {},
|
||||||
|
combination_slots: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -301,11 +306,29 @@ export async function enrichSectionsWithVariants(sections) {
|
||||||
exercise_club_id: c.club_id,
|
exercise_club_id: c.club_id,
|
||||||
exercise_created_by: c.created_by,
|
exercise_created_by: c.created_by,
|
||||||
exercise_status: c.status,
|
exercise_status: c.status,
|
||||||
|
...(isCombo ? { combination_slots: c.combination_slots || [] } : {}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outline für CombinationMethodProfileEditor: pro‑Slot‑Zeiten 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) {
|
export function parseMin(v) {
|
||||||
if (v === '' || v === null || v === undefined) return null
|
if (v === '' || v === null || v === undefined) return null
|
||||||
const n = parseInt(String(v), 10)
|
const n = parseInt(String(v), 10)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user