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>
464 lines
17 KiB
JavaScript
464 lines
17 KiB
JavaScript
import api from './api'
|
||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||
|
||
export function defaultSection(title = 'Hauptteil') {
|
||
return { title, guidance_notes: '', items: [] }
|
||
}
|
||
|
||
function normalizeCatalogMethodProfile(cp) {
|
||
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
|
||
return {}
|
||
}
|
||
|
||
/** NULL = Planung folgt Katalogprofil der Übung */
|
||
function normalizePlanningMethodProfile(pm) {
|
||
if (pm == null) return null
|
||
if (typeof pm === 'object' && !Array.isArray(pm)) return { ...pm }
|
||
return null
|
||
}
|
||
|
||
export function exerciseRow() {
|
||
return {
|
||
item_type: 'exercise',
|
||
exercise_id: '',
|
||
exercise_variant_id: '',
|
||
exercise_kind: 'simple',
|
||
exercise_title: '',
|
||
variants: [],
|
||
planned_duration_min: '',
|
||
actual_duration_min: '',
|
||
notes: '',
|
||
modifications: '',
|
||
source_training_module_id: '',
|
||
source_module_title: '',
|
||
catalog_method_archetype: '',
|
||
catalog_method_profile: {},
|
||
planning_method_profile: null,
|
||
}
|
||
}
|
||
|
||
export async function hydrateExercisePlanningRow(exercise) {
|
||
let variants = Array.isArray(exercise?.variants) ? exercise.variants : []
|
||
let title = exercise?.title || ''
|
||
let exerciseKind = exercise?.exercise_kind
|
||
const id = exercise?.id
|
||
if (!id) return null
|
||
let meta = {}
|
||
|
||
let full
|
||
|
||
async function ensureFull() {
|
||
if (full !== undefined) return full
|
||
try {
|
||
full = await api.getExercise(id)
|
||
} catch {
|
||
full = null
|
||
}
|
||
return full
|
||
}
|
||
|
||
if (!variants.length) {
|
||
await ensureFull()
|
||
if (full) {
|
||
variants = Array.isArray(full.variants) ? full.variants : []
|
||
title = full.title || title
|
||
if (exerciseKind == null) exerciseKind = full.exercise_kind
|
||
meta = {
|
||
exercise_visibility: full.visibility || 'private',
|
||
exercise_club_id: full.club_id ?? null,
|
||
exercise_created_by: full.created_by ?? null,
|
||
exercise_status: full.status || 'draft',
|
||
catalog_method_archetype: typeof full.method_archetype === 'string' ? full.method_archetype.trim() : '',
|
||
catalog_method_profile: normalizeCatalogMethodProfile(full.method_profile),
|
||
}
|
||
}
|
||
} else {
|
||
meta = {
|
||
exercise_visibility: exercise?.visibility ?? null,
|
||
exercise_club_id: exercise?.club_id ?? null,
|
||
exercise_created_by: exercise?.created_by ?? null,
|
||
exercise_status: exercise?.status ?? null,
|
||
}
|
||
if (
|
||
meta.exercise_visibility == null ||
|
||
meta.exercise_created_by == null ||
|
||
exerciseKind == null
|
||
) {
|
||
await ensureFull()
|
||
if (full) {
|
||
if (meta.exercise_visibility == null) meta.exercise_visibility = full.visibility || 'private'
|
||
if (meta.exercise_club_id == null) meta.exercise_club_id = full.club_id ?? null
|
||
if (meta.exercise_created_by == null) meta.exercise_created_by = full.created_by ?? null
|
||
if (meta.exercise_status == null) meta.exercise_status = full.status || 'draft'
|
||
if (exerciseKind == null) exerciseKind = full.exercise_kind
|
||
if (!variants.length) variants = Array.isArray(full.variants) ? full.variants : []
|
||
}
|
||
}
|
||
meta.exercise_visibility = meta.exercise_visibility || 'private'
|
||
meta.exercise_status = meta.exercise_status || 'draft'
|
||
}
|
||
|
||
const row = exerciseRow()
|
||
row.exercise_id = id
|
||
row.exercise_variant_id = ''
|
||
row.exercise_title = title
|
||
row.exercise_kind =
|
||
String(exerciseKind || 'simple').toLowerCase().trim() === 'combination' ? 'combination' : 'simple'
|
||
if (row.exercise_kind === 'combination') {
|
||
row.variants = []
|
||
row.exercise_variant_id = ''
|
||
} else {
|
||
row.variants = variants
|
||
}
|
||
Object.assign(row, meta)
|
||
if (row.exercise_kind === 'combination') {
|
||
if (full === undefined) await ensureFull()
|
||
if (full) {
|
||
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
|
||
return row
|
||
}
|
||
|
||
export function noteRow() {
|
||
return { item_type: 'note', note_body: '', source_training_module_id: '', source_module_title: '' }
|
||
}
|
||
|
||
/** Zur Serialisierung in die Planungs-API (persistente Modul-Herkunft). */
|
||
function parseOptionalSourceTrainingModuleIdForPayload(v) {
|
||
if (v === null || v === undefined || v === '') return null
|
||
const n = typeof v === 'number' ? v : parseInt(String(v).trim(), 10)
|
||
return Number.isFinite(n) && n >= 1 ? n : null
|
||
}
|
||
|
||
export function normalizeUnitToForm(fullUnit) {
|
||
if (fullUnit.sections && fullUnit.sections.length) {
|
||
return fullUnit.sections.map((sec) => ({
|
||
title: sec.title,
|
||
guidance_notes: sec.guidance_notes || '',
|
||
items: (sec.items || []).map((it) => {
|
||
if (it.item_type === 'note') {
|
||
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||
const rowNote = {
|
||
item_type: 'note',
|
||
note_body: it.note_body || '',
|
||
source_training_module_id: '',
|
||
source_module_title: '',
|
||
}
|
||
if (sm != null) {
|
||
rowNote.source_training_module_id = sm
|
||
rowNote.source_module_title = (
|
||
it.source_module_title ||
|
||
it.source_training_module_title ||
|
||
''
|
||
).trim()
|
||
}
|
||
return rowNote
|
||
}
|
||
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
||
const isCombo = ek === 'combination'
|
||
return {
|
||
item_type: 'exercise',
|
||
exercise_id: it.exercise_id,
|
||
exercise_kind: isCombo ? 'combination' : 'simple',
|
||
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
|
||
exercise_title: it.exercise_title || '',
|
||
variants: [],
|
||
planned_duration_min:
|
||
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
||
? String(it.planned_duration_min)
|
||
: '',
|
||
actual_duration_min:
|
||
it.actual_duration_min !== null && it.actual_duration_min !== undefined
|
||
? String(it.actual_duration_min)
|
||
: '',
|
||
notes: it.notes ?? '',
|
||
modifications: it.modifications ?? '',
|
||
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
|
||
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
|
||
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
|
||
...(smEx != null
|
||
? {
|
||
source_training_module_id: smEx,
|
||
source_module_title: (
|
||
it.source_module_title ||
|
||
it.source_training_module_title ||
|
||
''
|
||
).trim(),
|
||
}
|
||
: {}),
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
if (fullUnit.exercises && fullUnit.exercises.length) {
|
||
return [
|
||
{
|
||
title: 'Übungen',
|
||
guidance_notes: '',
|
||
items: fullUnit.exercises.map((ex) => {
|
||
const ek = String(ex.exercise_kind || 'simple').toLowerCase().trim()
|
||
const isCombo = ek === 'combination'
|
||
return {
|
||
item_type: 'exercise',
|
||
exercise_kind: ek,
|
||
exercise_id: ex.exercise_id,
|
||
exercise_variant_id: isCombo ? '' : (ex.exercise_variant_id ?? ''),
|
||
exercise_title: ex.exercise_title || '',
|
||
variants: [],
|
||
planned_duration_min:
|
||
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
|
||
? String(ex.planned_duration_min)
|
||
: '',
|
||
actual_duration_min:
|
||
ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
|
||
? String(ex.actual_duration_min)
|
||
: '',
|
||
notes: ex.notes ?? '',
|
||
modifications: ex.modifications ?? '',
|
||
catalog_method_archetype: String(ex.catalog_method_archetype ?? '').trim(),
|
||
catalog_method_profile: normalizeCatalogMethodProfile(ex.catalog_method_profile),
|
||
planning_method_profile: normalizePlanningMethodProfile(ex.planning_method_profile),
|
||
}
|
||
}),
|
||
},
|
||
]
|
||
}
|
||
return [defaultSection()]
|
||
}
|
||
|
||
export async function enrichSectionsWithVariants(sections) {
|
||
if (!sections?.length) return sections
|
||
const ids = []
|
||
for (const sec of sections) {
|
||
for (const it of sec.items || []) {
|
||
if (it.item_type === 'note') continue
|
||
if (it.exercise_id) ids.push(it.exercise_id)
|
||
}
|
||
}
|
||
const unique = [...new Set(ids)]
|
||
const cache = new Map()
|
||
await Promise.all(
|
||
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: ek,
|
||
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
||
visibility: ex.visibility || 'private',
|
||
club_id: ex.club_id ?? null,
|
||
created_by: ex.created_by ?? null,
|
||
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, {
|
||
title: '',
|
||
exercise_kind: 'simple',
|
||
variants: [],
|
||
visibility: 'private',
|
||
club_id: null,
|
||
created_by: null,
|
||
status: 'draft',
|
||
method_archetype: '',
|
||
method_profile: {},
|
||
combination_slots: [],
|
||
})
|
||
}
|
||
})
|
||
)
|
||
return sections.map((sec) => ({
|
||
...sec,
|
||
items: (sec.items || []).map((it) => {
|
||
if (it.item_type === 'note') return it
|
||
if (!it.exercise_id) return it
|
||
const c = cache.get(it.exercise_id)
|
||
if (!c) return it
|
||
const ek = String(c.exercise_kind || 'simple').toLowerCase().trim()
|
||
const isCombo = ek === 'combination'
|
||
const itemCatalog = normalizeCatalogMethodProfile(it.catalog_method_profile)
|
||
const catalog_method_profile =
|
||
Object.keys(itemCatalog).length > 0
|
||
? itemCatalog
|
||
: normalizeCatalogMethodProfile(c.method_profile)
|
||
const rowArche = String(it.catalog_method_archetype ?? '').trim()
|
||
const catalog_method_archetype = rowArche || String(c.method_archetype ?? '').trim()
|
||
return {
|
||
...it,
|
||
catalog_method_archetype,
|
||
catalog_method_profile,
|
||
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
|
||
exercise_kind: isCombo ? 'combination' : 'simple',
|
||
exercise_title: it.exercise_title || c.title,
|
||
exercise_variant_id: isCombo ? '' : it.exercise_variant_id,
|
||
variants:
|
||
isCombo ? [] : Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
|
||
exercise_visibility: c.visibility,
|
||
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)
|
||
return Number.isFinite(n) ? n : null
|
||
}
|
||
|
||
export function buildSectionsPayload(sections) {
|
||
return sections.map((sec, si) => ({
|
||
order_index: si,
|
||
title: (sec.title || '').trim() || 'Abschnitt',
|
||
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
||
items: (sec.items || [])
|
||
.map((it, ii) => {
|
||
if (it.item_type === 'note') {
|
||
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||
const row = {
|
||
item_type: 'note',
|
||
order_index: ii,
|
||
note_body: it.note_body ?? '',
|
||
}
|
||
if (sm != null) row.source_training_module_id = sm
|
||
return row
|
||
}
|
||
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
|
||
return null
|
||
}
|
||
const isCombo = String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||
const vid = isCombo ? null : it.exercise_variant_id
|
||
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||
const rowEx = {
|
||
item_type: 'exercise',
|
||
order_index: ii,
|
||
exercise_id: parseInt(it.exercise_id, 10),
|
||
exercise_variant_id:
|
||
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
|
||
planned_duration_min: parseMin(it.planned_duration_min),
|
||
actual_duration_min: parseMin(it.actual_duration_min),
|
||
notes: it.notes?.trim() ? it.notes.trim() : null,
|
||
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
|
||
}
|
||
if (isCombo) {
|
||
const pmp = it.planning_method_profile
|
||
if (pmp != null && typeof pmp === 'object' && !Array.isArray(pmp)) {
|
||
rowEx.planning_method_profile = { ...pmp }
|
||
}
|
||
}
|
||
if (smEx != null) rowEx.source_training_module_id = smEx
|
||
return rowEx
|
||
})
|
||
.filter(Boolean),
|
||
}))
|
||
}
|
||
|
||
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
||
export async function insertTrainingModuleIntoPlanningSections({
|
||
sections,
|
||
moduleDetail,
|
||
sectionIndex,
|
||
insertBeforeItemIndex,
|
||
}) {
|
||
const secIx = typeof sectionIndex === 'number' ? sectionIndex : parseInt(String(sectionIndex), 10)
|
||
if (
|
||
!Array.isArray(sections) ||
|
||
!Number.isFinite(secIx) ||
|
||
secIx < 0 ||
|
||
secIx >= sections.length ||
|
||
!moduleDetail ||
|
||
typeof moduleDetail !== 'object'
|
||
) {
|
||
return sections
|
||
}
|
||
const prev = [...(sections[secIx].items || [])]
|
||
let beforeIx
|
||
if (insertBeforeItemIndex === null || insertBeforeItemIndex === undefined || insertBeforeItemIndex === 'end') {
|
||
beforeIx = prev.length
|
||
} else if (insertBeforeItemIndex === 'start') {
|
||
beforeIx = 0
|
||
} else {
|
||
const n = typeof insertBeforeItemIndex === 'number' ? insertBeforeItemIndex : parseInt(String(insertBeforeItemIndex), 10)
|
||
beforeIx = Number.isFinite(n) ? Math.min(Math.max(n, 0), prev.length) : prev.length
|
||
}
|
||
|
||
const midRaw = moduleDetail.id
|
||
const midNum = typeof midRaw === 'number' ? midRaw : parseInt(String(midRaw), 10)
|
||
if (!Number.isFinite(midNum) || midNum < 1) return sections
|
||
|
||
const modTitle = (moduleDetail.title || '').trim() || `Modul #${midNum}`
|
||
const modItems = [...(moduleDetail.items || [])].sort(
|
||
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
|
||
)
|
||
|
||
const appendRows = []
|
||
for (const mi of modItems) {
|
||
if (mi.item_type === 'note') {
|
||
appendRows.push({
|
||
item_type: 'note',
|
||
note_body: mi.note_body || '',
|
||
source_training_module_id: midNum,
|
||
source_module_title: modTitle,
|
||
})
|
||
continue
|
||
}
|
||
if (!mi.exercise_id) continue
|
||
const hydrated = await hydrateExercisePlanningRow({ id: mi.exercise_id })
|
||
if (!hydrated) continue
|
||
hydrated.source_training_module_id = midNum
|
||
hydrated.source_module_title = modTitle
|
||
if (
|
||
hydrated.exercise_kind !== 'combination' &&
|
||
mi.exercise_variant_id
|
||
) {
|
||
hydrated.exercise_variant_id = String(mi.exercise_variant_id)
|
||
}
|
||
hydrated.planned_duration_min =
|
||
mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
|
||
? String(mi.planned_duration_min)
|
||
: ''
|
||
hydrated.notes = mi.notes ?? ''
|
||
appendRows.push(hydrated)
|
||
}
|
||
|
||
const mergedItems = [...prev.slice(0, beforeIx), ...appendRows, ...prev.slice(beforeIx)]
|
||
return sections.map((sec, idx) => (idx === secIx ? { ...sec, items: mergedItems } : sec))
|
||
}
|
||
|
||
export function sectionPlannedMinutes(sec) {
|
||
return (sec.items || []).reduce((sum, it) => {
|
||
if (it.item_type !== 'exercise') return sum
|
||
const m = parseMin(it.planned_duration_min)
|
||
return sum + (m || 0)
|
||
}, 0)
|
||
}
|