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(' · ')
|
||||
}
|
||||
|
||||
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 & 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}`}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: 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) {
|
||||
if (v === '' || v === null || v === undefined) return null
|
||||
const n = parseInt(String(v), 10)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user