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/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..f817e15 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 @@ -348,10 +444,47 @@ 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 - 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 +536,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 +552,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 +570,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 +623,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 +630,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 +690,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/app.css b/frontend/src/app.css index 29877e8..958b06a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2143,6 +2143,352 @@ html.modal-scroll-locked .app-main { } } +/* Rahmen-Import (Planung): großer Dialog + Filter */ +.fw-import-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1010; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + overflow-y: auto; + background: rgba(0, 0, 0, 0.5); +} +.fw-import-modal-panel { + width: 100%; + max-width: min(920px, 96vw); + max-height: min(94vh, 1200px); + overflow-y: auto; + box-sizing: border-box; + padding: clamp(18px, 3vw, 2rem); + border-radius: 14px; + background: var(--surface); +} +.fw-import-modal-panel__title { + margin: 0 0 0.5rem; + font-size: 1.35rem; +} +.fw-import-modal-panel__lead { + margin: 0 0 1.1rem; + font-size: 0.92rem; + line-height: 1.55; + color: var(--text2); + max-width: 52rem; +} +.fw-import-results-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + margin-bottom: 0.85rem; + padding: 12px 14px; + border-radius: 10px; + background: var(--surface2); + border: 1px solid var(--border); +} +.fw-import-results-bar__count { + font-size: 0.95rem; + color: var(--text1); +} +.fw-import-results-bar__num { + font-size: 1.25rem; + color: var(--accent-dark); +} +.fw-import-results-bar__warn { + color: var(--danger); + font-weight: 600; +} +.fw-import-results-bar__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.fw-import-filter-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + background: color-mix(in srgb, var(--accent) 18%, var(--surface)); + color: var(--accent-dark); + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); +} +.fw-import-filter-chips { + list-style: none; + margin: 0 0 12px; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.fw-import-filter-chip { + font-size: 0.78rem; + padding: 4px 10px; + border-radius: 8px; + background: var(--surface2); + border: 1px solid var(--border); + color: var(--text2); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} +.fw-import-filter-panel { + margin-bottom: 1.15rem; + padding: 16px 18px; + border-radius: 12px; + background: var(--surface2); + border: 1px solid var(--border); +} +.fw-import-filter-panel__grid { + display: grid; + gap: 14px; +} +.fw-import-filter-panel__search { + margin-bottom: 0; +} +.fw-import-filter-panel__hint { + margin: 12px 0 0; +} +.fw-import-duration-fieldset { + border: none; + margin: 0; + padding: 0; +} +.fw-import-duration-mode { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin: 8px 0 10px; +} +.fw-import-duration-mode__opt { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.88rem; + cursor: pointer; +} +.fw-import-duration-range { + margin-top: 4px; +} +.fw-import-duration-presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} +.fw-import-duration-preset--on { + background: var(--accent-dark) !important; + color: #fff !important; + border-color: var(--accent-dark) !important; +} +.fw-import-catalog-block .form-label { + display: block; + margin-bottom: 6px; +} +.fw-import-program-select__count { + font-weight: 500; + color: var(--text3); + font-size: 0.85em; +} +.fw-import-muted { + color: var(--text2); + margin-top: 0.75rem; +} +.fw-import-sessions { + border: none; + margin: 1rem 0; + padding: 0; +} +.fw-import-sessions__list { + list-style: none; + margin: 0; + padding: 0; +} +.fw-import-sessions__item { + margin-bottom: 10px; +} +.fw-import-sessions__label { + display: flex; + gap: 10px; + align-items: flex-start; + cursor: pointer; +} +.fw-import-sessions__label--disabled { + cursor: not-allowed; + opacity: 0.55; +} +.fw-import-sessions__check { + margin-top: 0.2rem; + flex-shrink: 0; +} +.fw-import-sessions__body { + flex: 1; + min-width: 0; +} +.fw-import-sessions__title-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 10px; +} +.fw-import-sessions__dur { + font-size: 0.82rem; + font-weight: 600; + color: var(--accent-dark); +} +.fw-import-sessions__dur--muted { + color: var(--text3); + font-weight: 500; +} +.fw-import-sessions__warn { + display: block; + font-size: 0.82rem; + color: var(--danger); + margin-top: 4px; +} +.fw-import-sessions__date { + display: block; + margin-top: 8px; +} +.fw-import-sessions__date .form-input { + max-width: 220px; + margin-top: 4px; +} +.fw-import-dates-panel { + margin-bottom: 0.75rem; + padding: 14px; + border-radius: 10px; + background: var(--surface2); +} +.fw-import-dates-panel__action { + align-self: end; +} +.fw-import-dates-panel__action .btn { + width: 100%; +} +.fw-import-modal-panel__footer { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1.25rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); +} + +/* Rahmen-Import: Mobile (Vollbild wie .modal-panel--form) */ +@media (max-width: 639px) { + .fw-import-modal-backdrop { + align-items: stretch; + justify-content: stretch; + padding: 0; + } + .fw-import-modal-panel { + max-width: 100%; + width: 100%; + max-height: 100dvh; + height: 100dvh; + border-radius: 0; + padding: 12px; + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + padding-top: max(12px, env(safe-area-inset-top, 0px)); + padding-bottom: max(12px, env(safe-area-inset-bottom, 0px)); + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + touch-action: pan-y; + } + .fw-import-modal-panel__title { + font-size: 1.05rem; + padding-right: 4px; + } + .fw-import-modal-panel__lead { + font-size: 0.85rem; + margin-bottom: 0.85rem; + } + .fw-import-results-bar { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + .fw-import-results-bar__actions { + width: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + .fw-import-results-bar__actions .fw-import-filter-badge { + grid-column: 1 / -1; + text-align: center; + } + .fw-import-results-bar__actions .btn { + width: 100%; + min-height: 44px; + justify-content: center; + } + .fw-import-filter-panel { + padding: 12px; + } + .fw-import-duration-mode { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + .fw-import-duration-mode__opt { + min-height: 44px; + padding: 8px 10px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border2); + } + .fw-import-duration-presets .btn { + min-height: 44px; + flex: 1 1 calc(50% - 4px); + min-width: calc(50% - 4px); + } + .fw-import-filter-panel .framework-catalog-checkgrid { + max-height: min(180px, 28vh); + } + .fw-import-sessions__date .form-input { + max-width: 100%; + width: 100%; + } + .fw-import-modal-panel__footer { + flex-direction: column; + gap: 8px; + padding-bottom: max(8px, env(safe-area-inset-bottom, 0px)); + } + .fw-import-modal-panel__footer .btn { + width: 100%; + min-height: 48px; + } + .fw-import-modal-panel__footer .btn-primary { + order: -1; + } +} + +@media (max-width: 380px) { + .fw-import-results-bar__actions { + grid-template-columns: 1fr; + } +} + +/* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */ +.page-form-editor__body .framework-edit { + min-width: 0; +} +.framework-edit__danger-zone { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} +.framework-edit__delete-btn:hover { + border-color: var(--danger); + color: var(--danger); +} + /* Rahmenprogramm-Editor: Kurz-Einstieg ausklappbar */ .framework-edit-intro { margin-bottom: 1rem; @@ -5040,7 +5386,370 @@ html.modal-scroll-locked .app-main { border-color: var(--border2); } -/* Liste Rahmenprogramme: Abstand nur über gap (kein .card+.card zwischen li) */ +/* Rahmenprogramm-Filter auf Übersichtsseite */ +.fw-prog-filter-block--list { + margin-bottom: 1rem; +} +.fw-prog-filter-block--list .fw-import-filter-panel { + margin-top: 0.75rem; +} + +/* —— Rahmenprogramm-Bibliothek (Liste) —— */ +.fw-prog-page__header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem 1.25rem; + margin-bottom: 1.25rem; +} +.fw-prog-page__intro { + flex: 1 1 16rem; + min-width: 0; + max-width: 40rem; +} +.fw-prog-page__title { + margin-bottom: 0.35rem; +} +.fw-prog-page__lead { + margin: 0; + color: var(--text2); + font-size: 0.95rem; + line-height: 1.55; + max-width: 36rem; +} +.fw-prog-page__help { + margin-top: 10px; + max-width: 36rem; +} +.fw-prog-page__cta { + text-decoration: none; + white-space: nowrap; + flex-shrink: 0; +} +.fw-prog-page__error { + border-left: 4px solid var(--danger); + margin-bottom: 1rem; + color: var(--text1); +} +.fw-prog-page__loading { + text-align: center; + padding: 2.5rem 1.5rem; +} +.fw-prog-page__loading p { + margin: 0.75rem 0 0; + color: var(--text2); +} +.fw-prog-page__empty { + text-align: center; + padding: 2rem 1.25rem 1.75rem; +} +.fw-prog-page__empty--filter { + margin-top: 1rem; + padding: 1.5rem 1.25rem; +} +.fw-prog-page__empty-icon { + font-size: 2.25rem; + line-height: 1; + margin-bottom: 0.75rem; + opacity: 0.85; +} +.fw-prog-page__empty-title { + margin: 0 0 0.5rem; + font-size: 1.1rem; + font-weight: 700; + color: var(--text1); +} +.fw-prog-page__empty-text { + margin: 0 0 1.25rem; + color: var(--text2); + font-size: 0.92rem; + line-height: 1.5; + max-width: 28rem; + margin-left: auto; + margin-right: auto; +} +.fw-prog-page__empty .btn-full { + max-width: 20rem; + margin: 0 auto; + text-decoration: none; +} + +.fw-prog-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 14px; +} +.fw-prog-list > li { + margin: 0; + min-width: 0; +} + +.fw-prog-card { + position: relative; + overflow: hidden; + padding: 0; + margin-bottom: 0; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +.fw-prog-card:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06); +} +.fw-prog-card__accent { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-dark) 100%); + border-radius: 12px 0 0 12px; +} +.fw-prog-card__inner { + padding: 1.1rem 1.15rem 1.15rem 1.35rem; + min-width: 0; +} +.fw-prog-card__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem 1.25rem; + margin-bottom: 0.85rem; +} +.fw-prog-card__title-block { + flex: 1 1 12rem; + min-width: 0; +} +.fw-prog-card__title { + margin: 0; + font-size: 1.12rem; + font-weight: 700; + line-height: 1.3; +} +.fw-prog-card__title-link { + color: var(--accent-dark); + text-decoration: none; + word-break: break-word; +} +.fw-prog-card__title-link:hover { + text-decoration: underline; +} +.fw-prog-card__desc { + margin: 0.45rem 0 0; + font-size: 0.9rem; + line-height: 1.5; + color: var(--text2); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.fw-prog-card__stats { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; +} +.fw-prog-card__stat { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 4.5rem; + padding: 0.45rem 0.65rem; + border-radius: 10px; + background: var(--surface2); + border: 1px solid var(--border); + text-align: center; +} +.fw-prog-card__stat--duration { + background: var(--accent-light); + border-color: color-mix(in srgb, var(--accent) 25%, var(--border)); +} +.fw-prog-card__stat--duration .fw-prog-card__stat-value { + color: var(--accent-dark); + font-weight: 700; +} +.fw-prog-card__stat--muted .fw-prog-card__stat-value { + color: var(--text3); + font-weight: 500; + font-size: 0.78rem; +} +.fw-prog-card__stat-label { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); + margin-bottom: 2px; +} +.fw-prog-card__stat-value { + font-size: 0.82rem; + font-weight: 600; + color: var(--text1); + line-height: 1.25; + max-width: 6.5rem; + word-break: break-word; +} +.fw-prog-card__section { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} +.fw-prog-card__section-title { + margin: 0 0 0.5rem; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text3); +} +.fw-prog-card__goal-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 6px 8px; +} +.fw-prog-card__goal { + font-size: 0.84rem; + font-weight: 600; + color: var(--text1); + padding: 5px 11px; + border-radius: 999px; + background: var(--accent-light); + border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border)); + line-height: 1.35; + max-width: 100%; + word-break: break-word; +} +.fw-prog-card__catalog { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); + gap: 0.65rem 1rem; +} +.fw-prog-card__catalog-group { + min-width: 0; +} +.fw-prog-card__catalog-label { + display: block; + font-size: 0.72rem; + font-weight: 600; + color: var(--text3); + margin-bottom: 4px; +} +.fw-prog-card__chip-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 4px 6px; +} +.fw-prog-card__chip { + font-size: 0.78rem; + padding: 3px 8px; + border-radius: 6px; + background: var(--surface2); + color: var(--text2); + border: 1px solid var(--border); + line-height: 1.35; + word-break: break-word; +} +.fw-prog-card__catalog-group--focus .fw-prog-card__chip { + border-color: color-mix(in srgb, var(--accent) 18%, var(--border)); + background: color-mix(in srgb, var(--accent-light) 60%, var(--surface2)); +} +.fw-prog-card__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 1rem; + padding-top: 0.85rem; + border-top: 1px solid var(--border); +} +.fw-prog-card__btn-primary { + text-decoration: none; + flex: 1 1 auto; + min-width: 7rem; + justify-content: center; +} +.fw-prog-card__btn-danger { + flex: 0 1 auto; +} +.fw-prog-card__btn-danger:hover { + border-color: var(--danger); + color: var(--danger); +} + +@media (min-width: 640px) { + .fw-prog-card__actions { + justify-content: flex-end; + } + .fw-prog-card__btn-primary { + flex: 0 1 auto; + } +} + +@media (min-width: 900px) { + .fw-prog-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; + } + .fw-prog-card { + height: 100%; + display: flex; + flex-direction: column; + } + .fw-prog-card__inner { + flex: 1; + display: flex; + flex-direction: column; + } + .fw-prog-card__actions { + margin-top: auto; + } +} + +@media (max-width: 479px) { + .fw-prog-page__header { + flex-direction: column; + } + .fw-prog-page__cta { + width: 100%; + justify-content: center; + } + .fw-prog-card__head { + flex-direction: column; + } + .fw-prog-card__stats { + width: 100%; + justify-content: stretch; + } + .fw-prog-card__stat { + flex: 1 1 0; + min-width: 0; + } + .fw-prog-card__catalog { + grid-template-columns: 1fr; + } + .fw-prog-card__actions .btn { + width: 100%; + justify-content: center; + } +} + +/* Legacy-Klasse (falls noch referenziert) */ .framework-programs-list { list-style: none; padding: 0; 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/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx new file mode 100644 index 0000000..2ccd4ed --- /dev/null +++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx @@ -0,0 +1,134 @@ +import React from 'react' +import NavStateLink from '../NavStateLink' +import { + frameworkSessionDurationLabel, + splitFrameworkCommaAgg, + splitFrameworkGoalsAgg, + frameworkProgramHasCatalogMeta, +} from '../../utils/frameworkProgramListHelpers' + +function CatalogGroup({ label, items, variant }) { + if (!items.length) return null + return ( +
+ {label} + +
+ ) +} + +/** + * Einzelkarte für die Rahmenprogramm-Bibliothek. + */ +export default function FrameworkProgramListCard({ row, returnContext, onDelete }) { + const title = (row.title || '').trim() || `Rahmen #${row.id}` + const description = (row.description || '').trim() + const durationLabel = frameworkSessionDurationLabel(row) + const hasDuration = durationLabel !== 'Dauer nicht angegeben' + const goals = splitFrameworkGoalsAgg(row.goal_titles_agg) + const focusAreas = splitFrameworkCommaAgg(row.focus_area_names_agg) + const styleDirs = splitFrameworkCommaAgg( + row.style_direction_names_agg || row.style_direction_name + ) + const trainingTypes = splitFrameworkCommaAgg(row.training_type_names_agg) + const targetGroups = splitFrameworkCommaAgg(row.target_group_names_agg) + const goalsCount = Number(row.goals_count) + const slotsCount = Number(row.slots_count) + const showCatalog = frameworkProgramHasCatalogMeta(row) + + return ( +
+
+ ) +} diff --git a/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx b/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx new file mode 100644 index 0000000..0f318c0 --- /dev/null +++ b/frontend/src/components/planning/FrameworkProgramsFilterBlock.jsx @@ -0,0 +1,299 @@ +import React, { useMemo } from 'react' +import { + collectDistinctSessionDurationsMinutes, + EMPTY_FRAMEWORK_IMPORT_FILTERS, + filterFrameworkPrograms, + hasActiveFrameworkImportFilters, + summarizeFrameworkImportFilters, +} from '../../utils/frameworkProgramListHelpers' +import { formatDurationDisplay } from '../../utils/trainingDurationUtils' + +/** + * Gemeinsamer Filter für Rahmenprogramm-Liste und Import-Dialog. + */ +export default function FrameworkProgramsFilterBlock({ + programs = [], + filters, + onFiltersChange, + panelOpen = true, + onPanelOpenChange, + catalogFocusAreas = [], + catalogTrainingTypes = [], + catalogTargetGroups = [], + disabled = false, + durationRadioName = 'fw-duration-mode', + showHint = true, + className = '', +}) { + const distinctDurations = useMemo( + () => collectDistinctSessionDurationsMinutes(programs), + [programs] + ) + + const matchCount = useMemo( + () => filterFrameworkPrograms(programs, filters).length, + [programs, filters] + ) + + const totalCount = (programs || []).length + const filterActive = hasActiveFrameworkImportFilters(filters) + const filterSummaryParts = useMemo( + () => + summarizeFrameworkImportFilters(filters, { + focusAreas: catalogFocusAreas, + trainingTypes: catalogTrainingTypes, + targetGroups: catalogTargetGroups, + }), + [filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups] + ) + + const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch })) + + const clearFilters = () => onFiltersChange({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }) + + const toggleId = (key, id) => { + const s = String(id) + onFiltersChange((prev) => { + const cur = prev[key] || [] + const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s] + return { ...prev, [key]: next } + }) + } + + const togglePanel = () => { + if (onPanelOpenChange) onPanelOpenChange(!panelOpen) + } + + return ( +
+
+
+ {matchCount} + + {' '} + von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'} + + {matchCount === 0 && totalCount > 0 ? ( + — kein Treffer + ) : null} +
+
+ {filterActive ? ( + + Filter aktiv + + ) : null} + {filterActive ? ( + + ) : null} + {onPanelOpenChange ? ( + + ) : null} +
+
+ + {!panelOpen && filterActive && filterSummaryParts.length > 0 ? ( + + ) : null} + + {panelOpen ? ( +
+
+
+ + updateFilter({ query: e.target.value })} + placeholder="z. B. Gürtel, Koordination …" + disabled={disabled} + /> +
+ +
+ Ziel-Session-Dauer +
+ {[ + { id: 'any', label: 'Alle' }, + { id: 'range', label: 'Zeitspanne' }, + { id: 'preset', label: 'Vorhandene Zeiten' }, + ].map((opt) => ( + + ))} +
+ + {filters.durationMode === 'range' ? ( +
+
+ + + updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value }) + } + placeholder="z. B. 60" + disabled={disabled} + /> +
+
+ + + updateFilter({ durationMode: 'range', durationRangeTo: e.target.value }) + } + placeholder="z. B. 90" + disabled={disabled} + /> +
+
+ ) : null} + + {filters.durationMode === 'preset' ? ( + distinctDurations.length === 0 ? ( +

+ In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege + Dauer pro Session im Rahmenprogramm fest. +

+ ) : ( +
+ {distinctDurations.map((min) => { + const on = filters.durationPresetMin === min + return ( + + ) + })} +
+ ) + ) : null} +
+ + {catalogFocusAreas.length > 0 ? ( +
+ Fokusbereich +
+ {catalogFocusAreas.map((fa) => ( + + ))} +
+
+ ) : null} + + {catalogTrainingTypes.length > 0 ? ( +
+ Trainingsart +
+ {catalogTrainingTypes.map((t) => ( + + ))} +
+
+ ) : null} + + {catalogTargetGroups.length > 0 ? ( +
+ Zielgruppe +
+ {catalogTargetGroups.map((tg) => ( + + ))} +
+
+ ) : null} +
+ {showHint ? ( +

+ Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur + Programme mit hinterlegter Session-Dauer berücksichtigt. +

+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx index 3a499e9..cbadef2 100644 --- a/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningFrameworkImportModal.jsx @@ -1,4 +1,12 @@ -import React from 'react' +import React, { useEffect, useMemo, useState } from 'react' +import FrameworkProgramsFilterBlock from './FrameworkProgramsFilterBlock' +import { + EMPTY_FRAMEWORK_IMPORT_FILTERS, + filterFrameworkPrograms, + frameworkProgramOptionLabel, + frameworkSessionDurationLabel, +} from '../../utils/frameworkProgramListHelpers' +import { formatDurationDisplay } from '../../utils/trainingDurationUtils' /** * Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen. @@ -6,6 +14,9 @@ import React from 'react' export default function TrainingPlanningFrameworkImportModal({ open, frameworkProgramsList, + catalogFocusAreas = [], + catalogTrainingTypes = [], + catalogTargetGroups = [], fwImportProgramId, onProgramChange, fwImportLoading, @@ -23,72 +34,104 @@ export default function TrainingPlanningFrameworkImportModal({ onSubmit, onClose, }) { + const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })) + const [filterPanelOpen, setFilterPanelOpen] = useState(true) + + useEffect(() => { + if (!open) { + setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }) + setFilterPanelOpen(true) + } + }, [open]) + + const filteredPrograms = useMemo( + () => filterFrameworkPrograms(frameworkProgramsList, filters), + [frameworkProgramsList, filters] + ) + + const matchCount = filteredPrograms.length + + useEffect(() => { + if (!fwImportProgramId) return + const stillVisible = filteredPrograms.some((p) => String(p.id) === String(fwImportProgramId)) + if (!stillVisible) onProgramChange('') + }, [filteredPrograms, fwImportProgramId, onProgramChange]) + + const selectedProgramSummary = useMemo(() => { + if (!fwImportProgramId) return null + return (frameworkProgramsList || []).find((p) => String(p.id) === String(fwImportProgramId)) + }, [frameworkProgramsList, fwImportProgramId]) + if (!open) return null return (
-
-

Sessions aus Rahmen übernehmen

-

+

+

Sessions aus Rahmen übernehmen

+

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. + eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs).

-
- + + +
+ + {selectedProgramSummary ? ( +

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

+ ) : null}
{fwImportLoading ? ( -

Laden der Sessions…

+

Laden der Sessions…

) : fwImportDetail?.slots?.length ? ( <> -
- - Sessions (mit Ablauf) - -
    +
    + Sessions (mit Ablauf) +
      {[...fwImportDetail.slots] .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) .map((slot) => { @@ -96,40 +139,46 @@ 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 ( -
    • +
    -
    +
    -
    +
    ) : fwImportProgramId ? ( -

    Keine Sessions in diesem Programm.

    +

    Keine Sessions in diesem Programm.

    ) : null} -
    +
    +
    + + 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, @@ -494,6 +498,22 @@ export default function TrainingFrameworkProgramEditPage() { await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: true }) } + const actionConfig = useMemo( + () => ({ + saving, + isNew, + onSave: handleSave, + onSaveAndClose: handleSaveAndClose, + onCancel: goBack, + showSave: true, + showSaveAndClose: true, + cancelLabel: 'Abbrechen', + }), + [saving, isNew, goBack, handleSave, handleSaveAndClose] + ) + + const pageTitle = isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten' + const handleUnsavedDialogSave = async () => { const ok = await performFrameworkSave({ fromUnsavedDialog: true }) if (ok) blocker.proceed() @@ -544,14 +564,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 +580,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 +827,20 @@ export default function TrainingFrameworkProgramEditPage() {
    +
    + + slotField(si, 'planned_duration_min', e.target.value)} + placeholder="z. B. 1,5 h" + /> +
    +
    Notizen (Session)
    @@ -854,10 +909,12 @@ export default function TrainingFrameworkProgramEditPage() { } return ( -
    +
    -

    {isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}

    -
    Kurz erklärt: Was ist ein Rahmenprogramm? @@ -910,35 +967,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 +1052,6 @@ export default function TrainingFrameworkProgramEditPage() {
    -
    -
    - - updateField('planned_period_start', e.target.value)} - /> -
    -
    - - updateField('planned_period_end', e.target.value)} - /> -
    -
    -
    @@ -1033,6 +1081,14 @@ export default function TrainingFrameworkProgramEditPage() {
    + + {!isNew ? ( +
    + +
    + ) : null}
    - - - {!isNew ? ( - - ) : null}
    setBypassDirty(true)} /> -
    + ) } diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx index a1f86c9..d504c20 100644 --- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx @@ -1,61 +1,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import api from '../utils/api' import NavStateLink from '../components/NavStateLink' +import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock' +import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard' import { useAuth } from '../context/AuthContext' import { getTenantClubDependencyKey } from '../utils/activeClub' import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext' - -function dashIfEmpty(val) { - const s = (val ?? '').toString().trim() - return s.length ? s : '—' -} - -function FrameworkSummaryMeta({ r }) { - const trainingTypes = - 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 rowStyle = { - display: 'grid', - gridTemplateColumns: 'minmax(6.5rem, 32%) 1fr', - gap: '0.25rem 0.75rem', - alignItems: 'start', - marginTop: '0.35rem', - lineHeight: 1.45, - } - - return ( -
    -
    -
    Fokusbereich
    -
    {dashIfEmpty(focus)}
    -
    - {styleDir ? ( -
    -
    Stilrichtung
    -
    {styleDir}
    -
    - ) : null} -
    -
    Trainingsarten
    -
    {trainingTypes.length ? trainingTypes : '—'}
    -
    -
    -
    Zielgruppen
    -
    {targetGroups.length ? targetGroups : '—'}
    -
    -
    -
    Kurzbeschreibung
    -
    - {(r.description && String(r.description).trim()) || '—'} -
    -
    -
    - ) -} +import { + EMPTY_FRAMEWORK_IMPORT_FILTERS, + filterFrameworkPrograms, + hasActiveFrameworkImportFilters, +} from '../utils/frameworkProgramListHelpers' export default function TrainingFrameworkProgramsListPage() { const { user } = useAuth() @@ -64,16 +19,38 @@ export default function TrainingFrameworkProgramsListPage() { const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [catalogFocusAreas, setCatalogFocusAreas] = useState([]) + const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([]) + const [catalogTargetGroups, setCatalogTargetGroups] = useState([]) + const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })) + const [filterPanelOpen, setFilterPanelOpen] = useState(true) + + const filteredRows = useMemo( + () => filterFrameworkPrograms(rows, filters), + [rows, filters] + ) + const filterActive = hasActiveFrameworkImportFilters(filters) const load = useCallback(async () => { setLoading(true) setError('') try { - const list = await api.listTrainingFrameworkPrograms() + const [list, fa, tt, tg] = await Promise.all([ + api.listTrainingFrameworkPrograms(), + api.listFocusAreas({ status: 'active' }), + api.listTrainingTypes({ status: 'active' }), + api.listTargetGroups({ status: 'active' }), + ]) setRows(Array.isArray(list) ? list : []) + setCatalogFocusAreas(Array.isArray(fa) ? fa : []) + setCatalogTrainingTypes(Array.isArray(tt) ? tt : []) + setCatalogTargetGroups(Array.isArray(tg) ? tg : []) } catch (e) { setError(e.message || 'Laden fehlgeschlagen') setRows([]) + setCatalogFocusAreas([]) + setCatalogTrainingTypes([]) + setCatalogTargetGroups([]) } finally { setLoading(false) } @@ -94,116 +71,99 @@ export default function TrainingFrameworkProgramsListPage() { } return ( - <> -
    +
    +
    +

    Trainingsrahmenprogramme

    +

    + Vorlagen für Entwicklungsziele und Sessions — die Übernahme in Gruppentermine erfolgt in der + Trainingsplanung. +

    +
    + Mehr zur Übernahme in die Planung +
    + Unter Planung wählst du eine Gruppe und übernimmst Slots aus einem Rahmenprogramm in + echte Termine. So bleibt die Bibliothek wiederverwendbar, ohne dass Einzelgruppen fest verdrahtet sind. +
    +
    +
    + -
    -

    - Trainingsrahmenprogramme -

    -

    - Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der - Trainingsplanung (Registerkarte oben). -

    -
    - Mehr zur Übernahme in die Planung -
    - Unter Planung wählst du eine Gruppe und übernimmst Slots aus einem Rahmenprogramm in - echte Termine. So bleibt die Bibliothek wiederverwendbar, ohne dass Einzelgruppen fest verdrahtet sind. -
    -
    + Rahmenprogramm anlegen + +
    + + {error ? ( +
    + {error} +
    + ) : null} + + {loading ? ( +
    + + ) : rows.length === 0 ? ( +
    + +

    Noch keine Rahmenprogramme

    +

    + Lege ein neues Programm an — mit Titel, mindestens einem Entwicklungsziel und optional Sessions samt + Übungsablauf. +

    - Rahmenprogramm anlegen + Erstes Rahmenprogramm anlegen
    + ) : ( + <> + - {error && ( -
    - {error} -
    - )} - - {loading ? ( -
    -
    -

    Laden…

    -
    - ) : rows.length === 0 ? ( -
    -

    - Noch kein Rahmenprogramm gespeichert. Lege ein neues an — mit Titel, mindestens einem Ziel und optional - Slots samt Übungen. -

    - - Rahmenprogramm anlegen - -
    - ) : ( -
      - {rows.map((r) => ( -
    • -
      -
      - - {r.title || `Rahmen #${r.id}`} - -
      - - {(r.goals_count ?? '—') + ' Ziele · '} - {(r.slots_count ?? '—') + ' Slots'} - -
      - -
      -
      - - Bearbeiten - - -
      -
      -
    • - ))} -
    - )} - + {filteredRows.length === 0 ? ( +
    +

    Kein Treffer

    +

    + {filterActive + ? 'Kein Rahmenprogramm passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.' + : 'Keine Einträge.'} +

    +
    + ) : ( +
      + {filteredRows.map((r) => ( +
    • + +
    • + ))} +
    + )} + + )} +
    ) } diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index 6ab62e1..ecea0db 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -119,13 +119,14 @@ export default function TrainingModulesListPage() {

    - Bearbeiten - + diff --git a/frontend/src/utils/frameworkProgramListHelpers.js b/frontend/src/utils/frameworkProgramListHelpers.js new file mode 100644 index 0000000..4621f04 --- /dev/null +++ b/frontend/src/utils/frameworkProgramListHelpers.js @@ -0,0 +1,223 @@ +import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils' + +export function frameworkSessionDurationLabel(row) { + return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, { + empty: 'Dauer nicht angegeben', + }) +} + +/** Komma-getrennte Aggregat-Strings aus der Listen-API in Einträge zerlegen. */ +export function splitFrameworkCommaAgg(value) { + const s = (value ?? '').toString().trim() + if (!s) return [] + return s + .split(',') + .map((x) => x.trim()) + .filter(Boolean) +} + +/** Entwicklungsziele aus goal_titles_agg (Trenner „|“). */ +export function splitFrameworkGoalsAgg(value) { + const s = (value ?? '').toString().trim() + if (!s) return [] + return s + .split('|') + .map((x) => x.trim()) + .filter(Boolean) +} + +export function frameworkProgramHasCatalogMeta(row) { + return ( + splitFrameworkCommaAgg(row?.focus_area_names_agg).length > 0 || + splitFrameworkCommaAgg(row?.style_direction_names_agg).length > 0 || + splitFrameworkCommaAgg(row?.training_type_names_agg).length > 0 || + splitFrameworkCommaAgg(row?.target_group_names_agg).length > 0 + ) +} + +function parseIdList(raw) { + if (Array.isArray(raw)) { + return raw.map((x) => String(x)).filter(Boolean) + } + if (typeof raw === 'string' && raw.trim().startsWith('[')) { + try { + const arr = JSON.parse(raw) + if (Array.isArray(arr)) return arr.map((x) => String(x)) + } catch { + /* ignore */ + } + } + return [] +} + +/** Eindeutige Session-Dauern (Minuten) aus allen Rahmen in der Liste. */ +export function collectDistinctSessionDurationsMinutes(rows) { + const set = new Set() + for (const r of rows || []) { + const lo = r.session_duration_min + const hi = r.session_duration_max + if (lo != null && lo !== '' && Number.isFinite(Number(lo))) set.add(Number(lo)) + if (hi != null && hi !== '' && Number.isFinite(Number(hi))) set.add(Number(hi)) + } + return [...set].sort((a, b) => a - b) +} + +function programDurationBounds(row) { + const lo = row.session_duration_min != null ? Number(row.session_duration_min) : null + const hi = row.session_duration_max != null ? Number(row.session_duration_max) : null + if (lo != null && Number.isFinite(lo) && lo > 0) { + const hiEff = hi != null && Number.isFinite(hi) && hi > 0 ? hi : lo + return { lo, hi: hiEff } + } + if (hi != null && Number.isFinite(hi) && hi > 0) { + return { lo: hi, hi } + } + return null +} + +/** Überlappung Programm-Session-Spanne mit Filter-Spanne (Minuten). */ +function rowMatchesDurationRange(row, fromMin, toMin) { + const hasFrom = fromMin != null && fromMin !== '' && !Number.isNaN(Number(fromMin)) + const hasTo = toMin != null && toMin !== '' && !Number.isNaN(Number(toMin)) + if (!hasFrom && !hasTo) return true + + const bounds = programDurationBounds(row) + if (!bounds) return false + + const fLo = hasFrom ? Number(fromMin) : bounds.lo + const fHi = hasTo ? Number(toMin) : hasFrom ? Number(fromMin) : bounds.hi + const filterLo = Math.min(fLo, fHi) + const filterHi = Math.max(fLo, fHi) + + return bounds.lo <= filterHi && bounds.hi >= filterLo +} + +function rowMatchesDurationPreset(row, presetMin, toleranceMin = 10) { + if (presetMin == null || presetMin === '' || Number.isNaN(Number(presetMin))) return true + const t = Number(presetMin) + const bounds = programDurationBounds(row) + if (!bounds) return false + return t >= bounds.lo - toleranceMin && t <= bounds.hi + toleranceMin +} + +export const EMPTY_FRAMEWORK_IMPORT_FILTERS = { + query: '', + focusAreaIds: [], + trainingTypeIds: [], + targetGroupIds: [], + durationMode: 'any', + durationRangeFrom: '', + durationRangeTo: '', + durationPresetMin: null, +} + +export function hasActiveFrameworkImportFilters(filters = {}) { + const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters } + if ((f.query || '').trim()) return true + if ((f.focusAreaIds || []).length) return true + if ((f.trainingTypeIds || []).length) return true + if ((f.targetGroupIds || []).length) return true + if (f.durationMode === 'range') { + if (String(f.durationRangeFrom || '').trim() !== '') return true + if (String(f.durationRangeTo || '').trim() !== '') return true + } + if (f.durationMode === 'preset' && f.durationPresetMin != null) return true + return false +} + +/** + * Kurzbeschreibung aktiver Filter (für Zusammenfassung außerhalb des Panels). + */ +export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) { + const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters } + const parts = [] + const q = (f.query || '').trim() + if (q) parts.push(`Suche: „${q}"`) + + const nameById = (list, id) => list?.find((x) => String(x.id) === String(id))?.name || id + + if ((f.focusAreaIds || []).length) { + const names = f.focusAreaIds.map((id) => nameById(catalogs.focusAreas, id)) + parts.push(`Fokus: ${names.join(', ')}`) + } + if ((f.trainingTypeIds || []).length) { + const names = f.trainingTypeIds.map((id) => nameById(catalogs.trainingTypes, id)) + parts.push(`Trainingsart: ${names.join(', ')}`) + } + if ((f.targetGroupIds || []).length) { + const names = f.targetGroupIds.map((id) => nameById(catalogs.targetGroups, id)) + parts.push(`Zielgruppe: ${names.join(', ')}`) + } + + if (f.durationMode === 'range') { + const a = String(f.durationRangeFrom || '').trim() + const b = String(f.durationRangeTo || '').trim() + if (a || b) { + const fromLbl = a ? formatDurationDisplay(Number(a), { empty: a }) : '—' + const toLbl = b ? formatDurationDisplay(Number(b), { empty: b }) : '—' + parts.push(`Dauer: ${fromLbl} – ${toLbl}`) + } + } else if (f.durationMode === 'preset' && f.durationPresetMin != null) { + parts.push(`Dauer: ${formatDurationDisplay(f.durationPresetMin)}`) + } + + return parts +} + +/** + * Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog). + */ +export function filterFrameworkPrograms(rows, filters = {}) { + const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters } + const q = (f.query || '').trim().toLowerCase() + const focusIds = new Set((f.focusAreaIds || []).map(String)) + const typeIds = new Set((f.trainingTypeIds || []).map(String)) + const tgIds = new Set((f.targetGroupIds || []).map(String)) + + return (rows || []).filter((r) => { + if (q) { + const blob = [ + r.title, + r.description, + r.goal_titles_agg, + r.focus_area_names_agg, + r.style_direction_names_agg, + r.training_type_names_agg, + r.target_group_names_agg, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + if (!blob.includes(q)) return false + } + + if (focusIds.size) { + const fa = parseIdList(r.focus_area_ids) + if (!fa.some((id) => focusIds.has(id))) return false + } + if (typeIds.size) { + const tt = parseIdList(r.training_type_ids) + if (!tt.some((id) => typeIds.has(id))) return false + } + if (tgIds.size) { + const tg = parseIdList(r.target_group_ids) + if (!tg.some((id) => tgIds.has(id))) return false + } + + if (f.durationMode === 'range') { + if (!rowMatchesDurationRange(r, f.durationRangeFrom, f.durationRangeTo)) return false + } else if (f.durationMode === 'preset') { + if (!rowMatchesDurationPreset(r, f.durationPresetMin)) return false + } + + return true + }) +} + +export function frameworkProgramOptionLabel(row) { + const title = (row?.title || '').trim() || `Rahmen #${row?.id}` + const dur = frameworkSessionDurationLabel(row) + const slots = row?.slots_count != null ? `${row.slots_count} Slot(s)` : '' + const bits = [dur !== 'Dauer nicht angegeben' ? dur : null, slots].filter(Boolean) + return bits.length ? `${title} · ${bits.join(' · ')}` : title +} diff --git a/frontend/src/utils/frameworkProgramListHelpers.test.js b/frontend/src/utils/frameworkProgramListHelpers.test.js new file mode 100644 index 0000000..e528d53 --- /dev/null +++ b/frontend/src/utils/frameworkProgramListHelpers.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + collectDistinctSessionDurationsMinutes, + filterFrameworkPrograms, + hasActiveFrameworkImportFilters, + splitFrameworkCommaAgg, + splitFrameworkGoalsAgg, +} from './frameworkProgramListHelpers.js' + +describe('frameworkProgramListHelpers', () => { + const rows = [ + { id: 1, title: 'A', session_duration_min: 60, session_duration_max: 60, focus_area_ids: [1] }, + { id: 2, title: 'B', session_duration_min: 90, session_duration_max: 120, focus_area_ids: [2] }, + { id: 3, title: 'C', session_duration_min: null, session_duration_max: null }, + ] + + it('collectDistinctSessionDurationsMinutes', () => { + expect(collectDistinctSessionDurationsMinutes(rows)).toEqual([60, 90, 120]) + }) + + it('filterFrameworkPrograms duration preset', () => { + const out = filterFrameworkPrograms(rows, { durationMode: 'preset', durationPresetMin: 90 }) + expect(out.map((r) => r.id)).toEqual([2]) + }) + + it('filterFrameworkPrograms duration range overlap', () => { + const out = filterFrameworkPrograms(rows, { + durationMode: 'range', + durationRangeFrom: '75', + durationRangeTo: '100', + }) + expect(out.map((r) => r.id)).toEqual([2]) + }) + + it('hasActiveFrameworkImportFilters', () => { + expect(hasActiveFrameworkImportFilters({})).toBe(false) + expect(hasActiveFrameworkImportFilters({ query: 'x' })).toBe(true) + expect(hasActiveFrameworkImportFilters({ durationMode: 'preset', durationPresetMin: 60 })).toBe(true) + }) + + it('splitFrameworkCommaAgg and splitFrameworkGoalsAgg', () => { + expect(splitFrameworkCommaAgg('Technik, Kondition')).toEqual(['Technik', 'Kondition']) + expect(splitFrameworkCommaAgg('')).toEqual([]) + expect(splitFrameworkGoalsAgg('Gürtel | Koordination')).toEqual(['Gürtel', 'Koordination']) + }) +}) diff --git a/frontend/src/utils/trainingDurationUtils.js b/frontend/src/utils/trainingDurationUtils.js new file mode 100644 index 0000000..32cdb46 --- /dev/null +++ b/frontend/src/utils/trainingDurationUtils.js @@ -0,0 +1,102 @@ +/** + * 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 +} + +/** + * Anzeige für Session-Dauer (ein Slot oder Min/Max über mehrere Sessions). + * @param {number|null|undefined} minMinutes + * @param {number|null|undefined} maxMinutes + */ +export function formatSessionDurationRange(minMinutes, maxMinutes, { empty = '—' } = {}) { + const lo = minMinutes != null && minMinutes !== '' ? Number(minMinutes) : null + const hi = maxMinutes != null && maxMinutes !== '' ? Number(maxMinutes) : null + if (lo != null && Number.isFinite(lo) && lo > 0) { + if (hi != null && Number.isFinite(hi) && hi > 0 && hi !== lo) { + return `${formatDurationDisplay(lo, { empty: '' })} – ${formatDurationDisplay(hi, { empty: '' })}` + } + return formatDurationDisplay(lo, { empty }) + } + if (hi != null && Number.isFinite(hi) && hi > 0) { + return formatDurationDisplay(hi, { empty }) + } + return empty +} 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 +}