Merge pull request 'Parlellsession- Plan' (#35) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s

Reviewed-on: #35
This commit is contained in:
Lars 2026-05-15 22:04:52 +02:00
commit bd9cfaa6e4
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`. **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) ## 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] types: [completed]
jobs: 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: pytest-backend:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -39,7 +39,7 @@ jobs:
cd /app && cd /app &&
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py && ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
python scripts/security_release_checks.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: 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 addopts = -q --tb=short
markers = markers =
smoke: Schnelle Kern-Regression. 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). 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 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]]: def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]:
"""None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung.""" """None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung."""
if raw is None: if raw is None:
@ -522,21 +610,52 @@ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
return i 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( cur.execute(
""" """
SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id SELECT id FROM training_unit_phases
FROM training_unit_sections WHERE training_unit_id = %s AND phase_kind = 'whole_group' AND order_index = %s
WHERE training_unit_id = %s LIMIT 1
ORDER BY order_index
""", """,
(unit_id,), (unit_id, order_index),
) )
secs = [] row = cur.fetchone()
for sec_row in cur.fetchall(): if row:
sec = r2d(sec_row) return int(row["id"])
cur.execute( 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.*, SELECT tusi.*,
e.title AS exercise_title, e.title AS exercise_title,
e.exercise_kind AS exercise_kind, 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 LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
WHERE tusi.section_id = %s WHERE tusi.section_id = %s
ORDER BY tusi.order_index ORDER BY tusi.order_index
""", """
(sec["id"],),
)
sec["items"] = [r2d(r) for r in cur.fetchall()] def _hydrate_section_item_combination_slots(cur, it: Dict[str, Any]) -> None:
for it in sec["items"]: """Setzt `combination_slots` für KombiÜbungen; sonst leere Liste."""
if it.get("item_type") != "exercise": if it.get("item_type") != "exercise":
continue return
cmp_raw = it.get("catalog_method_profile") cmp_raw = it.get("catalog_method_profile")
if not isinstance(cmp_raw, dict): if not isinstance(cmp_raw, dict):
it["catalog_method_profile"] = {} it["catalog_method_profile"] = {}
else: else:
it["catalog_method_profile"] = dict(cmp_raw) it["catalog_method_profile"] = dict(cmp_raw)
ek = str(it.get("exercise_kind") or "simple").strip().lower() ek = str(it.get("exercise_kind") or "simple").strip().lower()
if ek == "combination" and it.get("exercise_id"): if ek == "combination" and it.get("exercise_id"):
try: try:
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"])) it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
except (TypeError, ValueError): except (TypeError, ValueError):
it["combination_slots"] = [] it["combination_slots"] = []
else: else:
it["combination_slots"] = [] 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) secs.append(sec)
return secs return secs
def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]:
"""Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder).""" """Verschachtelte Phasen/Streams/Sektionen für GET (UI kann parallele Sp später nutzen)."""
secs = _fetch_sections(cur, unit_id) 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]] = [] out: List[Dict[str, Any]] = []
for sec in secs: for prow in cur.fetchall():
items_clean: List[Dict[str, Any]] = [] p = r2d(prow)
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)): pk = str(p.get("phase_kind") or "").strip().lower()
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note") if pk == "whole_group":
oix = it.get("order_index") cur.execute(
if itype == "note": """
note_item = { SELECT id, training_unit_id, order_index, title, guidance_notes,
"item_type": "note", source_template_section_id, phase_id, parallel_stream_id
"order_index": oix, FROM training_unit_sections
"note_body": it.get("note_body") or "", WHERE phase_id = %s
} ORDER BY order_index
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id")) """,
if sm is not None: (p["id"],),
note_item["source_training_module_id"] = sm )
items_clean.append(note_item) secs: List[Dict[str, Any]] = []
continue for srow in cur.fetchall():
if itype != "exercise" or not it.get("exercise_id"): sec = r2d(srow)
continue sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
ex_item = { secs.append(sec)
"item_type": "exercise", 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, "order_index": oix,
"exercise_id": it["exercise_id"], "note_body": it.get("note_body") or "",
"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")) sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
if sm is not None: if sm is not None:
ex_item["source_training_module_id"] = sm note_item["source_training_module_id"] = sm
items_clean.append(ex_item) items_clean.append(note_item)
out.append( continue
{ if itype != "exercise" or not it.get("exercise_id"):
"title": sec.get("title"), continue
"order_index": sec.get("order_index"), ex_item = {
"guidance_notes": sec.get("guidance_notes"), "item_type": "exercise",
"items": items_clean, "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 return out
@ -637,6 +882,7 @@ def _copy_blueprint_into_scheduled_unit(
planned_date: str, planned_date: str,
profile_id: int, profile_id: int,
origin_framework_slot_id: Optional[int], origin_framework_slot_id: Optional[int],
role: str,
) -> int: ) -> int:
cur.execute( cur.execute(
""" """
@ -692,8 +938,8 @@ def _copy_blueprint_into_scheduled_unit(
if not row: if not row:
raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden") raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden")
nu = row["id"] nu = row["id"]
cloned = _sections_clone_payload(cur, blueprint_unit_id) cloned = _phases_clone_payload(cur, blueprint_unit_id)
_replace_unit_sections(cur, nu, cloned) _replace_unit_phases(cur, nu, cloned, profile_id, role, profile_id)
return nu 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]: 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"] uid = unit["id"]
unit["phases"] = _fetch_phases_nested(cur, uid)
unit["sections"] = _fetch_sections(cur, uid) unit["sections"] = _fetch_sections(cur, uid)
_flatten_exercises_from_sections(unit) _flatten_exercises_from_sections(unit)
return unit return unit
def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int: 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( cur.execute(
""" """
SELECT id FROM training_unit_sections SELECT tus.id
WHERE training_unit_id = %s AND order_index = %s 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), (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"]) 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( def _append_copied_module_items_to_section(
cur, cur,
section_id: int, 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]): 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): for si, sec in enumerate(sections_in):
title = (sec.get("title") or "").strip() or "Abschnitt" _insert_one_replacement_section(
order_ix = sec.get("order_index") cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None
if order_ix is None: )
order_ix = si
src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
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( cur.execute(
""" """
INSERT INTO training_unit_sections ( INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes)
training_unit_id, order_index, title, guidance_notes, source_template_section_id VALUES (%s, %s, %s, %s, %s)
) VALUES (%s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
( (
unit_id, unit_id,
order_ix, p_oix,
title, kind,
sec.get("guidance_notes"), ph.get("title"),
src_tsec, ph.get("guidance_notes"),
), ),
) )
sid = cur.fetchone()["id"] phase_id = int(cur.fetchone()["id"])
_insert_section_items(cur, sid, sec.get("items")) 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]: 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]): def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
if not exercises_in: if not exercises_in:
return return
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
cur.execute( cur.execute(
""" """
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) INSERT INTO training_unit_sections (
VALUES (%s, 0, %s, NULL) training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes
)
VALUES (%s, %s, NULL, 0, %s, NULL)
RETURNING id RETURNING id
""", """,
(unit_id, "Übungen"), (unit_id, pid, "Übungen"),
) )
sid = cur.fetchone()["id"] sid = cur.fetchone()["id"]
slot = 0 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): 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( cur.execute(
""" """
SELECT id, title, guidance_text SELECT id, title, guidance_text
@ -1072,29 +1536,26 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
(template_id,), (template_id,),
) )
rows = cur.fetchall() rows = cur.fetchall()
for row in rows: for gi, row in enumerate(rows):
r = r2d(row) r = r2d(row)
cur.execute( cur.execute(
""" """
INSERT INTO training_unit_sections ( INSERT INTO training_unit_sections (
training_unit_id, order_index, title, guidance_notes, source_template_section_id training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
) VALUES (%s, ( ) VALUES (%s, %s, NULL, %s, %s, %s, %s)
SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2
WHERE u2.training_unit_id = %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 # Fallback: keine Sektionen in Vorlage → ein leerer Block
if not rows: if not rows:
cur.execute( cur.execute(
""" """
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) INSERT INTO training_unit_sections (
SELECT %s, 0, %s, NULL training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes
WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s) ) 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( def apply_training_module_to_training_unit(
unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context) 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 profile_id = tenant.profile_id
role = tenant.global_role role = tenant.global_role
if not _has_planning_role(role): if not _has_planning_role(role):
@ -1780,12 +2245,44 @@ def apply_training_module_to_training_unit(
if section_order_index < 0: if section_order_index < 0:
raise HTTPException(status_code=400, detail="section_order_index ungültig") 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
unit_row = _training_unit_guard_row(cur, unit_id) unit_row = _training_unit_guard_row(cur, unit_id)
_assert_training_unit_permission(cur, unit_row, profile_id, role) _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) 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) _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) _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, lead_ins,
) )
if assistant_set: if assistant_set:
av_db = None if assistant_val is None else PsycopgJson(assistant_val)
cur.execute( cur.execute(
""" """
INSERT INTO training_units ( 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) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
base_params + (assistant_val,), base_params + (av_db,),
) )
else: else:
cur.execute( cur.execute(
@ -1892,10 +2390,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
unit_id = cur.fetchone()["id"] unit_id = cur.fetchone()["id"]
_assert_single_plan_content_key_create(data)
phases_in = data.get("phases")
sections_in = data.get("sections") sections_in = data.get("sections")
exercises_in = data.get("exercises") 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) _replace_unit_sections(cur, unit_id, sections_in)
elif tpl_id_safe: elif tpl_id_safe:
_instantiate_from_template(cur, unit_id, 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, eff_lead_for_co,
) )
assist_sql = ", assistant_trainer_profile_ids = %s" assist_sql = ", assistant_trainer_profile_ids = %s"
assist_params.append(na) assist_params.append(None if na is None else PsycopgJson(na))
debrief_frag = "" debrief_frag = ""
if "debrief_completed" in data and not is_blueprint: 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", detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request",
) )
_template_access(cur, tid, profile_id, role) _template_access(cur, tid, profile_id, role)
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
cur.execute( cur.execute(
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id) "UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
) )
_instantiate_from_template(cur, unit_id, tid) _instantiate_from_template(cur, unit_id, tid)
content_handled = True 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 []) _replace_unit_sections(cur, unit_id, data["sections"] or [])
elif not content_handled and "exercises" in data: 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 []) _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) _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
conn.commit() conn.commit()
@ -2196,6 +2707,7 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext =
str(planned_date), str(planned_date),
profile_id, profile_id,
slot_id, slot_id,
role,
) )
_promote_private_exercises_used_in_unit(cur, new_id, profile_id, role) _promote_private_exercises_used_in_unit(cur, new_id, profile_id, role)

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from unittest.mock import patch
import pytest import pytest
from fastapi import Query from fastapi import Query
@ -11,6 +12,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from fastapi_param_unwrap import unwrap_query_default from fastapi_param_unwrap import unwrap_query_default
from main import app from main import app
from tenant_context import TenantContext, get_tenant_context
@pytest.fixture @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: def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None:
r = client.get("/api/dashboard/kpis") r = client.get("/api/dashboard/kpis")
assert r.status_code == 401 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 # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.123" APP_VERSION = "0.8.140"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062" DB_SCHEMA_VERSION = "20260515063"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@ -24,7 +24,7 @@ MODULE_VERSIONS = {
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "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_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", "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) "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", "training_modules": "1.0.0",
"import_wiki": "1.0.0", "import_wiki": "1.0.0",
@ -36,6 +36,122 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.123",
"date": "2026-05-13", "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* | | 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) ## 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 | | [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 | | [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 | | [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) | | [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) | | [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 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`**. - **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. - **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**. **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). **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 | | Virtualisierung für die längste produktive Liste | A1, S2 |
| Schwere Imports auf `import()` umziehen (gezielt) | A4 | | 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. **Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
@ -90,13 +93,17 @@
## Phase 4 API-Client Modularisierung ## 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. **Fokus:** Wartbarkeit für viele neue Features.
| Task | Bezug | | Task | Bezug | Status |
|------|--------| |------|-------|--------|
| `frontend/src/api/` anlegen, `request`/`client` zentral | A2 | | `frontend/src/api/client.js` — zentraler HTTP-Client | A2 | erledigt (Welle 1) |
| Facade: bestehende Importe von `utils/api` nicht sofort alle brechen; Migration in Wellen | A2 | | `frontend/src/api/planning.js` — Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, Dashboard-KPIs) | A2 | erledigt (Welle 2) |
| Neue Endpoints nur noch in Domänen-Dateien | S3 | | `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. **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 | | **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert |
| **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen | | **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 | | **M4** | Phase 4 migrationsbereit für alle neuen Features |
| **M5** | Phase 5 für Top-Listen abgeschlossen | | **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); 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 { .tu-sec-drag-grip {
flex-shrink: 0; flex-shrink: 0;
display: inline-flex; display: inline-flex;
@ -6586,6 +6658,21 @@ button.combo-coach-cand-link:hover {
max-width: none !important; max-width: none !important;
padding: 0 !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-section,
.training-run-header { .training-run-header {
break-inside: avoid; 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' /** Routen-Einstieg: Implementierung in `components/exercises/ExercisesListPageRoot.jsx` (Phase-3 Soft-Limit). */
import { Link } from 'react-router-dom' export { default } from '../components/exercises/ExercisesListPageRoot'
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

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 React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExerciseFullContent from '../components/ExerciseFullContent' import ExerciseFullContent from '../components/ExerciseFullContent'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import { import {
COACH_ENTRY_BRANCH_GATE,
buildCoachSavePlanPayload,
coachBranchPicksStepStorageSuffix,
coachBranchPicksStorageKey,
coachOutlineGroupsFromTimeline,
coachShouldPromptSplitRejoin,
coachShouldPromptSplitRejoinTransition,
durationOverridesMapFromDeltas,
findCoachTimelineJumpIndexForPhase,
flattenPlanTimeline, flattenPlanTimeline,
itemStableKey, itemStableKey,
sectionsToPutPayload, listCoachStreamFocusOptions,
mergeCoachBranchPicksWithUrlFocus,
normalizeCoachBranchPicks,
summarizeTimelineEntry, summarizeTimelineEntry,
} from '../utils/trainingPlanUtils' } from '../utils/trainingPlanUtils'
function storageStepKey(unitId) { function storageStepKey(unitId, mergedPicks) {
return `sj_coach_step_${unitId}` 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) { function storageDeltasKey(unitId) {
@ -54,14 +77,16 @@ function CoachControlsBand({
showJumpToTimerOwnerRow = true, showJumpToTimerOwnerRow = true,
onJumpToTimerOwner, onJumpToTimerOwner,
timerOwnerLabelIndex, timerOwnerLabelIndex,
branchGateMode = false,
}) { }) {
const disPrev = step <= 0 const disPrev = step <= 0
const disNext = step >= timelineLength - 1 const disNext = branchGateMode || step >= timelineLength - 1
const alive = runStartAt != null || pausedAccumMs > 0 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 istLabelMin = roundedMinForApply == null ? '—' : String(roundedMinForApply)
const doneLabel = const doneLabel = branchGateMode
timelineLength <= 1 ? 'Zuerst Gruppe wählen'
: timelineLength <= 1
? 'Nachbereitung öffnen' ? 'Nachbereitung öffnen'
: isLastCoachStep : isLastCoachStep
? 'Nachbereitung & Ist-Zeit' ? 'Nachbereitung & Ist-Zeit'
@ -105,6 +130,7 @@ function CoachControlsBand({
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }} style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }}
disabled={branchGateMode}
onClick={onTimerStart} onClick={onTimerStart}
> >
Start{alive ? ` · ${clockStr}` : ''} 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}> <button type="button" className="btn btn-secondary" style={{ minHeight: '44px', flex: '0 1 auto', fontWeight: 600 }} disabled={!canApplyForOwner} onClick={onApplyActual}>
Ist ({istLabelMin}&nbsp;Min) Ist ({istLabelMin}&nbsp;Min)
</button> </button>
{alive && ( {alive && !branchGateMode && (
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@ -133,6 +159,7 @@ function CoachControlsBand({
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
style={{ width: '100%', minHeight: '44px', fontWeight: 700, lineHeight: 1.2, padding: '6px 10px', whiteSpace: 'normal', textAlign: 'center' }} style={{ width: '100%', minHeight: '44px', fontWeight: 700, lineHeight: 1.2, padding: '6px 10px', whiteSpace: 'normal', textAlign: 'center' }}
disabled={branchGateMode}
onClick={onDone} onClick={onDone}
> >
{doneLabel} {doneLabel}
@ -155,8 +182,11 @@ function CoachControlsBand({
export default function TrainingCoachPage() { export default function TrainingCoachPage() {
const { unitId } = useParams() const { unitId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const idNum = unitId ? parseInt(unitId, 10) : NaN const idNum = unitId ? parseInt(unitId, 10) : NaN
const coachFocusResetRef = useRef(null)
const [unit, setUnit] = useState(null) const [unit, setUnit] = useState(null)
const [loadError, setLoadError] = useState(null) const [loadError, setLoadError] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -169,6 +199,8 @@ export default function TrainingCoachPage() {
const [coachDebriefPhase, setCoachDebriefPhase] = useState(false) const [coachDebriefPhase, setCoachDebriefPhase] = useState(false)
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
const [branchPicks, setBranchPicks] = useState({})
const [streamChoiceHint, setStreamChoiceHint] = useState(null)
const [deltas, setDeltas] = useState({}) const [deltas, setDeltas] = useState({})
const [runStartAt, setRunStartAt] = useState(null) const [runStartAt, setRunStartAt] = useState(null)
@ -176,6 +208,8 @@ export default function TrainingCoachPage() {
const [timerOwningStep, setTimerOwningStep] = useState(null) const [timerOwningStep, setTimerOwningStep] = useState(null)
const [, setPulse] = useState(0) const [, setPulse] = useState(0)
const [splitRejoinPrompt, setSplitRejoinPrompt] = useState(null)
const [trainerAppend, setTrainerAppend] = useState('') const [trainerAppend, setTrainerAppend] = useState('')
const [saveMarkDone, setSaveMarkDone] = useState(true) const [saveMarkDone, setSaveMarkDone] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -187,12 +221,52 @@ export default function TrainingCoachPage() {
setUnit(u) setUnit(u)
}, [idNum]) }, [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(() => { useEffect(() => {
if (!unitId || Number.isNaN(idNum)) { if (!unitId || Number.isNaN(idNum)) {
setLoadError('Ungültige Trainingseinheit') setLoadError('Ungültige Trainingseinheit')
setLoading(false) setLoading(false)
return return
} }
coachFocusResetRef.current = null
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
setLoading(true) setLoading(true)
@ -200,9 +274,13 @@ export default function TrainingCoachPage() {
try { try {
await reloadUnit() await reloadUnit()
if (cancelled) return if (cancelled) return
let nextBranchPicks = {}
try { try {
const s = parseInt(sessionStorage.getItem(storageStepKey(idNum)), 10) const br = sessionStorage.getItem(coachBranchPicksStorageKey(idNum))
if (!Number.isNaN(s) && s >= 0) setStep(s) if (br) {
const o = JSON.parse(br)
if (o && typeof o === 'object') nextBranchPicks = normalizeCoachBranchPicks(o)
}
} catch { } catch {
/* ignore */ /* ignore */
} }
@ -220,6 +298,10 @@ export default function TrainingCoachPage() {
} catch { } catch {
/* ignore */ /* ignore */
} }
if (!cancelled) {
setBranchPicks(nextBranchPicks)
setStreamChoiceHint(null)
}
} catch (e) { } catch (e) {
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen') if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
} finally { } finally {
@ -232,8 +314,68 @@ export default function TrainingCoachPage() {
}, [unitId, idNum, reloadUnit]) }, [unitId, idNum, reloadUnit])
useEffect(() => { useEffect(() => {
sessionStorage.setItem(storageStepKey(idNum), String(step)) if (!unit) return
}, [idNum, step]) 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(() => { useEffect(() => {
try { try {
@ -257,46 +399,91 @@ export default function TrainingCoachPage() {
return () => clearInterval(iv) return () => clearInterval(iv)
}, [runStartAt]) }, [runStartAt])
const timeline = useMemo(() => flattenPlanTimeline(unit), [unit])
const clampStep = (s, len = timeline.length) => const clampStep = (s, len = timeline.length) =>
Math.max(0, Math.min(s, Math.max(len - 1, 0))) Math.max(0, Math.min(s, Math.max(len - 1, 0)))
const timerReset = useCallback(() => {
setRunStartAt(null)
setPausedAccumMs(0)
setTimerOwningStep(null)
}, [])
useEffect(() => { useEffect(() => {
if (!unit) return if (!unit) return
if (timeline.length === 0) { if (timeline.length === 0) {
setStep(0) setStep(0)
return return
} }
if (coachDebriefPhase) return if (coachDebriefPhase) {
setStep((prev) => clampStep(prev, timeline.length)) setStep(timeline.length - 1)
} else {
setStep((prev) => clampStep(prev, timeline.length))
}
}, [unit, timeline.length, coachDebriefPhase]) }, [unit, timeline.length, coachDebriefPhase])
useEffect(() => { useEffect(() => {
if (!coachDebriefPhase || !unit || timeline.length === 0) return if (!unit || Number.isNaN(idNum)) return
setStep(timeline.length - 1) if (timeline.length === 0) {
}, [coachDebriefPhase, unit, timeline.length]) 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 = const elapsedMs =
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0) pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000)) const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000))
const currentEntry = timeline[step] const safeStep = useMemo(() => {
const nextEntry = timeline[step + 1] || null if (!timeline.length) return 0
const next2Entry = timeline[step + 2] || null 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 clockStr = formatClock(tickDisplaySec)
const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000)) const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000))
const showJumpToTimerOwner = const showJumpToTimerOwner =
timerOwningStep != null && timerOwningStep != null &&
step !== timerOwningStep && safeStep !== timerOwningStep &&
(runStartAt != null || pausedAccumMs > 0) (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 = () => { const timerStart = () => {
setRunStartAt(Date.now()) setRunStartAt(Date.now())
setTimerOwningStep(step) setTimerOwningStep(safeStep)
} }
const timerPause = () => { const timerPause = () => {
@ -306,14 +493,8 @@ export default function TrainingCoachPage() {
} }
} }
const timerReset = () => {
setRunStartAt(null)
setPausedAccumMs(0)
setTimerOwningStep(null)
}
const applySuggestedDuration = () => { const applySuggestedDuration = () => {
const idx = timerOwningStep != null ? timerOwningStep : step const idx = timerOwningStep != null ? timerOwningStep : safeStep
const ent = timeline[idx] const ent = timeline[idx]
const item = ent?.item const item = ent?.item
if (!item || item.item_type !== 'exercise') return if (!item || item.item_type !== 'exercise') return
@ -324,12 +505,28 @@ export default function TrainingCoachPage() {
timerPause() 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 goPrev = () => setStep((s) => clampStep(s - 1))
const goNext = () => setStep((s) => clampStep(s + 1)) const goNext = () => setStep((s) => clampStep(s + 1))
const markCurrentDoneAdvance = () => { const markCurrentDoneAdvance = () => {
const ownerIdx = timerOwningStep != null ? timerOwningStep : step const ownerIdx = timerOwningStep != null ? timerOwningStep : safeStep
const ent = timeline[ownerIdx] const ent = timeline[ownerIdx]
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
const item = ent?.item const item = ent?.item
if (item?.item_type === 'exercise' && elapsedMs > 650) { if (item?.item_type === 'exercise' && elapsedMs > 650) {
const key = itemStableKey(item, ent.secOrder, ent.ii) const key = itemStableKey(item, ent.secOrder, ent.ii)
@ -339,7 +536,12 @@ export default function TrainingCoachPage() {
timerReset() timerReset()
const lastIdx = timeline.length - 1 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) setCoachDebriefPhase(true)
try { try {
sessionStorage.setItem(storageDebriefKey(idNum), '1') sessionStorage.setItem(storageDebriefKey(idNum), '1')
@ -348,25 +550,31 @@ export default function TrainingCoachPage() {
} }
return 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)) setStep((s) => clampStep(s + 1, timeline.length))
} }
const durationOverridesForApi = useMemo(() => { useEffect(() => {
const out = {} if (!atBranchGate) return
for (let i = 0; i < timeline.length; i++) { if (runStartAt != null || pausedAccumMs > 0) timerReset()
const ent = timeline[i] }, [step, atBranchGate, runStartAt, pausedAccumMs, timerReset])
const { item } = ent
if (item.item_type !== 'exercise' || item.id == null) continue const durationOverridesForApi = useMemo(() => durationOverridesMapFromDeltas(unit, deltas), [unit, deltas])
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(() => { useEffect(() => {
if (currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE) {
setCatalogExercise(null)
setCatalogError(null)
setCatalogLoading(false)
return
}
const item = currentEntry?.item const item = currentEntry?.item
if (!item || item.item_type === 'note') { if (!item || item.item_type === 'note') {
setCatalogExercise(null) setCatalogExercise(null)
@ -402,16 +610,16 @@ export default function TrainingCoachPage() {
return () => { return () => {
cancelled = true 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 () => { const handleSaveDebrief = async () => {
setSaveOk(null) setSaveOk(null)
setSaving(true) setSaving(true)
try { try {
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi) const sectionsPayloadPart = buildCoachSavePlanPayload(unit, durationOverridesForApi)
const tn = trainerAppend.trim() const tn = trainerAppend.trim()
const payload = { const payload = {
sections: sectionsPayload, ...sectionsPayloadPart,
...(saveMarkDone ? { status: 'completed' } : {}), ...(saveMarkDone ? { status: 'completed' } : {}),
} }
if (tn) { if (tn) {
@ -421,18 +629,24 @@ export default function TrainingCoachPage() {
.trim() .trim()
} }
await api.updateTrainingUnit(idNum, payload) await api.updateTrainingUnit(idNum, payload)
await reloadUnit()
setTrainerAppend('') setTrainerAppend('')
try { try {
sessionStorage.removeItem(storageDeltasKey(idNum)) sessionStorage.removeItem(storageDeltasKey(idNum))
sessionStorage.removeItem(storageDebriefKey(idNum)) sessionStorage.removeItem(storageDebriefKey(idNum))
sessionStorage.removeItem(coachBranchPicksStorageKey(idNum))
clearCoachStepStorageForUnit(idNum)
} catch { } catch {
/* ignore */ /* ignore */
} }
setDeltas({}) setDeltas({})
setBranchPicks({})
setStreamChoiceHint(null)
setSplitRejoinPrompt(null)
setCoachDebriefPhase(false) setCoachDebriefPhase(false)
setSaveOk('Gespeichert.')
setDebriefOpen(false) setDebriefOpen(false)
setSaveOk('Gespeichert.')
setSearchParams({}, { replace: true })
navigate(`/planning/run/${unitId}`, { replace: true })
} catch (e) { } catch (e) {
setSaveOk(`Fehler: ${e.message || e}`) setSaveOk(`Fehler: ${e.message || e}`)
} finally { } finally {
@ -486,6 +700,41 @@ export default function TrainingCoachPage() {
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}> <button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Planung Planung
</button> </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 <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
@ -496,6 +745,111 @@ export default function TrainingCoachPage() {
</button> </button>
</nav> </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 <header
className="card training-coach-hero training-coach-hero--compact" className="card training-coach-hero training-coach-hero--compact"
style={{ style={{
@ -509,44 +863,93 @@ export default function TrainingCoachPage() {
> >
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}> <div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Coach ·{' '} 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> </div>
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}> <h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
{unit.planned_date} {unit.planned_date}
{unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`} {unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`}
{unit.planned_focus ? ` · ${unit.planned_focus}` : ''} {unit.planned_focus ? ` · ${unit.planned_focus}` : ''}
</h1> </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> </header>
{outlineOpen && ( {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 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={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto', minHeight: 0 }}> Trainingsrahmen · nach Blöcken und Streams
{timeline.map((ent, ix) => { </div>
const lbl = summarizeTimelineEntry(ent) <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', minHeight: 0 }}>
const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}` {outlineGroups.map((grp) => (
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step <div key={grp.mergeKey} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
return ( <div
<button
key={`${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`}
type="button"
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
style={{ style={{
textAlign: 'left', fontSize: '0.72rem',
justifyContent: 'flex-start', fontWeight: 700,
opacity: active ? 1 : 0.92, color: 'var(--accent-dark)',
fontWeight: active ? 700 : 500 textTransform: 'uppercase',
}} letterSpacing: '0.04em',
onClick={() => {
setCoachDebriefPhase(false)
setStep(ix)
}} }}
> >
<span style={{ opacity: 0.75 }}>{secTitle.substring(0, 14)} </span> {grp.heading}
<span>{lbl}</span> {grp.sub ? <span style={{ fontWeight: 600, color: 'var(--text3)', textTransform: 'none' }}> · {grp.sub}</span> : null}
</button> </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>
</div> </div>
)} )}
@ -566,13 +969,16 @@ export default function TrainingCoachPage() {
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline {timeline
.filter((e) => e.item.item_type === 'exercise') .filter((e) => e.item?.item_type === 'exercise')
.map((ent) => { .map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii) const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? '' const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
return ( return (
<label key={`db-${k}`} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}> <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 <input
type="number" type="number"
min="0" min="0"
@ -635,6 +1041,8 @@ export default function TrainingCoachPage() {
</div> </div>
) : ( ) : (
<> <>
{!splitRejoinPrompt ? (
<>
<div <div
className="training-coach-assist training-coach-assist--compact" className="training-coach-assist training-coach-assist--compact"
style={{ style={{
@ -648,7 +1056,12 @@ export default function TrainingCoachPage() {
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}> <div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
Als Nächstes Als Nächstes
</div> </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 }}> <p style={{ margin: '0', fontSize: '0.9rem', color: 'var(--text1)', lineHeight: 1.4 }}>
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)} <strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
@ -667,7 +1080,7 @@ export default function TrainingCoachPage() {
</div> </div>
<CoachControlsBand <CoachControlsBand
step={step} step={safeStep}
timelineLength={timeline.length} timelineLength={timeline.length}
onPrev={goPrev} onPrev={goPrev}
onNext={goNext} onNext={goNext}
@ -683,15 +1096,99 @@ export default function TrainingCoachPage() {
isLastCoachStep={isLastCoachStep} isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner} showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow showJumpToTimerOwnerRow
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)} onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
timerOwnerLabelIndex={timerOwningStep ?? 0} timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate}
/> />
<div className="training-coach-scroll"> <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 className="card" style={{ padding: '16px 14px' }}>
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}> <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>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</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> <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 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' }}> <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> </div>
{currentEntry?.item && ( {currentEntry?.item && (
<> <>
@ -788,13 +1286,16 @@ export default function TrainingCoachPage() {
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{timeline {timeline
.filter((e) => e.item.item_type === 'exercise') .filter((e) => e.item?.item_type === 'exercise')
.map((ent) => { .map((ent) => {
const k = itemStableKey(ent.item, ent.secOrder, ent.ii) const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? '' const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
return ( return (
<label key={k} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}> <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 <input
type="number" type="number"
min="0" min="0"
@ -840,7 +1341,7 @@ export default function TrainingCoachPage() {
</div> </div>
<CoachControlsBand <CoachControlsBand
step={step} step={safeStep}
timelineLength={timeline.length} timelineLength={timeline.length}
onPrev={goPrev} onPrev={goPrev}
onNext={goNext} onNext={goNext}
@ -856,9 +1357,12 @@ export default function TrainingCoachPage() {
isLastCoachStep={isLastCoachStep} isLastCoachStep={isLastCoachStep}
showJumpToTimerOwner={showJumpToTimerOwner} showJumpToTimerOwner={showJumpToTimerOwner}
showJumpToTimerOwnerRow={false} showJumpToTimerOwnerRow={false}
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)} onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
timerOwnerLabelIndex={timerOwningStep ?? 0} timerOwnerLabelIndex={timerOwningStep ?? 0}
branchGateMode={atBranchGate}
/> />
</>
) : null}
</> </>
)} )}
</div> </div>

View File

@ -14,6 +14,7 @@ import {
enrichSectionsWithVariants, enrichSectionsWithVariants,
buildSectionsPayload, buildSectionsPayload,
hydrateExercisePlanningRow, hydrateExercisePlanningRow,
reorderBlockIntoParallelStreamEnd,
} from '../utils/trainingUnitSectionsForm' } from '../utils/trainingUnitSectionsForm'
const DND_FW_SLOT = 'application/x-shinkan-framework-slot' const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
@ -553,7 +554,7 @@ export default function TrainingFrameworkProgramEditPage() {
} }
const moveSectionsAcrossFrameworkSlots = useCallback( const moveSectionsAcrossFrameworkSlots = useCallback(
({ fromSlot, fromSectionIdx, toSlot, toSectionIdx }) => { ({ fromSlot, fromSectionIdx, toSlot, toSectionIdx, toParallelStream }) => {
setForm((prev) => { setForm((prev) => {
const slots = prev.slots.map((sl) => ({ const slots = prev.slots.map((sl) => ({
...sl, ...sl,
@ -581,17 +582,32 @@ export default function TrainingFrameworkProgramEditPage() {
const [block] = fromSecs.splice(fromSectionIdx, 1) 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) { if (fromSlot === toSlot) {
let insertAt = toSectionIdx let insertAt = toSectionIdx
if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1 if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1
insertAt = Math.max(0, Math.min(insertAt, fromSecs.length)) insertAt = Math.max(0, Math.min(insertAt, fromSecs.length))
fromSecs.splice(insertAt, 0, block) fromSecs.splice(insertAt, 0, block)
if (applyParallelStreamEnd) {
slots[fromSlot].sections = applyParallelStreamEnd(fromSecs, insertAt)
}
return { ...prev, slots } return { ...prev, slots }
} }
const toSecs = slots[toSlot].sections const toSecs = slots[toSlot].sections
const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length)) const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length))
toSecs.splice(ia, 0, block) toSecs.splice(ia, 0, block)
if (applyParallelStreamEnd) {
slots[toSlot].sections = applyParallelStreamEnd(toSecs, ia)
}
return { ...prev, slots } 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). * 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 React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import CombinationPlanBracket from '../components/CombinationPlanBracket' 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' import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
function storageKey(unitId) { function storageKey(unitId) {
@ -36,6 +42,8 @@ export default function TrainingUnitRunPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [checked, setChecked] = useState(() => new Set()) const [checked, setChecked] = useState(() => new Set())
const [peekCtx, setPeekCtx] = useState(null) 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) => { const loadChecked = useCallback((uid) => {
try { try {
@ -107,20 +115,245 @@ export default function TrainingUnitRunPage() {
} }
}, [idNum, persistChecked]) }, [idNum, persistChecked])
const sections = useMemo(() => sortedSections(unit), [unit]) const sections = useMemo(() => sectionsWithPlanLocForDisplay(unit), [unit])
const planModel = useMemo(() => buildPlanRunViewModelFromSections(sections), [sections])
const totalPlannedMin = useMemo(() => { const printStreamOptions = useMemo(() => {
let t = 0 const opts = []
for (const sec of sections) { for (const run of planModel.runs) {
for (const it of sortedItems(sec)) { if (run.kind !== 'parallel' || !run.streams) continue
if (it.item_type === 'exercise' && it.planned_duration_min != null) { for (const st of run.streams) {
const n = Number(it.planned_duration_min) opts.push({
if (Number.isFinite(n)) t += n 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 return opts
}, [sections]) }, [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) { if (loading) {
return ( return (
@ -152,21 +385,66 @@ export default function TrainingUnitRunPage() {
onClose={() => setPeekCtx(null)} 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')}> <button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
Zur Planung Zur Planung
</button> </button>
<Link <Link
to={`/planning/run/${unitId}/coach`} to={`/planning/run/${unitId}/coach`}
className="btn btn-primary" 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) Im Training (Coach)
</Link> </Link>
<button type="button" className="btn btn-secondary" onClick={() => window.print()}> <button type="button" className="btn btn-secondary" onClick={() => window.print()}>
Drucken / PDF Drucken / PDF
</button> </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 Fortschritt leeren
</button> </button>
</nav> </nav>
@ -200,10 +478,81 @@ export default function TrainingUnitRunPage() {
</span> </span>
{totalPlannedMin > 0 && ( {totalPlannedMin > 0 && (
<span> <span>
<strong>Geplante Zeit (Übungen):</strong> ca. {totalPlannedMin} Min. <strong>Geplante Zeit (Übungen, gesamt):</strong> ca. {totalPlannedMin} Min.
</span> </span>
)} )}
</div> </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 && ( {unit.notes && (
<div <div
className="training-run-notes-print" className="training-run-notes-print"
@ -212,7 +561,7 @@ export default function TrainingUnitRunPage() {
padding: '0.65rem 0.85rem', padding: '0.65rem 0.85rem',
background: 'var(--accent-light)', background: 'var(--accent-light)',
borderRadius: '8px', borderRadius: '8px',
fontSize: '0.92rem' fontSize: '0.92rem',
}} }}
> >
<strong>Hinweis Teilnehmer:</strong> {unit.notes} <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. Noch keine Abschnitte in diesem Plan. Unter <Link to="/planning">Planung</Link> bearbeiten.
</p> </p>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> <div className="training-run-body" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{sections.map((sec, si) => { {planModel.runs.map((run, runIdx) => {
const secOrder = sec.order_index ?? si if (run.kind === 'parallel') {
const items = sortedItems(sec) const visibleStreams = run.streams.filter((st) => showStreamColumn(st.printStreamId))
return ( if (!visibleStreams.length) return null
<section key={sec.id ?? `sec-${si}`} className="card training-run-section" style={{ padding: '1.15rem 1.25rem' }}> return (
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.65rem', color: 'var(--accent-dark)' }}> <div
{sec.title || `Abschnitt ${si + 1}`} key={`run-p-${run.phaseOrderIndex}-${runIdx}`}
</h2> className="training-run-phase training-run-phase--parallel"
{sec.guidance_notes && ( >
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', marginBottom: '0.85rem', whiteSpace: 'pre-wrap' }}> <div
{sec.guidance_notes} className="card training-run-phase-banner"
</p> style={{
)} padding: '0.85rem 1rem',
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.65rem' }}> background: 'linear-gradient(135deg, hsl(200 35% 94%), var(--surface2))',
{items.map((it, ii) => { border: '1px solid hsl(200 40% 80%)',
const ck = itemStableKey(it, secOrder, ii) borderRadius: '10px',
const done = checked.has(ck) }}
>
if (it.item_type === 'note') { <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 ( return (
<li key={ck} className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}> <div
<label 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={{ 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', display: 'flex',
gap: '0.75rem', flexWrap: 'wrap',
alignItems: 'flex-start', alignItems: 'center',
cursor: 'pointer', justifyContent: 'space-between',
fontSize: '0.92rem' gap: '8px',
}} }}
> >
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '4px', width: '20px', height: '20px' }} /> <span>
<span style={{ whiteSpace: 'pre-wrap', color: 'var(--text2)' }}> {st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
<em style={{ fontStyle: 'normal', color: 'var(--text3)', fontSize: '0.8rem' }}>Notiz</em> <span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
<br /> · ca. {st.minutes} Min. (Üb.)
{it.note_body || ''} </span>
</span> </span>
</label> <Link
</li> 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 = if (!showWholeGroupInView) return null
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 ( return (
<li key={ck} className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}> <div
<label key={`run-wg-${run.phaseOrderIndex}-${runIdx}`}
style={{ className="training-run-phase training-run-phase--whole training-run-wg-block"
display: 'flex', >
gap: '0.75rem', {run.kind !== 'legacy' ? (
alignItems: 'flex-start', <div
cursor: 'pointer' className="card training-run-phase-banner"
}} style={{
> padding: '0.85rem 1rem',
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '6px', width: '22px', height: '22px', flexShrink: 0 }} /> marginBottom: '0.25rem',
<span style={{ flex: 1, minWidth: 0 }}> background: 'color-mix(in srgb, var(--accent) 12%, var(--surface2))',
<span style={{ fontSize: '1.02rem', fontWeight: 600 }}> border: '1px solid color-mix(in srgb, var(--accent) 28%, var(--border))',
{title} borderRadius: '10px',
{variant} }}
</span> >
{metaParts.length > 0 && ( <div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text3)', marginTop: '3px', lineHeight: 1.35 }}> GANZGRUPPE
{metaParts.join(' · ')} </div>
</div> <div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
)} {run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
{(it.notes || it.modifications) && ( </div>
<div style={{ marginTop: '0.35rem', fontSize: '0.88rem', color: 'var(--text2)', whiteSpace: 'pre-wrap' }}> <div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
{it.notes && ( Geplante Übungszeit: ca. {run.minutes} Min.
<> </div>
<strong style={{ fontWeight: 600 }}>Coach:</strong> {it.notes} </div>
</> ) : null}
)} <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{it.modifications && ( {run.sections.map((sec) => renderSectionCard(sec, Math.max(0, sections.indexOf(sec))))}
<> </div>
{it.notes ? <br /> : null} </div>
<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>
) )
})} })}
</div> </div>
)} )}
{unit.trainer_notes && ( {unit.trainer_notes && (
<div className="card training-run-trainer-note no-print" style={{ marginTop: '1.25rem', padding: '1rem', borderLeft: `4px solid var(--accent)` }}> <div
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text3)', marginBottom: '0.35rem', textTransform: 'uppercase' }}> 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 Nur Trainer
</div> </div>
<p style={{ fontSize: '0.92rem', whiteSpace: 'pre-wrap' }}>{unit.trainer_notes}</p> <p style={{ fontSize: '0.92rem', whiteSpace: 'pre-wrap' }}>{unit.trainer_notes}</p>
</div> </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). Haken werden nur auf diesem Gerät gespeichert (Session Tab schließen kann sie löschen).
</footer> </footer>
</div> </div>

View File

@ -4,86 +4,13 @@
* Zentrale API-Kommunikation mit automatischer Token-Injektion * Zentrale API-Kommunikation mit automatischer Token-Injektion
*/ */
import { stripHtmlToText } from './htmlUtils' import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from './combinationMethodProfileUi' import * as exercises from '../api/exercises.js'
import * as planning from '../api/planning.js'
const API_URL = import.meta.env.VITE_API_URL || '' export { ACTIVE_CLUB_STORAGE_KEY }
export * from '../api/exercises.js'
/** LocalStorage + Request-Header für Mandanten-Kontext */ export * from '../api/planning.js'
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
}
}
// ============================================================================ // ============================================================================
// Auth // Auth
@ -399,583 +326,6 @@ export async function deleteMethod(id) {
return request(`/api/methods/${id}`, { method: 'DELETE' }) 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) // Catalogs (Admin-verwaltbare Stammdaten)
// ============================================================================ // ============================================================================
@ -1328,176 +678,6 @@ export async function deleteTrainerContext(id) {
return request(`/api/trainer-contexts/${id}`, { method: 'DELETE' }) 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 // Version & Health
// ============================================================================ // ============================================================================
@ -1567,74 +747,11 @@ export const api = {
updateMethod, updateMethod,
deleteMethod, deleteMethod,
// Exercises // Exercises + Medien/Archiv (Progression, KI) → frontend/src/api/exercises.js
listExercises, ...exercises,
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,
// Training Planning // Training Planning → frontend/src/api/planning.js
listTrainingUnits, ...planning,
getDashboardKpis,
getTrainingExerciseClubVisibilityQueue,
getTrainingUnit,
createTrainingUnit,
updateTrainingUnit,
deleteTrainingUnit,
quickCreateTrainingUnit,
createTrainingUnitFromFrameworkSlot,
listTrainingPlanTemplates,
getTrainingPlanTemplate,
createTrainingPlanTemplate,
updateTrainingPlanTemplate,
deleteTrainingPlanTemplate,
listTrainingModules,
getTrainingModule,
createTrainingModule,
updateTrainingModule,
deleteTrainingModule,
applyTrainingModuleToTrainingUnit,
listTrainingFrameworkPrograms,
getTrainingFrameworkProgram,
createTrainingFrameworkProgram,
updateTrainingFrameworkProgram,
deleteTrainingFrameworkProgram,
// Catalogs // Catalogs
listFocusAreas, listFocusAreas,

View File

@ -2,7 +2,15 @@
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id. * 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) { export function sortedSections(unit) {
const raw = unit?.sections const raw = unit?.sections
if (!Array.isArray(raw)) return [] if (!Array.isArray(raw)) return []
@ -20,10 +28,352 @@ export function itemStableKey(it, secOrder, ix) {
return `${secOrder}-${it?.item_type || 'row'}-${ix}` return `${secOrder}-${it?.item_type || 'row'}-${ix}`
} }
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander. */ export function sumExerciseMinutesInSection(sec) {
export function flattenPlanTimeline(unit) { 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 = [] 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 const secOrder = sec.order_index ?? si
sortedItems(sec).forEach((item, ii) => { sortedItems(sec).forEach((item, ii) => {
list.push({ list.push({
@ -33,13 +383,165 @@ export function flattenPlanTimeline(unit) {
flatIndex: list.length, flatIndex: list.length,
sec, sec,
item, 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 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) return ''
if (item.item_type === 'note') { if (item.item_type === 'note') {
const t = String(item.note_body || '').trim() 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: [] } 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) { function normalizeCatalogMethodProfile(cp) {
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp } if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
return {} return {}
@ -156,65 +351,132 @@ function parseOptionalSourceTrainingModuleIdForPayload(v) {
return Number.isFinite(n) && n >= 1 ? n : null 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) { export function normalizeUnitToForm(fullUnit) {
if (Array.isArray(fullUnit?.phases) && fullUnit.phases.length > 0) {
return normalizePhasesToFormSections(fullUnit)
}
if (fullUnit.sections && fullUnit.sections.length) { if (fullUnit.sections && fullUnit.sections.length) {
return fullUnit.sections.map((sec) => ({ return fullUnit.sections.map((sec) => ({
title: sec.title, title: sec.title,
guidance_notes: sec.guidance_notes || '', guidance_notes: sec.guidance_notes || '',
items: (sec.items || []).map((it) => { items: formItemsFromApiItems(sec.items),
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(),
}
: {}),
}
}),
})) }))
} }
if (fullUnit.exercises && fullUnit.exercises.length) { if (fullUnit.exercises && fullUnit.exercises.length) {
@ -398,9 +660,9 @@ export function parseMin(v) {
return Number.isFinite(n) ? n : null return Number.isFinite(n) ? n : null
} }
export function buildSectionsPayload(sections) { export function buildOneSectionPayload(sec, orderIndex) {
return sections.map((sec, si) => ({ return {
order_index: si, order_index: orderIndex,
title: (sec.title || '').trim() || 'Abschnitt', title: (sec.title || '').trim() || 'Abschnitt',
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null, guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
items: (sec.items || []) items: (sec.items || [])
@ -445,7 +707,431 @@ export function buildSectionsPayload(sections) {
return rowEx return rowEx
}) })
.filter(Boolean), .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). */ /** 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", "status": "failed",
"failedTests": [] "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); 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 { try {
await page.reload({ waitUntil: 'networkidle' }); 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(profilesMe).toBe(1);
expect(trainingUnits).toBe(0); expect(trainingUnits).toBe(0);
expect(dashboardKpis).toBe(1); expect(dashboardKpis).toBe(1);
expect(kpisStatuses.some((s) => s === 200)).toBe(true);
} finally { } finally {
page.off('request', onRequest); 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 }) => { 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'); 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 }) => { test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 }); await page.setViewportSize({ width: 1280, height: 800 });
await login(page); await login(page);