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 ? ( -
+ {!structureOnly ? ( +
- + updateSectionField(sIdx, 'planned_duration_min', e.target.value) + } + placeholder="z. B. 15 oder 0,25 h" + /> +
+ ) : null} + {enableParallelPhaseControls && useCompactPhaseMoves ? ( +
+ - - {planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => ( - - ))} - + Phase / Gruppe + +
+ + + {planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)) + .filter((o) => o.key.startsWith('par:')) + .map((o) => ( + + ))} +
) : null} {!structureOnly && planMin > 0 && (

- 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() {

{unit.planned_date} - {unit.planned_time_start && - ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`} + {unit.planned_duration_min + ? ` · ${unit.planned_duration_min >= 60 && unit.planned_duration_min % 60 === 0 ? `${unit.planned_duration_min / 60} h` : `${unit.planned_duration_min} Min`}` + : unit.planned_time_start + ? ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}` + : ''}

{planScope === 'club' && (unit.group_name || '').trim() ? (

{ if (planningClubId != null && planningClubId !== '') { setNewTplClubId(String(planningClubId)) @@ -155,6 +166,39 @@ export default function TrainingUnitFormShell({

+
+ + updateFormField('planned_duration_min', e.target.value)} + placeholder="z. B. 1,5 oder 90 (Minuten)" + /> +

+ {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} +

+
+
String(x)) + : fw.focus_area_id != null + ? [String(fw.focus_area_id)] + : [], + style_direction_ids: Array.isArray(fw.style_direction_ids) + ? fw.style_direction_ids.map((x) => String(x)) + : fw.style_direction_id != null + ? [String(fw.style_direction_id)] + : [], training_type_ids: Array.isArray(fw.training_type_ids) ? fw.training_type_ids.map((x) => String(x)) : [], target_group_ids: Array.isArray(fw.target_group_ids) ? fw.target_group_ids.map((x) => String(x)) : [], - planned_period_start: fw.planned_period_start || '', - planned_period_end: fw.planned_period_end || '', visibility: fw.visibility || 'private', club_id: fw.club_id != null ? String(fw.club_id) : '', goals: goalsIn.map((g) => ({ @@ -142,6 +145,10 @@ function serverFrameworkToForm(fw) { slots: (fw.slots || []).map((s) => ({ title: s.title || '', notes: s.notes || '', + planned_duration_min: + s.planned_duration_min != null && s.planned_duration_min !== undefined + ? String(s.planned_duration_min) + : '', sections: normalizeUnitToForm({ sections: s.sections, exercises: s.exercises, @@ -170,6 +177,7 @@ function buildApiPayload(form) { sort_order: si, title: (s.title || '').trim() || null, notes: (s.notes || '').trim() || null, + planned_duration_min: parseDurationInput(s.planned_duration_min), } if (plan.phases) { return { ...base, phases: plan.phases } @@ -177,14 +185,12 @@ function buildApiPayload(form) { return { ...base, sections: plan.sections } }) - const focusAreaId = - form.focus_area_id && !Number.isNaN(parseInt(form.focus_area_id, 10)) - ? parseInt(form.focus_area_id, 10) - : null - const styleDirectionId = - form.style_direction_id && !Number.isNaN(parseInt(form.style_direction_id, 10)) - ? parseInt(form.style_direction_id, 10) - : null + const focus_area_ids = (form.focus_area_ids || []) + .map((x) => parseInt(String(x), 10)) + .filter((n) => !Number.isNaN(n) && n > 0) + const style_direction_ids = (form.style_direction_ids || []) + .map((x) => parseInt(String(x), 10)) + .filter((n) => !Number.isNaN(n) && n > 0) const training_type_ids = (form.training_type_ids || []) .map((x) => parseInt(String(x), 10)) @@ -201,12 +207,10 @@ function buildApiPayload(form) { return { title: (form.title || '').trim(), description: (form.description || '').trim() || null, - focus_area_id: focusAreaId, - style_direction_id: styleDirectionId, + focus_area_ids, + style_direction_ids, training_type_ids, target_group_ids, - planned_period_start: form.planned_period_start || null, - planned_period_end: form.planned_period_end || null, visibility: form.visibility || 'private', club_id: clubId, goals, @@ -544,14 +548,15 @@ export default function TrainingFrameworkProgramEditPage() { desktopLayout ? undefined : { display: panelActive(key) ? 'block' : 'none' } const trainingTypesFiltered = useMemo(() => { - if (!form.focus_area_id) return trainingTypesCatalog + const faSet = new Set((form.focus_area_ids || []).map(String)) + if (!faSet.size) return trainingTypesCatalog return trainingTypesCatalog.filter( - (t) => !t.focus_area_id || String(t.focus_area_id) === String(form.focus_area_id) + (t) => !t.focus_area_id || faSet.has(String(t.focus_area_id)) ) - }, [trainingTypesCatalog, form.focus_area_id]) + }, [trainingTypesCatalog, form.focus_area_ids]) useEffect(() => { - if (!form.focus_area_id || trainingTypesCatalog.length === 0) return + if (!(form.focus_area_ids || []).length || trainingTypesCatalog.length === 0) return const allowed = new Set(trainingTypesFiltered.map((t) => String(t.id))) setForm((prev) => { const cur = prev.training_type_ids || [] @@ -559,7 +564,27 @@ export default function TrainingFrameworkProgramEditPage() { if (next.length === cur.length) return prev return { ...prev, training_type_ids: next } }) - }, [form.focus_area_id, trainingTypesCatalog.length, trainingTypesFiltered]) + }, [form.focus_area_ids, trainingTypesCatalog.length, trainingTypesFiltered]) + + const toggleFocusAreaId = (fid) => { + const idStr = String(fid) + setForm((prev) => { + const s = new Set(prev.focus_area_ids || []) + if (s.has(idStr)) s.delete(idStr) + else s.add(idStr) + return { ...prev, focus_area_ids: [...s].sort((a, b) => Number(a) - Number(b)) } + }) + } + + const toggleStyleDirectionId = (sid) => { + const idStr = String(sid) + setForm((prev) => { + const s = new Set(prev.style_direction_ids || []) + if (s.has(idStr)) s.delete(idStr) + else s.add(idStr) + return { ...prev, style_direction_ids: [...s].sort((a, b) => Number(a) - Number(b)) } + }) + } const toggleTrainingTypeId = (tid) => { const idStr = String(tid) @@ -786,6 +811,20 @@ export default function TrainingFrameworkProgramEditPage() {
+
+ + slotField(si, 'planned_duration_min', e.target.value)} + placeholder="z. B. 1,5 h" + /> +
+
Notizen (Session)
@@ -910,35 +949,47 @@ export default function TrainingFrameworkProgramEditPage() { />
- - -

Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.

+ +
+ {focusAreas.length === 0 ? ( +

+ Keine Fokusbereiche im Katalog. +

+ ) : ( + focusAreas.map((fa) => ( + + )) + )} +
+

Aus dem Katalog; filtert die Trainingsarten unten.

- - + +
+ {styleDirections.length === 0 ? ( +

+ Keine Stilrichtungen im Katalog. +

+ ) : ( + styleDirections.map((sd) => ( + + )) + )} +
@@ -983,27 +1034,6 @@ export default function TrainingFrameworkProgramEditPage() {
-
-
- - updateField('planned_period_start', e.target.value)} - /> -
-
- - updateField('planned_period_end', e.target.value)} - /> -
-
-
diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx index a1f86c9..9374e96 100644 --- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx @@ -15,8 +15,18 @@ function FrameworkSummaryMeta({ r }) { typeof r.training_type_names_agg === 'string' ? r.training_type_names_agg.trim() : '' const targetGroups = typeof r.target_group_names_agg === 'string' ? r.target_group_names_agg.trim() : '' - const styleDir = typeof r.style_direction_name === 'string' ? r.style_direction_name.trim() : '' - const focus = typeof r.focus_area_name === 'string' ? r.focus_area_name.trim() : '' + const styleDir = + typeof r.style_direction_names_agg === 'string' + ? r.style_direction_names_agg.trim() + : typeof r.style_direction_name === 'string' + ? r.style_direction_name.trim() + : '' + const focus = + typeof r.focus_area_names_agg === 'string' + ? r.focus_area_names_agg.trim() + : typeof r.focus_area_name === 'string' + ? r.focus_area_name.trim() + : '' const rowStyle = { display: 'grid', @@ -33,12 +43,10 @@ function FrameworkSummaryMeta({ r }) {
Fokusbereich
{dashIfEmpty(focus)}
- {styleDir ? ( -
-
Stilrichtung
-
{styleDir}
-
- ) : null} +
+
Stilrichtungen
+
{dashIfEmpty(styleDir)}
+
Trainingsarten
{trainingTypes.length ? trainingTypes : '—'}
diff --git a/frontend/src/utils/trainingDurationUtils.js b/frontend/src/utils/trainingDurationUtils.js new file mode 100644 index 0000000..9b84f8e --- /dev/null +++ b/frontend/src/utils/trainingDurationUtils.js @@ -0,0 +1,82 @@ +/** + * Eingabe/Ausgabe geplanter Trainingsdauer (Minuten intern, Anzeige z. B. 1,5 h). + */ + +export function parseDurationInput(raw) { + if (raw === '' || raw == null) return null + const s = String(raw).trim().toLowerCase().replace(/\s+/g, '') + if (!s) return null + + const hMatch = s.match(/^(\d+(?:[.,]\d+)?)\s*h(?:\s*(\d{1,2}))?$/) + if (hMatch) { + const h = parseFloat(hMatch[1].replace(',', '.')) + const extraMin = hMatch[2] ? parseInt(hMatch[2], 10) : 0 + if (!Number.isFinite(h) || h < 0) return null + const total = Math.round(h * 60) + (Number.isFinite(extraMin) ? extraMin : 0) + return total > 0 ? total : null + } + + if (/^\d+(?:[.,]\d+)?$/.test(s)) { + const n = parseFloat(s.replace(',', '.')) + if (!Number.isFinite(n) || n <= 0) return null + if (s.includes(',') || s.includes('.') || n < 8) { + return Math.round(n * 60) + } + return Math.round(n) + } + + const minMatch = s.match(/^(\d+)\s*min?$/) + if (minMatch) { + const m = parseInt(minMatch[1], 10) + return Number.isFinite(m) && m > 0 ? m : null + } + + return null +} + +export function formatDurationDisplay(minutes, { empty = '—' } = {}) { + if (minutes == null || minutes === '') return empty + const m = Number(minutes) + if (!Number.isFinite(m) || m <= 0) return empty + if (m % 60 === 0) return `${m / 60} h` + if (m >= 60) { + const h = Math.floor(m / 60) + const rest = m % 60 + return `${h} h ${rest} Min` + } + return `${m} Min` +} + +/** Formularfeld: Minuten → Anzeige-String (z. B. 90 → "1,5") */ +export function minutesToDurationFieldValue(minutes) { + if (minutes == null || minutes === '') return '' + const m = Number(minutes) + if (!Number.isFinite(m) || m <= 0) return '' + if (m % 60 === 0) return String(m / 60) + if (m >= 60) { + const h = m / 60 + return Number.isInteger(h) ? String(h) : String(Math.round(h * 10) / 10).replace('.', ',') + } + return String(m) +} + +export function sumExercisePlannedMinutes(sections) { + let sum = 0 + for (const sec of sections || []) { + for (const it of sec.items || []) { + if (it.item_type !== 'exercise') continue + const n = parseDurationInput(it.planned_duration_min) + if (n != null) sum += n + } + } + return sum +} + +export function sumSectionPlannedMinutes(sections) { + let sum = 0 + for (const sec of sections || []) { + const n = parseDurationInput(sec.planned_duration_min) + if (n != null) sum += n + } + return sum +} diff --git a/frontend/src/utils/trainingUnitEditorCore.js b/frontend/src/utils/trainingUnitEditorCore.js index a03a905..91150e0 100644 --- a/frontend/src/utils/trainingUnitEditorCore.js +++ b/frontend/src/utils/trainingUnitEditorCore.js @@ -1,5 +1,6 @@ import { buildPlanPayloadForSave, defaultSection } from './trainingUnitSectionsForm' import { sessionAssignDefaults } from './trainingPlanningPageHelpers' +import { minutesToDurationFieldValue, parseDurationInput } from './trainingDurationUtils' /** Leeres Formular für neue Einheit (ohne async Varianten-Anreicherung). */ export function createEmptyTrainingUnitFormData({ @@ -13,6 +14,7 @@ export function createEmptyTrainingUnitFormData({ planned_date: plannedDate || '', planned_time_start: timeStart || '', planned_time_end: timeEnd || '', + planned_duration_min: '', planned_focus: '', actual_date: '', actual_time_start: '', @@ -52,6 +54,7 @@ export function trainingUnitToFormFields(fullUnit, sections) { planned_date: fullUnit.planned_date || '', planned_time_start: fullUnit.planned_time_start?.slice?.(0, 5) || fullUnit.planned_time_start || '', planned_time_end: fullUnit.planned_time_end?.slice?.(0, 5) || fullUnit.planned_time_end || '', + planned_duration_min: minutesToDurationFieldValue(fullUnit.planned_duration_min), planned_focus: fullUnit.planned_focus || '', actual_date: fullUnit.actual_date || '', actual_time_start: fullUnit.actual_time_start?.slice?.(0, 5) || fullUnit.actual_time_start || '', @@ -84,6 +87,7 @@ export function buildTrainingUnitSavePayload(formData, { editingUnit = null, dra planned_date: formData.planned_date, planned_time_start: formData.planned_time_start || null, planned_time_end: formData.planned_time_end || null, + planned_duration_min: parseDurationInput(formData.planned_duration_min), planned_focus: formData.planned_focus || null, actual_date: formData.actual_date || null, actual_time_start: formData.actual_time_start || null, diff --git a/frontend/src/utils/trainingUnitSectionsForm.js b/frontend/src/utils/trainingUnitSectionsForm.js index 54423cd..56b4827 100644 --- a/frontend/src/utils/trainingUnitSectionsForm.js +++ b/frontend/src/utils/trainingUnitSectionsForm.js @@ -2,7 +2,7 @@ import api from './api' import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes' export function defaultSection(title = 'Hauptteil') { - return { title, guidance_notes: '', items: [] } + return { title, guidance_notes: '', planned_duration_min: '', items: [] } } /** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */ @@ -440,6 +440,10 @@ function normalizePhasesToFormSections(fullUnit) { out.push({ title: sec.title, guidance_notes: sec.guidance_notes || '', + planned_duration_min: + sec.planned_duration_min != null && sec.planned_duration_min !== undefined + ? String(sec.planned_duration_min) + : '', items: formItemsFromApiItems(sec.items), planLoc: { ...streamLoc }, }) @@ -459,6 +463,10 @@ function normalizePhasesToFormSections(fullUnit) { out.push({ title: sec.title, guidance_notes: sec.guidance_notes || '', + planned_duration_min: + sec.planned_duration_min != null && sec.planned_duration_min !== undefined + ? String(sec.planned_duration_min) + : '', items: formItemsFromApiItems(sec.items), planLoc: { ...loc }, }) @@ -476,6 +484,10 @@ export function normalizeUnitToForm(fullUnit) { return fullUnit.sections.map((sec) => ({ title: sec.title, guidance_notes: sec.guidance_notes || '', + planned_duration_min: + sec.planned_duration_min != null && sec.planned_duration_min !== undefined + ? String(sec.planned_duration_min) + : '', items: formItemsFromApiItems(sec.items), })) } @@ -665,6 +677,7 @@ export function buildOneSectionPayload(sec, orderIndex) { order_index: orderIndex, title: (sec.title || '').trim() || 'Abschnitt', guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null, + planned_duration_min: parseMin(sec.planned_duration_min), items: (sec.items || []) .map((it, ii) => { if (it.item_type === 'note') { @@ -1173,6 +1186,7 @@ export function templateSectionsPayloadFromFormSections(sections) { order_index: si, title: (s.title || '').trim() || `Abschnitt ${si + 1}`, guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null, + planned_duration_min: parseMin(s.planned_duration_min), phase_kind: pk, phase_order_index: poi, parallel_stream_order_index: pso, @@ -1263,6 +1277,10 @@ export function formSectionsFromPlanTemplateRows(templateSections) { return { title: s.title || 'Abschnitt', guidance_notes: s.guidance_text || '', + planned_duration_min: + s.planned_duration_min != null && s.planned_duration_min !== undefined + ? String(s.planned_duration_min) + : '', items: [], planLoc, } @@ -1342,9 +1360,34 @@ export async function insertTrainingModuleIntoPlanningSections({ } export function sectionPlannedMinutes(sec) { + const sectionMin = parseMin(sec?.planned_duration_min) + if (sectionMin != null && sectionMin > 0) return sectionMin return (sec.items || []).reduce((sum, it) => { if (it.item_type !== 'exercise') return sum const m = parseMin(it.planned_duration_min) return sum + (m || 0) }, 0) } + +/** Abschnitt unterhalb einer Parallel-Phase in die Ganzgruppe darunter verschieben. */ +export function reorderSectionAfterParallelRunAsWholeGroup(prev, fromI, phaseOrderIndex) { + const po = Number(phaseOrderIndex) || 0 + const idxs = indicesOfParallelPhase(prev, po) + if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev + const lg = idxs[idxs.length - 1] + const arr = [...prev] + const [moved] = arr.splice(fromI, 1) + let insertAt = lg + 1 + if (fromI < insertAt) insertAt -= 1 + insertAt = Math.max(0, Math.min(insertAt, arr.length)) + const below = insertAt < arr.length ? arr[insertAt] : undefined + let planLocNext + if (below?.planLoc?.phaseKind === 'whole_group') { + planLocNext = { ...below.planLoc } + } else { + const mx = maxPhaseOrderIndexFromSections(arr) + planLocNext = defaultPlanLocWholeGroup(mx + 1) + } + arr.splice(insertAt, 0, { ...moved, planLoc: planLocNext }) + return arr +}