Merge pull request 'Verbesserung Darstellung Rahmenprogramme' (#42) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m22s
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m22s
Reviewed-on: #42
This commit is contained in:
commit
d42eb3ac52
|
|
@ -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`?
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -99,6 +99,32 @@ def _target_group_ids(cur, framework_id: int) -> List[int]:
|
||||||
return [r["target_group_id"] for r in cur.fetchall()]
|
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]:
|
def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
fid = row["id"]
|
fid = row["id"]
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -136,6 +162,14 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
uid = row_b["id"]
|
uid = row_b["id"]
|
||||||
s["blueprint_training_unit_id"] = uid
|
s["blueprint_training_unit_id"] = uid
|
||||||
unit_min: Dict[str, Any] = {"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)
|
_hydrate_training_unit_payload(cur, unit_min)
|
||||||
s["phases"] = unit_min.get("phases", [])
|
s["phases"] = unit_min.get("phases", [])
|
||||||
s["sections"] = unit_min.get("sections", [])
|
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["slots"] = slots
|
||||||
row["training_type_ids"] = _training_type_ids(cur, fid)
|
row["training_type_ids"] = _training_type_ids(cur, fid)
|
||||||
row["target_group_ids"] = _target_group_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
|
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:
|
def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None:
|
||||||
if not goals_in:
|
if not goals_in:
|
||||||
raise HTTPException(status_code=400, detail="Mindestens ein Entwicklungsziel (goals) ist erforderlich")
|
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"]
|
sid = cur.fetchone()["id"]
|
||||||
|
|
||||||
|
slot_pdur = _optional_positive_int(
|
||||||
|
slot.get("planned_duration_min"), "planned_duration_min"
|
||||||
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO training_units (
|
INSERT INTO training_units (
|
||||||
group_id, planned_date,
|
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,
|
status, notes, trainer_notes,
|
||||||
created_by, plan_template_id, framework_slot_id
|
created_by, plan_template_id, framework_slot_id
|
||||||
) VALUES (
|
) VALUES (
|
||||||
NULL, NULL,
|
NULL, NULL,
|
||||||
NULL, NULL, NULL,
|
NULL, NULL, %s, NULL,
|
||||||
'planned', NULL, NULL,
|
'planned', NULL, NULL,
|
||||||
%s, NULL, %s
|
%s, NULL, %s
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(profile_id, sid),
|
(slot_pdur, profile_id, sid),
|
||||||
)
|
)
|
||||||
bid = cur.fetchone()["id"]
|
bid = cur.fetchone()["id"]
|
||||||
|
|
||||||
|
|
@ -327,8 +413,6 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
base_sel = """
|
base_sel = """
|
||||||
SELECT fp.*,
|
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)
|
(SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id)
|
||||||
AS goals_count,
|
AS goals_count,
|
||||||
(SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id)
|
(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,
|
WHERE t.framework_program_id = fp.id) AS training_types_count,
|
||||||
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
|
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
|
||||||
WHERE tg.framework_program_id = fp.id) AS target_groups_count,
|
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)
|
SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name)
|
||||||
FROM training_framework_program_training_types j
|
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
|
FROM training_framework_program_target_groups j
|
||||||
JOIN target_groups tg ON tg.id = j.target_group_id
|
JOIN target_groups tg ON tg.id = j.target_group_id
|
||||||
WHERE j.framework_program_id = fp.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
|
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(
|
vis_clause, vis_params = library_content_visibility_sql(
|
||||||
alias="fp",
|
alias="fp",
|
||||||
|
|
@ -403,10 +536,11 @@ def create_training_framework_program(
|
||||||
if not isinstance(goals_in, list) or not goals_in:
|
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")
|
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")
|
fa_ids, sd_ids = _parse_context_ids_from_payload(data)
|
||||||
sd_id = _optional_positive_int(data.get("style_direction_id"), "style_direction_id")
|
|
||||||
tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids")
|
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")
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
@ -418,19 +552,17 @@ def create_training_framework_program(
|
||||||
planned_period_start, planned_period_end,
|
planned_period_start, planned_period_end,
|
||||||
visibility, club_id, created_by,
|
visibility, club_id, created_by,
|
||||||
focus_area_id, style_direction_id
|
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
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
title[:200],
|
title[:200],
|
||||||
data.get("description"),
|
data.get("description"),
|
||||||
data.get("planned_period_start"),
|
|
||||||
data.get("planned_period_end"),
|
|
||||||
vis,
|
vis,
|
||||||
club_id,
|
club_id,
|
||||||
profile_id,
|
profile_id,
|
||||||
fa_id,
|
fa_legacy,
|
||||||
sd_id,
|
sd_legacy,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
fid = cur.fetchone()["id"]
|
fid = cur.fetchone()["id"]
|
||||||
|
|
@ -438,6 +570,8 @@ def create_training_framework_program(
|
||||||
_insert_slots_and_blueprints(cur, fid, slots_in, profile_id, role)
|
_insert_slots_and_blueprints(cur, fid, slots_in, profile_id, role)
|
||||||
_replace_training_types(cur, fid, tt_ids)
|
_replace_training_types(cur, fid, tt_ids)
|
||||||
_replace_target_groups(cur, fid, tg_ids)
|
_replace_target_groups(cur, fid, tg_ids)
|
||||||
|
_replace_focus_areas(cur, fid, fa_ids)
|
||||||
|
_replace_style_directions(cur, fid, sd_ids)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return _response_framework_detail(fid, profile_id, role)
|
return _response_framework_detail(fid, profile_id, role)
|
||||||
|
|
@ -489,13 +623,6 @@ def update_training_framework_program(
|
||||||
if "description" in data:
|
if "description" in data:
|
||||||
header_fields.append("description = %s")
|
header_fields.append("description = %s")
|
||||||
header_params.append(data.get("description"))
|
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:
|
if "visibility" in data:
|
||||||
header_fields.append("visibility = %s")
|
header_fields.append("visibility = %s")
|
||||||
header_params.append(merged_vis)
|
header_params.append(merged_vis)
|
||||||
|
|
@ -503,18 +630,26 @@ def update_training_framework_program(
|
||||||
header_fields.append("club_id = %s")
|
header_fields.append("club_id = %s")
|
||||||
header_params.append(merged_club)
|
header_params.append(merged_club)
|
||||||
|
|
||||||
if "focus_area_id" in data:
|
if "focus_area_ids" in data or "focus_area_id" in data:
|
||||||
fidv = data.get("focus_area_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]
|
||||||
header_fields.append("focus_area_id = %s")
|
header_fields.append("focus_area_id = %s")
|
||||||
header_params.append(
|
header_params.append(fa_ids[0] if len(fa_ids) == 1 else None)
|
||||||
None if fidv in (None, "") else _optional_positive_int(fidv, "focus_area_id")
|
_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:
|
if not sd_ids and data.get("style_direction_id") not in (None, ""):
|
||||||
sidv = data.get("style_direction_id")
|
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_fields.append("style_direction_id = %s")
|
||||||
header_params.append(
|
header_params.append(sd_ids[0] if len(sd_ids) == 1 else None)
|
||||||
None if sidv in (None, "") else _optional_positive_int(sidv, "style_direction_id")
|
_replace_style_directions(cur, framework_id, sd_ids)
|
||||||
)
|
|
||||||
|
|
||||||
if header_fields:
|
if header_fields:
|
||||||
header_fields.append("updated_at = NOW()")
|
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
|
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(
|
cur.execute(
|
||||||
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
|
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
|
||||||
(framework_id,),
|
(framework_id,),
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,7 @@ def _ensure_default_whole_group_phase(cur, unit_id: int, *, order_index: int = 0
|
||||||
|
|
||||||
_SECTION_ROWS_SQL = """
|
_SECTION_ROWS_SQL = """
|
||||||
SELECT tus.id, tus.training_unit_id, tus.order_index, tus.title, tus.guidance_notes,
|
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
|
tus.source_template_section_id, tus.phase_id, tus.parallel_stream_id
|
||||||
FROM training_unit_sections tus
|
FROM training_unit_sections tus
|
||||||
LEFT JOIN training_unit_phases ph ON ph.id = tus.phase_id
|
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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, training_unit_id, order_index, title, guidance_notes,
|
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
|
FROM training_unit_sections
|
||||||
WHERE phase_id = %s
|
WHERE phase_id = %s
|
||||||
ORDER BY order_index
|
ORDER BY order_index
|
||||||
|
|
@ -771,7 +772,7 @@ def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, training_unit_id, order_index, title, guidance_notes,
|
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
|
FROM training_unit_sections
|
||||||
WHERE parallel_stream_id = %s
|
WHERE parallel_stream_id = %s
|
||||||
ORDER BY order_index
|
ORDER BY order_index
|
||||||
|
|
@ -831,6 +832,7 @@ def _clone_section_payload_dict(sec: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"title": sec.get("title"),
|
"title": sec.get("title"),
|
||||||
"order_index": sec.get("order_index"),
|
"order_index": sec.get("order_index"),
|
||||||
"guidance_notes": sec.get("guidance_notes"),
|
"guidance_notes": sec.get("guidance_notes"),
|
||||||
|
"planned_duration_min": sec.get("planned_duration_min"),
|
||||||
"items": items_clean,
|
"items": items_clean,
|
||||||
}
|
}
|
||||||
stid = sec.get("source_template_section_id")
|
stid = sec.get("source_template_section_id")
|
||||||
|
|
@ -974,6 +976,7 @@ def _copy_blueprint_into_scheduled_unit(
|
||||||
planned_date,
|
planned_date,
|
||||||
planned_time_start,
|
planned_time_start,
|
||||||
planned_time_end,
|
planned_time_end,
|
||||||
|
planned_duration_min,
|
||||||
planned_focus,
|
planned_focus,
|
||||||
actual_date,
|
actual_date,
|
||||||
actual_time_start,
|
actual_time_start,
|
||||||
|
|
@ -992,6 +995,7 @@ def _copy_blueprint_into_scheduled_unit(
|
||||||
%s,
|
%s,
|
||||||
planned_time_start,
|
planned_time_start,
|
||||||
planned_time_end,
|
planned_time_end,
|
||||||
|
planned_duration_min,
|
||||||
planned_focus,
|
planned_focus,
|
||||||
NULL::DATE,
|
NULL::DATE,
|
||||||
NULL::TIME WITHOUT TIME ZONE,
|
NULL::TIME WITHOUT TIME ZONE,
|
||||||
|
|
@ -1281,8 +1285,9 @@ def _insert_one_replacement_section(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO training_unit_sections (
|
INSERT INTO training_unit_sections (
|
||||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
|
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes,
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
planned_duration_min, source_template_section_id
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -1292,6 +1297,7 @@ def _insert_one_replacement_section(
|
||||||
order_ix,
|
order_ix,
|
||||||
title,
|
title,
|
||||||
sec.get("guidance_notes"),
|
sec.get("guidance_notes"),
|
||||||
|
sec.get("planned_duration_min"),
|
||||||
src_tsec,
|
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
|
p_so = int(raw_so) if raw_so is not None and raw_so != "" else 0
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
p_so = 0
|
p_so = 0
|
||||||
|
pdur = _optional_positive_int(sec.get("planned_duration_min"), "planned_duration_min")
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
"order_index": order_ix,
|
"order_index": order_ix,
|
||||||
"guidance_text": sec.get("guidance_text"),
|
"guidance_text": sec.get("guidance_text"),
|
||||||
|
"planned_duration_min": pdur,
|
||||||
"phase_kind": pk,
|
"phase_kind": pk,
|
||||||
"phase_order_index": p_oi,
|
"phase_order_index": p_oi,
|
||||||
"parallel_stream_order_index": p_so,
|
"parallel_stream_order_index": p_so,
|
||||||
|
|
@ -1645,15 +1653,16 @@ def _insert_training_plan_template_sections(cur, template_id: int, sections_in:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO training_plan_template_sections (
|
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
|
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,
|
template_id,
|
||||||
row["order_index"],
|
row["order_index"],
|
||||||
row["title"],
|
row["title"],
|
||||||
row["guidance_text"],
|
row["guidance_text"],
|
||||||
|
row["planned_duration_min"],
|
||||||
row["phase_kind"],
|
row["phase_kind"],
|
||||||
row["phase_order_index"],
|
row["phase_order_index"],
|
||||||
row["parallel_stream_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"),
|
"title": rr.get("title"),
|
||||||
"order_index": j,
|
"order_index": j,
|
||||||
"guidance_notes": rr.get("guidance_text"),
|
"guidance_notes": rr.get("guidance_text"),
|
||||||
|
"planned_duration_min": rr.get("planned_duration_min"),
|
||||||
"items": [],
|
"items": [],
|
||||||
**(
|
**(
|
||||||
{"source_template_section_id": int(tid)}
|
{"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"),
|
"title": rr.get("title"),
|
||||||
"order_index": j,
|
"order_index": j,
|
||||||
"guidance_notes": rr.get("guidance_text"),
|
"guidance_notes": rr.get("guidance_text"),
|
||||||
|
"planned_duration_min": rr.get("planned_duration_min"),
|
||||||
"items": [],
|
"items": [],
|
||||||
**(
|
**(
|
||||||
{"source_template_section_id": int(tid)}
|
{"source_template_section_id": int(tid)}
|
||||||
|
|
@ -1775,7 +1786,7 @@ def _instantiate_from_template(
|
||||||
) -> None:
|
) -> None:
|
||||||
cur.execute(
|
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
|
FROM training_plan_template_sections
|
||||||
WHERE template_id = %s
|
WHERE template_id = %s
|
||||||
ORDER BY order_index
|
ORDER BY order_index
|
||||||
|
|
@ -2591,11 +2602,13 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
||||||
eff_lead_for_co,
|
eff_lead_for_co,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pdur = _optional_positive_int(data.get("planned_duration_min"), "planned_duration_min")
|
||||||
base_params = (
|
base_params = (
|
||||||
group_id,
|
group_id,
|
||||||
planned_date,
|
planned_date,
|
||||||
data.get("planned_time_start"),
|
data.get("planned_time_start"),
|
||||||
data.get("planned_time_end"),
|
data.get("planned_time_end"),
|
||||||
|
pdur,
|
||||||
data.get("planned_focus"),
|
data.get("planned_focus"),
|
||||||
data.get("status", "planned"),
|
data.get("status", "planned"),
|
||||||
data.get("notes"),
|
data.get("notes"),
|
||||||
|
|
@ -2610,11 +2623,11 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
||||||
"""
|
"""
|
||||||
INSERT INTO training_units (
|
INSERT INTO training_units (
|
||||||
group_id, planned_date, planned_time_start, planned_time_end,
|
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,
|
plan_template_id,
|
||||||
lead_trainer_profile_id,
|
lead_trainer_profile_id,
|
||||||
assistant_trainer_profile_ids
|
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
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
base_params + (av_db,),
|
base_params + (av_db,),
|
||||||
|
|
@ -2624,10 +2637,10 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
||||||
"""
|
"""
|
||||||
INSERT INTO training_units (
|
INSERT INTO training_units (
|
||||||
group_id, planned_date, planned_time_start, planned_time_end,
|
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,
|
plan_template_id,
|
||||||
lead_trainer_profile_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
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
base_params,
|
base_params,
|
||||||
|
|
@ -2717,6 +2730,11 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
||||||
if "planned_time_end" in data:
|
if "planned_time_end" in data:
|
||||||
blueprint_fields.append("planned_time_end = %s")
|
blueprint_fields.append("planned_time_end = %s")
|
||||||
blueprint_params.append(data.get("planned_time_end"))
|
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:
|
if "notes" in data:
|
||||||
blueprint_fields.append("notes = %s")
|
blueprint_fields.append("notes = %s")
|
||||||
blueprint_params.append(data.get("notes"))
|
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_date = COALESCE(%s, planned_date),
|
||||||
planned_time_start = %s,
|
planned_time_start = %s,
|
||||||
planned_time_end = %s,
|
planned_time_end = %s,
|
||||||
|
planned_duration_min = %s,
|
||||||
planned_focus = %s,
|
planned_focus = %s,
|
||||||
actual_date = %s,
|
actual_date = %s,
|
||||||
actual_time_start = %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_date"),
|
||||||
data.get("planned_time_start"),
|
data.get("planned_time_start"),
|
||||||
data.get("planned_time_end"),
|
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("planned_focus"),
|
||||||
data.get("actual_date"),
|
data.get("actual_date"),
|
||||||
data.get("actual_time_start"),
|
data.get("actual_time_start"),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.149"
|
APP_VERSION = "0.8.150"
|
||||||
BUILD_DATE = "2026-05-19"
|
BUILD_DATE = "2026-05-20"
|
||||||
DB_SCHEMA_VERSION = "20260516065"
|
DB_SCHEMA_VERSION = "20260520066"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/* Rahmenprogramm-Editor: Kurz-Einstieg ausklappbar */
|
||||||
.framework-edit-intro {
|
.framework-edit-intro {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
@ -5040,7 +5386,370 @@ html.modal-scroll-locked .app-main {
|
||||||
border-color: var(--border2);
|
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 {
|
.framework-programs-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
swapAdjacentPhaseRuns,
|
swapAdjacentPhaseRuns,
|
||||||
reorderBlocksImmutableWithPlanLoc,
|
reorderBlocksImmutableWithPlanLoc,
|
||||||
reorderSectionBeforeParallelRunAsWholeGroup,
|
reorderSectionBeforeParallelRunAsWholeGroup,
|
||||||
|
reorderSectionAfterParallelRunAsWholeGroup,
|
||||||
reorderSectionAsFirstInParallelStream,
|
reorderSectionAsFirstInParallelStream,
|
||||||
reorderBlockIntoParallelStreamEnd,
|
reorderBlockIntoParallelStreamEnd,
|
||||||
globalInsertBeforeIndexForParallelStreamEnd,
|
globalInsertBeforeIndexForParallelStreamEnd,
|
||||||
|
|
@ -37,6 +38,7 @@ import {
|
||||||
noteRow,
|
noteRow,
|
||||||
sectionPlannedMinutes,
|
sectionPlannedMinutes,
|
||||||
} from '../utils/trainingUnitSectionsForm'
|
} from '../utils/trainingUnitSectionsForm'
|
||||||
|
import { formatDurationDisplay, parseDurationInput } from '../utils/trainingDurationUtils'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
@ -274,6 +276,19 @@ export default function TrainingUnitSectionsEditor({
|
||||||
user?.training_planning_prefs?.module_display_mode
|
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) =>
|
const ensure = (prev) =>
|
||||||
prev && prev.length ? prev : [defaultSection()]
|
prev && prev.length ? prev : [defaultSection()]
|
||||||
|
|
||||||
|
|
@ -1299,6 +1314,47 @@ export default function TrainingUnitSectionsEditor({
|
||||||
return sIdx === 0
|
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 sectionMoveDisabledDown = (sIdx) => {
|
||||||
const sec = list[sIdx]
|
const sec = list[sIdx]
|
||||||
const L = sec?.planLoc
|
const L = sec?.planLoc
|
||||||
|
|
@ -2004,28 +2060,74 @@ export default function TrainingUnitSectionsEditor({
|
||||||
}
|
}
|
||||||
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||||||
/>
|
/>
|
||||||
{enableParallelPhaseControls ? (
|
{!structureOnly ? (
|
||||||
<div className="form-row" style={{ marginTop: '10px', marginBottom: '2px' }}>
|
<div
|
||||||
|
className="form-row"
|
||||||
|
style={{ marginTop: '10px', marginBottom: '2px', maxWidth: '220px' }}
|
||||||
|
>
|
||||||
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
||||||
Zuordnung
|
Geplante Abschnittsdauer
|
||||||
</label>
|
</label>
|
||||||
<select
|
<input
|
||||||
className="form-input"
|
type="text"
|
||||||
value={planLocKey(sec.planLoc)}
|
className="form-input tu-ex-duration"
|
||||||
onChange={(e) => applySectionPlanTarget(sIdx, e.target.value)}
|
inputMode="decimal"
|
||||||
|
value={sec.planned_duration_min ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSectionField(sIdx, 'planned_duration_min', e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="z. B. 15 oder 0,25 h"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{enableParallelPhaseControls && useCompactPhaseMoves ? (
|
||||||
|
<div className="tu-section-phase-moves" style={{ marginTop: '8px' }}>
|
||||||
|
<span
|
||||||
|
className="form-label"
|
||||||
|
style={{ fontSize: '0.75rem', display: 'block', marginBottom: '6px' }}
|
||||||
>
|
>
|
||||||
<option value="">Standard — eine Ganzgruppe (klassischer Ablauf)</option>
|
Phase / Gruppe
|
||||||
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => (
|
</span>
|
||||||
<option key={o.key} value={o.key}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||||
{o.label}
|
<button
|
||||||
</option>
|
type="button"
|
||||||
))}
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
</select>
|
title="In Ganzgruppe darüber"
|
||||||
|
onClick={() => moveSectionToWholeGroupAbove(sIdx)}
|
||||||
|
>
|
||||||
|
Ganz ↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
|
title="In Ganzgruppe darunter"
|
||||||
|
onClick={() => moveSectionToWholeGroupBelow(sIdx)}
|
||||||
|
>
|
||||||
|
Ganz ↓
|
||||||
|
</button>
|
||||||
|
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list))
|
||||||
|
.filter((o) => o.key.startsWith('par:'))
|
||||||
|
.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
|
title={o.label}
|
||||||
|
onClick={() => moveSectionToPlanTarget(sIdx, o.key)}
|
||||||
|
>
|
||||||
|
{o.label.length > 14 ? `${o.label.slice(0, 12)}…` : o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!structureOnly && planMin > 0 && (
|
{!structureOnly && planMin > 0 && (
|
||||||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
Geplant in diesem Abschnitt:{' '}
|
||||||
|
<strong>{formatDurationDisplay(planMin)}</strong>
|
||||||
|
{parseDurationInput(sec.planned_duration_min) != null
|
||||||
|
? ' (Abschnittsangabe)'
|
||||||
|
: ' (Summe Übungen)'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
134
frontend/src/components/planning/FrameworkProgramListCard.jsx
Normal file
134
frontend/src/components/planning/FrameworkProgramListCard.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={`fw-prog-card__catalog-group fw-prog-card__catalog-group--${variant}`}>
|
||||||
|
<span className="fw-prog-card__catalog-label">{label}</span>
|
||||||
|
<ul className="fw-prog-card__chip-list" aria-label={label}>
|
||||||
|
{items.map((name) => (
|
||||||
|
<li key={name} className="fw-prog-card__chip">
|
||||||
|
{name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<article className="fw-prog-card card">
|
||||||
|
<div className="fw-prog-card__accent" aria-hidden="true" />
|
||||||
|
<div className="fw-prog-card__inner">
|
||||||
|
<header className="fw-prog-card__head">
|
||||||
|
<div className="fw-prog-card__title-block">
|
||||||
|
<h2 className="fw-prog-card__title">
|
||||||
|
<NavStateLink
|
||||||
|
to={`/planning/framework-programs/${row.id}`}
|
||||||
|
returnContext={returnContext}
|
||||||
|
className="fw-prog-card__title-link"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</NavStateLink>
|
||||||
|
</h2>
|
||||||
|
{description ? (
|
||||||
|
<p className="fw-prog-card__desc">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="fw-prog-card__stats" aria-label="Kennzahlen">
|
||||||
|
<li
|
||||||
|
className={
|
||||||
|
'fw-prog-card__stat' +
|
||||||
|
(hasDuration ? ' fw-prog-card__stat--duration' : ' fw-prog-card__stat--muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="fw-prog-card__stat-label">Session</span>
|
||||||
|
<span className="fw-prog-card__stat-value">{durationLabel}</span>
|
||||||
|
</li>
|
||||||
|
<li className="fw-prog-card__stat">
|
||||||
|
<span className="fw-prog-card__stat-label">Ziele</span>
|
||||||
|
<span className="fw-prog-card__stat-value">
|
||||||
|
{Number.isFinite(goalsCount) ? goalsCount : '—'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="fw-prog-card__stat">
|
||||||
|
<span className="fw-prog-card__stat-label">Sessions</span>
|
||||||
|
<span className="fw-prog-card__stat-value">
|
||||||
|
{Number.isFinite(slotsCount) ? slotsCount : '—'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{goals.length > 0 ? (
|
||||||
|
<section className="fw-prog-card__section fw-prog-card__section--goals">
|
||||||
|
<h3 className="fw-prog-card__section-title">Entwicklungsziele</h3>
|
||||||
|
<ul className="fw-prog-card__goal-list" aria-label="Entwicklungsziele">
|
||||||
|
{goals.map((g) => (
|
||||||
|
<li key={g} className="fw-prog-card__goal">
|
||||||
|
{g}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showCatalog ? (
|
||||||
|
<section className="fw-prog-card__section fw-prog-card__section--catalog">
|
||||||
|
<h3 className="fw-prog-card__section-title">Einordnung</h3>
|
||||||
|
<div className="fw-prog-card__catalog">
|
||||||
|
<CatalogGroup label="Fokus" items={focusAreas} variant="focus" />
|
||||||
|
<CatalogGroup label="Stil" items={styleDirs} variant="style" />
|
||||||
|
<CatalogGroup label="Trainingsart" items={trainingTypes} variant="type" />
|
||||||
|
<CatalogGroup label="Zielgruppe" items={targetGroups} variant="target" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<footer className="fw-prog-card__actions">
|
||||||
|
<NavStateLink
|
||||||
|
to={`/planning/framework-programs/${row.id}`}
|
||||||
|
returnContext={returnContext}
|
||||||
|
className="btn btn-primary fw-prog-card__btn-primary"
|
||||||
|
>
|
||||||
|
Öffnen
|
||||||
|
</NavStateLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary fw-prog-card__btn-danger"
|
||||||
|
onClick={() => onDelete(row.id, row.title)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className={'fw-prog-filter-block' + (className ? ` ${className}` : '')}>
|
||||||
|
<div className="fw-import-results-bar">
|
||||||
|
<div className="fw-import-results-bar__count">
|
||||||
|
<strong className="fw-import-results-bar__num">{matchCount}</strong>
|
||||||
|
<span>
|
||||||
|
{' '}
|
||||||
|
von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
|
||||||
|
</span>
|
||||||
|
{matchCount === 0 && totalCount > 0 ? (
|
||||||
|
<span className="fw-import-results-bar__warn"> — kein Treffer</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="fw-import-results-bar__actions">
|
||||||
|
{filterActive ? (
|
||||||
|
<span className="fw-import-filter-badge" title={filterSummaryParts.join(' · ')}>
|
||||||
|
Filter aktiv
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{filterActive ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Filter zurücksetzen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{onPanelOpenChange ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={togglePanel}
|
||||||
|
aria-expanded={panelOpen}
|
||||||
|
>
|
||||||
|
{panelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!panelOpen && filterActive && filterSummaryParts.length > 0 ? (
|
||||||
|
<ul className="fw-import-filter-chips" aria-label="Aktive Filter">
|
||||||
|
{filterSummaryParts.map((part) => (
|
||||||
|
<li key={part} className="fw-import-filter-chip">
|
||||||
|
{part}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{panelOpen ? (
|
||||||
|
<div className="fw-import-filter-panel">
|
||||||
|
<div className="fw-import-filter-panel__grid">
|
||||||
|
<div className="form-row fw-import-filter-panel__search">
|
||||||
|
<label className="form-label">Suche (Titel, Ziele, Katalog)</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={filters.query}
|
||||||
|
onChange={(e) => updateFilter({ query: e.target.value })}
|
||||||
|
placeholder="z. B. Gürtel, Koordination …"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="fw-import-duration-fieldset">
|
||||||
|
<legend className="form-label">Ziel-Session-Dauer</legend>
|
||||||
|
<div className="fw-import-duration-mode" role="radiogroup" aria-label="Dauer-Filtermodus">
|
||||||
|
{[
|
||||||
|
{ id: 'any', label: 'Alle' },
|
||||||
|
{ id: 'range', label: 'Zeitspanne' },
|
||||||
|
{ id: 'preset', label: 'Vorhandene Zeiten' },
|
||||||
|
].map((opt) => (
|
||||||
|
<label key={opt.id} className="fw-import-duration-mode__opt">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={durationRadioName}
|
||||||
|
checked={filters.durationMode === opt.id}
|
||||||
|
disabled={disabled || (opt.id === 'preset' && distinctDurations.length === 0)}
|
||||||
|
onChange={() =>
|
||||||
|
updateFilter({
|
||||||
|
durationMode: opt.id,
|
||||||
|
...(opt.id === 'any'
|
||||||
|
? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filters.durationMode === 'range' ? (
|
||||||
|
<div className="responsive-grid-2 fw-import-duration-range">
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Von (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="form-input"
|
||||||
|
value={filters.durationRangeFrom}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="z. B. 60"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label">Bis (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="form-input"
|
||||||
|
value={filters.durationRangeTo}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateFilter({ durationMode: 'range', durationRangeTo: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="z. B. 90"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filters.durationMode === 'preset' ? (
|
||||||
|
distinctDurations.length === 0 ? (
|
||||||
|
<p className="form-sub" style={{ margin: '8px 0 0' }}>
|
||||||
|
In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege
|
||||||
|
Dauer pro Session im Rahmenprogramm fest.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="fw-import-duration-presets">
|
||||||
|
{distinctDurations.map((min) => {
|
||||||
|
const on = filters.durationPresetMin === min
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={min}
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
'btn framework-ctrl framework-ctrl--xs' +
|
||||||
|
(on ? ' fw-import-duration-preset--on' : ' btn-secondary')
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() =>
|
||||||
|
updateFilter({
|
||||||
|
durationMode: 'preset',
|
||||||
|
durationPresetMin: on ? null : min,
|
||||||
|
durationRangeFrom: '',
|
||||||
|
durationRangeTo: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatDurationDisplay(min)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{catalogFocusAreas.length > 0 ? (
|
||||||
|
<div className="fw-import-catalog-block">
|
||||||
|
<span className="form-label">Fokusbereich</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogFocusAreas.map((fa) => (
|
||||||
|
<label key={fa.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(filters.focusAreaIds || []).includes(String(fa.id))}
|
||||||
|
onChange={() => toggleId('focusAreaIds', fa.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span>{fa.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{catalogTrainingTypes.length > 0 ? (
|
||||||
|
<div className="fw-import-catalog-block">
|
||||||
|
<span className="form-label">Trainingsart</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogTrainingTypes.map((t) => (
|
||||||
|
<label key={t.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(filters.trainingTypeIds || []).includes(String(t.id))}
|
||||||
|
onChange={() => toggleId('trainingTypeIds', t.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span>{t.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{catalogTargetGroups.length > 0 ? (
|
||||||
|
<div className="fw-import-catalog-block">
|
||||||
|
<span className="form-label">Zielgruppe</span>
|
||||||
|
<div className="framework-catalog-checkgrid">
|
||||||
|
{catalogTargetGroups.map((tg) => (
|
||||||
|
<label key={tg.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(filters.targetGroupIds || []).includes(String(tg.id))}
|
||||||
|
onChange={() => toggleId('targetGroupIds', tg.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span>{tg.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{showHint ? (
|
||||||
|
<p className="form-sub fw-import-filter-panel__hint">
|
||||||
|
Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
|
||||||
|
Programme mit hinterlegter Session-Dauer berücksichtigt.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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.
|
* Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen.
|
||||||
|
|
@ -6,6 +14,9 @@ import React from 'react'
|
||||||
export default function TrainingPlanningFrameworkImportModal({
|
export default function TrainingPlanningFrameworkImportModal({
|
||||||
open,
|
open,
|
||||||
frameworkProgramsList,
|
frameworkProgramsList,
|
||||||
|
catalogFocusAreas = [],
|
||||||
|
catalogTrainingTypes = [],
|
||||||
|
catalogTargetGroups = [],
|
||||||
fwImportProgramId,
|
fwImportProgramId,
|
||||||
onProgramChange,
|
onProgramChange,
|
||||||
fwImportLoading,
|
fwImportLoading,
|
||||||
|
|
@ -23,72 +34,104 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClose,
|
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
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="fw-import-modal-backdrop"
|
||||||
data-testid="planning-framework-import-modal"
|
data-testid="planning-framework-import-modal"
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
background: 'rgba(0,0,0,0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 1010,
|
|
||||||
padding: '1rem',
|
|
||||||
overflowY: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div className="fw-import-modal-panel">
|
||||||
style={{
|
<h2 className="fw-import-modal-panel__title">Sessions aus Rahmen übernehmen</h2>
|
||||||
background: 'var(--surface)',
|
<p className="fw-import-modal-panel__lead">
|
||||||
borderRadius: '12px',
|
|
||||||
padding: 'clamp(14px, 3vw, 1.75rem)',
|
|
||||||
maxWidth: 'min(620px, 100%)',
|
|
||||||
width: '100%',
|
|
||||||
maxHeight: '90vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 style={{ marginBottom: '0.65rem' }}>Sessions aus Rahmen übernehmen</h2>
|
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
|
||||||
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
|
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
|
||||||
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
|
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs).
|
||||||
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="form-row">
|
<FrameworkProgramsFilterBlock
|
||||||
<label className="form-label">Rahmenprogramm</label>
|
programs={frameworkProgramsList}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
panelOpen={filterPanelOpen}
|
||||||
|
onPanelOpenChange={setFilterPanelOpen}
|
||||||
|
catalogFocusAreas={catalogFocusAreas}
|
||||||
|
catalogTrainingTypes={catalogTrainingTypes}
|
||||||
|
catalogTargetGroups={catalogTargetGroups}
|
||||||
|
disabled={fwImportSubmitting}
|
||||||
|
durationRadioName="fw-duration-mode"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-row fw-import-program-select">
|
||||||
|
<label className="form-label">
|
||||||
|
Rahmenprogramm
|
||||||
|
<span className="fw-import-program-select__count">
|
||||||
|
{' '}
|
||||||
|
({matchCount} {matchCount === 1 ? 'Treffer' : 'Treffer'})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={fwImportProgramId}
|
value={fwImportProgramId}
|
||||||
onChange={(e) => onProgramChange(e.target.value)}
|
onChange={(e) => onProgramChange(e.target.value)}
|
||||||
disabled={fwImportLoading || fwImportSubmitting}
|
disabled={fwImportLoading || fwImportSubmitting || matchCount === 0}
|
||||||
>
|
>
|
||||||
<option value="">Bitte wählen…</option>
|
<option value="">
|
||||||
{frameworkProgramsList.map((fp) => (
|
{matchCount === 0 ? 'Kein Rahmenprogramm passt zum Filter' : 'Bitte wählen…'}
|
||||||
|
</option>
|
||||||
|
{filteredPrograms.map((fp) => (
|
||||||
<option key={fp.id} value={String(fp.id)}>
|
<option key={fp.id} value={String(fp.id)}>
|
||||||
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
|
{frameworkProgramOptionLabel(fp)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedProgramSummary ? (
|
||||||
|
<p className="form-sub" style={{ marginTop: '6px' }}>
|
||||||
|
Session-Dauer: <strong>{frameworkSessionDurationLabel(selectedProgramSummary)}</strong>
|
||||||
|
{selectedProgramSummary.goal_titles_agg ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· Ziele: {selectedProgramSummary.goal_titles_agg}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fwImportLoading ? (
|
{fwImportLoading ? (
|
||||||
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>Laden der Sessions…</p>
|
<p className="fw-import-muted">Laden der Sessions…</p>
|
||||||
) : fwImportDetail?.slots?.length ? (
|
) : fwImportDetail?.slots?.length ? (
|
||||||
<>
|
<>
|
||||||
<fieldset style={{ border: 'none', margin: '1rem 0', padding: 0 }}>
|
<fieldset className="fw-import-sessions">
|
||||||
<legend className="form-label" style={{ padding: 0, marginBottom: '0.5rem' }}>
|
<legend className="form-label">Sessions (mit Ablauf)</legend>
|
||||||
Sessions (mit Ablauf)
|
<ul className="fw-import-sessions__list">
|
||||||
</legend>
|
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
|
||||||
{[...fwImportDetail.slots]
|
{[...fwImportDetail.slots]
|
||||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||||
.map((slot) => {
|
.map((slot) => {
|
||||||
|
|
@ -96,40 +139,46 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
const checked = fwImportSelectedSlots.has(slot.id)
|
const checked = fwImportSelectedSlots.has(slot.id)
|
||||||
const label =
|
const label =
|
||||||
(slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}`
|
(slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}`
|
||||||
|
const slotDur =
|
||||||
|
slot.planned_duration_min != null
|
||||||
|
? formatDurationDisplay(slot.planned_duration_min)
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<li key={slot.id} style={{ marginBottom: '10px' }}>
|
<li key={slot.id} className="fw-import-sessions__item">
|
||||||
<label
|
<label
|
||||||
style={{
|
className={
|
||||||
display: 'flex',
|
'fw-import-sessions__label' + (hasBp ? '' : ' fw-import-sessions__label--disabled')
|
||||||
gap: '10px',
|
}
|
||||||
alignItems: 'flex-start',
|
|
||||||
cursor: hasBp ? 'pointer' : 'not-allowed',
|
|
||||||
opacity: hasBp ? 1 : 0.55,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
className="fw-import-sessions__check"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={!hasBp || fwImportSubmitting}
|
disabled={!hasBp || fwImportSubmitting}
|
||||||
onChange={() => onToggleSlot(slot)}
|
onChange={() => onToggleSlot(slot)}
|
||||||
style={{ marginTop: '0.2rem', flexShrink: 0 }}
|
|
||||||
/>
|
/>
|
||||||
<span style={{ flex: 1, minWidth: 0 }}>
|
<span className="fw-import-sessions__body">
|
||||||
<strong>{label}</strong>
|
<span className="fw-import-sessions__title-row">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
{slotDur ? (
|
||||||
|
<span className="fw-import-sessions__dur">{slotDur}</span>
|
||||||
|
) : (
|
||||||
|
<span className="fw-import-sessions__dur fw-import-sessions__dur--muted">
|
||||||
|
Dauer offen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
{!hasBp ? (
|
{!hasBp ? (
|
||||||
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
|
<span className="fw-import-sessions__warn">
|
||||||
Ohne Session-Ablauf — Übernahme nicht möglich.
|
Ohne Session-Ablauf — Übernahme nicht möglich.
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{hasBp && checked ? (
|
{hasBp && checked ? (
|
||||||
<span style={{ display: 'block', marginTop: '6px' }}>
|
<span className="fw-import-sessions__date">
|
||||||
<span className="form-label" style={{ fontSize: '0.78rem' }}>
|
<span className="form-label">Termin (Datum)</span>
|
||||||
Termin (Datum)
|
|
||||||
</span>
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ maxWidth: '200px', marginTop: '4px' }}
|
|
||||||
value={fwImportSlotDates[String(slot.id)] || ''}
|
value={fwImportSlotDates[String(slot.id)] || ''}
|
||||||
onChange={(e) => onSlotDateChange(String(slot.id), e.target.value)}
|
onChange={(e) => onSlotDateChange(String(slot.id), e.target.value)}
|
||||||
disabled={fwImportSubmitting}
|
disabled={fwImportSubmitting}
|
||||||
|
|
@ -144,15 +193,7 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
</ul>
|
</ul>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div
|
<div className="fw-import-dates-panel responsive-grid-3">
|
||||||
className="responsive-grid-3"
|
|
||||||
style={{
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
padding: '12px',
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Startdatum (Vorschlag)</label>
|
<label className="form-label">Startdatum (Vorschlag)</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -174,11 +215,10 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
disabled={fwImportSubmitting}
|
disabled={fwImportSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ alignSelf: 'end' }}>
|
<div className="form-row fw-import-dates-panel__action">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ width: '100%' }}
|
|
||||||
disabled={fwImportSubmitting}
|
disabled={fwImportSubmitting}
|
||||||
onClick={onApplyDateSuggestions}
|
onClick={onApplyDateSuggestions}
|
||||||
>
|
>
|
||||||
|
|
@ -188,10 +228,10 @@ export default function TrainingPlanningFrameworkImportModal({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : fwImportProgramId ? (
|
) : fwImportProgramId ? (
|
||||||
<p style={{ color: 'var(--text2)', marginTop: '0.75rem' }}>Keine Sessions in diesem Programm.</p>
|
<p className="fw-import-muted">Keine Sessions in diesem Programm.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}>
|
<div className="fw-import-modal-panel__footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
legacyPlanningUnitDeepLinkTarget,
|
legacyPlanningUnitDeepLinkTarget,
|
||||||
parsePlanningHubQuery,
|
parsePlanningHubQuery,
|
||||||
} from '../../utils/planningUnitRoutes'
|
} from '../../utils/planningUnitRoutes'
|
||||||
|
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
|
||||||
|
|
||||||
function TrainingPlanningPageRoot() {
|
function TrainingPlanningPageRoot() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -55,6 +56,9 @@ function TrainingPlanningPageRoot() {
|
||||||
|
|
||||||
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
|
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
|
||||||
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
|
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
|
||||||
|
const [fwImportCatalogFocus, setFwImportCatalogFocus] = useState([])
|
||||||
|
const [fwImportCatalogTypes, setFwImportCatalogTypes] = useState([])
|
||||||
|
const [fwImportCatalogTargetGroups, setFwImportCatalogTargetGroups] = useState([])
|
||||||
const [fwImportProgramId, setFwImportProgramId] = useState('')
|
const [fwImportProgramId, setFwImportProgramId] = useState('')
|
||||||
const [fwImportDetail, setFwImportDetail] = useState(null)
|
const [fwImportDetail, setFwImportDetail] = useState(null)
|
||||||
const [fwImportLoading, setFwImportLoading] = useState(false)
|
const [fwImportLoading, setFwImportLoading] = useState(false)
|
||||||
|
|
@ -271,12 +275,25 @@ function TrainingPlanningPageRoot() {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await api.listTrainingFrameworkPrograms()
|
const [list, fa, tt, tg] = await Promise.all([
|
||||||
if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
|
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) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error('Rahmenprogramme laden:', e)
|
console.error('Rahmenprogramme laden:', e)
|
||||||
setFrameworkProgramsList([])
|
setFrameworkProgramsList([])
|
||||||
|
setFwImportCatalogFocus([])
|
||||||
|
setFwImportCatalogTypes([])
|
||||||
|
setFwImportCatalogTargetGroups([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
@ -1033,7 +1050,9 @@ function TrainingPlanningPageRoot() {
|
||||||
onClick={() => handleEdit(unit)}
|
onClick={() => handleEdit(unit)}
|
||||||
title={[
|
title={[
|
||||||
planScope === 'club' && unit.group_name ? unit.group_name : '',
|
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.lead_trainer_name?.trim(),
|
||||||
unit.planned_focus?.trim(),
|
unit.planned_focus?.trim(),
|
||||||
unit.status === 'completed'
|
unit.status === 'completed'
|
||||||
|
|
@ -1066,9 +1085,11 @@ function TrainingPlanningPageRoot() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 600 }}>
|
<span style={{ fontWeight: 600 }}>
|
||||||
{unit.planned_time_start
|
{unit.planned_duration_min
|
||||||
? `${unit.planned_time_start.slice(0, 5)}`
|
? formatDurationDisplay(unit.planned_duration_min)
|
||||||
: 'Ganztags'}
|
: unit.planned_time_start
|
||||||
|
? `${unit.planned_time_start.slice(0, 5)}`
|
||||||
|
: 'Ganztags'}
|
||||||
</span>
|
</span>
|
||||||
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
||||||
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
|
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
|
||||||
|
|
@ -1157,9 +1178,18 @@ function TrainingPlanningPageRoot() {
|
||||||
<div style={{ minWidth: 0, flex: '1 1 200px' }}>
|
<div style={{ minWidth: 0, flex: '1 1 200px' }}>
|
||||||
<h3 style={{ marginBottom: '0.25rem' }}>
|
<h3 style={{ marginBottom: '0.25rem' }}>
|
||||||
{unit.planned_date}
|
{unit.planned_date}
|
||||||
{unit.planned_time_start &&
|
{unit.planned_duration_min
|
||||||
` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}
|
? ` · ${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)}` : ''}`
|
||||||
|
: ''}
|
||||||
</h3>
|
</h3>
|
||||||
|
{unit.planned_duration_min && unit.planned_time_start ? (
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--text3)', margin: '0 0 0.35rem' }}>
|
||||||
|
Uhrzeit: {unit.planned_time_start.slice(0, 5)}
|
||||||
|
{unit.planned_time_end ? ` – ${unit.planned_time_end.slice(0, 5)}` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1375,6 +1405,9 @@ function TrainingPlanningPageRoot() {
|
||||||
<TrainingPlanningFrameworkImportModal
|
<TrainingPlanningFrameworkImportModal
|
||||||
open={frameworkImportOpen}
|
open={frameworkImportOpen}
|
||||||
frameworkProgramsList={frameworkProgramsList}
|
frameworkProgramsList={frameworkProgramsList}
|
||||||
|
catalogFocusAreas={fwImportCatalogFocus}
|
||||||
|
catalogTrainingTypes={fwImportCatalogTypes}
|
||||||
|
catalogTargetGroups={fwImportCatalogTargetGroups}
|
||||||
fwImportProgramId={fwImportProgramId}
|
fwImportProgramId={fwImportProgramId}
|
||||||
onProgramChange={onFwImportProgramChange}
|
onProgramChange={onFwImportProgramChange}
|
||||||
fwImportLoading={fwImportLoading}
|
fwImportLoading={fwImportLoading}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibili
|
||||||
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
||||||
import { activeClubMemberships } from '../../utils/activeClub'
|
import { activeClubMemberships } from '../../utils/activeClub'
|
||||||
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
|
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
|
||||||
|
import {
|
||||||
|
formatDurationDisplay,
|
||||||
|
minutesToDurationFieldValue,
|
||||||
|
parseDurationInput,
|
||||||
|
sumExercisePlannedMinutes,
|
||||||
|
sumSectionPlannedMinutes,
|
||||||
|
} from '../../utils/trainingDurationUtils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vollseiten-Formular: Trainingseinheit planen / nachbereiten (ohne Modal-Overlay).
|
* Vollseiten-Formular: Trainingseinheit planen / nachbereiten (ohne Modal-Overlay).
|
||||||
|
|
@ -40,6 +47,10 @@ export default function TrainingUnitFormShell({
|
||||||
const roleLc = String(user?.role || '').toLowerCase()
|
const roleLc = String(user?.role || '').toLowerCase()
|
||||||
const isSuperadmin = roleLc === 'superadmin'
|
const isSuperadmin = roleLc === 'superadmin'
|
||||||
|
|
||||||
|
const plannedTotalParsed = parseDurationInput(formData.planned_duration_min)
|
||||||
|
const sumFromSections = sumSectionPlannedMinutes(formData.sections)
|
||||||
|
const sumFromExercises = sumExercisePlannedMinutes(formData.sections)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (planningClubId != null && planningClubId !== '') {
|
if (planningClubId != null && planningClubId !== '') {
|
||||||
setNewTplClubId(String(planningClubId))
|
setNewTplClubId(String(planningClubId))
|
||||||
|
|
@ -155,6 +166,39 @@ export default function TrainingUnitFormShell({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row" style={{ marginBottom: '1rem' }}>
|
||||||
|
<label className="form-label">Geplante Gesamtdauer</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={formData.planned_duration_min}
|
||||||
|
onChange={(e) => updateFormField('planned_duration_min', e.target.value)}
|
||||||
|
placeholder="z. B. 1,5 oder 90 (Minuten)"
|
||||||
|
/>
|
||||||
|
<p className="form-sub" style={{ marginTop: '0.35rem' }}>
|
||||||
|
{plannedTotalParsed != null ? (
|
||||||
|
<>
|
||||||
|
Eingabe: <strong>{formatDurationDisplay(plannedTotalParsed)}</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Stunden mit Komma/Punkt (1,5 = 90 Min) oder Minuten ab 8.'
|
||||||
|
)}
|
||||||
|
{sumFromSections > 0 || sumFromExercises > 0 ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· Summe Abschnitte: <strong>{formatDurationDisplay(sumFromSections)}</strong>
|
||||||
|
{sumFromExercises > 0 && sumFromExercises !== sumFromSections ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· Übungen: <strong>{formatDurationDisplay(sumFromExercises)}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Trainingsfokus</label>
|
<label className="form-label">Trainingsfokus</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import FormActionBar from '../components/FormActionBar'
|
import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { useNavReturn } from '../hooks/useNavReturn'
|
import { useNavReturn } from '../hooks/useNavReturn'
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
reorderSectionBeforeParallelRunAsWholeGroup,
|
reorderSectionBeforeParallelRunAsWholeGroup,
|
||||||
reorderSectionAsFirstInParallelStream,
|
reorderSectionAsFirstInParallelStream,
|
||||||
} from '../utils/trainingUnitSectionsForm'
|
} from '../utils/trainingUnitSectionsForm'
|
||||||
|
import { parseDurationInput } from '../utils/trainingDurationUtils'
|
||||||
|
|
||||||
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
||||||
|
|
||||||
|
|
@ -52,7 +53,7 @@ function emptyGoal() {
|
||||||
|
|
||||||
|
|
||||||
function emptySlot() {
|
function emptySlot() {
|
||||||
return { title: '', notes: '', sections: [defaultSection('Ablauf')] }
|
return { title: '', notes: '', planned_duration_min: '', sections: [defaultSection('Ablauf')] }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichFrameworkSlotSections(slots) {
|
async function enrichFrameworkSlotSections(slots) {
|
||||||
|
|
@ -79,12 +80,10 @@ function defaultForm() {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
focus_area_id: '',
|
focus_area_ids: [],
|
||||||
style_direction_id: '',
|
style_direction_ids: [],
|
||||||
training_type_ids: [],
|
training_type_ids: [],
|
||||||
target_group_ids: [],
|
target_group_ids: [],
|
||||||
planned_period_start: '',
|
|
||||||
planned_period_end: '',
|
|
||||||
visibility: 'private',
|
visibility: 'private',
|
||||||
club_id: '',
|
club_id: '',
|
||||||
goals: [emptyGoal()],
|
goals: [emptyGoal()],
|
||||||
|
|
@ -105,12 +104,10 @@ function frameworkDraftSnapshot(fm) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
title: (fm.title || '').trim(),
|
title: (fm.title || '').trim(),
|
||||||
description: (fm.description || '').trim(),
|
description: (fm.description || '').trim(),
|
||||||
focus_area_id: fm.focus_area_id || '',
|
focus_area_ids: [...(fm.focus_area_ids || [])].map(String).sort(),
|
||||||
style_direction_id: fm.style_direction_id || '',
|
style_direction_ids: [...(fm.style_direction_ids || [])].map(String).sort(),
|
||||||
training_type_ids: [...(fm.training_type_ids || [])].map(String).sort(),
|
training_type_ids: [...(fm.training_type_ids || [])].map(String).sort(),
|
||||||
target_group_ids: [...(fm.target_group_ids || [])].map(String).sort(),
|
target_group_ids: [...(fm.target_group_ids || [])].map(String).sort(),
|
||||||
planned_period_start: fm.planned_period_start || '',
|
|
||||||
planned_period_end: fm.planned_period_end || '',
|
|
||||||
visibility: (fm.visibility || '').trim(),
|
visibility: (fm.visibility || '').trim(),
|
||||||
club_id: (fm.club_id || '').trim(),
|
club_id: (fm.club_id || '').trim(),
|
||||||
goals: goalsNorm,
|
goals: goalsNorm,
|
||||||
|
|
@ -123,16 +120,22 @@ function serverFrameworkToForm(fw) {
|
||||||
return {
|
return {
|
||||||
title: fw.title || '',
|
title: fw.title || '',
|
||||||
description: fw.description || '',
|
description: fw.description || '',
|
||||||
focus_area_id: fw.focus_area_id != null ? String(fw.focus_area_id) : '',
|
focus_area_ids: Array.isArray(fw.focus_area_ids)
|
||||||
style_direction_id: fw.style_direction_id != null ? String(fw.style_direction_id) : '',
|
? fw.focus_area_ids.map((x) => 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)
|
training_type_ids: Array.isArray(fw.training_type_ids)
|
||||||
? fw.training_type_ids.map((x) => String(x))
|
? fw.training_type_ids.map((x) => String(x))
|
||||||
: [],
|
: [],
|
||||||
target_group_ids: Array.isArray(fw.target_group_ids)
|
target_group_ids: Array.isArray(fw.target_group_ids)
|
||||||
? fw.target_group_ids.map((x) => String(x))
|
? 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',
|
visibility: fw.visibility || 'private',
|
||||||
club_id: fw.club_id != null ? String(fw.club_id) : '',
|
club_id: fw.club_id != null ? String(fw.club_id) : '',
|
||||||
goals: goalsIn.map((g) => ({
|
goals: goalsIn.map((g) => ({
|
||||||
|
|
@ -142,6 +145,10 @@ function serverFrameworkToForm(fw) {
|
||||||
slots: (fw.slots || []).map((s) => ({
|
slots: (fw.slots || []).map((s) => ({
|
||||||
title: s.title || '',
|
title: s.title || '',
|
||||||
notes: s.notes || '',
|
notes: s.notes || '',
|
||||||
|
planned_duration_min:
|
||||||
|
s.planned_duration_min != null && s.planned_duration_min !== undefined
|
||||||
|
? String(s.planned_duration_min)
|
||||||
|
: '',
|
||||||
sections: normalizeUnitToForm({
|
sections: normalizeUnitToForm({
|
||||||
sections: s.sections,
|
sections: s.sections,
|
||||||
exercises: s.exercises,
|
exercises: s.exercises,
|
||||||
|
|
@ -170,6 +177,7 @@ function buildApiPayload(form) {
|
||||||
sort_order: si,
|
sort_order: si,
|
||||||
title: (s.title || '').trim() || null,
|
title: (s.title || '').trim() || null,
|
||||||
notes: (s.notes || '').trim() || null,
|
notes: (s.notes || '').trim() || null,
|
||||||
|
planned_duration_min: parseDurationInput(s.planned_duration_min),
|
||||||
}
|
}
|
||||||
if (plan.phases) {
|
if (plan.phases) {
|
||||||
return { ...base, phases: plan.phases }
|
return { ...base, phases: plan.phases }
|
||||||
|
|
@ -177,14 +185,12 @@ function buildApiPayload(form) {
|
||||||
return { ...base, sections: plan.sections }
|
return { ...base, sections: plan.sections }
|
||||||
})
|
})
|
||||||
|
|
||||||
const focusAreaId =
|
const focus_area_ids = (form.focus_area_ids || [])
|
||||||
form.focus_area_id && !Number.isNaN(parseInt(form.focus_area_id, 10))
|
.map((x) => parseInt(String(x), 10))
|
||||||
? parseInt(form.focus_area_id, 10)
|
.filter((n) => !Number.isNaN(n) && n > 0)
|
||||||
: null
|
const style_direction_ids = (form.style_direction_ids || [])
|
||||||
const styleDirectionId =
|
.map((x) => parseInt(String(x), 10))
|
||||||
form.style_direction_id && !Number.isNaN(parseInt(form.style_direction_id, 10))
|
.filter((n) => !Number.isNaN(n) && n > 0)
|
||||||
? parseInt(form.style_direction_id, 10)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const training_type_ids = (form.training_type_ids || [])
|
const training_type_ids = (form.training_type_ids || [])
|
||||||
.map((x) => parseInt(String(x), 10))
|
.map((x) => parseInt(String(x), 10))
|
||||||
|
|
@ -201,12 +207,10 @@ function buildApiPayload(form) {
|
||||||
return {
|
return {
|
||||||
title: (form.title || '').trim(),
|
title: (form.title || '').trim(),
|
||||||
description: (form.description || '').trim() || null,
|
description: (form.description || '').trim() || null,
|
||||||
focus_area_id: focusAreaId,
|
focus_area_ids,
|
||||||
style_direction_id: styleDirectionId,
|
style_direction_ids,
|
||||||
training_type_ids,
|
training_type_ids,
|
||||||
target_group_ids,
|
target_group_ids,
|
||||||
planned_period_start: form.planned_period_start || null,
|
|
||||||
planned_period_end: form.planned_period_end || null,
|
|
||||||
visibility: form.visibility || 'private',
|
visibility: form.visibility || 'private',
|
||||||
club_id: clubId,
|
club_id: clubId,
|
||||||
goals,
|
goals,
|
||||||
|
|
@ -494,6 +498,22 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: true })
|
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 handleUnsavedDialogSave = async () => {
|
||||||
const ok = await performFrameworkSave({ fromUnsavedDialog: true })
|
const ok = await performFrameworkSave({ fromUnsavedDialog: true })
|
||||||
if (ok) blocker.proceed()
|
if (ok) blocker.proceed()
|
||||||
|
|
@ -544,14 +564,15 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
desktopLayout ? undefined : { display: panelActive(key) ? 'block' : 'none' }
|
desktopLayout ? undefined : { display: panelActive(key) ? 'block' : 'none' }
|
||||||
|
|
||||||
const trainingTypesFiltered = useMemo(() => {
|
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(
|
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(() => {
|
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)))
|
const allowed = new Set(trainingTypesFiltered.map((t) => String(t.id)))
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
const cur = prev.training_type_ids || []
|
const cur = prev.training_type_ids || []
|
||||||
|
|
@ -559,7 +580,27 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
if (next.length === cur.length) return prev
|
if (next.length === cur.length) return prev
|
||||||
return { ...prev, training_type_ids: next }
|
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 toggleTrainingTypeId = (tid) => {
|
||||||
const idStr = String(tid)
|
const idStr = String(tid)
|
||||||
|
|
@ -786,6 +827,20 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row" style={{ marginTop: '0.5rem', maxWidth: '220px' }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '0.82rem' }}>
|
||||||
|
Geplante Session-Dauer
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={slot.planned_duration_min ?? ''}
|
||||||
|
onChange={(e) => slotField(si, 'planned_duration_min', e.target.value)}
|
||||||
|
placeholder="z. B. 1,5 h"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<details className="framework-slot-details">
|
<details className="framework-slot-details">
|
||||||
<summary className="framework-slot-details__summary">Notizen (Session)</summary>
|
<summary className="framework-slot-details__summary">Notizen (Session)</summary>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|
@ -854,10 +909,12 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<PageFormEditorChrome
|
||||||
|
testId="framework-program-form"
|
||||||
|
title={pageTitle}
|
||||||
|
actionConfig={actionConfig}
|
||||||
|
>
|
||||||
<div className="framework-edit">
|
<div className="framework-edit">
|
||||||
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
|
|
||||||
|
|
||||||
<details className="framework-edit-intro">
|
<details className="framework-edit-intro">
|
||||||
<summary className="framework-edit-intro__summary">
|
<summary className="framework-edit-intro__summary">
|
||||||
Kurz erklärt: Was ist ein Rahmenprogramm?
|
Kurz erklärt: Was ist ein Rahmenprogramm?
|
||||||
|
|
@ -910,35 +967,47 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Fokusbereich (optional)</label>
|
<label className="form-label">Fokusbereiche (optional, Mehrfachwahl)</label>
|
||||||
<select
|
<div className="framework-catalog-checkgrid">
|
||||||
className="form-input"
|
{focusAreas.length === 0 ? (
|
||||||
value={form.focus_area_id}
|
<p className="form-sub" style={{ marginTop: 0 }}>
|
||||||
onChange={(e) => updateField('focus_area_id', e.target.value)}
|
Keine Fokusbereiche im Katalog.
|
||||||
>
|
</p>
|
||||||
<option value="">— keiner —</option>
|
) : (
|
||||||
{focusAreas.map((fa) => (
|
focusAreas.map((fa) => (
|
||||||
<option key={fa.id} value={String(fa.id)}>
|
<label key={fa.id} className="framework-catalog-check">
|
||||||
{fa.name}
|
<input
|
||||||
</option>
|
type="checkbox"
|
||||||
))}
|
checked={(form.focus_area_ids || []).includes(String(fa.id))}
|
||||||
</select>
|
onChange={() => toggleFocusAreaId(fa.id)}
|
||||||
<p className="form-sub">Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.</p>
|
/>
|
||||||
|
<span>{fa.name}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="form-sub">Aus dem Katalog; filtert die Trainingsarten unten.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Stilrichtung (optional)</label>
|
<label className="form-label">Stilrichtungen (optional, Mehrfachwahl)</label>
|
||||||
<select
|
<div className="framework-catalog-checkgrid">
|
||||||
className="form-input"
|
{styleDirections.length === 0 ? (
|
||||||
value={form.style_direction_id}
|
<p className="form-sub" style={{ marginTop: 0 }}>
|
||||||
onChange={(e) => updateField('style_direction_id', e.target.value)}
|
Keine Stilrichtungen im Katalog.
|
||||||
>
|
</p>
|
||||||
<option value="">— keine —</option>
|
) : (
|
||||||
{styleDirections.map((sd) => (
|
styleDirections.map((sd) => (
|
||||||
<option key={sd.id} value={String(sd.id)}>
|
<label key={sd.id} className="framework-catalog-check">
|
||||||
{sd.name}
|
<input
|
||||||
</option>
|
type="checkbox"
|
||||||
))}
|
checked={(form.style_direction_ids || []).includes(String(sd.id))}
|
||||||
</select>
|
onChange={() => toggleStyleDirectionId(sd.id)}
|
||||||
|
/>
|
||||||
|
<span>{sd.name}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Trainingsarten (optional, Mehrfachwahl)</label>
|
<label className="form-label">Trainingsarten (optional, Mehrfachwahl)</label>
|
||||||
|
|
@ -983,27 +1052,6 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Zeitraum von</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="form-input"
|
|
||||||
value={form.planned_period_start}
|
|
||||||
onChange={(e) => updateField('planned_period_start', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Zeitraum bis</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="form-input"
|
|
||||||
value={form.planned_period_end}
|
|
||||||
onChange={(e) => updateField('planned_period_end', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
|
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
<label className="form-label">Sichtbarkeit</label>
|
||||||
|
|
@ -1033,6 +1081,14 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isNew ? (
|
||||||
|
<div className="framework-edit__danger-zone">
|
||||||
|
<button type="button" className="btn btn-secondary framework-edit__delete-btn" onClick={handleDelete}>
|
||||||
|
Rahmenprogramm löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -1261,20 +1317,6 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormActionBar
|
|
||||||
isNew={isNew}
|
|
||||||
saving={saving}
|
|
||||||
onSave={handleSave}
|
|
||||||
onSaveAndClose={handleSaveAndClose}
|
|
||||||
onCancel={goBack}
|
|
||||||
cancelLabel="Abbrechen"
|
|
||||||
/>
|
|
||||||
{!isNew ? (
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={handleDelete} style={{ marginTop: '10px' }}>
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ExercisePickerModal
|
<ExercisePickerModal
|
||||||
|
|
@ -1343,6 +1385,6 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
onSave={handleUnsavedDialogSave}
|
onSave={handleUnsavedDialogSave}
|
||||||
onDiscardWithoutSave={() => setBypassDirty(true)}
|
onDiscardWithoutSave={() => setBypassDirty(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageFormEditorChrome>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,16 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import NavStateLink from '../components/NavStateLink'
|
import NavStateLink from '../components/NavStateLink'
|
||||||
|
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
|
||||||
|
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
||||||
|
import {
|
||||||
function dashIfEmpty(val) {
|
EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
||||||
const s = (val ?? '').toString().trim()
|
filterFrameworkPrograms,
|
||||||
return s.length ? s : '—'
|
hasActiveFrameworkImportFilters,
|
||||||
}
|
} from '../utils/frameworkProgramListHelpers'
|
||||||
|
|
||||||
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 (
|
|
||||||
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
|
|
||||||
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
|
|
||||||
</div>
|
|
||||||
{styleDir ? (
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtung</dt>
|
|
||||||
<dd style={{ margin: 0 }}>{styleDir}</dd>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Trainingsarten</dt>
|
|
||||||
<dd style={{ margin: 0 }}>{trainingTypes.length ? trainingTypes : '—'}</dd>
|
|
||||||
</div>
|
|
||||||
<div style={rowStyle}>
|
|
||||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Zielgruppen</dt>
|
|
||||||
<dd style={{ margin: 0 }}>{targetGroups.length ? targetGroups : '—'}</dd>
|
|
||||||
</div>
|
|
||||||
<div style={{ ...rowStyle, marginTop: '0.5rem' }}>
|
|
||||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Kurzbeschreibung</dt>
|
|
||||||
<dd style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
|
||||||
{(r.description && String(r.description).trim()) || '—'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TrainingFrameworkProgramsListPage() {
|
export default function TrainingFrameworkProgramsListPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -64,16 +19,38 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
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 : [])
|
setRows(Array.isArray(list) ? list : [])
|
||||||
|
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
|
||||||
|
setCatalogTrainingTypes(Array.isArray(tt) ? tt : [])
|
||||||
|
setCatalogTargetGroups(Array.isArray(tg) ? tg : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || 'Laden fehlgeschlagen')
|
setError(e.message || 'Laden fehlgeschlagen')
|
||||||
setRows([])
|
setRows([])
|
||||||
|
setCatalogFocusAreas([])
|
||||||
|
setCatalogTrainingTypes([])
|
||||||
|
setCatalogTargetGroups([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -94,116 +71,99 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="fw-prog-page">
|
||||||
<div
|
<header className="fw-prog-page__header">
|
||||||
style={{
|
<div className="fw-prog-page__intro">
|
||||||
display: 'flex',
|
<h1 className="page-title fw-prog-page__title">Trainingsrahmenprogramme</h1>
|
||||||
flexWrap: 'wrap',
|
<p className="fw-prog-page__lead">
|
||||||
alignItems: 'flex-start',
|
Vorlagen für Entwicklungsziele und Sessions — die Übernahme in Gruppentermine erfolgt in der
|
||||||
justifyContent: 'space-between',
|
Trainingsplanung.
|
||||||
gap: '1rem',
|
</p>
|
||||||
marginBottom: '1.25rem',
|
<details className="planning-filter-help fw-prog-page__help">
|
||||||
}}
|
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
|
||||||
|
<div className="planning-filter-help__body">
|
||||||
|
Unter <strong>Planung</strong> 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.
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<NavStateLink
|
||||||
|
to="/planning/framework-programs/new"
|
||||||
|
returnContext={frameworkListReturn}
|
||||||
|
className="btn btn-primary fw-prog-page__cta"
|
||||||
>
|
>
|
||||||
<div>
|
Rahmenprogramm anlegen
|
||||||
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
|
</NavStateLink>
|
||||||
Trainingsrahmenprogramme
|
</header>
|
||||||
</h1>
|
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
|
{error ? (
|
||||||
Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der
|
<div className="card fw-prog-page__error" role="alert">
|
||||||
Trainingsplanung (Registerkarte oben).
|
{error}
|
||||||
</p>
|
</div>
|
||||||
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
|
) : null}
|
||||||
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
|
|
||||||
<div className="planning-filter-help__body">
|
{loading ? (
|
||||||
Unter <strong>Planung</strong> wählst du eine Gruppe und übernimmst Slots aus einem Rahmenprogramm in
|
<div className="fw-prog-page__loading card">
|
||||||
echte Termine. So bleibt die Bibliothek wiederverwendbar, ohne dass Einzelgruppen fest verdrahtet sind.
|
<div className="spinner" aria-hidden="true" />
|
||||||
</div>
|
<p>Rahmenprogramme werden geladen…</p>
|
||||||
</details>
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="card fw-prog-page__empty">
|
||||||
|
<div className="fw-prog-page__empty-icon" aria-hidden="true">
|
||||||
|
📋
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className="fw-prog-page__empty-title">Noch keine Rahmenprogramme</h2>
|
||||||
|
<p className="fw-prog-page__empty-text">
|
||||||
|
Lege ein neues Programm an — mit Titel, mindestens einem Entwicklungsziel und optional Sessions samt
|
||||||
|
Übungsablauf.
|
||||||
|
</p>
|
||||||
<NavStateLink
|
<NavStateLink
|
||||||
to="/planning/framework-programs/new"
|
to="/planning/framework-programs/new"
|
||||||
returnContext={frameworkListReturn}
|
returnContext={frameworkListReturn}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-full"
|
||||||
style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}
|
|
||||||
>
|
>
|
||||||
Rahmenprogramm anlegen
|
Erstes Rahmenprogramm anlegen
|
||||||
</NavStateLink>
|
</NavStateLink>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FrameworkProgramsFilterBlock
|
||||||
|
programs={rows}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
panelOpen={filterPanelOpen}
|
||||||
|
onPanelOpenChange={setFilterPanelOpen}
|
||||||
|
catalogFocusAreas={catalogFocusAreas}
|
||||||
|
catalogTrainingTypes={catalogTrainingTypes}
|
||||||
|
catalogTargetGroups={catalogTargetGroups}
|
||||||
|
durationRadioName="fw-list-duration-mode"
|
||||||
|
className="fw-prog-filter-block--list"
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{filteredRows.length === 0 ? (
|
||||||
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
|
<div className="card fw-prog-page__empty fw-prog-page__empty--filter">
|
||||||
{error}
|
<h2 className="fw-prog-page__empty-title">Kein Treffer</h2>
|
||||||
</div>
|
<p className="fw-prog-page__empty-text">
|
||||||
)}
|
{filterActive
|
||||||
|
? 'Kein Rahmenprogramm passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
|
||||||
{loading ? (
|
: 'Keine Einträge.'}
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
</p>
|
||||||
<div className="spinner" />
|
</div>
|
||||||
<p>Laden…</p>
|
) : (
|
||||||
</div>
|
<ul className="fw-prog-list" aria-label="Rahmenprogramme">
|
||||||
) : rows.length === 0 ? (
|
{filteredRows.map((r) => (
|
||||||
<div className="card">
|
<li key={r.id}>
|
||||||
<p style={{ color: 'var(--text2)', marginBottom: '1rem' }}>
|
<FrameworkProgramListCard
|
||||||
Noch kein Rahmenprogramm gespeichert. Lege ein neues an — mit Titel, mindestens einem Ziel und optional
|
row={r}
|
||||||
Slots samt Übungen.
|
returnContext={frameworkListReturn}
|
||||||
</p>
|
onDelete={handleDelete}
|
||||||
<NavStateLink
|
/>
|
||||||
to="/planning/framework-programs/new"
|
</li>
|
||||||
returnContext={frameworkListReturn}
|
))}
|
||||||
className="btn btn-primary btn-full"
|
</ul>
|
||||||
style={{ textDecoration: 'none' }}
|
)}
|
||||||
>
|
</>
|
||||||
Rahmenprogramm anlegen
|
)}
|
||||||
</NavStateLink>
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="framework-programs-list">
|
|
||||||
{rows.map((r) => (
|
|
||||||
<li key={r.id} className="card">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0, flex: '1 1 220px' }}>
|
|
||||||
<NavStateLink
|
|
||||||
to={`/planning/framework-programs/${r.id}`}
|
|
||||||
returnContext={frameworkListReturn}
|
|
||||||
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
|
|
||||||
>
|
|
||||||
{r.title || `Rahmen #${r.id}`}
|
|
||||||
</NavStateLink>
|
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
|
||||||
<span>
|
|
||||||
{(r.goals_count ?? '—') + ' Ziele · '}
|
|
||||||
{(r.slots_count ?? '—') + ' Slots'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<FrameworkSummaryMeta r={r} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
||||||
<NavStateLink
|
|
||||||
to={`/planning/framework-programs/${r.id}`}
|
|
||||||
returnContext={frameworkListReturn}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
Bearbeiten
|
|
||||||
</NavStateLink>
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,13 +119,14 @@ export default function TrainingModulesListPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
<Link
|
<NavStateLink
|
||||||
|
to={`/planning/training-modules/${r.id}`}
|
||||||
|
returnContext={modulesListReturn}
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ textDecoration: 'none' }}
|
style={{ textDecoration: 'none' }}
|
||||||
to={`/planning/training-modules/${r.id}`}
|
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</Link>
|
</NavStateLink>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
||||||
Löschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
223
frontend/src/utils/frameworkProgramListHelpers.js
Normal file
223
frontend/src/utils/frameworkProgramListHelpers.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
46
frontend/src/utils/frameworkProgramListHelpers.test.js
Normal file
46
frontend/src/utils/frameworkProgramListHelpers.test.js
Normal file
|
|
@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
102
frontend/src/utils/trainingDurationUtils.js
Normal file
102
frontend/src/utils/trainingDurationUtils.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { buildPlanPayloadForSave, defaultSection } from './trainingUnitSectionsForm'
|
import { buildPlanPayloadForSave, defaultSection } from './trainingUnitSectionsForm'
|
||||||
import { sessionAssignDefaults } from './trainingPlanningPageHelpers'
|
import { sessionAssignDefaults } from './trainingPlanningPageHelpers'
|
||||||
|
import { minutesToDurationFieldValue, parseDurationInput } from './trainingDurationUtils'
|
||||||
|
|
||||||
/** Leeres Formular für neue Einheit (ohne async Varianten-Anreicherung). */
|
/** Leeres Formular für neue Einheit (ohne async Varianten-Anreicherung). */
|
||||||
export function createEmptyTrainingUnitFormData({
|
export function createEmptyTrainingUnitFormData({
|
||||||
|
|
@ -13,6 +14,7 @@ export function createEmptyTrainingUnitFormData({
|
||||||
planned_date: plannedDate || '',
|
planned_date: plannedDate || '',
|
||||||
planned_time_start: timeStart || '',
|
planned_time_start: timeStart || '',
|
||||||
planned_time_end: timeEnd || '',
|
planned_time_end: timeEnd || '',
|
||||||
|
planned_duration_min: '',
|
||||||
planned_focus: '',
|
planned_focus: '',
|
||||||
actual_date: '',
|
actual_date: '',
|
||||||
actual_time_start: '',
|
actual_time_start: '',
|
||||||
|
|
@ -52,6 +54,7 @@ export function trainingUnitToFormFields(fullUnit, sections) {
|
||||||
planned_date: fullUnit.planned_date || '',
|
planned_date: fullUnit.planned_date || '',
|
||||||
planned_time_start: fullUnit.planned_time_start?.slice?.(0, 5) || fullUnit.planned_time_start || '',
|
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_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 || '',
|
planned_focus: fullUnit.planned_focus || '',
|
||||||
actual_date: fullUnit.actual_date || '',
|
actual_date: fullUnit.actual_date || '',
|
||||||
actual_time_start: fullUnit.actual_time_start?.slice?.(0, 5) || fullUnit.actual_time_start || '',
|
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_date: formData.planned_date,
|
||||||
planned_time_start: formData.planned_time_start || null,
|
planned_time_start: formData.planned_time_start || null,
|
||||||
planned_time_end: formData.planned_time_end || null,
|
planned_time_end: formData.planned_time_end || null,
|
||||||
|
planned_duration_min: parseDurationInput(formData.planned_duration_min),
|
||||||
planned_focus: formData.planned_focus || null,
|
planned_focus: formData.planned_focus || null,
|
||||||
actual_date: formData.actual_date || null,
|
actual_date: formData.actual_date || null,
|
||||||
actual_time_start: formData.actual_time_start || null,
|
actual_time_start: formData.actual_time_start || null,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import api from './api'
|
||||||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||||||
|
|
||||||
export function defaultSection(title = 'Hauptteil') {
|
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). */
|
/** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */
|
||||||
|
|
@ -440,6 +440,10 @@ function normalizePhasesToFormSections(fullUnit) {
|
||||||
out.push({
|
out.push({
|
||||||
title: sec.title,
|
title: sec.title,
|
||||||
guidance_notes: sec.guidance_notes || '',
|
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),
|
items: formItemsFromApiItems(sec.items),
|
||||||
planLoc: { ...streamLoc },
|
planLoc: { ...streamLoc },
|
||||||
})
|
})
|
||||||
|
|
@ -459,6 +463,10 @@ function normalizePhasesToFormSections(fullUnit) {
|
||||||
out.push({
|
out.push({
|
||||||
title: sec.title,
|
title: sec.title,
|
||||||
guidance_notes: sec.guidance_notes || '',
|
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),
|
items: formItemsFromApiItems(sec.items),
|
||||||
planLoc: { ...loc },
|
planLoc: { ...loc },
|
||||||
})
|
})
|
||||||
|
|
@ -476,6 +484,10 @@ export function normalizeUnitToForm(fullUnit) {
|
||||||
return fullUnit.sections.map((sec) => ({
|
return fullUnit.sections.map((sec) => ({
|
||||||
title: sec.title,
|
title: sec.title,
|
||||||
guidance_notes: sec.guidance_notes || '',
|
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),
|
items: formItemsFromApiItems(sec.items),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -665,6 +677,7 @@ export function buildOneSectionPayload(sec, orderIndex) {
|
||||||
order_index: orderIndex,
|
order_index: orderIndex,
|
||||||
title: (sec.title || '').trim() || 'Abschnitt',
|
title: (sec.title || '').trim() || 'Abschnitt',
|
||||||
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
||||||
|
planned_duration_min: parseMin(sec.planned_duration_min),
|
||||||
items: (sec.items || [])
|
items: (sec.items || [])
|
||||||
.map((it, ii) => {
|
.map((it, ii) => {
|
||||||
if (it.item_type === 'note') {
|
if (it.item_type === 'note') {
|
||||||
|
|
@ -1173,6 +1186,7 @@ export function templateSectionsPayloadFromFormSections(sections) {
|
||||||
order_index: si,
|
order_index: si,
|
||||||
title: (s.title || '').trim() || `Abschnitt ${si + 1}`,
|
title: (s.title || '').trim() || `Abschnitt ${si + 1}`,
|
||||||
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null,
|
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null,
|
||||||
|
planned_duration_min: parseMin(s.planned_duration_min),
|
||||||
phase_kind: pk,
|
phase_kind: pk,
|
||||||
phase_order_index: poi,
|
phase_order_index: poi,
|
||||||
parallel_stream_order_index: pso,
|
parallel_stream_order_index: pso,
|
||||||
|
|
@ -1263,6 +1277,10 @@ export function formSectionsFromPlanTemplateRows(templateSections) {
|
||||||
return {
|
return {
|
||||||
title: s.title || 'Abschnitt',
|
title: s.title || 'Abschnitt',
|
||||||
guidance_notes: s.guidance_text || '',
|
guidance_notes: s.guidance_text || '',
|
||||||
|
planned_duration_min:
|
||||||
|
s.planned_duration_min != null && s.planned_duration_min !== undefined
|
||||||
|
? String(s.planned_duration_min)
|
||||||
|
: '',
|
||||||
items: [],
|
items: [],
|
||||||
planLoc,
|
planLoc,
|
||||||
}
|
}
|
||||||
|
|
@ -1342,9 +1360,34 @@ export async function insertTrainingModuleIntoPlanningSections({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sectionPlannedMinutes(sec) {
|
export function sectionPlannedMinutes(sec) {
|
||||||
|
const sectionMin = parseMin(sec?.planned_duration_min)
|
||||||
|
if (sectionMin != null && sectionMin > 0) return sectionMin
|
||||||
return (sec.items || []).reduce((sum, it) => {
|
return (sec.items || []).reduce((sum, it) => {
|
||||||
if (it.item_type !== 'exercise') return sum
|
if (it.item_type !== 'exercise') return sum
|
||||||
const m = parseMin(it.planned_duration_min)
|
const m = parseMin(it.planned_duration_min)
|
||||||
return sum + (m || 0)
|
return sum + (m || 0)
|
||||||
}, 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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user