diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index d621e88..4294d5d 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -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,28 +468,32 @@ 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( - { - "item_type": "note", - "order_index": oix, - "note_body": it.get("note_body") or "", - } - ) + 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( - { - "item_type": "exercise", - "order_index": oix, - "exercise_id": it["exercise_id"], - "exercise_variant_id": it.get("exercise_variant_id"), - "planned_duration_min": it.get("planned_duration_min"), - "actual_duration_min": it.get("actual_duration_min"), - "notes": it.get("notes"), - "modifications": it.get("modifications"), - } - ) + ex_item = { + "item_type": "exercise", + "order_index": oix, + "exercise_id": it["exercise_id"], + "exercise_variant_id": it.get("exercise_variant_id"), + "planned_duration_min": it.get("planned_duration_min"), + "actual_duration_min": it.get("actual_duration_min"), + "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, ), ) diff --git a/frontend/src/app.css b/frontend/src/app.css index 9379619..44d1318 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 31daf70..27f790d 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -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 ( -
+ + {showModuleBand ? ( +
+ Baustein: {modBandTitle} +
+ ) : null} +
{enableItemDragReorder ? (
+
) } @@ -611,11 +632,17 @@ export default function TrainingUnitSectionsEditor({ : Number(it.exercise_variant_id) return ( -
+ + {showModuleBand ? ( +
+ Baustein: {modBandTitle} +
+ ) : null} +
{enableItemDragReorder ? ( ) : null}
+ ) })} diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx index 08e88db..4700e12 100644 --- a/frontend/src/pages/TrainingModuleEditPage.jsx +++ b/frontend/src/pages/TrainingModuleEditPage.jsx @@ -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() {
- { + 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) : '') + } + }} + >
- - setClubIdField(e.target.value)} - placeholder="Leer = aktiver Verein (Server)" - /> + + {visibility !== 'club' ? ( +

+ Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine + Vereinsbindung fest). +

+ ) : clubChoices.length === 0 ? ( +

+ Kein aktiver Verein im Profil — bitte zuerst einem Verein beitreten. +

+ ) : clubChoices.length === 1 ? ( + <> + +

+ Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet. +

+ + ) : ( + <> + +

+ Bei „Automatisch“ entscheidet der aktiv gewählte Verein beim Speichern (wie bei anderen + Bibliotheksinhalten). +

+ + )}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index 0a04cbc..a4e97a5 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -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" >

- Trainingsmodul übernehmen + Modul einfügen

- Der Inhalt wird kopiert 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 kopiert 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.

{moduleApplyErr ? ( @@ -1930,7 +1954,10 @@ function TrainingPlanningPage() {
+
+ + +
+
@@ -2473,16 +2526,14 @@ function TrainingPlanningPage() { - {editingUnit?.id ? ( - - ) : null} + } sections={formData.sections} diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index de9670e..f99851e 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -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