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). */