Parlellsession- Plan #35

Merged
Lars merged 34 commits from develop into main 2026-05-15 22:04:53 +02:00
45 changed files with 12885 additions and 7519 deletions

View File

@ -482,6 +482,8 @@ skill_level_definitions (
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
**Schema-Hinweis (2026-05):** Tabelle `training_unit_sections` hat **`UNIQUE (training_unit_id, order_index)`** (Migration 031). Damit sind **zwei gleichzeitige „Spuren“ mit jeweils eigener Sektion auf derselben `order_index`** nicht abbildbar — Voraussetzung für Parallele Streams ist eine **geplante Migrations-/Constraint-Anpassung** (partielle Uniques pro Phase/Stream); siehe Arbeitsdokument `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md`. **Keine invasive Migration ohne explizite Freigabe.**
---
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)

View File

@ -0,0 +1,125 @@
# Parallele Trainingsstreams — Ist-Analyse und risikoarmer Umsetzungsplan
**Status:** Stufe A (Analyse/Plan, ohne produktive Umsetzung in jener Session)
**Stand:** 2026-05-14
**Verbindliche fachliche Basis:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
Dieses Dokument **persistiert** die strukturierte Prüfung der realen Codebasis (`training_planning.py`, `training_framework_programs.py`, `training_unit_sections`/`items`, Frontend Planung/Run/Coach) und den empfohlenen Implementierungspfad.
---
## 1. Zusammenfassung
- Plan-Inhalt pro Einheit ist heute **eine flache Liste** `training_unit_sections` mit **`UNIQUE (training_unit_id, order_index)`** (Migration 031) und `training_unit_section_items`; zentral: **`_fetch_sections`**, **`_replace_unit_sections`**, **`_hydrate_training_unit_payload`** in `backend/routers/training_planning.py`.
- Parallele Phasen/Streams **passen** zu den Produktregeln (ein Kalendertermin, N Streams, je Miniplan), sind im Schema aber **nicht** abbildbar ohne Erweiterung und **ohne Auflösung** des globalen `order_index`-Modells.
- **Empfehlung:** **Normalisierte** Tabellen `training_unit_phases`, `training_unit_parallel_streams`, erweiterte `training_unit_sections` mit FK auf Phase bzw. Stream, **partielle Unique-Indizes** statt `UNIQUE (training_unit_id, order_index)` für alle Sektionen.
- **Blocker im Code:** u. a. `POST /api/training-units/{id}/apply-training-module` mit **`section_order_index` global pro Einheit** (`_resolve_training_unit_section_id`).
- **Nicht persistiert an anderer Stelle:** Erste Fassung existierte nur als Chat-Antwort; dieses File ist die **kanonische** Arbeitskopie im Repo.
---
## 2. Ist-Analyse (kurz)
### Datenbank
- `training_unit_sections`: u. a. `training_unit_id`, `order_index`, `UNIQUE (training_unit_id, order_index)`.
- `training_unit_section_items`: Übung/Notiz, `planning_method_profile` (Kombi), `source_training_module_id`.
### Backend (`training_planning.py`)
- `_replace_unit_sections`: DELETE aller Sektionen der Einheit + INSERT (vollständiger Ersetzungsbaum).
- `_sections_clone_payload` + `_copy_blueprint_into_scheduled_unit`: tiefe Kopie für `from-framework-slot`.
- `_flatten_exercises_from_sections`: flaches `exercises` am Unit-Payload.
- `apply_training_module_to_training_unit`: Sektion per **`section_order_index`** global.
### Rahmen (`training_framework_programs.py`)
- Blueprint-`training_units` pro Slot; gleiche `_replace_unit_sections`-Semantik.
### Frontend
- Planung: `TrainingPlanningPageRoot.jsx`, `TrainingUnitSectionsEditor`, `buildSectionsPayload` / `normalizeUnitToForm`.
- Run: `TrainingUnitRunPage.jsx` — Fortschritt `sessionStorage` Key `sj_training_run_checked_${unitId}`.
- Coach: `TrainingCoachPage.jsx``flattenPlanTimeline` (linearer Ablauf).
### Tests
- Kaum Abdeckung für Plan-Inhalt; vorhanden u. a. `test_training_unit_assignments.py` (Merge Co-Trainer, ohne DB), `test_training_units_list_keyset.py` (Keyset-Validierung).
---
## 3. Technische Optionen und Empfehlung
| Option | Kurz |
|--------|------|
| A JSONB nur auf `training_units` | Niedriges DDL-Risiko, hohes Drift-/Wartungsrisiko — **nicht empfohlen** |
| B Normalisiert Phasen/Streams | **Empfohlen** — eine Wahrheit, saubere Kopie, Rahmen kompatibel |
| C Nur UI-Konvention ohne DB | Widerspricht Produkt — **abgelehnt** |
---
## 4. Migrations- und Kompatibilitätsstrategie
- Default **`whole_group`Phase** für alle bestehenden Einheiten; alle bisherigen Sektionen erhalten `phase_id`.
- Unique-Regel: **pro Phase** bzw. **pro Stream** `order_index` eindeutig (partielle Unique-Indizes).
- API optional: zusätzlich abgeleitetes flaches `sections` für Übergang — Entscheidung je nach Consumer (praktisch nur dieses Frontend).
---
## 5. API- / Frontend-Hotspots
- `GET`/`PUT` `/api/training-units/{id}`: verschachtelte `phases` / `streams` / `sections` / `items`.
- `POST .../apply-training-module`: Kontext **Phase/Stream + Sektionsindex im Träger**.
- Run/Coach: stream-spezifischer Fortschritt; `flattenPlanTimeline` phase-aware oder pro Stream.
---
## 6. Implementierungspakete (Überblick)
0. Spike DDL + Contract-Doku
1. **Erledigt (2026-05-14):** Migration **063** + `training_planning`: Phasen/Streams-Schema, Backfill whole_group, `GET` mit `phases`, Legacy-`sections`-PUT unverändert (eine whole_group-Phase).
2. PUT mit echten Parallelphasen / Streams, `apply-training-module` mit Stream-Kontext, `from-framework-slot`-Kopie
3. Planung UI
4. Run + Coach
5. Co-Trainer pro Stream
6. MVP+ (Duplizieren, Verschieben, „nur meine Spur“)
---
## 7. Risiken
- Migration Unique-Constraint / bestehende Daten.
- Regression Run/Coach / Dashboard-Joins (meist unkritisch, solange `training_unit_id` auf Sektionen bleibt).
- Rahmen-Blueprints: gleiche Struktur wie Kalender-Einheiten anstreben (oder bewusst zweite Phase nur Kalender).
---
## 8. Offene Produkt-/Technikfragen
- Rahmen-Blueprint parallel im MVP oder erst nach Kalender-Einheit?
- Semantik `exercises`-Flatlist bei Parallelität.
- Merge-Regel `assistant_trainer_profile_ids` Kopf vs. Stream-Zuweisungen.
---
## 9. Verweise
- Fachkonzept: `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
- Technische Spec (Entwurf): `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
- Domänenüberblick: `.claude/docs/functional/DOMAIN_MODEL.md` (Abschnitt Parallele Streams)
- `./PARALLEL_TRAINING_STREAMS_PREREQ_PROMPT.md`**Prompt** für Folgesession (Performance/Wartung/Vorbereitung)
---
## 10. Vorbereitende Arbeiten (Session 2026-05-13)
Ohne produktives Parallel-Feature, nur Risikoabbau und Transparenz:
- **`training_planning.py`:** Lesepfad `_fetch_sections` in SQL-Konstanten + `_fetch_section_items_for_section` / `_hydrate_section_item_combination_slots` strukturiert; `_replace_unit_sections` delegiert an `_insert_one_replacement_section`; `_hydrate_training_unit_payload` dokumentiert.
- **Tests:** `tests/test_training_planning_sections_pure.py` (flatten, ohne DB); `tests/test_training_planning_sections_integration.py` (Roundtrip replace↔fetch bei `TRAINING_PLANNING_INTEGRATION=1`).
- **Frontend:** Kurzkommentare an Planung (`TrainingPlanningPageRoot`, `buildSectionsPayload`), Run, Coach, `flattenPlanTimeline` — Anbindungspunkte für spätere Phase/Stream-Logik.
- **DOMAIN_MODEL:** UNIQUE-Hinweis und „keine Migration ohne Freigabe“.
**Empfohlene nächste Schritte:** Pakete **0** (DDL/Contract festzurren) und **1** (Schema + Migration + hydrate/replace laut Plan Abschnitt 46) in einer dedizierten Feature-Session; danach Paket **2** (PUT/Module/Clone).
---

View File

@ -0,0 +1,41 @@
# Prompt: Vorbereitungs- / Vorarbeit-Session (Performance & Wartung) für „Parallele Trainingsstreams“
**Kontext:** Du arbeitest in **Shinkan Jinkendo** (`c:\Dev\shinkan-jinkendo`). Das Feature **Parallele Trainingsstreams / Breakout** ist **inhaltlich** spezifiziert; eine **Ist-Analyse und ein risikoarmer Umsetzungsplan** liegen **persistiert** in:
- `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md`
- Fachlich: `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
- Technik-Entwurf: `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
**Deine Rolle:** Du hast bereits **Refaktorierungs- und Wartungsaufgaben** mit Fokus **Performance, Lesbarkeit und Testbarkeit** durchgeführt. In **dieser** Session geht es **nicht** darum, das komplette Parallel-Feature zu bauen, sondern um **Vorarbeiten („Prerequisites“)**, die die geplante Komplexitätsauflösung **sicherer und billiger** machen.
## Ziele
1. **Lesepfad Planung vereinheitlichen:** `backend/routers/training_planning.py` ist zentral für `_fetch_sections`, `_replace_unit_sections`, `_hydrate_training_unit_payload`, Klonen, Blueprint-Kopie, `apply-training-module`. Prüfe, ob klar abgegrenzte Hilfsfunktionen (ohne Verhaltensänderung) die **nächste** große Änderung erleichtern — **keine** Feature-Logik für Phasen/Streams hinzufügen, nur Struktur/Tests/Docs wenn nötig.
2. **Test-Lücken schließen (minimal, hoher Nutzen):** Heute fehlen **DB/API-Tests** für kritische Pfade (`_replace_unit_sections` Roundtrip, `from-framework-slot` Struktur-Kopie, optional `apply-training-module`). Ergänze **kleine, deterministische** Tests (pytest mit DB, falls im Projekt üblich), ohne riesige Fixtures.
3. **Frontend-Schneidstellen markieren:** kurze Kommentare oder ein **Working-Doc-Update**, wo `TrainingPlanningPageRoot`, `buildSectionsPayload`, `TrainingUnitRunPage`, `TrainingCoachPage` + `trainingPlanUtils.flattenPlanTimeline` später angebunden werden — **kein** großes UI-Rewrite.
4. **Migrations-Sicherheit:** Dokumentiere in **einem Absatz** im `ANALYSIS`-Dokument oder hier, welche **Unique-Constraints** (`training_unit_sections`: `UNIQUE (training_unit_id, order_index)`) die Parallelität blockieren — **ohne** sie schon zu ändern, außer es ist Teil einer **explizit** freigegebenen ersten Migration.
5. **Performance nur berührensensible Stellen:** Einzelabruf `GET /api/training-units/{id}` wird mit mehr JOINs kommen. Falls du **jetzt** N+1 oder redundante Arbeit in `_fetch_sections` siehst und das **risikoarm** verbesserbar ist, nur mit **Messpunkt/Messvorstellung** (kein unnötiger Micro-Optimismus).
## Leitplanken
- **Stabilität vor Geschwindigkeit:** Keine Änderung, die bestehende Einheiten, Rahmen-Blueprints oder Run-Modus bricht.
- **Keine pauschalen Refactors:** Nur Änderungen mit **klarem** Träger für das Parallel-Feature oder mit **Test-Regression-Schutz**.
- **Regeln:** `.claude/rules/ARCHITECTURE.md`, `CODING_RULES.md`, Zugriffsschicht wo relevant.
## Erwartete Ausgabe
1. Kurze **Liste erledigter Vorarbeiten** (Dateien, was warum).
2. **Empfohlene Reihenfolge** für die **nächste** Session, die Phasen/Streams **implementiert** (verweis auf `PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md` Pakete 02).
3. Falls nichts Sinnvolles ohne Feature-Branch riskiert werden kann: **explizit** „keine Code-Änderung“, nur Risiko-Notiz.
## Optionaler Startbefehl
```
Lies zuerst:
.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md
dann backend/routers/training_planning.py (Abschnitte um _fetch_sections, _replace_unit_sections).
```

View File

@ -10,7 +10,7 @@ on:
types: [completed]
jobs:
# Wie Mitai-Jinkendo: pytest im laufenden backend-Container (Python aus Image, gleiche DB wie Deploy).
# Pytest im laufenden backend-Container; ACCESS_LAYER + TRAINING_PLANNING Integration gegen dieselbe PostgreSQL wie Deploy (Schema via Container-Start migriert).
pytest-backend:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
@ -39,7 +39,7 @@ jobs:
cd /app &&
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
python scripts/security_release_checks.py &&
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
ACCESS_LAYER_INTEGRATION=1 TRAINING_PLANNING_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
"
lint-backend:

View File

@ -0,0 +1,85 @@
-- Migration 063: Phasen und parallele Streams pro Trainingseinheit (Grundlage Breakout).
-- Bestehende Sektionen werden einer Default-whole_group-Phase zugeordnet.
-- UNIQUE (training_unit_id, order_index) auf Sektionen entfällt zugunsten
-- eindeutiger order_index je Phase bzw. je parallel_stream.
-- ── Phasen ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS training_unit_phases (
id SERIAL PRIMARY KEY,
training_unit_id INT NOT NULL REFERENCES training_units(id) ON DELETE CASCADE,
order_index INT NOT NULL,
phase_kind VARCHAR(20) NOT NULL CHECK (phase_kind IN ('whole_group', 'parallel')),
title VARCHAR(200),
guidance_notes TEXT,
UNIQUE (training_unit_id, order_index)
);
CREATE INDEX IF NOT EXISTS idx_training_unit_phases_unit ON training_unit_phases(training_unit_id);
-- ── Streams innerhalb einer Parallelphase ──────────────────────────────────
CREATE TABLE IF NOT EXISTS training_unit_parallel_streams (
id SERIAL PRIMARY KEY,
phase_id INT NOT NULL REFERENCES training_unit_phases(id) ON DELETE CASCADE,
order_index INT NOT NULL,
title VARCHAR(200),
notes TEXT,
assigned_trainer_profile_ids JSONB,
UNIQUE (phase_id, order_index)
);
CREATE INDEX IF NOT EXISTS idx_training_unit_parallel_streams_phase
ON training_unit_parallel_streams(phase_id);
COMMENT ON COLUMN training_unit_parallel_streams.assigned_trainer_profile_ids IS
'Optionale Co-Trainer-IDs (JSON-Array von Profil-IDs) für diese Teilstrecke; MVP+';
-- ── Sektionen: Zuordnung zu Phase (gemeinsam) oder Stream (parallel) ─────
ALTER TABLE training_unit_sections
ADD COLUMN IF NOT EXISTS phase_id INT REFERENCES training_unit_phases(id) ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS parallel_stream_id INT REFERENCES training_unit_parallel_streams(id) ON DELETE CASCADE;
-- Backfill: je Einheit mit Sektionen eine whole_group-Phase, alle Sektionen dorthin
INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title)
SELECT tu.id, 0, 'whole_group', NULL
FROM training_units tu
WHERE EXISTS (SELECT 1 FROM training_unit_sections s WHERE s.training_unit_id = tu.id)
AND NOT EXISTS (
SELECT 1 FROM training_unit_phases p
WHERE p.training_unit_id = tu.id AND p.order_index = 0 AND p.phase_kind = 'whole_group'
);
UPDATE training_unit_sections tus
SET phase_id = p.id
FROM training_unit_phases p
WHERE tus.phase_id IS NULL
AND p.training_unit_id = tus.training_unit_id
AND p.order_index = 0
AND p.phase_kind = 'whole_group';
-- Alte globale Reihenfolge-Eindeutigkeit pro Einheit entfernen
ALTER TABLE training_unit_sections
DROP CONSTRAINT IF EXISTS training_unit_sections_training_unit_id_order_index_key;
-- Genau eine Zielspalte gesetzt: gemeinsame Phase ODER paralleler Stream
ALTER TABLE training_unit_sections
DROP CONSTRAINT IF EXISTS training_unit_sections_phase_or_stream_chk;
ALTER TABLE training_unit_sections
ADD CONSTRAINT training_unit_sections_phase_or_stream_chk CHECK (
(phase_id IS NOT NULL AND parallel_stream_id IS NULL)
OR (phase_id IS NULL AND parallel_stream_id IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_phase_order
ON training_unit_sections (phase_id, order_index)
WHERE phase_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_stream_order
ON training_unit_sections (parallel_stream_id, order_index)
WHERE parallel_stream_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_training_unit_sections_phase
ON training_unit_sections(phase_id) WHERE phase_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_training_unit_sections_parallel_stream
ON training_unit_sections(parallel_stream_id) WHERE parallel_stream_id IS NOT NULL;

View File

@ -6,5 +6,5 @@ python_functions = test_*
addopts = -q --tb=short
markers =
smoke: Schnelle Kern-Regression.
integration: PostgreSQL-Mandanten-Integration (ACCESS_LAYER_INTEGRATION=1).
integration: PostgreSQL-Integration (z. B. ACCESS_LAYER_INTEGRATION=1, TRAINING_PLANNING_INTEGRATION=1).
slow: Lange/schwere Tests; in CI wie Mitai-Jinkendo ausgeschlossen (Auswahl: not slow).

View File

@ -484,6 +484,94 @@ def _normalize_assistant_trainer_profile_ids(
)
return uniq
def _normalize_stream_assigned_trainer_profile_ids(
cur,
raw_val: Any,
*,
group_id: Optional[int],
profile_id: int,
role: str,
unit_created_by: Optional[int],
eff_lead_nid: Optional[int],
) -> Any:
"""
JSONB-Liste für training_unit_parallel_streams.assigned_trainer_profile_ids.
Ohne group_id (Rahmen-Blueprint): nur Profil-Existenz + keine Überschneidung mit Leitung.
Mit group_id: gleiche Vereins-/Zuweisungsregeln wie assistant_trainer_profile_ids.
"""
if raw_val is None:
return None
if not isinstance(raw_val, list):
raise HTTPException(
status_code=400,
detail="assigned_trainer_profile_ids (Stream) muss Liste oder null sein",
)
ids_in: List[int] = []
for x in raw_val:
try:
i = int(x)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail="assigned_trainer_profile_ids (Stream) ungültig",
)
if i < 1:
raise HTTPException(
status_code=400,
detail="assigned_trainer_profile_ids (Stream) ungültig",
)
ids_in.append(i)
uniq = sorted(set(ids_in))
for nid in uniq:
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
if not cur.fetchone():
raise HTTPException(
status_code=400,
detail="Profil für Stream-Co-Trainer nicht gefunden",
)
if eff_lead_nid is not None and nid == eff_lead_nid:
raise HTTPException(
status_code=400,
detail="Leitung und Stream-Co-Trainer dürfen sich nicht überschneiden",
)
if group_id is None:
return uniq
cur.execute(
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
grd = dict(gr)
cid = grd.get("club_id")
if cid is None:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
club_i = int(cid)
if not is_platform_admin(role) and not _caller_may_assign_session_trainers(
cur, grd, profile_id, role, unit_created_by
):
raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer (Stream) zuzuweisen")
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
for x in grd.get("co_trainer_ids") or []:
try:
eligible.add(int(x))
except (TypeError, ValueError):
continue
for nid in uniq:
if is_platform_admin(role):
continue
if nid in eligible:
continue
if not _profile_active_in_club(cur, club_i, nid):
raise HTTPException(
status_code=400,
detail="Stream-Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
)
return uniq
def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]:
"""None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung."""
if raw is None:
@ -522,21 +610,52 @@ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
return i
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
# ── Sektionen laden / ersetzen (Kernpfad Planungsinhalt) ──────────────────
# Hinweis: Pro Sektion ein Items-Query (N+1) — bewusst einfach; Batching später möglich.
def _clear_unit_plan_content(cur, unit_id: int) -> None:
"""Löscht alle Planungs-Phasen der Einheit (CASCADE: Streams, Sektionen, Items)."""
cur.execute("DELETE FROM training_unit_phases WHERE training_unit_id = %s", (unit_id,))
def _ensure_default_whole_group_phase(cur, unit_id: int, *, order_index: int = 0) -> int:
"""Legt bei Bedarf eine whole_group-Phase an; gibt phase.id zurück."""
cur.execute(
"""
SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id
FROM training_unit_sections
WHERE training_unit_id = %s
ORDER BY order_index
SELECT id FROM training_unit_phases
WHERE training_unit_id = %s AND phase_kind = 'whole_group' AND order_index = %s
LIMIT 1
""",
(unit_id,),
(unit_id, order_index),
)
secs = []
for sec_row in cur.fetchall():
sec = r2d(sec_row)
cur.execute(
"""
row = cur.fetchone()
if row:
return int(row["id"])
cur.execute(
"""
INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes)
VALUES (%s, %s, 'whole_group', NULL, NULL)
RETURNING id
""",
(unit_id, order_index),
)
return int(cur.fetchone()["id"])
_SECTION_ROWS_SQL = """
SELECT tus.id, tus.training_unit_id, tus.order_index, tus.title, tus.guidance_notes,
tus.source_template_section_id, tus.phase_id, tus.parallel_stream_id
FROM training_unit_sections tus
LEFT JOIN training_unit_phases ph ON ph.id = tus.phase_id
LEFT JOIN training_unit_parallel_streams ps ON ps.id = tus.parallel_stream_id
LEFT JOIN training_unit_phases ph_s ON ph_s.id = ps.phase_id
WHERE tus.training_unit_id = %s
ORDER BY COALESCE(ph.order_index, ph_s.order_index) ASC,
ps.order_index ASC NULLS FIRST,
tus.order_index ASC
"""
_SECTION_ITEMS_ROWS_SQL = """
SELECT tusi.*,
e.title AS exercise_title,
e.exercise_kind AS exercise_kind,
@ -558,75 +677,201 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
WHERE tusi.section_id = %s
ORDER BY tusi.order_index
""",
(sec["id"],),
)
sec["items"] = [r2d(r) for r in cur.fetchall()]
for it in sec["items"]:
if it.get("item_type") != "exercise":
continue
cmp_raw = it.get("catalog_method_profile")
if not isinstance(cmp_raw, dict):
it["catalog_method_profile"] = {}
else:
it["catalog_method_profile"] = dict(cmp_raw)
ek = str(it.get("exercise_kind") or "simple").strip().lower()
if ek == "combination" and it.get("exercise_id"):
try:
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
except (TypeError, ValueError):
it["combination_slots"] = []
else:
it["combination_slots"] = []
"""
def _hydrate_section_item_combination_slots(cur, it: Dict[str, Any]) -> None:
"""Setzt `combination_slots` für KombiÜbungen; sonst leere Liste."""
if it.get("item_type") != "exercise":
return
cmp_raw = it.get("catalog_method_profile")
if not isinstance(cmp_raw, dict):
it["catalog_method_profile"] = {}
else:
it["catalog_method_profile"] = dict(cmp_raw)
ek = str(it.get("exercise_kind") or "simple").strip().lower()
if ek == "combination" and it.get("exercise_id"):
try:
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
except (TypeError, ValueError):
it["combination_slots"] = []
else:
it["combination_slots"] = []
def _fetch_section_items_for_section(cur, section_id: int) -> List[Dict[str, Any]]:
cur.execute(_SECTION_ITEMS_ROWS_SQL, (section_id,))
items = [r2d(r) for r in cur.fetchall()]
for it in items:
_hydrate_section_item_combination_slots(cur, it)
return items
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
"""Lädt alle Sektionen inkl. Items und Katalog-Anreicherung für die Einheit."""
cur.execute(_SECTION_ROWS_SQL, (unit_id,))
secs = []
for sec_row in cur.fetchall():
sec = r2d(sec_row)
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
secs.append(sec)
return secs
def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
"""Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder)."""
secs = _fetch_sections(cur, unit_id)
def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]:
"""Verschachtelte Phasen/Streams/Sektionen für GET (UI kann parallele Sp später nutzen)."""
cur.execute(
"""
SELECT id, training_unit_id, order_index, phase_kind, title, guidance_notes
FROM training_unit_phases
WHERE training_unit_id = %s
ORDER BY order_index
""",
(unit_id,),
)
out: List[Dict[str, Any]] = []
for sec in secs:
items_clean: List[Dict[str, Any]] = []
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
oix = it.get("order_index")
if itype == "note":
note_item = {
"item_type": "note",
"order_index": oix,
"note_body": it.get("note_body") or "",
}
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None:
note_item["source_training_module_id"] = sm
items_clean.append(note_item)
continue
if itype != "exercise" or not it.get("exercise_id"):
continue
ex_item = {
"item_type": "exercise",
for prow in cur.fetchall():
p = r2d(prow)
pk = str(p.get("phase_kind") or "").strip().lower()
if pk == "whole_group":
cur.execute(
"""
SELECT id, training_unit_id, order_index, title, guidance_notes,
source_template_section_id, phase_id, parallel_stream_id
FROM training_unit_sections
WHERE phase_id = %s
ORDER BY order_index
""",
(p["id"],),
)
secs: List[Dict[str, Any]] = []
for srow in cur.fetchall():
sec = r2d(srow)
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
secs.append(sec)
p["sections"] = secs
p["streams"] = []
elif pk == "parallel":
p["sections"] = []
cur.execute(
"""
SELECT id, phase_id, order_index, title, notes, assigned_trainer_profile_ids
FROM training_unit_parallel_streams
WHERE phase_id = %s
ORDER BY order_index
""",
(p["id"],),
)
streams: List[Dict[str, Any]] = []
for st_row in cur.fetchall():
st = r2d(st_row)
cur.execute(
"""
SELECT id, training_unit_id, order_index, title, guidance_notes,
source_template_section_id, phase_id, parallel_stream_id
FROM training_unit_sections
WHERE parallel_stream_id = %s
ORDER BY order_index
""",
(st["id"],),
)
secs = []
for sec_row in cur.fetchall():
sec = r2d(sec_row)
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
secs.append(sec)
st["sections"] = secs
streams.append(st)
p["streams"] = streams
else:
p["sections"] = []
p["streams"] = []
out.append(p)
return out
def _clone_section_payload_dict(sec: Dict[str, Any]) -> Dict[str, Any]:
"""Sektion inkl. Items ohne DB-IDs (für phases-Payload / Kopie)."""
items_clean: List[Dict[str, Any]] = []
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
oix = it.get("order_index")
if itype == "note":
note_item = {
"item_type": "note",
"order_index": oix,
"exercise_id": it["exercise_id"],
"exercise_variant_id": it.get("exercise_variant_id"),
"planned_duration_min": it.get("planned_duration_min"),
"actual_duration_min": it.get("actual_duration_min"),
"notes": it.get("notes"),
"modifications": it.get("modifications"),
"planning_method_profile": it.get("planning_method_profile"),
"note_body": it.get("note_body") or "",
}
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None:
ex_item["source_training_module_id"] = sm
items_clean.append(ex_item)
out.append(
{
"title": sec.get("title"),
"order_index": sec.get("order_index"),
"guidance_notes": sec.get("guidance_notes"),
"items": items_clean,
}
)
note_item["source_training_module_id"] = sm
items_clean.append(note_item)
continue
if itype != "exercise" or not it.get("exercise_id"):
continue
ex_item = {
"item_type": "exercise",
"order_index": oix,
"exercise_id": it["exercise_id"],
"exercise_variant_id": it.get("exercise_variant_id"),
"planned_duration_min": it.get("planned_duration_min"),
"actual_duration_min": it.get("actual_duration_min"),
"notes": it.get("notes"),
"modifications": it.get("modifications"),
"planning_method_profile": it.get("planning_method_profile"),
}
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None:
ex_item["source_training_module_id"] = sm
items_clean.append(ex_item)
row: Dict[str, Any] = {
"title": sec.get("title"),
"order_index": sec.get("order_index"),
"guidance_notes": sec.get("guidance_notes"),
"items": items_clean,
}
stid = sec.get("source_template_section_id")
if stid is not None and stid != "":
try:
stid_i = int(stid)
if stid_i >= 1:
row["source_template_section_id"] = stid_i
except (TypeError, ValueError):
pass
return row
def _phases_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
"""Vollständige Phasen/Streams/Sektionen für tiefe Kopie (ohne DB-IDs)."""
nested = _fetch_phases_nested(cur, unit_id)
out: List[Dict[str, Any]] = []
for ph in nested:
kind = str(ph.get("phase_kind") or "").strip().lower()
if kind not in ("whole_group", "parallel"):
kind = "whole_group"
pd: Dict[str, Any] = {
"order_index": ph.get("order_index"),
"phase_kind": kind,
"title": ph.get("title"),
"guidance_notes": ph.get("guidance_notes"),
}
if kind == "whole_group":
pd["sections"] = [_clone_section_payload_dict(s) for s in ph.get("sections") or []]
pd["streams"] = []
else:
pd["sections"] = []
streams_clean: List[Dict[str, Any]] = []
for st in ph.get("streams") or []:
sd: Dict[str, Any] = {
"order_index": st.get("order_index"),
"title": st.get("title"),
"notes": st.get("notes"),
"assigned_trainer_profile_ids": st.get("assigned_trainer_profile_ids"),
"sections": [_clone_section_payload_dict(s) for s in st.get("sections") or []],
}
streams_clean.append(sd)
pd["streams"] = streams_clean
out.append(pd)
return out
@ -637,6 +882,7 @@ def _copy_blueprint_into_scheduled_unit(
planned_date: str,
profile_id: int,
origin_framework_slot_id: Optional[int],
role: str,
) -> int:
cur.execute(
"""
@ -692,8 +938,8 @@ def _copy_blueprint_into_scheduled_unit(
if not row:
raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden")
nu = row["id"]
cloned = _sections_clone_payload(cur, blueprint_unit_id)
_replace_unit_sections(cur, nu, cloned)
cloned = _phases_clone_payload(cur, blueprint_unit_id)
_replace_unit_phases(cur, nu, cloned, profile_id, role, profile_id)
return nu
@ -707,17 +953,27 @@ def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None:
def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]:
"""GET-Payload: `phases` (verschachtelt), flache `sections` + abgeleitete `exercises` (Legacy)."""
uid = unit["id"]
unit["phases"] = _fetch_phases_nested(cur, uid)
unit["sections"] = _fetch_sections(cur, uid)
_flatten_exercises_from_sections(unit)
return unit
def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int:
"""Erste Sektion mit order_index in einer whole_group-Phase (Parallelstreams ausgeschlossen)."""
cur.execute(
"""
SELECT id FROM training_unit_sections
WHERE training_unit_id = %s AND order_index = %s
SELECT tus.id
FROM training_unit_sections tus
INNER JOIN training_unit_phases p ON p.id = tus.phase_id
WHERE tus.training_unit_id = %s
AND tus.order_index = %s
AND tus.parallel_stream_id IS NULL
AND LOWER(TRIM(p.phase_kind)) = 'whole_group'
ORDER BY p.order_index ASC, tus.id ASC
LIMIT 1
""",
(unit_id, section_order_index),
)
@ -729,6 +985,52 @@ def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: in
return int(r["id"])
def _resolve_training_unit_section_id_for_apply(
cur,
unit_id: int,
section_order_index: int,
*,
phase_order_index: Optional[int],
parallel_stream_order_index: Optional[int],
) -> int:
"""Ziel-Abschnitt: ganzes Gruppen physisch (nur section_order_index) oder innerhalb eines Parallelstreams."""
if parallel_stream_order_index is None:
return _resolve_training_unit_section_id(cur, unit_id, section_order_index)
if phase_order_index is None:
raise HTTPException(
status_code=400,
detail="phase_order_index ist bei parallel_stream_order_index Pflicht",
)
cur.execute(
"""
SELECT tus.id
FROM training_unit_sections tus
INNER JOIN training_unit_parallel_streams st ON st.id = tus.parallel_stream_id
INNER JOIN training_unit_phases p ON p.id = st.phase_id
WHERE tus.training_unit_id = %s
AND tus.order_index = %s
AND st.order_index = %s
AND p.order_index = %s
AND LOWER(TRIM(p.phase_kind)) = 'parallel'
ORDER BY tus.id ASC
LIMIT 1
""",
(
unit_id,
section_order_index,
parallel_stream_order_index,
phase_order_index,
),
)
r = cur.fetchone()
if not r:
raise HTTPException(
status_code=400,
detail="Abschnitt im Parallelstream für diese Indizes nicht gefunden",
)
return int(r["id"])
def _append_copied_module_items_to_section(
cur,
section_id: int,
@ -874,31 +1176,188 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
)
def _insert_one_replacement_section(
cur,
unit_id: int,
sec: Any,
enumeration_index: int,
*,
phase_id: Optional[int] = None,
parallel_stream_id: Optional[int] = None,
) -> None:
"""Eine Sektion inkl. Items (genau eines von phase_id / parallel_stream_id gesetzt)."""
if (phase_id is None) == (parallel_stream_id is None):
raise HTTPException(
status_code=500, detail="Intern: Sektion braucht phase_id oder parallel_stream_id"
)
title = (sec.get("title") or "").strip() or "Abschnitt"
order_ix = sec.get("order_index")
if order_ix is None:
order_ix = enumeration_index
src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
cur.execute(
"""
INSERT INTO training_unit_sections (
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
) VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
unit_id,
phase_id,
parallel_stream_id,
order_ix,
title,
sec.get("guidance_notes"),
src_tsec,
),
)
sid = cur.fetchone()["id"]
_insert_section_items(cur, sid, sec.get("items"))
def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]):
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
"""Ersetzt den gesamten Plan (Legacy): eine whole_group-Phase + Sektionen."""
_clear_unit_plan_content(cur, unit_id)
phase_id = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
for si, sec in enumerate(sections_in):
title = (sec.get("title") or "").strip() or "Abschnitt"
order_ix = sec.get("order_index")
if order_ix is None:
order_ix = si
src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
_insert_one_replacement_section(
cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None
)
def _replace_unit_phases(
cur,
unit_id: int,
phases_in: List[Any],
profile_id: int,
role: str,
unit_created_by: Optional[int],
) -> None:
"""Ersetzt Phasen inkl. paralleler Streams und Sektionen (voller Plan)."""
if not isinstance(phases_in, list):
raise HTTPException(status_code=400, detail="phases muss eine Liste sein")
cur.execute(
"""
SELECT tu.group_id,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS eff_lead
FROM training_units tu
LEFT JOIN training_groups tg ON tg.id = tu.group_id
WHERE tu.id = %s
""",
(unit_id,),
)
ur = cur.fetchone()
group_id_opt = int(ur["group_id"]) if ur and ur.get("group_id") is not None else None
eff_lead_raw = ur.get("eff_lead") if ur else None
eff_lead_nid = int(eff_lead_raw) if eff_lead_raw is not None else None
_clear_unit_plan_content(cur, unit_id)
for pi, ph in enumerate(phases_in):
kind = str(ph.get("phase_kind") or "").strip().lower()
if kind not in ("whole_group", "parallel"):
raise HTTPException(
status_code=400,
detail="phase_kind muss whole_group oder parallel sein",
)
# Reihenfolge strikt aus der Liste (pi): vermeidet UNIQUE(tu, order_index)-Kollisionen,
# wenn der Client dieselbe phase_order_index mehrfach trägt (z. B. nach Zuordnungswechseln).
p_oix = int(pi)
cur.execute(
"""
INSERT INTO training_unit_sections (
training_unit_id, order_index, title, guidance_notes, source_template_section_id
) VALUES (%s, %s, %s, %s, %s)
INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(
unit_id,
order_ix,
title,
sec.get("guidance_notes"),
src_tsec,
p_oix,
kind,
ph.get("title"),
ph.get("guidance_notes"),
),
)
sid = cur.fetchone()["id"]
_insert_section_items(cur, sid, sec.get("items"))
phase_id = int(cur.fetchone()["id"])
if kind == "whole_group":
secs = ph.get("sections")
if secs is None:
secs = []
if not isinstance(secs, list):
raise HTTPException(status_code=400, detail="sections muss Liste sein")
for si, sec in enumerate(secs):
_insert_one_replacement_section(
cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None
)
else:
streams = ph.get("streams")
if streams is None:
streams = []
if not isinstance(streams, list):
raise HTTPException(status_code=400, detail="streams muss Liste sein")
for si, st in enumerate(streams):
raw_asst = st.get("assigned_trainer_profile_ids")
asst_norm = _normalize_stream_assigned_trainer_profile_ids(
cur,
raw_asst,
group_id=group_id_opt,
profile_id=profile_id,
role=role,
unit_created_by=unit_created_by,
eff_lead_nid=eff_lead_nid,
)
asst_db = None if asst_norm is None else PsycopgJson(asst_norm)
st_oix = st.get("order_index")
if st_oix is None:
st_oix = si
cur.execute(
"""
INSERT INTO training_unit_parallel_streams (
phase_id, order_index, title, notes, assigned_trainer_profile_ids
) VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(
phase_id,
int(st_oix),
st.get("title"),
st.get("notes"),
asst_db,
),
)
sid = int(cur.fetchone()["id"])
secs = st.get("sections")
if secs is None:
secs = []
if not isinstance(secs, list):
raise HTTPException(
status_code=400,
detail="sections (Stream) muss Liste sein",
)
for ti, sec in enumerate(secs):
_insert_one_replacement_section(
cur, unit_id, sec, ti, phase_id=None, parallel_stream_id=sid
)
def _assert_single_plan_content_key_create(data: dict) -> None:
"""Höchstens ein Plan-Inhalt: phases | sections | exercises (Non-None)."""
n = sum(1 for k in ("phases", "sections", "exercises") if data.get(k) is not None)
if n > 1:
raise HTTPException(
status_code=400,
detail="Nur eines von phases, sections oder exercises angeben",
)
def _assert_single_plan_content_key_update(data: dict) -> None:
"""PUT: höchstens einer der Keys phases | sections | exercises."""
keys = [k for k in ("phases", "sections", "exercises") if k in data]
if len(keys) > 1:
raise HTTPException(
status_code=400,
detail="Nur eines von phases, sections oder exercises im Body gleichzeitig",
)
def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]:
@ -1027,13 +1486,16 @@ def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int,
def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
if not exercises_in:
return
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
cur.execute(
"""
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
VALUES (%s, 0, %s, NULL)
INSERT INTO training_unit_sections (
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes
)
VALUES (%s, %s, NULL, 0, %s, NULL)
RETURNING id
""",
(unit_id, "Übungen"),
(unit_id, pid, "Übungen"),
)
sid = cur.fetchone()["id"]
slot = 0
@ -1062,6 +1524,8 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List
def _instantiate_from_template(cur, unit_id: int, template_id: int):
_clear_unit_plan_content(cur, unit_id)
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
cur.execute(
"""
SELECT id, title, guidance_text
@ -1072,29 +1536,26 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
(template_id,),
)
rows = cur.fetchall()
for row in rows:
for gi, row in enumerate(rows):
r = r2d(row)
cur.execute(
"""
INSERT INTO training_unit_sections (
training_unit_id, order_index, title, guidance_notes, source_template_section_id
) VALUES (%s, (
SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2
WHERE u2.training_unit_id = %s
), %s, %s, %s)
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
) VALUES (%s, %s, NULL, %s, %s, %s, %s)
""",
(unit_id, unit_id, r["title"], r["guidance_text"], r["id"]),
(unit_id, pid, gi, r["title"], r["guidance_text"], r["id"]),
)
# Fallback: keine Sektionen in Vorlage → ein leerer Block
if not rows:
cur.execute(
"""
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
SELECT %s, 0, %s, NULL
WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s)
INSERT INTO training_unit_sections (
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes
) VALUES (%s, %s, NULL, 0, 'Hauptteil', NULL)
""",
(unit_id, "Hauptteil", unit_id),
(unit_id, pid),
)
@ -1758,7 +2219,11 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
def apply_training_module_to_training_unit(
unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
"""Kopiert die Positionen eines Trainingsmoduls ans Ende eines Abschnitts (lokal bearbeitbar)."""
"""Kopiert Modul-Positionen ans Ende eines Abschnitts.
Ziel: `section_order_index` in einer whole_group-Phase (Standard) oder
zusätzlich `phase_order_index` + `parallel_stream_order_index` für einen Stream.
"""
profile_id = tenant.profile_id
role = tenant.global_role
if not _has_planning_role(role):
@ -1780,12 +2245,44 @@ def apply_training_module_to_training_unit(
if section_order_index < 0:
raise HTTPException(status_code=400, detail="section_order_index ungültig")
ps_raw = data.get("parallel_stream_order_index")
parallel_stream_oi: Optional[int] = None
if ps_raw is not None and ps_raw != "":
try:
parallel_stream_oi = int(ps_raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="parallel_stream_order_index ungültig")
if parallel_stream_oi < 0:
raise HTTPException(status_code=400, detail="parallel_stream_order_index ungültig")
phase_oi: Optional[int] = None
ph_raw = data.get("phase_order_index")
if ph_raw is not None and ph_raw != "":
try:
phase_oi = int(ph_raw)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="phase_order_index ungültig")
if phase_oi < 0:
raise HTTPException(status_code=400, detail="phase_order_index ungültig")
if phase_oi is not None and parallel_stream_oi is None:
raise HTTPException(
status_code=400,
detail="phase_order_index nur zusammen mit parallel_stream_order_index",
)
with get_db() as conn:
cur = get_cursor(conn)
unit_row = _training_unit_guard_row(cur, unit_id)
_assert_training_unit_permission(cur, unit_row, profile_id, role)
section_id = _resolve_training_unit_section_id(cur, unit_id, section_order_index)
section_id = _resolve_training_unit_section_id_for_apply(
cur,
unit_id,
section_order_index,
phase_order_index=phase_oi,
parallel_stream_order_index=parallel_stream_oi,
)
mod_items, src_mid = load_training_module_for_apply(cur, module_id, profile_id, role)
_append_copied_module_items_to_section(cur, section_id, mod_items, src_mid)
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
@ -1863,6 +2360,7 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
lead_ins,
)
if assistant_set:
av_db = None if assistant_val is None else PsycopgJson(assistant_val)
cur.execute(
"""
INSERT INTO training_units (
@ -1874,7 +2372,7 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
base_params + (assistant_val,),
base_params + (av_db,),
)
else:
cur.execute(
@ -1892,10 +2390,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
unit_id = cur.fetchone()["id"]
_assert_single_plan_content_key_create(data)
phases_in = data.get("phases")
sections_in = data.get("sections")
exercises_in = data.get("exercises")
if sections_in is not None:
if phases_in is not None:
_replace_unit_phases(cur, unit_id, phases_in, profile_id, role, profile_id)
elif sections_in is not None:
_replace_unit_sections(cur, unit_id, sections_in)
elif tpl_id_safe:
_instantiate_from_template(cur, unit_id, tpl_id_safe)
@ -2013,7 +2515,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
eff_lead_for_co,
)
assist_sql = ", assistant_trainer_profile_ids = %s"
assist_params.append(na)
assist_params.append(None if na is None else PsycopgJson(na))
debrief_frag = ""
if "debrief_completed" in data and not is_blueprint:
@ -2071,20 +2573,29 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request",
)
_template_access(cur, tid, profile_id, role)
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
cur.execute(
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
)
_instantiate_from_template(cur, unit_id, tid)
content_handled = True
if not content_handled and "sections" in data:
_assert_single_plan_content_key_update(data)
if not content_handled and "phases" in data:
_replace_unit_phases(
cur,
unit_id,
data.get("phases") or [],
profile_id,
role,
unit_row.get("created_by"),
)
elif not content_handled and "sections" in data:
_replace_unit_sections(cur, unit_id, data["sections"] or [])
elif not content_handled and "exercises" in data:
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
_clear_unit_plan_content(cur, unit_id)
_insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])
if content_handled or "sections" in data or "exercises" in data:
if content_handled or any(k in data for k in ("phases", "sections", "exercises")):
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
conn.commit()
@ -2196,6 +2707,7 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext =
str(planned_date),
profile_id,
slot_id,
role,
)
_promote_private_exercises_used_in_unit(cur, new_id, profile_id, role)

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import os
from unittest.mock import patch
import pytest
from fastapi import Query
@ -11,6 +12,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from fastapi_param_unwrap import unwrap_query_default
from main import app
from tenant_context import TenantContext, get_tenant_context
@pytest.fixture
@ -27,3 +29,41 @@ def test_unwrap_query_default_for_direct_route_calls() -> None:
def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None:
r = client.get("/api/dashboard/kpis")
assert r.status_code == 401
def _fake_tenant_for_kpis() -> TenantContext:
return TenantContext(
profile_id=42,
global_role="trainer",
effective_club_id=7,
club_ids=frozenset({7}),
memberships=[],
)
@patch("routers.dashboard.list_training_units")
@patch("routers.dashboard.list_exercises_like_get")
def test_dashboard_kpis_200_when_inner_lists_mocked(
mock_list_ex: object,
mock_list_tu: object,
client: TestClient,
) -> None:
mock_list_ex.return_value = []
mock_list_tu.return_value = []
app.dependency_overrides[get_tenant_context] = _fake_tenant_for_kpis
try:
r = client.get("/api/dashboard/kpis")
assert r.status_code == 200, r.text
data = r.json()
assert "year" in data
assert data["draft_count"] == 0
assert data["mine_count"] == 0
assert data["ytd_completed_count"] == 0
th = data["training_home"]
assert th["upcoming"] == []
assert th["planned_with_notes"] == []
assert th["review_pending"] == []
assert mock_list_ex.call_count == 2
assert mock_list_tu.call_count == 3
finally:
app.dependency_overrides.clear()

