diff --git a/backend/version.py b/backend/version.py index e4fa0a1..aaf4f4d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.139" +APP_VERSION = "0.8.140" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260515063" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.140", + "date": "2026-05-14", + "changes": [ + "Frontend Trainingsplanung: Breakout-Panel (neue Ganzgruppen-/parallele Phase, Stream in letzter parallelen Phase); pro Abschnitt Zuordnung zu Phase/Stream oder klassischer Ein-Ganzgruppen-Ablauf.", + ], + }, { "version": "0.8.139", "date": "2026-05-14", diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 7ac7580..6577d1b 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -8,6 +8,11 @@ import { cloneJsonSerializablePlanningProfile, comboSlotsOutlineForProfileEditor, defaultSection, + defaultPlanLocWholeGroup, + defaultPlanLocParallel, + maxPhaseOrderIndexFromSections, + buildPlanTargetOptions, + planLocKey, exerciseRow, noteRow, sectionPlannedMinutes, @@ -16,6 +21,28 @@ import api from '../utils/api' import { isCompactTagLegendMode } from '../config/planningModuleUx' import { useAuth } from '../context/AuthContext' +function stripPlanLocFromSection(s) { + if (!s || typeof s !== 'object') return s + const { planLoc: _ignored, ...rest } = s + return rest +} + +function planSelectOptionsForSection(sections, sIdx, baseOpts) { + const sec = sections[sIdx] + const k = planLocKey(sec?.planLoc) + if (k && !baseOpts.some((o) => o.key === k)) { + const pl = sec.planLoc + const label = + pl.phaseKind === 'parallel' + ? `Parallel · Phase ${pl.phaseOrderIndex ?? 0} · Stream ${pl.parallelStreamOrderIndex ?? 0}` + : `Ganzgruppe · Phase ${pl.phaseOrderIndex ?? 0}` + return [...baseOpts, { key: k, label, template: { ...pl } }].sort((a, b) => + a.key.localeCompare(b.key, undefined, { numeric: true }) + ) + } + return baseOpts +} + const DND_TU_ITEM = 'application/x-shinkan-training-unit-item' const DND_TU_SECTION = 'application/x-shinkan-training-section-v1' @@ -192,6 +219,8 @@ export default function TrainingUnitSectionsEditor({ onMoveSectionsAcrossSlots = null, /** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */ betweenInsertMenus = true, + /** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */ + enableParallelPhaseControls = false, }) { const { user } = useAuth() const planningCompactLegend = isCompactTagLegendMode( @@ -218,7 +247,63 @@ export default function TrainingUnitSectionsEditor({ } const addSection = () => { - patch((prev) => [...prev, defaultSection(`Abschnitt ${prev.length + 1}`)]) + patch((prev) => { + const base = defaultSection(`Abschnitt ${prev.length + 1}`) + const last = prev[prev.length - 1] + const next = last?.planLoc ? { ...base, planLoc: { ...last.planLoc } } : base + return [...prev, next] + }) + } + + const addWholeGroupPhase = () => { + patch((prev) => { + const nextPo = maxPhaseOrderIndexFromSections(prev) + 1 + const pl = defaultPlanLocWholeGroup(nextPo) + const base = defaultSection(`Abschnitt ${prev.length + 1}`) + return [...prev, { ...base, planLoc: pl }] + }) + } + + const addParallelPhase = () => { + patch((prev) => { + const nextPo = maxPhaseOrderIndexFromSections(prev) + 1 + const pl = defaultPlanLocParallel(nextPo, 0) + const base = defaultSection(`Abschnitt ${prev.length + 1}`) + return [...prev, { ...base, planLoc: pl }] + }) + } + + const addStreamToLastParallelPhase = () => { + patch((prev) => { + const par = (prev || []).filter((s) => s?.planLoc?.phaseKind === 'parallel') + if (!par.length) return prev + const maxP = Math.max(...par.map((s) => s.planLoc.phaseOrderIndex ?? 0)) + const inPhase = par.filter((s) => (s.planLoc.phaseOrderIndex ?? 0) === maxP) + const maxS = Math.max(...inPhase.map((s) => s.planLoc.parallelStreamOrderIndex ?? 0)) + const newSo = maxS + 1 + const tmpl = { + ...inPhase[0].planLoc, + parallelStreamOrderIndex: newSo, + streamTitle: null, + streamNotes: null, + streamAssignedTrainerProfileIds: null, + } + const base = defaultSection(`Abschnitt ${prev.length + 1}`) + return [...prev, { ...base, planLoc: tmpl }] + }) + } + + const applySectionPlanTarget = (sIdx, rawKey) => { + patch((prev) => { + if (!rawKey) { + return prev.map((s, i) => (i === sIdx ? stripPlanLocFromSection(s) : s)) + } + const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev)) + const hit = opts.find((o) => o.key === rawKey) + if (!hit) return prev + const tpl = { ...hit.template } + return prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s)) + }) } const removeSection = (sIdx) => { @@ -604,6 +689,11 @@ export default function TrainingUnitSectionsEditor({ const list = ensure(sections) + const hasParallelPhase = useMemo( + () => list.some((s) => s?.planLoc?.phaseKind === 'parallel'), + [list] + ) + const comboPlanningModalDerived = useMemo(() => { if (!comboPlanningModal) { return { item: null, sIdx: null, iIdx: null } @@ -686,6 +776,48 @@ export default function TrainingUnitSectionsEditor({ ) : null} ) : null} + {enableParallelPhaseControls ? ( +
+
+ Breakout: Phasen und parallele Streams +
+

+ Legt fest, ob Abschnitte zur ganzen Gruppe oder zu parallelen Gruppen gehören. „Abschnitt hinzufügen“ unten + übernimmt die Zuordnung des letzten Abschnitts. Speichern erzeugt bei Bedarf automatisch den{' '} + phases-Payload fürs Backend. +

+
+ + + +
+
+ ) : null} {list.map((sec, sIdx) => { const planMin = sectionPlannedMinutes(sec) const itemCount = sec.items?.length ?? 0 @@ -784,6 +916,20 @@ export default function TrainingUnitSectionsEditor({ Abschnitt entfernen + {enableParallelPhaseControls && sec.planLoc ? ( +

+ {sec.planLoc.phaseKind === 'whole_group' + ? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}` + : `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`} +

+ ) : null}