Parlellsession- Plan #35
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.138"
|
APP_VERSION = "0.8.139"
|
||||||
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.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",
|
"version": "0.8.138",
|
||||||
"date": "2026-05-14",
|
"date": "2026-05-14",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,18 @@ import { trainingVisibilityShortDE } from '../../utils/trainingPlanningPageHelpe
|
||||||
/**
|
/**
|
||||||
* Dialog: Trainingsmodul in die Abschnitte einer Einheit einfügen (Bibliothekskopie).
|
* 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({
|
export default function TrainingPlanningModuleApplyModal({
|
||||||
open,
|
open,
|
||||||
busy,
|
busy,
|
||||||
|
|
@ -101,7 +113,7 @@ export default function TrainingPlanningModuleApplyModal({
|
||||||
>
|
>
|
||||||
{(sections || []).map((s, i) => (
|
{(sections || []).map((s, i) => (
|
||||||
<option key={`sec-opt-u-${i}`} value={String(i)}>
|
<option key={`sec-opt-u-${i}`} value={String(i)}>
|
||||||
{(s.title || `Abschnitt ${i + 1}`).trim()}
|
{formatModuleApplySectionOptionLabel(s, i)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -148,7 +160,7 @@ export default function TrainingPlanningModuleApplyModal({
|
||||||
>
|
>
|
||||||
{(sections || []).map((s, i) => (
|
{(sections || []).map((s, i) => (
|
||||||
<option key={`sec-opt-${i}`} value={String(i)}>
|
<option key={`sec-opt-${i}`} value={String(i)}>
|
||||||
{(s.title || `Abschnitt ${i + 1}`).trim()}
|
{formatModuleApplySectionOptionLabel(s, i)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,12 @@ import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImp
|
||||||
import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
|
import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
|
||||||
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
||||||
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
||||||
/* Parallele Trainingsstreams (Breakout): Planungs-API bleibt flache sections/items bis Schema-Paket 1–2;
|
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
|
||||||
Payload-Aufbau → buildSectionsPayload (trainingUnitSectionsForm); Backend _replace_unit_sections. */
|
|
||||||
import {
|
import {
|
||||||
defaultSection,
|
defaultSection,
|
||||||
normalizeUnitToForm,
|
normalizeUnitToForm,
|
||||||
enrichSectionsWithVariants,
|
enrichSectionsWithVariants,
|
||||||
buildSectionsPayload,
|
buildPlanPayloadForSave,
|
||||||
hydrateExercisePlanningRow,
|
hydrateExercisePlanningRow,
|
||||||
insertTrainingModuleIntoPlanningSections,
|
insertTrainingModuleIntoPlanningSections,
|
||||||
} from '../../utils/trainingUnitSectionsForm'
|
} from '../../utils/trainingUnitSectionsForm'
|
||||||
|
|
@ -959,7 +958,7 @@ function TrainingPlanningPageRoot() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sectionsPayload = buildSectionsPayload(formData.sections)
|
const planPart = buildPlanPayloadForSave(formData.sections)
|
||||||
const payload = {
|
const payload = {
|
||||||
planned_date: formData.planned_date,
|
planned_date: formData.planned_date,
|
||||||
planned_time_start: formData.planned_time_start || null,
|
planned_time_start: formData.planned_time_start || null,
|
||||||
|
|
@ -972,7 +971,7 @@ function TrainingPlanningPageRoot() {
|
||||||
status: formData.status || 'planned',
|
status: formData.status || 'planned',
|
||||||
notes: formData.notes || null,
|
notes: formData.notes || null,
|
||||||
trainer_notes: formData.trainer_notes || null,
|
trainer_notes: formData.trainer_notes || null,
|
||||||
sections: sectionsPayload
|
...planPart,
|
||||||
}
|
}
|
||||||
if (editingUnit) {
|
if (editingUnit) {
|
||||||
payload.debrief_completed =
|
payload.debrief_completed =
|
||||||
|
|
|
||||||
|
|
@ -156,12 +156,13 @@ function parseOptionalSourceTrainingModuleIdForPayload(v) {
|
||||||
return Number.isFinite(n) && n >= 1 ? n : null
|
return Number.isFinite(n) && n >= 1 ? n : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeUnitToForm(fullUnit) {
|
function sortByOrderIndex(arr) {
|
||||||
if (fullUnit.sections && fullUnit.sections.length) {
|
return [...(arr || [])].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||||
return fullUnit.sections.map((sec) => ({
|
}
|
||||||
title: sec.title,
|
|
||||||
guidance_notes: sec.guidance_notes || '',
|
/** Katalog-Abschnitt (GET) → Editor-Zeilen inkl. Kombi/Modul-Meta — wird von Legacy `sections` und von `phases` genutzt. */
|
||||||
items: (sec.items || []).map((it) => {
|
function formItemsFromApiItems(items) {
|
||||||
|
return (items || []).map((it) => {
|
||||||
if (it.item_type === 'note') {
|
if (it.item_type === 'note') {
|
||||||
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||||
const rowNote = {
|
const rowNote = {
|
||||||
|
|
@ -214,7 +215,73 @@ export function normalizeUnitToForm(fullUnit) {
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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: formItemsFromApiItems(sec.items),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (fullUnit.exercises && fullUnit.exercises.length) {
|
if (fullUnit.exercises && fullUnit.exercises.length) {
|
||||||
|
|
@ -398,10 +465,9 @@ export function parseMin(v) {
|
||||||
return Number.isFinite(n) ? n : null
|
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 buildOneSectionPayload(sec, orderIndex) {
|
||||||
export function buildSectionsPayload(sections) {
|
return {
|
||||||
return sections.map((sec, si) => ({
|
order_index: orderIndex,
|
||||||
order_index: si,
|
|
||||||
title: (sec.title || '').trim() || 'Abschnitt',
|
title: (sec.title || '').trim() || 'Abschnitt',
|
||||||
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
||||||
items: (sec.items || [])
|
items: (sec.items || [])
|
||||||
|
|
@ -446,7 +512,110 @@ export function buildSectionsPayload(sections) {
|
||||||
return rowEx
|
return rowEx
|
||||||
})
|
})
|
||||||
.filter(Boolean),
|
.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). */
|
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user