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 ? (
+
- Zuordnung
+ Geplante Abschnittsdauer
- applySectionPlanTarget(sIdx, e.target.value)}
+
+ updateSectionField(sIdx, 'planned_duration_min', e.target.value)
+ }
+ placeholder="z. B. 15 oder 0,25 h"
+ />
+
+ ) : null}
+ {enableParallelPhaseControls && useCompactPhaseMoves ? (
+
+
- Standard — eine Ganzgruppe (klassischer Ablauf)
- {planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => (
-
- {o.label}
-
- ))}
-
+ Phase / Gruppe
+
+
+ moveSectionToWholeGroupAbove(sIdx)}
+ >
+ Ganz ↑
+
+ moveSectionToWholeGroupBelow(sIdx)}
+ >
+ Ganz ↓
+
+ {planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list))
+ .filter((o) => o.key.startsWith('par:'))
+ .map((o) => (
+ moveSectionToPlanTarget(sIdx, o.key)}
+ >
+ {o.label.length > 14 ? `${o.label.slice(0, 12)}…` : o.label}
+
+ ))}
+
) : 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}
+
+ {items.map((name) => (
+
+ {name}
+
+ ))}
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+
+ {title}
+
+
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
+ Session
+ {durationLabel}
+
+
+ Ziele
+
+ {Number.isFinite(goalsCount) ? goalsCount : '—'}
+
+
+
+ Sessions
+
+ {Number.isFinite(slotsCount) ? slotsCount : '—'}
+
+
+
+
+
+ {goals.length > 0 ? (
+
+ Entwicklungsziele
+
+ {goals.map((g) => (
+
+ {g}
+
+ ))}
+
+
+ ) : null}
+
+ {showCatalog ? (
+
+ Einordnung
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+ Öffnen
+
+ onDelete(row.id, row.title)}
+ >
+ Löschen
+
+
+
+
+ )
+}
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 ? (
+
+ Filter zurücksetzen
+
+ ) : null}
+ {onPanelOpenChange ? (
+
+ {panelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
+
+ ) : null}
+
+
+
+ {!panelOpen && filterActive && filterSummaryParts.length > 0 ? (
+
+ {filterSummaryParts.map((part) => (
+
+ {part}
+
+ ))}
+
+ ) : null}
+
+ {panelOpen ? (
+
+
+
+ Suche (Titel, Ziele, Katalog)
+ 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) => (
+
+
+ updateFilter({
+ durationMode: opt.id,
+ ...(opt.id === 'any'
+ ? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
+ : {}),
+ })
+ }
+ />
+ {opt.label}
+
+ ))}
+
+
+ {filters.durationMode === 'range' ? (
+
+ ) : 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 (
+
+ updateFilter({
+ durationMode: 'preset',
+ durationPresetMin: on ? null : min,
+ durationRangeFrom: '',
+ durationRangeTo: '',
+ })
+ }
+ >
+ {formatDurationDisplay(min)}
+
+ )
+ })}
+
+ )
+ ) : null}
+
+
+ {catalogFocusAreas.length > 0 ? (
+
+
Fokusbereich
+
+ {catalogFocusAreas.map((fa) => (
+
+ toggleId('focusAreaIds', fa.id)}
+ disabled={disabled}
+ />
+ {fa.name}
+
+ ))}
+
+
+ ) : null}
+
+ {catalogTrainingTypes.length > 0 ? (
+
+
Trainingsart
+
+ {catalogTrainingTypes.map((t) => (
+
+ toggleId('trainingTypeIds', t.id)}
+ disabled={disabled}
+ />
+ {t.name}
+
+ ))}
+
+
+ ) : null}
+
+ {catalogTargetGroups.length > 0 ? (
+
+
Zielgruppe
+
+ {catalogTargetGroups.map((tg) => (
+
+ toggleId('targetGroupIds', tg.id)}
+ disabled={disabled}
+ />
+ {tg.name}
+
+ ))}
+
+
+ ) : 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).
-
-
Rahmenprogramm
+
+
+
+
+ Rahmenprogramm
+
+ {' '}
+ ({matchCount} {matchCount === 1 ? 'Treffer' : 'Treffer'})
+
+
onProgramChange(e.target.value)}
- disabled={fwImportLoading || fwImportSubmitting}
+ disabled={fwImportLoading || fwImportSubmitting || matchCount === 0}
>
- Bitte wählen…
- {frameworkProgramsList.map((fp) => (
+
+ {matchCount === 0 ? 'Kein Rahmenprogramm passt zum Filter' : 'Bitte wählen…'}
+
+ {filteredPrograms.map((fp) => (
- {(fp.title || '').trim() || `Rahmen #${fp.id}`}
+ {frameworkProgramOptionLabel(fp)}
))}
+ {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)
+
-
+
Startdatum (Vorschlag)
-
+
@@ -188,10 +228,10 @@ export default function TrainingPlanningFrameworkImportModal({
>
) : fwImportProgramId ? (
-
Keine Sessions in diesem Programm.
+
Keine Sessions in diesem Programm.
) : null}
-
+
{
try {
- const list = await api.listTrainingFrameworkPrograms()
- if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
+ const [list, fa, tt, tg] = await Promise.all([
+ api.listTrainingFrameworkPrograms(),
+ api.listFocusAreas({ status: 'active' }),
+ api.listTrainingTypes({ status: 'active' }),
+ api.listTargetGroups({ status: 'active' }),
+ ])
+ if (!cancelled) {
+ setFrameworkProgramsList(Array.isArray(list) ? list : [])
+ setFwImportCatalogFocus(Array.isArray(fa) ? fa : [])
+ setFwImportCatalogTypes(Array.isArray(tt) ? tt : [])
+ setFwImportCatalogTargetGroups(Array.isArray(tg) ? tg : [])
+ }
} catch (e) {
if (!cancelled) {
console.error('Rahmenprogramme laden:', e)
setFrameworkProgramsList([])
+ setFwImportCatalogFocus([])
+ setFwImportCatalogTypes([])
+ setFwImportCatalogTargetGroups([])
}
}
})()
@@ -1033,7 +1050,9 @@ function TrainingPlanningPageRoot() {
onClick={() => handleEdit(unit)}
title={[
planScope === 'club' && unit.group_name ? unit.group_name : '',
- unit.planned_time_start?.slice(0, 5) || '',
+ unit.planned_duration_min
+ ? formatDurationDisplay(unit.planned_duration_min)
+ : unit.planned_time_start?.slice(0, 5) || '',
unit.lead_trainer_name?.trim(),
unit.planned_focus?.trim(),
unit.status === 'completed'
@@ -1066,9 +1085,11 @@ function TrainingPlanningPageRoot() {
}}
>
- {unit.planned_time_start
- ? `${unit.planned_time_start.slice(0, 5)}`
- : 'Ganztags'}
+ {unit.planned_duration_min
+ ? formatDurationDisplay(unit.planned_duration_min)
+ : unit.planned_time_start
+ ? `${unit.planned_time_start.slice(0, 5)}`
+ : 'Ganztags'}
{planScope === 'club' && (unit.group_name || '').trim() ? (
@@ -1157,9 +1178,18 @@ 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
+ ? ` · ${formatDurationDisplay(unit.planned_duration_min)}`
+ : unit.planned_time_start
+ ? ` · ${unit.planned_time_start.slice(0, 5)}${unit.planned_time_end ? ` – ${unit.planned_time_end.slice(0, 5)}` : ''}`
+ : ''}
+ {unit.planned_duration_min && unit.planned_time_start ? (
+
+ Uhrzeit: {unit.planned_time_start.slice(0, 5)}
+ {unit.planned_time_end ? ` – ${unit.planned_time_end.slice(0, 5)}` : ''}
+
+ ) : null}
{planScope === 'club' && (unit.group_name || '').trim() ? (
{
if (planningClubId != null && planningClubId !== '') {
setNewTplClubId(String(planningClubId))
@@ -155,6 +166,39 @@ export default function TrainingUnitFormShell({
+
+
Geplante Gesamtdauer
+
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}
+
+
+
Trainingsfokus
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() {
+
+
+ Geplante Session-Dauer
+
+ 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() {
/>
-
Fokusbereich (optional)
-
updateField('focus_area_id', e.target.value)}
- >
- — keiner —
- {focusAreas.map((fa) => (
-
- {fa.name}
-
- ))}
-
-
Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.
+
Fokusbereiche (optional, Mehrfachwahl)
+
+ {focusAreas.length === 0 ? (
+
+ Keine Fokusbereiche im Katalog.
+
+ ) : (
+ focusAreas.map((fa) => (
+
+ toggleFocusAreaId(fa.id)}
+ />
+ {fa.name}
+
+ ))
+ )}
+
+
Aus dem Katalog; filtert die Trainingsarten unten.
-
Stilrichtung (optional)
-
updateField('style_direction_id', e.target.value)}
- >
- — keine —
- {styleDirections.map((sd) => (
-
- {sd.name}
-
- ))}
-
+
Stilrichtungen (optional, Mehrfachwahl)
+
+ {styleDirections.length === 0 ? (
+
+ Keine Stilrichtungen im Katalog.
+
+ ) : (
+ styleDirections.map((sd) => (
+
+ toggleStyleDirectionId(sd.id)}
+ />
+ {sd.name}
+
+ ))
+ )}
+
Trainingsarten (optional, Mehrfachwahl)
@@ -983,27 +1052,6 @@ export default function TrainingFrameworkProgramEditPage() {
-
-
Sichtbarkeit
@@ -1033,6 +1081,14 @@ export default function TrainingFrameworkProgramEditPage() {
+
+ {!isNew ? (
+
+
+ Rahmenprogramm löschen
+
+
+ ) : null}
-
-
- {!isNew ? (
-
- Löschen
-
- ) : 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 (
- <>
-
-
Bearbeiten
-
+
handleDelete(r.id, r.title)}>
Löschen
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
+}