From 5a8a212f408a7714d8495aed86c3220c0689567f Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 13:02:09 +0200 Subject: [PATCH 1/6] Update version information and add training duration features - Bumped application version to 0.8.150 and updated build date and database schema version. - Introduced new SQL migration for planned duration fields in training units and sections. - Added functions to handle focus areas and style directions in training framework programs. - Enhanced training planning components to support planned duration input and display. - Updated frontend components to manage and display planned duration for training units and sections. --- ...raining_durations_framework_context_mn.sql | 36 ++++ .../routers/training_framework_programs.py | 168 +++++++++++++--- backend/routers/training_planning.py | 44 +++-- backend/version.py | 6 +- .../components/TrainingUnitSectionsEditor.jsx | 132 +++++++++++-- .../planning/TrainingPlanningPageRoot.jsx | 7 +- .../planning/TrainingUnitFormShell.jsx | 44 +++++ .../TrainingFrameworkProgramEditPage.jsx | 186 ++++++++++-------- .../TrainingFrameworkProgramsListPage.jsx | 24 ++- frontend/src/utils/trainingDurationUtils.js | 82 ++++++++ frontend/src/utils/trainingUnitEditorCore.js | 4 + .../src/utils/trainingUnitSectionsForm.js | 45 ++++- 12 files changed, 629 insertions(+), 149 deletions(-) create mode 100644 backend/migrations/066_training_durations_framework_context_mn.sql create mode 100644 frontend/src/utils/trainingDurationUtils.js 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 +} -- 2.43.0 From 9353909fdabcd232397d381e4531c0b480474655 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 13:19:40 +0200 Subject: [PATCH 2/6] Enhance Training Framework Programs with Session Duration and Filtering Features - Added SQL aggregations for session duration (min/max) and goal titles in the training framework programs query. - Updated the TrainingPlanningFrameworkImportModal component to include filtering options for focus areas, training types, and target groups. - Implemented session duration display in the TrainingFrameworkProgramsListPage, improving user visibility of program details. - Introduced utility functions for formatting session duration ranges, enhancing the overall user experience in training planning. --- ...EWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md | 65 +++++++ .../routers/training_framework_programs.py | 41 +++- .../TrainingPlanningFrameworkImportModal.jsx | 176 +++++++++++++++++- .../planning/TrainingPlanningPageRoot.jsx | 46 ++++- .../TrainingFrameworkProgramsListPage.jsx | 28 +++ .../src/utils/frameworkProgramListHelpers.js | 88 +++++++++ frontend/src/utils/trainingDurationUtils.js | 20 ++ 7 files changed, 450 insertions(+), 14 deletions(-) create mode 100644 .claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md create mode 100644 frontend/src/utils/frameworkProgramListHelpers.js diff --git a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md new file mode 100644 index 0000000..d9d8663 --- /dev/null +++ b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md @@ -0,0 +1,65 @@ +# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap) + +**Stand:** 2026-05-20 +**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 2–3 offen + +## Phase 1 (umgesetzt) + +### Listen-Anzeige Session-Dauer + +- **GET `/api/training-framework-programs`:** `session_duration_min`, `session_duration_max` (aus Blueprint-`training_units.planned_duration_min`), `goal_titles_agg`, ID-Arrays für Katalog-M:N. +- **UI:** Rahmenprogramm-Liste, Trainingsplanung (Einheiten-Liste/Kalender), Import-Dialog (Programm + pro Slot). + +### Import-Filter (clientseitig) + +- Textsuche (Titel, Beschreibung, Ziele, Katalog-Namen) +- Fokusbereich, Trainingsart, Zielgruppe (Checkboxen, Katalog-API) +- Ziel-Session-Dauer in Minuten (±10 Min Toleranz gegen Min/Max der Slots) + +**Grenze:** Entwicklungsziele sind **freie Texte** pro Rahmen (`training_framework_goals.title`), keine kontrollierte Taxonomie → Filter nur Volltext, keine homogene „Ziel-Tags“-Liste. + +## Phase 2 (empfohlen, ohne KI) + +| Kriterium | Datenquelle heute | Verbesserung | +|-----------|-------------------|--------------| +| Fokusbereich / Stil / Trainingsart / Zielgruppe | M:N am Rahmenkopf | bereits filterbar | +| Entwicklungsziele | Freitext-Ziele | Optional: Ziel-Vorlagen-Katalog oder Tags (Migration) | +| Session-Dauer | `planned_duration_min` pro Slot | erledigt | +| Fähigkeiten-Schwerpunkt | noch nicht | siehe Phase 3 | + +**API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek. + +## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch) + +### Ziel + +Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben). + +### Variante A — Regelbasiert (ohne KI) + +1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln. +2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit). +3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten. +4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail. + +**Vorteil:** reproduzierbar, offline, Governance-konform. +**Aufwand:** ca. 1–2 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“. + +### Variante B — KI-Zusammenfassung (OpenRouter, optional) + +1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen. +2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`). +3. Speichern als `ai_context_summary` (Version, Modell, Timestamp) — **nur Vorschlag**, manuelle Bestätigung vor Übernahme in Stammdaten. + +**Vorteil:** natürliche Schwerpunkte auch bei unvollständigen Skill-Links. +**Risiko:** Halluzination, Kosten, Datenschutz (Vereinsdaten in Prompt). + +### Empfehlung + +Zuerst **Variante A** für Listen/Filter und Abgleich mit manuell gesetzten Fokusbereichen; KI nur als **„Vorschlag generieren“-Button** im Rahmen-Editor, wenn Regelwerk und Katalog-Zuordnung zu dünn sind. + +## Offene Produktfragen + +1. Soll Filter **UND** (alle Kriterien) oder **ODER** (mindestens eines) sein? — Import aktuell **UND**. +2. Rahmen mit **unterschiedlichen** Slot-Dauern: Liste zeigt Min–Max; Filter „90 Min“ trifft Range. +3. Sollen homogenisierte **Entwicklungsziel-Tags** ein eigener Katalog werden (Admin), analog `target_groups`? diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index b2d2eaa..f817e15 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -444,7 +444,46 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_ FROM training_framework_program_target_groups j JOIN target_groups tg ON tg.id = j.target_group_id WHERE j.framework_program_id = fp.id - ) AS target_group_names_agg + ) AS target_group_names_agg, + ( + SELECT STRING_AGG(g.title::text, ' | ' ORDER BY g.sort_order) + FROM training_framework_goals g + WHERE g.framework_program_id = fp.id + ) AS goal_titles_agg, + ( + SELECT MIN(tu.planned_duration_min)::int + FROM training_framework_slots fs + INNER JOIN training_units tu ON tu.framework_slot_id = fs.id + WHERE fs.framework_program_id = fp.id + AND tu.planned_duration_min IS NOT NULL + ) AS session_duration_min, + ( + SELECT MAX(tu.planned_duration_min)::int + FROM training_framework_slots fs + INNER JOIN training_units tu ON tu.framework_slot_id = fs.id + WHERE fs.framework_program_id = fp.id + AND tu.planned_duration_min IS NOT NULL + ) AS session_duration_max, + ( + SELECT COALESCE(json_agg(j.focus_area_id ORDER BY j.focus_area_id), '[]'::json) + FROM training_framework_program_focus_areas j + WHERE j.framework_program_id = fp.id + ) AS focus_area_ids, + ( + SELECT COALESCE(json_agg(j.style_direction_id ORDER BY j.style_direction_id), '[]'::json) + FROM training_framework_program_style_directions j + WHERE j.framework_program_id = fp.id + ) AS style_direction_ids, + ( + SELECT COALESCE(json_agg(j.training_type_id ORDER BY j.training_type_id), '[]'::json) + FROM training_framework_program_training_types j + WHERE j.framework_program_id = fp.id + ) AS training_type_ids, + ( + SELECT COALESCE(json_agg(j.target_group_id ORDER BY j.target_group_id), '[]'::json) + FROM training_framework_program_target_groups j + WHERE j.framework_program_id = fp.id + ) AS target_group_ids FROM training_framework_programs fp """ vis_clause, vis_params = library_content_visibility_sql( diff --git a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx index 3a499e9..248832b 100644 --- a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx @@ -1,4 +1,10 @@ -import React from 'react' +import React, { useMemo, useState } from 'react' +import { + filterFrameworkPrograms, + frameworkProgramOptionLabel, + frameworkSessionDurationLabel, +} from '../../utils/frameworkProgramListHelpers' +import { formatDurationDisplay } from '../../utils/trainingDurationUtils' /** * Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen. @@ -6,6 +12,9 @@ import React from 'react' export default function TrainingPlanningFrameworkImportModal({ open, frameworkProgramsList, + catalogFocusAreas = [], + catalogTrainingTypes = [], + catalogTargetGroups = [], fwImportProgramId, onProgramChange, fwImportLoading, @@ -23,6 +32,41 @@ export default function TrainingPlanningFrameworkImportModal({ onSubmit, onClose, }) { + const [filterQuery, setFilterQuery] = useState('') + const [filterFocusIds, setFilterFocusIds] = useState([]) + const [filterTypeIds, setFilterTypeIds] = useState([]) + const [filterTargetGroupIds, setFilterTargetGroupIds] = useState([]) + const [filterDurationMin, setFilterDurationMin] = useState('') + + const filteredPrograms = useMemo( + () => + filterFrameworkPrograms(frameworkProgramsList, { + query: filterQuery, + focusAreaIds: filterFocusIds, + trainingTypeIds: filterTypeIds, + targetGroupIds: filterTargetGroupIds, + durationTargetMin: filterDurationMin === '' ? null : parseInt(filterDurationMin, 10), + }), + [ + frameworkProgramsList, + filterQuery, + filterFocusIds, + filterTypeIds, + filterTargetGroupIds, + filterDurationMin, + ] + ) + + const selectedProgramSummary = useMemo(() => { + if (!fwImportProgramId) return null + return (frameworkProgramsList || []).find((p) => String(p.id) === String(fwImportProgramId)) + }, [frameworkProgramsList, fwImportProgramId]) + + const toggleId = (list, setList, id) => { + const s = String(id) + setList((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s])) + } + if (!open) return null return ( @@ -48,7 +92,7 @@ export default function TrainingPlanningFrameworkImportModal({ background: 'var(--surface)', borderRadius: '12px', padding: 'clamp(14px, 3vw, 1.75rem)', - maxWidth: 'min(620px, 100%)', + maxWidth: 'min(680px, 100%)', width: '100%', maxHeight: '90vh', overflowY: 'auto', @@ -60,9 +104,104 @@ export default function TrainingPlanningFrameworkImportModal({

Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '} eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '} - Verknüpfung zum Rahmen-Slot wird gespeichert, damit die Herkunft sichtbar bleibt. + Verknüpfung zum Rahmen-Slot bleibt sichtbar.

