Update version to 0.8.147 and add functionality to save exercises as training modules
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Has been cancelled
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Has been cancelled
- Incremented app version to 0.8.147 and updated changelog to reflect the new version. - Introduced a new modal for saving exercises as training modules within the training planning interface. - Enhanced the TrainingPlanningPageRoot component to manage the new save module functionality, including state management for the modal. - Updated the TrainingPlanningUnitFormModal to include an option for saving exercises as a module, improving user experience in training planning.
This commit is contained in:
parent
7693139242
commit
a51f794945
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.146"
|
||||
APP_VERSION = "0.8.147"
|
||||
BUILD_DATE = "2026-05-19"
|
||||
DB_SCHEMA_VERSION = "20260516065"
|
||||
|
||||
|
|
@ -37,7 +37,12 @@ MODULE_VERSIONS = {
|
|||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.146",
|
||||
"version": "0.8.147",
|
||||
"date": "2026-05-19",
|
||||
"changes": [
|
||||
"Planung: Liste – Rahmen-Session & Übungen→Modul; Dialog Modul aus Einheit; klarere Rahmen-Unit-ID aus Liste",
|
||||
],
|
||||
},
|
||||
"date": "2026-05-19",
|
||||
"changes": [
|
||||
"Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework",
|
||||
|
|
|
|||
326
frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
Normal file
326
frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../../utils/api'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { activeClubMemberships } from '../../utils/activeClub'
|
||||
import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit'
|
||||
|
||||
/**
|
||||
* Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl).
|
||||
*/
|
||||
export default function SaveExercisesAsModuleModal({
|
||||
open,
|
||||
onClose,
|
||||
unitId,
|
||||
planningModalClubId,
|
||||
onSuccess,
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { user } = useAuth()
|
||||
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
|
||||
const roleLc = String(user?.role || '').toLowerCase()
|
||||
const isSuperadmin = roleLc === 'superadmin'
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [unitLabel, setUnitLabel] = useState('')
|
||||
const [candidates, setCandidates] = useState([])
|
||||
const [selected, setSelected] = useState(() => [])
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [visibility, setVisibility] = useState('club')
|
||||
const [clubId, setClubId] = useState('')
|
||||
|
||||
const resetLocal = useCallback(() => {
|
||||
setLoadErr('')
|
||||
setUnitLabel('')
|
||||
setCandidates([])
|
||||
setSelected([])
|
||||
setTitle('')
|
||||
setVisibility('club')
|
||||
setClubId('')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !unitId) {
|
||||
resetLocal()
|
||||
return
|
||||
}
|
||||
if (planningModalClubId != null && planningModalClubId !== '') {
|
||||
setClubId(String(planningModalClubId))
|
||||
} else if (memberClubs.length === 1) {
|
||||
setClubId(String(memberClubs[0].id))
|
||||
}
|
||||
setLoading(true)
|
||||
setLoadErr('')
|
||||
api
|
||||
.getTrainingUnit(unitId)
|
||||
.then((u) => {
|
||||
const dateStr = (u.planned_date || '').trim() || 'Training'
|
||||
setUnitLabel(dateStr)
|
||||
setTitle(`Modul · ${dateStr}`)
|
||||
const c = collectExercisePlacementsForModule(u)
|
||||
setCandidates(c)
|
||||
setSelected(c.map(() => true))
|
||||
})
|
||||
.catch((e) => {
|
||||
setLoadErr(e.message || 'Einheit konnte nicht geladen werden')
|
||||
setCandidates([])
|
||||
setSelected([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, unitId, planningModalClubId, memberClubs.length, resetLocal])
|
||||
|
||||
const toggleOne = (idx) => {
|
||||
setSelected((prev) => {
|
||||
const next = [...prev]
|
||||
next[idx] = !next[idx]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const setAll = (on) => {
|
||||
setSelected(candidates.map(() => on))
|
||||
}
|
||||
|
||||
const selectedCount = selected.filter(Boolean).length
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!unitId || submitting) return
|
||||
const itemsPayload = []
|
||||
let oi = 0
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
if (!selected[i]) continue
|
||||
const c = candidates[i]
|
||||
itemsPayload.push({
|
||||
item_type: 'exercise',
|
||||
order_index: oi,
|
||||
exercise_id: c.exercise_id,
|
||||
exercise_variant_id: c.exercise_variant_id,
|
||||
planned_duration_min: c.planned_duration_min,
|
||||
notes: c.notes,
|
||||
})
|
||||
oi += 1
|
||||
}
|
||||
if (!itemsPayload.length) {
|
||||
toast.error('Mindestens eine Übung auswählen.')
|
||||
return
|
||||
}
|
||||
const tit = (title || '').trim()
|
||||
if (!tit) {
|
||||
toast.error('Bitte einen Modultitel angeben.')
|
||||
return
|
||||
}
|
||||
let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null
|
||||
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
|
||||
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
|
||||
return
|
||||
}
|
||||
if (visibility !== 'club') cid = null
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created = await api.createTrainingModule({
|
||||
title: tit,
|
||||
visibility,
|
||||
club_id: cid,
|
||||
items: itemsPayload,
|
||||
})
|
||||
toast.success('Trainingsmodul gespeichert.')
|
||||
if (created?.id) {
|
||||
navigate(`/planning/training-modules/${created.id}`)
|
||||
}
|
||||
onSuccess?.()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1100,
|
||||
padding: '1rem',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
maxWidth: 'min(560px, 100%)',
|
||||
width: '100%',
|
||||
padding: '1.25rem',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '0.65rem' }}>Übungen als Trainingsmodul</h2>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45, marginBottom: '1rem' }}>
|
||||
Es werden die <strong>gespeicherten</strong> Übungspositionen der Einheit vom{' '}
|
||||
<strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand
|
||||
brauchst.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text2)' }}>Laden …</p>
|
||||
) : loadErr ? (
|
||||
<p style={{ color: 'var(--danger)' }}>{loadErr}</p>
|
||||
) : candidates.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)' }}>In dieser Einheit sind keine Übungen im Ablauf hinterlegt.</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label className="form-label">Modultitel</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
placeholder="z. B. Aufwärmsequenz"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginBottom: '0.75rem' }}>
|
||||
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => setAll(true)}>
|
||||
Alle
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => setAll(false)}>
|
||||
Keine
|
||||
</button>
|
||||
<span style={{ fontSize: '0.82rem', color: 'var(--text3)', alignSelf: 'center' }}>
|
||||
{selectedCount} von {candidates.length} gewählt (Reihenfolge wie im Plan)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
padding: '10px 12px',
|
||||
maxHeight: 'min(240px, 40vh)',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{candidates.map((c, idx) => (
|
||||
<li
|
||||
key={`${c.exercise_id}-${idx}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
padding: '8px 0',
|
||||
borderTop: idx === 0 ? 'none' : '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selected[idx]}
|
||||
onChange={() => toggleOne(idx)}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text1)', fontSize: '0.92rem' }}>
|
||||
{c.exercise_title}
|
||||
</div>
|
||||
{c.contextLabel ? (
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: 2 }}>{c.contextLabel}</div>
|
||||
) : null}
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: 2 }}>
|
||||
{c.exercise_variant_id ? `Variante #${c.exercise_variant_id}` : 'Standard-Variante'}
|
||||
{c.planned_duration_min != null && Number.isFinite(Number(c.planned_duration_min))
|
||||
? ` · ${c.planned_duration_min} Min (Plan)`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={visibility}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setVisibility(v)
|
||||
if (v === 'club' && !clubId && planningModalClubId != null) {
|
||||
setClubId(String(planningModalClubId))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="private">Privat</option>
|
||||
<option value="club">Verein</option>
|
||||
{isSuperadmin ? <option value="official">Offiziell</option> : null}
|
||||
</select>
|
||||
</div>
|
||||
{visibility === 'club' ? (
|
||||
<div className="form-row" style={{ marginBottom: '1rem' }}>
|
||||
<label className="form-label">Verein</label>
|
||||
<select className="form-input" value={clubId} onChange={(e) => setClubId(e.target.value)}>
|
||||
<option value="">— Verein wählen —</option>
|
||||
{memberClubs.map((cl) => (
|
||||
<option key={cl.id} value={String(cl.id)}>
|
||||
{cl.name || `Verein #${cl.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={submitting}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting || !candidates.length}>
|
||||
{submitting ? 'Speichern…' : 'Modul anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && loadErr ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !loadErr && candidates.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal
|
|||
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
||||
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
||||
import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal'
|
||||
import SaveExercisesAsModuleModal from './SaveExercisesAsModuleModal'
|
||||
/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */
|
||||
import {
|
||||
defaultSection,
|
||||
|
|
@ -53,6 +54,10 @@ function TrainingPlanningPageRoot() {
|
|||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingUnit, setEditingUnit] = useState(null)
|
||||
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
|
||||
/** Einheit für „Rahmen-Session“-Dialog (Liste oder geöffnetes Bearbeiten) */
|
||||
const [publishFrameworkUnitId, setPublishFrameworkUnitId] = useState(null)
|
||||
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
|
||||
const [saveModuleUnitId, setSaveModuleUnitId] = useState(null)
|
||||
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
|
||||
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
|
||||
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
|
||||
|
|
@ -1853,6 +1858,28 @@ function TrainingPlanningPageRoot() {
|
|||
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
title="Gespeicherten Ablauf als Session eines Rahmenprogramms übernehmen"
|
||||
onClick={() => {
|
||||
setPublishFrameworkUnitId(unit.id)
|
||||
setPublishFrameworkOpen(true)
|
||||
}}
|
||||
>
|
||||
Rahmen-Session…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
title="Gespeicherte Übungen aus dieser Einheit als Trainingsmodul speichern"
|
||||
onClick={() => {
|
||||
setSaveModuleUnitId(unit.id)
|
||||
setSaveModuleOpen(true)
|
||||
}}
|
||||
>
|
||||
Übungen → Modul…
|
||||
</button>
|
||||
{mayConfigureSessionAssignments(unit) ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1959,9 +1986,29 @@ function TrainingPlanningPageRoot() {
|
|||
|
||||
<TrainingPublishToFrameworkModal
|
||||
open={publishFrameworkOpen}
|
||||
onClose={() => setPublishFrameworkOpen(false)}
|
||||
onSuccess={() => setShowModal(false)}
|
||||
unitId={editingUnit?.id}
|
||||
onClose={() => {
|
||||
setPublishFrameworkOpen(false)
|
||||
setPublishFrameworkUnitId(null)
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowModal(false)
|
||||
setPublishFrameworkUnitId(null)
|
||||
}}
|
||||
unitId={publishFrameworkUnitId ?? editingUnit?.id}
|
||||
planningModalClubId={planningModalClubId}
|
||||
/>
|
||||
|
||||
<SaveExercisesAsModuleModal
|
||||
open={saveModuleOpen}
|
||||
onClose={() => {
|
||||
setSaveModuleOpen(false)
|
||||
setSaveModuleUnitId(null)
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowModal(false)
|
||||
setSaveModuleUnitId(null)
|
||||
}}
|
||||
unitId={saveModuleUnitId ?? editingUnit?.id}
|
||||
planningModalClubId={planningModalClubId}
|
||||
/>
|
||||
|
||||
|
|
@ -1985,7 +2032,18 @@ function TrainingPlanningPageRoot() {
|
|||
sectionsEditMode={sectionsEditMode}
|
||||
setSectionsEditMode={setSectionsEditMode}
|
||||
onSaveAsTemplate={handleSaveAsTemplate}
|
||||
onRequestPublishToFramework={() => setPublishFrameworkOpen(true)}
|
||||
onRequestPublishToFramework={() => {
|
||||
if (editingUnit?.id) {
|
||||
setPublishFrameworkUnitId(editingUnit.id)
|
||||
setPublishFrameworkOpen(true)
|
||||
}
|
||||
}}
|
||||
onRequestSaveAsModule={() => {
|
||||
if (editingUnit?.id) {
|
||||
setSaveModuleUnitId(editingUnit.id)
|
||||
setSaveModuleOpen(true)
|
||||
}
|
||||
}}
|
||||
onRequestTrainingModulePick={(ctx) => {
|
||||
void openModuleApplyModal(ctx)
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export default function TrainingPlanningUnitFormModal({
|
|||
setSectionsEditMode,
|
||||
onSaveAsTemplate,
|
||||
onRequestPublishToFramework,
|
||||
onRequestSaveAsModule,
|
||||
onRequestTrainingModulePick,
|
||||
onRequestExercisePick,
|
||||
onPeekExercise,
|
||||
|
|
@ -504,6 +505,17 @@ export default function TrainingPlanningUnitFormModal({
|
|||
Als Rahmen-Session speichern…
|
||||
</button>
|
||||
) : null}
|
||||
{editingUnit?.id && !editingUnit?.framework_slot_id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginBottom: '2px' }}
|
||||
onClick={() => onRequestSaveAsModule?.()}
|
||||
title="Gespeicherte Übungen als Trainingsmodul sichern"
|
||||
>
|
||||
Übungen als Modul…
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
60
frontend/src/utils/trainingPlanModuleFromUnit.js
Normal file
60
frontend/src/utils/trainingPlanModuleFromUnit.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Übungspositionen aus GET /api/training-units/:id für die Modul-Erstellung (Reihenfolge = Timeline).
|
||||
* @param {object} unit – hydratisierte Trainingseinheit mit `phases` und/oder `sections`
|
||||
* @returns {Array<{ exercise_id: number, exercise_variant_id: number|null, planned_duration_min: number|null, notes: string|null, exercise_title?: string, contextLabel: string }>}
|
||||
*/
|
||||
export function collectExercisePlacementsForModule(unit) {
|
||||
if (!unit || typeof unit !== 'object') return []
|
||||
|
||||
const rows = []
|
||||
|
||||
const pushFromSection = (sec, ctx) => {
|
||||
const st = (sec?.title || '').trim()
|
||||
for (const it of sec?.items || []) {
|
||||
if ((it?.item_type || 'exercise') !== 'exercise') continue
|
||||
const eid = it.exercise_id
|
||||
if (!eid) continue
|
||||
const labelParts = [ctx, st].filter(Boolean)
|
||||
rows.push({
|
||||
exercise_id: Number(eid),
|
||||
exercise_variant_id:
|
||||
it.exercise_variant_id != null && it.exercise_variant_id !== ''
|
||||
? Number(it.exercise_variant_id)
|
||||
: null,
|
||||
planned_duration_min:
|
||||
it.planned_duration_min != null && it.planned_duration_min !== ''
|
||||
? Number(it.planned_duration_min)
|
||||
: null,
|
||||
notes: it.notes != null && String(it.notes).trim() ? String(it.notes).trim() : null,
|
||||
exercise_title: (it.exercise_title || '').trim() || `Übung #${eid}`,
|
||||
contextLabel: labelParts.length ? labelParts.join(' · ') : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const phases = Array.isArray(unit.phases) ? unit.phases : []
|
||||
for (const ph of phases) {
|
||||
const pk = String(ph?.phase_kind || '').toLowerCase()
|
||||
const phaseCtx = (ph?.title || '').trim()
|
||||
if (pk === 'parallel') {
|
||||
for (const st of ph.streams || []) {
|
||||
const streamCtx = [phaseCtx, (st?.title || '').trim()].filter(Boolean).join(' · ')
|
||||
for (const sec of st?.sections || []) {
|
||||
pushFromSection(sec, streamCtx)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const sec of ph?.sections || []) {
|
||||
pushFromSection(sec, phaseCtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.length && Array.isArray(unit.sections)) {
|
||||
for (const sec of unit.sections) {
|
||||
pushFromSection(sec, '')
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user