shinkan-jinkendo/frontend/src/utils/trainingUnitSectionsForm.js
Lars 805ad3c5a5
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
feat(training-unit-editor): enhance combination slots handling and UI improvements
- 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>
2026-05-13 09:58:59 +02:00

464 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 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)
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)
}