diff --git a/backend/migrations/064_training_plan_template_phases.sql b/backend/migrations/064_training_plan_template_phases.sql new file mode 100644 index 0000000..da7b72b --- /dev/null +++ b/backend/migrations/064_training_plan_template_phases.sql @@ -0,0 +1,8 @@ +-- Vorlagen: Phasen/Parallel-Streams wie im Einheiten-Editor (planLoc-Abbild) +ALTER TABLE training_plan_template_sections + ADD COLUMN IF NOT EXISTS phase_kind VARCHAR(20) NOT NULL DEFAULT 'whole_group', + ADD COLUMN IF NOT EXISTS phase_order_index INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS parallel_stream_order_index INT NULL; + +COMMENT ON COLUMN training_plan_template_sections.parallel_stream_order_index IS + 'NULL = Ganzgruppen-Abschnitt; 0..n = Stream innerhalb paralleler Phase'; diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index acd9449..af32f85 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -1523,32 +1523,187 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List _insert_section_items(cur, sid, filtered, start_order=0) -def _instantiate_from_template(cur, unit_id: int, template_id: int): - _clear_unit_plan_content(cur, unit_id) - pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) +def _normalize_training_plan_template_section_payload(sec: Any, si: int) -> Dict[str, Any]: + title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}" + order_ix = sec.get("order_index") + if order_ix is None: + order_ix = si + try: + order_ix = int(order_ix) + except (TypeError, ValueError): + order_ix = si + pk = str(sec.get("phase_kind") or "whole_group").strip().lower() + if pk not in ("whole_group", "parallel"): + pk = "whole_group" + try: + p_oi = int(sec.get("phase_order_index") if sec.get("phase_order_index") is not None else 0) + except (TypeError, ValueError): + p_oi = 0 + p_so: Optional[int] = None + if pk == "parallel": + raw_so = sec.get("parallel_stream_order_index") + try: + p_so = int(raw_so) if raw_so is not None and raw_so != "" else 0 + except (TypeError, ValueError): + p_so = 0 + return { + "title": title, + "order_index": order_ix, + "guidance_text": sec.get("guidance_text"), + "phase_kind": pk, + "phase_order_index": p_oi, + "parallel_stream_order_index": p_so, + } + + +def _insert_training_plan_template_sections(cur, template_id: int, sections_in: List[Any]) -> None: + for si, sec in enumerate(sections_in): + row = _normalize_training_plan_template_section_payload(sec, si) + cur.execute( + """ + INSERT INTO training_plan_template_sections ( + template_id, order_index, title, guidance_text, + phase_kind, phase_order_index, parallel_stream_order_index + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + template_id, + row["order_index"], + row["title"], + row["guidance_text"], + row["phase_kind"], + row["phase_order_index"], + row["parallel_stream_order_index"], + ), + ) + + +def _template_rows_to_phases_payload(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Flache Vorlagen-Sektionen → `phases`-Liste wie beim Training-Unit PUT (nur Gliederung, leere items).""" + if not rows: + return [] + phases_out: List[Dict[str, Any]] = [] + i = 0 + n = len(rows) + while i < n: + r0 = rows[i] + pk0 = str(r0.get("phase_kind") or "whole_group").strip().lower() + if pk0 not in ("whole_group", "parallel"): + pk0 = "whole_group" + try: + p_oix0 = int(r0.get("phase_order_index") if r0.get("phase_order_index") is not None else 0) + except (TypeError, ValueError): + p_oix0 = 0 + run: List[Dict[str, Any]] = [] + while i < n: + r = rows[i] + pk = str(r.get("phase_kind") or "whole_group").strip().lower() + if pk not in ("whole_group", "parallel"): + pk = "whole_group" + try: + p_oix = int(r.get("phase_order_index") if r.get("phase_order_index") is not None else 0) + except (TypeError, ValueError): + p_oix = 0 + if pk != pk0 or p_oix != p_oix0: + break + run.append(r) + i += 1 + if pk0 == "whole_group": + secs = [] + for j, rr in enumerate(run): + tid = rr.get("id") + secs.append( + { + "title": rr.get("title"), + "order_index": j, + "guidance_notes": rr.get("guidance_text"), + "items": [], + **( + {"source_template_section_id": int(tid)} + if tid is not None + else {} + ), + } + ) + phases_out.append( + { + "phase_kind": "whole_group", + "order_index": p_oix0, + "title": None, + "guidance_notes": None, + "sections": secs, + } + ) + else: + by_stream: Dict[int, List[Dict[str, Any]]] = {} + for rr in run: + raw_so = rr.get("parallel_stream_order_index") + try: + so = int(raw_so) if raw_so is not None and raw_so != "" else 0 + except (TypeError, ValueError): + so = 0 + by_stream.setdefault(so, []).append(rr) + stream_order = sorted(by_stream.keys()) + streams = [] + for so in stream_order: + bucket = by_stream[so] + st: Dict[str, Any] = { + "order_index": so, + "title": None, + "notes": None, + "sections": [], + } + for j, rr in enumerate(bucket): + tid = rr.get("id") + st["sections"].append( + { + "title": rr.get("title"), + "order_index": j, + "guidance_notes": rr.get("guidance_text"), + "items": [], + **( + {"source_template_section_id": int(tid)} + if tid is not None + else {} + ), + } + ) + streams.append(st) + phases_out.append( + { + "phase_kind": "parallel", + "order_index": p_oix0, + "title": None, + "guidance_notes": None, + "streams": streams, + } + ) + return phases_out + + +def _instantiate_from_template( + cur, + unit_id: int, + template_id: int, + *, + profile_id: int, + role: str, + unit_created_by: int, +) -> None: cur.execute( """ - SELECT id, title, guidance_text + SELECT id, title, guidance_text, order_index, phase_kind, phase_order_index, parallel_stream_order_index FROM training_plan_template_sections WHERE template_id = %s ORDER BY order_index """, (template_id,), ) - rows = cur.fetchall() - for gi, row in enumerate(rows): - r = r2d(row) - cur.execute( - """ - INSERT INTO training_unit_sections ( - training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id - ) VALUES (%s, %s, NULL, %s, %s, %s, %s) - """, - (unit_id, pid, gi, r["title"], r["guidance_text"], r["id"]), - ) - - # Fallback: keine Sektionen in Vorlage → ein leerer Block + rows_raw = cur.fetchall() + rows = [r2d(r) for r in rows_raw] if not rows: + _clear_unit_plan_content(cur, unit_id) + pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) cur.execute( """ INSERT INTO training_unit_sections ( @@ -1557,6 +1712,18 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int): """, (unit_id, pid), ) + return + + phases_payload = _template_rows_to_phases_payload(rows) + _clear_unit_plan_content(cur, unit_id) + _replace_unit_phases( + cur, + unit_id, + phases_payload, + profile_id, + role, + unit_created_by, + ) def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]: @@ -1674,18 +1841,7 @@ def create_training_plan_template(data: dict, tenant: TenantContext = Depends(ge (club_id, profile_id, name, data.get("description"), visibility), ) tid = cur.fetchone()["id"] - for si, sec in enumerate(sections_in): - title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}" - order_ix = sec.get("order_index") - if order_ix is None: - order_ix = si - cur.execute( - """ - INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text) - VALUES (%s, %s, %s, %s) - """, - (tid, order_ix, title, sec.get("guidance_text")), - ) + _insert_training_plan_template_sections(cur, tid, sections_in) conn.commit() return get_training_plan_template(tid, tenant) @@ -1743,18 +1899,7 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo "DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,) ) sections_in = data["sections"] or [] - for si, sec in enumerate(sections_in): - title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}" - order_ix = sec.get("order_index") - if order_ix is None: - order_ix = si - cur.execute( - """ - INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text) - VALUES (%s, %s, %s, %s) - """, - (template_id, order_ix, title, sec.get("guidance_text")), - ) + _insert_training_plan_template_sections(cur, template_id, sections_in) conn.commit() return get_training_plan_template(template_id, tenant) @@ -2400,7 +2545,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ elif sections_in is not None: _replace_unit_sections(cur, unit_id, sections_in) elif tpl_id_safe: - _instantiate_from_template(cur, unit_id, tpl_id_safe) + _instantiate_from_template( + cur, + unit_id, + tpl_id_safe, + profile_id=profile_id, + role=role, + unit_created_by=profile_id, + ) elif exercises_in is not None: _insert_sections_from_legacy_exercises(cur, unit_id, exercises_in) @@ -2576,7 +2728,14 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen cur.execute( "UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id) ) - _instantiate_from_template(cur, unit_id, tid) + _instantiate_from_template( + cur, + unit_id, + tid, + profile_id=profile_id, + role=role, + unit_created_by=int(unit_row.get("created_by") or profile_id), + ) content_handled = True _assert_single_plan_content_key_update(data) @@ -2783,7 +2942,14 @@ def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_t unit_id = cur.fetchone()["id"] if tpl_id_safe: - _instantiate_from_template(cur, unit_id, tpl_id_safe) + _instantiate_from_template( + cur, + unit_id, + tpl_id_safe, + profile_id=profile_id, + role=role, + unit_created_by=profile_id, + ) _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) conn.commit() diff --git a/backend/version.py b/backend/version.py index aaf4f4d..1c29c8d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.140" -BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260515063" +APP_VERSION = "0.8.141" +BUILD_DATE = "2026-05-14" +DB_SCHEMA_VERSION = "20260515064" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -24,7 +24,7 @@ MODULE_VERSIONS = { "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak "training_programs": "0.1.0", - "planning": "0.11.0", # PUT/POST training_units: phases (parallel streams); Rahmen→Termin-Kopie _replace_unit_phases; apply-training-module phase_order_index + parallel_stream_order_index + "planning": "0.12.0", # Trainingsvorlagen: Phasen/Streams in template_sections (064); Instantiate über _replace_unit_phases "dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine) "training_modules": "1.0.0", "import_wiki": "1.0.0", @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.141", + "date": "2026-05-14", + "changes": [ + "DB 064: Vorlagen-Sektionen mit phase_kind / phase_order_index / parallel_stream_order_index; Speichern und Anwenden behält Split-Sessions; Server: Vorlage → Einheit über Phasen-Replace.", + ], + }, { "version": "0.8.140", "date": "2026-05-14", diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index 1ce61ec..266e4f7 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -19,6 +19,8 @@ import { buildPlanPayloadForSave, hydrateExercisePlanningRow, insertTrainingModuleIntoPlanningSections, + templateSectionsPayloadFromFormSections, + formSectionsFromPlanTemplateRows, } from '../../utils/trainingUnitSectionsForm' import { addDaysIsoDate, @@ -549,12 +551,8 @@ function TrainingPlanningPageRoot() { setFormData((fd) => ({ ...fd, sections: (tpl.sections || []).length - ? tpl.sections.map((s) => ({ - title: s.title, - guidance_notes: s.guidance_text || '', - items: [] - })) - : [defaultSection()] + ? formSectionsFromPlanTemplateRows(tpl.sections) + : [defaultSection()], })) } catch (err) { toast.error('Vorlage laden: ' + err.message) @@ -651,10 +649,7 @@ function TrainingPlanningPageRoot() { try { await api.createTrainingPlanTemplate({ name: name.trim(), - sections: formData.sections.map((s) => ({ - title: s.title || 'Abschnitt', - guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null - })) + sections: templateSectionsPayloadFromFormSections(formData.sections), }) await loadPlanTemplates() toast.success('Vorlage gespeichert.') diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index dd733c4..3f7050a 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -1161,6 +1161,54 @@ export function buildPlanPayloadForSave(sections) { return buildPhasesPayloadFromFlat(list) } +/** Payload-Zeilen für POST/PUT /api/training-plan-templates (inkl. Split-/Phasen-Metadaten). */ +export function templateSectionsPayloadFromFormSections(sections) { + const norm = inheritPlanLocForPhasedSave(Array.isArray(sections) ? sections : []) + return norm.map((s, si) => { + const canon = canonicalPlanLocForPhasedSave(s.planLoc) + const pk = canon?.phaseKind === 'parallel' ? 'parallel' : 'whole_group' + const poi = canon?.phaseOrderIndex ?? 0 + const pso = canon?.phaseKind === 'parallel' ? (canon.parallelStreamOrderIndex ?? 0) : null + return { + order_index: si, + title: (s.title || '').trim() || `Abschnitt ${si + 1}`, + guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null, + phase_kind: pk, + phase_order_index: poi, + parallel_stream_order_index: pso, + } + }) +} + +/** GET-Vorlage → Editor-Abschnitte mit planLoc (Split-Sessions). */ +export function formSectionsFromPlanTemplateRows(templateSections) { + const rows = Array.isArray(templateSections) ? [...templateSections] : [] + rows.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + if (!rows.length) return [defaultSection()] + return rows.map((s) => { + const pk = String(s.phase_kind || 'whole_group').toLowerCase().trim() + const poiRaw = s.phase_order_index + const poi = poiRaw == null || poiRaw === '' ? 0 : Number(poiRaw) + const phaseOrderIndex = Number.isFinite(poi) ? poi : 0 + const soRaw = s.parallel_stream_order_index + let planLoc + if (pk === 'parallel') { + const so = soRaw == null || soRaw === '' ? 0 : Number(soRaw) + planLoc = { + ...defaultPlanLocParallel(phaseOrderIndex, Number.isFinite(so) ? so : 0), + } + } else { + planLoc = { ...defaultPlanLocWholeGroup(phaseOrderIndex) } + } + return { + title: s.title || 'Abschnitt', + guidance_notes: s.guidance_text || '', + items: [], + planLoc, + } + }) +} + /** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */ export async function insertTrainingModuleIntoPlanningSections({ sections,