View File

@ -0,0 +1,290 @@
"""
PostgreSQL-Integration: Roundtrip _replace_unit_sections / _replace_unit_phases Fetch-Helfer.
Aktivierung:
- Lokal: TRAINING_PLANNING_INTEGRATION=1
- CI: .gitea/workflows/test.yml setzt die Variable beim pytest-Lauf (deployter Backend-Container + PostgreSQL).
Voraussetzung: migrierte DB, DB_* wie Docker-Compose.
"""
from __future__ import annotations
import os
import uuid
import pytest
from db import get_db, get_cursor
from routers.training_planning import (
_fetch_phases_nested,
_fetch_sections,
_replace_unit_phases,
_replace_unit_sections,
)
def _integration_enabled() -> bool:
return os.getenv("TRAINING_PLANNING_INTEGRATION", "").strip().lower() in ("1", "true", "yes")
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _integration_enabled(),
reason="TRAINING_PLANNING_INTEGRATION=1 und PostgreSQL (DB_*) erforderlich",
),
]
def _db_ping() -> bool:
try:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT 1 AS ok")
row = cur.fetchone()
return row is not None and row.get("ok") == 1
except Exception:
return False
@pytest.fixture(scope="module")
def db_ready():
if not _db_ping():
pytest.skip("PostgreSQL nicht erreichbar (DB_HOST/DB_PORT/…)")
def test_replace_sections_roundtrip(db_ready):
"""INSERT-Hilfsdaten, replace, fetch — gleiche Semantik wie produktiver PUT-Pfad."""
suffix = uuid.uuid4().hex[:12]
club_name = f"tpl_it_club_{suffix}"
email = f"tpl_it_{suffix}@test.local"
from auth import hash_pin
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"INSERT INTO clubs (name, abbreviation, status) VALUES (%s, %s, %s) RETURNING id",
(club_name, "T", "active"),
)
club_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO profiles (email, pin_hash, name, role, active_club_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(email, hash_pin("x"), f"TP {suffix}", "trainer", club_id),
)
profile_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO training_groups (club_id, name, trainer_id, status)
VALUES (%s, %s, %s, %s)
RETURNING id
""",
(club_id, f"Gruppe {suffix}", profile_id, "active"),
)
group_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO exercises (title, goal, execution, visibility, status, created_by)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(f"Übung {suffix}", "Ziel", "Ablauf", "private", "draft", profile_id),
)
ex_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO training_units (
group_id, planned_date, status, created_by
) VALUES (%s, %s, %s, %s)
RETURNING id
""",
(group_id, "2026-06-01", "planned", profile_id),
)
unit_id = int(cur.fetchone()["id"])
sections_in = [
{
"title": "A1",
"order_index": 0,
"guidance_notes": "gn",
"items": [
{
"item_type": "note",
"order_index": 0,
"note_body": "Hinweis",
},
{
"item_type": "exercise",
"order_index": 1,
"exercise_id": ex_id,
"planned_duration_min": 5,
"notes": "n1",
},
],
},
{
"title": "B2",
"order_index": 1,
"items": [],
},
]
_replace_unit_sections(cur, unit_id, sections_in)
loaded = _fetch_sections(cur, unit_id)
conn.commit()
try:
assert len(loaded) == 2
assert loaded[0]["title"] == "A1"
assert loaded[0]["guidance_notes"] == "gn"
assert loaded[0]["order_index"] == 0
assert len(loaded[0]["items"]) == 2
assert loaded[0]["items"][0]["item_type"] == "note"
assert loaded[0]["items"][0]["note_body"] == "Hinweis"
assert loaded[0]["items"][1]["item_type"] == "exercise"
assert int(loaded[0]["items"][1]["exercise_id"]) == ex_id
assert loaded[0]["items"][1]["planned_duration_min"] == 5
assert loaded[1]["title"] == "B2"
assert loaded[1]["items"] == []
finally:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
cur.execute("DELETE FROM exercises WHERE id = %s", (ex_id,))
cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,))
cur.execute("DELETE FROM profiles WHERE id = %s", (profile_id,))
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
conn.commit()
def test_replace_phases_roundtrip_parallel_stream(db_ready):
"""Phasen inkl. parallel-Stream-Sektionen ersetzen und wieder laden."""
suffix = uuid.uuid4().hex[:12]
club_name = f"ph_it_club_{suffix}"
email = f"ph_it_{suffix}@test.local"
from auth import hash_pin
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"INSERT INTO clubs (name, abbreviation, status) VALUES (%s, %s, %s) RETURNING id",
(club_name, "P", "active"),
)
club_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO profiles (email, pin_hash, name, role, active_club_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
""",
(email, hash_pin("x"), f"PH {suffix}", "trainer", club_id),
)
profile_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO training_groups (club_id, name, trainer_id, status)
VALUES (%s, %s, %s, %s)
RETURNING id
""",
(club_id, f"Gruppe PH {suffix}", profile_id, "active"),
)
group_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO exercises (title, goal, execution, visibility, status, created_by)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(f"Übung PH {suffix}", "Ziel", "Ablauf", "private", "draft", profile_id),
)
ex_id = int(cur.fetchone()["id"])
cur.execute(
"""
INSERT INTO training_units (
group_id, planned_date, status, created_by
) VALUES (%s, %s, %s, %s)
RETURNING id
""",
(group_id, "2026-06-02", "planned", profile_id),
)
unit_id = int(cur.fetchone()["id"])
phases_in = [
{
"phase_kind": "whole_group",
"order_index": 0,
"title": "Aufwärmen",
"sections": [
{
"title": "Gemeinsam",
"order_index": 0,
"items": [
{"item_type": "note", "order_index": 0, "note_body": "Los"},
],
},
],
},
{
"phase_kind": "parallel",
"order_index": 1,
"title": "Breakout",
"streams": [
{
"order_index": 0,
"title": "Matte A",
"sections": [
{
"title": "Technik A",
"order_index": 0,
"items": [
{
"item_type": "exercise",
"order_index": 0,
"exercise_id": ex_id,
"planned_duration_min": 10,
},
],
},
],
},
],
},
]
_replace_unit_phases(cur, unit_id, phases_in, profile_id, "trainer", profile_id)
nested = _fetch_phases_nested(cur, unit_id)
flat_sec = _fetch_sections(cur, unit_id)
conn.commit()
try:
assert len(nested) == 2
assert nested[0]["phase_kind"] == "whole_group"
assert len(nested[0].get("sections") or []) == 1
assert nested[1]["phase_kind"] == "parallel"
streams = nested[1].get("streams") or []
assert len(streams) == 1
assert len(streams[0].get("sections") or []) == 1
assert streams[0]["sections"][0]["title"] == "Technik A"
assert len(streams[0]["sections"][0].get("items") or []) == 1
assert int(streams[0]["sections"][0]["items"][0]["exercise_id"]) == ex_id
assert len(flat_sec) == 2
finally:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
cur.execute("DELETE FROM exercises WHERE id = %s", (ex_id,))
cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,))
cur.execute("DELETE FROM profiles WHERE id = %s", (profile_id,))
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
conn.commit()

View File

@ -0,0 +1,32 @@
"""Unit-Tests ohne DB: abgeleitete Trainingseinheit-Payload-Helfer."""
import pytest
from routers.training_planning import _flatten_exercises_from_sections
def test_flatten_exercises_from_sections_order():
unit = {
"sections": [
{
"order_index": 1,
"items": [
{"order_index": 1, "item_type": "exercise", "exercise_id": 10},
{"order_index": 0, "item_type": "note"},
{"order_index": 2, "item_type": "exercise", "exercise_id": 20},
],
},
{
"order_index": 0,
"items": [{"order_index": 0, "item_type": "exercise", "exercise_id": 5}],
},
]
}
_flatten_exercises_from_sections(unit)
# Sektionen nach order_index; innerhalb nur exercise-Items nach order_index
assert [x["exercise_id"] for x in unit["exercises"]] == [5, 10, 20]
def test_flatten_exercises_from_sections_empty():
unit = {"sections": []}
_flatten_exercises_from_sections(unit)
assert unit["exercises"] == []

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.123"
APP_VERSION = "0.8.140"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062"
DB_SCHEMA_VERSION = "20260515063"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -24,7 +24,7 @@ MODULE_VERSIONS = {
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
"training_programs": "0.1.0",
"planning": "0.9.4", # list_training_units: Keyset-Pagination + stabile Sortierung (NULLS LAST + id)
"planning": "0.11.0", # PUT/POST training_units: phases (parallel streams); Rahmen→Termin-Kopie _replace_unit_phases; apply-training-module phase_order_index + parallel_stream_order_index
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
"training_modules": "1.0.0",
"import_wiki": "1.0.0",
@ -36,6 +36,122 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.140",
"date": "2026-05-14",
"changes": [
"Frontend Trainingsplanung: Breakout-Panel (neue Ganzgruppen-/parallele Phase, Stream in letzter parallelen Phase); pro Abschnitt Zuordnung zu Phase/Stream oder klassischer Ein-Ganzgruppen-Ablauf.",
],
},
{
"version": "0.8.139",
"date": "2026-05-14",
"changes": [
"Frontend Trainingsplanung: GET phases → Editor mit planLoc pro Abschnitt; Speichern sendet PUT phases bei Breakout-Einheiten (sonst weiter sections); Modul-Dialog zeigt Phase/Stream in der Abschnittsauswahl.",
],
},
{
"version": "0.8.138",
"date": "2026-05-14",
"changes": [
"Planung Paket 2: POST/PUT training_units mit phases (voller Phasen-/Stream-Plan); höchstens eines von phases, sections, exercises pro Request; Rahmen-Blueprint→Termin kopiert verschachtelten Plan; apply-training-module optional phase_order_index + parallel_stream_order_index.",
"Fix: POST from-framework-slot übergibt role an _copy_blueprint_into_scheduled_unit (Stream-Trainer-Validierung).",
"Integrationstest test_replace_phases_roundtrip_parallel_stream.",
],
},
{
"version": "0.8.137",
"date": "2026-05-14",
"changes": [
"DB 063: training_unit_phases, training_unit_parallel_streams; Sektionen mit phase_id oder parallel_stream_id; Default whole_group für Bestand.",
"Planung: GET training_unit liefert phases (verschachtelt) + sections (flach sortiert); Legacy-PUT sections weiterhin eine whole_group-Phase.",
],
},
{
"version": "0.8.136",
"date": "2026-05-13",
"changes": [
"Fix: api/exercises.js — Mandanten-Header für Raw-fetch (PUT Übung, Medien-Upload, Bulk-Archiv) über lokale withActiveClubHeaders statt mergeActiveClubHeader-Import (ReferenceError beim Speichern).",
],
},
{
"version": "0.8.135",
"date": "2026-05-13",
"changes": [
"Frontend Phase 4 Welle 3: frontend/src/api/exercises.js (Übungen, Medien/Archiv, Progressionsgraphen, KI); client.js exportiert API_URL und mergeActiveClubHeader; utils/api.js re-exportiert Modul, api-Objekt spread exercises.",
],
},
{
"version": "0.8.134",
"date": "2026-05-14",
"changes": [
"Frontend Phase 4 Welle 2: frontend/src/api/planning.js (Trainingsplanung); utils/api.js re-exportiert Modul, api-Objekt spread planning.",
],
},
{
"version": "0.8.133",
"date": "2026-05-14",
"changes": [
"Frontend Phase 4 Welle 1: frontend/src/api/client.js (request, ACTIVE_CLUB_STORAGE_KEY); utils/api.js importiert Client, bleibt Facade. Roadmap Phase 4 gestartet.",
],
},
{
"version": "0.8.132",
"date": "2026-05-14",
"changes": [
"Frontend Phase 3 abgeschlossen: TrainingPlanningPageRoot, ExerciseFormPageRoot, ExercisesListPageRoot unter components/; pages/ nur Re-Export (Soft-Limit). Roadmap UMSETZUNGSPLAN Phase 3 / M3 aktualisiert.",
],
},
{
"version": "0.8.131",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3: TrainingPlanningUnitFormModal (Neu/Bearbeiten-Einheit); frameworkLineageText in trainingPlanningPageHelpers; BASELINE_SNAPSHOT §3.4 k6-Log-Mapping.",
],
},
{
"version": "0.8.130",
"date": "2026-05-13",
"changes": [
"Fix: PUT/POST training_units — assistant_trainer_profile_ids als JSONB mit psycopg2.extras.Json schreiben (rohe Python-Liste → ProgrammingError/500 bei Co-Zuweisung).",
],
},
{
"version": "0.8.129",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3: TrainingPlanningTrainerAssignModal (Trainer zuweisen) aus Trainingsplanungsseite; Handler per useCallback.",
],
},
{
"version": "0.8.128",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3: TrainingPlanningModuleApplyModal (Trainingsmodul einfügen) aus Trainingsplanungsseite; gemeinsamer Callback onModuleApplySectionIndexChange.",
],
},
{
"version": "0.8.126",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3: TrainingPlanningFrameworkImportModal aus Trainingsplanungsseite; Playwright-Test 13 (Rahmen-Dialog, skip ohne Gruppe).",
],
},
{
"version": "0.8.125",
"date": "2026-05-13",
"changes": [
"Tests: Playwright 11 (Übungsliste Bulk-Toolbar), 12 (Trainingsplanung); Dashboard-Test 8 prüft HTTP 200 auf /api/dashboard/kpis; pytest test_dashboard_kpis_200_when_inner_lists_mocked.",
"Frontend Phase 3: trainingPlanningPageHelpers.js aus TrainingPlanningPage; ExerciseListBulkToolbar data-testid.",
],
},
{
"version": "0.8.124",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3 (Teil): ExerciseListBulkToolbar-Komponente; Übungsliste nur Verdrahtung.",
],
},
{
"version": "0.8.123",
"date": "2026-05-13",

View File

@ -90,6 +90,22 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
|----------|-------------------|------------------|
| 10 VUs, 30 s `/health` | *—* | *nach Messung* |
### 3.4 Aus dem Deployment-/CI-Log übernehmen (k6 `k6-health-baseline`)
Das Skript `scripts/load/k6-health-baseline.js` nutzt **10 VUs**, **30 s**, Ziel **`GET {BASE_URL}/health`** (siehe Workflow-Env für `BASE_URL`).
**In die Tabelle oben (Abschnitt 3.3) eintragen — aus der k6-Zusammenfassung am Ende des Jobs:**
| Feld in BASELINE_SNAPSHOT | Wo im k6-Log (typisch) |
|---------------------------|-------------------------|
| **p95** (Latenz ms) | Zeile **`http_req_duration`** → Wert **`p(95)=…`** (ganze Zahl oder ms mit Einheit wie `12.34ms`) |
| **Fehlerquote** | Zeile **`http_req_failed`** → z.B. `0.00%` bzw. `✓ 0%` — oder kurz „0 %“ notieren |
| **Checks** (optional) | Zeile **`checks`** → Anteil **`✓`** (soll **100 %** sein, sonst Hinweis) |
| **Datum / BASE_URL** | Deploy-Datum + die **öffentliche** Basis-URL des Laufs (wie im Workflow gesetzt, z.B. `https://dev.shinkan.jinkendo.de`) |
| **App-Version** (optional) | dieselbe wie im Deploy (`backend/version.py` / Release), damit M2-Vergleich ressortfähig bleibt |
**Zusätzlich (Abschnitt 2.2):** nur die Zeile **`/health` GET`** mit dem **gleichen** p95 befüllen, wenn ihr dort noch Platzhalter habt — echte API-Routen (`/api/...`) kommen weiter aus Monitoring/k6 mit Auth, nicht aus diesem Job.
---
## 4. Nächster Schritt (Roadmap)

View File

@ -9,6 +9,9 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
| [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md) | Zielarchitektur (Frontend, API, Daten), Qualitätsziele, Einbindung neuer Features |
| [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md) | Erfasste Architekturschuld, Reihenfolge und Massnahmen zur Behebung |
| [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md) | Phasen, Meilensteine, Abnahmekriterien, Aufwandsschwerpunkte |
| [`frontend/src/api/client.js`](../../frontend/src/api/client.js) | Phase 4: zentraler HTTP-Client (`request`, `ACTIVE_CLUB_STORAGE_KEY`, `API_URL`, `mergeActiveClubHeader`) |
| [`frontend/src/api/exercises.js`](../../frontend/src/api/exercises.js) | Phase 4: Übungen, Medien/Archiv, Progressionsgraphen, KI-Hilfen |
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
| [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) |

View File

@ -7,7 +7,8 @@
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 910**. Weiter: God-Pages (Planung/Formular).
- **Phase 3 (abgeschlossen 2026-05-14):** Übungsliste modularisiert; Trainingsplanung/Übungsformular: **Page-Dateien unter Soft-Limit** — Implementierung in `TrainingPlanningPageRoot.jsx`, `ExerciseFormPageRoot.jsx`, `ExercisesListPageRoot.jsx`; `pages/*.jsx` nur Re-Export. Playwright **Tests 910**.
- **Phase 4 (fortlaufend 2026-05-14):** API **Welle 1** `client.js`; **Welle 2** `planning.js`; **Welle 3** `exercises.js`; `utils/api.js` bleibt Facade (`export *`, `api`-Objekt `...exercises`, `...planning`).
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
@ -82,7 +83,9 @@
| Virtualisierung für die längste produktive Liste | A1, S2 |
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 910**. Offen: Seite unter Soft-Limit (~500 Zeilen, derzeit ~918 LOC), Zerteilung Planung/Übungsformular.
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 910**.
**Abgeschlossen (2026-05-14):** Routen bleiben unter `frontend/src/pages/`; schwere Implementierung in **`components/planning/TrainingPlanningPageRoot.jsx`**, **`components/exercises/ExerciseFormPageRoot.jsx`**, **`components/exercises/ExercisesListPageRoot.jsx`** — **`pages/*` nur Re-Export** (Soft-Limit ~500 Zeilen laut `VERBINDLICHE_REGELN_SHINKAN.md`).
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
@ -90,13 +93,17 @@
## Phase 4 API-Client Modularisierung
**Status:** **fortlaufend** (2026-05-14) — Welle 1: **`client.js`**; Welle 2: **`planning.js`**; Welle 3: **`exercises.js`**; **`utils/api.js`** bleibt vollständige Facade.
**Fokus:** Wartbarkeit für viele neue Features.
| Task | Bezug |
|------|--------|
| `frontend/src/api/` anlegen, `request`/`client` zentral | A2 |
| Facade: bestehende Importe von `utils/api` nicht sofort alle brechen; Migration in Wellen | A2 |
| Neue Endpoints nur noch in Domänen-Dateien | S3 |
| Task | Bezug | Status |
|------|-------|--------|
| `frontend/src/api/client.js` — zentraler HTTP-Client | A2 | erledigt (Welle 1) |
| `frontend/src/api/planning.js` — Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, Dashboard-KPIs) | A2 | erledigt (Welle 2) |
| `frontend/src/api/exercises.js` — Übungen, Medien/Archiv, Varianten, Progressionsgraphen, KI | A2 | erledigt (Welle 3) |
| Weitere Domänen-Module unter `frontend/src/api/` + Entlastung von `utils/api.js` | A2 | offen |
| Neue Endpoints primär in Domänen-Dateien | S3 | offen |
**Abnahme:** Anteil neuer Module > X% der neuen Zeilen (Team-Ziel); Monolith wächst nicht weiter.
@ -121,7 +128,7 @@
|-------------|--------|
| **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert |
| **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen |
| **M3** | Phase 3 Referenz-Page + Virtualisierung live |
| **M3** | Phase 3 abgeschlossen: Page-Dateien Soft-Limit (Re-Export); Virtualisierung Übungsliste |
| **M4** | Phase 4 migrationsbereit für alle neuen Features |
| **M5** | Phase 5 für Top-Listen abgeschlossen |

View File

@ -0,0 +1,79 @@
/**
* HTTP-Client: Token, Mandanten-Header, Fehler-Mapping.
* Alle API-Aufrufe laufen über request() siehe utils/api.js (Facade) und Domänenmodule (planning.js, exercises.js).
*/
export const API_URL = import.meta.env.VITE_API_URL || ''
/** LocalStorage + Request-Header für Mandanten-Kontext */
export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id'
export function mergeActiveClubHeader(headers = {}) {
const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
if (cid && /^\d+$/.test(String(cid).trim())) {
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
}
return { ...headers }
}
/**
* Generischer API-Aufruf inkl. X-Auth-Token und X-Active-Club-Id.
*/
export async function request(endpoint, options = {}) {
const token = localStorage.getItem('authToken')
const method = (options.method || 'GET').toUpperCase()
const headers = mergeActiveClubHeader({
...options.headers,
})
if (method !== 'GET' && method !== 'HEAD') {
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json'
}
}
if (token) {
headers['X-Auth-Token'] = token
}
const url = `${API_URL}${endpoint}`
try {
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
if (response.status === 502) {
throw new Error(
'HTTP 502 (Bad Gateway): Der Reverse-Proxy hat die API nicht korrekt erreicht. Ist `shinkan-api` aktiv (`docker compose ps`, `docker logs shinkan-api`)? Bei Host-Routing nur einen Weg verwenden — alles auf Port 3003 (Nginx nach `backend:8000`) oder sauber `/api` → Backend-Port.'
)
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
} catch (e) {
if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) {
const hint =
API_URL && API_URL.length > 0
? `Verbindung zum API unter ${API_URL} fehlgeschlagen. Läuft das Backend (z. B. Port 8098) und ist CORS erlaubt?`
: 'Kein VITE_API_URL gesetzt: Anfragen gehen an die Frontend-URL und schlagen oft fehl. Setze in .env z. B. VITE_API_URL=http://localhost:8098 und starte Vite neu.'
throw new Error(`${hint} [Technisch: ${e.message}; URL war ${endpoint}]`)
}
throw e
}
}

View File

