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
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</div>
|
||||
) : 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) => {
|
||||
const planMin = sectionPlannedMinutes(sec)
|
||||
const itemCount = sec.items?.length ?? 0
|
||||
|
|
@ -784,6 +916,20 @@ export default function TrainingUnitSectionsEditor({
|
|||
Abschnitt entfernen
|
||||
</button>
|
||||
</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
|
||||
className="form-input"
|
||||
rows={2}
|
||||
|
|
@ -793,6 +939,25 @@ export default function TrainingUnitSectionsEditor({
|
|||
}
|
||||
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 && (
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||||
|
|
|
|||
|
|
@ -353,6 +353,7 @@ export default function TrainingPlanningUnitFormModal({
|
|||
onRequestExercisePick={onRequestExercisePick}
|
||||
onPeekExercise={onPeekExercise}
|
||||
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||||
enableParallelPhaseControls
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user