feat(training-planning): enhance training module integration and UI
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s
- Introduced a new function to handle optional source training module IDs, ensuring proper validation and integration. - Updated the backend to include source training module ID and title in section items, allowing for better tracking of module origins. - Enhanced the frontend to display module bands in the Training Unit Sections Editor, improving user experience by indicating the source of exercises and notes. - Added functionality to insert training modules at specified positions within sections, providing users with more control over their training plans. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c1243651bb
commit
e41908af73
|
|
@ -404,6 +404,19 @@ _ORIGIN_LINEAGE_FIELDS = """
|
|||
"""
|
||||
|
||||
|
||||
def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
|
||||
"""Erlaubt None; sonst positives int (FK-Verletzung bei ungültigem Modul möglich)."""
|
||||
if raw_val is None or raw_val == "":
|
||||
return None
|
||||
try:
|
||||
i = int(raw_val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if i < 1:
|
||||
return None
|
||||
return i
|
||||
|
||||
|
||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -429,10 +442,12 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||||
LIMIT 1
|
||||
) AS exercise_focus_area,
|
||||
ev.variant_name AS exercise_variant_name
|
||||
ev.variant_name AS exercise_variant_name,
|
||||
tm.title AS source_module_title
|
||||
FROM training_unit_section_items tusi
|
||||
LEFT JOIN exercises e ON tusi.exercise_id = e.id
|
||||
LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id
|
||||
LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
|
||||
WHERE tusi.section_id = %s
|
||||
ORDER BY tusi.order_index
|
||||
""",
|
||||
|
|
@ -453,18 +468,19 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
||||
oix = it.get("order_index")
|
||||
if itype == "note":
|
||||
items_clean.append(
|
||||
{
|
||||
note_item = {
|
||||
"item_type": "note",
|
||||
"order_index": oix,
|
||||
"note_body": it.get("note_body") or "",
|
||||
}
|
||||
)
|
||||
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
|
||||
if sm is not None:
|
||||
note_item["source_training_module_id"] = sm
|
||||
items_clean.append(note_item)
|
||||
continue
|
||||
if itype != "exercise" or not it.get("exercise_id"):
|
||||
continue
|
||||
items_clean.append(
|
||||
{
|
||||
ex_item = {
|
||||
"item_type": "exercise",
|
||||
"order_index": oix,
|
||||
"exercise_id": it["exercise_id"],
|
||||
|
|
@ -474,7 +490,10 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
"notes": it.get("notes"),
|
||||
"modifications": it.get("modifications"),
|
||||
}
|
||||
)
|
||||
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
|
||||
if sm is not None:
|
||||
ex_item["source_training_module_id"] = sm
|
||||
items_clean.append(ex_item)
|
||||
out.append(
|
||||
{
|
||||
"title": sec.get("title"),
|
||||
|
|
@ -670,18 +689,19 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
|||
body = raw.get("note_body")
|
||||
if body is None:
|
||||
body = ""
|
||||
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_section_items (
|
||||
section_id, order_index, item_type,
|
||||
exercise_id, exercise_variant_id,
|
||||
planned_duration_min, actual_duration_min,
|
||||
notes, modifications, note_body
|
||||
notes, modifications, note_body, source_training_module_id
|
||||
) VALUES (%s, %s, 'note',
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, %s
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, %s, %s
|
||||
)
|
||||
""",
|
||||
(section_id, order_ix, body),
|
||||
(section_id, order_ix, body, src_mod),
|
||||
)
|
||||
continue
|
||||
|
||||
|
|
@ -691,15 +711,16 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
|||
eid = int(eid)
|
||||
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
|
||||
_validate_variant_for_exercise(cur, eid, vid)
|
||||
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_section_items (
|
||||
section_id, order_index, item_type,
|
||||
exercise_id, exercise_variant_id,
|
||||
planned_duration_min, actual_duration_min,
|
||||
notes, modifications, note_body
|
||||
notes, modifications, note_body, source_training_module_id
|
||||
) VALUES (%s, %s, 'exercise',
|
||||
%s, %s, %s, %s, %s, %s, NULL
|
||||
%s, %s, %s, %s, %s, %s, NULL, %s
|
||||
)
|
||||
""",
|
||||
(
|
||||
|
|
@ -711,6 +732,7 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
|||
raw.get("actual_duration_min"),
|
||||
raw.get("notes"),
|
||||
raw.get("modifications"),
|
||||
src_mod,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5115,6 +5115,19 @@ a.analysis-split__nav-item {
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tu-planning-module-band {
|
||||
margin-top: 0.85rem;
|
||||
margin-bottom: 0.05rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border2);
|
||||
background: linear-gradient(to right, var(--accent-light), transparent 140%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-dark);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tu-item-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ import {
|
|||
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
|
||||
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
||||
|
||||
function normalizedPlanningModuleChainId(raw) {
|
||||
if (raw == null || raw === '') return null
|
||||
const n = typeof raw === 'number' ? raw : Number(raw)
|
||||
return Number.isFinite(n) && n >= 1 ? n : null
|
||||
}
|
||||
|
||||
function dtHasType(e, mime) {
|
||||
const t = e?.dataTransfer?.types
|
||||
if (!t || !mime) return false
|
||||
|
|
@ -521,15 +527,29 @@ export default function TrainingUnitSectionsEditor({
|
|||
}
|
||||
: {}
|
||||
|
||||
const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
|
||||
const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
|
||||
const showModuleBand =
|
||||
curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
|
||||
const modBandTitle =
|
||||
(it.source_module_title || '').trim() ||
|
||||
(curMn != null ? `Modul #${curMn}` : '')
|
||||
|
||||
if (it.item_type === 'note') {
|
||||
const notePv = truncatePreview(it.note_body || '', 260)
|
||||
const noteHasText = Boolean((it.note_body || '').trim())
|
||||
return (
|
||||
<Fragment key={`it-${sIdx}-${iIdx}`}>
|
||||
{showModuleBand ? (
|
||||
<div
|
||||
key={`note-${sIdx}-${iIdx}`}
|
||||
className={`${rowCommon} tu-item-row--note`}
|
||||
{...dndRowProps}
|
||||
className="tu-planning-module-band"
|
||||
role="group"
|
||||
aria-label={`Baustein ${modBandTitle}`}
|
||||
>
|
||||
Baustein: {modBandTitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`${rowCommon} tu-item-row--note`} {...dndRowProps}>
|
||||
{enableItemDragReorder ? (
|
||||
<span
|
||||
className="tu-row-grip"
|
||||
|
|
@ -596,6 +616,7 @@ export default function TrainingUnitSectionsEditor({
|
|||
✗
|
||||
</button>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -611,11 +632,17 @@ export default function TrainingUnitSectionsEditor({
|
|||
: Number(it.exercise_variant_id)
|
||||
|
||||
return (
|
||||
<Fragment key={`it-${sIdx}-${iIdx}`}>
|
||||
{showModuleBand ? (
|
||||
<div
|
||||
key={`ex-${sIdx}-${iIdx}`}
|
||||
className={`${rowCommon} tu-item-row--exercise`}
|
||||
{...dndRowProps}
|
||||
className="tu-planning-module-band"
|
||||
role="group"
|
||||
aria-label={`Baustein ${modBandTitle}`}
|
||||
>
|
||||
Baustein: {modBandTitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`${rowCommon} tu-item-row--exercise`} {...dndRowProps}>
|
||||
<div className="tu-item-row__mainline">
|
||||
{enableItemDragReorder ? (
|
||||
<span
|
||||
|
|
@ -793,6 +820,7 @@ export default function TrainingUnitSectionsEditor({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
|
||||
|
||||
function nextLocalKey() {
|
||||
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
|
@ -38,6 +40,19 @@ export default function TrainingModuleEditPage() {
|
|||
const [primaryMethodId, setPrimaryMethodId] = useState('')
|
||||
const [items, setItems] = useState([])
|
||||
|
||||
const { user } = useAuth()
|
||||
const clubChoices = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew || visibility !== 'club') return
|
||||
if ((clubIdField || '').trim() !== '') return
|
||||
if (clubChoices.length === 1) setClubIdField(String(clubChoices[0].id))
|
||||
else {
|
||||
const r = getResolvedActiveClubIdForUi(user)
|
||||
if (r) setClubIdField(String(r))
|
||||
}
|
||||
}, [isNew, visibility, clubIdField, clubChoices, user])
|
||||
|
||||
const itemsPayload = items.map((it, i) => {
|
||||
if (it.item_type === 'note') {
|
||||
return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' }
|
||||
|
|
@ -133,8 +148,16 @@ export default function TrainingModuleEditPage() {
|
|||
}, [isNew, moduleId])
|
||||
|
||||
const buildBody = () => {
|
||||
const cid =
|
||||
visibility === 'club' && clubIdField !== '' ? parseInt(clubIdField, 10) : null
|
||||
let cid = null
|
||||
if (visibility === 'club') {
|
||||
const raw = (clubIdField || '').trim()
|
||||
if (raw !== '') {
|
||||
const p = parseInt(raw, 10)
|
||||
if (Number.isFinite(p) && p >= 1) cid = p
|
||||
} else if (clubChoices.length === 1) {
|
||||
cid = clubChoices[0].id
|
||||
}
|
||||
}
|
||||
const pm =
|
||||
primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null
|
||||
return {
|
||||
|
|
@ -270,22 +293,79 @@ export default function TrainingModuleEditPage() {
|
|||
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select className="form-input" value={visibility} onChange={(e) => setVisibility(e.target.value)}>
|
||||
<select
|
||||
className="form-input"
|
||||
value={visibility}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setVisibility(v)
|
||||
if (v !== 'club') {
|
||||
setClubIdField('')
|
||||
return
|
||||
}
|
||||
const xs = clubChoices
|
||||
if (xs.length === 1) setClubIdField(String(xs[0].id))
|
||||
else if (xs.length === 0) setClubIdField('')
|
||||
else {
|
||||
const resolved = getResolvedActiveClubIdForUi(user)
|
||||
setClubIdField(resolved != null ? String(resolved) : '')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="private">Privat</option>
|
||||
<option value="club">Vereinsintern</option>
|
||||
<option value="official">Offiziell</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Vereins‑ID (optional, bei Vereins‑Sichtbarkeit)</label>
|
||||
<label className="form-label">Verein (bei „Vereinsintern“)</label>
|
||||
{visibility !== 'club' ? (
|
||||
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
|
||||
Vereinsbindung fest).
|
||||
</p>
|
||||
) : clubChoices.length === 0 ? (
|
||||
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}>
|
||||
Kein aktiver Verein im Profil — bitte zuerst einem Verein beitreten.
|
||||
</p>
|
||||
) : clubChoices.length === 1 ? (
|
||||
<>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min={1}
|
||||
disabled
|
||||
readOnly
|
||||
value={
|
||||
(clubChoices[0].short_name || clubChoices[0].name || '').trim() ||
|
||||
`Verein #${clubChoices[0].id}`
|
||||
}
|
||||
/>
|
||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className="form-input"
|
||||
value={clubIdField}
|
||||
onChange={(e) => setClubIdField(e.target.value)}
|
||||
placeholder="Leer = aktiver Verein (Server)"
|
||||
/>
|
||||
>
|
||||
<option value="">Automatisch (aktueller Verein im Profil)</option>
|
||||
{clubChoices.map((c) => {
|
||||
const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
|
||||
return (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{ln}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Bei „Automatisch“ entscheidet der aktiv gewählte Verein beim Speichern (wie bei anderen
|
||||
Bibliotheksinhalten).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
enrichSectionsWithVariants,
|
||||
buildSectionsPayload,
|
||||
hydrateExercisePlanningRow,
|
||||
insertTrainingModuleIntoPlanningSections,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
|
||||
function addDaysIsoDate(isoDay, daysDelta) {
|
||||
|
|
@ -149,6 +150,7 @@ function TrainingPlanningPage() {
|
|||
const [moduleApplyList, setModuleApplyList] = useState([])
|
||||
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
|
||||
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
|
||||
const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('__end__')
|
||||
const [moduleApplyErr, setModuleApplyErr] = useState('')
|
||||
|
||||
const [startDate, setStartDate] = useState(today)
|
||||
|
|
@ -196,6 +198,19 @@ function TrainingPlanningPage() {
|
|||
return Number.isFinite(c) ? c : null
|
||||
}, [groups, formData.group_id])
|
||||
|
||||
const moduleApplyTargetItems = useMemo(() => {
|
||||
const secs = formData.sections || []
|
||||
if (!secs.length) return []
|
||||
let ix =
|
||||
typeof moduleApplySectionIx === 'number'
|
||||
? moduleApplySectionIx
|
||||
: parseInt(String(moduleApplySectionIx), 10)
|
||||
if (!Number.isFinite(ix)) ix = 0
|
||||
if (ix < 0 || ix >= secs.length) return []
|
||||
const sec = secs[ix]
|
||||
return Array.isArray(sec?.items) ? sec.items : []
|
||||
}, [formData.sections, moduleApplySectionIx])
|
||||
|
||||
const refreshPlanningSectionMeta = useCallback(async () => {
|
||||
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
|
||||
setFormData((prev) => ({ ...prev, sections: next }))
|
||||
|
|
@ -672,6 +687,7 @@ function TrainingPlanningPage() {
|
|||
const openModuleApplyModal = useCallback(async () => {
|
||||
setModuleApplyErr('')
|
||||
setModuleApplySectionIx(0)
|
||||
setModuleApplyInsertSlot('__end__')
|
||||
setModuleApplyOpen(true)
|
||||
try {
|
||||
const list = await api.listTrainingModules()
|
||||
|
|
@ -685,41 +701,48 @@ function TrainingPlanningPage() {
|
|||
}, [])
|
||||
|
||||
const handleApplyTrainingModuleConfirm = useCallback(async () => {
|
||||
if (!editingUnit?.id) return
|
||||
const mid = parseInt(moduleApplyModuleId, 10)
|
||||
if (!Number.isFinite(mid)) {
|
||||
alert('Bitte ein Trainingsmodul wählen.')
|
||||
return
|
||||
}
|
||||
let secIx = parseInt(moduleApplySectionIx, 10)
|
||||
let secIx = parseInt(String(moduleApplySectionIx), 10)
|
||||
if (!Number.isFinite(secIx)) secIx = 0
|
||||
if (!formData.sections?.length) {
|
||||
|
||||
const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
|
||||
if (!baseSections.length) {
|
||||
alert('Keine Abschnitte im Formular.')
|
||||
return
|
||||
}
|
||||
if (secIx < 0 || secIx >= formData.sections.length) secIx = 0
|
||||
if (secIx < 0 || secIx >= baseSections.length) secIx = 0
|
||||
|
||||
let insertBefore = null
|
||||
if (moduleApplyInsertSlot === '__end__') insertBefore = 'end'
|
||||
else if (moduleApplyInsertSlot === '__start__') insertBefore = 'start'
|
||||
else if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
|
||||
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
|
||||
insertBefore = Number.isFinite(zi) ? zi : 'end'
|
||||
} else insertBefore = 'end'
|
||||
|
||||
setModuleApplyBusy(true)
|
||||
setModuleApplyErr('')
|
||||
try {
|
||||
await api.applyTrainingModuleToTrainingUnit(editingUnit.id, {
|
||||
module_id: mid,
|
||||
section_order_index: secIx,
|
||||
const detail = await api.getTrainingModule(mid)
|
||||
let nextSections = await insertTrainingModuleIntoPlanningSections({
|
||||
sections: baseSections,
|
||||
moduleDetail: detail,
|
||||
sectionIndex: secIx,
|
||||
insertBeforeItemIndex: insertBefore,
|
||||
})
|
||||
await handleEdit({ id: editingUnit.id })
|
||||
nextSections = await enrichSectionsWithVariants(nextSections)
|
||||
setFormData((fd) => ({ ...fd, sections: nextSections }))
|
||||
setModuleApplyOpen(false)
|
||||
} catch (e) {
|
||||
setModuleApplyErr(e.message || 'Übernehmen fehlgeschlagen')
|
||||
setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
|
||||
} finally {
|
||||
setModuleApplyBusy(false)
|
||||
}
|
||||
}, [
|
||||
editingUnit?.id,
|
||||
moduleApplyModuleId,
|
||||
moduleApplySectionIx,
|
||||
formData.sections?.length,
|
||||
handleEdit,
|
||||
])
|
||||
}, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot, formData.sections])
|
||||
|
||||
const handleTakeLead = async (unit) => {
|
||||
if (!user?.id) return
|
||||
|
|
@ -1895,11 +1918,12 @@ function TrainingPlanningPage() {
|
|||
aria-labelledby="module-apply-title"
|
||||
>
|
||||
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
|
||||
Trainingsmodul übernehmen
|
||||
Modul einfügen
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
Der Inhalt wird <strong>kopiert</strong> und ans Ende des gewählten Abschnitts angehängt (Herkunft wird
|
||||
gespeichert). Anschließend kannst du ihn lokal bearbeiten.
|
||||
Übungen und Notizen des Moduls werden <strong>kopiert</strong> wie bei einer einzelnen Übung —
|
||||
ohne die Einheit vorher gespeichert zu haben (Speichern am Ende wie gewohnt). Die Herkunft bleibt
|
||||
am Block sichtbar; du kannst alles weiter anpassen.
|
||||
</p>
|
||||
|
||||
{moduleApplyErr ? (
|
||||
|
|
@ -1930,7 +1954,10 @@ function TrainingPlanningPage() {
|
|||
<select
|
||||
className="form-input"
|
||||
value={String(moduleApplySectionIx)}
|
||||
onChange={(e) => setModuleApplySectionIx(parseInt(e.target.value, 10))}
|
||||
onChange={(e) => {
|
||||
setModuleApplySectionIx(parseInt(e.target.value, 10))
|
||||
setModuleApplyInsertSlot('__end__')
|
||||
}}
|
||||
disabled={moduleApplyBusy || !formData.sections?.length}
|
||||
>
|
||||
{(formData.sections || []).map((s, i) => (
|
||||
|
|
@ -1941,6 +1968,32 @@ function TrainingPlanningPage() {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Position in diesem Abschnitt</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={moduleApplyInsertSlot}
|
||||
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
|
||||
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
|
||||
>
|
||||
<option value="__end__">Ans Ende einfügen (nach allen Einträgen)</option>
|
||||
<option value="__start__">An den Anfang (vor dem ersten Eintrag)</option>
|
||||
{moduleApplyTargetItems.map((row, xi) => {
|
||||
const labelPart =
|
||||
row.item_type === 'note'
|
||||
? 'Zwischen-Anmerkung'
|
||||
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
|
||||
const clipped =
|
||||
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
|
||||
return (
|
||||
<option key={`before-${xi}`} value={`before:${xi}`}>
|
||||
Vor Eintrag {xi + 1}: {clipped}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -1959,7 +2012,7 @@ function TrainingPlanningPage() {
|
|||
Abbrechen
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={moduleApplyBusy} onClick={handleApplyTrainingModuleConfirm}>
|
||||
{moduleApplyBusy ? 'Übernehmen …' : 'Übernehmen'}
|
||||
{moduleApplyBusy ? 'Einfügen …' : 'Einfügen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -2473,16 +2526,14 @@ function TrainingPlanningPage() {
|
|||
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
|
||||
Vorlage aus Aufbau speichern
|
||||
</button>
|
||||
{editingUnit?.id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={openModuleApplyModal}
|
||||
title="Übungen und Notizen aus einem Modul ans Ende eines Abschnitts kopieren"
|
||||
title="Modulpositionen hier einfügen (Kopie, auch ohne zwischengespeicherte Einheit)"
|
||||
>
|
||||
Aus Modul übernehmen…
|
||||
Modul einfügen…
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
sections={formData.sections}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export function exerciseRow() {
|
|||
actual_duration_min: '',
|
||||
notes: '',
|
||||
modifications: '',
|
||||
source_training_module_id: '',
|
||||
source_module_title: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +71,14 @@ export async function hydrateExercisePlanningRow(exercise) {
|
|||
}
|
||||
|
||||
export function noteRow() {
|
||||
return { item_type: 'note', note_body: '' }
|
||||
return { item_type: 'note', note_body: '', source_training_module_id: '', source_module_title: '' }
|
||||
}
|
||||
|
||||
/** Zur Serialisierung in die Planungs-API (persistente Modul-Herkunft). */
|
||||
function parseOptionalSourceTrainingModuleIdForPayload(v) {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v).trim(), 10)
|
||||
return Number.isFinite(n) && n >= 1 ? n : null
|
||||
}
|
||||
|
||||
export function normalizeUnitToForm(fullUnit) {
|
||||
|
|
@ -79,8 +88,24 @@ export function normalizeUnitToForm(fullUnit) {
|
|||
guidance_notes: sec.guidance_notes || '',
|
||||
items: (sec.items || []).map((it) => {
|
||||
if (it.item_type === 'note') {
|
||||
return { item_type: 'note', note_body: it.note_body || '' }
|
||||
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||
const rowNote = {
|
||||
item_type: 'note',
|
||||
note_body: it.note_body || '',
|
||||
source_training_module_id: '',
|
||||
source_module_title: '',
|
||||
}
|
||||
if (sm != null) {
|
||||
rowNote.source_training_module_id = sm
|
||||
rowNote.source_module_title = (
|
||||
it.source_module_title ||
|
||||
it.source_training_module_title ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
return rowNote
|
||||
}
|
||||
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
exercise_id: it.exercise_id,
|
||||
|
|
@ -97,6 +122,16 @@ export function normalizeUnitToForm(fullUnit) {
|
|||
: '',
|
||||
notes: it.notes ?? '',
|
||||
modifications: it.modifications ?? '',
|
||||
...(smEx != null
|
||||
? {
|
||||
source_training_module_id: smEx,
|
||||
source_module_title: (
|
||||
it.source_module_title ||
|
||||
it.source_training_module_title ||
|
||||
''
|
||||
).trim(),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
|
@ -199,17 +234,21 @@ export function buildSectionsPayload(sections) {
|
|||
items: (sec.items || [])
|
||||
.map((it, ii) => {
|
||||
if (it.item_type === 'note') {
|
||||
return {
|
||||
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||
const row = {
|
||||
item_type: 'note',
|
||||
order_index: ii,
|
||||
note_body: it.note_body ?? '',
|
||||
}
|
||||
if (sm != null) row.source_training_module_id = sm
|
||||
return row
|
||||
}
|
||||
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
|
||||
return null
|
||||
}
|
||||
const vid = it.exercise_variant_id
|
||||
return {
|
||||
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||
const rowEx = {
|
||||
item_type: 'exercise',
|
||||
order_index: ii,
|
||||
exercise_id: parseInt(it.exercise_id, 10),
|
||||
|
|
@ -220,11 +259,80 @@ export function buildSectionsPayload(sections) {
|
|||
notes: it.notes?.trim() ? it.notes.trim() : null,
|
||||
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
|
||||
}
|
||||
if (smEx != null) rowEx.source_training_module_id = smEx
|
||||
return rowEx
|
||||
})
|
||||
.filter(Boolean),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
||||
export async function insertTrainingModuleIntoPlanningSections({
|
||||
sections,
|
||||
moduleDetail,
|
||||
sectionIndex,
|
||||
insertBeforeItemIndex,
|
||||
}) {
|
||||
const secIx = typeof sectionIndex === 'number' ? sectionIndex : parseInt(String(sectionIndex), 10)
|
||||
if (
|
||||
!Array.isArray(sections) ||
|
||||
!Number.isFinite(secIx) ||
|
||||
secIx < 0 ||
|
||||
secIx >= sections.length ||
|
||||
!moduleDetail ||
|
||||
typeof moduleDetail !== 'object'
|
||||
) {
|
||||
return sections
|
||||
}
|
||||
const prev = [...(sections[secIx].items || [])]
|
||||
let beforeIx
|
||||
if (insertBeforeItemIndex === null || insertBeforeItemIndex === undefined || insertBeforeItemIndex === 'end') {
|
||||
beforeIx = prev.length
|
||||
} else if (insertBeforeItemIndex === 'start') {
|
||||
beforeIx = 0
|
||||
} else {
|
||||
const n = typeof insertBeforeItemIndex === 'number' ? insertBeforeItemIndex : parseInt(String(insertBeforeItemIndex), 10)
|
||||
beforeIx = Number.isFinite(n) ? Math.min(Math.max(n, 0), prev.length) : prev.length
|
||||
}
|
||||
|
||||
const midRaw = moduleDetail.id
|
||||
const midNum = typeof midRaw === 'number' ? midRaw : parseInt(String(midRaw), 10)
|
||||
if (!Number.isFinite(midNum) || midNum < 1) return sections
|
||||
|
||||
const modTitle = (moduleDetail.title || '').trim() || `Modul #${midNum}`
|
||||
const modItems = [...(moduleDetail.items || [])].sort(
|
||||
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
|
||||
)
|
||||
|
||||
const appendRows = []
|
||||
for (const mi of modItems) {
|
||||
if (mi.item_type === 'note') {
|
||||
appendRows.push({
|
||||
item_type: 'note',
|
||||
note_body: mi.note_body || '',
|
||||
source_training_module_id: midNum,
|
||||
source_module_title: modTitle,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!mi.exercise_id) continue
|
||||
const hydrated = await hydrateExercisePlanningRow({ id: mi.exercise_id })
|
||||
if (!hydrated) continue
|
||||
hydrated.source_training_module_id = midNum
|
||||
hydrated.source_module_title = modTitle
|
||||
if (mi.exercise_variant_id) hydrated.exercise_variant_id = String(mi.exercise_variant_id)
|
||||
hydrated.planned_duration_min =
|
||||
mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
|
||||
? String(mi.planned_duration_min)
|
||||
: ''
|
||||
hydrated.notes = mi.notes ?? ''
|
||||
appendRows.push(hydrated)
|
||||
}
|
||||
|
||||
const mergedItems = [...prev.slice(0, beforeIx), ...appendRows, ...prev.slice(beforeIx)]
|
||||
return sections.map((sec, idx) => (idx === secIx ? { ...sec, items: mergedItems } : sec))
|
||||
}
|
||||
|
||||
export function sectionPlannedMinutes(sec) {
|
||||
return (sec.items || []).reduce((sum, it) => {
|
||||
if (it.item_type !== 'exercise') return sum
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user