diff --git a/backend/version.py b/backend/version.py index f4798f4..e4fa0a1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.138" +APP_VERSION = "0.8.139" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260515063" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.139", + "date": "2026-05-14", + "changes": [ + "Frontend Trainingsplanung: GET phases → Editor mit planLoc pro Abschnitt; Speichern sendet PUT phases bei Breakout-Einheiten (sonst weiter sections); Modul-Dialog zeigt Phase/Stream in der Abschnittsauswahl.", + ], + }, { "version": "0.8.138", "date": "2026-05-14", diff --git a/frontend/src/components/planning/TrainingPlanningModuleApplyModal.jsx b/frontend/src/components/planning/TrainingPlanningModuleApplyModal.jsx index 8d9ae91..61838b7 100644 --- a/frontend/src/components/planning/TrainingPlanningModuleApplyModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningModuleApplyModal.jsx @@ -5,6 +5,18 @@ import { trainingVisibilityShortDE } from '../../utils/trainingPlanningPageHelpe /** * Dialog: Trainingsmodul in die Abschnitte einer Einheit einfügen (Bibliothekskopie). */ + +function formatModuleApplySectionOptionLabel(section, flatIndex) { + const title = (section?.title || '').trim() || `Abschnitt ${flatIndex + 1}` + const pl = section?.planLoc + if (pl?.phaseKind === 'parallel') { + const st = (pl.streamTitle || '').trim() + const streamHint = st ? ` — ${st}` : '' + return `${title}${streamHint} (Phase ${pl.phaseOrderIndex ?? 0}, Stream ${pl.parallelStreamOrderIndex ?? 0})` + } + return title +} + export default function TrainingPlanningModuleApplyModal({ open, busy, @@ -101,7 +113,7 @@ export default function TrainingPlanningModuleApplyModal({ > {(sections || []).map((s, i) => ( ))} @@ -148,7 +160,7 @@ export default function TrainingPlanningModuleApplyModal({ > {(sections || []).map((s, i) => ( ))} diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index 9f85400..1ce61ec 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -11,13 +11,12 @@ import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImp import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal' import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal' import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal' -/* Parallele Trainingsstreams (Breakout): Planungs-API bleibt flache sections/items bis Schema-Paket 1–2; - Payload-Aufbau → buildSectionsPayload (trainingUnitSectionsForm); Backend _replace_unit_sections. */ +/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */ import { defaultSection, normalizeUnitToForm, enrichSectionsWithVariants, - buildSectionsPayload, + buildPlanPayloadForSave, hydrateExercisePlanningRow, insertTrainingModuleIntoPlanningSections, } from '../../utils/trainingUnitSectionsForm' @@ -959,7 +958,7 @@ function TrainingPlanningPageRoot() { return } try { - const sectionsPayload = buildSectionsPayload(formData.sections) + const planPart = buildPlanPayloadForSave(formData.sections) const payload = { planned_date: formData.planned_date, planned_time_start: formData.planned_time_start || null, @@ -972,7 +971,7 @@ function TrainingPlanningPageRoot() { status: formData.status || 'planned', notes: formData.notes || null, trainer_notes: formData.trainer_notes || null, - sections: sectionsPayload + ...planPart, } if (editingUnit) { payload.debrief_completed = diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index e6105d6..2f44e16 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -156,65 +156,132 @@ function parseOptionalSourceTrainingModuleIdForPayload(v) { return Number.isFinite(n) && n >= 1 ? n : null } +function sortByOrderIndex(arr) { + return [...(arr || [])].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) +} + +/** Katalog-Abschnitt (GET) → Editor-Zeilen inkl. Kombi/Modul-Meta — wird von Legacy `sections` und von `phases` genutzt. */ +function formItemsFromApiItems(items) { + return (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(), + } + : {}), + } + }) +} + +/** GET `phases` → flache Editor-Abschnitte mit `planLoc` (für PUT `phases` Roundtrip). */ +function normalizePhasesToFormSections(fullUnit) { + const phases = sortByOrderIndex(fullUnit.phases || []) + const out = [] + for (const ph of phases) { + const pOi = ph.order_index ?? 0 + const pk = String(ph.phase_kind || 'whole_group').toLowerCase().trim() + const basePhaseLoc = { + phaseTitle: ph.title ?? null, + phaseGuidanceNotes: ph.guidance_notes ?? null, + } + if (pk === 'parallel') { + for (const st of sortByOrderIndex(ph.streams || [])) { + const sOi = st.order_index ?? 0 + const streamLoc = { + phaseKind: 'parallel', + phaseOrderIndex: pOi, + parallelStreamOrderIndex: sOi, + ...basePhaseLoc, + streamTitle: st.title ?? null, + streamNotes: st.notes ?? null, + streamAssignedTrainerProfileIds: st.assigned_trainer_profile_ids ?? null, + } + for (const sec of sortByOrderIndex(st.sections || [])) { + out.push({ + title: sec.title, + guidance_notes: sec.guidance_notes || '', + items: formItemsFromApiItems(sec.items), + planLoc: { ...streamLoc }, + }) + } + } + } else { + const loc = { + phaseKind: 'whole_group', + phaseOrderIndex: pOi, + parallelStreamOrderIndex: null, + ...basePhaseLoc, + streamTitle: null, + streamNotes: null, + streamAssignedTrainerProfileIds: null, + } + for (const sec of sortByOrderIndex(ph.sections || [])) { + out.push({ + title: sec.title, + guidance_notes: sec.guidance_notes || '', + items: formItemsFromApiItems(sec.items), + planLoc: { ...loc }, + }) + } + } + } + return out.length ? out : [defaultSection()] +} + export function normalizeUnitToForm(fullUnit) { + if (Array.isArray(fullUnit?.phases) && fullUnit.phases.length > 0) { + return normalizePhasesToFormSections(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(), - } - : {}), - } - }), + items: formItemsFromApiItems(sec.items), })) } if (fullUnit.exercises && fullUnit.exercises.length) { @@ -398,10 +465,9 @@ export function parseMin(v) { return Number.isFinite(n) ? n : null } -/** PUT /api/training-units/:id `sections` — flache order_index pro Einheit; Parallelität bricht am DB-UNIQUE (training_unit_id, order_index) bis Migration. */ -export function buildSectionsPayload(sections) { - return sections.map((sec, si) => ({ - order_index: si, +export function buildOneSectionPayload(sec, orderIndex) { + return { + order_index: orderIndex, title: (sec.title || '').trim() || 'Abschnitt', guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null, items: (sec.items || []) @@ -446,7 +512,110 @@ export function buildSectionsPayload(sections) { return rowEx }) .filter(Boolean), - })) + } +} + +/** PUT /api/training-units: Legacy `sections` (eine whole_group-Phase) wenn kein `planLoc` gesetzt ist. */ +export function buildSectionsPayload(sections) { + return sections.map((sec, si) => buildOneSectionPayload(sec, si)) +} + +function stripPlanLoc(sec) { + if (!sec || typeof sec !== 'object') return sec + const { planLoc: _pl, ...rest } = sec + return rest +} + +function inheritPlanLocForPhasedSave(sections) { + let prev = { + phaseKind: 'whole_group', + phaseOrderIndex: 0, + parallelStreamOrderIndex: null, + phaseTitle: null, + phaseGuidanceNotes: null, + streamTitle: null, + streamNotes: null, + streamAssignedTrainerProfileIds: null, + } + return sections.map((s) => { + if (s?.planLoc && s.planLoc.phaseKind) { + prev = { ...s.planLoc } + return { ...s, planLoc: prev } + } + return { ...s, planLoc: { ...prev } } + }) +} + +function buildPhasesPayloadFromFlat(sections) { + const norm = inheritPlanLocForPhasedSave(sections) + const phases = [] + let i = 0 + while (i < norm.length) { + const loc0 = norm[i].planLoc + const pOi = loc0.phaseOrderIndex ?? 0 + const pk = loc0.phaseKind === 'parallel' ? 'parallel' : 'whole_group' + + const run = [] + while (i < norm.length) { + const L = norm[i].planLoc + const pk2 = L.phaseKind === 'parallel' ? 'parallel' : 'whole_group' + if ((L.phaseOrderIndex ?? 0) !== pOi || pk2 !== pk) break + run.push(norm[i]) + i += 1 + } + + const head = run[0].planLoc + if (pk === 'whole_group') { + phases.push({ + phase_kind: 'whole_group', + order_index: pOi, + title: head.phaseTitle ?? null, + guidance_notes: head.phaseGuidanceNotes ?? null, + sections: run.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)), + }) + } else { + const byStream = new Map() + for (const s of run) { + const soi = s.planLoc.parallelStreamOrderIndex ?? 0 + if (!byStream.has(soi)) byStream.set(soi, []) + byStream.get(soi).push(s) + } + const streamOrder = [...byStream.keys()].sort((a, b) => a - b) + const streams = streamOrder.map((soi) => { + const bucket = byStream.get(soi) + const h = bucket[0].planLoc + const st = { + order_index: soi, + title: h.streamTitle ?? null, + notes: h.streamNotes ?? null, + sections: bucket.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)), + } + const asst = h.streamAssignedTrainerProfileIds + if (asst !== null && asst !== undefined) st.assigned_trainer_profile_ids = asst + return st + }) + phases.push({ + phase_kind: 'parallel', + order_index: pOi, + title: head.phaseTitle ?? null, + guidance_notes: head.phaseGuidanceNotes ?? null, + streams, + }) + } + } + return { phases } +} + +/** + * Speichern einer Einheit: flache `sections` oder verschachtelte `phases`, sobald `planLoc` gesetzt ist. + */ +export function buildPlanPayloadForSave(sections) { + const list = Array.isArray(sections) ? sections : [] + const anyPhased = list.some((s) => s && s.planLoc && s.planLoc.phaseKind) + if (!anyPhased) { + return { sections: buildSectionsPayload(list) } + } + return buildPhasesPayloadFromFlat(list) } /** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */