chore(version): update version and changelog for release 0.8.140
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
- Bumped APP_VERSION to 0.8.140 and updated the changelog to reflect recent changes. - Enhanced the Training Planning Module with new controls for managing whole group and parallel phases, including the ability to add streams to existing parallel phases. - Introduced utility functions for handling phase and stream configurations, improving the overall structure and usability of the training unit sections editor. - Updated the TrainingPlanningUnitFormModal to support the new phase controls, ensuring seamless integration with the frontend components.
This commit is contained in:
parent
749c185e3d
commit
f50e9db523
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.139"
|
APP_VERSION = "0.8.140"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260515063"
|
DB_SCHEMA_VERSION = "20260515063"
|
||||||
|
|
||||||
|
|
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.139",
|
||||||
"date": "2026-05-14",
|
"date": "2026-05-14",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ import {
|
||||||
cloneJsonSerializablePlanningProfile,
|
cloneJsonSerializablePlanningProfile,
|
||||||
comboSlotsOutlineForProfileEditor,
|
comboSlotsOutlineForProfileEditor,
|
||||||
defaultSection,
|
defaultSection,
|
||||||
|
defaultPlanLocWholeGroup,
|
||||||
|
defaultPlanLocParallel,
|
||||||
|
maxPhaseOrderIndexFromSections,
|
||||||
|
buildPlanTargetOptions,
|
||||||
|
planLocKey,
|
||||||
exerciseRow,
|
exerciseRow,
|
||||||
noteRow,
|
noteRow,
|
||||||
sectionPlannedMinutes,
|
sectionPlannedMinutes,
|
||||||
|
|
@ -16,6 +21,28 @@ import api from '../utils/api'
|
||||||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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_ITEM = 'application/x-shinkan-training-unit-item'
|
||||||
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
||||||
|
|
||||||
|
|
@ -192,6 +219,8 @@ export default function TrainingUnitSectionsEditor({
|
||||||
onMoveSectionsAcrossSlots = null,
|
onMoveSectionsAcrossSlots = null,
|
||||||
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
|
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
|
||||||
betweenInsertMenus = true,
|
betweenInsertMenus = true,
|
||||||
|
/** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */
|
||||||
|
enableParallelPhaseControls = false,
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const planningCompactLegend = isCompactTagLegendMode(
|
const planningCompactLegend = isCompactTagLegendMode(
|
||||||
|
|
@ -218,7 +247,63 @@ export default function TrainingUnitSectionsEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
const addSection = () => {
|
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) => {
|
const removeSection = (sIdx) => {
|
||||||
|
|
@ -604,6 +689,11 @@ export default function TrainingUnitSectionsEditor({
|
||||||
|
|
||||||
const list = ensure(sections)
|
const list = ensure(sections)
|
||||||
|
|
||||||
|
const hasParallelPhase = useMemo(
|
||||||
|
() => list.some((s) => s?.planLoc?.phaseKind === 'parallel'),
|
||||||
|
[list]
|
||||||
|
)
|
||||||
|
|
||||||
const comboPlanningModalDerived = useMemo(() => {
|
const comboPlanningModalDerived = useMemo(() => {
|
||||||
if (!comboPlanningModal) {
|
if (!comboPlanningModal) {
|
||||||
return { item: null, sIdx: null, iIdx: null }
|
return { item: null, sIdx: null, iIdx: null }
|
||||||
|
|
@ -686,6 +776,48 @@ export default function TrainingUnitSectionsEditor({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{enableParallelPhaseControls ? (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
padding: '12px 14px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||||||
|
borderRadius: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '0.88rem', fontWeight: 600, marginBottom: '6px', color: 'var(--text1)' }}>
|
||||||
|
Breakout: Phasen und parallele Streams
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0 0 12px', fontSize: '0.8rem', color: 'var(--text2)', lineHeight: 1.5 }}>
|
||||||
|
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{' '}
|
||||||
|
<code style={{ fontSize: '0.78em' }}>phases</code>-Payload fürs Backend.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={addWholeGroupPhase}>
|
||||||
|
Neue Ganzgruppen-Phase
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={addParallelPhase}>
|
||||||
|
Neue parallele Phase
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={addStreamToLastParallelPhase}
|
||||||
|
disabled={!hasParallelPhase}
|
||||||
|
title={
|
||||||
|
hasParallelPhase
|
||||||
|
? 'Weiterer Stream in der letzten parallelen Phase (höchster Phasen-Index)'
|
||||||
|
: 'Zuerst eine parallele Phase anlegen'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Stream hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{list.map((sec, sIdx) => {
|
{list.map((sec, sIdx) => {
|
||||||
const planMin = sectionPlannedMinutes(sec)
|
const planMin = sectionPlannedMinutes(sec)
|
||||||
const itemCount = sec.items?.length ?? 0
|
const itemCount = sec.items?.length ?? 0
|
||||||
|
|
@ -784,6 +916,20 @@ export default function TrainingUnitSectionsEditor({
|
||||||
Abschnitt entfernen
|
Abschnitt entfernen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{enableParallelPhaseControls && sec.planLoc ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
margin: '0 0 8px',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sec.planLoc.phaseKind === 'whole_group'
|
||||||
|
? `Ganzgruppen-Phase ${sec.planLoc.phaseOrderIndex ?? 0}`
|
||||||
|
: `Parallel · Phase ${sec.planLoc.phaseOrderIndex ?? 0} · Stream ${sec.planLoc.parallelStreamOrderIndex ?? 0}`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<textarea
|
<textarea
|
||||||
className="form-input"
|
className="form-input"
|
||||||
rows={2}
|
rows={2}
|
||||||
|
|
@ -793,6 +939,25 @@ export default function TrainingUnitSectionsEditor({
|
||||||
}
|
}
|
||||||
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||||||
/>
|
/>
|
||||||
|
{enableParallelPhaseControls ? (
|
||||||
|
<div className="form-row" style={{ marginTop: '10px', marginBottom: '2px' }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
||||||
|
Zuordnung
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={planLocKey(sec.planLoc)}
|
||||||
|
onChange={(e) => applySectionPlanTarget(sIdx, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Standard — eine Ganzgruppe (klassischer Ablauf)</option>
|
||||||
|
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => (
|
||||||
|
<option key={o.key} value={o.key}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{planMin > 0 && (
|
{planMin > 0 && (
|
||||||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
onRequestExercisePick={onRequestExercisePick}
|
onRequestExercisePick={onRequestExercisePick}
|
||||||
onPeekExercise={onPeekExercise}
|
onPeekExercise={onPeekExercise}
|
||||||
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||||||
|
enableParallelPhaseControls
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,87 @@ export function defaultSection(title = 'Hauptteil') {
|
||||||
return { title, guidance_notes: '', items: [] }
|
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) {
|
function normalizeCatalogMethodProfile(cp) {
|
||||||
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
|
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
|
||||||
return {}
|
return {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user