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 ? ( +
+ 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.
+
+ {sec.planLoc.phaseKind === 'whole_group' + ? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}` + : `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`} +
+ ) : null} + {enableParallelPhaseControls ? ( +Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen) diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx index fb61f7e..eaec781 100644 --- a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx @@ -353,6 +353,7 @@ export default function TrainingPlanningUnitFormModal({ onRequestExercisePick={onRequestExercisePick} onPeekExercise={onPeekExercise} showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'} + enableParallelPhaseControls /> diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index 2f44e16..2177967 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -5,6 +5,87 @@ export function defaultSection(title = 'Hauptteil') { return { title, guidance_notes: '', items: [] } } +/** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */ +export function defaultPlanLocWholeGroup(phaseOrderIndex = 0) { + return { + phaseKind: 'whole_group', + phaseOrderIndex, + parallelStreamOrderIndex: null, + phaseTitle: null, + phaseGuidanceNotes: null, + streamTitle: null, + streamNotes: null, + streamAssignedTrainerProfileIds: null, + } +} + +/** Standard-`planLoc` für einen Stream innerhalb einer parallelen Phase. */ +export function defaultPlanLocParallel(phaseOrderIndex, streamOrderIndex) { + return { + phaseKind: 'parallel', + phaseOrderIndex, + parallelStreamOrderIndex: streamOrderIndex, + phaseTitle: null, + phaseGuidanceNotes: null, + streamTitle: null, + streamNotes: null, + streamAssignedTrainerProfileIds: null, + } +} + +export function planLocKey(pl) { + if (!pl || !pl.phaseKind) return '' + if (pl.phaseKind === 'whole_group') return `wg:${pl.phaseOrderIndex ?? 0}` + return `par:${pl.phaseOrderIndex ?? 0}:${pl.parallelStreamOrderIndex ?? 0}` +} + +export function maxPhaseOrderIndexFromSections(sections) { + let m = -1 + for (const s of sections || []) { + const pl = s?.planLoc + if (!pl || typeof pl.phaseOrderIndex !== 'number') continue + if (pl.phaseOrderIndex > m) m = pl.phaseOrderIndex + } + return m +} + +/** + * Eindeutige Ziele für die Zuordnung eines Abschnitts (Dropdown). + * `template` ist ein vollständiges planLoc-Objekt zum Kopieren. + */ +export function buildPlanTargetOptions(sections) { + const map = new Map() + for (const s of sections || []) { + const pl = s?.planLoc + if (!pl?.phaseKind) continue + if (pl.phaseKind === 'whole_group') { + const po = pl.phaseOrderIndex ?? 0 + const k = `wg:${po}` + if (!map.has(k)) { + const title = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : '' + map.set(k, { + key: k, + label: title || `Ganzgruppe · Phase ${po}`, + template: { ...pl }, + }) + } + } else { + const po = pl.phaseOrderIndex ?? 0 + const so = pl.parallelStreamOrderIndex ?? 0 + const k = `par:${po}:${so}` + if (!map.has(k)) { + const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : '' + map.set(k, { + key: k, + label: st || `Parallel · Phase ${po} · Stream ${so}`, + template: { ...pl }, + }) + } + } + } + return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true })) +} + function normalizeCatalogMethodProfile(cp) { if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp } return {}