diff --git a/backend/migrations/066_training_durations_framework_context_mn.sql b/backend/migrations/066_training_durations_framework_context_mn.sql new file mode 100644 index 0000000..2b02555 --- /dev/null +++ b/backend/migrations/066_training_durations_framework_context_mn.sql @@ -0,0 +1,36 @@ +-- Geplante Gesamt- und Abschnittsdauer; Rahmenprogramm: Fokus/Stil als M:N (wie Trainingsarten/Zielgruppen) + +ALTER TABLE training_units + ADD COLUMN IF NOT EXISTS planned_duration_min INT; + +ALTER TABLE training_unit_sections + ADD COLUMN IF NOT EXISTS planned_duration_min INT; + +ALTER TABLE training_plan_template_sections + ADD COLUMN IF NOT EXISTS planned_duration_min INT; + +CREATE TABLE IF NOT EXISTS training_framework_program_focus_areas ( + framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE, + focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE CASCADE, + PRIMARY KEY (framework_program_id, focus_area_id) +); + +CREATE INDEX IF NOT EXISTS idx_tfpfa_focus ON training_framework_program_focus_areas(focus_area_id); + +CREATE TABLE IF NOT EXISTS training_framework_program_style_directions ( + framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE, + style_direction_id INT NOT NULL REFERENCES style_directions(id) ON DELETE CASCADE, + PRIMARY KEY (framework_program_id, style_direction_id) +); + +CREATE INDEX IF NOT EXISTS idx_tfpsd_style ON training_framework_program_style_directions(style_direction_id); + +INSERT INTO training_framework_program_focus_areas (framework_program_id, focus_area_id) +SELECT id, focus_area_id FROM training_framework_programs +WHERE focus_area_id IS NOT NULL +ON CONFLICT DO NOTHING; + +INSERT INTO training_framework_program_style_directions (framework_program_id, style_direction_id) +SELECT id, style_direction_id FROM training_framework_programs +WHERE style_direction_id IS NOT NULL +ON CONFLICT DO NOTHING; diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index afa338a..b2d2eaa 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -99,6 +99,32 @@ def _target_group_ids(cur, framework_id: int) -> List[int]: return [r["target_group_id"] for r in cur.fetchall()] +def _focus_area_ids(cur, framework_id: int) -> List[int]: + cur.execute( + """ + SELECT focus_area_id + FROM training_framework_program_focus_areas + WHERE framework_program_id = %s + ORDER BY focus_area_id + """, + (framework_id,), + ) + return [r["focus_area_id"] for r in cur.fetchall()] + + +def _style_direction_ids(cur, framework_id: int) -> List[int]: + cur.execute( + """ + SELECT style_direction_id + FROM training_framework_program_style_directions + WHERE framework_program_id = %s + ORDER BY style_direction_id + """, + (framework_id,), + ) + return [r["style_direction_id"] for r in cur.fetchall()] + + def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]: fid = row["id"] cur.execute( @@ -136,6 +162,14 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]: uid = row_b["id"] s["blueprint_training_unit_id"] = uid unit_min: Dict[str, Any] = {"id": uid} + cur.execute( + "SELECT planned_duration_min FROM training_units WHERE id = %s", + (uid,), + ) + urow = cur.fetchone() + s["planned_duration_min"] = ( + urow["planned_duration_min"] if urow else None + ) _hydrate_training_unit_payload(cur, unit_min) s["phases"] = unit_min.get("phases", []) s["sections"] = unit_min.get("sections", []) @@ -143,6 +177,8 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]: row["slots"] = slots row["training_type_ids"] = _training_type_ids(cur, fid) row["target_group_ids"] = _target_group_ids(cur, fid) + row["focus_area_ids"] = _focus_area_ids(cur, fid) + row["style_direction_ids"] = _style_direction_ids(cur, fid) return row @@ -209,6 +245,53 @@ def _replace_target_groups(cur, framework_id: int, ids: Sequence[int]) -> None: ) +def _replace_focus_areas(cur, framework_id: int, ids: Sequence[int]) -> None: + cur.execute( + "DELETE FROM training_framework_program_focus_areas WHERE framework_program_id = %s", + (framework_id,), + ) + for fid in ids: + cur.execute( + """ + INSERT INTO training_framework_program_focus_areas (framework_program_id, focus_area_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (framework_id, fid), + ) + + +def _replace_style_directions(cur, framework_id: int, ids: Sequence[int]) -> None: + cur.execute( + "DELETE FROM training_framework_program_style_directions WHERE framework_program_id = %s", + (framework_id,), + ) + for sid in ids: + cur.execute( + """ + INSERT INTO training_framework_program_style_directions (framework_program_id, style_direction_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (framework_id, sid), + ) + + +def _parse_context_ids_from_payload(data: dict) -> tuple: + """focus_area_ids / style_direction_ids (M:N); Legacy focus_area_id / style_direction_id.""" + fa_ids = _parse_positive_int_ids(data.get("focus_area_ids"), "focus_area_ids") + if not fa_ids and data.get("focus_area_id") not in (None, ""): + one = _optional_positive_int(data.get("focus_area_id"), "focus_area_id") + if one is not None: + fa_ids = [one] + sd_ids = _parse_positive_int_ids(data.get("style_direction_ids"), "style_direction_ids") + if not sd_ids and data.get("style_direction_id") not in (None, ""): + one = _optional_positive_int(data.get("style_direction_id"), "style_direction_id") + if one is not None: + sd_ids = [one] + return fa_ids, sd_ids + + def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None: if not goals_in: raise HTTPException(status_code=400, detail="Mindestens ein Entwicklungsziel (goals) ist erforderlich") @@ -277,22 +360,25 @@ def _insert_slots_and_blueprints( ) sid = cur.fetchone()["id"] + slot_pdur = _optional_positive_int( + slot.get("planned_duration_min"), "planned_duration_min" + ) cur.execute( """ INSERT INTO training_units ( group_id, planned_date, - planned_time_start, planned_time_end, planned_focus, + planned_time_start, planned_time_end, planned_duration_min, planned_focus, status, notes, trainer_notes, created_by, plan_template_id, framework_slot_id ) VALUES ( NULL, NULL, - NULL, NULL, NULL, + NULL, NULL, %s, NULL, 'planned', NULL, NULL, %s, NULL, %s ) RETURNING id """, - (profile_id, sid), + (slot_pdur, profile_id, sid), ) bid = cur.fetchone()["id"] @@ -327,8 +413,6 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_ cur = get_cursor(conn) base_sel = """ SELECT fp.*, - fa.name AS focus_area_name, - sd.name AS style_direction_name, (SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id) AS goals_count, (SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id) @@ -337,6 +421,18 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_ WHERE t.framework_program_id = fp.id) AS training_types_count, (SELECT COUNT(*)::int FROM training_framework_program_target_groups tg WHERE tg.framework_program_id = fp.id) AS target_groups_count, + ( + SELECT STRING_AGG(fa.name::text, ', ' ORDER BY fa.name) + FROM training_framework_program_focus_areas j + JOIN focus_areas fa ON fa.id = j.focus_area_id + WHERE j.framework_program_id = fp.id + ) AS focus_area_names_agg, + ( + SELECT STRING_AGG(sd.name::text, ', ' ORDER BY sd.name) + FROM training_framework_program_style_directions j + JOIN style_directions sd ON sd.id = j.style_direction_id + WHERE j.framework_program_id = fp.id + ) AS style_direction_names_agg, ( SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name) FROM training_framework_program_training_types j @@ -350,8 +446,6 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_ WHERE j.framework_program_id = fp.id ) AS target_group_names_agg FROM training_framework_programs fp - LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id - LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id """ vis_clause, vis_params = library_content_visibility_sql( alias="fp", @@ -403,10 +497,11 @@ def create_training_framework_program( if not isinstance(goals_in, list) or not goals_in: raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht") - fa_id = _optional_positive_int(data.get("focus_area_id"), "focus_area_id") - sd_id = _optional_positive_int(data.get("style_direction_id"), "style_direction_id") + fa_ids, sd_ids = _parse_context_ids_from_payload(data) tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids") tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids") + fa_legacy = fa_ids[0] if len(fa_ids) == 1 else None + sd_legacy = sd_ids[0] if len(sd_ids) == 1 else None with get_db() as conn: cur = get_cursor(conn) @@ -418,19 +513,17 @@ def create_training_framework_program( planned_period_start, planned_period_end, visibility, club_id, created_by, focus_area_id, style_direction_id - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ) VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s) RETURNING id """, ( title[:200], data.get("description"), - data.get("planned_period_start"), - data.get("planned_period_end"), vis, club_id, profile_id, - fa_id, - sd_id, + fa_legacy, + sd_legacy, ), ) fid = cur.fetchone()["id"] @@ -438,6 +531,8 @@ def create_training_framework_program( _insert_slots_and_blueprints(cur, fid, slots_in, profile_id, role) _replace_training_types(cur, fid, tt_ids) _replace_target_groups(cur, fid, tg_ids) + _replace_focus_areas(cur, fid, fa_ids) + _replace_style_directions(cur, fid, sd_ids) conn.commit() return _response_framework_detail(fid, profile_id, role) @@ -489,13 +584,6 @@ def update_training_framework_program( if "description" in data: header_fields.append("description = %s") header_params.append(data.get("description")) - if "planned_period_start" in data: - header_fields.append("planned_period_start = %s") - header_params.append(data.get("planned_period_start")) - if "planned_period_end" in data: - header_fields.append("planned_period_end = %s") - header_params.append(data.get("planned_period_end")) - if "visibility" in data: header_fields.append("visibility = %s") header_params.append(merged_vis) @@ -503,18 +591,26 @@ def update_training_framework_program( header_fields.append("club_id = %s") header_params.append(merged_club) - if "focus_area_id" in data: - fidv = data.get("focus_area_id") + if "focus_area_ids" in data or "focus_area_id" in data: + fa_ids = _parse_positive_int_ids(data.get("focus_area_ids"), "focus_area_ids") + if not fa_ids and data.get("focus_area_id") not in (None, ""): + one = _optional_positive_int(data.get("focus_area_id"), "focus_area_id") + if one is not None: + fa_ids = [one] header_fields.append("focus_area_id = %s") - header_params.append( - None if fidv in (None, "") else _optional_positive_int(fidv, "focus_area_id") + header_params.append(fa_ids[0] if len(fa_ids) == 1 else None) + _replace_focus_areas(cur, framework_id, fa_ids) + if "style_direction_ids" in data or "style_direction_id" in data: + sd_ids = _parse_positive_int_ids( + data.get("style_direction_ids"), "style_direction_ids" ) - if "style_direction_id" in data: - sidv = data.get("style_direction_id") + if not sd_ids and data.get("style_direction_id") not in (None, ""): + one = _optional_positive_int(data.get("style_direction_id"), "style_direction_id") + if one is not None: + sd_ids = [one] header_fields.append("style_direction_id = %s") - header_params.append( - None if sidv in (None, "") else _optional_positive_int(sidv, "style_direction_id") - ) + header_params.append(sd_ids[0] if len(sd_ids) == 1 else None) + _replace_style_directions(cur, framework_id, sd_ids) if header_fields: header_fields.append("updated_at = NOW()") @@ -555,7 +651,17 @@ def update_training_framework_program( cur, framework_id, data.get("slots") or [], profile_id, role ) - if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data: + if ( + header_fields + or "goals" in data + or "slots" in data + or "training_type_ids" in data + or "target_group_ids" in data + or "focus_area_ids" in data + or "focus_area_id" in data + or "style_direction_ids" in data + or "style_direction_id" in data + ): cur.execute( "UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s", (framework_id,), diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index d08757d..52460f7 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -648,6 +648,7 @@ def _ensure_default_whole_group_phase(cur, unit_id: int, *, order_index: int = 0 _SECTION_ROWS_SQL = """ SELECT tus.id, tus.training_unit_id, tus.order_index, tus.title, tus.guidance_notes, + tus.planned_duration_min, tus.source_template_section_id, tus.phase_id, tus.parallel_stream_id FROM training_unit_sections tus LEFT JOIN training_unit_phases ph ON ph.id = tus.phase_id @@ -740,7 +741,7 @@ def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]: cur.execute( """ SELECT id, training_unit_id, order_index, title, guidance_notes, - source_template_section_id, phase_id, parallel_stream_id + planned_duration_min, source_template_section_id, phase_id, parallel_stream_id FROM training_unit_sections WHERE phase_id = %s ORDER BY order_index @@ -771,7 +772,7 @@ def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]: cur.execute( """ SELECT id, training_unit_id, order_index, title, guidance_notes, - source_template_section_id, phase_id, parallel_stream_id + planned_duration_min, source_template_section_id, phase_id, parallel_stream_id FROM training_unit_sections WHERE parallel_stream_id = %s ORDER BY order_index @@ -831,6 +832,7 @@ def _clone_section_payload_dict(sec: Dict[str, Any]) -> Dict[str, Any]: "title": sec.get("title"), "order_index": sec.get("order_index"), "guidance_notes": sec.get("guidance_notes"), + "planned_duration_min": sec.get("planned_duration_min"), "items": items_clean, } stid = sec.get("source_template_section_id") @@ -974,6 +976,7 @@ def _copy_blueprint_into_scheduled_unit( planned_date, planned_time_start, planned_time_end, + planned_duration_min, planned_focus, actual_date, actual_time_start, @@ -992,6 +995,7 @@ def _copy_blueprint_into_scheduled_unit( %s, planned_time_start, planned_time_end, + planned_duration_min, planned_focus, NULL::DATE, NULL::TIME WITHOUT TIME ZONE, @@ -1281,8 +1285,9 @@ def _insert_one_replacement_section( 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, %s, %s, %s, %s, %s) + training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, + planned_duration_min, source_template_section_id + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( @@ -1292,6 +1297,7 @@ def _insert_one_replacement_section( order_ix, title, sec.get("guidance_notes"), + sec.get("planned_duration_min"), src_tsec, ), ) @@ -1629,10 +1635,12 @@ def _normalize_training_plan_template_section_payload(sec: Any, si: int) -> Dict p_so = int(raw_so) if raw_so is not None and raw_so != "" else 0 except (TypeError, ValueError): p_so = 0 + pdur = _optional_positive_int(sec.get("planned_duration_min"), "planned_duration_min") return { "title": title, "order_index": order_ix, "guidance_text": sec.get("guidance_text"), + "planned_duration_min": pdur, "phase_kind": pk, "phase_order_index": p_oi, "parallel_stream_order_index": p_so, @@ -1645,15 +1653,16 @@ def _insert_training_plan_template_sections(cur, template_id: int, sections_in: cur.execute( """ INSERT INTO training_plan_template_sections ( - template_id, order_index, title, guidance_text, + template_id, order_index, title, guidance_text, planned_duration_min, phase_kind, phase_order_index, parallel_stream_order_index - ) VALUES (%s, %s, %s, %s, %s, %s, %s) + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, ( template_id, row["order_index"], row["title"], row["guidance_text"], + row["planned_duration_min"], row["phase_kind"], row["phase_order_index"], row["parallel_stream_order_index"], @@ -1700,6 +1709,7 @@ def _template_rows_to_phases_payload(rows: List[Dict[str, Any]]) -> List[Dict[st "title": rr.get("title"), "order_index": j, "guidance_notes": rr.get("guidance_text"), + "planned_duration_min": rr.get("planned_duration_min"), "items": [], **( {"source_template_section_id": int(tid)} @@ -1743,6 +1753,7 @@ def _template_rows_to_phases_payload(rows: List[Dict[str, Any]]) -> List[Dict[st "title": rr.get("title"), "order_index": j, "guidance_notes": rr.get("guidance_text"), + "planned_duration_min": rr.get("planned_duration_min"), "items": [], **( {"source_template_section_id": int(tid)} @@ -1775,7 +1786,7 @@ def _instantiate_from_template( ) -> None: cur.execute( """ - SELECT id, title, guidance_text, order_index, phase_kind, phase_order_index, parallel_stream_order_index + SELECT id, title, guidance_text, planned_duration_min, order_index, phase_kind, phase_order_index, parallel_stream_order_index FROM training_plan_template_sections WHERE template_id = %s ORDER BY order_index @@ -2591,11 +2602,13 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ eff_lead_for_co, ) + pdur = _optional_positive_int(data.get("planned_duration_min"), "planned_duration_min") base_params = ( group_id, planned_date, data.get("planned_time_start"), data.get("planned_time_end"), + pdur, data.get("planned_focus"), data.get("status", "planned"), data.get("notes"), @@ -2610,11 +2623,11 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ """ INSERT INTO training_units ( group_id, planned_date, planned_time_start, planned_time_end, - planned_focus, status, notes, trainer_notes, created_by, + planned_duration_min, planned_focus, status, notes, trainer_notes, created_by, plan_template_id, lead_trainer_profile_id, assistant_trainer_profile_ids - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, base_params + (av_db,), @@ -2624,10 +2637,10 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ """ INSERT INTO training_units ( group_id, planned_date, planned_time_start, planned_time_end, - planned_focus, status, notes, trainer_notes, created_by, + planned_duration_min, planned_focus, status, notes, trainer_notes, created_by, plan_template_id, lead_trainer_profile_id - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, base_params, @@ -2717,6 +2730,11 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen if "planned_time_end" in data: blueprint_fields.append("planned_time_end = %s") blueprint_params.append(data.get("planned_time_end")) + if "planned_duration_min" in data: + blueprint_fields.append("planned_duration_min = %s") + blueprint_params.append( + _optional_positive_int(data.get("planned_duration_min"), "planned_duration_min") + ) if "notes" in data: blueprint_fields.append("notes = %s") blueprint_params.append(data.get("notes")) @@ -2782,6 +2800,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen planned_date = COALESCE(%s, planned_date), planned_time_start = %s, planned_time_end = %s, + planned_duration_min = %s, planned_focus = %s, actual_date = %s, actual_time_start = %s, @@ -2801,6 +2820,9 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen data.get("planned_date"), data.get("planned_time_start"), data.get("planned_time_end"), + _optional_positive_int(data.get("planned_duration_min"), "planned_duration_min") + if "planned_duration_min" in data + else unit_row.get("planned_duration_min"), data.get("planned_focus"), data.get("actual_date"), data.get("actual_time_start"), diff --git a/backend/version.py b/backend/version.py index 6adcb35..529fd57 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.149" -BUILD_DATE = "2026-05-19" -DB_SCHEMA_VERSION = "20260516065" +APP_VERSION = "0.8.150" +BUILD_DATE = "2026-05-20" +DB_SCHEMA_VERSION = "20260520066" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index f2f148a..8c65d3c 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -25,6 +25,7 @@ import { swapAdjacentPhaseRuns, reorderBlocksImmutableWithPlanLoc, reorderSectionBeforeParallelRunAsWholeGroup, + reorderSectionAfterParallelRunAsWholeGroup, reorderSectionAsFirstInParallelStream, reorderBlockIntoParallelStreamEnd, globalInsertBeforeIndexForParallelStreamEnd, @@ -37,6 +38,7 @@ import { noteRow, sectionPlannedMinutes, } from '../utils/trainingUnitSectionsForm' +import { formatDurationDisplay, parseDurationInput } from '../utils/trainingDurationUtils' import api from '../utils/api' import { isCompactTagLegendMode } from '../config/planningModuleUx' import { useAuth } from '../context/AuthContext' @@ -274,6 +276,19 @@ export default function TrainingUnitSectionsEditor({ user?.training_planning_prefs?.module_display_mode ) + const [useCompactPhaseMoves, setUseCompactPhaseMoves] = useState(() => + typeof window !== 'undefined' + ? window.matchMedia('(max-width: 768px)').matches + : false + ) + useEffect(() => { + const mq = window.matchMedia('(max-width: 768px)') + const apply = () => setUseCompactPhaseMoves(!!mq.matches) + apply() + mq.addEventListener('change', apply) + return () => mq.removeEventListener('change', apply) + }, []) + const ensure = (prev) => prev && prev.length ? prev : [defaultSection()] @@ -1299,6 +1314,47 @@ export default function TrainingUnitSectionsEditor({ return sIdx === 0 } + const moveSectionToPlanTarget = (sIdx, rawKey) => { + if (!rawKey) return + patch((prev) => { + const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev)) + const hit = opts.find((o) => o.key === rawKey) + if (!hit) return prev + const tpl = { ...hit.template } + let next = prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s)) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } + + const moveSectionToWholeGroupAbove = (sIdx) => { + const sec = list[sIdx] + const po = sec?.planLoc?.phaseOrderIndex ?? 0 + if (sec?.planLoc?.phaseKind === 'parallel') { + patch((prev) => { + let next = reorderSectionBeforeParallelRunAsWholeGroup(prev, sIdx, po) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } else { + moveSection(sIdx, -1) + } + } + + const moveSectionToWholeGroupBelow = (sIdx) => { + const sec = list[sIdx] + const po = sec?.planLoc?.phaseOrderIndex ?? 0 + if (sec?.planLoc?.phaseKind === 'parallel') { + patch((prev) => { + let next = reorderSectionAfterParallelRunAsWholeGroup(prev, sIdx, po) + if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next) + return next + }) + } else { + moveSection(sIdx, 1) + } + } + const sectionMoveDisabledDown = (sIdx) => { const sec = list[sIdx] const L = sec?.planLoc @@ -2004,28 +2060,74 @@ export default function TrainingUnitSectionsEditor({ } placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)" /> - {enableParallelPhaseControls ? ( -
- Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen) + Geplant in diesem Abschnitt:{' '} + {formatDurationDisplay(planMin)} + {parseDurationInput(sec.planned_duration_min) != null + ? ' (Abschnittsangabe)' + : ' (Summe Übungen)'}
)} diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index 7e42b8a..9354295 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -1157,8 +1157,11 @@ function TrainingPlanningPageRoot() {{ if (planningClubId != null && planningClubId !== '') { setNewTplClubId(String(planningClubId)) @@ -155,6 +166,39 @@ export default function TrainingUnitFormShell({
+ {plannedTotalParsed != null ? ( + <> + Eingabe: {formatDurationDisplay(plannedTotalParsed)} + > + ) : ( + 'Stunden mit Komma/Punkt (1,5 = 90 Min) oder Minuten ab 8.' + )} + {sumFromSections > 0 || sumFromExercises > 0 ? ( + <> + {' '} + · Summe Abschnitte: {formatDurationDisplay(sumFromSections)} + {sumFromExercises > 0 && sumFromExercises !== sumFromSections ? ( + <> + {' '} + · Übungen: {formatDurationDisplay(sumFromExercises)} + > + ) : null} + > + ) : null} +
+Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.
+ ++ Keine Fokusbereiche im Katalog. +
+ ) : ( + focusAreas.map((fa) => ( + + )) + )} +Aus dem Katalog; filtert die Trainingsarten unten.
+ Keine Stilrichtungen im Katalog. +
+ ) : ( + styleDirections.map((sd) => ( + + )) + )} +