+
+ Rahmen filtern (optional) +
+
+ + setFilterQuery(e.target.value)} + placeholder="z. B. Gürtel, Koordination …" + disabled={fwImportSubmitting} + /> +
+
+ + setFilterDurationMin(e.target.value)} + placeholder="z. B. 90" + disabled={fwImportSubmitting} + /> +

+ Zeigt Programme, deren hinterlegte Session-Dauer in etwa passt (±10 Min). +

+
+ {catalogFocusAreas.length > 0 ? ( +
+ + Fokusbereich + +
+ {catalogFocusAreas.map((fa) => ( + + ))} +
+
+ ) : null} + {catalogTrainingTypes.length > 0 ? ( +
+ + Trainingsart + +
+ {catalogTrainingTypes.map((t) => ( + + ))} +
+
+ ) : null} + {catalogTargetGroups.length > 0 ? ( +
+ + Zielgruppe + +
+ {catalogTargetGroups.map((tg) => ( + + ))} +
+
+ ) : null} +

+ {filteredPrograms.length} von {frameworkProgramsList.length} Rahmenprogramm(en) sichtbar. + Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. +

+
+
+
+ {selectedProgramSummary ? ( +

+ Session-Dauer: {frameworkSessionDurationLabel(selectedProgramSummary)} + {selectedProgramSummary.goal_titles_agg ? ( + <> + {' '} + · Ziele: {selectedProgramSummary.goal_titles_agg} + + ) : null} +

+ ) : null}
{fwImportLoading ? ( @@ -96,6 +246,10 @@ export default function TrainingPlanningFrameworkImportModal({ const checked = fwImportSelectedSlots.has(slot.id) const label = (slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}` + const slotDur = + slot.planned_duration_min != null + ? formatDurationDisplay(slot.planned_duration_min) + : null return (