@ -0,0 +1,593 @@
/**
* Übungen: Liste/CRUD, Medien & Archiv-Anbindung, Progressionsgraphen, KI-Hilfen.
*/
import { stripHtmlToText } from '../utils/htmlUtils'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js'
/** Wie `mergeActiveClubHeader` in client.js — lokal, damit Raw-`fetch`-Pfade nicht von einem Namensimport abhängen. */
function withActiveClubHeaders(headers = {}) {
const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
if (cid && /^\d+$/.test(String(cid).trim())) {
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
}
return { ...headers }
}
// ============================================================================
// Exercises
// ============================================================================
export async function listExercises(filters = {}) {
const q = new URLSearchParams()
Object.entries(filters).forEach(([k, v]) => {
if (v === undefined || v === null) return
if (typeof v === 'boolean') {
q.set(k, v ? 'true' : 'false')
return
}
if (Array.isArray(v)) {
if (v.length === 0) return
v.forEach((item) => {
if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') {
q.append(k, String(item))
}
})
return
}
if (String(v).trim() !== '') q.set(k, String(v))
})
const query = q.toString()
return request(`/api/exercises${query ? '?' + query : ''}`)
}
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
export function buildExerciseApiPayload(formData, extras = {}) {
const num = (v) => (v === '' || v == null ? null : Number(v))
const goalHtml = formData.goal || ''
const execHtml = formData.execution || ''
const goalText = stripHtmlToText(goalHtml)
const execText = stripHtmlToText(execHtml)
if (!goalText && !execText) {
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).')
}
const mapFocus = (formData.focus_areas_multi || [])
.filter((x) => x && x.focus_area_id)
.map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary }))
const mapStyles = (formData.training_styles_multi || [])
.filter((x) => x && x.training_style_id)
.map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary }))
const mapTTypes = (formData.training_types_multi || [])
.filter((x) => x && x.training_type_id)
.map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary }))
const mapTg = (formData.target_groups_multi || [])
.filter((x) => x && x.target_group_id)
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase()
const payload = {
title: (formData.title || '').trim(),
summary: formData.summary || null,
goal: goalHtml.trim() ? goalHtml : null,
execution: execHtml.trim() ? execHtml : null,
preparation: formData.preparation || null,
trainer_notes: formData.trainer_notes || null,
duration_min: num(formData.duration_min),
duration_max: num(formData.duration_max),
group_size_min: num(formData.group_size_min),
group_size_max: num(formData.group_size_max),
equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
focus_areas_multi: mapFocus,
training_styles_multi: mapStyles,
training_types_multi: mapTTypes,
target_groups_multi: mapTg,
age_groups: [],
skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id,
is_primary: !!s.is_primary,
intensity: s.intensity || null,
required_level: s.required_level || null,
target_level: s.target_level || null,
})),
visibility: visibilityNorm,
status: formData.status || 'draft',
club_id: visibilityNorm === 'club' ? num(formData.club_id) : null,
exercise_kind:
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'
: 'simple',
...extras,
}
const isCombo = payload.exercise_kind === 'combination'
if (isCombo) {
let mpObj = {}
const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
if (mpRaw) {
try {
const parsed = JSON.parse(mpRaw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
}
mpObj = parsed
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
}
throw e
}
}
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
const combination_slots = []
function parseTimingField(raw) {
if (raw === '' || raw == null || raw === undefined) return undefined
const n = parseInt(String(raw), 10)
return Number.isFinite(n) ? n : undefined
}
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
let ids = Array.isArray(row.candidate_exercise_ids)
? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
/** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
ids = row.idsText
.split(/[\s,;]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n))
}
combination_slots.push({
slot_index: i,
title: (typeof row.title === 'string' && row.title.trim()) || null,
candidate_exercise_ids: ids,
})
}
const slot_profiles_v1_next = []
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
const o = { slot_index: i }
const advanceMode = normalizeAdvanceMode(row.advance_mode)
if (advanceMode !== 'timed') o.advance_mode = advanceMode
const load = parseTimingField(row.load_sec)
const crs = parseTimingField(row.consecutive_reps)
const rsc = parseTimingField(row.rep_series_count)
const intra = parseTimingField(row.intra_rep_rest_sec)
const tran = parseTimingField(row.transition_after_sec)
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
const allowInterSeriesPause =
advanceMode === 'timed' ||
((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2)
if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
if (
rsc !== undefined &&
rsc >= 1 &&
(advanceMode === 'rep' || advanceMode === 'manual')
) {
o.rep_series_count = Math.round(rsc)
}
if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra)
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
}
payload.method_archetype = (formData.method_archetype || '').trim() || null
if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
else delete mpObj.slot_profiles_v1
payload.method_profile = mpObj
payload.combination_slots = combination_slots
} else {
payload.method_archetype = null
payload.method_profile = {}
}
return payload
}
export async function uploadExerciseMedia(exerciseId, formData) {
const token = localStorage.getItem('authToken')
const headers = withActiveClubHeaders({})
if (token) headers['X-Auth-Token'] = token
const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 409 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(
typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden',
)
e.code = d.code
e.status = 409
e.payload = d
throw e
}
if (response.status === 413) {
const nginx = (text || '').toLowerCase().includes('nginx')
throw new Error(
nginx
? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).'
: 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.',
)
}
const msg =
typeof d === 'string'
? d
: d != null && typeof d === 'object' && typeof d.message === 'string'
? d.message
: d != null
? JSON.stringify(d)
: text && text.length < 400 && !/^\s*</.test(text)
? `HTTP ${response.status}: ${text.trim()}`
: `HTTP ${response.status}`
throw new Error(msg)
}
return response.json()
}
export async function updateExerciseMedia(exerciseId, mediaId, data) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseMedia(exerciseId, mediaId) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
}
export async function reorderExerciseMedia(exerciseId, mediaIds) {
return request(`/api/exercises/${exerciseId}/media/reorder`, {
method: 'PUT',
body: JSON.stringify({ media_ids: mediaIds }),
})
}
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
return request(`/api/media-assets/${assetId}/lifecycle`, {
method: 'POST',
body: JSON.stringify({ action, ...extra }),
})
}
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
export async function listMediaAssets(params = {}) {
const sp = new URLSearchParams()
if (params.q) sp.set('q', params.q)
if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
}
export async function patchMediaAsset(assetId, data) {
return request(`/api/media-assets/${assetId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function bulkMediaLifecycle(data) {
return request('/api/media-assets/bulk-lifecycle', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function bulkPatchMediaAssets(data) {
return request('/api/media-assets/bulk-patch', {
method: 'POST',
body: JSON.stringify(data),
})
}
/**
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
* @param {File[]} files
* @param {{ visibility?: string, club_id?: number }} [options]
*/
export async function bulkUploadMediaAssets(files, options = {}) {
const visibility = options.visibility || 'private'
const token = localStorage.getItem('authToken')
const headers = withActiveClubHeaders({})
if (token) headers['X-Auth-Token'] = token
const formData = new FormData()
formData.append('visibility', String(visibility))
if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id))
}
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
if (options.copyright_notice != null && String(options.copyright_notice).trim())
formData.append('copyright_notice', String(options.copyright_notice).trim())
const p06Fields = [
'rights_holder_confirmed',
'contains_identifiable_persons',
'person_consent_confirmed',
'person_consent_context',
'contains_minors',
'parental_consent_confirmed',
'parental_consent_context',
'contains_music',
'music_rights_confirmed',
'music_rights_context',
'contains_third_party_content',
'third_party_rights_confirmed',
'third_party_rights_context',
]
for (const f of p06Fields) {
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
}
const arr = Array.isArray(files) ? files : [files]
for (const f of arr) {
if (f) formData.append('files', f)
}
const url = `${API_URL}/api/media-assets/bulk-upload`
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function getMediaAssetJournal(assetId) {
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
}
export async function addMediaAssetDeclarationCorrection(assetId, body) {
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
body: JSON.stringify(body),
})
}
// P-11: Legal-Hold-Endpunkte
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
method: 'POST',
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
})
}
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
method: 'POST',
body: JSON.stringify({ release_note: releaseNote }),
})
}
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
}
export async function getExercise(id) {
return request(`/api/exercises/${id}`)
}
export async function createExercise(data) {
return request('/api/exercises', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExercise(id, data) {
const token = localStorage.getItem('authToken')
const headers = withActiveClubHeaders({ 'Content-Type': 'application/json' })
if (token) headers['X-Auth-Token'] = token
const url = `${API_URL}/api/exercises/${id}`
const response = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(data),
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 422 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
e.status = 422
e.code = d.code
e.payload = d
throw e
}
if (parsed?.detail != null) {
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
throw new Error(msg)
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
export async function createExerciseVariant(exerciseId, data) {
return request(`/api/exercises/${exerciseId}/variants`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseVariant(exerciseId, variantId, data) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseVariant(exerciseId, variantId) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
}
export async function reorderExerciseVariants(exerciseId, variantIds) {
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
method: 'PUT',
body: JSON.stringify({ variant_ids: variantIds }),
})
}
// Progressionsgraphen (Übung → Übung), Migration 032/033
export async function listExerciseProgressionGraphs() {
return request('/api/exercise-progression-graphs')
}
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
const q = includeEdges ? '?include_edges=true' : ''
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionGraph(id, data) {
return request(`/api/exercise-progression-graphs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionGraph(id) {
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
}
export async function listExerciseProgressionEdges(graphId, query = {}) {
const q = new URLSearchParams()
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
const qs = q.toString()
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
}
export async function createExerciseProgressionEdge(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
}
export async function createExerciseProgressionSequence(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
method: 'POST',
body: JSON.stringify({ edge_ids: edgeIds }),
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function regenerateExerciseAi(exerciseId, payload) {
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
method: 'POST',
body: JSON.stringify(payload),
})
}

View File

@ -0,0 +1,171 @@
/**
* Trainingsplanung: Einheiten, Vorlagen, Module, Rahmenprogramme, Dashboard-KPIs.
* Facade: weiterhin `utils/api.js` (default + Named Exports).
*/
import { request } from './client.js'
/** Query-Parameter wie GET /api/training-units. */
export async function listTrainingUnits(filters = {}) {
const q = new URLSearchParams()
if (filters.group_id != null && filters.group_id !== '') {
q.set('group_id', String(filters.group_id))
}
if (filters.club_id != null && filters.club_id !== '') {
q.set('club_id', String(filters.club_id))
}
if (filters.start_date) q.set('start_date', filters.start_date)
if (filters.end_date) q.set('end_date', filters.end_date)
if (filters.status) q.set('status', filters.status)
if (filters.debrief_pending === true) q.set('debrief_pending', 'true')
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
if (filters.cursor_planned_date) q.set('cursor_planned_date', String(filters.cursor_planned_date))
if (filters.cursor_planned_time != null && filters.cursor_planned_time !== '') {
q.set('cursor_planned_time', String(filters.cursor_planned_time))
}
if (filters.cursor_id != null && filters.cursor_id !== '') q.set('cursor_id', String(filters.cursor_id))
const qs = q.toString()
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
}
/** Dashboard Kurzüberblick: Entwürfe / meine Übungen / YTD abgeschlossene Einheiten (ein Roundtrip). */
export async function getDashboardKpis() {
return request('/api/dashboard/kpis')
}
/** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
const q = new URLSearchParams()
if (filters.start_date) q.set('start_date', String(filters.start_date))
if (filters.end_date) q.set('end_date', String(filters.end_date))
if (filters.assigned_to_me === false) q.set('assigned_to_me', 'false')
if (filters.limit_units != null && filters.limit_units !== '') {
q.set('limit_units', String(filters.limit_units))
}
const qs = q.toString()
return request(`/api/training-units/exercises-club-visibility-queue${qs ? `?${qs}` : ''}`)
}
export async function getTrainingUnit(id) {
return request(`/api/training-units/${id}`)
}
export async function createTrainingUnit(data) {
return request('/api/training-units', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingUnit(id, data) {
return request(`/api/training-units/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingUnit(id) {
return request(`/api/training-units/${id}`, { method: 'DELETE' })
}
export async function quickCreateTrainingUnit(data) {
return request('/api/training-units/quick-create', {
method: 'POST',
body: JSON.stringify(data),
})
}
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
export async function createTrainingUnitFromFrameworkSlot(data) {
return request('/api/training-units/from-framework-slot', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function listTrainingPlanTemplates() {
return request('/api/training-plan-templates')
}
export async function getTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`)
}
export async function createTrainingPlanTemplate(data) {
return request('/api/training-plan-templates', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingPlanTemplate(id, data) {
return request(`/api/training-plan-templates/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
}
export async function listTrainingModules() {
return request('/api/training-modules')
}
export async function getTrainingModule(id) {
return request(`/api/training-modules/${id}`)
}
export async function createTrainingModule(data) {
return request('/api/training-modules', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingModule(id, data) {
return request(`/api/training-modules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingModule(id) {
return request(`/api/training-modules/${id}`, { method: 'DELETE' })
}
/** Kopiert Modul-Inhalte ans Ende eines Abschnitts (section_order_index 0-basiert). */
export async function applyTrainingModuleToTrainingUnit(unitId, data) {
return request(`/api/training-units/${unitId}/apply-training-module`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function listTrainingFrameworkPrograms() {
return request('/api/training-framework-programs')
}
export async function getTrainingFrameworkProgram(id) {
return request(`/api/training-framework-programs/${id}`)
}
export async function createTrainingFrameworkProgram(data) {
return request('/api/training-framework-programs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingFrameworkProgram(id, data) {
return request(`/api/training-framework-programs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingFrameworkProgram(id) {
return request(`/api/training-framework-programs/${id}`, { method: 'DELETE' })
}

View File

@ -5935,6 +5935,78 @@ a.analysis-split__nav-item {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
}
/* Planungseditor: Einfügebänder — Split vs. Ganzgruppe (inaktiv sichtbar, aktiv weiterhin Akzent-Ring) */
.tu-section-dropband--region-split {
height: 12px;
background: hsl(200 38% 92%);
border: 1px dashed hsl(200 42% 58%);
}
.tu-section-dropband--region-whole {
height: 12px;
background: color-mix(in srgb, var(--accent) 10%, var(--surface2));
border: 1px dashed color-mix(in srgb, var(--accent) 32%, transparent);
}
.tu-section-dropband--region-split-to-whole {
height: 12px;
background: linear-gradient(
90deg,
hsl(200 38% 92%) 0%,
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 100%
);
border: 1px dashed hsl(200 36% 50%);
}
.tu-section-dropband--region-whole-to-split {
height: 12px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 0%,
hsl(200 38% 92%) 100%
);
border: 1px dashed hsl(200 36% 50%);
}
.tu-section-dropband--region-neutral {
height: 11px;
border: 1px dashed color-mix(in srgb, var(--border) 70%, transparent);
background: color-mix(in srgb, var(--surface2) 55%, transparent);
}
/* Stream-Tag / Planende: klar getrennte Drop-Ziele bei parallelen Phasen */
.tu-stream-chip-pill--drop-active {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent);
}
.tu-section-dropband--whole-plan-end {
background: color-mix(in srgb, var(--accent) 14%, var(--surface2));
border: 1px dashed color-mix(in srgb, var(--accent) 35%, transparent);
height: 14px;
}
.tu-section-stream-append__drop {
margin: 0;
width: 100%;
}
/* Parallele Phase: feste Einfügebänder oberhalb/unterhalb des Split-Headers */
.tu-phase-drop--above-split,
.tu-phase-drop--below-split {
margin: 6px 2px 8px;
min-height: 14px;
}
.tu-section-dropband--phase-parallel-slot {
height: 14px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 0%,
hsl(200 30% 94%) 100%
);
border: 1px dashed color-mix(in srgb, var(--accent) 38%, transparent);
}
.tu-sec-drag-grip {
flex-shrink: 0;
display: inline-flex;
@ -6586,6 +6658,21 @@ button.combo-coach-cand-link:hover {
max-width: none !important;
padding: 0 !important;
}
/* Split: jede weitere Breakout-Spalte auf neuer Seite (Gesamtplan-Druck) */
.training-run-breakout-stream--page-break {
break-before: page;
page-break-before: always;
}
.training-run-parallel-columns {
display: block !important;
}
.training-run-breakout-stream {
margin-bottom: 14px;
}
.training-run-phase-schedule {
break-inside: avoid;
page-break-inside: avoid;
}
.training-run-section,
.training-run-header {
break-inside: avoid;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
import React from 'react'
export default function ExerciseListBulkToolbar({
selectedCount,
bulkMaxIds,
onClearSelection,
onOpenBulkModal,
}) {
if (selectedCount < 1) return null
return (
<div className="card exercise-bulk-toolbar" data-testid="exercise-list-bulk-toolbar">
<strong>{selectedCount} ausgewählt</strong>
<button type="button" className="btn btn-secondary btn-small" onClick={onClearSelection}>
Auswahl aufheben
</button>
<button type="button" className="btn btn-primary btn-small" onClick={onOpenBulkModal}>
Massenänderung
</button>
<span className="exercise-bulk-toolbar__meta">
Bis zu {bulkMaxIds} pro Anfrage. Für Verein ohne Auswahl: aktiver Vereinskontext (
<code>X-Active-Club-Id</code>
).
</span>
</div>
)
}

View File

@ -0,0 +1,590 @@
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
import { Link } from 'react-router-dom'
import api from '../../utils/api'
import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
import PageSectionNav from '../PageSectionNav'
import ExerciseListCard from './ExerciseListCard'
import ExerciseListFilterModal from './ExerciseListFilterModal'
import ExerciseListBulkModal from './ExerciseListBulkModal'
import ExerciseListSearchBar from './ExerciseListSearchBar'
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
compactExerciseListPrefsPayload,
} from '../../constants/exerciseListFilters'
const ExerciseProgressionGraphPanel = lazy(() => import('../ExerciseProgressionGraphPanel'))
const BULK_MAX_IDS = 500
const EXERCISES_PAGE_TABS = [
{ id: 'list', label: 'Liste' },
{ id: 'progression', label: 'Progressionsgraphen' },
]
function ExercisesListPageRoot() {
const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [mineOnly, setMineOnly] = useState(() => {
try {
const sp = new URLSearchParams(window.location.search)
return sp.get('mine') === '1' || sp.get('created_by_me') === '1'
} catch {
return false
}
})
const [searchInput, setSearchInput] = useState('')
const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
const [filters, setFilters] = useState(() => ({ ...INITIAL_EXERCISE_LIST_FILTERS }))
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [savingExercisePrefs, setSavingExercisePrefs] = useState(false)
const [pageTab, setPageTab] = useState('list')
const prefsAppliedRef = useRef(false)
const [selectedIds, setSelectedIds] = useState(() => new Set())
const [bulkModalOpen, setBulkModalOpen] = useState(false)
const [bulkVisibility, setBulkVisibility] = useState('')
const [bulkStatus, setBulkStatus] = useState('')
const [bulkClubSelect, setBulkClubSelect] = useState('')
const [bulkClubManual, setBulkClubManual] = useState('')
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false)
const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([])
const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false)
const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([])
const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false)
const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
useEffect(() => {
if (!user?.id) return
if (prefsAppliedRef.current) return
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
setFilters(applyDashboardExerciseListUrl(merged))
try {
const sp = new URLSearchParams(window.location.search)
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
} catch {
/* ignore */
}
prefsAppliedRef.current = true
}, [user?.id, user?.exercise_list_prefs])
useEffect(() => {
if (!user?.id) prefsAppliedRef.current = false
}, [user?.id])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t)
}, [searchInput])
useEffect(() => {
const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
return () => clearTimeout(t)
}, [aiSearchInput])
useEffect(() => {
if (!filterModalOpen) return
const onKey = (e) => {
if (e.key === 'Escape') setFilterModalOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [filterModalOpen])
const queryBase = useMemo(
() => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly),
[filters, debouncedSearch, debouncedAiSearch, mineOnly]
)
const {
catalogs,
catalogsReady,
exercises,
setExercises,
listFetching,
loadingMore,
hasMore,
loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
useEffect(() => {
setSelectedIds(new Set())
}, [queryBase])
const focusOptions = useMemo(
() =>
catalogs.focusAreas.map((fa) => ({
id: fa.id,
label: `${fa.icon || ''} ${fa.name || ''}`.trim(),
})),
[catalogs.focusAreas]
)
const styleOptions = useMemo(
() => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.styleDirections]
)
const trainingTypeOptions = useMemo(
() => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
[catalogs.trainingTypes]
)
const targetGroupOptions = useMemo(
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
[catalogs.targetGroups]
)
const skillOptions = useMemo(
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.skills]
)
const visibilityOptions = useMemo(
() => [
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
{ id: 'official', label: 'Offiziell' },
],
[]
)
const statusOptions = useMemo(
() => [
{ id: 'draft', label: 'Entwurf' },
{ id: 'in_review', label: 'In Prüfung' },
{ id: 'approved', label: 'Freigegeben' },
{ id: 'archived', label: 'Archiviert' },
],
[]
)
const filterChips = useMemo(
() =>
buildExerciseListFilterChips({
mineOnly,
setMineOnly,
filters,
setFilters,
focusOptions,
styleOptions,
trainingTypeOptions,
targetGroupOptions,
skillOptions,
visibilityOptions,
statusOptions,
}),
[
mineOnly,
filters,
focusOptions,
styleOptions,
trainingTypeOptions,
targetGroupOptions,
skillOptions,
visibilityOptions,
statusOptions,
]
)
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
const searchTitleSuggestions = useMemo(() => {
const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
return [...new Set(titles)].slice(0, 80)
}, [exercises])
const clubNameById = useMemo(() => {
const m = {}
for (const c of activeClubMemberships(user?.clubs)) {
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
}
return m
}, [user?.clubs])
const effectiveClubId =
user?.effective_club_id != null && user.effective_club_id !== ''
? Number(user.effective_club_id)
: user?.active_club_id != null && user.active_club_id !== ''
? Number(user.active_club_id)
: null
const toggleSelect = useCallback((id) => {
setSelectedIds((prev) => {
const n = new Set(prev)
const nid = Number(id)
if (Number.isNaN(nid)) return prev
if (n.has(nid)) n.delete(nid)
else n.add(nid)
return n
})
}, [])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const toggleSelectAllPage = useCallback(() => {
setSelectedIds((prev) => {
const n = new Set(prev)
const allSel =
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
if (allSel) {
exercises.forEach((e) => n.delete(Number(e.id)))
} else {
exercises.forEach((e) => n.add(Number(e.id)))
}
return n
})
}, [exercises])
const allOnPageSelected = useMemo(
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
[exercises, selectedIds]
)
const bulkVisibilityOptions = useMemo(() => {
const base = [
{ id: '', label: '— nicht ändern —' },
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
]
if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
return base
}, [isSuperadmin])
const handleDelete = async (exercise) => {
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
try {
await api.deleteExercise(exercise.id)
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
}
}
const resetAllFilters = useCallback(() => {
setMineOnly(false)
setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS })
}, [])
const handleSaveExerciseFilterPrefs = useCallback(async () => {
const uid = user?.id
if (!uid) {
alert('Nicht angemeldet.')
return
}
setSavingExercisePrefs(true)
try {
const payload = compactExerciseListPrefsPayload(filters)
await api.updateProfile(uid, { exercise_list_prefs: payload })
await checkAuth()
alert('Standardfilter für die Übungsliste gespeichert.')
} catch (e) {
alert('Speichern fehlgeschlagen: ' + (e.message || String(e)))
} finally {
setSavingExercisePrefs(false)
}
}, [user?.id, filters, checkAuth])
const openBulkModal = () => {
setBulkVisibility('')
setBulkStatus('')
setBulkClubSelect('')
setBulkClubManual('')
setBulkPatchFocusAreas(false)
setBulkFocusAreaIds([])
setBulkPatchStyleDirections(false)
setBulkStyleDirectionIds([])
setBulkPatchTrainingTypes(false)
setBulkTrainingTypeIds([])
setBulkPatchTargetGroups(false)
setBulkTargetGroupIds([])
setBulkModalOpen(true)
}
const handleBulkSubmit = async () => {
const anyRelationPatch =
bulkPatchFocusAreas ||
bulkPatchStyleDirections ||
bulkPatchTrainingTypes ||
bulkPatchTargetGroups
if (!bulkVisibility && !bulkStatus && !anyRelationPatch) {
alert(
'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).'
)
return
}
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
if (ids.length === 0) {
alert('Keine Übungen ausgewählt.')
return
}
if (ids.length > BULK_MAX_IDS) {
alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
return
}
const payload = { exercise_ids: ids }
if (bulkVisibility) payload.visibility = bulkVisibility
if (bulkStatus) payload.status = bulkStatus
if (bulkPatchFocusAreas) {
payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchStyleDirections) {
payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchTrainingTypes) {
payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchTargetGroups) {
payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkVisibility === 'club') {
const manual = String(bulkClubManual || '').trim()
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
payload.club_id = Number(bulkClubSelect)
}
}
setBulkSubmitting(true)
try {
const res = await api.bulkPatchExercisesMetadata(payload)
const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
let resolvedClubId = null
if (bulkVisibility === 'club') {
if (payload.club_id != null) resolvedClubId = payload.club_id
else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
}
const clubLabel =
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
let nextPrimaryFocusName = null
if (bulkPatchFocusAreas) {
const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
if (faNums.length > 0) {
const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0]))
nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0])
}
}
setExercises((prev) =>
prev.map((e) => {
if (!updatedSet.has(Number(e.id))) return e
const next = { ...e }
if (bulkVisibility) {
next.visibility = bulkVisibility
next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
next.club_name = bulkVisibility === 'club' ? clubLabel : null
}
if (bulkStatus) next.status = bulkStatus
if (bulkPatchFocusAreas) {
if (nextPrimaryFocusName == null) delete next.focus_area
else next.focus_area = nextPrimaryFocusName
}
return next
})
)
let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
if (Array.isArray(res.failed) && res.failed.length) {
msg +=
'\n\n' +
res.failed
.slice(0, 12)
.map((f) => `#${f.id}: ${f.detail}`)
.join('\n')
if (res.failed.length > 12) msg += '\n…'
}
alert(msg)
setBulkModalOpen(false)
clearSelection()
} catch (err) {
alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
} finally {
setBulkSubmitting(false)
}
}
if (!catalogsReady && pageTab === 'list') {
return (
<div className="app-page">
<div className="empty-state" style={{ padding: '2.5rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Kataloge
</p>
</div>
</div>
)
}
return (
<div className="app-page">
<div className="exercises-page__header">
<h1 className="page-title exercises-page__title">Übungen</h1>
{pageTab === 'list' ? (
<Link to="/exercises/new" className="btn btn-primary">
+ Neu
</Link>
) : (
<span aria-hidden="true" />
)}
</div>
<PageSectionNav
ariaLabel="Übungen Bereiche"
value={pageTab}
onChange={setPageTab}
items={EXERCISES_PAGE_TABS}
className="exercises-page-toolbar-tabs"
/>
{pageTab === 'progression' ? (
<Suspense
fallback={
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Progressionsgraphen
</p>
</div>
}
>
<ExerciseProgressionGraphPanel />
</Suspense>
) : (
<>
<ExerciseListSearchBar
searchTitleSuggestions={searchTitleSuggestions}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
aiSearchInput={aiSearchInput}
onAiSearchInputChange={setAiSearchInput}
mineOnly={mineOnly}
onToggleMineOnly={() => setMineOnly((v) => !v)}
onOpenFilter={() => setFilterModalOpen(true)}
filterChips={filterChips}
onResetAllFilters={resetAllFilters}
exerciseCount={exercises.length}
allOnPageSelected={allOnPageSelected}
onToggleSelectAllPage={toggleSelectAllPage}
/>
<ExerciseListBulkToolbar
selectedCount={selectedIds.size}
bulkMaxIds={BULK_MAX_IDS}
onClearSelection={clearSelection}
onOpenBulkModal={openBulkModal}
/>
<ExerciseListFilterModal
open={filterModalOpen}
onClose={() => setFilterModalOpen(false)}
filters={filters}
setFilters={setFilters}
focusOptions={focusOptions}
styleOptions={styleOptions}
trainingTypeOptions={trainingTypeOptions}
targetGroupOptions={targetGroupOptions}
skillOptions={skillOptions}
visibilityOptions={visibilityOptions}
statusOptions={statusOptions}
savingExercisePrefs={savingExercisePrefs}
onSaveStandard={handleSaveExerciseFilterPrefs}
onResetAll={resetAllFilters}
/>
<ExerciseListBulkModal
open={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
onSubmit={handleBulkSubmit}
bulkSubmitting={bulkSubmitting}
selectedCount={selectedIds.size}
bulkMaxIds={BULK_MAX_IDS}
user={user}
isPlatformAdmin={isPlatformAdmin}
statusOptions={statusOptions}
bulkVisibilityOptions={bulkVisibilityOptions}
focusOptions={focusOptions}
styleOptions={styleOptions}
trainingTypeOptions={trainingTypeOptions}
targetGroupOptions={targetGroupOptions}
bulkVisibility={bulkVisibility}
setBulkVisibility={setBulkVisibility}
bulkStatus={bulkStatus}
setBulkStatus={setBulkStatus}
bulkClubSelect={bulkClubSelect}
setBulkClubSelect={setBulkClubSelect}
bulkClubManual={bulkClubManual}
setBulkClubManual={setBulkClubManual}
bulkPatchFocusAreas={bulkPatchFocusAreas}
setBulkPatchFocusAreas={setBulkPatchFocusAreas}
bulkFocusAreaIds={bulkFocusAreaIds}
setBulkFocusAreaIds={setBulkFocusAreaIds}
bulkPatchStyleDirections={bulkPatchStyleDirections}
setBulkPatchStyleDirections={setBulkPatchStyleDirections}
bulkStyleDirectionIds={bulkStyleDirectionIds}
setBulkStyleDirectionIds={setBulkStyleDirectionIds}
bulkPatchTrainingTypes={bulkPatchTrainingTypes}
setBulkPatchTrainingTypes={setBulkPatchTrainingTypes}
bulkTrainingTypeIds={bulkTrainingTypeIds}
setBulkTrainingTypeIds={setBulkTrainingTypeIds}
bulkPatchTargetGroups={bulkPatchTargetGroups}
setBulkPatchTargetGroups={setBulkPatchTargetGroups}
bulkTargetGroupIds={bulkTargetGroupIds}
setBulkTargetGroupIds={setBulkTargetGroupIds}
/>
{listFetching && exercises.length === 0 ? (
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Übungen
</p>
</div>
) : exercises.length === 0 ? (
<div className="card">
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
</div>
) : (
<>
{listFetching ? (
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p>
) : null}
<p className="exercises-meta-line">
{exercises.length} angezeigt
{hasMore ? ' · es gibt weitere Einträge' : ''}
</p>
<div className="exercises-list-grid" data-testid="exercises-list-grid">
{exercises.map((exercise) => (
<ExerciseListCard
key={exercise.id}
exercise={exercise}
user={user}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
onDelete={handleDelete}
/>
))}
</div>
{hasMore && (
<div className="exercises-load-more">
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</>
)}
</div>
)
}
export default ExercisesListPageRoot

View File

@ -0,0 +1,210 @@
import React from 'react'
/**
* Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen.
*/
export default function TrainingPlanningFrameworkImportModal({
open,
frameworkProgramsList,
fwImportProgramId,
onProgramChange,
fwImportLoading,
fwImportDetail,
fwImportSelectedSlots,
onToggleSlot,
fwImportSlotDates,
onSlotDateChange,
fwImportStartDate,
onFwImportStartDateChange,
fwImportIntervalDays,
onFwImportIntervalDaysChange,
fwImportSubmitting,
onApplyDateSuggestions,
onSubmit,
onClose,
}) {
if (!open) return null
return (
<div
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
style={{
background: 'var(--surface)',
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{' '}
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt.
</p>
<div className="form-row">
<label className="form-label">Rahmenprogramm</label>
<select
className="form-input"
value={fwImportProgramId}
onChange={(e) => onProgramChange(e.target.value)}
disabled={fwImportLoading || fwImportSubmitting}
>
<option value="">Bitte wählen</option>
{frameworkProgramsList.map((fp) => (
<option key={fp.id} value={String(fp.id)}>
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
</option>
))}
</select>
</div>
{fwImportLoading ? (
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>Laden der Sessions</p>
) : fwImportDetail?.slots?.length ? (
<>
<fieldset style={{ border: 'none', margin: '1rem 0', padding: 0 }}>
<legend className="form-label" style={{ padding: 0, marginBottom: '0.5rem' }}>
Sessions (mit Ablauf)
</legend>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{[...fwImportDetail.slots]
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.map((slot) => {
const hasBp = !!slot.blueprint_training_unit_id
const checked = fwImportSelectedSlots.has(slot.id)
const label =
(slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}`
return (
<li key={slot.id} style={{ marginBottom: '10px' }}>
<label
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
cursor: hasBp ? 'pointer' : 'not-allowed',
opacity: hasBp ? 1 : 0.55,
}}
>
<input
type="checkbox"
checked={checked}
disabled={!hasBp || fwImportSubmitting}
onChange={() => onToggleSlot(slot)}
style={{ marginTop: '0.2rem', flexShrink: 0 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<strong>{label}</strong>
{!hasBp ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
Ohne Session-Ablauf Übernahme nicht möglich.
</span>
) : null}
{hasBp && checked ? (
<span style={{ display: 'block', marginTop: '6px' }}>
<span className="form-label" style={{ fontSize: '0.78rem' }}>
Termin (Datum)
</span>
<input
type="date"
className="form-input"
style={{ maxWidth: '200px', marginTop: '4px' }}
value={fwImportSlotDates[String(slot.id)] || ''}
onChange={(e) => onSlotDateChange(String(slot.id), e.target.value)}
disabled={fwImportSubmitting}
/>
</span>
) : null}
</span>
</label>
</li>
)
})}
</ul>
</fieldset>
<div
className="responsive-grid-3"
style={{
marginBottom: '0.75rem',
padding: '12px',
background: 'var(--surface2)',
borderRadius: '8px',
}}
>
<div className="form-row">
<label className="form-label">Startdatum (Vorschlag)</label>
<input
type="date"
className="form-input"
value={fwImportStartDate}
onChange={(e) => onFwImportStartDateChange(e.target.value)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row">
<label className="form-label">Abstand (Tage)</label>
<input
type="number"
min={0}
className="form-input"
value={fwImportIntervalDays}
onChange={(e) => onFwImportIntervalDaysChange(parseInt(e.target.value, 10) || 0)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row" style={{ alignSelf: 'end' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={fwImportSubmitting}
onClick={onApplyDateSuggestions}
>
Datumsvorschläge setzen
</button>
</div>
</div>
</>
) : fwImportProgramId ? (
<p style={{ color: 'var(--text2)', marginTop: '0.75rem' }}>Keine Sessions in diesem Programm.</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}>
<button
type="button"
className="btn btn-primary"
disabled={fwImportSubmitting || !fwImportDetail}
onClick={onSubmit}
>
{fwImportSubmitting ? 'Übernehmen…' : 'In Planung übernehmen'}
</button>
<button type="button" className="btn btn-secondary" disabled={fwImportSubmitting} onClick={onClose}>
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,331 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { trainingVisibilityShortDE } from '../../utils/trainingPlanningPageHelpers'
/**
* Dialog: Trainingsmodul in die Abschnitte einer Einheit einfügen (Bibliothekskopie).
*/
function formatModuleApplySectionOptionLabel(section, flatIndex) {
const title = (section?.title || '').trim() || `Abschnitt ${flatIndex + 1}`
const pl = section?.planLoc
if (pl?.phaseKind === 'parallel') {
const st = (pl.streamTitle || '').trim()
const streamHint = st ? `${st}` : ''
return `${title}${streamHint} (Phase ${pl.phaseOrderIndex ?? 0}, Stream ${pl.parallelStreamOrderIndex ?? 0})`
}
return title
}
export default function TrainingPlanningModuleApplyModal({
open,
busy,
err,
placementLocked,
placementSummary,
sections,
sectionIx,
onSectionIndexChange,
insertSlot,
onInsertSlotChange,
targetItems,
searchQuery,
onSearchQueryChange,
filteredList,
fullList,
selectedModuleId,
onSelectModuleId,
modulePickPreview,
onConfirm,
onCancel,
}) {
if (!open) return null
const handleBackdropMouseDown = (ev) => {
if (ev.target !== ev.currentTarget || busy) return
onCancel()
}
return (
<div
data-testid="planning-module-apply-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',
}}
role="presentation"
onMouseDown={handleBackdropMouseDown}
>
<div
className="card"
style={{
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(560px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
role="dialog"
aria-labelledby="module-apply-title"
>
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
Trainingsmodul einfügen
</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.87rem', marginBottom: '0.85rem', lineHeight: 1.5 }}>
Alle Positionen des gewählten Moduls werden <strong>als neue Zeilen</strong> eingefügt (Kopie, mit klarer
Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben Speichern am Ende
wie gewohnt. <strong>Vollständige Textsuche oder Modulkategorien</strong> planen wir serverseitig für
eine spätere Iteration; vorerst steht hier eine{' '}
<strong>Schnellsuche über Titel und Freitext-Felder</strong> zur Verfügung.
</p>
{err ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{err}</p>
) : null}
{placementLocked ? (
<>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', lineHeight: 1.5, color: 'var(--text2)' }}>
Aktuelle Einfügeposition: Abschnitt <strong>{placementSummary.secTitle}</strong>{' '}
<span aria-hidden>/</span> {placementSummary.positionDescription}
</p>
<details className="tu-module-apply-placement-details">
<summary style={{ outline: 'none' }}>Abschnitt oder Position ändern</summary>
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(sectionIx)}
onChange={(e) => onSectionIndexChange(parseInt(e.target.value, 10))}
disabled={busy || !sections?.length}
>
{(sections || []).map((s, i) => (
<option key={`sec-opt-u-${i}`} value={String(i)}>
{formatModuleApplySectionOptionLabel(s, i)}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={insertSlot}
onChange={(e) => onInsertSlotChange(e.target.value)}
disabled={busy || !(sections?.length > 0)}
>
<option value={`before:${targetItems.length}`}>
Am Ende einfügen (nach allen Einträgen)
</option>
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
{targetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-u-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
</div>
</details>
</>
) : (
<>
<div className="form-row">
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(sectionIx)}
onChange={(e) => onSectionIndexChange(parseInt(e.target.value, 10))}
disabled={busy || !sections?.length}
>
{(sections || []).map((s, i) => (
<option key={`sec-opt-${i}`} value={String(i)}>
{formatModuleApplySectionOptionLabel(s, i)}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={insertSlot}
onChange={(e) => onInsertSlotChange(e.target.value)}
disabled={busy || !(sections?.length > 0)}
>
<option value={`before:${targetItems.length}`}>
Am Ende einfügen (nach allen Einträgen)
</option>
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
{targetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
</>
)}
<div className="form-row" style={{ marginTop: placementLocked ? '1rem' : undefined }}>
<label className="form-label">Suche Module</label>
<input
type="search"
enterKeyHint="search"
className="form-input tu-modulepick-search"
placeholder="Freitext: Titel, Kurzbeschreibung, Ziel, Zielgruppe …"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
disabled={busy}
aria-label="Module durch Freitext filtern"
/>
</div>
<div className="form-row" style={{ marginBottom: '0.65rem' }}>
<label className="form-label" id="module-pick-label">
Modulliste
</label>
</div>
<div
className="tu-modulepick-list"
role="listbox"
aria-labelledby="module-pick-label"
aria-activedescendant={selectedModuleId ? `module-pick-opt-${selectedModuleId}` : undefined}
>
{!filteredList.length ? (
<p style={{ margin: '0.45rem', fontSize: '0.86rem', color: 'var(--text3)', lineHeight: 1.45 }}>
{!fullList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'}
</p>
) : (
filteredList.map((m) => {
const title = ((m.title || '').trim() || `Modul #${m.id}`).trim()
const visLbl = trainingVisibilityShortDE(m.visibility)
const nPos = typeof m.items_count === 'number' ? m.items_count : '—'
const selected = String(m.id) === String(selectedModuleId)
return (
<button
key={m.id}
id={`module-pick-opt-${m.id}`}
type="button"
role="option"
aria-selected={selected}
className={`tu-modulepick-item${selected ? ' tu-modulepick-item--active' : ''}`}
disabled={busy}
onClick={() => onSelectModuleId(String(m.id))}
>
<span className="tu-modulepick-item__title">{title}</span>
<span className="tu-modulepick-item__meta">
{nPos} {typeof nPos === 'number' ? (nPos === 1 ? 'Position' : 'Positionen') : 'Position(en)'}
{visLbl ? <> · {visLbl}</> : null}
{m.summary ? <> · {(m.summary || '').trim().slice(0, 72)}{(m.summary || '').trim().length > 72 ? '…' : ''}</> : null}
</span>
</button>
)
})
)}
</div>
{selectedModuleId ? (
<div className="tu-modulepick-preview" aria-live="polite">
<div className="tu-modulepick-preview__title">Ablauf-Vorschau (Bibliotheksmodul)</div>
{modulePickPreview.loading ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
Übungen und Hinweise laden
</p>
) : modulePickPreview.err ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--danger)' }}>
{modulePickPreview.err}
</p>
) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben).
</p>
) : (
<>
<ol className="tu-modulepick-preview__list">
{(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => (
<li key={`pv-ex-${qi}`}>{t}</li>
))}
</ol>
{modulePickPreview.exercises.length > 12 ? (
<p className="tu-modulepick-preview__more">
und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge.
</p>
) : null}
{modulePickPreview.notes > 0 ? (
<p className="tu-modulepick-preview__more">
zusätzlich {modulePickPreview.notes}{' '}
{modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '}
(ohne Aufzählung)
</p>
) : null}
</>
)}
</div>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => {
if (busy) return
onCancel()
}}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={onConfirm}>
{busy ? 'Einfügen …' : 'Einfügen'}
</button>
</div>
<p style={{ margin: '1rem 0 0', fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Neue Module kannst du unter{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
anlegen.
</p>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,155 @@
import React from 'react'
/**
* Modal: organisatorische Trainer-Zuweisung (Leitung + Co) für eine bestehende Einheit.
*/
export default function TrainingPlanningTrainerAssignModal({
open,
unit,
leadTrainerProfileId,
onLeadChange,
sessionAssistantsInherit,
onSessionAssistantsInheritChange,
sessionAssistantProfileIds,
onCoTrainerToggle,
clubDirectory,
coTrainerOptions,
saving,
onBackdropRequestClose,
onCancel,
onSave,
}) {
if (!open || !unit) return null
return (
<div
data-testid="planning-trainer-assign-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: 1020,
padding: '1rem',
overflowY: 'auto',
}}
role="presentation"
onClick={() => {
if (!saving) onBackdropRequestClose()
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="trainer-assign-modal-title"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(460px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
>
<h2 id="trainer-assign-modal-title" style={{ marginBottom: '0.5rem', fontSize: '1.1rem' }}>
Trainer zuweisen (organisatorisch)
</h2>
<p style={{ fontSize: '0.86rem', color: 'var(--text2)', marginBottom: '1rem', lineHeight: 1.45 }}>
{(unit.planned_date || '').toString().slice(0, 10)}
{unit.planned_time_start ? ` · ${String(unit.planned_time_start).slice(0, 5)}` : ''}
{(unit.group_name || '').trim() ? ` · ${(unit.group_name || '').trim()}` : null}
</p>
<div className="form-row">
<label className="form-label">Leitung (diese Einheit)</label>
<select
className="form-input"
value={leadTrainerProfileId}
onChange={(e) => onLeadChange(e.target.value)}
disabled={saving}
>
<option value="">Standard (Haupttrainer der Gruppe)</option>
{(clubDirectory || []).map((m) => {
const idStr = String(m.id)
return (
<option key={idStr} value={idStr}>
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
</option>
)
})}
</select>
</div>
<div className="form-row" style={{ marginTop: '0.85rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={sessionAssistantsInherit}
disabled={saving}
onChange={(e) => onSessionAssistantsInheritChange(e.target.checked)}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>Co-Trainer wie in der Trainingsgruppe</span>
</label>
</div>
{!sessionAssistantsInherit ? (
<div style={{ marginTop: '10px', maxHeight: '180px', overflowY: 'auto' }}>
{coTrainerOptions.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
const isOn = Number.isFinite(mid) && sessionAssistantProfileIds.includes(mid)
return (
<label
key={`assign-co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: saving ? 'default' : 'pointer',
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={isOn}
disabled={saving}
onChange={() => onCoTrainerToggle(mid)}
/>
<span>{labelText}</span>
</label>
)
})}
</div>
) : null}
{!clubDirectory.length ? (
<p style={{ marginTop: '10px', fontSize: '0.82rem', color: 'var(--text3)' }}>
Mitgliederverzeichnis konnte nicht geladen werden.
</p>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onCancel}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={saving} onClick={onSave}>
{saving ? 'Speichern …' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,486 @@
import React from 'react'
import { Link } from 'react-router-dom'
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
/**
* Großes Modal: Neue Trainingseinheit / Einheit bearbeiten (Planung, Trainer, Abschnitte, Durchführung, Notizen).
*/
export default function TrainingPlanningUnitFormModal({
open,
editingUnit,
formData,
updateFormField,
setFormData,
onSubmit,
onCancel,
draftPlanTemplateId,
onDraftTemplateSelect,
planTemplates,
clubDirectory,
clubDirectoryForCo,
planningModalClubId,
user,
onMetaRefresh,
sectionsEditMode,
setSectionsEditMode,
onSaveAsTemplate,
onRequestTrainingModulePick,
onRequestExercisePick,
onPeekExercise,
}) {
if (!open) return null
return (
<div
data-testid="planning-unit-form-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: 1000,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(12px, 3vw, 2rem)',
maxWidth: 'min(1100px, 100%)',
width: '100%',
maxHeight: '92vh',
overflowY: 'auto',
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '1rem' }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h2>
{editingUnit?.origin_framework_slot_id
? (() => {
const L = frameworkLineageText(editingUnit)
return (
<div
className="card"
style={{
marginBottom: '1.1rem',
padding: '12px 14px',
background: 'var(--surface2)',
fontSize: '0.9rem',
lineHeight: 1.5,
}}
>
<strong style={{ color: 'var(--text1)' }}>Herkunft:</strong>{' '}
{editingUnit.origin_framework_program_id ? (
<Link
to={`/planning/framework-programs/${editingUnit.origin_framework_program_id}`}
style={{ color: 'var(--accent-dark)' }}
>
{L.fpTitle}
</Link>
) : (
L.fpTitle
)}
<span style={{ color: 'var(--text2)' }}> · {L.slotBit}</span>
<p style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
</p>
</div>
)
})()
: null}
{!editingUnit && (
<div className="training-planning-template-panel" style={{ marginBottom: '1.35rem' }}>
<label className="form-label training-planning-template-panel__label" htmlFor="planning-draft-template">
Vorlage für den Ablauf
</label>
<select
id="planning-draft-template"
className="form-input training-planning-template-panel__select"
value={draftPlanTemplateId}
onChange={(e) => onDraftTemplateSelect(e.target.value)}
>
<option value="">Ohne Vorlage leere Gliederung (ein Abschnitt)</option>
{planTemplates.map((t) => (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
</option>
))}
</select>
<p className="training-planning-template-panel__help">
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
</p>
</div>
)}
<form onSubmit={onSubmit}>
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Datum *</label>
<input
type="date"
className="form-input"
value={formData.planned_date}
onChange={(e) => updateFormField('planned_date', e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.planned_time_start}
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.planned_time_end}
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Trainingsfokus</label>
<input
type="text"
className="form-input"
value={formData.planned_focus}
onChange={(e) => updateFormField('planned_focus', e.target.value)}
placeholder="z.B. Grundlagen, Kinder altersgerecht"
/>
</div>
<div
className="card"
style={{
marginTop: '1.25rem',
marginBottom: '0.25rem',
padding: '12px 14px',
background: 'var(--surface2)',
}}
>
<h3 style={{ margin: '0 0 10px', fontSize: '1rem' }}>Trainerzuordnung (diese Einheit)</h3>
<div className="form-row">
<label className="form-label">Leitung</label>
<select
className="form-input"
value={formData.lead_trainer_profile_id}
onChange={(e) => updateFormField('lead_trainer_profile_id', e.target.value)}
disabled={!editingUnit && !formData.group_id}
>
<option value="">Standard (Haupttrainer der Gruppe)</option>
{clubDirectory.map((m) => {
const idStr = String(m.id)
return (
<option key={idStr} value={idStr}>
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
</option>
)
})}
</select>
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem', lineHeight: 1.45 }}>
Für Vertretungen genügt in der Regel die Vereinsmitgliedschaft; Zuweisen dürfen u.a. Haupt-/CoTrainer
dieser Gruppe, der/die Ersteller:in der Einheit oder Vereinsadmins.
</p>
</div>
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.session_assistants_inherit}
onChange={(e) => updateFormField('session_assistants_inherit', e.target.checked)}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
Co-Trainer wie in der Trainingsgruppe (Standard)
</span>
</label>
</div>
{!formData.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
{clubDirectoryForCo.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
return (
<label
key={`co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: 'pointer',
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={isOn}
onChange={() => {
setFormData((prev) => {
const was = prev.session_assistant_profile_ids.includes(mid)
const nextIds = was
? prev.session_assistant_profile_ids.filter((x) => x !== mid)
: [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b)
return { ...prev, session_assistant_profile_ids: nextIds }
})
}}
/>
<span>{labelText}</span>
</label>
)
})}
</div>
) : null}
{!clubDirectory.length ? (
<p style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.4 }}>
Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).
</p>
) : null}
</div>
<TrainingPlanExerciseVisibilityPanel
sections={formData.sections}
targetClubId={planningModalClubId}
user={user}
onMetaRefresh={onMetaRefresh}
/>
<div style={{ marginTop: '2rem' }}>
{editingUnit ? (
<div style={{ marginBottom: '1rem' }}>
<div
role="radiogroup"
aria-label="Modus für Abschnitte und Übungen"
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '10px',
}}
>
<span className="form-label" style={{ marginBottom: 0, fontSize: '0.82rem' }}>
Ablauf bearbeiten als
</span>
<div
style={{
display: 'inline-flex',
borderRadius: '10px',
border: '1.5px solid var(--border2)',
overflow: 'hidden',
background: 'var(--surface2)',
}}
>
{[
{ id: 'planning', label: 'Planung' },
{ id: 'debrief', label: 'Nachbereitung' },
].map((opt, i) => (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={sectionsEditMode === opt.id}
onClick={() => setSectionsEditMode(opt.id)}
style={{
border: 'none',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: 'pointer',
background: sectionsEditMode === opt.id ? 'var(--accent-dark)' : 'transparent',
color: sectionsEditMode === opt.id ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
...(i > 0 ? { borderLeft: '1.5px solid var(--border2)' } : {}),
}}
>
{opt.label}
</button>
))}
</div>
</div>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
{sectionsEditMode === 'debrief'
? 'IstMinuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.'
: 'Ablauf, Übungen und geplante Minuten. IstWerte und Abweichungen unter „Nachbereitung“.'}
</p>
</div>
) : null}
<TrainingUnitSectionsEditor
heading="Abschnitte & Übungen"
headingAccessory={
<>
<button type="button" className="btn btn-secondary" onClick={onSaveAsTemplate}>
Vorlage aus Aufbau speichern
</button>
</>
}
sections={formData.sections}
wideExerciseGrid
onSectionsChange={(updater) =>
setFormData((prev) => ({
...prev,
sections: updater(prev.sections),
}))
}
onRequestTrainingModulePick={onRequestTrainingModulePick}
onRequestExercisePick={onRequestExercisePick}
onPeekExercise={onPeekExercise}
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
enableParallelPhaseControls
/>
</div>
<div style={{ marginBottom: '1.75rem' }} />
{editingUnit && (
<>
<h3 style={{ marginTop: '0.5rem', marginBottom: '1rem' }}>Durchführung</h3>
<div className="responsive-grid-4" style={{ marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Tatsächliches Datum</label>
<input
type="date"
className="form-input"
value={formData.actual_date}
onChange={(e) => updateFormField('actual_date', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.actual_time_start}
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.actual_time_end}
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Teilnehmer</label>
<input
type="number"
className="form-input"
value={formData.attendance_count}
onChange={(e) => updateFormField('attendance_count', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="planned">Geplant</option>
<option value="completed">Durchgeführt</option>
<option value="cancelled">Abgesagt</option>
</select>
</div>
{formData.status === 'completed' ? (
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
cursor: 'pointer',
lineHeight: 1.45,
}}
>
<input
type="checkbox"
checked={!!formData.debrief_completed}
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
style={{ marginTop: '3px' }}
/>
<span>
<strong>Rückschau erledigt</strong>
<span className="muted" style={{ display: 'block', fontSize: '0.82rem', marginTop: '5px' }}>
Wenn angehakt, erscheint die Einheit nicht mehr unter Offene Rückschau auf dem Dashboard
(Nachbereitung gilt als abgeschlossen).
</span>
</span>
</label>
</div>
) : null}
</>
)}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
<div className="form-row">
<label className="form-label">Öffentliche Notizen</label>
<textarea
className="form-input"
rows={3}
value={formData.notes}
onChange={(e) => updateFormField('notes', e.target.value)}
placeholder="Für Teilnehmer"
/>
</div>
<div className="form-row">
<label className="form-label">Trainernotizen</label>
<textarea
className="form-input"
rows={3}
value={formData.trainer_notes}
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
{editingUnit ? 'Speichern' : 'Erstellen'}
</button>
<button type="button" className="btn btn-secondary" onClick={onCancel}>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,599 +1,2 @@
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import PageSectionNav from '../components/PageSectionNav'
import ExerciseListCard from '../components/exercises/ExerciseListCard'
import ExerciseListFilterModal from '../components/exercises/ExerciseListFilterModal'
import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal'
import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar'
import { buildExerciseListFilterChips } from '../utils/exerciseListFilterChips'
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../utils/exerciseListQuery'
import { useExerciseListCatalogsAndQuery } from '../hooks/useExerciseListCatalogsAndQuery'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
compactExerciseListPrefsPayload,
} from '../constants/exerciseListFilters'
const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel'))
const BULK_MAX_IDS = 500
const EXERCISES_PAGE_TABS = [
{ id: 'list', label: 'Liste' },
{ id: 'progression', label: 'Progressionsgraphen' },
]
function ExercisesListPage() {
const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [mineOnly, setMineOnly] = useState(() => {
try {
const sp = new URLSearchParams(window.location.search)
return sp.get('mine') === '1' || sp.get('created_by_me') === '1'
} catch {
return false
}
})
const [searchInput, setSearchInput] = useState('')
const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
const [filters, setFilters] = useState(() => ({ ...INITIAL_EXERCISE_LIST_FILTERS }))
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [savingExercisePrefs, setSavingExercisePrefs] = useState(false)
const [pageTab, setPageTab] = useState('list')
const prefsAppliedRef = useRef(false)
const [selectedIds, setSelectedIds] = useState(() => new Set())
const [bulkModalOpen, setBulkModalOpen] = useState(false)
const [bulkVisibility, setBulkVisibility] = useState('')
const [bulkStatus, setBulkStatus] = useState('')
const [bulkClubSelect, setBulkClubSelect] = useState('')
const [bulkClubManual, setBulkClubManual] = useState('')
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false)
const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([])
const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false)
const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([])
const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false)
const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
useEffect(() => {
if (!user?.id) return
if (prefsAppliedRef.current) return
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
setFilters(applyDashboardExerciseListUrl(merged))
try {
const sp = new URLSearchParams(window.location.search)
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
} catch {
/* ignore */
}
prefsAppliedRef.current = true
}, [user?.id, user?.exercise_list_prefs])
useEffect(() => {
if (!user?.id) prefsAppliedRef.current = false
}, [user?.id])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t)
}, [searchInput])
useEffect(() => {
const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
return () => clearTimeout(t)
}, [aiSearchInput])
useEffect(() => {
if (!filterModalOpen) return
const onKey = (e) => {
if (e.key === 'Escape') setFilterModalOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [filterModalOpen])
const queryBase = useMemo(
() => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly),
[filters, debouncedSearch, debouncedAiSearch, mineOnly]
)
const {
catalogs,
catalogsReady,
exercises,
setExercises,
listFetching,
loadingMore,
hasMore,
loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
useEffect(() => {
setSelectedIds(new Set())
}, [queryBase])
const focusOptions = useMemo(
() =>
catalogs.focusAreas.map((fa) => ({
id: fa.id,
label: `${fa.icon || ''} ${fa.name || ''}`.trim(),
})),
[catalogs.focusAreas]
)
const styleOptions = useMemo(
() => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.styleDirections]
)
const trainingTypeOptions = useMemo(
() => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
[catalogs.trainingTypes]
)
const targetGroupOptions = useMemo(
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
[catalogs.targetGroups]
)
const skillOptions = useMemo(
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.skills]
)
const visibilityOptions = useMemo(
() => [
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
{ id: 'official', label: 'Offiziell' },
],
[]
)
const statusOptions = useMemo(
() => [
{ id: 'draft', label: 'Entwurf' },
{ id: 'in_review', label: 'In Prüfung' },
{ id: 'approved', label: 'Freigegeben' },
{ id: 'archived', label: 'Archiviert' },
],
[]
)
const filterChips = useMemo(
() =>
buildExerciseListFilterChips({
mineOnly,
setMineOnly,
filters,
setFilters,
focusOptions,
styleOptions,
trainingTypeOptions,
targetGroupOptions,
skillOptions,
visibilityOptions,
statusOptions,
}),
[
mineOnly,
filters,
focusOptions,
styleOptions,
trainingTypeOptions,
targetGroupOptions,
skillOptions,
visibilityOptions,
statusOptions,
]
)
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
const searchTitleSuggestions = useMemo(() => {
const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
return [...new Set(titles)].slice(0, 80)
}, [exercises])
const clubNameById = useMemo(() => {
const m = {}
for (const c of activeClubMemberships(user?.clubs)) {
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
}
return m
}, [user?.clubs])
const effectiveClubId =
user?.effective_club_id != null && user.effective_club_id !== ''
? Number(user.effective_club_id)
: user?.active_club_id != null && user.active_club_id !== ''
? Number(user.active_club_id)
: null
const toggleSelect = useCallback((id) => {
setSelectedIds((prev) => {
const n = new Set(prev)
const nid = Number(id)
if (Number.isNaN(nid)) return prev
if (n.has(nid)) n.delete(nid)
else n.add(nid)
return n
})
}, [])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const toggleSelectAllPage = useCallback(() => {
setSelectedIds((prev) => {
const n = new Set(prev)
const allSel =
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
if (allSel) {
exercises.forEach((e) => n.delete(Number(e.id)))
} else {
exercises.forEach((e) => n.add(Number(e.id)))
}
return n
})
}, [exercises])
const allOnPageSelected = useMemo(
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
[exercises, selectedIds]
)
const bulkVisibilityOptions = useMemo(() => {
const base = [
{ id: '', label: '— nicht ändern —' },
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
]
if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
return base
}, [isSuperadmin])
const handleDelete = async (exercise) => {
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
try {
await api.deleteExercise(exercise.id)
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
}
}
const resetAllFilters = useCallback(() => {
setMineOnly(false)
setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS })
}, [])
const handleSaveExerciseFilterPrefs = useCallback(async () => {
const uid = user?.id
if (!uid) {
alert('Nicht angemeldet.')
return
}
setSavingExercisePrefs(true)
try {
const payload = compactExerciseListPrefsPayload(filters)
await api.updateProfile(uid, { exercise_list_prefs: payload })
await checkAuth()
alert('Standardfilter für die Übungsliste gespeichert.')
} catch (e) {
alert('Speichern fehlgeschlagen: ' + (e.message || String(e)))
} finally {
setSavingExercisePrefs(false)
}
}, [user?.id, filters, checkAuth])
const openBulkModal = () => {
setBulkVisibility('')
setBulkStatus('')
setBulkClubSelect('')
setBulkClubManual('')
setBulkPatchFocusAreas(false)
setBulkFocusAreaIds([])
setBulkPatchStyleDirections(false)
setBulkStyleDirectionIds([])
setBulkPatchTrainingTypes(false)
setBulkTrainingTypeIds([])
setBulkPatchTargetGroups(false)
setBulkTargetGroupIds([])
setBulkModalOpen(true)
}
const handleBulkSubmit = async () => {
const anyRelationPatch =
bulkPatchFocusAreas ||
bulkPatchStyleDirections ||
bulkPatchTrainingTypes ||
bulkPatchTargetGroups
if (!bulkVisibility && !bulkStatus && !anyRelationPatch) {
alert(
'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).'
)
return
}
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
if (ids.length === 0) {
alert('Keine Übungen ausgewählt.')
return
}
if (ids.length > BULK_MAX_IDS) {
alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
return
}
const payload = { exercise_ids: ids }
if (bulkVisibility) payload.visibility = bulkVisibility
if (bulkStatus) payload.status = bulkStatus
if (bulkPatchFocusAreas) {
payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchStyleDirections) {
payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchTrainingTypes) {
payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchTargetGroups) {
payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkVisibility === 'club') {
const manual = String(bulkClubManual || '').trim()
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
payload.club_id = Number(bulkClubSelect)
}
}
setBulkSubmitting(true)
try {
const res = await api.bulkPatchExercisesMetadata(payload)
const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
let resolvedClubId = null
if (bulkVisibility === 'club') {
if (payload.club_id != null) resolvedClubId = payload.club_id
else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
}
const clubLabel =
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
let nextPrimaryFocusName = null
if (bulkPatchFocusAreas) {
const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
if (faNums.length > 0) {
const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0]))
nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0])
}
}
setExercises((prev) =>
prev.map((e) => {
if (!updatedSet.has(Number(e.id))) return e
const next = { ...e }
if (bulkVisibility) {
next.visibility = bulkVisibility
next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
next.club_name = bulkVisibility === 'club' ? clubLabel : null
}
if (bulkStatus) next.status = bulkStatus
if (bulkPatchFocusAreas) {
if (nextPrimaryFocusName == null) delete next.focus_area
else next.focus_area = nextPrimaryFocusName
}
return next
})
)
let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
if (Array.isArray(res.failed) && res.failed.length) {
msg +=
'\n\n' +
res.failed
.slice(0, 12)
.map((f) => `#${f.id}: ${f.detail}`)
.join('\n')
if (res.failed.length > 12) msg += '\n…'
}
alert(msg)
setBulkModalOpen(false)
clearSelection()
} catch (err) {
alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
} finally {
setBulkSubmitting(false)
}
}
if (!catalogsReady && pageTab === 'list') {
return (
<div className="app-page">
<div className="empty-state" style={{ padding: '2.5rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Kataloge
</p>
</div>
</div>
)
}
return (
<div className="app-page">
<div className="exercises-page__header">
<h1 className="page-title exercises-page__title">Übungen</h1>
{pageTab === 'list' ? (
<Link to="/exercises/new" className="btn btn-primary">
+ Neu
</Link>
) : (
<span aria-hidden="true" />
)}
</div>
<PageSectionNav
ariaLabel="Übungen Bereiche"
value={pageTab}
onChange={setPageTab}
items={EXERCISES_PAGE_TABS}
className="exercises-page-toolbar-tabs"
/>
{pageTab === 'progression' ? (
<Suspense
fallback={
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Progressionsgraphen
</p>
</div>
}
>
<ExerciseProgressionGraphPanel />
</Suspense>
) : (
<>
<ExerciseListSearchBar
searchTitleSuggestions={searchTitleSuggestions}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
aiSearchInput={aiSearchInput}
onAiSearchInputChange={setAiSearchInput}
mineOnly={mineOnly}
onToggleMineOnly={() => setMineOnly((v) => !v)}
onOpenFilter={() => setFilterModalOpen(true)}
filterChips={filterChips}
onResetAllFilters={resetAllFilters}
exerciseCount={exercises.length}
allOnPageSelected={allOnPageSelected}
onToggleSelectAllPage={toggleSelectAllPage}
/>
{selectedIds.size > 0 ? (
<div className="card exercise-bulk-toolbar">
<strong>{selectedIds.size} ausgewählt</strong>
<button type="button" className="btn btn-secondary btn-small" onClick={clearSelection}>
Auswahl aufheben
</button>
<button type="button" className="btn btn-primary btn-small" onClick={openBulkModal}>
Massenänderung
</button>
<span className="exercise-bulk-toolbar__meta">
Bis zu {BULK_MAX_IDS} pro Anfrage. Für Verein ohne Auswahl: aktiver Vereinskontext (
<code>X-Active-Club-Id</code>
).
</span>
</div>
) : null}
<ExerciseListFilterModal
open={filterModalOpen}
onClose={() => setFilterModalOpen(false)}
filters={filters}
setFilters={setFilters}
focusOptions={focusOptions}
styleOptions={styleOptions}
trainingTypeOptions={trainingTypeOptions}
targetGroupOptions={targetGroupOptions}
skillOptions={skillOptions}
visibilityOptions={visibilityOptions}
statusOptions={statusOptions}
savingExercisePrefs={savingExercisePrefs}
onSaveStandard={handleSaveExerciseFilterPrefs}
onResetAll={resetAllFilters}
/>
<ExerciseListBulkModal
open={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
onSubmit={handleBulkSubmit}
bulkSubmitting={bulkSubmitting}
selectedCount={selectedIds.size}
bulkMaxIds={BULK_MAX_IDS}
user={user}
isPlatformAdmin={isPlatformAdmin}
statusOptions={statusOptions}
bulkVisibilityOptions={bulkVisibilityOptions}
focusOptions={focusOptions}
styleOptions={styleOptions}
trainingTypeOptions={trainingTypeOptions}
targetGroupOptions={targetGroupOptions}
bulkVisibility={bulkVisibility}
setBulkVisibility={setBulkVisibility}
bulkStatus={bulkStatus}
setBulkStatus={setBulkStatus}
bulkClubSelect={bulkClubSelect}
setBulkClubSelect={setBulkClubSelect}
bulkClubManual={bulkClubManual}
setBulkClubManual={setBulkClubManual}
bulkPatchFocusAreas={bulkPatchFocusAreas}
setBulkPatchFocusAreas={setBulkPatchFocusAreas}
bulkFocusAreaIds={bulkFocusAreaIds}
setBulkFocusAreaIds={setBulkFocusAreaIds}
bulkPatchStyleDirections={bulkPatchStyleDirections}
setBulkPatchStyleDirections={setBulkPatchStyleDirections}
bulkStyleDirectionIds={bulkStyleDirectionIds}
setBulkStyleDirectionIds={setBulkStyleDirectionIds}
bulkPatchTrainingTypes={bulkPatchTrainingTypes}
setBulkPatchTrainingTypes={setBulkPatchTrainingTypes}
bulkTrainingTypeIds={bulkTrainingTypeIds}
setBulkTrainingTypeIds={setBulkTrainingTypeIds}
bulkPatchTargetGroups={bulkPatchTargetGroups}
setBulkPatchTargetGroups={setBulkPatchTargetGroups}
bulkTargetGroupIds={bulkTargetGroupIds}
setBulkTargetGroupIds={setBulkTargetGroupIds}
/>
{listFetching && exercises.length === 0 ? (
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Übungen
</p>
</div>
) : exercises.length === 0 ? (
<div className="card">
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
</div>
) : (
<>
{listFetching ? (
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p>
) : null}
<p className="exercises-meta-line">
{exercises.length} angezeigt
{hasMore ? ' · es gibt weitere Einträge' : ''}
</p>
<div className="exercises-list-grid" data-testid="exercises-list-grid">
{exercises.map((exercise) => (
<ExerciseListCard
key={exercise.id}
exercise={exercise}
user={user}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
onDelete={handleDelete}
/>
))}
</div>
{hasMore && (
<div className="exercises-load-more">
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</>
)}
</div>
)
}
export default ExercisesListPage
/** Routen-Einstieg: Implementierung in `components/exercises/ExercisesListPageRoot.jsx` (Phase-3 Soft-Limit). */
export { default } from '../components/exercises/ExercisesListPageRoot'

View File

@ -1,20 +1,43 @@
/**
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
* Coach-Modus: Schrittfolge mit Split-Punkten (branch_gate), Stream-Wahl pro paralleler Phase, Assistenz und Zeitnahme.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal'
import {
COACH_ENTRY_BRANCH_GATE,
buildCoachSavePlanPayload,
coachBranchPicksStepStorageSuffix,
coachBranchPicksStorageKey,
coachOutlineGroupsFromTimeline,
coachShouldPromptSplitRejoin,
coachShouldPromptSplitRejoinTransition,
durationOverridesMapFromDeltas,
findCoachTimelineJumpIndexForPhase,
flattenPlanTimeline,
itemStableKey,
sectionsToPutPayload,
listCoachStreamFocusOptions,
mergeCoachBranchPicksWithUrlFocus,
normalizeCoachBranchPicks,
summarizeTimelineEntry,
} from '../utils/trainingPlanUtils'
function storageStepKey(unitId) {
return `sj_coach_step_${unitId}`
function storageStepKey(unitId, mergedPicks) {
return `sj_coach_step_${unitId}_${coachBranchPicksStepStorageSuffix(mergedPicks)}`
}
function clearCoachStepStorageForUnit(unitId) {
const pref = `sj_coach_step_${unitId}_`
try {
for (let i = sessionStorage.length - 1; i >= 0; i -= 1) {
const k = sessionStorage.key(i)
if (k && k.startsWith(pref)) sessionStorage.removeItem(k)
}
} catch {
/* ignore */
}
}
function storageDeltasKey(unitId) {
@ -54,14 +77,16 @@ function CoachControlsBand({
showJumpToTimerOwnerRow = true,
onJumpToTimerOwner,
timerOwnerLabelIndex,
branchGateMode = false,
}) {
const disPrev = step <= 0
const disNext = step >= timelineLength - 1
const disNext = branchGateMode || step >= timelineLength - 1
const alive = runStartAt != null || pausedAccumMs > 0
const canApplyForOwner = roundedMinForApply != null && roundedMinForApply >= 1
const canApplyForOwner = !branchGateMode && roundedMinForApply != null && roundedMinForApply >= 1
const istLabelMin = roundedMinForApply == null ? '—' : String(roundedMinForApply)
const doneLabel =
timelineLength <= 1
const doneLabel = branchGateMode
? 'Zuerst Gruppe wählen'
: timelineLength <= 1
? 'Nachbereitung öffnen'
: isLastCoachStep
? 'Nachbereitung & Ist-Zeit'
@ -105,6 +130,7 @@ function CoachControlsBand({
type="button"
className="btn btn-primary"
style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }}
disabled={branchGateMode}
onClick={onTimerStart}
>
Start{alive ? ` · ${clockStr}` : ''}
@ -117,7 +143,7 @@ function CoachControlsBand({
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px', flex: '0 1 auto', fontWeight: 600 }} disabled={!canApplyForOwner} onClick={onApplyActual}>
Ist ({istLabelMin}&nbsp;Min)
</button>
{alive && (
{alive && !branchGateMode && (
<button
type="button"
className="btn btn-secondary"
@ -133,6 +159,7 @@ function CoachControlsBand({
type="button"
className="btn btn-primary"
style={{ width: '100%', minHeight: '44px', fontWeight: 700, lineHeight: 1.2, padding: '6px 10px', whiteSpace: 'normal', textAlign: 'center' }}
disabled={branchGateMode}
onClick={onDone}
>
{doneLabel}
@ -155,8 +182,11 @@ function CoachControlsBand({
export default function TrainingCoachPage() {
const { unitId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const idNum = unitId ? parseInt(unitId, 10) : NaN
const coachFocusResetRef = useRef(null)
const [unit, setUnit] = useState(null)
const [loadError, setLoadError] = useState(null)
const [loading, setLoading] = useState(true)
@ -169,6 +199,8 @@ export default function TrainingCoachPage() {
const [coachDebriefPhase, setCoachDebriefPhase] = useState(false)
const [step, setStep] = useState(0)
const [branchPicks, setBranchPicks] = useState({})
const [streamChoiceHint, setStreamChoiceHint] = useState(null)
const [deltas, setDeltas] = useState({})
const [runStartAt, setRunStartAt] = useState(null)
@ -176,6 +208,8 @@ export default function TrainingCoachPage() {
const [timerOwningStep, setTimerOwningStep] = useState(null)
const [, setPulse] = useState(0)
const [splitRejoinPrompt, setSplitRejoinPrompt] = useState(null)
const [trainerAppend, setTrainerAppend] = useState('')
const [saveMarkDone, setSaveMarkDone] = useState(true)
const [saving, setSaving] = useState(false)
@ -187,12 +221,52 @@ export default function TrainingCoachPage() {
setUnit(u)
}, [idNum])
const coachFocus = useMemo(() => {
const poRaw = searchParams.get('po')
const soRaw = searchParams.get('so')
if (poRaw == null || poRaw === '' || soRaw == null || soRaw === '') return null
const po = parseInt(poRaw, 10)
const so = parseInt(soRaw, 10)
if (!Number.isFinite(po) || !Number.isFinite(so)) return null
if (!unit) return null
const opts = listCoachStreamFocusOptions(unit)
if (!opts.some((o) => o.phaseOrder === po && o.streamOrder === so)) return null
return { phaseOrder: po, streamOrder: so }
}, [searchParams, unit])
const mergedPicks = useMemo(
() => mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocus),
[branchPicks, coachFocus]
)
const streamFocusOptions = useMemo(() => (unit ? listCoachStreamFocusOptions(unit) : []), [unit])
const navigationKey = coachBranchPicksStepStorageSuffix(mergedPicks)
const streamQuickSelectValue = useMemo(() => {
for (let i = 0; i < streamFocusOptions.length; i++) {
const o = streamFocusOptions[i]
if (mergedPicks[o.phaseOrder] === o.streamOrder) return o.valueKey
}
return ''
}, [streamFocusOptions, mergedPicks])
const hasStreamSelectParams =
(searchParams.get('po') != null && searchParams.get('po') !== '') ||
(searchParams.get('so') != null && searchParams.get('so') !== '')
const streamParamsInvalid = Boolean(unit && hasStreamSelectParams && !coachFocus)
const timeline = useMemo(() => flattenPlanTimeline(unit, mergedPicks), [unit, mergedPicks])
const outlineGroups = useMemo(() => coachOutlineGroupsFromTimeline(timeline), [timeline])
useEffect(() => {
if (!unitId || Number.isNaN(idNum)) {
setLoadError('Ungültige Trainingseinheit')
setLoading(false)
return
}
coachFocusResetRef.current = null
let cancelled = false
;(async () => {
setLoading(true)
@ -200,9 +274,13 @@ export default function TrainingCoachPage() {
try {
await reloadUnit()
if (cancelled) return
let nextBranchPicks = {}
try {
const s = parseInt(sessionStorage.getItem(storageStepKey(idNum)), 10)
if (!Number.isNaN(s) && s >= 0) setStep(s)
const br = sessionStorage.getItem(coachBranchPicksStorageKey(idNum))
if (br) {
const o = JSON.parse(br)
if (o && typeof o === 'object') nextBranchPicks = normalizeCoachBranchPicks(o)
}
} catch {
/* ignore */
}
@ -220,6 +298,10 @@ export default function TrainingCoachPage() {
} catch {
/* ignore */
}
if (!cancelled) {
setBranchPicks(nextBranchPicks)
setStreamChoiceHint(null)
}
} catch (e) {
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
} finally {
@ -232,8 +314,68 @@ export default function TrainingCoachPage() {
}, [unitId, idNum, reloadUnit])
useEffect(() => {
sessionStorage.setItem(storageStepKey(idNum), String(step))
}, [idNum, step])
if (!unit) return
const po = searchParams.get('po')
const so = searchParams.get('so')
if ((po == null || po === '') && (so == null || so === '')) return
const p = parseInt(po, 10)
const s = parseInt(so, 10)
if (!Number.isFinite(p) || !Number.isFinite(s)) {
setSearchParams({}, { replace: true })
return
}
const opts = listCoachStreamFocusOptions(unit)
if (opts.length === 0 || !opts.some((o) => o.phaseOrder === p && o.streamOrder === s)) {
setSearchParams({}, { replace: true })
}
}, [unit, searchParams, setSearchParams])
useEffect(() => {
if (!unit) return
const ab = searchParams.get('atBranch')
if (ab == null || ab === '') return
const po = parseInt(ab, 10)
const phaseOrders = [...new Set(streamFocusOptions.map((o) => o.phaseOrder))]
if (!Number.isFinite(po) || !phaseOrders.includes(po)) {
const next = new URLSearchParams(searchParams)
next.delete('atBranch')
next.delete('preferSo')
setSearchParams(next, { replace: true })
}
}, [unit, searchParams, setSearchParams, streamFocusOptions])
useEffect(() => {
if (!unit || timeline.length === 0) return
const ab = searchParams.get('atBranch')
if (ab == null || ab === '') return
const po = parseInt(ab, 10)
if (!Number.isFinite(po)) return
const prefRaw = searchParams.get('preferSo')
const pref = prefRaw != null && prefRaw !== '' ? parseInt(prefRaw, 10) : NaN
const ix = findCoachTimelineJumpIndexForPhase(timeline, po, Number.isFinite(pref) ? pref : null)
if (ix >= 0) {
setStep(ix)
if (Number.isFinite(pref)) setStreamChoiceHint({ phaseOrder: po, streamOrder: pref })
}
const next = new URLSearchParams(searchParams)
next.delete('atBranch')
next.delete('preferSo')
setSearchParams(next, { replace: true })
}, [unit, timeline, searchParams, setSearchParams])
useEffect(() => {
if (Number.isNaN(idNum)) return
try {
sessionStorage.setItem(coachBranchPicksStorageKey(idNum), JSON.stringify(branchPicks))
} catch {
/* quota */
}
}, [idNum, branchPicks])
useEffect(() => {
if (Number.isNaN(idNum)) return
sessionStorage.setItem(storageStepKey(idNum, mergedPicks), String(step))
}, [idNum, mergedPicks, step])
useEffect(() => {
try {
@ -257,46 +399,91 @@ export default function TrainingCoachPage() {
return () => clearInterval(iv)
}, [runStartAt])
const timeline = useMemo(() => flattenPlanTimeline(unit), [unit])
const clampStep = (s, len = timeline.length) =>
Math.max(0, Math.min(s, Math.max(len - 1, 0)))
const timerReset = useCallback(() => {
setRunStartAt(null)
setPausedAccumMs(0)
setTimerOwningStep(null)
}, [])
useEffect(() => {
if (!unit) return
if (timeline.length === 0) {
setStep(0)
return
}
if (coachDebriefPhase) return
setStep((prev) => clampStep(prev, timeline.length))
if (coachDebriefPhase) {
setStep(timeline.length - 1)
} else {
setStep((prev) => clampStep(prev, timeline.length))
}
}, [unit, timeline.length, coachDebriefPhase])
useEffect(() => {
if (!coachDebriefPhase || !unit || timeline.length === 0) return
setStep(timeline.length - 1)
}, [coachDebriefPhase, unit, timeline.length])
if (!unit || Number.isNaN(idNum)) return
if (timeline.length === 0) {
setStep(0)
return
}
const prev = coachFocusResetRef.current
if (prev === null) {
coachFocusResetRef.current = navigationKey
} else if (prev !== navigationKey) {
coachFocusResetRef.current = navigationKey
setCoachDebriefPhase(false)
setSplitRejoinPrompt(null)
timerReset()
} else {
return
}
try {
const raw = sessionStorage.getItem(storageStepKey(idNum, mergedPicks))
const s = parseInt(raw, 10)
const maxIdx = Math.max(0, timeline.length - 1)
if (!Number.isNaN(s) && s >= 0) setStep(Math.min(s, maxIdx))
else setStep(0)
} catch {
setStep(0)
}
}, [unit, idNum, navigationKey, mergedPicks, timeline.length, timerReset])
const elapsedMs =
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000))
const currentEntry = timeline[step]
const nextEntry = timeline[step + 1] || null
const next2Entry = timeline[step + 2] || null
const safeStep = useMemo(() => {
if (!timeline.length) return 0
return Math.min(Math.max(0, step), timeline.length - 1)
}, [step, timeline.length])
useLayoutEffect(() => {
if (!timeline.length) return
const max = timeline.length - 1
const s = Math.min(Math.max(0, step), max)
if (s !== step) setStep(s)
}, [step, timeline.length])
const currentEntry = timeline[safeStep] ?? null
const nextEntry = timeline[safeStep + 1] || null
const next2Entry = timeline[safeStep + 2] || null
const clockStr = formatClock(tickDisplaySec)
const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000))
const showJumpToTimerOwner =
timerOwningStep != null &&
step !== timerOwningStep &&
safeStep !== timerOwningStep &&
(runStartAt != null || pausedAccumMs > 0)
const isLastCoachStep = timeline.length > 0 && step >= timeline.length - 1
const isLastCoachStep =
timeline.length > 0 &&
safeStep >= timeline.length - 1 &&
currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE
const timerStart = () => {
setRunStartAt(Date.now())
setTimerOwningStep(step)
setTimerOwningStep(safeStep)
}
const timerPause = () => {
@ -306,14 +493,8 @@ export default function TrainingCoachPage() {
}
}
const timerReset = () => {
setRunStartAt(null)
setPausedAccumMs(0)
setTimerOwningStep(null)
}
const applySuggestedDuration = () => {
const idx = timerOwningStep != null ? timerOwningStep : step
const idx = timerOwningStep != null ? timerOwningStep : safeStep
const ent = timeline[idx]
const item = ent?.item
if (!item || item.item_type !== 'exercise') return
@ -324,12 +505,28 @@ export default function TrainingCoachPage() {
timerPause()
}
const pickStreamForPhase = useCallback(
(phaseOrder, streamOrder) => {
if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return
setStreamChoiceHint(null)
setSplitRejoinPrompt(null)
setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder }))
timerReset()
setCoachDebriefPhase(false)
setSearchParams({ po: String(phaseOrder), so: String(streamOrder) }, { replace: true })
},
[timerReset, setSearchParams]
)
const atBranchGate = currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE
const goPrev = () => setStep((s) => clampStep(s - 1))
const goNext = () => setStep((s) => clampStep(s + 1))
const markCurrentDoneAdvance = () => {
const ownerIdx = timerOwningStep != null ? timerOwningStep : step
const ownerIdx = timerOwningStep != null ? timerOwningStep : safeStep
const ent = timeline[ownerIdx]
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
const item = ent?.item
if (item?.item_type === 'exercise' && elapsedMs > 650) {
const key = itemStableKey(item, ent.secOrder, ent.ii)
@ -339,7 +536,12 @@ export default function TrainingCoachPage() {
timerReset()
const lastIdx = timeline.length - 1
if (step >= lastIdx && lastIdx >= 0) {
if (safeStep >= lastIdx && lastIdx >= 0) {
const rejoin = coachShouldPromptSplitRejoin(unit, timeline[safeStep])
if (rejoin) {
setSplitRejoinPrompt(rejoin)
return
}
setCoachDebriefPhase(true)
try {
sessionStorage.setItem(storageDebriefKey(idNum), '1')
@ -348,25 +550,31 @@ export default function TrainingCoachPage() {
}
return
}
const nextIdx = safeStep + 1
if (nextIdx < timeline.length) {
const rejoinMid = coachShouldPromptSplitRejoinTransition(unit, timeline[safeStep], timeline[nextIdx])
if (rejoinMid) {
setSplitRejoinPrompt(rejoinMid)
return
}
}
setStep((s) => clampStep(s + 1, timeline.length))
}
const durationOverridesForApi = useMemo(() => {
const out = {}
for (let i = 0; i < timeline.length; i++) {
const ent = timeline[i]
const { item } = ent
if (item.item_type !== 'exercise' || item.id == null) continue
const k = itemStableKey(item, ent.secOrder, ent.ii)
const dv = deltas[k]?.actual_duration_min
if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) {
out[String(item.id)] = { actual_duration_min: Number(dv) }
}
}
return out
}, [timeline, deltas])
useEffect(() => {
if (!atBranchGate) return
if (runStartAt != null || pausedAccumMs > 0) timerReset()
}, [step, atBranchGate, runStartAt, pausedAccumMs, timerReset])
const durationOverridesForApi = useMemo(() => durationOverridesMapFromDeltas(unit, deltas), [unit, deltas])
useEffect(() => {
if (currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE) {
setCatalogExercise(null)
setCatalogError(null)
setCatalogLoading(false)
return
}
const item = currentEntry?.item
if (!item || item.item_type === 'note') {
setCatalogExercise(null)
@ -402,16 +610,16 @@ export default function TrainingCoachPage() {
return () => {
cancelled = true
}
}, [step, currentEntry?.item?.exercise_id, currentEntry?.item?.exercise_variant_id, currentEntry?.item?.item_type])
}, [step, currentEntry?.entryKind, currentEntry?.item?.exercise_id, currentEntry?.item?.exercise_variant_id, currentEntry?.item?.item_type])
const handleSaveDebrief = async () => {
setSaveOk(null)
setSaving(true)
try {
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi)
const sectionsPayloadPart = buildCoachSavePlanPayload(unit, durationOverridesForApi)
const tn = trainerAppend.trim()
const payload = {
sections: sectionsPayload,
...sectionsPayloadPart,
...(saveMarkDone ? { status: 'completed' } : {}),
}
if (tn) {
@ -421,18 +629,24 @@ export default function TrainingCoachPage() {
.trim()
}
await api.updateTrainingUnit(idNum, payload)
await reloadUnit()
setTrainerAppend('')
try {
sessionStorage.removeItem(storageDeltasKey(idNum))
sessionStorage.removeItem(storageDebriefKey(idNum))
sessionStorage.removeItem(coachBranchPicksStorageKey(idNum))
clearCoachStepStorageForUnit(idNum)
} catch {
/* ignore */
}
setDeltas({})
setBranchPicks({})
setStreamChoiceHint(null)
setSplitRejoinPrompt(null)
setCoachDebriefPhase(false)
setSaveOk('Gespeichert.')
setDebriefOpen(false)
setSaveOk('Gespeichert.')
setSearchParams({}, { replace: true })
navigate(`/planning/run/${unitId}`, { replace: true })
} catch (e) {
setSaveOk(`Fehler: ${e.message || e}`)
} finally {
@ -486,6 +700,41 @@ export default function TrainingCoachPage() {
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Planung
</button>
{streamFocusOptions.length > 0 ? (
<label
className="no-print"
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem', color: 'var(--text2)' }}
>
<span style={{ color: 'var(--text3)', whiteSpace: 'nowrap' }}>Ansicht</span>
<select
className="form-input"
style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }}
value={streamQuickSelectValue}
onChange={(e) => {
setSplitRejoinPrompt(null)
setCoachDebriefPhase(false)
timerReset()
const v = e.target.value
if (!v) {
setBranchPicks({})
setStreamChoiceHint(null)
setSplitRejoinPrompt(null)
setSearchParams({}, { replace: true })
} else {
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
pickStreamForPhase(ppo, sso)
}
}}
>
<option value="">Ablauf mit Split-Punkten (Standard)</option>
{streamFocusOptions.map((o) => (
<option key={o.valueKey} value={o.valueKey}>
{o.label}
</option>
))}
</select>
</label>
) : null}
<button
type="button"
className="btn btn-primary"
@ -496,6 +745,111 @@ export default function TrainingCoachPage() {
</button>
</nav>
{streamParamsInvalid ? (
<p
className="no-print card"
style={{
padding: '10px 12px',
fontSize: '0.82rem',
color: 'var(--danger)',
marginBottom: '8px',
flexShrink: 0,
}}
>
Ungültige Stream-Parameter in der Adresszeile es wird der Gesamtplan angezeigt.
</p>
) : null}
{splitRejoinPrompt && !coachDebriefPhase ? (
<div
className="card no-print"
style={{
flexShrink: 0,
marginBottom: '10px',
padding: '16px 14px',
borderRadius: '12px',
border: '2px solid var(--accent)',
background: 'linear-gradient(135deg, var(--accent-light), var(--surface))',
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
Parallelphase · Abschluss
</div>
<p style={{ fontSize: '0.95rem', fontWeight: 700, margin: '0 0 8px', color: 'var(--text1)' }}>
{splitRejoinPrompt.phaseTitle != null && String(splitRejoinPrompt.phaseTitle).trim()
? String(splitRejoinPrompt.phaseTitle).trim()
: `Phase ${splitRejoinPrompt.phaseOrderIndex}`}
{' — '}
alle Gruppen zusammen?
</p>
<p style={{ fontSize: '0.84rem', color: 'var(--text2)', margin: '0 0 12px', lineHeight: 1.45 }}>
Diese Phase hat mehrere Streams. Kurz mit dem anderen Trainer klären, dann gemeinsam weitermachen. Ist-Zeiten
und Speichern erfolgen in der Nachbereitung am Ende der Einheit.
</p>
<ul style={{ margin: '0 0 14px', paddingLeft: '1.2rem', fontSize: '0.82rem', color: 'var(--text2)' }}>
{splitRejoinPrompt.streams.map((st) => (
<li key={st.printStreamId || `s-${st.streamOrder}`}>
{st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}{' '}
<span style={{ color: 'var(--text3)' }}>(ca. {st.minutes} Min. Üb.)</span>
</li>
))}
</ul>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{safeStep < timeline.length - 1 ? (
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '48px', fontWeight: 700 }}
onClick={() => {
setSplitRejoinPrompt(null)
setStep((s) => clampStep(s + 1, timeline.length))
}}
>
Gruppen zusammengeführt weiter mit dem Plan
</button>
) : (
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '48px', fontWeight: 700 }}
onClick={() => {
setSplitRejoinPrompt(null)
setCoachDebriefPhase(true)
try {
sessionStorage.setItem(storageDebriefKey(idNum), '1')
} catch {
/* ignore */
}
}}
>
Alle Gruppen fertig zur Nachbereitung
</button>
)}
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px' }} onClick={() => setSplitRejoinPrompt(null)}>
Zurück andere Gruppe läuft noch
</button>
<button
type="button"
className="btn btn-secondary"
style={{ minHeight: '44px', fontSize: '0.82rem' }}
onClick={() => {
setSplitRejoinPrompt(null)
setCoachDebriefPhase(true)
try {
sessionStorage.setItem(storageDebriefKey(idNum), '1')
} catch {
/* ignore */
}
}}
>
{safeStep < timeline.length - 1
? 'Ausnahme: jetzt schon zur Nachbereitung'
: 'Ausnahme: trotzdem zur Nachbereitung'}
</button>
</div>
</div>
) : null}
<header
className="card training-coach-hero training-coach-hero--compact"
style={{
@ -509,44 +863,93 @@ export default function TrainingCoachPage() {
>
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Coach ·{' '}
{coachDebriefPhase ? 'Nachbereitung · abschließend speichern' : `Schritt ${(step || 0) + 1} / ${Math.max(timeline.length, 1)}`}
{coachDebriefPhase ? 'Nachbereitung · abschließend speichern' : `Schritt ${(safeStep || 0) + 1} / ${Math.max(timeline.length, 1)}`}
</div>
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
{unit.planned_date}
{unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`}
{unit.planned_focus ? ` · ${unit.planned_focus}` : ''}
</h1>
{streamFocusOptions.length > 0 ? (
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', margin: '8px 0 0', lineHeight: 1.4 }}>
Parallele Phasen erscheinen als Schritt <strong>Gruppe wählen</strong> kein Durcheinander mehr aus
verschränkten Streams. Pro paralleler Phase entscheiden Sie (oder der Co-Trainer auf dem eigenen Gerät), welcher
Stream coacht wird. Kurzwahl oben springt die betreffende Phase sofort auf einen Stream (z. B. zwischen Gruppen
wechseln).
{Object.keys(mergedPicks).length > 0 ? (
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text3)', fontSize: '0.78rem' }}>
Aktuell festgelegt:{' '}
{Object.keys(mergedPicks)
.map((pk) => {
const po = parseInt(pk, 10)
const so = mergedPicks[pk]
const o = streamFocusOptions.find((x) => x.phaseOrder === po && x.streamOrder === so)
return o?.label ?? `Phase ${po} · Stream ${so}`
})
.join(' · ')}
</span>
) : null}
</p>
) : null}
</header>
{outlineOpen && (
<div className="card training-coach-outline" style={{ flexShrink: 0, marginBottom: '8px', padding: '10px 12px', maxHeight: 'min(28vh, 260px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>Ablauf · Antippen zum Springen</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto', minHeight: 0 }}>
{timeline.map((ent, ix) => {
const lbl = summarizeTimelineEntry(ent)
const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}`
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
return (
<button
key={`${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`}
type="button"
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>
Trainingsrahmen · nach Blöcken und Streams
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', minHeight: 0 }}>
{outlineGroups.map((grp) => (
<div key={grp.mergeKey} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div
style={{
textAlign: 'left',
justifyContent: 'flex-start',
opacity: active ? 1 : 0.92,
fontWeight: active ? 700 : 500
}}
onClick={() => {
setCoachDebriefPhase(false)
setStep(ix)
fontSize: '0.72rem',
fontWeight: 700,
color: 'var(--accent-dark)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
<span style={{ opacity: 0.75 }}>{secTitle.substring(0, 14)} </span>
<span>{lbl}</span>
</button>
)
})}
{grp.heading}
{grp.sub ? <span style={{ fontWeight: 600, color: 'var(--text3)', textTransform: 'none' }}> · {grp.sub}</span> : null}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{grp.entries.map(({ ix, ent }) => {
const lbl = summarizeTimelineEntry(ent)
const ctx = ent.coachContext || ''
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === safeStep
const rowKey =
ent.entryKind === COACH_ENTRY_BRANCH_GATE
? `gate-${ix}`
: `${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`
return (
<button
key={rowKey}
type="button"
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
style={{
textAlign: 'left',
justifyContent: 'flex-start',
opacity: active ? 1 : 0.92,
fontWeight: active ? 700 : 500,
}}
onClick={() => {
setCoachDebriefPhase(false)
setStep(ix)
}}
>
{ctx ? (
<span style={{ opacity: 0.8, display: 'block', fontSize: '0.72rem', marginBottom: '2px' }}>
{ctx.length > 42 ? `${ctx.slice(0, 40)}` : ctx}
</span>
) : null}
<span>{lbl}</span>
</button>
)
})}
</div>
</div>
))}
</div>
</div>
)}
@ -566,13 +969,16 @@ export default function TrainingCoachPage() {
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline
.filter((e) => e.item.item_type === 'exercise')
.filter((e) => e.item?.item_type === 'exercise')
.map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
return (
<label key={`db-${k}`} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
<span style={{ wordBreak: 'break-word' }}>
{ent.coachContext ? <span style={{ color: 'var(--text3)' }}>{ent.coachContext} · </span> : null}
{summarizeTimelineEntry(ent)}
</span>
<input
type="number"
min="0"
@ -635,6 +1041,8 @@ export default function TrainingCoachPage() {
</div>
) : (
<>
{!splitRejoinPrompt ? (
<>
<div
className="training-coach-assist training-coach-assist--compact"
style={{
@ -648,7 +1056,12 @@ export default function TrainingCoachPage() {
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
Als Nächstes
</div>
{nextEntry ? (
{atBranchGate ? (
<p style={{ margin: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
Wählen Sie unten eine Gruppe. Danach zeigt der Coach fortlaufend nur die Übungen dieses Streams in dieser
parallelen Phase.
</p>
) : nextEntry ? (
<>
<p style={{ margin: '0', fontSize: '0.9rem', color: 'var(--text1)', lineHeight: 1.4 }}>
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
@ -667,7 +1080,7 @@ export default function TrainingCoachPage() {
</div>
<CoachControlsBand
step={step}
step={safeStep}
timelineLength={timeline.length}
onPrev={goPrev}
onNext={goNext}
@ -683,15 +1096,99 @@ export default function TrainingCoachPage() {
isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate}
/>
<div className="training-coach-scroll">
{currentEntry?.item?.item_type === 'note' ? (
{atBranchGate && currentEntry?.branchMeta ? (
<div
className="card"
style={{
padding: '18px 16px',
marginBottom: '12px',
borderRadius: '14px',
border: '2px solid var(--accent)',
background: 'linear-gradient(180deg, var(--accent-light) 0%, var(--surface) 38%)',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
}}
>
<div style={{ fontSize: '0.74rem', fontWeight: 800, color: 'var(--accent-dark)', marginBottom: '6px', letterSpacing: '0.04em' }}>
SPLIT GRUPPE WÄHLEN
</div>
<p style={{ fontSize: '1.05rem', fontWeight: 800, margin: '0 0 8px', color: 'var(--text1)' }}>
{currentEntry.branchMeta.phaseTitle != null && String(currentEntry.branchMeta.phaseTitle).trim()
? String(currentEntry.branchMeta.phaseTitle).trim()
: `Phase ${currentEntry.branchMeta.phaseOrderIndex}`}
</p>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', margin: '0 0 16px', lineHeight: 1.45 }}>
Tippen Sie auf eine Kachel, um <strong>diese Gruppe</strong> zu coachen. Andere Trainer:innen wählen auf
ihrem Gerät parallel eine andere Kachel.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '12px' }}>
{(currentEntry.branchMeta.streams || []).map((st) => {
const hinted =
streamChoiceHint?.phaseOrder === currentEntry.branchMeta.phaseOrderIndex &&
streamChoiceHint?.streamOrder === st.streamOrder
const label = st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`
const baseBg = hinted ? 'var(--accent)' : 'var(--surface2)'
const baseColor = hinted ? '#fff' : 'var(--text1)'
const borderCol = hinted ? 'var(--accent-dark)' : 'var(--border)'
return (
<button
key={st.printStreamId || `s-${st.streamOrder}`}
type="button"
className="btn"
style={{
textAlign: 'left',
justifyContent: 'flex-start',
flexDirection: 'column',
alignItems: 'stretch',
padding: '14px 16px',
minHeight: '96px',
fontWeight: 700,
background: baseBg,
color: baseColor,
border: `2px solid ${borderCol}`,
borderRadius: '12px',
boxShadow: hinted ? '0 4px 0 var(--accent-dark)' : '0 3px 0 hsl(200 20% 78%)',
transition: 'transform 0.08s ease, box-shadow 0.08s ease',
}}
onClick={() => pickStreamForPhase(currentEntry.branchMeta.phaseOrderIndex, st.streamOrder)}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1rem' }}>
<span style={{ fontSize: '1.35rem', lineHeight: 1 }} aria-hidden="true">
{hinted ? '◉' : '○'}
</span>
<span>{label}</span>
</span>
<span
style={{
display: 'block',
fontSize: '0.82rem',
fontWeight: 600,
opacity: hinted ? 0.95 : 0.88,
marginTop: '8px',
}}
>
{st.minutes} Min. Üb. · Jetzt aktivieren
</span>
{hinted ? (
<span style={{ display: 'block', fontSize: '0.74rem', fontWeight: 600, marginTop: '8px', opacity: 0.92 }}>
Vorschlag aus Planansicht trotzdem andere Kachel möglich
</span>
) : null}
</button>
)
})}
</div>
</div>
) : currentEntry?.item?.item_type === 'note' ? (
<div className="card" style={{ padding: '16px 14px' }}>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
{currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil {step + 1}
{currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
{safeStep + 1}
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</div>
<p style={{ fontSize: '1.05rem', lineHeight: 1.52, whiteSpace: 'pre-wrap', margin: 0 }}>{currentEntry.item.note_body || ''}</p>
@ -700,7 +1197,8 @@ export default function TrainingCoachPage() {
<>
<div className="card training-coach-plan-strip" style={{ padding: '12px 14px', marginBottom: '12px', borderRadius: '12px', borderLeft: `3px solid var(--accent)` }}>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '6px' }}>
In diesem Training · {currentEntry?.sec.title || 'Abschnitt'} · Teil {step + 1}
In diesem Training · {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Teil{' '}
{safeStep + 1}
</div>
{currentEntry?.item && (
<>
@ -788,13 +1286,16 @@ export default function TrainingCoachPage() {
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline
.filter((e) => e.item.item_type === 'exercise')
.filter((e) => e.item?.item_type === 'exercise')
.map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
return (
<label key={k} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
<span style={{ wordBreak: 'break-word' }}>
{ent.coachContext ? <span style={{ color: 'var(--text3)' }}>{ent.coachContext} · </span> : null}
{summarizeTimelineEntry(ent)}
</span>
<input
type="number"
min="0"
@ -840,7 +1341,7 @@ export default function TrainingCoachPage() {
</div>
<CoachControlsBand
step={step}
step={safeStep}
timelineLength={timeline.length}
onPrev={goPrev}
onNext={goNext}
@ -856,9 +1357,12 @@ export default function TrainingCoachPage() {
isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow={false}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate}
/>
</>
) : null}
</>
)}
</div>

View File

@ -14,6 +14,7 @@ import {
enrichSectionsWithVariants,
buildSectionsPayload,
hydrateExercisePlanningRow,
reorderBlockIntoParallelStreamEnd,
} from '../utils/trainingUnitSectionsForm'
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
@ -553,7 +554,7 @@ export default function TrainingFrameworkProgramEditPage() {
}
const moveSectionsAcrossFrameworkSlots = useCallback(
({ fromSlot, fromSectionIdx, toSlot, toSectionIdx }) => {
({ fromSlot, fromSectionIdx, toSlot, toSectionIdx, toParallelStream }) => {
setForm((prev) => {
const slots = prev.slots.map((sl) => ({
...sl,
@ -581,17 +582,32 @@ export default function TrainingFrameworkProgramEditPage() {
const [block] = fromSecs.splice(fromSectionIdx, 1)
const applyParallelStreamEnd =
toParallelStream != null && toParallelStream.po != null && toParallelStream.so != null
? (secs, insertedAt) => {
const po = Number(toParallelStream.po) || 0
const so = Number(toParallelStream.so) || 0
return reorderBlockIntoParallelStreamEnd(secs, insertedAt, po, so)
}
: null
if (fromSlot === toSlot) {
let insertAt = toSectionIdx
if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1
insertAt = Math.max(0, Math.min(insertAt, fromSecs.length))
fromSecs.splice(insertAt, 0, block)
if (applyParallelStreamEnd) {
slots[fromSlot].sections = applyParallelStreamEnd(fromSecs, insertAt)
}
return { ...prev, slots }
}
const toSecs = slots[toSlot].sections
const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length))
toSecs.splice(ia, 0, block)
if (applyParallelStreamEnd) {
slots[toSlot].sections = applyParallelStreamEnd(toSecs, ia)
}
return { ...prev, slots }
})
},

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,18 @@
/**
* Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert).
* Phasen: Ganzgruppe vs. Split (planLoc); Druck mit optional getrennten Breakout-Seiten.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePeekModal from '../components/ExercisePeekModal'
import CombinationPlanBracket from '../components/CombinationPlanBracket'
import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
import {
buildPlanRunViewModelFromSections,
itemStableKey,
sectionsWithPlanLocForDisplay,
sortedItems,
} from '../utils/trainingPlanUtils'
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function storageKey(unitId) {
@ -36,6 +42,8 @@ export default function TrainingUnitRunPage() {
const [loading, setLoading] = useState(true)
const [checked, setChecked] = useState(() => new Set())
const [peekCtx, setPeekCtx] = useState(null)
/** null | printStreamId z. B. p0-s1 — nur dieser Split-Stream in @media print */
const [printOnlyStreamId, setPrintOnlyStreamId] = useState(null)
const loadChecked = useCallback((uid) => {
try {
@ -107,20 +115,245 @@ export default function TrainingUnitRunPage() {
}
}, [idNum, persistChecked])
const sections = useMemo(() => sortedSections(unit), [unit])
const sections = useMemo(() => sectionsWithPlanLocForDisplay(unit), [unit])
const planModel = useMemo(() => buildPlanRunViewModelFromSections(sections), [sections])
const totalPlannedMin = useMemo(() => {
let t = 0
for (const sec of sections) {
for (const it of sortedItems(sec)) {
if (it.item_type === 'exercise' && it.planned_duration_min != null) {
const n = Number(it.planned_duration_min)
if (Number.isFinite(n)) t += n
}
const printStreamOptions = useMemo(() => {
const opts = []
for (const run of planModel.runs) {
if (run.kind !== 'parallel' || !run.streams) continue
for (const st of run.streams) {
opts.push({
id: st.printStreamId,
label: `${run.phaseTitle ? String(run.phaseTitle).trim().slice(0, 28) : `Phase ${run.phaseOrderIndex}`} · ${st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}`,
})
}
}
return t
}, [sections])
return opts
}, [planModel.runs])
const totalPlannedMin = planModel.totalMin
const showWholeGroupInView = !printOnlyStreamId
const showStreamColumn = (streamPrintId) => !printOnlyStreamId || streamPrintId === printOnlyStreamId
const renderSectionCard = (sec, siInUnit) => {
const secOrder = sec.order_index ?? siInUnit
const items = sortedItems(sec)
return (
<section
key={sec.id ?? `sec-${secOrder}-${siInUnit}`}
className="card training-run-section"
style={{ padding: '1.15rem 1.25rem' }}
>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.65rem', color: 'var(--accent-dark)' }}>
{sec.title || `Abschnitt ${siInUnit + 1}`}
</h2>
{sec.guidance_notes && (
<p
style={{
fontSize: '0.88rem',
color: 'var(--text2)',
marginBottom: '0.85rem',
whiteSpace: 'pre-wrap',
}}
>
{sec.guidance_notes}
</p>
)}
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '0.65rem',
}}
>
{items.map((it, ii) => {
const ck = itemStableKey(it, secOrder, ii)
const done = checked.has(ck)
if (it.item_type === 'note') {
return (
<li
key={ck}
className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}
>
<label
style={{
display: 'flex',
gap: '0.75rem',
alignItems: 'flex-start',
cursor: 'pointer',
fontSize: '0.92rem',
}}
>
<input
type="checkbox"
className="training-run-checkbox training-run-checkbox--printable"
checked={done}
onChange={() => toggle(ck)}
style={{ marginTop: '4px', width: '20px', height: '20px' }}
/>
<span style={{ whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>
<em style={{ fontStyle: 'normal', color: 'var(--text3)', fontSize: '0.8rem' }}>
Notiz
</em>
<br />
{it.note_body || ''}
</span>
</label>
</li>
)
}
const title =
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : 'Übung')
const variant = it.exercise_variant_name ? ` (${it.exercise_variant_name})` : ''
const plan = formatMin(it.planned_duration_min)
const extras = []
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isComboRow = exKind === 'combination'
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
const comboEffectiveProfile = isComboRow
? effectiveComboMethodProfile(
it.catalog_method_profile || {},
it.planning_method_profile ?? null
)
: null
return (
<li
key={ck}
className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}
>
<label
style={{
display: 'flex',
gap: '0.75rem',
alignItems: 'flex-start',
cursor: 'pointer',
}}
>
<input
type="checkbox"
className="training-run-checkbox training-run-checkbox--printable"
checked={done}
onChange={() => toggle(ck)}
style={{
marginTop: '6px',
width: '22px',
height: '22px',
flexShrink: 0,
}}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: '1.02rem', fontWeight: 600 }}>
{title}
{variant}
</span>
{metaParts.length > 0 && (
<div
style={{
fontSize: '0.85rem',
color: 'var(--text3)',
marginTop: '3px',
lineHeight: 1.35,
}}
>
{metaParts.join(' · ')}
</div>
)}
{(it.notes || it.modifications) && (
<div
style={{
marginTop: '0.35rem',
fontSize: '0.88rem',
color: 'var(--text2)',
whiteSpace: 'pre-wrap',
}}
>
{it.notes && (
<>
<strong style={{ fontWeight: 600 }}>Coach:</strong> {it.notes}
</>
)}
{it.modifications && (
<>
{it.notes ? <br /> : null}
<strong style={{ fontWeight: 600 }}>Anpassung:</strong> {it.modifications}
</>
)}
</div>
)}
{isComboRow && it.exercise_id ? (
<div className="training-run-combo-embed">
<CombinationPlanBracket
methodArchetype={String(it.catalog_method_archetype || '').trim()}
methodProfile={comboEffectiveProfile || {}}
combinationSlots={
Array.isArray(it.combination_slots) ? it.combination_slots : []
}
planningAdjusted={
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
}
/>
</div>
) : null}
{it.exercise_id && (
<div
className="no-print"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '10px',
marginTop: '0.55rem',
alignItems: 'center',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.82rem', margin: 0 }}
onClick={() =>
setPeekCtx({
exerciseId: it.exercise_id,
variantId:
it.exercise_variant_id != null
? Number(it.exercise_variant_id)
: null,
peekExtras: isComboRow
? {
catalog_method_profile: it.catalog_method_profile,
planning_method_profile: it.planning_method_profile,
}
: undefined,
})
}
>
Katalog (Popup)
</button>
<Link
to={`/exercises/${it.exercise_id}`}
style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
>
Vollständige Seite öffnen
</Link>
</div>
)}
</span>
</label>
</li>
)
})}
</ul>
</section>
)
}
if (loading) {
return (
@ -152,21 +385,66 @@ export default function TrainingUnitRunPage() {
onClose={() => setPeekCtx(null)}
/>
<nav className="training-run-toolbar no-print" style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<nav
className="training-run-toolbar no-print"
style={{
marginBottom: '1rem',
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Zur Planung
</button>
<Link
to={`/planning/run/${unitId}/coach`}
className="btn btn-primary"
style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
style={{
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Im Training (Coach)
</Link>
<button type="button" className="btn btn-secondary" onClick={() => window.print()}>
Drucken / PDF
</button>
<button type="button" className="btn btn-secondary" title="Alle Häkchen auf dieser Matte zurücksetzen" onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}>
{printStreamOptions.length > 0 ? (
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.86rem',
color: 'var(--text2)',
}}
>
Druck / Vorschau
<select
className="form-input"
style={{ marginBottom: 0, minWidth: '200px', fontSize: '0.86rem' }}
value={printOnlyStreamId || ''}
onChange={(e) => setPrintOnlyStreamId(e.target.value || null)}
>
<option value="">Gesamter Plan (empfohlen)</option>
{printStreamOptions.map((o) => (
<option key={o.id} value={o.id}>
Nur: {o.label}
</option>
))}
</select>
</label>
) : null}
<button
type="button"
className="btn btn-secondary"
title="Alle Häkchen auf dieser Matte zurücksetzen"
onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}
>
Fortschritt leeren
</button>
</nav>
@ -200,10 +478,81 @@ export default function TrainingUnitRunPage() {
</span>
{totalPlannedMin > 0 && (
<span>
<strong>Geplante Zeit (Übungen):</strong> ca. {totalPlannedMin} Min.
<strong>Geplante Zeit (Übungen, gesamt):</strong> ca. {totalPlannedMin} Min.
</span>
)}
</div>
{printOnlyStreamId ? (
<div
className="no-print"
style={{
marginTop: '0.75rem',
padding: '0.5rem 0.75rem',
background: 'hsl(200 40% 94%)',
borderRadius: '8px',
fontSize: '0.82rem',
color: 'hsl(200 25% 28%)',
}}
>
Vorschau wie fürs Drucken: nur die gewählte Split-Gruppe. Ganzgruppen-Blöcke sind ausgeblendet.
</div>
) : null}
{planModel.mode === 'phased' && planModel.runs.length > 0 && showWholeGroupInView && (
<div
className="training-run-phase-schedule training-run-notes-print"
style={{
marginTop: '1rem',
padding: '0.75rem 0.9rem',
background: 'var(--surface2)',
borderRadius: '8px',
fontSize: '0.86rem',
overflowX: 'auto',
}}
>
<strong style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text1)' }}>
Zeitplanung (Summe Übungsminuten)
</strong>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '280px' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--text3)', fontSize: '0.78rem' }}>
<th style={{ padding: '4px 8px 4px 0', fontWeight: 600 }}>Block</th>
<th style={{ padding: '4px 8px', fontWeight: 600 }}>Art</th>
<th style={{ padding: '4px 8px', fontWeight: 600 }}> Min</th>
<th style={{ padding: '4px 0 4px 8px', fontWeight: 600 }}> bis hier</th>
</tr>
</thead>
<tbody>
{planModel.runs.map((run, ri) => {
const cum = planModel.runCumulativeEnds[ri]
const label =
run.phaseTitle != null && String(run.phaseTitle).trim()
? String(run.phaseTitle).trim()
: run.kind === 'legacy'
? 'Ablauf'
: run.kind === 'whole_group'
? `Ganzgruppe (Phase ${run.phaseOrderIndex})`
: `Parallel (Phase ${run.phaseOrderIndex})`
return (
<tr key={`sched-${ri}-${run.kind}`} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 8px 6px 0', verticalAlign: 'top' }}>{label}</td>
<td style={{ padding: '6px 8px', color: 'var(--text2)', whiteSpace: 'nowrap' }}>
{run.kind === 'parallel' ? 'Split' : run.kind === 'legacy' ? '—' : 'Ganzgruppe'}
</td>
<td style={{ padding: '6px 8px' }}>{run.minutes || '—'}</td>
<td style={{ padding: '6px 0 6px 8px', fontWeight: 600 }}>{cum}</td>
</tr>
)
})}
</tbody>
</table>
{unit.planned_time_start && (
<p style={{ margin: '0.55rem 0 0', fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4 }}>
Hinweis: Startzeit oben im Kopf; Minuten sind aus den Übungseinplanungen ohne Pausen und ohne
automatische Uhrzeitliste.
</p>
)}
</div>
)}
{unit.notes && (
<div
className="training-run-notes-print"
@ -212,7 +561,7 @@ export default function TrainingUnitRunPage() {
padding: '0.65rem 0.85rem',
background: 'var(--accent-light)',
borderRadius: '8px',
fontSize: '0.92rem'
fontSize: '0.92rem',
}}
>
<strong>Hinweis Teilnehmer:</strong> {unit.notes}
@ -225,171 +574,175 @@ export default function TrainingUnitRunPage() {
Noch keine Abschnitte in diesem Plan. Unter <Link to="/planning">Planung</Link> bearbeiten.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{sections.map((sec, si) => {
const secOrder = sec.order_index ?? si
const items = sortedItems(sec)
return (
<section key={sec.id ?? `sec-${si}`} className="card training-run-section" style={{ padding: '1.15rem 1.25rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.65rem', color: 'var(--accent-dark)' }}>
{sec.title || `Abschnitt ${si + 1}`}
</h2>
{sec.guidance_notes && (
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', marginBottom: '0.85rem', whiteSpace: 'pre-wrap' }}>
{sec.guidance_notes}
</p>
)}
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{items.map((it, ii) => {
const ck = itemStableKey(it, secOrder, ii)
const done = checked.has(ck)
if (it.item_type === 'note') {
<div className="training-run-body" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{planModel.runs.map((run, runIdx) => {
if (run.kind === 'parallel') {
const visibleStreams = run.streams.filter((st) => showStreamColumn(st.printStreamId))
if (!visibleStreams.length) return null
return (
<div
key={`run-p-${run.phaseOrderIndex}-${runIdx}`}
className="training-run-phase training-run-phase--parallel"
>
<div
className="card training-run-phase-banner"
style={{
padding: '0.85rem 1rem',
background: 'linear-gradient(135deg, hsl(200 35% 94%), var(--surface2))',
border: '1px solid hsl(200 40% 80%)',
borderRadius: '10px',
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
PARALLELE PHASE
</div>
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
</div>
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
{printOnlyStreamId
? `Auszug eine Gruppe · ca. ${visibleStreams[0]?.minutes ?? run.minutes} Min. (Üb.)`
: `Geplante Übungszeit (gesamt): ca. ${run.minutes} Min. · Jede Spalte kann separat gedruckt werden (Dropdown oder Seitenumbruch).`}
</div>
</div>
<div
className="training-run-parallel-columns"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '1rem',
alignItems: 'start',
}}
>
{visibleStreams.map((st, streamIdx) => {
const siUnit = (sec) => Math.max(0, sections.indexOf(sec))
return (
<li key={ck} className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}>
<label
<div
key={st.printStreamId}
className={
'training-run-breakout-stream' +
(!printOnlyStreamId && streamIdx > 0
? ' training-run-breakout-stream--page-break'
: '')
}
data-print-id={st.printStreamId}
>
<div
className="training-run-stream-ribbon"
style={{
marginBottom: '10px',
padding: '8px 12px',
borderRadius: '8px',
background: 'hsl(200 38% 92%)',
border: '1px dashed hsl(200 42% 58%)',
fontSize: '0.88rem',
fontWeight: 700,
color: 'hsl(200 30% 28%)',
display: 'flex',
gap: '0.75rem',
alignItems: 'flex-start',
cursor: 'pointer',
fontSize: '0.92rem'
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '4px', width: '20px', height: '20px' }} />
<span style={{ whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>
<em style={{ fontStyle: 'normal', color: 'var(--text3)', fontSize: '0.8rem' }}>Notiz</em>
<br />
{it.note_body || ''}
<span>
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
· ca. {st.minutes} Min. (Üb.)
</span>
</span>
</label>
</li>
<Link
className="no-print"
to={`/planning/run/${unitId}/coach?atBranch=${run.phaseOrderIndex}&preferSo=${st.streamOrder}`}
style={{
fontSize: '0.78rem',
fontWeight: 600,
color: 'var(--accent-dark)',
textDecoration: 'underline',
textUnderlineOffset: '2px',
whiteSpace: 'nowrap',
}}
>
Coach · Split-Punkt (Vorschlag)
</Link>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
</div>
</div>
)
}
})}
</div>
</div>
)
}
const title =
it.exercise_title ||
(it.exercise_id ? `Übung #${it.exercise_id}` : 'Übung')
const variant = it.exercise_variant_name ? ` (${it.exercise_variant_name})` : ''
const plan = formatMin(it.planned_duration_min)
const extras = []
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isComboRow = exKind === 'combination'
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
const comboEffectiveProfile = isComboRow
? effectiveComboMethodProfile(
it.catalog_method_profile || {},
it.planning_method_profile ?? null,
)
: null
if (!showWholeGroupInView) return null
return (
<li key={ck} className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}>
<label
style={{
display: 'flex',
gap: '0.75rem',
alignItems: 'flex-start',
cursor: 'pointer'
}}
>
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '6px', width: '22px', height: '22px', flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: '1.02rem', fontWeight: 600 }}>
{title}
{variant}
</span>
{metaParts.length > 0 && (
<div style={{ fontSize: '0.85rem', color: 'var(--text3)', marginTop: '3px', lineHeight: 1.35 }}>
{metaParts.join(' · ')}
</div>
)}
{(it.notes || it.modifications) && (
<div style={{ marginTop: '0.35rem', fontSize: '0.88rem', color: 'var(--text2)', whiteSpace: 'pre-wrap' }}>
{it.notes && (
<>
<strong style={{ fontWeight: 600 }}>Coach:</strong> {it.notes}
</>
)}
{it.modifications && (
<>
{it.notes ? <br /> : null}
<strong style={{ fontWeight: 600 }}>Anpassung:</strong> {it.modifications}
</>
)}
</div>
)}
{isComboRow && it.exercise_id ? (
<div className="training-run-combo-embed">
<CombinationPlanBracket
methodArchetype={String(it.catalog_method_archetype || '').trim()}
methodProfile={comboEffectiveProfile || {}}
combinationSlots={
Array.isArray(it.combination_slots) ? it.combination_slots : []
}
planningAdjusted={
it.planning_method_profile != null &&
typeof it.planning_method_profile === 'object' &&
!Array.isArray(it.planning_method_profile)
}
/>
</div>
) : null}
{it.exercise_id && (
<div className="no-print" style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginTop: '0.55rem', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.82rem', margin: 0 }}
onClick={() =>
setPeekCtx({
exerciseId: it.exercise_id,
variantId:
it.exercise_variant_id != null
? Number(it.exercise_variant_id)
: null,
peekExtras: isComboRow
? {
catalog_method_profile: it.catalog_method_profile,
planning_method_profile: it.planning_method_profile,
}
: undefined,
})
}
>
Katalog (Popup)
</button>
<Link
to={`/exercises/${it.exercise_id}`}
style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
>
Vollständige Seite öffnen
</Link>
</div>
)}
</span>
</label>
</li>
)
})}
</ul>
</section>
return (
<div
key={`run-wg-${run.phaseOrderIndex}-${runIdx}`}
className="training-run-phase training-run-phase--whole training-run-wg-block"
>
{run.kind !== 'legacy' ? (
<div
className="card training-run-phase-banner"
style={{
padding: '0.85rem 1rem',
marginBottom: '0.25rem',
background: 'color-mix(in srgb, var(--accent) 12%, var(--surface2))',
border: '1px solid color-mix(in srgb, var(--accent) 28%, var(--border))',
borderRadius: '10px',
}}
>
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
GANZGRUPPE
</div>
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
</div>
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
Geplante Übungszeit: ca. {run.minutes} Min.
</div>
</div>
) : null}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{run.sections.map((sec) => renderSectionCard(sec, Math.max(0, sections.indexOf(sec))))}
</div>
</div>
)
})}
</div>
)}
{unit.trainer_notes && (
<div className="card training-run-trainer-note no-print" style={{ marginTop: '1.25rem', padding: '1rem', borderLeft: `4px solid var(--accent)` }}>
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text3)', marginBottom: '0.35rem', textTransform: 'uppercase' }}>
<div
className="card training-run-trainer-note no-print"
style={{
marginTop: '1.25rem',
padding: '1rem',
borderLeft: `4px solid var(--accent)`,
}}
>
<div
style={{
fontSize: '0.75rem',
fontWeight: 700,
color: 'var(--text3)',
marginBottom: '0.35rem',
textTransform: 'uppercase',
}}
>
Nur Trainer
</div>
<p style={{ fontSize: '0.92rem', whiteSpace: 'pre-wrap' }}>{unit.trainer_notes}</p>
</div>
)}
<footer className="no-print training-run-footer" style={{ marginTop: '1.75rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--text3)' }}>
<footer
className="no-print training-run-footer"
style={{ marginTop: '1.75rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--text3)' }}
>
Haken werden nur auf diesem Gerät gespeichert (Session Tab schließen kann sie löschen).
</footer>
</div>

View File

@ -4,86 +4,13 @@
* Zentrale API-Kommunikation mit automatischer Token-Injektion
*/
import { stripHtmlToText } from './htmlUtils'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from './combinationMethodProfileUi'
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
import * as exercises from '../api/exercises.js'
import * as planning from '../api/planning.js'
const API_URL = import.meta.env.VITE_API_URL || ''
/** LocalStorage + Request-Header für Mandanten-Kontext */
export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id'
function mergeActiveClubHeader(headers = {}) {
const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
if (cid && /^\d+$/.test(String(cid).trim())) {
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
}
return { ...headers }
}
/**
* Generic API request with automatic token injection
*/
async function request(endpoint, options = {}) {
const token = localStorage.getItem('authToken')
const method = (options.method || 'GET').toUpperCase()
const headers = mergeActiveClubHeader({
...options.headers,
})
// GET ohne Body: kein Content-Type: application/json (manche Proxies/Headers stören sich)
if (method !== 'GET' && method !== 'HEAD') {
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json'
}
}
if (token) {
headers['X-Auth-Token'] = token
}
const url = `${API_URL}${endpoint}`
try {
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
if (response.status === 502) {
throw new Error(
'HTTP 502 (Bad Gateway): Der Reverse-Proxy hat die API nicht korrekt erreicht. Ist `shinkan-api` aktiv (`docker compose ps`, `docker logs shinkan-api`)? Bei Host-Routing nur einen Weg verwenden — alles auf Port 3003 (Nginx nach `backend:8000`) oder sauber `/api` → Backend-Port.'
)
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
} catch (e) {
if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) {
const hint =
API_URL && API_URL.length > 0
? `Verbindung zum API unter ${API_URL} fehlgeschlagen. Läuft das Backend (z. B. Port 8098) und ist CORS erlaubt?`
: 'Kein VITE_API_URL gesetzt: Anfragen gehen an die Frontend-URL und schlagen oft fehl. Setze in .env z. B. VITE_API_URL=http://localhost:8098 und starte Vite neu.'
// Ursache oft: CORS (v. a. bei Fehler-Antworten ohne CORS-Header), Adblocker, falsche URL —
// Login kann trotzdem klappen wenn nur eine andere Route betroffen ist.
throw new Error(`${hint} [Technisch: ${e.message}; URL war ${endpoint}]`)
}
throw e
}
}
export { ACTIVE_CLUB_STORAGE_KEY }
export * from '../api/exercises.js'
export * from '../api/planning.js'
// ============================================================================
// Auth
@ -399,583 +326,6 @@ export async function deleteMethod(id) {
return request(`/api/methods/${id}`, { method: 'DELETE' })
}
// ============================================================================
// Exercises
// ============================================================================
export async function listExercises(filters = {}) {
const q = new URLSearchParams()
Object.entries(filters).forEach(([k, v]) => {
if (v === undefined || v === null) return
if (typeof v === 'boolean') {
q.set(k, v ? 'true' : 'false')
return
}
if (Array.isArray(v)) {
if (v.length === 0) return
v.forEach((item) => {
if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') {
q.append(k, String(item))
}
})
return
}
if (String(v).trim() !== '') q.set(k, String(v))
})
const query = q.toString()
return request(`/api/exercises${query ? '?' + query : ''}`)
}
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
export function buildExerciseApiPayload(formData, extras = {}) {
const num = (v) => (v === '' || v == null ? null : Number(v))
const goalHtml = formData.goal || ''
const execHtml = formData.execution || ''
const goalText = stripHtmlToText(goalHtml)
const execText = stripHtmlToText(execHtml)
if (!goalText && !execText) {
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).')
}
const mapFocus = (formData.focus_areas_multi || [])
.filter((x) => x && x.focus_area_id)
.map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary }))
const mapStyles = (formData.training_styles_multi || [])
.filter((x) => x && x.training_style_id)
.map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary }))
const mapTTypes = (formData.training_types_multi || [])
.filter((x) => x && x.training_type_id)
.map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary }))
const mapTg = (formData.target_groups_multi || [])
.filter((x) => x && x.target_group_id)
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase()
const payload = {
title: (formData.title || '').trim(),
summary: formData.summary || null,
goal: goalHtml.trim() ? goalHtml : null,
execution: execHtml.trim() ? execHtml : null,
preparation: formData.preparation || null,
trainer_notes: formData.trainer_notes || null,
duration_min: num(formData.duration_min),
duration_max: num(formData.duration_max),
group_size_min: num(formData.group_size_min),
group_size_max: num(formData.group_size_max),
equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
focus_areas_multi: mapFocus,
training_styles_multi: mapStyles,
training_types_multi: mapTTypes,
target_groups_multi: mapTg,
age_groups: [],
skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id,
is_primary: !!s.is_primary,
intensity: s.intensity || null,
required_level: s.required_level || null,
target_level: s.target_level || null,
})),
visibility: visibilityNorm,
status: formData.status || 'draft',
club_id: visibilityNorm === 'club' ? num(formData.club_id) : null,
exercise_kind:
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'
: 'simple',
...extras,
}
const isCombo = payload.exercise_kind === 'combination'
if (isCombo) {
let mpObj = {}
const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
if (mpRaw) {
try {
const parsed = JSON.parse(mpRaw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
}
mpObj = parsed
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
}
throw e
}
}
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
const combination_slots = []
function parseTimingField(raw) {
if (raw === '' || raw == null || raw === undefined) return undefined
const n = parseInt(String(raw), 10)
return Number.isFinite(n) ? n : undefined
}
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
let ids = Array.isArray(row.candidate_exercise_ids)
? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
/** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
ids = row.idsText
.split(/[\s,;]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n))
}
combination_slots.push({
slot_index: i,
title: (typeof row.title === 'string' && row.title.trim()) || null,
candidate_exercise_ids: ids,
})
}
const slot_profiles_v1_next = []
for (let i = 0; i < slotRows.length; i += 1) {
const row = slotRows[i] || {}
const o = { slot_index: i }
const advanceMode = normalizeAdvanceMode(row.advance_mode)
if (advanceMode !== 'timed') o.advance_mode = advanceMode
const load = parseTimingField(row.load_sec)
const crs = parseTimingField(row.consecutive_reps)
const rsc = parseTimingField(row.rep_series_count)
const intra = parseTimingField(row.intra_rep_rest_sec)
const tran = parseTimingField(row.transition_after_sec)
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
const allowInterSeriesPause =
advanceMode === 'timed' ||
((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2)
if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
if (
rsc !== undefined &&
rsc >= 1 &&
(advanceMode === 'rep' || advanceMode === 'manual')
) {
o.rep_series_count = Math.round(rsc)
}
if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra)
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
}
payload.method_archetype = (formData.method_archetype || '').trim() || null
if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
else delete mpObj.slot_profiles_v1
payload.method_profile = mpObj
payload.combination_slots = combination_slots
} else {
payload.method_archetype = null
payload.method_profile = {}
}
return payload
}
export async function uploadExerciseMedia(exerciseId, formData) {
const token = localStorage.getItem('authToken')
const headers = mergeActiveClubHeader({})
if (token) headers['X-Auth-Token'] = token
const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 409 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(
typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden',
)
e.code = d.code
e.status = 409
e.payload = d
throw e
}
if (response.status === 413) {
const nginx = (text || '').toLowerCase().includes('nginx')
throw new Error(
nginx
? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).'
: 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.',
)
}
const msg =
typeof d === 'string'
? d
: d != null && typeof d === 'object' && typeof d.message === 'string'
? d.message
: d != null
? JSON.stringify(d)
: text && text.length < 400 && !/^\s*</.test(text)
? `HTTP ${response.status}: ${text.trim()}`
: `HTTP ${response.status}`
throw new Error(msg)
}
return response.json()
}
export async function updateExerciseMedia(exerciseId, mediaId, data) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseMedia(exerciseId, mediaId) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
}
export async function reorderExerciseMedia(exerciseId, mediaIds) {
return request(`/api/exercises/${exerciseId}/media/reorder`, {
method: 'PUT',
body: JSON.stringify({ media_ids: mediaIds }),
})
}
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
return request(`/api/media-assets/${assetId}/lifecycle`, {
method: 'POST',
body: JSON.stringify({ action, ...extra }),
})
}
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
export async function listMediaAssets(params = {}) {
const sp = new URLSearchParams()
if (params.q) sp.set('q', params.q)
if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
}
export async function patchMediaAsset(assetId, data) {
return request(`/api/media-assets/${assetId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function bulkMediaLifecycle(data) {
return request('/api/media-assets/bulk-lifecycle', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function bulkPatchMediaAssets(data) {
return request('/api/media-assets/bulk-patch', {
method: 'POST',
body: JSON.stringify(data),
})
}
/**
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
* @param {File[]} files
* @param {{ visibility?: string, club_id?: number }} [options]
*/
export async function bulkUploadMediaAssets(files, options = {}) {
const visibility = options.visibility || 'private'
const token = localStorage.getItem('authToken')
const headers = mergeActiveClubHeader({})
if (token) headers['X-Auth-Token'] = token
const formData = new FormData()
formData.append('visibility', String(visibility))
if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id))
}
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
if (options.copyright_notice != null && String(options.copyright_notice).trim())
formData.append('copyright_notice', String(options.copyright_notice).trim())
const p06Fields = [
'rights_holder_confirmed',
'contains_identifiable_persons',
'person_consent_confirmed',
'person_consent_context',
'contains_minors',
'parental_consent_confirmed',
'parental_consent_context',
'contains_music',
'music_rights_confirmed',
'music_rights_context',
'contains_third_party_content',
'third_party_rights_confirmed',
'third_party_rights_context',
]
for (const f of p06Fields) {
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
}
const arr = Array.isArray(files) ? files : [files]
for (const f of arr) {
if (f) formData.append('files', f)
}
const url = `${API_URL}/api/media-assets/bulk-upload`
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function getMediaAssetJournal(assetId) {
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
}
export async function addMediaAssetDeclarationCorrection(assetId, body) {
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
body: JSON.stringify(body),
})
}
// P-11: Legal-Hold-Endpunkte
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
method: 'POST',
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
})
}
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
method: 'POST',
body: JSON.stringify({ release_note: releaseNote }),
})
}
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
}
export async function getExercise(id) {
return request(`/api/exercises/${id}`)
}
export async function createExercise(data) {
return request('/api/exercises', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function updateExercise(id, data) {
const token = localStorage.getItem('authToken')
const headers = mergeActiveClubHeader({ 'Content-Type': 'application/json' })
if (token) headers['X-Auth-Token'] = token
const url = `${API_URL}/api/exercises/${id}`
const response = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(data),
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 422 &&
d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.code === 'string'
) {
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
e.status = 422
e.code = d.code
e.payload = d
throw e
}
if (parsed?.detail != null) {
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
throw new Error(msg)
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
export async function createExerciseVariant(exerciseId, data) {
return request(`/api/exercises/${exerciseId}/variants`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseVariant(exerciseId, variantId, data) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseVariant(exerciseId, variantId) {
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
}
export async function reorderExerciseVariants(exerciseId, variantIds) {
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
method: 'PUT',
body: JSON.stringify({ variant_ids: variantIds }),
})
}
// Progressionsgraphen (Übung → Übung), Migration 032/033
export async function listExerciseProgressionGraphs() {
return request('/api/exercise-progression-graphs')
}
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
const q = includeEdges ? '?include_edges=true' : ''
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionGraph(id, data) {
return request(`/api/exercise-progression-graphs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionGraph(id) {
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
}
export async function listExerciseProgressionEdges(graphId, query = {}) {
const q = new URLSearchParams()
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
const qs = q.toString()
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
}
export async function createExerciseProgressionEdge(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
}
export async function createExerciseProgressionSequence(graphId, data) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
method: 'POST',
body: JSON.stringify({ edge_ids: edgeIds }),
})
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function regenerateExerciseAi(exerciseId, payload) {
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
method: 'POST',
body: JSON.stringify(payload),
})
}
// ============================================================================
// Catalogs (Admin-verwaltbare Stammdaten)
// ============================================================================
@ -1328,176 +678,6 @@ export async function deleteTrainerContext(id) {
return request(`/api/trainer-contexts/${id}`, { method: 'DELETE' })
}
// ============================================================================
// Training Planning
// ============================================================================
/** Query-Parameter wie GET /api/training-units. */
export async function listTrainingUnits(filters = {}) {
const q = new URLSearchParams()
if (filters.group_id != null && filters.group_id !== '') {
q.set('group_id', String(filters.group_id))
}
if (filters.club_id != null && filters.club_id !== '') {
q.set('club_id', String(filters.club_id))
}
if (filters.start_date) q.set('start_date', filters.start_date)
if (filters.end_date) q.set('end_date', filters.end_date)
if (filters.status) q.set('status', filters.status)
if (filters.debrief_pending === true) q.set('debrief_pending', 'true')
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
if (filters.sort) q.set('sort', String(filters.sort))
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
if (filters.cursor_planned_date) q.set('cursor_planned_date', String(filters.cursor_planned_date))
if (filters.cursor_planned_time != null && filters.cursor_planned_time !== '') {
q.set('cursor_planned_time', String(filters.cursor_planned_time))
}
if (filters.cursor_id != null && filters.cursor_id !== '') q.set('cursor_id', String(filters.cursor_id))
const qs = q.toString()
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
}
/** Dashboard Kurzüberblick: Entwürfe / meine Übungen / YTD abgeschlossene Einheiten (ein Roundtrip). */
export async function getDashboardKpis() {
return request('/api/dashboard/kpis')
}
/** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
const q = new URLSearchParams()
if (filters.start_date) q.set('start_date', String(filters.start_date))
if (filters.end_date) q.set('end_date', String(filters.end_date))
if (filters.assigned_to_me === false) q.set('assigned_to_me', 'false')
if (filters.limit_units != null && filters.limit_units !== '') {
q.set('limit_units', String(filters.limit_units))
}
const qs = q.toString()
return request(`/api/training-units/exercises-club-visibility-queue${qs ? `?${qs}` : ''}`)
}
export async function getTrainingUnit(id) {
return request(`/api/training-units/${id}`)
}
export async function createTrainingUnit(data) {
return request('/api/training-units', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function updateTrainingUnit(id, data) {
return request(`/api/training-units/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteTrainingUnit(id) {
return request(`/api/training-units/${id}`, { method: 'DELETE' })
}
export async function quickCreateTrainingUnit(data) {
return request('/api/training-units/quick-create', {
method: 'POST',
body: JSON.stringify(data)
})
}
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
export async function createTrainingUnitFromFrameworkSlot(data) {
return request('/api/training-units/from-framework-slot', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function listTrainingPlanTemplates() {
return request('/api/training-plan-templates')
}
export async function getTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`)
}
export async function createTrainingPlanTemplate(data) {
return request('/api/training-plan-templates', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function updateTrainingPlanTemplate(id, data) {
return request(`/api/training-plan-templates/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteTrainingPlanTemplate(id) {
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
}
export async function listTrainingModules() {
return request('/api/training-modules')
}
export async function getTrainingModule(id) {
return request(`/api/training-modules/${id}`)
}
export async function createTrainingModule(data) {
return request('/api/training-modules', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingModule(id, data) {
return request(`/api/training-modules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingModule(id) {
return request(`/api/training-modules/${id}`, { method: 'DELETE' })
}
/** Kopiert Modul-Inhalte ans Ende eines Abschnitts (section_order_index 0-basiert). */
export async function applyTrainingModuleToTrainingUnit(unitId, data) {
return request(`/api/training-units/${unitId}/apply-training-module`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function listTrainingFrameworkPrograms() {
return request('/api/training-framework-programs')
}
export async function getTrainingFrameworkProgram(id) {
return request(`/api/training-framework-programs/${id}`)
}
export async function createTrainingFrameworkProgram(data) {
return request('/api/training-framework-programs', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateTrainingFrameworkProgram(id, data) {
return request(`/api/training-framework-programs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteTrainingFrameworkProgram(id) {
return request(`/api/training-framework-programs/${id}`, { method: 'DELETE' })
}
// ============================================================================
// Version & Health
// ============================================================================
@ -1567,74 +747,11 @@ export const api = {
updateMethod,
deleteMethod,
// Exercises
listExercises,
getExercise,
createExercise,
updateExercise,
bulkPatchExercisesMetadata,
deleteExercise,
createExerciseVariant,
updateExerciseVariant,
deleteExerciseVariant,
reorderExerciseVariants,
buildExerciseApiPayload,
suggestExerciseAi,
regenerateExerciseAi,
uploadExerciseMedia,
updateExerciseMedia,
deleteExerciseMedia,
reorderExerciseMedia,
postMediaAssetLifecycle,
listMediaAssets,
patchMediaAsset,
bulkMediaLifecycle,
bulkPatchMediaAssets,
bulkUploadMediaAssets,
getMediaAssetJournal,
addMediaAssetDeclarationCorrection,
attachExerciseMediaFromAsset,
setMediaAssetLegalHold,
releaseMediaAssetLegalHold,
listMediaAssetsWithLegalHold,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,
createExerciseProgressionGraph,
updateExerciseProgressionGraph,
deleteExerciseProgressionGraph,
listExerciseProgressionEdges,
createExerciseProgressionEdge,
updateExerciseProgressionEdge,
deleteExerciseProgressionEdge,
createExerciseProgressionSequence,
deleteExerciseProgressionEdgesBatch,
// Exercises + Medien/Archiv (Progression, KI) → frontend/src/api/exercises.js
...exercises,
// Training Planning
listTrainingUnits,
getDashboardKpis,
getTrainingExerciseClubVisibilityQueue,
getTrainingUnit,
createTrainingUnit,
updateTrainingUnit,
deleteTrainingUnit,
quickCreateTrainingUnit,
createTrainingUnitFromFrameworkSlot,
listTrainingPlanTemplates,
getTrainingPlanTemplate,
createTrainingPlanTemplate,
updateTrainingPlanTemplate,
deleteTrainingPlanTemplate,
listTrainingModules,
getTrainingModule,
createTrainingModule,
updateTrainingModule,
deleteTrainingModule,
applyTrainingModuleToTrainingUnit,
listTrainingFrameworkPrograms,
getTrainingFrameworkProgram,
createTrainingFrameworkProgram,
updateTrainingFrameworkProgram,
deleteTrainingFrameworkProgram,
// Training Planning → frontend/src/api/planning.js
...planning,
// Catalogs
listFocusAreas,

View File

@ -2,7 +2,15 @@
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
*/
import { cloneJsonSerializablePlanningProfile } from './trainingUnitSectionsForm'
import {
buildPlanPayloadForSave,
cloneJsonSerializablePlanningProfile,
defaultPlanLocWholeGroup,
inheritPlanLocForPhasedSave,
phaseRunsFromSections,
sectionIndicesForParallelStream,
streamsForParallelPhaseOrders,
} from './trainingUnitSectionsForm'
export function sortedSections(unit) {
const raw = unit?.sections
if (!Array.isArray(raw)) return []
@ -20,10 +28,352 @@ export function itemStableKey(it, secOrder, ix) {
return `${secOrder}-${it?.item_type || 'row'}-${ix}`
}
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander. */
export function flattenPlanTimeline(unit) {
export function sumExerciseMinutesInSection(sec) {
let t = 0
for (const it of sortedItems(sec)) {
if (it.item_type === 'exercise' && it.planned_duration_min != null) {
const n = Number(it.planned_duration_min)
if (Number.isFinite(n)) t += n
}
}
return t
}
/**
* GET liefert `planLoc` oft nicht auf flachen `sections`, aber `unit.phases` (verschachtelt).
* Baut pro Abschnitt `planLoc` für phaseRuns / Darstellung (camelCase wie im Editor).
*/
function planLocBySectionIdFromPhases(phases) {
const byId = new Map()
if (!Array.isArray(phases)) return byId
for (const ph of phases) {
const po = Number(ph.order_index ?? ph.orderIndex ?? 0) || 0
const pk = String(ph.phase_kind ?? ph.phaseKind ?? '')
.toLowerCase()
.trim()
const phaseTitle = ph.title ?? ph.phaseTitle ?? null
const phaseGuidanceNotes = ph.guidance_notes ?? ph.guidanceNotes ?? null
if (pk === 'whole_group') {
for (const sec of ph.sections || []) {
const sid = sec.id != null ? Number(sec.id) : NaN
if (!Number.isFinite(sid)) continue
byId.set(sid, {
phaseKind: 'whole_group',
phaseOrderIndex: po,
parallelStreamOrderIndex: null,
phaseTitle,
phaseGuidanceNotes,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
})
}
} else if (pk === 'parallel') {
for (const st of ph.streams || []) {
const so = Number(st.order_index ?? st.orderIndex ?? 0) || 0
const streamTitle = st.title ?? st.streamTitle ?? null
const streamNotes = st.notes ?? st.streamNotes ?? null
const streamAssignedTrainerProfileIds =
st.assigned_trainer_profile_ids ?? st.streamAssignedTrainerProfileIds ?? null
for (const sec of st.sections || []) {
const sid = sec.id != null ? Number(sec.id) : NaN
if (!Number.isFinite(sid)) continue
byId.set(sid, {
phaseKind: 'parallel',
phaseOrderIndex: po,
parallelStreamOrderIndex: so,
phaseTitle,
phaseGuidanceNotes,
streamTitle,
streamNotes,
streamAssignedTrainerProfileIds,
})
}
}
}
}
return byId
}
function maxPhaseOrderIndexFromNestedPhases(phases) {
let m = -1
if (!Array.isArray(phases)) return m
for (const ph of phases) {
const po = Number(ph.order_index ?? ph.orderIndex ?? 0)
if (Number.isFinite(po)) m = Math.max(m, po)
}
return m
}
export function sectionsWithPlanLocForDisplay(unit) {
const sorted = sortedSections(unit)
const byId = planLocBySectionIdFromPhases(unit?.phases)
const merged = sorted.map((s) => {
const sid = s.id != null ? Number(s.id) : NaN
if (Number.isFinite(sid) && byId.has(sid)) {
return { ...s, planLoc: { ...byId.get(sid) } }
}
if (s.planLoc && s.planLoc.phaseKind) return s
return { ...s }
})
const inherited = inheritPlanLocForPhasedSave(merged)
const maxPhPo = maxPhaseOrderIndexFromNestedPhases(unit?.phases)
return inherited.map((s) => {
const sid = s.id != null ? Number(s.id) : NaN
if (!Number.isFinite(sid) || byId.has(sid)) return s
const pl = s.planLoc
if (pl?.phaseKind === 'parallel') {
const po = maxPhPo >= 0 ? maxPhPo + 1 : 0
return {
...s,
planLoc: {
...defaultPlanLocWholeGroup(po),
phaseTitle: pl.phaseTitle ?? null,
phaseGuidanceNotes: pl.phaseGuidanceNotes ?? null,
},
}
}
return s
})
}
/**
* Läuft auf bereits angereichter Abschnittsliste (gleiche Objektreferenzen wie in Slices).
*/
export function buildPlanRunViewModelFromSections(sections) {
if (!sections.length) {
return { mode: 'empty', runs: [], totalMin: 0, runCumulativeEnds: [] }
}
const runsMeta = phaseRunsFromSections(sections)
const runs = []
let cum = 0
const runCumulativeEnds = []
if (runsMeta.length === 0) {
const minutes = sections.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
cum = minutes
runCumulativeEnds.push(cum)
runs.push({
kind: 'legacy',
phaseOrderIndex: 0,
phaseTitle: null,
minutes,
sections,
streams: null,
globalOrderSections: sections,
})
return { mode: 'legacy', runs, totalMin: minutes, runCumulativeEnds }
}
for (const r of runsMeta) {
const slice = sections.slice(r.start, r.end)
if (r.phaseKind === 'whole_group') {
const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
cum += minutes
runCumulativeEnds.push(cum)
const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null
runs.push({
kind: 'whole_group',
phaseOrderIndex: r.phaseOrderIndex,
phaseTitle,
minutes,
sections: slice,
streams: null,
globalOrderSections: slice,
})
} else {
const po = r.phaseOrderIndex
const streamOrders = streamsForParallelPhaseOrders(slice, po)
const streams = streamOrders.map((so) => {
const idxs = sectionIndicesForParallelStream(slice, po, so)
const streamSecs = idxs
.map((i) => slice[i])
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const first = streamSecs[0]
const streamTitle = first?.planLoc?.streamTitle ?? null
const minutes = streamSecs.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
return {
streamOrder: so,
streamTitle,
minutes,
sections: streamSecs,
printStreamId: `p${po}-s${so}`,
}
})
const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
cum += minutes
runCumulativeEnds.push(cum)
const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null
runs.push({
kind: 'parallel',
phaseOrderIndex: po,
phaseTitle,
minutes,
streams,
sections: null,
globalOrderSections: slice,
})
}
}
return { mode: 'phased', runs, totalMin: cum, runCumulativeEnds }
}
/** @param {object} unit Trainingseinheit inkl. `sections`, optional `phases` (GET) */
export function buildPlanRunViewModel(unit) {
return buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit))
}
function coachContextLabelForSection(sec, sectionsList) {
const pl = sec?.planLoc
if (!pl?.phaseKind) return 'Ablauf'
if (pl.phaseKind === 'whole_group') {
const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : null
return pt ? `Ganzgruppe · ${pt}` : 'Ganzgruppe'
}
const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : `Phase ${pl.phaseOrderIndex ?? 0}`
const so = pl.parallelStreamOrderIndex ?? 0
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : `Gruppe ${so + 1}`
return `Parallel · ${pt} · ${st}`
}
export const COACH_ENTRY_BRANCH_GATE = 'branch_gate'
/** Normalisierte Stream-Wahl pro paralleler Phase (Phase-Index → Stream-Order). */
export function normalizeCoachBranchPicks(raw) {
const out = {}
if (!raw || typeof raw !== 'object') return out
for (const [k, v] of Object.entries(raw)) {
const pk = parseInt(String(k), 10)
const sv = typeof v === 'number' ? v : parseInt(String(v), 10)
if (Number.isFinite(pk) && Number.isFinite(sv)) out[pk] = sv
}
return out
}
/**
* URL-/Dropdown-Fokus `coachFocus` in Pick-Map mergen (fest gewählter Stream für eine Phase).
* @param {object} branchPicks Roh-Picks (z. B. aus Session)
* @param {{ phaseOrder: number, streamOrder: number }|null} coachFocusUrl z. B. ?po=&so=
*/
export function mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocusUrl) {
const m = normalizeCoachBranchPicks(branchPicks)
if (
coachFocusUrl != null &&
Number.isFinite(coachFocusUrl.phaseOrder) &&
Number.isFinite(coachFocusUrl.streamOrder)
) {
m[coachFocusUrl.phaseOrder] = coachFocusUrl.streamOrder
}
return m
}
/** Kurzstring für SessionStorage-Schlüssel (Sortierung stabil). */
export function coachBranchPicksStepStorageSuffix(mergedPicks) {
const keys = Object.keys(mergedPicks)
.map((k) => parseInt(String(k), 10))
.filter((n) => Number.isFinite(n))
.sort((a, b) => a - b)
if (!keys.length) return 'full'
return keys.map((k) => `p${k}-s${mergedPicks[k]}`).join('_')
}
export function coachBranchPicksStorageKey(unitId) {
return `sj_coach_branches_${unitId}`
}
/**
* Index zum Springen: Co-Trainer-Link atBranch+preferSo Gate falls noch offen, sonst erste Kachel im Stream.
*/
export function findCoachTimelineJumpIndexForPhase(timeline, phaseOrder, preferStreamOrder = null) {
const po = Number(phaseOrder)
if (!Number.isFinite(po) || !Array.isArray(timeline)) return -1
const hint = preferStreamOrder != null && Number.isFinite(Number(preferStreamOrder)) ? Number(preferStreamOrder) : null
if (hint != null) {
const ixStream = timeline.findIndex(
(e) =>
e.entryKind !== COACH_ENTRY_BRANCH_GATE &&
e.runMeta?.kind === 'parallel' &&
e.runMeta.phaseOrderIndex === po &&
e.runMeta.streamOrder === hint
)
if (ixStream >= 0) return ixStream
}
const ixGate = timeline.findIndex(
(e) => e.entryKind === COACH_ENTRY_BRANCH_GATE && e.branchMeta?.phaseOrderIndex === po
)
return ixGate
}
/** Gruppiert die flache Coach-Timeline für den Trainingsrahmen (Überschriften + Einträge). */
export function coachOutlineGroupsFromTimeline(timeline) {
const groups = []
for (let ix = 0; ix < timeline.length; ix++) {
const ent = timeline[ix]
let mergeKey = `ix-${ix}`
let heading = 'Ablauf'
let sub = ''
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
const po = ent.branchMeta?.phaseOrderIndex ?? 0
mergeKey = `gate-${po}`
heading = 'Split · Gruppe wählen'
const pt = ent.branchMeta?.phaseTitle
sub =
pt != null && String(pt).trim()
? String(pt).trim()
: `Parallele Phase ${po}`
} else {
const rm = ent.runMeta
if (rm?.kind === 'whole_group') {
mergeKey = `wg-${rm.phaseOrderIndex}`
const pl = ent.sec?.planLoc
const ptt = pl?.phaseTitle
heading = 'Ganzgruppe'
sub = ptt != null && String(ptt).trim() ? String(ptt).trim() : ''
} else if (rm?.kind === 'parallel' && rm.streamOrder != null) {
mergeKey = `par-${rm.phaseOrderIndex}-s${rm.streamOrder}`
const pl = ent.sec?.planLoc
const ptt = pl?.phaseTitle
const stt = pl?.streamTitle
const ptl = ptt != null && String(ptt).trim() ? String(ptt).trim() : `Phase ${rm.phaseOrderIndex}`
const stl = stt != null && String(stt).trim() ? String(stt).trim() : `Gruppe ${rm.streamOrder + 1}`
heading = `Parallel · ${ptl}`
sub = stl
} else if (rm?.kind === 'legacy') {
mergeKey = 'legacy'
heading = 'Ablauf'
sub = ''
} else {
mergeKey = `misc-${ix}`
}
}
const prev = groups[groups.length - 1]
if (!prev || prev.mergeKey !== mergeKey) {
groups.push({ mergeKey, heading, sub, entries: [{ ix, ent }] })
} else {
prev.entries.push({ ix, ent })
}
}
return groups
}
/**
* Flache Coach-Reihenfolge. Pro paralleler Phase ohne Eintrag in branchPicks: ein sichtbarer branch_gate,
* damit keine verschränkten Split-Übungen, bis eine Gruppe gewählt wurde.
* @param {object} unit
* @param {object} branchPicks z. B. { 0: 1 } = Phase 0 Stream 1
*/
export function flattenPlanTimeline(unit, branchPicks = {}) {
const sections = sectionsWithPlanLocForDisplay(unit)
const model = buildPlanRunViewModelFromSections(sections)
if (model.mode === 'empty') return []
const picks = normalizeCoachBranchPicks(branchPicks)
const list = []
sortedSections(unit).forEach((sec, si) => {
const pushSectionItems = (sec, coachCtx, runMeta) => {
const si = Math.max(0, sections.indexOf(sec))
const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((item, ii) => {
list.push({
@ -33,13 +383,165 @@ export function flattenPlanTimeline(unit) {
flatIndex: list.length,
sec,
item,
coachContext: coachCtx,
runMeta: runMeta || null,
})
})
})
}
for (const run of model.runs) {
if (run.kind === 'legacy') {
const meta = { kind: 'legacy', phaseOrderIndex: 0, streamOrder: null }
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
}
continue
}
if (run.kind === 'whole_group') {
const meta = { kind: 'whole_group', phaseOrderIndex: run.phaseOrderIndex, streamOrder: null }
for (const sec of run.globalOrderSections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
}
continue
}
if (run.kind === 'parallel') {
const po = run.phaseOrderIndex
const chosen = picks[po]
const hasPick = chosen !== undefined && chosen !== null && Number.isFinite(Number(chosen))
if (!hasPick) {
list.push({
entryKind: COACH_ENTRY_BRANCH_GATE,
si: -1,
ii: -1,
secOrder: -1,
flatIndex: list.length,
sec: null,
item: null,
coachContext: '',
branchMeta: {
phaseOrderIndex: po,
phaseTitle: run.phaseTitle,
streams: run.streams || [],
},
runMeta: { kind: 'parallel', phaseOrderIndex: po, streamOrder: null },
})
} else {
const st = run.streams?.find((x) => x.streamOrder === Number(chosen))
const meta = { kind: 'parallel', phaseOrderIndex: po, streamOrder: Number(chosen) }
if (st?.sections?.length) {
for (const sec of st.sections) {
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
}
}
}
}
}
return list
}
export function summarizeTimelineEntry({ item }) {
/** Optionen für Coach-Stream-Auswahl (phases · Gruppe). */
export function listCoachStreamFocusOptions(unit) {
const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit))
const opts = []
for (const run of model.runs) {
if (run.kind !== 'parallel' || !run.streams?.length) continue
const phaseLabel =
run.phaseTitle != null && String(run.phaseTitle).trim()
? String(run.phaseTitle).trim()
: `Phase ${run.phaseOrderIndex}`
for (const st of run.streams) {
const gl =
st.streamTitle != null && String(st.streamTitle).trim()
? String(st.streamTitle).trim()
: `Gruppe ${st.streamOrder + 1}`
opts.push({
phaseOrder: run.phaseOrderIndex,
streamOrder: st.streamOrder,
label: `${phaseLabel} · ${gl}`,
valueKey: `${run.phaseOrderIndex}-${st.streamOrder}`,
})
}
}
return opts
}
/** Alle Ist-Minuten-Overrides aus lokalem Delta-State für PUT (unabhängig von gefilterter Coach-Timeline). */
export function durationOverridesMapFromDeltas(unit, deltas) {
const out = {}
if (!unit || !deltas || typeof deltas !== 'object') return out
const sections = sortedSections(unit)
sections.forEach((sec, si) => {
const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((it, ii) => {
if (it.item_type !== 'exercise' || it.id == null) return
const k = itemStableKey(it, secOrder, ii)
const dv = deltas[k]?.actual_duration_min
if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) {
out[String(it.id)] = { actual_duration_min: Number(dv) }
}
})
})
return out
}
/** PUT-Body für Coach-Speichern: `phases` wenn Plan Phasen hat, sonst `sections` (wie Planungseditor). */
export function buildCoachSavePlanPayload(unit, durationOverridesByItemId = {}) {
const withLoc = sectionsWithPlanLocForDisplay(unit)
const withDur = withLoc.map((sec) => ({
...sec,
items: sortedItems(sec).map((it) => {
if (it.item_type !== 'exercise' || it.id == null) return it
const o = durationOverridesByItemId[String(it.id)]
const av = o?.actual_duration_min
if (av !== undefined && av !== '' && av !== null && Number.isFinite(Number(av))) {
return { ...it, actual_duration_min: Number(av) }
}
return it
}),
}))
return buildPlanPayloadForSave(withDur)
}
/**
* Nach dem letzten Block eines Streams: Rückfrage, wenn die parallele Phase mehrere Gruppen hat.
*/
export function coachShouldPromptSplitRejoin(unit, lastTimelineEntry) {
const rm = lastTimelineEntry?.runMeta
if (!rm || rm.kind !== 'parallel' || rm.streamOrder == null) return null
const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit))
const run = model.runs.find((r) => r.kind === 'parallel' && r.phaseOrderIndex === rm.phaseOrderIndex)
if (!run?.streams || run.streams.length <= 1) return null
return {
phaseOrderIndex: run.phaseOrderIndex,
phaseTitle: run.phaseTitle,
streams: run.streams,
}
}
/**
* Nach dem letzten Block eines gewählten Streams: Rückfrage vor Ganzgruppenphase oder vor dem nächsten Split,
* wenn die aktuelle Parallelphase mehrere Streams hat.
*/
export function coachShouldPromptSplitRejoinTransition(unit, currentEntry, nextEntry) {
if (!currentEntry || !nextEntry) return null
const cRm = currentEntry.runMeta
if (!cRm || cRm.kind !== 'parallel' || cRm.streamOrder == null) return null
const intoWholeGroup = nextEntry.runMeta?.kind === 'whole_group'
const intoNextSplit = nextEntry.entryKind === COACH_ENTRY_BRANCH_GATE
if (!intoWholeGroup && !intoNextSplit) return null
return coachShouldPromptSplitRejoin(unit, currentEntry)
}
export function summarizeTimelineEntry(ent) {
if (!ent) return ''
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
const t = ent.branchMeta?.phaseTitle != null && String(ent.branchMeta.phaseTitle).trim()
? String(ent.branchMeta.phaseTitle).trim()
: ''
return t ? `Split wählen · ${t}` : 'Split · Gruppe wählen'
}
const { item } = ent
if (!item) return ''
if (item.item_type === 'note') {
const t = String(item.note_body || '').trim()

View File

@ -0,0 +1,115 @@
/** Reine Hilfen für Trainingsplanung (Kalender, Sichtbarkeits-Kurztext, Trainer-Zuordnung). */
export function trainingVisibilityShortDE(visibility) {
const v = String(visibility || '').trim().toLowerCase()
if (v === 'official') return 'Öffentliche Bibliothek'
if (v === 'club') return 'Verein'
if (v === 'private') return 'Privat'
return visibility ? String(visibility) : ''
}
export function addDaysIsoDate(isoDay, daysDelta) {
const d = new Date(`${isoDay}T12:00:00`)
d.setDate(d.getDate() + daysDelta)
return d.toISOString().slice(0, 10)
}
export function pad2(n) {
return String(n).padStart(2, '0')
}
export function toIsoLocal(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
/** Montag = erster Wochentag (ISO-Woche UI) */
export function mondayIndex(d) {
return (d.getDay() + 6) % 7
}
/** Kalendarische Monatsansicht: erster und letzter Tag des sichtbaren Rasters (MoSo) */
export function getCalendarGridRange(ym) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0]
const m = parts[1]
if (!y || !m || m < 1 || m > 12) {
const t = new Date()
return { gridStart: toIsoLocal(t), gridEnd: toIsoLocal(t) }
}
const first = new Date(y, m - 1, 1)
const last = new Date(y, m, 0)
const gridStart = new Date(first)
gridStart.setDate(first.getDate() - mondayIndex(first))
const lastMon = mondayIndex(last)
const gridEnd = new Date(last)
gridEnd.setDate(last.getDate() + (6 - lastMon))
return { gridStart: toIsoLocal(gridStart), gridEnd: toIsoLocal(gridEnd) }
}
export function shiftCalendarMonth(ym, delta) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0] || new Date().getFullYear()
const m = parts[1] || 1
const d = new Date(y, m - 1 + delta, 1)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`
}
export function enumerateIsoDays(fromIso, toIso) {
const out = []
const cur = new Date(`${fromIso}T12:00:00`)
const end = new Date(`${toIso}T12:00:00`)
while (cur <= end) {
out.push(toIsoLocal(cur))
cur.setDate(cur.getDate() + 1)
}
return out
}
export const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
export function toNumList(arr) {
if (!Array.isArray(arr)) return []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
export const sessionAssignDefaults = () => ({
lead_trainer_profile_id: '',
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
/** Co_trainer_ids aus TrainingGroups (Liste/JSON) → Zahlenliste */
export function normalizeGroupCoTrainerIds(raw) {
if (raw == null) return []
const arr = Array.isArray(raw) ? raw : []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
/** Mitgliederverzeichnis-Einträge ohne effektiven Leitungsträger als CoOption */
export function filterDirectoryExcludingLead(directory, excludeLeadPid) {
const ex =
excludeLeadPid != null && excludeLeadPid !== '' && Number.isFinite(Number(excludeLeadPid))
? Number(excludeLeadPid)
: null
if (ex == null) return directory
return directory.filter((m) => Number(m.id) !== ex)
}
/** Kurztexte für Rahmen-Herkunft (Listen + Formular-Modal). */
export function frameworkLineageText(unit) {
const fpTitle = (unit.origin_framework_program_title || '').trim() || 'Rahmenprogramm'
const st = (unit.origin_framework_slot_title || '').trim()
const idx = unit.origin_framework_slot_sort_order
const slotBit = st || (typeof idx === 'number' ? `Session ${idx + 1}` : 'Session')
return { fpTitle, slotBit, fpId: unit.origin_framework_program_id }
}

View File

@ -5,6 +5,201 @@ export function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
}
/** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */
export function defaultPlanLocWholeGroup(phaseOrderIndex = 0) {
return {
phaseKind: 'whole_group',
phaseOrderIndex,
parallelStreamOrderIndex: null,
phaseTitle: null,
phaseGuidanceNotes: null,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
}
/** Standard-`planLoc` für einen Stream innerhalb einer parallelen Phase. */
export function defaultPlanLocParallel(phaseOrderIndex, streamOrderIndex) {
return {
phaseKind: 'parallel',
phaseOrderIndex,
parallelStreamOrderIndex: streamOrderIndex,
phaseTitle: null,
phaseGuidanceNotes: null,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
}
export function planLocKey(pl) {
if (!pl || !pl.phaseKind) return ''
if (pl.phaseKind === 'whole_group') return `wg:${pl.phaseOrderIndex ?? 0}`
return `par:${pl.phaseOrderIndex ?? 0}:${pl.parallelStreamOrderIndex ?? 0}`
}
export function maxPhaseOrderIndexFromSections(sections) {
let m = -1
for (const s of sections || []) {
const pl = s?.planLoc
if (!pl || typeof pl.phaseOrderIndex !== 'number') continue
if (pl.phaseOrderIndex > m) m = pl.phaseOrderIndex
}
return m
}
/**
* Eindeutige Ziele für die Zuordnung eines Abschnitts (Dropdown).
* `template` ist ein vollständiges planLoc-Objekt zum Kopieren.
*/
export function buildPlanTargetOptions(sections) {
const map = new Map()
for (const s of sections || []) {
const pl = s?.planLoc
if (!pl?.phaseKind) continue
if (pl.phaseKind === 'whole_group') {
const po = pl.phaseOrderIndex ?? 0
const k = `wg:${po}`
if (!map.has(k)) {
const title = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : ''
map.set(k, {
key: k,
label: title || `Ganzgruppe · Phase ${po}`,
template: { ...pl },
})
}
} else {
const po = pl.phaseOrderIndex ?? 0
const so = pl.parallelStreamOrderIndex ?? 0
const k = `par:${po}:${so}`
if (!map.has(k)) {
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
map.set(k, {
key: k,
label: st || `Parallel · Phase ${po} · Stream ${so}`,
template: { ...pl },
})
}
}
}
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
}
/** Max. Streams pro paralleler Phase (UI + API-Schutz). */
export const MAX_PARALLEL_STREAMS_PER_PHASE = 5
/** Farben pro Stream-Index (max. 5 unterschiedliche Farbzyklen). */
export function parallelStreamVisual(streamOrderIndex) {
const n = Math.max(0, Number(streamOrderIndex) || 0)
const hues = [200, 135, 38, 285, 22]
const h = hues[n % hues.length]
return {
border: `hsl(${h} 50% 36%)`,
soft: `hsl(${h} 36% 94%)`,
tabBg: `hsl(${h} 34% 92%)`,
tabBgActive: `hsl(${h} 40% 82%)`,
}
}
export function streamTabLabelFromIndices(sections, globalIndices) {
const first = globalIndices?.[0]
if (first === undefined || !sections?.[first]) return 'Stream'
const pl = sections[first].planLoc
const t = pl?.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
if (t) return t
const so = pl?.parallelStreamOrderIndex ?? 0
return `Stream ${so + 1}`
}
/** Sortierte Stream-Indizes innerhalb einer parallelen Phase (für Reiter). */
export function streamsForParallelPhaseOrders(sections, phaseOrderIndex) {
const set = new Set()
const po = Number(phaseOrderIndex) || 0
for (const s of sections || []) {
const L = s?.planLoc
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) {
set.add(L.parallelStreamOrderIndex ?? 0)
}
}
return [...set].sort((a, b) => a - b)
}
/** Globale Abschnitts-Indizes eines Streams. */
export function sectionIndicesForParallelStream(sections, phaseOrderIndex, streamOrderIndex) {
const out = []
const po = Number(phaseOrderIndex) || 0
const so = Number(streamOrderIndex) || 0
;(sections || []).forEach((s, i) => {
const L = s?.planLoc
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po && (L.parallelStreamOrderIndex ?? 0) === so) {
out.push(i)
}
})
return out
}
/** Abschnitte an den angegebenen globalen Indizes entfernen (mindestens ein Abschnitt bleibt). */
export function reorderWithoutIndices(prev, removeGlobalIndices) {
const set = new Set(removeGlobalIndices || [])
const next = (prev || []).filter((_, i) => !set.has(i))
return next.length ? next : [defaultSection()]
}
/**
* Ob in den Abschnitten eines Stream-Buckets planerisch etwas steht (Übungen, Text-Anmerkungen).
* Trennlinien-Marker (---) zählen nicht als Inhalt.
*/
export function parallelStreamBucketHasContent(sections, globalIndices, separatorBody = '---') {
for (const gi of globalIndices || []) {
const sec = (sections || [])[gi]
if (!sec) continue
for (const it of sec.items || []) {
if ((it.item_type || '') === 'note') {
const b = (it.note_body || '').trim()
if (b && b !== separatorBody) return true
} else {
if (it.exercise_id) return true
if ((it.exercise_title || '').trim()) return true
}
}
}
return false
}
/** Parallele Phase auflösen: alle Abschnitte dieser Phase werden Ganzgruppe (gleicher phaseOrderIndex). */
export function dissolveParallelPhaseToWholeGroup(sections, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
return (sections || []).map((s) => {
const L = s?.planLoc
if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s
return {
...s,
planLoc: {
...defaultPlanLocWholeGroup(po),
phaseTitle: L.phaseTitle ?? null,
phaseGuidanceNotes: L.phaseGuidanceNotes ?? null,
},
}
})
}
export function reorderWithinBucketIndices(prev, bucketGlobalIndicesSorted, oldPos, newPos) {
const sortedIdx = [...bucketGlobalIndicesSorted].sort((a, b) => a - b)
if (oldPos === newPos || oldPos < 0 || newPos < 0 || oldPos >= sortedIdx.length || newPos >= sortedIdx.length) {
return prev
}
const values = sortedIdx.map((gi) => prev[gi])
const arr = [...values]
const [x] = arr.splice(oldPos, 1)
arr.splice(newPos, 0, x)
const next = [...prev]
sortedIdx.forEach((gi, k) => {
next[gi] = arr[k]
})
return next
}
function normalizeCatalogMethodProfile(cp) {
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
return {}
@ -156,65 +351,132 @@ function parseOptionalSourceTrainingModuleIdForPayload(v) {
return Number.isFinite(n) && n >= 1 ? n : null
}
function sortByOrderIndex(arr) {
return [...(arr || [])].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
}
/** Katalog-Abschnitt (GET) → Editor-Zeilen inkl. Kombi/Modul-Meta — wird von Legacy `sections` und von `phases` genutzt. */
function formItemsFromApiItems(items) {
return (items || []).map((it) => {
if (it.item_type === 'note') {
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowNote = {
item_type: 'note',
note_body: it.note_body || '',
source_training_module_id: '',
source_module_title: '',
}
if (sm != null) {
rowNote.source_training_module_id = sm
rowNote.source_module_title = (
it.source_module_title ||
it.source_training_module_title ||
''
).trim()
}
return rowNote
}
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
exercise_kind: isCombo ? 'combination' : 'simple',
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
exercise_title: it.exercise_title || '',
variants: [],
planned_duration_min:
it.planned_duration_min !== null && it.planned_duration_min !== undefined
? String(it.planned_duration_min)
: '',
actual_duration_min:
it.actual_duration_min !== null && it.actual_duration_min !== undefined
? String(it.actual_duration_min)
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
...(smEx != null
? {
source_training_module_id: smEx,
source_module_title: (
it.source_module_title ||
it.source_training_module_title ||
''
).trim(),
}
: {}),
}
})
}
/** GET `phases` → flache Editor-Abschnitte mit `planLoc` (für PUT `phases` Roundtrip). */
function normalizePhasesToFormSections(fullUnit) {
const phases = sortByOrderIndex(fullUnit.phases || [])
const out = []
for (const ph of phases) {
const pOi = ph.order_index ?? 0
const pk = String(ph.phase_kind || 'whole_group').toLowerCase().trim()
const basePhaseLoc = {
phaseTitle: ph.title ?? null,
phaseGuidanceNotes: ph.guidance_notes ?? null,
}
if (pk === 'parallel') {
for (const st of sortByOrderIndex(ph.streams || [])) {
const sOi = st.order_index ?? 0
const streamLoc = {
phaseKind: 'parallel',
phaseOrderIndex: pOi,
parallelStreamOrderIndex: sOi,
...basePhaseLoc,
streamTitle: st.title ?? null,
streamNotes: st.notes ?? null,
streamAssignedTrainerProfileIds: st.assigned_trainer_profile_ids ?? null,
}
for (const sec of sortByOrderIndex(st.sections || [])) {
out.push({
title: sec.title,
guidance_notes: sec.guidance_notes || '',
items: formItemsFromApiItems(sec.items),
planLoc: { ...streamLoc },
})
}
}
} else {
const loc = {
phaseKind: 'whole_group',
phaseOrderIndex: pOi,
parallelStreamOrderIndex: null,
...basePhaseLoc,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
for (const sec of sortByOrderIndex(ph.sections || [])) {
out.push({
title: sec.title,
guidance_notes: sec.guidance_notes || '',
items: formItemsFromApiItems(sec.items),
planLoc: { ...loc },
})
}
}
}
return out.length ? out : [defaultSection()]
}
export function normalizeUnitToForm(fullUnit) {
if (Array.isArray(fullUnit?.phases) && fullUnit.phases.length > 0) {
return normalizePhasesToFormSections(fullUnit)
}
if (fullUnit.sections && fullUnit.sections.length) {
return fullUnit.sections.map((sec) => ({
title: sec.title,
guidance_notes: sec.guidance_notes || '',
items: (sec.items || []).map((it) => {
if (it.item_type === 'note') {
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const rowNote = {
item_type: 'note',
note_body: it.note_body || '',
source_training_module_id: '',
source_module_title: '',
}
if (sm != null) {
rowNote.source_training_module_id = sm
rowNote.source_module_title = (
it.source_module_title ||
it.source_training_module_title ||
''
).trim()
}
return rowNote
}
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
const isCombo = ek === 'combination'
return {
item_type: 'exercise',
exercise_id: it.exercise_id,
exercise_kind: isCombo ? 'combination' : 'simple',
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
exercise_title: it.exercise_title || '',
variants: [],
planned_duration_min:
it.planned_duration_min !== null && it.planned_duration_min !== undefined
? String(it.planned_duration_min)
: '',
actual_duration_min:
it.actual_duration_min !== null && it.actual_duration_min !== undefined
? String(it.actual_duration_min)
: '',
notes: it.notes ?? '',
modifications: it.modifications ?? '',
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
...(smEx != null
? {
source_training_module_id: smEx,
source_module_title: (
it.source_module_title ||
it.source_training_module_title ||
''
).trim(),
}
: {}),
}
}),
items: formItemsFromApiItems(sec.items),
}))
}
if (fullUnit.exercises && fullUnit.exercises.length) {
@ -398,9 +660,9 @@ export function parseMin(v) {
return Number.isFinite(n) ? n : null
}
export function buildSectionsPayload(sections) {
return sections.map((sec, si) => ({
order_index: si,
export function buildOneSectionPayload(sec, orderIndex) {
return {
order_index: orderIndex,
title: (sec.title || '').trim() || 'Abschnitt',
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
items: (sec.items || [])
@ -445,7 +707,431 @@ export function buildSectionsPayload(sections) {
return rowEx
})
.filter(Boolean),
}))
}
}
/** PUT /api/training-units: Legacy `sections` (eine whole_group-Phase) wenn kein `planLoc` gesetzt ist. */
export function buildSectionsPayload(sections) {
return sections.map((sec, si) => buildOneSectionPayload(sec, si))
}
function stripPlanLoc(sec) {
if (!sec || typeof sec !== 'object') return sec
const { planLoc: _pl, ...rest } = sec
return rest
}
export function inheritPlanLocForPhasedSave(sections) {
let prev = {
phaseKind: 'whole_group',
phaseOrderIndex: 0,
parallelStreamOrderIndex: null,
phaseTitle: null,
phaseGuidanceNotes: null,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
return (sections || []).map((s) => {
if (s?.planLoc && s.planLoc.phaseKind) {
prev = { ...s.planLoc }
return { ...s, planLoc: prev }
}
return { ...s, planLoc: { ...prev } }
})
}
/** Phasen-„Runs“ in der flachen Abschnittsliste (Reihenfolge wie beim Speichern). */
export function phaseRunsFromSections(sections) {
const norm = inheritPlanLocForPhasedSave(sections)
const runs = []
let i = 0
while (i < norm.length) {
const loc0 = norm[i]?.planLoc
if (!loc0?.phaseKind) {
i += 1
continue
}
const pOi = loc0.phaseOrderIndex ?? 0
const pk = loc0.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
const start = i
while (i < norm.length) {
const L = norm[i]?.planLoc
if (!L?.phaseKind) break
const pk2 = L.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
if ((L.phaseOrderIndex ?? 0) !== pOi || pk2 !== pk) break
i += 1
}
runs.push({ phaseKind: pk, phaseOrderIndex: pOi, start, end: i })
}
return runs
}
/** Vertauscht zwei unmittelbar benachbarte Runs (upperRunIndex = erste der beiden). */
export function swapAdjacentPhaseRuns(prev, upperRunIndex) {
const runs = phaseRunsFromSections(prev)
const a = upperRunIndex
const b = upperRunIndex + 1
if (a < 0 || b >= runs.length) return prev
const rgA = runs[a]
const rgB = runs[b]
const head = prev.slice(0, rgA.start)
const blA = prev.slice(rgA.start, rgA.end)
const blB = prev.slice(rgB.start, rgB.end)
const tail = prev.slice(rgB.end)
return [...head, ...blB, ...blA, ...tail]
}
export function movePhaseRunUpByPhaseOrder(prev, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const runs = phaseRunsFromSections(prev)
const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po)
if (rIdx <= 0) return prev
return swapAdjacentPhaseRuns(prev, rIdx - 1)
}
export function movePhaseRunDownByPhaseOrder(prev, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const runs = phaseRunsFromSections(prev)
const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po)
if (rIdx < 0 || rIdx >= runs.length - 1) return prev
return swapAdjacentPhaseRuns(prev, rIdx)
}
/**
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen.
* Regel: Einfügen vor Abschnitt X übernimmt X.planLoc; am Listenende nach Parallel neue Ganzgruppen-Phase.
*/
export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
const arr = [...prev]
if (fromI < 0 || fromI >= arr.length) return prev
const [moved] = arr.splice(fromI, 1)
let insertAt = toBeforeIdx
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
insertAt = Math.max(0, Math.min(insertAt, arr.length))
const below = insertAt < arr.length ? arr[insertAt] : undefined
const above = insertAt > 0 ? arr[insertAt - 1] : undefined
let planLocNext = null
const belowKind = below?.planLoc?.phaseKind
const aboveKind = above?.planLoc?.phaseKind
const movedKind = moved?.planLoc?.phaseKind
const belowPo = below?.planLoc?.phaseOrderIndex ?? 0
const movedPo = moved?.planLoc?.phaseOrderIndex ?? 0
if (belowKind === 'parallel' && aboveKind === 'whole_group') {
if (movedKind !== 'parallel') {
planLocNext = { ...above.planLoc }
} else if (movedPo !== belowPo) {
planLocNext = { ...below.planLoc }
}
} else if (belowKind === 'parallel' && !above) {
if (movedKind !== 'parallel') {
planLocNext = defaultPlanLocWholeGroup(0)
}
}
if (!planLocNext && belowKind) {
planLocNext = { ...below.planLoc }
}
if (!planLocNext && insertAt === arr.length) {
if (!above) {
planLocNext = defaultPlanLocWholeGroup(0)
} else if (above.planLoc?.phaseKind === 'parallel') {
const mx = maxPhaseOrderIndexFromSections(arr)
planLocNext = defaultPlanLocWholeGroup(mx + 1)
} else if (above.planLoc?.phaseKind === 'whole_group') {
planLocNext = { ...above.planLoc }
}
}
if (!planLocNext && above?.planLoc?.phaseKind === 'whole_group') {
planLocNext = { ...above.planLoc }
}
if (!planLocNext && above?.planLoc?.phaseKind === 'parallel') {
const mx = maxPhaseOrderIndexFromSections(arr)
planLocNext = defaultPlanLocWholeGroup(mx + 1)
}
let nextMoved = { ...moved }
if (planLocNext) {
nextMoved = { ...moved, planLoc: planLocNext }
} else {
nextMoved = stripPlanLoc(moved)
}
arr.splice(insertAt, 0, nextMoved)
return arr
}
/**
* Abschnitt direkt vor den Parallel-Lauf setzen (immer Ganzgruppe oberhalb der Split-Phase).
*/
export function reorderSectionBeforeParallelRunAsWholeGroup(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 fg = idxs[0]
const arr = [...prev]
const [moved] = arr.splice(fromI, 1)
let insertAt = fg
if (fromI < insertAt) insertAt -= 1
insertAt = Math.max(0, Math.min(insertAt, arr.length))
const above = insertAt > 0 ? arr[insertAt - 1] : undefined
let planLocNext
if (above?.planLoc?.phaseKind === 'whole_group') {
planLocNext = { ...above.planLoc }
} else if (!above) {
planLocNext = defaultPlanLocWholeGroup(0)
} else {
const mx = maxPhaseOrderIndexFromSections(arr)
planLocNext = defaultPlanLocWholeGroup(mx + 1)
}
arr.splice(insertAt, 0, { ...moved, planLoc: planLocNext })
return arr
}
/** Abschnitt als neuen ersten Eintrag der Parallel-Phase (gleiche Phasen-/Stream-Metadaten wie bisheriger Kopf). */
export function reorderSectionAsFirstInParallelPhase(prev, fromI, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const idxs = indicesOfParallelPhase(prev, po)
if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev
let fg = idxs[0]
const arr = [...prev]
const headTpl = { ...arr[fg].planLoc }
const [moved] = arr.splice(fromI, 1)
if (fromI < fg) fg -= 1
fg = Math.max(0, Math.min(fg, arr.length))
arr.splice(fg, 0, { ...moved, planLoc: { ...headTpl } })
return arr
}
/** Abschnitt als ersten Eintrag eines parallelen Streams setzen (planLoc wie erster Abschnitt dieses Streams, bzw. leerer Stream wie reorderBlockIntoParallelStreamEnd). */
export function reorderSectionAsFirstInParallelStream(prev, fromI, phaseOrderIndex, streamOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const so = Number(streamOrderIndex) || 0
const len = prev?.length ?? 0
if (fromI < 0 || fromI >= len) return prev
const arr = [...prev]
const [moved] = arr.splice(fromI, 1)
const streamIdx = sectionIndicesForParallelStream(arr, po, so)
let insertAt
let headTpl
let skipFromIAdjust = false
if (streamIdx.length) {
const first = Math.min(...streamIdx)
headTpl = { ...arr[first].planLoc }
insertAt = first
} else {
const phaseIdx = indicesOfParallelPhase(arr, po)
if (!phaseIdx.length) {
const ml = moved?.planLoc
if (ml?.phaseKind !== 'parallel' || (ml.phaseOrderIndex ?? 0) !== po) return prev
headTpl = {
...ml,
parallelStreamOrderIndex: so,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
insertAt = Math.min(fromI, arr.length)
skipFromIAdjust = true
} else {
const ref = arr[phaseIdx[phaseIdx.length - 1]]
headTpl = {
...ref.planLoc,
parallelStreamOrderIndex: so,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
insertAt = phaseIdx[phaseIdx.length - 1] + 1
}
}
if (!skipFromIAdjust && fromI < insertAt) insertAt -= 1
insertAt = Math.max(0, Math.min(insertAt, arr.length))
arr.splice(insertAt, 0, { ...moved, planLoc: { ...headTpl } })
return arr
}
/**
* Abschnitt ans Ende eines parallelen Streams setzen (planLoc wie dieser Stream).
* Leerer Stream: Einfügen hinter den letzten Abschnitt der zugehörigen parallelen Phase, planLoc vom Referenz-Abschnitt mit angepasstem streamIndex.
*/
export function reorderBlockIntoParallelStreamEnd(prev, fromI, phaseOrderIndex, streamOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const so = Number(streamOrderIndex) || 0
const len = prev?.length ?? 0
if (fromI < 0 || fromI >= len) return prev
const arr = [...prev]
const [moved] = arr.splice(fromI, 1)
const streamIdx = sectionIndicesForParallelStream(arr, po, so)
let insertAt
let planLocTemplate
if (streamIdx.length) {
const last = Math.max(...streamIdx)
planLocTemplate = { ...arr[last].planLoc }
insertAt = last + 1
} else {
const phaseIdx = indicesOfParallelPhase(arr, po)
if (!phaseIdx.length) return prev
const ref = arr[phaseIdx[phaseIdx.length - 1]]
planLocTemplate = {
...ref.planLoc,
parallelStreamOrderIndex: so,
streamTitle: null,
streamNotes: null,
streamAssignedTrainerProfileIds: null,
}
insertAt = phaseIdx[phaseIdx.length - 1] + 1
}
insertAt = Math.max(0, Math.min(insertAt, arr.length))
arr.splice(insertAt, 0, { ...moved, planLoc: { ...planLocTemplate } })
return arr
}
/** Globales insertBeforeIndex, um einen Abschnitt hinter den letzten des Streams einzufügen (z. B. Rahmen-Slots). */
export function globalInsertBeforeIndexForParallelStreamEnd(sections, phaseOrderIndex, streamOrderIndex) {
const arr = sections || []
const po = Number(phaseOrderIndex) || 0
const so = Number(streamOrderIndex) || 0
const streamIdx = sectionIndicesForParallelStream(arr, po, so)
if (streamIdx.length) return Math.max(...streamIdx) + 1
const phaseIdx = indicesOfParallelPhase(arr, po)
if (!phaseIdx.length) return arr.length
return Math.max(...phaseIdx) + 1
}
/** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */
export function indicesOfParallelPhase(sections, phaseOrderIndex) {
const po = Number(phaseOrderIndex) || 0
const out = []
;(sections || []).forEach((s, i) => {
const L = s?.planLoc
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) out.push(i)
})
return out.sort((a, b) => a - b)
}
/** Gesamten Parallel-Block an neue Position (insertBefore globale Liste) schieben. */
export function moveParallelPhaseRunToInsertBefore(prev, phaseOrderIndex, toBeforeIdx) {
const po = Number(phaseOrderIndex) || 0
const indices = indicesOfParallelPhase(prev, po)
if (!indices.length) return prev
const indexSet = new Set(indices)
const blocks = indices.map((i) => prev[i])
const without = prev.filter((_, i) => !indexSet.has(i))
let ins = toBeforeIdx
for (const i of indices) {
if (i < toBeforeIdx) ins -= 1
}
ins = Math.max(0, Math.min(ins, without.length))
return [...without.slice(0, ins), ...blocks, ...without.slice(ins)]
}
/**
* Nach Drag&Drop: wenn aus einer Parallelphase noch 1 Stream übrig ist (vorher 2), Rückfrage wie beim Stream-Löschen.
*/
export function afterSectionReorderParallelGuard(prev, next) {
const seenPo = new Set()
for (const s of prev || []) {
const L = s?.planLoc
if (L?.phaseKind !== 'parallel') continue
seenPo.add(L.phaseOrderIndex ?? 0)
}
let out = next
for (const po of seenPo) {
const prevN = streamsForParallelPhaseOrders(prev, po).length
if (prevN < 2) continue
const nowN = streamsForParallelPhaseOrders(out, po).length
if (nowN <= 1) {
if (
window.confirm(
'In dieser parallelen Phase ist nur noch eine Gruppe übrig. Parallelaufbau auflösen und alle zugehörigen Abschnitte als gemeinsame Ganzgruppen-Phase führen?'
)
) {
out = dissolveParallelPhaseToWholeGroup(out, po)
}
}
}
return out
}
function buildPhasesPayloadFromFlat(sections) {
const norm = inheritPlanLocForPhasedSave(sections)
const phases = []
let i = 0
while (i < norm.length) {
const loc0 = norm[i].planLoc
const pOi = loc0.phaseOrderIndex ?? 0
const pk = loc0.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
const run = []
while (i < norm.length) {
const L = norm[i].planLoc
const pk2 = L.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
if ((L.phaseOrderIndex ?? 0) !== pOi || pk2 !== pk) break
run.push(norm[i])
i += 1
}
const head = run[0].planLoc
if (pk === 'whole_group') {
phases.push({
phase_kind: 'whole_group',
order_index: pOi,
title: head.phaseTitle ?? null,
guidance_notes: head.phaseGuidanceNotes ?? null,
sections: run.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)),
})
} else {
const byStream = new Map()
for (const s of run) {
const soi = s.planLoc.parallelStreamOrderIndex ?? 0
if (!byStream.has(soi)) byStream.set(soi, [])
byStream.get(soi).push(s)
}
const streamOrder = [...byStream.keys()].sort((a, b) => a - b)
const streams = streamOrder.map((soi) => {
const bucket = byStream.get(soi)
const h = bucket[0].planLoc
const st = {
order_index: soi,
title: h.streamTitle ?? null,
notes: h.streamNotes ?? null,
sections: bucket.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)),
}
const asst = h.streamAssignedTrainerProfileIds
if (asst !== null && asst !== undefined) st.assigned_trainer_profile_ids = asst
return st
})
phases.push({
phase_kind: 'parallel',
order_index: pOi,
title: head.phaseTitle ?? null,
guidance_notes: head.phaseGuidanceNotes ?? null,
streams,
})
}
}
return { phases }
}
/**
* Speichern einer Einheit: flache `sections` oder verschachtelte `phases`, sobald `planLoc` gesetzt ist.
*/
export function buildPlanPayloadForSave(sections) {
const list = Array.isArray(sections) ? sections : []
const anyPhased = list.some((s) => s && s.planLoc && s.planLoc.phaseKind)
if (!anyPhased) {
return { sections: buildSectionsPayload(list) }
}
return buildPhasesPayloadFromFlat(list)
}
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */

View File

@ -1,4 +1,8 @@
{
"status": "passed",
"failedTests": []
"status": "failed",
"failedTests": [
"d6ae548bbe32e0652471-c2435d34f500841a9fcc",
"d6ae548bbe32e0652471-6495823d1677ce34da5c",
"d6ae548bbe32e0652471-b581d2777c999619d7af"
]
}

View File

@ -0,0 +1,137 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: dev-smoke-test.spec.js >> 11. Übungsliste: Massenauswahl zeigt Bulk-Toolbar
- Location: tests\dev-smoke-test.spec.js:253:1
# Error details
```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
Call log:
- navigating to "http://127.0.0.1:3098/", waiting until "load"
```
# Test source
```ts
1 | const { test, expect } = require('@playwright/test');
2 |
3 | const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
4 | const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
5 |
6 | /** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
7 | async function submitLoginForm(page) {
8 | await page.getByRole('button', { name: 'Anmelden' }).click();
9 | }
10 |
11 | async function login(page) {
> 12 | await page.goto('/');
| ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
13 | await page.waitForLoadState('networkidle');
14 |
15 | // Warte bis Login-Seite geladen ist
16 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
17 |
18 | await page.fill('input[type="email"]', TEST_EMAIL);
19 | await page.fill('input[type="password"]', TEST_PASSWORD);
20 | await submitLoginForm(page);
21 | // Wait until auth is complete: URL leaves /login and Dashboard is rendered
22 | await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
23 | await page.waitForLoadState('networkidle');
24 | }
25 |
26 | test('1. Login funktioniert', async ({ page }) => {
27 | await page.goto('/');
28 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
29 | await page.fill('input[type="email"]', TEST_EMAIL);
30 | await page.fill('input[type="password"]', TEST_PASSWORD);
31 | await submitLoginForm(page);
32 | await page.waitForLoadState('networkidle');
33 |
34 | // Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
35 | const loginButton = page.locator('button:has-text("Login")');
36 | await expect(loginButton).toHaveCount(0, { timeout: 10000 });
37 |
38 | await page.screenshot({ path: 'screenshots/01-nach-login.png' });
39 | console.log('✓ Login erfolgreich');
40 | });
41 |
42 | test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
43 | await login(page);
44 |
45 | // Warte bis Spinner verschwunden
46 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
47 |
48 | // Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
49 | const main = page.locator('.app-main');
50 | await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
51 | timeout: 5000,
52 | });
53 | await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
54 |
55 | await page.screenshot({ path: 'screenshots/02-dashboard.png' });
56 | console.log('✓ Dashboard OK');
57 | });
58 |
59 | test('3. Navigation zu Übungen', async ({ page }) => {
60 | await login(page);
61 |
62 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
63 |
64 | // Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
65 | await page.setViewportSize({ width: 390, height: 844 });
66 |
67 | // Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
68 | const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
69 | await Promise.all([
70 | page.waitForURL(
71 | (u) => {
72 | const path = u.pathname.replace(/\/$/, '') || '/'
73 | return path === '/exercises'
74 | },
75 | { timeout: 15000 },
76 | ),
77 | exercisesLink.click(),
78 | ]);
79 | await page.waitForLoadState('networkidle');
80 |
81 | // Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
82 | const main = page.locator('.app-main');
83 | await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
84 | timeout: 10000,
85 | });
86 |
87 | await page.screenshot({ path: 'screenshots/03-uebungen.png' });
88 | console.log('✓ Übungen-Seite erreichbar');
89 | });
90 |
91 | test('4. Navigation zu Vereine', async ({ page }) => {
92 | await login(page);
93 | await page.setViewportSize({ width: 390, height: 844 });
94 |
95 | await page.locator('.bottom-nav a[href="/clubs"]').click();
96 | await page.waitForLoadState('networkidle');
97 |
98 | // ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
99 | // Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
100 | await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
101 | await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
102 | timeout: 5000,
103 | });
104 |
105 | await page.screenshot({ path: 'screenshots/04-vereine.png' });
106 | console.log('✓ Vereine-Seite erreichbar');
107 | });
108 |
109 | test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
110 | // Desktop-Viewport
111 | await page.setViewportSize({ width: 1280, height: 800 });
112 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,137 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: dev-smoke-test.spec.js >> 12. Trainingsplanung: Seite lädt mit Überschrift
- Location: tests\dev-smoke-test.spec.js:275:1
# Error details
```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
Call log:
- navigating to "http://127.0.0.1:3098/", waiting until "load"
```
# Test source
```ts
1 | const { test, expect } = require('@playwright/test');
2 |
3 | const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
4 | const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
5 |
6 | /** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
7 | async function submitLoginForm(page) {
8 | await page.getByRole('button', { name: 'Anmelden' }).click();
9 | }
10 |
11 | async function login(page) {
> 12 | await page.goto('/');
| ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
13 | await page.waitForLoadState('networkidle');
14 |
15 | // Warte bis Login-Seite geladen ist
16 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
17 |
18 | await page.fill('input[type="email"]', TEST_EMAIL);
19 | await page.fill('input[type="password"]', TEST_PASSWORD);
20 | await submitLoginForm(page);
21 | // Wait until auth is complete: URL leaves /login and Dashboard is rendered
22 | await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
23 | await page.waitForLoadState('networkidle');
24 | }
25 |
26 | test('1. Login funktioniert', async ({ page }) => {
27 | await page.goto('/');
28 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
29 | await page.fill('input[type="email"]', TEST_EMAIL);
30 | await page.fill('input[type="password"]', TEST_PASSWORD);
31 | await submitLoginForm(page);
32 | await page.waitForLoadState('networkidle');
33 |
34 | // Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
35 | const loginButton = page.locator('button:has-text("Login")');
36 | await expect(loginButton).toHaveCount(0, { timeout: 10000 });
37 |
38 | await page.screenshot({ path: 'screenshots/01-nach-login.png' });
39 | console.log('✓ Login erfolgreich');
40 | });
41 |
42 | test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
43 | await login(page);
44 |
45 | // Warte bis Spinner verschwunden
46 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
47 |
48 | // Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
49 | const main = page.locator('.app-main');
50 | await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
51 | timeout: 5000,
52 | });
53 | await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
54 |
55 | await page.screenshot({ path: 'screenshots/02-dashboard.png' });
56 | console.log('✓ Dashboard OK');
57 | });
58 |
59 | test('3. Navigation zu Übungen', async ({ page }) => {
60 | await login(page);
61 |
62 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
63 |
64 | // Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
65 | await page.setViewportSize({ width: 390, height: 844 });
66 |
67 | // Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
68 | const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
69 | await Promise.all([
70 | page.waitForURL(
71 | (u) => {
72 | const path = u.pathname.replace(/\/$/, '') || '/'
73 | return path === '/exercises'
74 | },
75 | { timeout: 15000 },
76 | ),
77 | exercisesLink.click(),
78 | ]);
79 | await page.waitForLoadState('networkidle');
80 |
81 | // Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
82 | const main = page.locator('.app-main');
83 | await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
84 | timeout: 10000,
85 | });
86 |
87 | await page.screenshot({ path: 'screenshots/03-uebungen.png' });
88 | console.log('✓ Übungen-Seite erreichbar');
89 | });
90 |
91 | test('4. Navigation zu Vereine', async ({ page }) => {
92 | await login(page);
93 | await page.setViewportSize({ width: 390, height: 844 });
94 |
95 | await page.locator('.bottom-nav a[href="/clubs"]').click();
96 | await page.waitForLoadState('networkidle');
97 |
98 | // ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
99 | // Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
100 | await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
101 | await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
102 | timeout: 5000,
103 | });
104 |
105 | await page.screenshot({ path: 'screenshots/04-vereine.png' });
106 | console.log('✓ Vereine-Seite erreichbar');
107 | });
108 |
109 | test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
110 | // Desktop-Viewport
111 | await page.setViewportSize({ width: 1280, height: 800 });
112 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,137 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: dev-smoke-test.spec.js >> 8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)
- Location: tests\dev-smoke-test.spec.js:165:1
# Error details
```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
Call log:
- navigating to "http://127.0.0.1:3098/", waiting until "load"
```
# Test source
```ts
1 | const { test, expect } = require('@playwright/test');
2 |
3 | const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
4 | const TEST_PASSWORD = process.env.TEST_PASSWORD || '12345678';
5 |
6 | /** Primärer Submit auf der Login-Seite (nicht den Tab "Login" vs. "Registrieren"). */
7 | async function submitLoginForm(page) {
8 | await page.getByRole('button', { name: 'Anmelden' }).click();
9 | }
10 |
11 | async function login(page) {
> 12 | await page.goto('/');
| ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3098/
13 | await page.waitForLoadState('networkidle');
14 |
15 | // Warte bis Login-Seite geladen ist
16 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
17 |
18 | await page.fill('input[type="email"]', TEST_EMAIL);
19 | await page.fill('input[type="password"]', TEST_PASSWORD);
20 | await submitLoginForm(page);
21 | // Wait until auth is complete: URL leaves /login and Dashboard is rendered
22 | await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
23 | await page.waitForLoadState('networkidle');
24 | }
25 |
26 | test('1. Login funktioniert', async ({ page }) => {
27 | await page.goto('/');
28 | await page.waitForSelector('input[type="email"]', { timeout: 10000 });
29 | await page.fill('input[type="email"]', TEST_EMAIL);
30 | await page.fill('input[type="password"]', TEST_PASSWORD);
31 | await submitLoginForm(page);
32 | await page.waitForLoadState('networkidle');
33 |
34 | // Nach Login soll der Tab "Login" (Moduswahl) verschwinden — nicht der Submit "Anmelden"
35 | const loginButton = page.locator('button:has-text("Login")');
36 | await expect(loginButton).toHaveCount(0, { timeout: 10000 });
37 |
38 | await page.screenshot({ path: 'screenshots/01-nach-login.png' });
39 | console.log('✓ Login erfolgreich');
40 | });
41 |
42 | test('2. Dashboard lädt ohne Fehler', async ({ page }) => {
43 | await login(page);
44 |
45 | // Warte bis Spinner verschwunden
46 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
47 |
48 | // Dashboard: h1 „Dashboard“ + Begrüßungstext (nicht mehr „Willkommen bei Shinkan“ als Überschrift)
49 | const main = page.locator('.app-main');
50 | await expect(main.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeVisible({
51 | timeout: 5000,
52 | });
53 | await expect(main.getByText(/Shinkan unterstützt dich/i)).toBeVisible({ timeout: 5000 });
54 |
55 | await page.screenshot({ path: 'screenshots/02-dashboard.png' });
56 | console.log('✓ Dashboard OK');
57 | });
58 |
59 | test('3. Navigation zu Übungen', async ({ page }) => {
60 | await login(page);
61 |
62 | await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
63 |
64 | // Bei Viewport ≥1024px ist .bottom-nav versteckt — Mobile garantieren wie in playwright.config.js
65 | await page.setViewportSize({ width: 390, height: 844 });
66 |
67 | // Bottom-Nav: Navigation und URL gemeinsam abwarten (vermeidet race mit networkidle)
68 | const exercisesLink = page.locator('.bottom-nav').getByRole('link', { name: /Übungen/i });
69 | await Promise.all([
70 | page.waitForURL(
71 | (u) => {
72 | const path = u.pathname.replace(/\/$/, '') || '/'
73 | return path === '/exercises'
74 | },
75 | { timeout: 15000 },
76 | ),
77 | exercisesLink.click(),
78 | ]);
79 | await page.waitForLoadState('networkidle');
80 |
81 | // Wie Test 4 (Vereine): eine eindeutige h1 — nicht h1,h2-Kombi (Strict Mode + mehrere Treffer)
82 | const main = page.locator('.app-main');
83 | await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
84 | timeout: 10000,
85 | });
86 |
87 | await page.screenshot({ path: 'screenshots/03-uebungen.png' });
88 | console.log('✓ Übungen-Seite erreichbar');
89 | });
90 |
91 | test('4. Navigation zu Vereine', async ({ page }) => {
92 | await login(page);
93 | await page.setViewportSize({ width: 390, height: 844 });
94 |
95 | await page.locator('.bottom-nav a[href="/clubs"]').click();
96 | await page.waitForLoadState('networkidle');
97 |
98 | // ClubsPage: <h1>Vereinsverwaltung</h1> + Tab <h2>Vereine</h2> → ein kombinierter
99 | // Selektor löst 2 Treffer aus (Playwright strict mode). URL + primäre Überschrift reichen.
100 | await expect(page).toHaveURL(/\/clubs\/?$/, { timeout: 5000 });
101 | await expect(page.getByRole('heading', { level: 1, name: /Vereinsverwaltung/i })).toBeVisible({
102 | timeout: 5000,
103 | });
104 |
105 | await page.screenshot({ path: 'screenshots/04-vereine.png' });
106 | console.log('✓ Vereine-Seite erreichbar');
107 | });
108 |
109 | test('5. Desktop-Sidebar sichtbar (Desktop)', async ({ page }) => {
110 | // Desktop-Viewport
111 | await page.setViewportSize({ width: 1280, height: 800 });
112 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -184,6 +184,17 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async
page.on('request', onRequest);
const kpisStatuses = [];
const onResponse = (response) => {
try {
const u = response.url();
if (u.includes('/api/dashboard/kpis')) kpisStatuses.push(response.status());
} catch {
/* ignore */
}
};
page.on('response', onResponse);
try {
await page.reload({ waitUntil: 'networkidle' });
@ -199,11 +210,13 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async
expect(profilesMe).toBe(1);
expect(trainingUnits).toBe(0);
expect(dashboardKpis).toBe(1);
expect(kpisStatuses.some((s) => s === 200)).toBe(true);
} finally {
page.off('request', onRequest);
page.off('response', onResponse);
}
console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis');
console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis (HTTP 200)');
});
test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', async ({ page }) => {
@ -237,6 +250,61 @@ test('10. Übungsliste: Filter-Dialog öffnet und schließt', async ({ page }) =
console.log('✓ Übungsliste: Filter-Dialog Smoke');
});
test('11. Übungsliste: Massenauswahl zeigt Bulk-Toolbar', async ({ page }, testInfo) => {
await login(page);
await page.goto('/exercises', { waitUntil: 'networkidle' });
const main = page.locator('.app-main');
await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
timeout: 15000,
});
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
const grid = main.getByTestId('exercises-list-grid');
const checks = grid.locator('input[type="checkbox"]');
const n = await checks.count();
if (n < 1) {
testInfo.skip(true, 'Keine Übung in der Liste (Bulk-Smoke braucht mind. einen Treffer)');
return;
}
await checks.first().click();
const bulk = main.getByTestId('exercise-list-bulk-toolbar');
await expect(bulk).toBeVisible({ timeout: 5000 });
await expect(bulk.getByRole('button', { name: /Massenänderung/i })).toBeVisible();
console.log('✓ Übungsliste: Bulk-Toolbar nach Auswahl');
});
test('12. Trainingsplanung: Seite lädt mit Überschrift', async ({ page }) => {
await login(page);
await page.goto('/planning', { waitUntil: 'networkidle' });
const main = page.locator('.app-main');
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 });
await expect(main.getByRole('heading', { level: 1, name: 'Trainingsplanung' })).toBeVisible({
timeout: 20000,
});
console.log('✓ Trainingsplanung: Grundansicht');
});
test('13. Trainingsplanung: Rahmen-Import-Dialog öffnet und schließt', async ({ page }, testInfo) => {
await login(page);
await page.goto('/planning', { waitUntil: 'networkidle' });
const main = page.locator('.app-main');
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 });
await expect(main.getByRole('heading', { level: 1, name: 'Trainingsplanung' })).toBeVisible({
timeout: 20000,
});
const openBtn = main.getByRole('button', { name: /Aus Rahmen übernehmen/i });
if (await openBtn.isDisabled()) {
testInfo.skip(true, 'Keine Trainingsgruppe — Button bleibt deaktiviert');
return;
}
await openBtn.click();
const dlg = page.getByTestId('planning-framework-import-modal');
await expect(dlg).toBeVisible({ timeout: 10000 });
await expect(dlg.getByRole('heading', { name: /Sessions aus Rahmen übernehmen/i })).toBeVisible();
await dlg.getByRole('button', { name: 'Abbrechen' }).click();
await expect(dlg).toHaveCount(0);
console.log('✓ Trainingsplanung: Rahmen-Import-Dialog Smoke');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);