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
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:
commit
bd9cfaa6e4
|
|
@ -482,6 +482,8 @@ skill_level_definitions (
|
|||
|
||||
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||
|
||||
**Schema-Hinweis (2026-05):** Tabelle `training_unit_sections` hat **`UNIQUE (training_unit_id, order_index)`** (Migration 031). Damit sind **zwei gleichzeitige „Spuren“ mit jeweils eigener Sektion auf derselben `order_index`** nicht abbildbar — Voraussetzung für Parallele Streams ist eine **geplante Migrations-/Constraint-Anpassung** (partielle Uniques pro Phase/Stream); siehe Arbeitsdokument `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md`. **Keine invasive Migration ohne explizite Freigabe.**
|
||||
|
||||
---
|
||||
|
||||
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
||||
|
|
|
|||
|
|
@ -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 4–6) in einer dedizierten Feature-Session; danach Paket **2** (PUT/Module/Clone).
|
||||
|
||||
---
|
||||
|
|
@ -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 0–2).
|
||||
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).
|
||||
```
|
||||
|
|
@ -10,7 +10,7 @@ on:
|
|||
types: [completed]
|
||||
|
||||
jobs:
|
||||
# Wie Mitai-Jinkendo: pytest im laufenden backend-Container (Python aus Image, gleiche DB wie Deploy).
|
||||
# Pytest im laufenden backend-Container; ACCESS_LAYER + TRAINING_PLANNING Integration gegen dieselbe PostgreSQL wie Deploy (Schema via Container-Start migriert).
|
||||
pytest-backend:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
cd /app &&
|
||||
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
|
||||
python scripts/security_release_checks.py &&
|
||||
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
||||
ACCESS_LAYER_INTEGRATION=1 TRAINING_PLANNING_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
||||
"
|
||||
|
||||
lint-backend:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -6,5 +6,5 @@ python_functions = test_*
|
|||
addopts = -q --tb=short
|
||||
markers =
|
||||
smoke: Schnelle Kern-Regression.
|
||||
integration: PostgreSQL-Mandanten-Integration (ACCESS_LAYER_INTEGRATION=1).
|
||||
integration: PostgreSQL-Integration (z. B. ACCESS_LAYER_INTEGRATION=1, TRAINING_PLANNING_INTEGRATION=1).
|
||||
slow: Lange/schwere Tests; in CI wie Mitai-Jinkendo ausgeschlossen (Auswahl: not slow).
|
||||
|
|
|
|||
|
|
@ -484,6 +484,94 @@ def _normalize_assistant_trainer_profile_ids(
|
|||
)
|
||||
return uniq
|
||||
|
||||
|
||||
def _normalize_stream_assigned_trainer_profile_ids(
|
||||
cur,
|
||||
raw_val: Any,
|
||||
*,
|
||||
group_id: Optional[int],
|
||||
profile_id: int,
|
||||
role: str,
|
||||
unit_created_by: Optional[int],
|
||||
eff_lead_nid: Optional[int],
|
||||
) -> Any:
|
||||
"""
|
||||
JSONB-Liste für training_unit_parallel_streams.assigned_trainer_profile_ids.
|
||||
Ohne group_id (Rahmen-Blueprint): nur Profil-Existenz + keine Überschneidung mit Leitung.
|
||||
Mit group_id: gleiche Vereins-/Zuweisungsregeln wie assistant_trainer_profile_ids.
|
||||
"""
|
||||
if raw_val is None:
|
||||
return None
|
||||
if not isinstance(raw_val, list):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="assigned_trainer_profile_ids (Stream) muss Liste oder null sein",
|
||||
)
|
||||
ids_in: List[int] = []
|
||||
for x in raw_val:
|
||||
try:
|
||||
i = int(x)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="assigned_trainer_profile_ids (Stream) ungültig",
|
||||
)
|
||||
if i < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="assigned_trainer_profile_ids (Stream) ungültig",
|
||||
)
|
||||
ids_in.append(i)
|
||||
uniq = sorted(set(ids_in))
|
||||
for nid in uniq:
|
||||
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Profil für Stream-Co-Trainer nicht gefunden",
|
||||
)
|
||||
if eff_lead_nid is not None and nid == eff_lead_nid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Leitung und Stream-Co-Trainer dürfen sich nicht überschneiden",
|
||||
)
|
||||
if group_id is None:
|
||||
return uniq
|
||||
cur.execute(
|
||||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||||
(group_id,),
|
||||
)
|
||||
gr = cur.fetchone()
|
||||
if not gr:
|
||||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||
grd = dict(gr)
|
||||
cid = grd.get("club_id")
|
||||
if cid is None:
|
||||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||
club_i = int(cid)
|
||||
if not is_platform_admin(role) and not _caller_may_assign_session_trainers(
|
||||
cur, grd, profile_id, role, unit_created_by
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer (Stream) zuzuweisen")
|
||||
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
|
||||
for x in grd.get("co_trainer_ids") or []:
|
||||
try:
|
||||
eligible.add(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for nid in uniq:
|
||||
if is_platform_admin(role):
|
||||
continue
|
||||
if nid in eligible:
|
||||
continue
|
||||
if not _profile_active_in_club(cur, club_i, nid):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Stream-Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
|
||||
)
|
||||
return uniq
|
||||
|
||||
|
||||
def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]:
|
||||
"""None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung."""
|
||||
if raw is None:
|
||||
|
|
@ -522,21 +610,52 @@ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
|
|||
return i
|
||||
|
||||
|
||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
# ── Sektionen laden / ersetzen (Kernpfad Planungsinhalt) ──────────────────
|
||||
# Hinweis: Pro Sektion ein Items-Query (N+1) — bewusst einfach; Batching später möglich.
|
||||
|
||||
|
||||
def _clear_unit_plan_content(cur, unit_id: int) -> None:
|
||||
"""Löscht alle Planungs-Phasen der Einheit (CASCADE: Streams, Sektionen, Items)."""
|
||||
cur.execute("DELETE FROM training_unit_phases WHERE training_unit_id = %s", (unit_id,))
|
||||
|
||||
|
||||
def _ensure_default_whole_group_phase(cur, unit_id: int, *, order_index: int = 0) -> int:
|
||||
"""Legt bei Bedarf eine whole_group-Phase an; gibt phase.id zurück."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
FROM training_unit_sections
|
||||
WHERE training_unit_id = %s
|
||||
ORDER BY order_index
|
||||
SELECT id FROM training_unit_phases
|
||||
WHERE training_unit_id = %s AND phase_kind = 'whole_group' AND order_index = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(unit_id,),
|
||||
(unit_id, order_index),
|
||||
)
|
||||
secs = []
|
||||
for sec_row in cur.fetchall():
|
||||
sec = r2d(sec_row)
|
||||
cur.execute(
|
||||
"""
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes)
|
||||
VALUES (%s, %s, 'whole_group', NULL, NULL)
|
||||
RETURNING id
|
||||
""",
|
||||
(unit_id, order_index),
|
||||
)
|
||||
return int(cur.fetchone()["id"])
|
||||
|
||||
|
||||
_SECTION_ROWS_SQL = """
|
||||
SELECT tus.id, tus.training_unit_id, tus.order_index, tus.title, tus.guidance_notes,
|
||||
tus.source_template_section_id, tus.phase_id, tus.parallel_stream_id
|
||||
FROM training_unit_sections tus
|
||||
LEFT JOIN training_unit_phases ph ON ph.id = tus.phase_id
|
||||
LEFT JOIN training_unit_parallel_streams ps ON ps.id = tus.parallel_stream_id
|
||||
LEFT JOIN training_unit_phases ph_s ON ph_s.id = ps.phase_id
|
||||
WHERE tus.training_unit_id = %s
|
||||
ORDER BY COALESCE(ph.order_index, ph_s.order_index) ASC,
|
||||
ps.order_index ASC NULLS FIRST,
|
||||
tus.order_index ASC
|
||||
"""
|
||||
_SECTION_ITEMS_ROWS_SQL = """
|
||||
SELECT tusi.*,
|
||||
e.title AS exercise_title,
|
||||
e.exercise_kind AS exercise_kind,
|
||||
|
|
@ -558,75 +677,201 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
|
||||
WHERE tusi.section_id = %s
|
||||
ORDER BY tusi.order_index
|
||||
""",
|
||||
(sec["id"],),
|
||||
)
|
||||
sec["items"] = [r2d(r) for r in cur.fetchall()]
|
||||
for it in sec["items"]:
|
||||
if it.get("item_type") != "exercise":
|
||||
continue
|
||||
cmp_raw = it.get("catalog_method_profile")
|
||||
if not isinstance(cmp_raw, dict):
|
||||
it["catalog_method_profile"] = {}
|
||||
else:
|
||||
it["catalog_method_profile"] = dict(cmp_raw)
|
||||
ek = str(it.get("exercise_kind") or "simple").strip().lower()
|
||||
if ek == "combination" and it.get("exercise_id"):
|
||||
try:
|
||||
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
|
||||
except (TypeError, ValueError):
|
||||
it["combination_slots"] = []
|
||||
else:
|
||||
it["combination_slots"] = []
|
||||
"""
|
||||
|
||||
|
||||
def _hydrate_section_item_combination_slots(cur, it: Dict[str, Any]) -> None:
|
||||
"""Setzt `combination_slots` für Kombi‑Übungen; sonst leere Liste."""
|
||||
if it.get("item_type") != "exercise":
|
||||
return
|
||||
cmp_raw = it.get("catalog_method_profile")
|
||||
if not isinstance(cmp_raw, dict):
|
||||
it["catalog_method_profile"] = {}
|
||||
else:
|
||||
it["catalog_method_profile"] = dict(cmp_raw)
|
||||
ek = str(it.get("exercise_kind") or "simple").strip().lower()
|
||||
if ek == "combination" and it.get("exercise_id"):
|
||||
try:
|
||||
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
|
||||
except (TypeError, ValueError):
|
||||
it["combination_slots"] = []
|
||||
else:
|
||||
it["combination_slots"] = []
|
||||
|
||||
|
||||
def _fetch_section_items_for_section(cur, section_id: int) -> List[Dict[str, Any]]:
|
||||
cur.execute(_SECTION_ITEMS_ROWS_SQL, (section_id,))
|
||||
items = [r2d(r) for r in cur.fetchall()]
|
||||
for it in items:
|
||||
_hydrate_section_item_combination_slots(cur, it)
|
||||
return items
|
||||
|
||||
|
||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
"""Lädt alle Sektionen inkl. Items und Katalog-Anreicherung für die Einheit."""
|
||||
cur.execute(_SECTION_ROWS_SQL, (unit_id,))
|
||||
secs = []
|
||||
for sec_row in cur.fetchall():
|
||||
sec = r2d(sec_row)
|
||||
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
|
||||
secs.append(sec)
|
||||
return secs
|
||||
|
||||
|
||||
def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
"""Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder)."""
|
||||
secs = _fetch_sections(cur, unit_id)
|
||||
def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
"""Verschachtelte Phasen/Streams/Sektionen für GET (UI kann parallele Sp später nutzen)."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, phase_kind, title, guidance_notes
|
||||
FROM training_unit_phases
|
||||
WHERE training_unit_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(unit_id,),
|
||||
)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for sec in secs:
|
||||
items_clean: List[Dict[str, Any]] = []
|
||||
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
|
||||
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
||||
oix = it.get("order_index")
|
||||
if itype == "note":
|
||||
note_item = {
|
||||
"item_type": "note",
|
||||
"order_index": oix,
|
||||
"note_body": it.get("note_body") or "",
|
||||
}
|
||||
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
|
||||
if sm is not None:
|
||||
note_item["source_training_module_id"] = sm
|
||||
items_clean.append(note_item)
|
||||
continue
|
||||
if itype != "exercise" or not it.get("exercise_id"):
|
||||
continue
|
||||
ex_item = {
|
||||
"item_type": "exercise",
|
||||
for prow in cur.fetchall():
|
||||
p = r2d(prow)
|
||||
pk = str(p.get("phase_kind") or "").strip().lower()
|
||||
if pk == "whole_group":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes,
|
||||
source_template_section_id, phase_id, parallel_stream_id
|
||||
FROM training_unit_sections
|
||||
WHERE phase_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(p["id"],),
|
||||
)
|
||||
secs: List[Dict[str, Any]] = []
|
||||
for srow in cur.fetchall():
|
||||
sec = r2d(srow)
|
||||
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
|
||||
secs.append(sec)
|
||||
p["sections"] = secs
|
||||
p["streams"] = []
|
||||
elif pk == "parallel":
|
||||
p["sections"] = []
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, phase_id, order_index, title, notes, assigned_trainer_profile_ids
|
||||
FROM training_unit_parallel_streams
|
||||
WHERE phase_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(p["id"],),
|
||||
)
|
||||
streams: List[Dict[str, Any]] = []
|
||||
for st_row in cur.fetchall():
|
||||
st = r2d(st_row)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes,
|
||||
source_template_section_id, phase_id, parallel_stream_id
|
||||
FROM training_unit_sections
|
||||
WHERE parallel_stream_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(st["id"],),
|
||||
)
|
||||
secs = []
|
||||
for sec_row in cur.fetchall():
|
||||
sec = r2d(sec_row)
|
||||
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
|
||||
secs.append(sec)
|
||||
st["sections"] = secs
|
||||
streams.append(st)
|
||||
p["streams"] = streams
|
||||
else:
|
||||
p["sections"] = []
|
||||
p["streams"] = []
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _clone_section_payload_dict(sec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Sektion inkl. Items ohne DB-IDs (für phases-Payload / Kopie)."""
|
||||
items_clean: List[Dict[str, Any]] = []
|
||||
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
|
||||
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
||||
oix = it.get("order_index")
|
||||
if itype == "note":
|
||||
note_item = {
|
||||
"item_type": "note",
|
||||
"order_index": oix,
|
||||
"exercise_id": it["exercise_id"],
|
||||
"exercise_variant_id": it.get("exercise_variant_id"),
|
||||
"planned_duration_min": it.get("planned_duration_min"),
|
||||
"actual_duration_min": it.get("actual_duration_min"),
|
||||
"notes": it.get("notes"),
|
||||
"modifications": it.get("modifications"),
|
||||
"planning_method_profile": it.get("planning_method_profile"),
|
||||
"note_body": it.get("note_body") or "",
|
||||
}
|
||||
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
|
||||
if sm is not None:
|
||||
ex_item["source_training_module_id"] = sm
|
||||
items_clean.append(ex_item)
|
||||
out.append(
|
||||
{
|
||||
"title": sec.get("title"),
|
||||
"order_index": sec.get("order_index"),
|
||||
"guidance_notes": sec.get("guidance_notes"),
|
||||
"items": items_clean,
|
||||
}
|
||||
)
|
||||
note_item["source_training_module_id"] = sm
|
||||
items_clean.append(note_item)
|
||||
continue
|
||||
if itype != "exercise" or not it.get("exercise_id"):
|
||||
continue
|
||||
ex_item = {
|
||||
"item_type": "exercise",
|
||||
"order_index": oix,
|
||||
"exercise_id": it["exercise_id"],
|
||||
"exercise_variant_id": it.get("exercise_variant_id"),
|
||||
"planned_duration_min": it.get("planned_duration_min"),
|
||||
"actual_duration_min": it.get("actual_duration_min"),
|
||||
"notes": it.get("notes"),
|
||||
"modifications": it.get("modifications"),
|
||||
"planning_method_profile": it.get("planning_method_profile"),
|
||||
}
|
||||
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
|
||||
if sm is not None:
|
||||
ex_item["source_training_module_id"] = sm
|
||||
items_clean.append(ex_item)
|
||||
row: Dict[str, Any] = {
|
||||
"title": sec.get("title"),
|
||||
"order_index": sec.get("order_index"),
|
||||
"guidance_notes": sec.get("guidance_notes"),
|
||||
"items": items_clean,
|
||||
}
|
||||
stid = sec.get("source_template_section_id")
|
||||
if stid is not None and stid != "":
|
||||
try:
|
||||
stid_i = int(stid)
|
||||
if stid_i >= 1:
|
||||
row["source_template_section_id"] = stid_i
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return row
|
||||
|
||||
|
||||
def _phases_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
"""Vollständige Phasen/Streams/Sektionen für tiefe Kopie (ohne DB-IDs)."""
|
||||
nested = _fetch_phases_nested(cur, unit_id)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for ph in nested:
|
||||
kind = str(ph.get("phase_kind") or "").strip().lower()
|
||||
if kind not in ("whole_group", "parallel"):
|
||||
kind = "whole_group"
|
||||
pd: Dict[str, Any] = {
|
||||
"order_index": ph.get("order_index"),
|
||||
"phase_kind": kind,
|
||||
"title": ph.get("title"),
|
||||
"guidance_notes": ph.get("guidance_notes"),
|
||||
}
|
||||
if kind == "whole_group":
|
||||
pd["sections"] = [_clone_section_payload_dict(s) for s in ph.get("sections") or []]
|
||||
pd["streams"] = []
|
||||
else:
|
||||
pd["sections"] = []
|
||||
streams_clean: List[Dict[str, Any]] = []
|
||||
for st in ph.get("streams") or []:
|
||||
sd: Dict[str, Any] = {
|
||||
"order_index": st.get("order_index"),
|
||||
"title": st.get("title"),
|
||||
"notes": st.get("notes"),
|
||||
"assigned_trainer_profile_ids": st.get("assigned_trainer_profile_ids"),
|
||||
"sections": [_clone_section_payload_dict(s) for s in st.get("sections") or []],
|
||||
}
|
||||
streams_clean.append(sd)
|
||||
pd["streams"] = streams_clean
|
||||
out.append(pd)
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -637,6 +882,7 @@ def _copy_blueprint_into_scheduled_unit(
|
|||
planned_date: str,
|
||||
profile_id: int,
|
||||
origin_framework_slot_id: Optional[int],
|
||||
role: str,
|
||||
) -> int:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -692,8 +938,8 @@ def _copy_blueprint_into_scheduled_unit(
|
|||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden")
|
||||
nu = row["id"]
|
||||
cloned = _sections_clone_payload(cur, blueprint_unit_id)
|
||||
_replace_unit_sections(cur, nu, cloned)
|
||||
cloned = _phases_clone_payload(cur, blueprint_unit_id)
|
||||
_replace_unit_phases(cur, nu, cloned, profile_id, role, profile_id)
|
||||
return nu
|
||||
|
||||
|
||||
|
|
@ -707,17 +953,27 @@ def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""GET-Payload: `phases` (verschachtelt), flache `sections` + abgeleitete `exercises` (Legacy)."""
|
||||
uid = unit["id"]
|
||||
unit["phases"] = _fetch_phases_nested(cur, uid)
|
||||
unit["sections"] = _fetch_sections(cur, uid)
|
||||
_flatten_exercises_from_sections(unit)
|
||||
return unit
|
||||
|
||||
|
||||
def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int:
|
||||
"""Erste Sektion mit order_index in einer whole_group-Phase (Parallelstreams ausgeschlossen)."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM training_unit_sections
|
||||
WHERE training_unit_id = %s AND order_index = %s
|
||||
SELECT tus.id
|
||||
FROM training_unit_sections tus
|
||||
INNER JOIN training_unit_phases p ON p.id = tus.phase_id
|
||||
WHERE tus.training_unit_id = %s
|
||||
AND tus.order_index = %s
|
||||
AND tus.parallel_stream_id IS NULL
|
||||
AND LOWER(TRIM(p.phase_kind)) = 'whole_group'
|
||||
ORDER BY p.order_index ASC, tus.id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(unit_id, section_order_index),
|
||||
)
|
||||
|
|
@ -729,6 +985,52 @@ def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: in
|
|||
return int(r["id"])
|
||||
|
||||
|
||||
def _resolve_training_unit_section_id_for_apply(
|
||||
cur,
|
||||
unit_id: int,
|
||||
section_order_index: int,
|
||||
*,
|
||||
phase_order_index: Optional[int],
|
||||
parallel_stream_order_index: Optional[int],
|
||||
) -> int:
|
||||
"""Ziel-Abschnitt: ganzes Gruppen physisch (nur section_order_index) oder innerhalb eines Parallelstreams."""
|
||||
if parallel_stream_order_index is None:
|
||||
return _resolve_training_unit_section_id(cur, unit_id, section_order_index)
|
||||
if phase_order_index is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="phase_order_index ist bei parallel_stream_order_index Pflicht",
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tus.id
|
||||
FROM training_unit_sections tus
|
||||
INNER JOIN training_unit_parallel_streams st ON st.id = tus.parallel_stream_id
|
||||
INNER JOIN training_unit_phases p ON p.id = st.phase_id
|
||||
WHERE tus.training_unit_id = %s
|
||||
AND tus.order_index = %s
|
||||
AND st.order_index = %s
|
||||
AND p.order_index = %s
|
||||
AND LOWER(TRIM(p.phase_kind)) = 'parallel'
|
||||
ORDER BY tus.id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
unit_id,
|
||||
section_order_index,
|
||||
parallel_stream_order_index,
|
||||
phase_order_index,
|
||||
),
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Abschnitt im Parallelstream für diese Indizes nicht gefunden",
|
||||
)
|
||||
return int(r["id"])
|
||||
|
||||
|
||||
def _append_copied_module_items_to_section(
|
||||
cur,
|
||||
section_id: int,
|
||||
|
|
@ -874,31 +1176,188 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
|||
)
|
||||
|
||||
|
||||
def _insert_one_replacement_section(
|
||||
cur,
|
||||
unit_id: int,
|
||||
sec: Any,
|
||||
enumeration_index: int,
|
||||
*,
|
||||
phase_id: Optional[int] = None,
|
||||
parallel_stream_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Eine Sektion inkl. Items (genau eines von phase_id / parallel_stream_id gesetzt)."""
|
||||
if (phase_id is None) == (parallel_stream_id is None):
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Intern: Sektion braucht phase_id oder parallel_stream_id"
|
||||
)
|
||||
title = (sec.get("title") or "").strip() or "Abschnitt"
|
||||
order_ix = sec.get("order_index")
|
||||
if order_ix is None:
|
||||
order_ix = enumeration_index
|
||||
src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
unit_id,
|
||||
phase_id,
|
||||
parallel_stream_id,
|
||||
order_ix,
|
||||
title,
|
||||
sec.get("guidance_notes"),
|
||||
src_tsec,
|
||||
),
|
||||
)
|
||||
sid = cur.fetchone()["id"]
|
||||
_insert_section_items(cur, sid, sec.get("items"))
|
||||
|
||||
|
||||
def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]):
|
||||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||
"""Ersetzt den gesamten Plan (Legacy): eine whole_group-Phase + Sektionen."""
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
phase_id = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
||||
for si, sec in enumerate(sections_in):
|
||||
title = (sec.get("title") or "").strip() or "Abschnitt"
|
||||
order_ix = sec.get("order_index")
|
||||
if order_ix is None:
|
||||
order_ix = si
|
||||
src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
|
||||
_insert_one_replacement_section(
|
||||
cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None
|
||||
)
|
||||
|
||||
|
||||
def _replace_unit_phases(
|
||||
cur,
|
||||
unit_id: int,
|
||||
phases_in: List[Any],
|
||||
profile_id: int,
|
||||
role: str,
|
||||
unit_created_by: Optional[int],
|
||||
) -> None:
|
||||
"""Ersetzt Phasen inkl. paralleler Streams und Sektionen (voller Plan)."""
|
||||
if not isinstance(phases_in, list):
|
||||
raise HTTPException(status_code=400, detail="phases muss eine Liste sein")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tu.group_id,
|
||||
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS eff_lead
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tg.id = tu.group_id
|
||||
WHERE tu.id = %s
|
||||
""",
|
||||
(unit_id,),
|
||||
)
|
||||
ur = cur.fetchone()
|
||||
group_id_opt = int(ur["group_id"]) if ur and ur.get("group_id") is not None else None
|
||||
eff_lead_raw = ur.get("eff_lead") if ur else None
|
||||
eff_lead_nid = int(eff_lead_raw) if eff_lead_raw is not None else None
|
||||
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
for pi, ph in enumerate(phases_in):
|
||||
kind = str(ph.get("phase_kind") or "").strip().lower()
|
||||
if kind not in ("whole_group", "parallel"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="phase_kind muss whole_group oder parallel sein",
|
||||
)
|
||||
# Reihenfolge strikt aus der Liste (pi): vermeidet UNIQUE(tu, order_index)-Kollisionen,
|
||||
# wenn der Client dieselbe phase_order_index mehrfach trägt (z. B. nach Zuordnungswechseln).
|
||||
p_oix = int(pi)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, %s, %s, %s)
|
||||
INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
unit_id,
|
||||
order_ix,
|
||||
title,
|
||||
sec.get("guidance_notes"),
|
||||
src_tsec,
|
||||
p_oix,
|
||||
kind,
|
||||
ph.get("title"),
|
||||
ph.get("guidance_notes"),
|
||||
),
|
||||
)
|
||||
sid = cur.fetchone()["id"]
|
||||
_insert_section_items(cur, sid, sec.get("items"))
|
||||
phase_id = int(cur.fetchone()["id"])
|
||||
if kind == "whole_group":
|
||||
secs = ph.get("sections")
|
||||
if secs is None:
|
||||
secs = []
|
||||
if not isinstance(secs, list):
|
||||
raise HTTPException(status_code=400, detail="sections muss Liste sein")
|
||||
for si, sec in enumerate(secs):
|
||||
_insert_one_replacement_section(
|
||||
cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None
|
||||
)
|
||||
else:
|
||||
streams = ph.get("streams")
|
||||
if streams is None:
|
||||
streams = []
|
||||
if not isinstance(streams, list):
|
||||
raise HTTPException(status_code=400, detail="streams muss Liste sein")
|
||||
for si, st in enumerate(streams):
|
||||
raw_asst = st.get("assigned_trainer_profile_ids")
|
||||
asst_norm = _normalize_stream_assigned_trainer_profile_ids(
|
||||
cur,
|
||||
raw_asst,
|
||||
group_id=group_id_opt,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
unit_created_by=unit_created_by,
|
||||
eff_lead_nid=eff_lead_nid,
|
||||
)
|
||||
asst_db = None if asst_norm is None else PsycopgJson(asst_norm)
|
||||
st_oix = st.get("order_index")
|
||||
if st_oix is None:
|
||||
st_oix = si
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_parallel_streams (
|
||||
phase_id, order_index, title, notes, assigned_trainer_profile_ids
|
||||
) VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
phase_id,
|
||||
int(st_oix),
|
||||
st.get("title"),
|
||||
st.get("notes"),
|
||||
asst_db,
|
||||
),
|
||||
)
|
||||
sid = int(cur.fetchone()["id"])
|
||||
secs = st.get("sections")
|
||||
if secs is None:
|
||||
secs = []
|
||||
if not isinstance(secs, list):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="sections (Stream) muss Liste sein",
|
||||
)
|
||||
for ti, sec in enumerate(secs):
|
||||
_insert_one_replacement_section(
|
||||
cur, unit_id, sec, ti, phase_id=None, parallel_stream_id=sid
|
||||
)
|
||||
|
||||
|
||||
def _assert_single_plan_content_key_create(data: dict) -> None:
|
||||
"""Höchstens ein Plan-Inhalt: phases | sections | exercises (Non-None)."""
|
||||
n = sum(1 for k in ("phases", "sections", "exercises") if data.get(k) is not None)
|
||||
if n > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nur eines von phases, sections oder exercises angeben",
|
||||
)
|
||||
|
||||
|
||||
def _assert_single_plan_content_key_update(data: dict) -> None:
|
||||
"""PUT: höchstens einer der Keys phases | sections | exercises."""
|
||||
keys = [k for k in ("phases", "sections", "exercises") if k in data]
|
||||
if len(keys) > 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nur eines von phases, sections oder exercises im Body gleichzeitig",
|
||||
)
|
||||
|
||||
|
||||
def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]:
|
||||
|
|
@ -1027,13 +1486,16 @@ def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int,
|
|||
def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
|
||||
if not exercises_in:
|
||||
return
|
||||
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
|
||||
VALUES (%s, 0, %s, NULL)
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes
|
||||
)
|
||||
VALUES (%s, %s, NULL, 0, %s, NULL)
|
||||
RETURNING id
|
||||
""",
|
||||
(unit_id, "Übungen"),
|
||||
(unit_id, pid, "Übungen"),
|
||||
)
|
||||
sid = cur.fetchone()["id"]
|
||||
slot = 0
|
||||
|
|
@ -1062,6 +1524,8 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List
|
|||
|
||||
|
||||
def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, guidance_text
|
||||
|
|
@ -1072,29 +1536,26 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
|||
(template_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
for gi, row in enumerate(rows):
|
||||
r = r2d(row)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, (
|
||||
SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2
|
||||
WHERE u2.training_unit_id = %s
|
||||
), %s, %s, %s)
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, NULL, %s, %s, %s, %s)
|
||||
""",
|
||||
(unit_id, unit_id, r["title"], r["guidance_text"], r["id"]),
|
||||
(unit_id, pid, gi, r["title"], r["guidance_text"], r["id"]),
|
||||
)
|
||||
|
||||
# Fallback: keine Sektionen in Vorlage → ein leerer Block
|
||||
if not rows:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
|
||||
SELECT %s, 0, %s, NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s)
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes
|
||||
) VALUES (%s, %s, NULL, 0, 'Hauptteil', NULL)
|
||||
""",
|
||||
(unit_id, "Hauptteil", unit_id),
|
||||
(unit_id, pid),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1758,7 +2219,11 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
|
|||
def apply_training_module_to_training_unit(
|
||||
unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
|
||||
):
|
||||
"""Kopiert die Positionen eines Trainingsmoduls ans Ende eines Abschnitts (lokal bearbeitbar)."""
|
||||
"""Kopiert Modul-Positionen ans Ende eines Abschnitts.
|
||||
|
||||
Ziel: `section_order_index` in einer whole_group-Phase (Standard) oder
|
||||
zusätzlich `phase_order_index` + `parallel_stream_order_index` für einen Stream.
|
||||
"""
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
if not _has_planning_role(role):
|
||||
|
|
@ -1780,12 +2245,44 @@ def apply_training_module_to_training_unit(
|
|||
if section_order_index < 0:
|
||||
raise HTTPException(status_code=400, detail="section_order_index ungültig")
|
||||
|
||||
ps_raw = data.get("parallel_stream_order_index")
|
||||
parallel_stream_oi: Optional[int] = None
|
||||
if ps_raw is not None and ps_raw != "":
|
||||
try:
|
||||
parallel_stream_oi = int(ps_raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="parallel_stream_order_index ungültig")
|
||||
if parallel_stream_oi < 0:
|
||||
raise HTTPException(status_code=400, detail="parallel_stream_order_index ungültig")
|
||||
|
||||
phase_oi: Optional[int] = None
|
||||
ph_raw = data.get("phase_order_index")
|
||||
if ph_raw is not None and ph_raw != "":
|
||||
try:
|
||||
phase_oi = int(ph_raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="phase_order_index ungültig")
|
||||
if phase_oi < 0:
|
||||
raise HTTPException(status_code=400, detail="phase_order_index ungültig")
|
||||
|
||||
if phase_oi is not None and parallel_stream_oi is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="phase_order_index nur zusammen mit parallel_stream_order_index",
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
unit_row = _training_unit_guard_row(cur, unit_id)
|
||||
_assert_training_unit_permission(cur, unit_row, profile_id, role)
|
||||
|
||||
section_id = _resolve_training_unit_section_id(cur, unit_id, section_order_index)
|
||||
section_id = _resolve_training_unit_section_id_for_apply(
|
||||
cur,
|
||||
unit_id,
|
||||
section_order_index,
|
||||
phase_order_index=phase_oi,
|
||||
parallel_stream_order_index=parallel_stream_oi,
|
||||
)
|
||||
mod_items, src_mid = load_training_module_for_apply(cur, module_id, profile_id, role)
|
||||
_append_copied_module_items_to_section(cur, section_id, mod_items, src_mid)
|
||||
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
||||
|
|
@ -1863,6 +2360,7 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
|||
lead_ins,
|
||||
)
|
||||
if assistant_set:
|
||||
av_db = None if assistant_val is None else PsycopgJson(assistant_val)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_units (
|
||||
|
|
@ -1874,7 +2372,7 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
|||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
base_params + (assistant_val,),
|
||||
base_params + (av_db,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
|
|
@ -1892,10 +2390,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
|||
|
||||
unit_id = cur.fetchone()["id"]
|
||||
|
||||
_assert_single_plan_content_key_create(data)
|
||||
phases_in = data.get("phases")
|
||||
sections_in = data.get("sections")
|
||||
exercises_in = data.get("exercises")
|
||||
|
||||
if sections_in is not None:
|
||||
if phases_in is not None:
|
||||
_replace_unit_phases(cur, unit_id, phases_in, profile_id, role, profile_id)
|
||||
elif sections_in is not None:
|
||||
_replace_unit_sections(cur, unit_id, sections_in)
|
||||
elif tpl_id_safe:
|
||||
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
||||
|
|
@ -2013,7 +2515,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
eff_lead_for_co,
|
||||
)
|
||||
assist_sql = ", assistant_trainer_profile_ids = %s"
|
||||
assist_params.append(na)
|
||||
assist_params.append(None if na is None else PsycopgJson(na))
|
||||
|
||||
debrief_frag = ""
|
||||
if "debrief_completed" in data and not is_blueprint:
|
||||
|
|
@ -2071,20 +2573,29 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request",
|
||||
)
|
||||
_template_access(cur, tid, profile_id, role)
|
||||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||
cur.execute(
|
||||
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
|
||||
)
|
||||
_instantiate_from_template(cur, unit_id, tid)
|
||||
content_handled = True
|
||||
|
||||
if not content_handled and "sections" in data:
|
||||
_assert_single_plan_content_key_update(data)
|
||||
if not content_handled and "phases" in data:
|
||||
_replace_unit_phases(
|
||||
cur,
|
||||
unit_id,
|
||||
data.get("phases") or [],
|
||||
profile_id,
|
||||
role,
|
||||
unit_row.get("created_by"),
|
||||
)
|
||||
elif not content_handled and "sections" in data:
|
||||
_replace_unit_sections(cur, unit_id, data["sections"] or [])
|
||||
elif not content_handled and "exercises" in data:
|
||||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
_insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])
|
||||
|
||||
if content_handled or "sections" in data or "exercises" in data:
|
||||
if content_handled or any(k in data for k in ("phases", "sections", "exercises")):
|
||||
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
||||
|
||||
conn.commit()
|
||||
|
|
@ -2196,6 +2707,7 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext =
|
|||
str(planned_date),
|
||||
profile_id,
|
||||
slot_id,
|
||||
role,
|
||||
)
|
||||
|
||||
_promote_private_exercises_used_in_unit(cur, new_id, profile_id, role)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Query
|
||||
|
|
@ -11,6 +12,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
|||
|
||||
from fastapi_param_unwrap import unwrap_query_default
|
||||
from main import app
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -27,3 +29,41 @@ def test_unwrap_query_default_for_direct_route_calls() -> None:
|
|||
def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None:
|
||||
r = client.get("/api/dashboard/kpis")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def _fake_tenant_for_kpis() -> TenantContext:
|
||||
return TenantContext(
|
||||
profile_id=42,
|
||||
global_role="trainer",
|
||||
effective_club_id=7,
|
||||
club_ids=frozenset({7}),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
|
||||
@patch("routers.dashboard.list_training_units")
|
||||
@patch("routers.dashboard.list_exercises_like_get")
|
||||
def test_dashboard_kpis_200_when_inner_lists_mocked(
|
||||
mock_list_ex: object,
|
||||
mock_list_tu: object,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
mock_list_ex.return_value = []
|
||||
mock_list_tu.return_value = []
|
||||
app.dependency_overrides[get_tenant_context] = _fake_tenant_for_kpis
|
||||
try:
|
||||
r = client.get("/api/dashboard/kpis")
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert "year" in data
|
||||
assert data["draft_count"] == 0
|
||||
assert data["mine_count"] == 0
|
||||
assert data["ytd_completed_count"] == 0
|
||||
th = data["training_home"]
|
||||
assert th["upcoming"] == []
|
||||
assert th["planned_with_notes"] == []
|
||||
assert th["review_pending"] == []
|
||||
assert mock_list_ex.call_count == 2
|
||||
assert mock_list_tu.call_count == 3
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
|
|
|||
290
backend/tests/test_training_planning_sections_integration.py
Normal file
290
backend/tests/test_training_planning_sections_integration.py
Normal 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()
|
||||
32
backend/tests/test_training_planning_sections_pure.py
Normal file
32
backend/tests/test_training_planning_sections_pure.py
Normal 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"] == []
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.123"
|
||||
APP_VERSION = "0.8.140"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260514062"
|
||||
DB_SCHEMA_VERSION = "20260515063"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -24,7 +24,7 @@ MODULE_VERSIONS = {
|
|||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
||||
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.9.4", # list_training_units: Keyset-Pagination + stabile Sortierung (NULLS LAST + id)
|
||||
"planning": "0.11.0", # PUT/POST training_units: phases (parallel streams); Rahmen→Termin-Kopie _replace_unit_phases; apply-training-module phase_order_index + parallel_stream_order_index
|
||||
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
|
||||
"training_modules": "1.0.0",
|
||||
"import_wiki": "1.0.0",
|
||||
|
|
@ -36,6 +36,122 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.140",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"Frontend Trainingsplanung: Breakout-Panel (neue Ganzgruppen-/parallele Phase, Stream in letzter parallelen Phase); pro Abschnitt Zuordnung zu Phase/Stream oder klassischer Ein-Ganzgruppen-Ablauf.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.139",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"Frontend Trainingsplanung: GET phases → Editor mit planLoc pro Abschnitt; Speichern sendet PUT phases bei Breakout-Einheiten (sonst weiter sections); Modul-Dialog zeigt Phase/Stream in der Abschnittsauswahl.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.138",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"Planung Paket 2: POST/PUT training_units mit phases (voller Phasen-/Stream-Plan); höchstens eines von phases, sections, exercises pro Request; Rahmen-Blueprint→Termin kopiert verschachtelten Plan; apply-training-module optional phase_order_index + parallel_stream_order_index.",
|
||||
"Fix: POST from-framework-slot übergibt role an _copy_blueprint_into_scheduled_unit (Stream-Trainer-Validierung).",
|
||||
"Integrationstest test_replace_phases_roundtrip_parallel_stream.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.137",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"DB 063: training_unit_phases, training_unit_parallel_streams; Sektionen mit phase_id oder parallel_stream_id; Default whole_group für Bestand.",
|
||||
"Planung: GET training_unit liefert phases (verschachtelt) + sections (flach sortiert); Legacy-PUT sections weiterhin eine whole_group-Phase.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.136",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Fix: api/exercises.js — Mandanten-Header für Raw-fetch (PUT Übung, Medien-Upload, Bulk-Archiv) über lokale withActiveClubHeaders statt mergeActiveClubHeader-Import (ReferenceError beim Speichern).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.135",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Frontend Phase 4 Welle 3: frontend/src/api/exercises.js (Übungen, Medien/Archiv, Progressionsgraphen, KI); client.js exportiert API_URL und mergeActiveClubHeader; utils/api.js re-exportiert Modul, api-Objekt spread exercises.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.134",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"Frontend Phase 4 Welle 2: frontend/src/api/planning.js (Trainingsplanung); utils/api.js re-exportiert Modul, api-Objekt spread planning.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.133",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"Frontend Phase 4 Welle 1: frontend/src/api/client.js (request, ACTIVE_CLUB_STORAGE_KEY); utils/api.js importiert Client, bleibt Facade. Roadmap Phase 4 gestartet.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.132",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"Frontend Phase 3 abgeschlossen: TrainingPlanningPageRoot, ExerciseFormPageRoot, ExercisesListPageRoot unter components/; pages/ nur Re-Export (Soft-Limit). Roadmap UMSETZUNGSPLAN Phase 3 / M3 aktualisiert.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.131",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Frontend Phase 3: TrainingPlanningUnitFormModal (Neu/Bearbeiten-Einheit); frameworkLineageText in trainingPlanningPageHelpers; BASELINE_SNAPSHOT §3.4 k6-Log-Mapping.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.130",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Fix: PUT/POST training_units — assistant_trainer_profile_ids als JSONB mit psycopg2.extras.Json schreiben (rohe Python-Liste → ProgrammingError/500 bei Co-Zuweisung).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.129",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Frontend Phase 3: TrainingPlanningTrainerAssignModal (Trainer zuweisen) aus Trainingsplanungsseite; Handler per useCallback.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.128",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Frontend Phase 3: TrainingPlanningModuleApplyModal (Trainingsmodul einfügen) aus Trainingsplanungsseite; gemeinsamer Callback onModuleApplySectionIndexChange.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.126",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Frontend Phase 3: TrainingPlanningFrameworkImportModal aus Trainingsplanungsseite; Playwright-Test 13 (Rahmen-Dialog, skip ohne Gruppe).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.125",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Tests: Playwright 11 (Übungsliste Bulk-Toolbar), 12 (Trainingsplanung); Dashboard-Test 8 prüft HTTP 200 auf /api/dashboard/kpis; pytest test_dashboard_kpis_200_when_inner_lists_mocked.",
|
||||
"Frontend Phase 3: trainingPlanningPageHelpers.js aus TrainingPlanningPage; ExerciseListBulkToolbar data-testid.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.124",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Frontend Phase 3 (Teil): ExerciseListBulkToolbar-Komponente; Übungsliste nur Verdrahtung.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.123",
|
||||
"date": "2026-05-13",
|
||||
|
|
|
|||
|
|
@ -90,6 +90,22 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
|
|||
|----------|-------------------|------------------|
|
||||
| 10 VUs, 30 s `/health` | *—* | *nach Messung* |
|
||||
|
||||
### 3.4 Aus dem Deployment-/CI-Log übernehmen (k6 `k6-health-baseline`)
|
||||
|
||||
Das Skript `scripts/load/k6-health-baseline.js` nutzt **10 VUs**, **30 s**, Ziel **`GET {BASE_URL}/health`** (siehe Workflow-Env für `BASE_URL`).
|
||||
|
||||
**In die Tabelle oben (Abschnitt 3.3) eintragen — aus der k6-Zusammenfassung am Ende des Jobs:**
|
||||
|
||||
| Feld in BASELINE_SNAPSHOT | Wo im k6-Log (typisch) |
|
||||
|---------------------------|-------------------------|
|
||||
| **p95** (Latenz ms) | Zeile **`http_req_duration`** → Wert **`p(95)=…`** (ganze Zahl oder ms mit Einheit wie `12.34ms`) |
|
||||
| **Fehlerquote** | Zeile **`http_req_failed`** → z. B. `0.00%` bzw. `✓ 0%` — oder kurz „0 %“ notieren |
|
||||
| **Checks** (optional) | Zeile **`checks`** → Anteil **`✓`** (soll **100 %** sein, sonst Hinweis) |
|
||||
| **Datum / BASE_URL** | Deploy-Datum + die **öffentliche** Basis-URL des Laufs (wie im Workflow gesetzt, z. B. `https://dev.shinkan.jinkendo.de`) |
|
||||
| **App-Version** (optional) | dieselbe wie im Deploy (`backend/version.py` / Release), damit M2-Vergleich ressortfähig bleibt |
|
||||
|
||||
**Zusätzlich (Abschnitt 2.2):** nur die Zeile **`/health` GET`** mit dem **gleichen** p95 befüllen, wenn ihr dort noch Platzhalter habt — echte API-Routen (`/api/...`) kommen weiter aus Monitoring/k6 mit Auth, nicht aus diesem Job.
|
||||
|
||||
---
|
||||
|
||||
## 4. Nächster Schritt (Roadmap)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
|
|||
| [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md) | Zielarchitektur (Frontend, API, Daten), Qualitätsziele, Einbindung neuer Features |
|
||||
| [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md) | Erfasste Architekturschuld, Reihenfolge und Massnahmen zur Behebung |
|
||||
| [UMSETZUNGSPLAN_ROADMAP.md](./UMSETZUNGSPLAN_ROADMAP.md) | Phasen, Meilensteine, Abnahmekriterien, Aufwandsschwerpunkte |
|
||||
| [`frontend/src/api/client.js`](../../frontend/src/api/client.js) | Phase 4: zentraler HTTP-Client (`request`, `ACTIVE_CLUB_STORAGE_KEY`, `API_URL`, `mergeActiveClubHeader`) |
|
||||
| [`frontend/src/api/exercises.js`](../../frontend/src/api/exercises.js) | Phase 4: Übungen, Medien/Archiv, Progressionsgraphen, KI-Hilfen |
|
||||
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
|
||||
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
|
||||
| [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md) | **Verbindliche** Shinkan-spezifische Regeln (Ergänzung zu den globalen Rules) |
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
|
||||
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
|
||||
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
|
||||
- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. 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 9–10**.
|
||||
- **Phase 4 (fortlaufend 2026-05-14):** API **Welle 1** `client.js`; **Welle 2** `planning.js`; **Welle 3** `exercises.js`; `utils/api.js` bleibt Facade (`export *`, `api`-Objekt `...exercises`, `...planning`).
|
||||
|
||||
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
|
||||
|
|
@ -82,7 +83,9 @@
|
|||
| Virtualisierung für die längste produktive Liste | A1, S2 |
|
||||
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
|
||||
|
||||
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. 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 9–10**.
|
||||
|
||||
**Abgeschlossen (2026-05-14):** Routen bleiben unter `frontend/src/pages/`; schwere Implementierung in **`components/planning/TrainingPlanningPageRoot.jsx`**, **`components/exercises/ExerciseFormPageRoot.jsx`**, **`components/exercises/ExercisesListPageRoot.jsx`** — **`pages/*` nur Re-Export** (Soft-Limit ~500 Zeilen laut `VERBINDLICHE_REGELN_SHINKAN.md`).
|
||||
|
||||
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
|
||||
|
||||
|
|
@ -90,13 +93,17 @@
|
|||
|
||||
## Phase 4 – API-Client Modularisierung
|
||||
|
||||
**Status:** **fortlaufend** (2026-05-14) — Welle 1: **`client.js`**; Welle 2: **`planning.js`**; Welle 3: **`exercises.js`**; **`utils/api.js`** bleibt vollständige Facade.
|
||||
|
||||
**Fokus:** Wartbarkeit für viele neue Features.
|
||||
|
||||
| Task | Bezug |
|
||||
|------|--------|
|
||||
| `frontend/src/api/` anlegen, `request`/`client` zentral | A2 |
|
||||
| Facade: bestehende Importe von `utils/api` nicht sofort alle brechen; Migration in Wellen | A2 |
|
||||
| Neue Endpoints nur noch in Domänen-Dateien | S3 |
|
||||
| Task | Bezug | Status |
|
||||
|------|-------|--------|
|
||||
| `frontend/src/api/client.js` — zentraler HTTP-Client | A2 | erledigt (Welle 1) |
|
||||
| `frontend/src/api/planning.js` — Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, Dashboard-KPIs) | A2 | erledigt (Welle 2) |
|
||||
| `frontend/src/api/exercises.js` — Übungen, Medien/Archiv, Varianten, Progressionsgraphen, KI | A2 | erledigt (Welle 3) |
|
||||
| Weitere Domänen-Module unter `frontend/src/api/` + Entlastung von `utils/api.js` | A2 | offen |
|
||||
| Neue Endpoints primär in Domänen-Dateien | S3 | offen |
|
||||
|
||||
**Abnahme:** Anteil neuer Module > X% der neuen Zeilen (Team-Ziel); Monolith wächst nicht weiter.
|
||||
|
||||
|
|
@ -121,7 +128,7 @@
|
|||
|-------------|--------|
|
||||
| **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert |
|
||||
| **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen |
|
||||
| **M3** | Phase 3 Referenz-Page + Virtualisierung live |
|
||||
| **M3** | Phase 3 abgeschlossen: Page-Dateien Soft-Limit (Re-Export); Virtualisierung Übungsliste |
|
||||
| **M4** | Phase 4 migrationsbereit für alle neuen Features |
|
||||
| **M5** | Phase 5 für Top-Listen abgeschlossen |
|
||||
|
||||
|
|
|
|||
79
frontend/src/api/client.js
Normal file
79
frontend/src/api/client.js
Normal 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
|
||||
}
|
||||
}
|
||||
593
frontend/src/api/exercises.js
Normal file
593
frontend/src/api/exercises.js
Normal 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),
|
||||
})
|
||||
}
|
||||
171
frontend/src/api/planning.js
Normal file
171
frontend/src/api/planning.js
Normal 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' })
|
||||
}
|
||||
|
|
@ -5935,6 +5935,78 @@ a.analysis-split__nav-item {
|
|||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
|
||||
}
|
||||
|
||||
/* Planungseditor: Einfügebänder — Split vs. Ganzgruppe (inaktiv sichtbar, aktiv weiterhin Akzent-Ring) */
|
||||
.tu-section-dropband--region-split {
|
||||
height: 12px;
|
||||
background: hsl(200 38% 92%);
|
||||
border: 1px dashed hsl(200 42% 58%);
|
||||
}
|
||||
|
||||
.tu-section-dropband--region-whole {
|
||||
height: 12px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface2));
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
}
|
||||
|
||||
.tu-section-dropband--region-split-to-whole {
|
||||
height: 12px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(200 38% 92%) 0%,
|
||||
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 100%
|
||||
);
|
||||
border: 1px dashed hsl(200 36% 50%);
|
||||
}
|
||||
|
||||
.tu-section-dropband--region-whole-to-split {
|
||||
height: 12px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 0%,
|
||||
hsl(200 38% 92%) 100%
|
||||
);
|
||||
border: 1px dashed hsl(200 36% 50%);
|
||||
}
|
||||
|
||||
.tu-section-dropband--region-neutral {
|
||||
height: 11px;
|
||||
border: 1px dashed color-mix(in srgb, var(--border) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--surface2) 55%, transparent);
|
||||
}
|
||||
|
||||
/* Stream-Tag / Planende: klar getrennte Drop-Ziele bei parallelen Phasen */
|
||||
.tu-stream-chip-pill--drop-active {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.tu-section-dropband--whole-plan-end {
|
||||
background: color-mix(in srgb, var(--accent) 14%, var(--surface2));
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.tu-section-stream-append__drop {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Parallele Phase: feste Einfügebänder oberhalb/unterhalb des Split-Headers */
|
||||
.tu-phase-drop--above-split,
|
||||
.tu-phase-drop--below-split {
|
||||
margin: 6px 2px 8px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.tu-section-dropband--phase-parallel-slot {
|
||||
height: 14px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--accent) 12%, var(--surface2)) 0%,
|
||||
hsl(200 30% 94%) 100%
|
||||
);
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 38%, transparent);
|
||||
}
|
||||
|
||||
.tu-sec-drag-grip {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
|
|
@ -6586,6 +6658,21 @@ button.combo-coach-cand-link:hover {
|
|||
max-width: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
/* Split: jede weitere Breakout-Spalte auf neuer Seite (Gesamtplan-Druck) */
|
||||
.training-run-breakout-stream--page-break {
|
||||
break-before: page;
|
||||
page-break-before: always;
|
||||
}
|
||||
.training-run-parallel-columns {
|
||||
display: block !important;
|
||||
}
|
||||
.training-run-breakout-stream {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.training-run-phase-schedule {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.training-run-section,
|
||||
.training-run-header {
|
||||
break-inside: avoid;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
2447
frontend/src/components/exercises/ExerciseFormPageRoot.jsx
Normal file
2447
frontend/src/components/exercises/ExerciseFormPageRoot.jsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
590
frontend/src/components/exercises/ExercisesListPageRoot.jsx
Normal file
590
frontend/src/components/exercises/ExercisesListPageRoot.jsx
Normal 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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
2023
frontend/src/components/planning/TrainingPlanningPageRoot.jsx
Normal file
2023
frontend/src/components/planning/TrainingPlanningPageRoot.jsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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-/Co‑Trainer
|
||||
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'
|
||||
? 'Ist‑Minuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.'
|
||||
: 'Ablauf, Übungen und geplante Minuten. Ist‑Werte 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
|
|
@ -1,599 +1,2 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import ExerciseListCard from '../components/exercises/ExerciseListCard'
|
||||
import ExerciseListFilterModal from '../components/exercises/ExerciseListFilterModal'
|
||||
import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal'
|
||||
import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar'
|
||||
import { buildExerciseListFilterChips } from '../utils/exerciseListFilterChips'
|
||||
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../utils/exerciseListQuery'
|
||||
import { useExerciseListCatalogsAndQuery } from '../hooks/useExerciseListCatalogsAndQuery'
|
||||
import {
|
||||
INITIAL_EXERCISE_LIST_FILTERS,
|
||||
mergeExerciseListPrefsFromApi,
|
||||
compactExerciseListPrefsPayload,
|
||||
} from '../constants/exerciseListFilters'
|
||||
|
||||
const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel'))
|
||||
|
||||
const BULK_MAX_IDS = 500
|
||||
const EXERCISES_PAGE_TABS = [
|
||||
{ id: 'list', label: 'Liste' },
|
||||
{ id: 'progression', label: 'Progressionsgraphen' },
|
||||
]
|
||||
|
||||
function ExercisesListPage() {
|
||||
const { user, checkAuth } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
||||
|
||||
const [mineOnly, setMineOnly] = useState(() => {
|
||||
try {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
return sp.get('mine') === '1' || sp.get('created_by_me') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [aiSearchInput, setAiSearchInput] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
||||
const [filters, setFilters] = useState(() => ({ ...INITIAL_EXERCISE_LIST_FILTERS }))
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||
const [savingExercisePrefs, setSavingExercisePrefs] = useState(false)
|
||||
const [pageTab, setPageTab] = useState('list')
|
||||
const prefsAppliedRef = useRef(false)
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set())
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false)
|
||||
const [bulkVisibility, setBulkVisibility] = useState('')
|
||||
const [bulkStatus, setBulkStatus] = useState('')
|
||||
const [bulkClubSelect, setBulkClubSelect] = useState('')
|
||||
const [bulkClubManual, setBulkClubManual] = useState('')
|
||||
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
||||
const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false)
|
||||
const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([])
|
||||
const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false)
|
||||
const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([])
|
||||
const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false)
|
||||
const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
|
||||
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
|
||||
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return
|
||||
if (prefsAppliedRef.current) return
|
||||
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
|
||||
setFilters(applyDashboardExerciseListUrl(merged))
|
||||
try {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
prefsAppliedRef.current = true
|
||||
}, [user?.id, user?.exercise_list_prefs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) prefsAppliedRef.current = false
|
||||
}, [user?.id])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
||||
return () => clearTimeout(t)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
|
||||
return () => clearTimeout(t)
|
||||
}, [aiSearchInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterModalOpen) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') setFilterModalOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [filterModalOpen])
|
||||
|
||||
const queryBase = useMemo(
|
||||
() => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly),
|
||||
[filters, debouncedSearch, debouncedAiSearch, mineOnly]
|
||||
)
|
||||
|
||||
const {
|
||||
catalogs,
|
||||
catalogsReady,
|
||||
exercises,
|
||||
setExercises,
|
||||
listFetching,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [queryBase])
|
||||
|
||||
const focusOptions = useMemo(
|
||||
() =>
|
||||
catalogs.focusAreas.map((fa) => ({
|
||||
id: fa.id,
|
||||
label: `${fa.icon || ''} ${fa.name || ''}`.trim(),
|
||||
})),
|
||||
[catalogs.focusAreas]
|
||||
)
|
||||
const styleOptions = useMemo(
|
||||
() => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
|
||||
[catalogs.styleDirections]
|
||||
)
|
||||
const trainingTypeOptions = useMemo(
|
||||
() => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
|
||||
[catalogs.trainingTypes]
|
||||
)
|
||||
const targetGroupOptions = useMemo(
|
||||
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
|
||||
[catalogs.targetGroups]
|
||||
)
|
||||
const skillOptions = useMemo(
|
||||
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
|
||||
[catalogs.skills]
|
||||
)
|
||||
const visibilityOptions = useMemo(
|
||||
() => [
|
||||
{ id: 'private', label: 'Privat' },
|
||||
{ id: 'club', label: 'Verein' },
|
||||
{ id: 'official', label: 'Offiziell' },
|
||||
],
|
||||
[]
|
||||
)
|
||||
const statusOptions = useMemo(
|
||||
() => [
|
||||
{ id: 'draft', label: 'Entwurf' },
|
||||
{ id: 'in_review', label: 'In Prüfung' },
|
||||
{ id: 'approved', label: 'Freigegeben' },
|
||||
{ id: 'archived', label: 'Archiviert' },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const filterChips = useMemo(
|
||||
() =>
|
||||
buildExerciseListFilterChips({
|
||||
mineOnly,
|
||||
setMineOnly,
|
||||
filters,
|
||||
setFilters,
|
||||
focusOptions,
|
||||
styleOptions,
|
||||
trainingTypeOptions,
|
||||
targetGroupOptions,
|
||||
skillOptions,
|
||||
visibilityOptions,
|
||||
statusOptions,
|
||||
}),
|
||||
[
|
||||
mineOnly,
|
||||
filters,
|
||||
focusOptions,
|
||||
styleOptions,
|
||||
trainingTypeOptions,
|
||||
targetGroupOptions,
|
||||
skillOptions,
|
||||
visibilityOptions,
|
||||
statusOptions,
|
||||
]
|
||||
)
|
||||
|
||||
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
|
||||
const searchTitleSuggestions = useMemo(() => {
|
||||
const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
|
||||
return [...new Set(titles)].slice(0, 80)
|
||||
}, [exercises])
|
||||
|
||||
const clubNameById = useMemo(() => {
|
||||
const m = {}
|
||||
for (const c of activeClubMemberships(user?.clubs)) {
|
||||
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
|
||||
}
|
||||
return m
|
||||
}, [user?.clubs])
|
||||
|
||||
const effectiveClubId =
|
||||
user?.effective_club_id != null && user.effective_club_id !== ''
|
||||
? Number(user.effective_club_id)
|
||||
: user?.active_club_id != null && user.active_club_id !== ''
|
||||
? Number(user.active_club_id)
|
||||
: null
|
||||
|
||||
const toggleSelect = useCallback((id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const n = new Set(prev)
|
||||
const nid = Number(id)
|
||||
if (Number.isNaN(nid)) return prev
|
||||
if (n.has(nid)) n.delete(nid)
|
||||
else n.add(nid)
|
||||
return n
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
||||
|
||||
const toggleSelectAllPage = useCallback(() => {
|
||||
setSelectedIds((prev) => {
|
||||
const n = new Set(prev)
|
||||
const allSel =
|
||||
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
|
||||
if (allSel) {
|
||||
exercises.forEach((e) => n.delete(Number(e.id)))
|
||||
} else {
|
||||
exercises.forEach((e) => n.add(Number(e.id)))
|
||||
}
|
||||
return n
|
||||
})
|
||||
}, [exercises])
|
||||
|
||||
const allOnPageSelected = useMemo(
|
||||
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
|
||||
[exercises, selectedIds]
|
||||
)
|
||||
|
||||
const bulkVisibilityOptions = useMemo(() => {
|
||||
const base = [
|
||||
{ id: '', label: '— nicht ändern —' },
|
||||
{ id: 'private', label: 'Privat' },
|
||||
{ id: 'club', label: 'Verein' },
|
||||
]
|
||||
if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
|
||||
return base
|
||||
}, [isSuperadmin])
|
||||
|
||||
const handleDelete = async (exercise) => {
|
||||
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
||||
try {
|
||||
await api.deleteExercise(exercise.id)
|
||||
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
|
||||
} catch (err) {
|
||||
alert('Fehler beim Löschen: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const resetAllFilters = useCallback(() => {
|
||||
setMineOnly(false)
|
||||
setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS })
|
||||
}, [])
|
||||
|
||||
const handleSaveExerciseFilterPrefs = useCallback(async () => {
|
||||
const uid = user?.id
|
||||
if (!uid) {
|
||||
alert('Nicht angemeldet.')
|
||||
return
|
||||
}
|
||||
setSavingExercisePrefs(true)
|
||||
try {
|
||||
const payload = compactExerciseListPrefsPayload(filters)
|
||||
await api.updateProfile(uid, { exercise_list_prefs: payload })
|
||||
await checkAuth()
|
||||
alert('Standardfilter für die Übungsliste gespeichert.')
|
||||
} catch (e) {
|
||||
alert('Speichern fehlgeschlagen: ' + (e.message || String(e)))
|
||||
} finally {
|
||||
setSavingExercisePrefs(false)
|
||||
}
|
||||
}, [user?.id, filters, checkAuth])
|
||||
|
||||
const openBulkModal = () => {
|
||||
setBulkVisibility('')
|
||||
setBulkStatus('')
|
||||
setBulkClubSelect('')
|
||||
setBulkClubManual('')
|
||||
setBulkPatchFocusAreas(false)
|
||||
setBulkFocusAreaIds([])
|
||||
setBulkPatchStyleDirections(false)
|
||||
setBulkStyleDirectionIds([])
|
||||
setBulkPatchTrainingTypes(false)
|
||||
setBulkTrainingTypeIds([])
|
||||
setBulkPatchTargetGroups(false)
|
||||
setBulkTargetGroupIds([])
|
||||
setBulkModalOpen(true)
|
||||
}
|
||||
|
||||
const handleBulkSubmit = async () => {
|
||||
const anyRelationPatch =
|
||||
bulkPatchFocusAreas ||
|
||||
bulkPatchStyleDirections ||
|
||||
bulkPatchTrainingTypes ||
|
||||
bulkPatchTargetGroups
|
||||
if (!bulkVisibility && !bulkStatus && !anyRelationPatch) {
|
||||
alert(
|
||||
'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).'
|
||||
)
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
|
||||
if (ids.length === 0) {
|
||||
alert('Keine Übungen ausgewählt.')
|
||||
return
|
||||
}
|
||||
if (ids.length > BULK_MAX_IDS) {
|
||||
alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
|
||||
return
|
||||
}
|
||||
const payload = { exercise_ids: ids }
|
||||
if (bulkVisibility) payload.visibility = bulkVisibility
|
||||
if (bulkStatus) payload.status = bulkStatus
|
||||
if (bulkPatchFocusAreas) {
|
||||
payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
|
||||
}
|
||||
if (bulkPatchStyleDirections) {
|
||||
payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
|
||||
}
|
||||
if (bulkPatchTrainingTypes) {
|
||||
payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
|
||||
}
|
||||
if (bulkPatchTargetGroups) {
|
||||
payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
|
||||
}
|
||||
if (bulkVisibility === 'club') {
|
||||
const manual = String(bulkClubManual || '').trim()
|
||||
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
|
||||
else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
|
||||
payload.club_id = Number(bulkClubSelect)
|
||||
}
|
||||
}
|
||||
setBulkSubmitting(true)
|
||||
try {
|
||||
const res = await api.bulkPatchExercisesMetadata(payload)
|
||||
const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
|
||||
let resolvedClubId = null
|
||||
if (bulkVisibility === 'club') {
|
||||
if (payload.club_id != null) resolvedClubId = payload.club_id
|
||||
else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
|
||||
}
|
||||
const clubLabel =
|
||||
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
|
||||
|
||||
let nextPrimaryFocusName = null
|
||||
if (bulkPatchFocusAreas) {
|
||||
const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
|
||||
if (faNums.length > 0) {
|
||||
const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0]))
|
||||
nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0])
|
||||
}
|
||||
}
|
||||
|
||||
setExercises((prev) =>
|
||||
prev.map((e) => {
|
||||
if (!updatedSet.has(Number(e.id))) return e
|
||||
const next = { ...e }
|
||||
if (bulkVisibility) {
|
||||
next.visibility = bulkVisibility
|
||||
next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
|
||||
next.club_name = bulkVisibility === 'club' ? clubLabel : null
|
||||
}
|
||||
if (bulkStatus) next.status = bulkStatus
|
||||
if (bulkPatchFocusAreas) {
|
||||
if (nextPrimaryFocusName == null) delete next.focus_area
|
||||
else next.focus_area = nextPrimaryFocusName
|
||||
}
|
||||
return next
|
||||
})
|
||||
)
|
||||
|
||||
let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
|
||||
if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
|
||||
if (Array.isArray(res.failed) && res.failed.length) {
|
||||
msg +=
|
||||
'\n\n' +
|
||||
res.failed
|
||||
.slice(0, 12)
|
||||
.map((f) => `#${f.id}: ${f.detail}`)
|
||||
.join('\n')
|
||||
if (res.failed.length > 12) msg += '\n…'
|
||||
}
|
||||
alert(msg)
|
||||
setBulkModalOpen(false)
|
||||
clearSelection()
|
||||
} catch (err) {
|
||||
alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
|
||||
} finally {
|
||||
setBulkSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!catalogsReady && pageTab === 'list') {
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="empty-state" style={{ padding: '2.5rem 1rem' }}>
|
||||
<div className="spinner" />
|
||||
<p className="muted" style={{ marginTop: '12px' }}>
|
||||
Lade Kataloge…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="exercises-page__header">
|
||||
<h1 className="page-title exercises-page__title">Übungen</h1>
|
||||
{pageTab === 'list' ? (
|
||||
<Link to="/exercises/new" className="btn btn-primary">
|
||||
+ Neu
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageSectionNav
|
||||
ariaLabel="Übungen Bereiche"
|
||||
value={pageTab}
|
||||
onChange={setPageTab}
|
||||
items={EXERCISES_PAGE_TABS}
|
||||
className="exercises-page-toolbar-tabs"
|
||||
/>
|
||||
|
||||
{pageTab === 'progression' ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
||||
<div className="spinner" />
|
||||
<p className="muted" style={{ marginTop: '12px' }}>
|
||||
Lade Progressionsgraphen…
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ExerciseProgressionGraphPanel />
|
||||
</Suspense>
|
||||
) : (
|
||||
<>
|
||||
<ExerciseListSearchBar
|
||||
searchTitleSuggestions={searchTitleSuggestions}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={setSearchInput}
|
||||
aiSearchInput={aiSearchInput}
|
||||
onAiSearchInputChange={setAiSearchInput}
|
||||
mineOnly={mineOnly}
|
||||
onToggleMineOnly={() => setMineOnly((v) => !v)}
|
||||
onOpenFilter={() => setFilterModalOpen(true)}
|
||||
filterChips={filterChips}
|
||||
onResetAllFilters={resetAllFilters}
|
||||
exerciseCount={exercises.length}
|
||||
allOnPageSelected={allOnPageSelected}
|
||||
onToggleSelectAllPage={toggleSelectAllPage}
|
||||
/>
|
||||
|
||||
{selectedIds.size > 0 ? (
|
||||
<div className="card exercise-bulk-toolbar">
|
||||
<strong>{selectedIds.size} ausgewählt</strong>
|
||||
<button type="button" className="btn btn-secondary btn-small" onClick={clearSelection}>
|
||||
Auswahl aufheben
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-small" onClick={openBulkModal}>
|
||||
Massenänderung…
|
||||
</button>
|
||||
<span className="exercise-bulk-toolbar__meta">
|
||||
Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext (
|
||||
<code>X-Active-Club-Id</code>
|
||||
).
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ExerciseListFilterModal
|
||||
open={filterModalOpen}
|
||||
onClose={() => setFilterModalOpen(false)}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
focusOptions={focusOptions}
|
||||
styleOptions={styleOptions}
|
||||
trainingTypeOptions={trainingTypeOptions}
|
||||
targetGroupOptions={targetGroupOptions}
|
||||
skillOptions={skillOptions}
|
||||
visibilityOptions={visibilityOptions}
|
||||
statusOptions={statusOptions}
|
||||
savingExercisePrefs={savingExercisePrefs}
|
||||
onSaveStandard={handleSaveExerciseFilterPrefs}
|
||||
onResetAll={resetAllFilters}
|
||||
/>
|
||||
|
||||
<ExerciseListBulkModal
|
||||
open={bulkModalOpen}
|
||||
onClose={() => setBulkModalOpen(false)}
|
||||
onSubmit={handleBulkSubmit}
|
||||
bulkSubmitting={bulkSubmitting}
|
||||
selectedCount={selectedIds.size}
|
||||
bulkMaxIds={BULK_MAX_IDS}
|
||||
user={user}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
statusOptions={statusOptions}
|
||||
bulkVisibilityOptions={bulkVisibilityOptions}
|
||||
focusOptions={focusOptions}
|
||||
styleOptions={styleOptions}
|
||||
trainingTypeOptions={trainingTypeOptions}
|
||||
targetGroupOptions={targetGroupOptions}
|
||||
bulkVisibility={bulkVisibility}
|
||||
setBulkVisibility={setBulkVisibility}
|
||||
bulkStatus={bulkStatus}
|
||||
setBulkStatus={setBulkStatus}
|
||||
bulkClubSelect={bulkClubSelect}
|
||||
setBulkClubSelect={setBulkClubSelect}
|
||||
bulkClubManual={bulkClubManual}
|
||||
setBulkClubManual={setBulkClubManual}
|
||||
bulkPatchFocusAreas={bulkPatchFocusAreas}
|
||||
setBulkPatchFocusAreas={setBulkPatchFocusAreas}
|
||||
bulkFocusAreaIds={bulkFocusAreaIds}
|
||||
setBulkFocusAreaIds={setBulkFocusAreaIds}
|
||||
bulkPatchStyleDirections={bulkPatchStyleDirections}
|
||||
setBulkPatchStyleDirections={setBulkPatchStyleDirections}
|
||||
bulkStyleDirectionIds={bulkStyleDirectionIds}
|
||||
setBulkStyleDirectionIds={setBulkStyleDirectionIds}
|
||||
bulkPatchTrainingTypes={bulkPatchTrainingTypes}
|
||||
setBulkPatchTrainingTypes={setBulkPatchTrainingTypes}
|
||||
bulkTrainingTypeIds={bulkTrainingTypeIds}
|
||||
setBulkTrainingTypeIds={setBulkTrainingTypeIds}
|
||||
bulkPatchTargetGroups={bulkPatchTargetGroups}
|
||||
setBulkPatchTargetGroups={setBulkPatchTargetGroups}
|
||||
bulkTargetGroupIds={bulkTargetGroupIds}
|
||||
setBulkTargetGroupIds={setBulkTargetGroupIds}
|
||||
/>
|
||||
|
||||
{listFetching && exercises.length === 0 ? (
|
||||
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
||||
<div className="spinner" />
|
||||
<p className="muted" style={{ marginTop: '12px' }}>
|
||||
Lade Übungen…
|
||||
</p>
|
||||
</div>
|
||||
) : exercises.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{listFetching ? (
|
||||
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
||||
) : null}
|
||||
<p className="exercises-meta-line">
|
||||
{exercises.length} angezeigt
|
||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||
</p>
|
||||
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseListCard
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
user={user}
|
||||
selectedIds={selectedIds}
|
||||
toggleSelect={toggleSelect}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="exercises-load-more">
|
||||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExercisesListPage
|
||||
/** Routen-Einstieg: Implementierung in `components/exercises/ExercisesListPageRoot.jsx` (Phase-3 Soft-Limit). */
|
||||
export { default } from '../components/exercises/ExercisesListPageRoot'
|
||||
|
|
|
|||
|
|
@ -1,20 +1,43 @@
|
|||
/**
|
||||
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
|
||||
* Coach-Modus: Schrittfolge mit Split-Punkten (branch_gate), Stream-Wahl pro paralleler Phase, Assistenz und Zeitnahme.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseFullContent from '../components/ExerciseFullContent'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import {
|
||||
COACH_ENTRY_BRANCH_GATE,
|
||||
buildCoachSavePlanPayload,
|
||||
coachBranchPicksStepStorageSuffix,
|
||||
coachBranchPicksStorageKey,
|
||||
coachOutlineGroupsFromTimeline,
|
||||
coachShouldPromptSplitRejoin,
|
||||
coachShouldPromptSplitRejoinTransition,
|
||||
durationOverridesMapFromDeltas,
|
||||
findCoachTimelineJumpIndexForPhase,
|
||||
flattenPlanTimeline,
|
||||
itemStableKey,
|
||||
sectionsToPutPayload,
|
||||
listCoachStreamFocusOptions,
|
||||
mergeCoachBranchPicksWithUrlFocus,
|
||||
normalizeCoachBranchPicks,
|
||||
summarizeTimelineEntry,
|
||||
} from '../utils/trainingPlanUtils'
|
||||
|
||||
function storageStepKey(unitId) {
|
||||
return `sj_coach_step_${unitId}`
|
||||
function storageStepKey(unitId, mergedPicks) {
|
||||
return `sj_coach_step_${unitId}_${coachBranchPicksStepStorageSuffix(mergedPicks)}`
|
||||
}
|
||||
|
||||
function clearCoachStepStorageForUnit(unitId) {
|
||||
const pref = `sj_coach_step_${unitId}_`
|
||||
try {
|
||||
for (let i = sessionStorage.length - 1; i >= 0; i -= 1) {
|
||||
const k = sessionStorage.key(i)
|
||||
if (k && k.startsWith(pref)) sessionStorage.removeItem(k)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function storageDeltasKey(unitId) {
|
||||
|
|
@ -54,14 +77,16 @@ function CoachControlsBand({
|
|||
showJumpToTimerOwnerRow = true,
|
||||
onJumpToTimerOwner,
|
||||
timerOwnerLabelIndex,
|
||||
branchGateMode = false,
|
||||
}) {
|
||||
const disPrev = step <= 0
|
||||
const disNext = step >= timelineLength - 1
|
||||
const disNext = branchGateMode || step >= timelineLength - 1
|
||||
const alive = runStartAt != null || pausedAccumMs > 0
|
||||
const canApplyForOwner = roundedMinForApply != null && roundedMinForApply >= 1
|
||||
const canApplyForOwner = !branchGateMode && roundedMinForApply != null && roundedMinForApply >= 1
|
||||
const istLabelMin = roundedMinForApply == null ? '—' : String(roundedMinForApply)
|
||||
const doneLabel =
|
||||
timelineLength <= 1
|
||||
const doneLabel = branchGateMode
|
||||
? 'Zuerst Gruppe wählen'
|
||||
: timelineLength <= 1
|
||||
? 'Nachbereitung öffnen'
|
||||
: isLastCoachStep
|
||||
? 'Nachbereitung & Ist-Zeit'
|
||||
|
|
@ -105,6 +130,7 @@ function CoachControlsBand({
|
|||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ minHeight: '44px', flex: '1 1 auto', fontWeight: 700 }}
|
||||
disabled={branchGateMode}
|
||||
onClick={onTimerStart}
|
||||
>
|
||||
▶ Start{alive ? ` · ${clockStr}` : ''}
|
||||
|
|
@ -117,7 +143,7 @@ function CoachControlsBand({
|
|||
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px', flex: '0 1 auto', fontWeight: 600 }} disabled={!canApplyForOwner} onClick={onApplyActual}>
|
||||
Ist ({istLabelMin} Min)
|
||||
</button>
|
||||
{alive && (
|
||||
{alive && !branchGateMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
|
|
@ -133,6 +159,7 @@ function CoachControlsBand({
|
|||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', minHeight: '44px', fontWeight: 700, lineHeight: 1.2, padding: '6px 10px', whiteSpace: 'normal', textAlign: 'center' }}
|
||||
disabled={branchGateMode}
|
||||
onClick={onDone}
|
||||
>
|
||||
{doneLabel}
|
||||
|
|
@ -155,8 +182,11 @@ function CoachControlsBand({
|
|||
export default function TrainingCoachPage() {
|
||||
const { unitId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const idNum = unitId ? parseInt(unitId, 10) : NaN
|
||||
|
||||
const coachFocusResetRef = useRef(null)
|
||||
|
||||
const [unit, setUnit] = useState(null)
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -169,6 +199,8 @@ export default function TrainingCoachPage() {
|
|||
const [coachDebriefPhase, setCoachDebriefPhase] = useState(false)
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
const [branchPicks, setBranchPicks] = useState({})
|
||||
const [streamChoiceHint, setStreamChoiceHint] = useState(null)
|
||||
const [deltas, setDeltas] = useState({})
|
||||
|
||||
const [runStartAt, setRunStartAt] = useState(null)
|
||||
|
|
@ -176,6 +208,8 @@ export default function TrainingCoachPage() {
|
|||
const [timerOwningStep, setTimerOwningStep] = useState(null)
|
||||
const [, setPulse] = useState(0)
|
||||
|
||||
const [splitRejoinPrompt, setSplitRejoinPrompt] = useState(null)
|
||||
|
||||
const [trainerAppend, setTrainerAppend] = useState('')
|
||||
const [saveMarkDone, setSaveMarkDone] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
|
@ -187,12 +221,52 @@ export default function TrainingCoachPage() {
|
|||
setUnit(u)
|
||||
}, [idNum])
|
||||
|
||||
const coachFocus = useMemo(() => {
|
||||
const poRaw = searchParams.get('po')
|
||||
const soRaw = searchParams.get('so')
|
||||
if (poRaw == null || poRaw === '' || soRaw == null || soRaw === '') return null
|
||||
const po = parseInt(poRaw, 10)
|
||||
const so = parseInt(soRaw, 10)
|
||||
if (!Number.isFinite(po) || !Number.isFinite(so)) return null
|
||||
if (!unit) return null
|
||||
const opts = listCoachStreamFocusOptions(unit)
|
||||
if (!opts.some((o) => o.phaseOrder === po && o.streamOrder === so)) return null
|
||||
return { phaseOrder: po, streamOrder: so }
|
||||
}, [searchParams, unit])
|
||||
|
||||
const mergedPicks = useMemo(
|
||||
() => mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocus),
|
||||
[branchPicks, coachFocus]
|
||||
)
|
||||
|
||||
const streamFocusOptions = useMemo(() => (unit ? listCoachStreamFocusOptions(unit) : []), [unit])
|
||||
|
||||
const navigationKey = coachBranchPicksStepStorageSuffix(mergedPicks)
|
||||
|
||||
const streamQuickSelectValue = useMemo(() => {
|
||||
for (let i = 0; i < streamFocusOptions.length; i++) {
|
||||
const o = streamFocusOptions[i]
|
||||
if (mergedPicks[o.phaseOrder] === o.streamOrder) return o.valueKey
|
||||
}
|
||||
return ''
|
||||
}, [streamFocusOptions, mergedPicks])
|
||||
|
||||
const hasStreamSelectParams =
|
||||
(searchParams.get('po') != null && searchParams.get('po') !== '') ||
|
||||
(searchParams.get('so') != null && searchParams.get('so') !== '')
|
||||
const streamParamsInvalid = Boolean(unit && hasStreamSelectParams && !coachFocus)
|
||||
|
||||
const timeline = useMemo(() => flattenPlanTimeline(unit, mergedPicks), [unit, mergedPicks])
|
||||
|
||||
const outlineGroups = useMemo(() => coachOutlineGroupsFromTimeline(timeline), [timeline])
|
||||
|
||||
useEffect(() => {
|
||||
if (!unitId || Number.isNaN(idNum)) {
|
||||
setLoadError('Ungültige Trainingseinheit')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
coachFocusResetRef.current = null
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -200,9 +274,13 @@ export default function TrainingCoachPage() {
|
|||
try {
|
||||
await reloadUnit()
|
||||
if (cancelled) return
|
||||
let nextBranchPicks = {}
|
||||
try {
|
||||
const s = parseInt(sessionStorage.getItem(storageStepKey(idNum)), 10)
|
||||
if (!Number.isNaN(s) && s >= 0) setStep(s)
|
||||
const br = sessionStorage.getItem(coachBranchPicksStorageKey(idNum))
|
||||
if (br) {
|
||||
const o = JSON.parse(br)
|
||||
if (o && typeof o === 'object') nextBranchPicks = normalizeCoachBranchPicks(o)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
|
@ -220,6 +298,10 @@ export default function TrainingCoachPage() {
|
|||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!cancelled) {
|
||||
setBranchPicks(nextBranchPicks)
|
||||
setStreamChoiceHint(null)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setLoadError(e.message || 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
|
|
@ -232,8 +314,68 @@ export default function TrainingCoachPage() {
|
|||
}, [unitId, idNum, reloadUnit])
|
||||
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem(storageStepKey(idNum), String(step))
|
||||
}, [idNum, step])
|
||||
if (!unit) return
|
||||
const po = searchParams.get('po')
|
||||
const so = searchParams.get('so')
|
||||
if ((po == null || po === '') && (so == null || so === '')) return
|
||||
const p = parseInt(po, 10)
|
||||
const s = parseInt(so, 10)
|
||||
if (!Number.isFinite(p) || !Number.isFinite(s)) {
|
||||
setSearchParams({}, { replace: true })
|
||||
return
|
||||
}
|
||||
const opts = listCoachStreamFocusOptions(unit)
|
||||
if (opts.length === 0 || !opts.some((o) => o.phaseOrder === p && o.streamOrder === s)) {
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [unit, searchParams, setSearchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (!unit) return
|
||||
const ab = searchParams.get('atBranch')
|
||||
if (ab == null || ab === '') return
|
||||
const po = parseInt(ab, 10)
|
||||
const phaseOrders = [...new Set(streamFocusOptions.map((o) => o.phaseOrder))]
|
||||
if (!Number.isFinite(po) || !phaseOrders.includes(po)) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('atBranch')
|
||||
next.delete('preferSo')
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
}, [unit, searchParams, setSearchParams, streamFocusOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!unit || timeline.length === 0) return
|
||||
const ab = searchParams.get('atBranch')
|
||||
if (ab == null || ab === '') return
|
||||
const po = parseInt(ab, 10)
|
||||
if (!Number.isFinite(po)) return
|
||||
const prefRaw = searchParams.get('preferSo')
|
||||
const pref = prefRaw != null && prefRaw !== '' ? parseInt(prefRaw, 10) : NaN
|
||||
const ix = findCoachTimelineJumpIndexForPhase(timeline, po, Number.isFinite(pref) ? pref : null)
|
||||
if (ix >= 0) {
|
||||
setStep(ix)
|
||||
if (Number.isFinite(pref)) setStreamChoiceHint({ phaseOrder: po, streamOrder: pref })
|
||||
}
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('atBranch')
|
||||
next.delete('preferSo')
|
||||
setSearchParams(next, { replace: true })
|
||||
}, [unit, timeline, searchParams, setSearchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isNaN(idNum)) return
|
||||
try {
|
||||
sessionStorage.setItem(coachBranchPicksStorageKey(idNum), JSON.stringify(branchPicks))
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}, [idNum, branchPicks])
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isNaN(idNum)) return
|
||||
sessionStorage.setItem(storageStepKey(idNum, mergedPicks), String(step))
|
||||
}, [idNum, mergedPicks, step])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -257,46 +399,91 @@ export default function TrainingCoachPage() {
|
|||
return () => clearInterval(iv)
|
||||
}, [runStartAt])
|
||||
|
||||
const timeline = useMemo(() => flattenPlanTimeline(unit), [unit])
|
||||
|
||||
const clampStep = (s, len = timeline.length) =>
|
||||
Math.max(0, Math.min(s, Math.max(len - 1, 0)))
|
||||
|
||||
const timerReset = useCallback(() => {
|
||||
setRunStartAt(null)
|
||||
setPausedAccumMs(0)
|
||||
setTimerOwningStep(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!unit) return
|
||||
if (timeline.length === 0) {
|
||||
setStep(0)
|
||||
return
|
||||
}
|
||||
if (coachDebriefPhase) return
|
||||
setStep((prev) => clampStep(prev, timeline.length))
|
||||
if (coachDebriefPhase) {
|
||||
setStep(timeline.length - 1)
|
||||
} else {
|
||||
setStep((prev) => clampStep(prev, timeline.length))
|
||||
}
|
||||
}, [unit, timeline.length, coachDebriefPhase])
|
||||
|
||||
useEffect(() => {
|
||||
if (!coachDebriefPhase || !unit || timeline.length === 0) return
|
||||
setStep(timeline.length - 1)
|
||||
}, [coachDebriefPhase, unit, timeline.length])
|
||||
if (!unit || Number.isNaN(idNum)) return
|
||||
if (timeline.length === 0) {
|
||||
setStep(0)
|
||||
return
|
||||
}
|
||||
const prev = coachFocusResetRef.current
|
||||
if (prev === null) {
|
||||
coachFocusResetRef.current = navigationKey
|
||||
} else if (prev !== navigationKey) {
|
||||
coachFocusResetRef.current = navigationKey
|
||||
setCoachDebriefPhase(false)
|
||||
setSplitRejoinPrompt(null)
|
||||
timerReset()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageStepKey(idNum, mergedPicks))
|
||||
const s = parseInt(raw, 10)
|
||||
const maxIdx = Math.max(0, timeline.length - 1)
|
||||
if (!Number.isNaN(s) && s >= 0) setStep(Math.min(s, maxIdx))
|
||||
else setStep(0)
|
||||
} catch {
|
||||
setStep(0)
|
||||
}
|
||||
}, [unit, idNum, navigationKey, mergedPicks, timeline.length, timerReset])
|
||||
|
||||
const elapsedMs =
|
||||
pausedAccumMs + (runStartAt != null ? Date.now() - runStartAt : 0)
|
||||
|
||||
const tickDisplaySec = Math.max(0, Math.floor(elapsedMs / 1000))
|
||||
|
||||
const currentEntry = timeline[step]
|
||||
const nextEntry = timeline[step + 1] || null
|
||||
const next2Entry = timeline[step + 2] || null
|
||||
const safeStep = useMemo(() => {
|
||||
if (!timeline.length) return 0
|
||||
return Math.min(Math.max(0, step), timeline.length - 1)
|
||||
}, [step, timeline.length])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!timeline.length) return
|
||||
const max = timeline.length - 1
|
||||
const s = Math.min(Math.max(0, step), max)
|
||||
if (s !== step) setStep(s)
|
||||
}, [step, timeline.length])
|
||||
|
||||
const currentEntry = timeline[safeStep] ?? null
|
||||
const nextEntry = timeline[safeStep + 1] || null
|
||||
const next2Entry = timeline[safeStep + 2] || null
|
||||
|
||||
const clockStr = formatClock(tickDisplaySec)
|
||||
const roundedMinApply = elapsedMs <= 650 ? null : Math.max(1, Math.round(elapsedMs / 60000))
|
||||
const showJumpToTimerOwner =
|
||||
timerOwningStep != null &&
|
||||
step !== timerOwningStep &&
|
||||
safeStep !== timerOwningStep &&
|
||||
(runStartAt != null || pausedAccumMs > 0)
|
||||
const isLastCoachStep = timeline.length > 0 && step >= timeline.length - 1
|
||||
const isLastCoachStep =
|
||||
timeline.length > 0 &&
|
||||
safeStep >= timeline.length - 1 &&
|
||||
currentEntry?.entryKind !== COACH_ENTRY_BRANCH_GATE
|
||||
|
||||
const timerStart = () => {
|
||||
setRunStartAt(Date.now())
|
||||
setTimerOwningStep(step)
|
||||
setTimerOwningStep(safeStep)
|
||||
}
|
||||
|
||||
const timerPause = () => {
|
||||
|
|
@ -306,14 +493,8 @@ export default function TrainingCoachPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const timerReset = () => {
|
||||
setRunStartAt(null)
|
||||
setPausedAccumMs(0)
|
||||
setTimerOwningStep(null)
|
||||
}
|
||||
|
||||
const applySuggestedDuration = () => {
|
||||
const idx = timerOwningStep != null ? timerOwningStep : step
|
||||
const idx = timerOwningStep != null ? timerOwningStep : safeStep
|
||||
const ent = timeline[idx]
|
||||
const item = ent?.item
|
||||
if (!item || item.item_type !== 'exercise') return
|
||||
|
|
@ -324,12 +505,28 @@ export default function TrainingCoachPage() {
|
|||
timerPause()
|
||||
}
|
||||
|
||||
const pickStreamForPhase = useCallback(
|
||||
(phaseOrder, streamOrder) => {
|
||||
if (!Number.isFinite(phaseOrder) || !Number.isFinite(streamOrder)) return
|
||||
setStreamChoiceHint(null)
|
||||
setSplitRejoinPrompt(null)
|
||||
setBranchPicks((prev) => ({ ...normalizeCoachBranchPicks(prev), [phaseOrder]: streamOrder }))
|
||||
timerReset()
|
||||
setCoachDebriefPhase(false)
|
||||
setSearchParams({ po: String(phaseOrder), so: String(streamOrder) }, { replace: true })
|
||||
},
|
||||
[timerReset, setSearchParams]
|
||||
)
|
||||
|
||||
const atBranchGate = currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE
|
||||
|
||||
const goPrev = () => setStep((s) => clampStep(s - 1))
|
||||
const goNext = () => setStep((s) => clampStep(s + 1))
|
||||
|
||||
const markCurrentDoneAdvance = () => {
|
||||
const ownerIdx = timerOwningStep != null ? timerOwningStep : step
|
||||
const ownerIdx = timerOwningStep != null ? timerOwningStep : safeStep
|
||||
const ent = timeline[ownerIdx]
|
||||
if (ent?.entryKind === COACH_ENTRY_BRANCH_GATE) return
|
||||
const item = ent?.item
|
||||
if (item?.item_type === 'exercise' && elapsedMs > 650) {
|
||||
const key = itemStableKey(item, ent.secOrder, ent.ii)
|
||||
|
|
@ -339,7 +536,12 @@ export default function TrainingCoachPage() {
|
|||
timerReset()
|
||||
|
||||
const lastIdx = timeline.length - 1
|
||||
if (step >= lastIdx && lastIdx >= 0) {
|
||||
if (safeStep >= lastIdx && lastIdx >= 0) {
|
||||
const rejoin = coachShouldPromptSplitRejoin(unit, timeline[safeStep])
|
||||
if (rejoin) {
|
||||
setSplitRejoinPrompt(rejoin)
|
||||
return
|
||||
}
|
||||
setCoachDebriefPhase(true)
|
||||
try {
|
||||
sessionStorage.setItem(storageDebriefKey(idNum), '1')
|
||||
|
|
@ -348,25 +550,31 @@ export default function TrainingCoachPage() {
|
|||
}
|
||||
return
|
||||
}
|
||||
const nextIdx = safeStep + 1
|
||||
if (nextIdx < timeline.length) {
|
||||
const rejoinMid = coachShouldPromptSplitRejoinTransition(unit, timeline[safeStep], timeline[nextIdx])
|
||||
if (rejoinMid) {
|
||||
setSplitRejoinPrompt(rejoinMid)
|
||||
return
|
||||
}
|
||||
}
|
||||
setStep((s) => clampStep(s + 1, timeline.length))
|
||||
}
|
||||
|
||||
const durationOverridesForApi = useMemo(() => {
|
||||
const out = {}
|
||||
for (let i = 0; i < timeline.length; i++) {
|
||||
const ent = timeline[i]
|
||||
const { item } = ent
|
||||
if (item.item_type !== 'exercise' || item.id == null) continue
|
||||
const k = itemStableKey(item, ent.secOrder, ent.ii)
|
||||
const dv = deltas[k]?.actual_duration_min
|
||||
if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) {
|
||||
out[String(item.id)] = { actual_duration_min: Number(dv) }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [timeline, deltas])
|
||||
useEffect(() => {
|
||||
if (!atBranchGate) return
|
||||
if (runStartAt != null || pausedAccumMs > 0) timerReset()
|
||||
}, [step, atBranchGate, runStartAt, pausedAccumMs, timerReset])
|
||||
|
||||
const durationOverridesForApi = useMemo(() => durationOverridesMapFromDeltas(unit, deltas), [unit, deltas])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentEntry?.entryKind === COACH_ENTRY_BRANCH_GATE) {
|
||||
setCatalogExercise(null)
|
||||
setCatalogError(null)
|
||||
setCatalogLoading(false)
|
||||
return
|
||||
}
|
||||
const item = currentEntry?.item
|
||||
if (!item || item.item_type === 'note') {
|
||||
setCatalogExercise(null)
|
||||
|
|
@ -402,16 +610,16 @@ export default function TrainingCoachPage() {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [step, currentEntry?.item?.exercise_id, currentEntry?.item?.exercise_variant_id, currentEntry?.item?.item_type])
|
||||
}, [step, currentEntry?.entryKind, currentEntry?.item?.exercise_id, currentEntry?.item?.exercise_variant_id, currentEntry?.item?.item_type])
|
||||
|
||||
const handleSaveDebrief = async () => {
|
||||
setSaveOk(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const sectionsPayload = sectionsToPutPayload(unit, durationOverridesForApi)
|
||||
const sectionsPayloadPart = buildCoachSavePlanPayload(unit, durationOverridesForApi)
|
||||
const tn = trainerAppend.trim()
|
||||
const payload = {
|
||||
sections: sectionsPayload,
|
||||
...sectionsPayloadPart,
|
||||
...(saveMarkDone ? { status: 'completed' } : {}),
|
||||
}
|
||||
if (tn) {
|
||||
|
|
@ -421,18 +629,24 @@ export default function TrainingCoachPage() {
|
|||
.trim()
|
||||
}
|
||||
await api.updateTrainingUnit(idNum, payload)
|
||||
await reloadUnit()
|
||||
setTrainerAppend('')
|
||||
try {
|
||||
sessionStorage.removeItem(storageDeltasKey(idNum))
|
||||
sessionStorage.removeItem(storageDebriefKey(idNum))
|
||||
sessionStorage.removeItem(coachBranchPicksStorageKey(idNum))
|
||||
clearCoachStepStorageForUnit(idNum)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDeltas({})
|
||||
setBranchPicks({})
|
||||
setStreamChoiceHint(null)
|
||||
setSplitRejoinPrompt(null)
|
||||
setCoachDebriefPhase(false)
|
||||
setSaveOk('Gespeichert.')
|
||||
setDebriefOpen(false)
|
||||
setSaveOk('Gespeichert.')
|
||||
setSearchParams({}, { replace: true })
|
||||
navigate(`/planning/run/${unitId}`, { replace: true })
|
||||
} catch (e) {
|
||||
setSaveOk(`Fehler: ${e.message || e}`)
|
||||
} finally {
|
||||
|
|
@ -486,6 +700,41 @@ export default function TrainingCoachPage() {
|
|||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
|
||||
Planung
|
||||
</button>
|
||||
{streamFocusOptions.length > 0 ? (
|
||||
<label
|
||||
className="no-print"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem', color: 'var(--text2)' }}
|
||||
>
|
||||
<span style={{ color: 'var(--text3)', whiteSpace: 'nowrap' }}>Ansicht</span>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ minWidth: 'min(220px, 72vw)', margin: 0, padding: '6px 8px', fontSize: '0.82rem' }}
|
||||
value={streamQuickSelectValue}
|
||||
onChange={(e) => {
|
||||
setSplitRejoinPrompt(null)
|
||||
setCoachDebriefPhase(false)
|
||||
timerReset()
|
||||
const v = e.target.value
|
||||
if (!v) {
|
||||
setBranchPicks({})
|
||||
setStreamChoiceHint(null)
|
||||
setSplitRejoinPrompt(null)
|
||||
setSearchParams({}, { replace: true })
|
||||
} else {
|
||||
const [ppo, sso] = v.split('-').map((x) => parseInt(x, 10))
|
||||
pickStreamForPhase(ppo, sso)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Ablauf mit Split-Punkten (Standard)</option>
|
||||
{streamFocusOptions.map((o) => (
|
||||
<option key={o.valueKey} value={o.valueKey}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
|
|
@ -496,6 +745,111 @@ export default function TrainingCoachPage() {
|
|||
</button>
|
||||
</nav>
|
||||
|
||||
{streamParamsInvalid ? (
|
||||
<p
|
||||
className="no-print card"
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
fontSize: '0.82rem',
|
||||
color: 'var(--danger)',
|
||||
marginBottom: '8px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Ungültige Stream-Parameter in der Adresszeile — es wird der Gesamtplan angezeigt.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{splitRejoinPrompt && !coachDebriefPhase ? (
|
||||
<div
|
||||
className="card no-print"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
marginBottom: '10px',
|
||||
padding: '16px 14px',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid var(--accent)',
|
||||
background: 'linear-gradient(135deg, var(--accent-light), var(--surface))',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
|
||||
Parallelphase · Abschluss
|
||||
</div>
|
||||
<p style={{ fontSize: '0.95rem', fontWeight: 700, margin: '0 0 8px', color: 'var(--text1)' }}>
|
||||
{splitRejoinPrompt.phaseTitle != null && String(splitRejoinPrompt.phaseTitle).trim()
|
||||
? String(splitRejoinPrompt.phaseTitle).trim()
|
||||
: `Phase ${splitRejoinPrompt.phaseOrderIndex}`}
|
||||
{' — '}
|
||||
alle Gruppen zusammen?
|
||||
</p>
|
||||
<p style={{ fontSize: '0.84rem', color: 'var(--text2)', margin: '0 0 12px', lineHeight: 1.45 }}>
|
||||
Diese Phase hat mehrere Streams. Kurz mit dem anderen Trainer klären, dann gemeinsam weitermachen. Ist-Zeiten
|
||||
und Speichern erfolgen in der Nachbereitung am Ende der Einheit.
|
||||
</p>
|
||||
<ul style={{ margin: '0 0 14px', paddingLeft: '1.2rem', fontSize: '0.82rem', color: 'var(--text2)' }}>
|
||||
{splitRejoinPrompt.streams.map((st) => (
|
||||
<li key={st.printStreamId || `s-${st.streamOrder}`}>
|
||||
{st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}{' '}
|
||||
<span style={{ color: 'var(--text3)' }}>(ca. {st.minutes} Min. Üb.)</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{safeStep < timeline.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ minHeight: '48px', fontWeight: 700 }}
|
||||
onClick={() => {
|
||||
setSplitRejoinPrompt(null)
|
||||
setStep((s) => clampStep(s + 1, timeline.length))
|
||||
}}
|
||||
>
|
||||
Gruppen zusammengeführt — weiter mit dem Plan
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ minHeight: '48px', fontWeight: 700 }}
|
||||
onClick={() => {
|
||||
setSplitRejoinPrompt(null)
|
||||
setCoachDebriefPhase(true)
|
||||
try {
|
||||
sessionStorage.setItem(storageDebriefKey(idNum), '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
>
|
||||
Alle Gruppen fertig — zur Nachbereitung
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary" style={{ minHeight: '44px' }} onClick={() => setSplitRejoinPrompt(null)}>
|
||||
Zurück — andere Gruppe läuft noch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ minHeight: '44px', fontSize: '0.82rem' }}
|
||||
onClick={() => {
|
||||
setSplitRejoinPrompt(null)
|
||||
setCoachDebriefPhase(true)
|
||||
try {
|
||||
sessionStorage.setItem(storageDebriefKey(idNum), '1')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{safeStep < timeline.length - 1
|
||||
? 'Ausnahme: jetzt schon zur Nachbereitung'
|
||||
: 'Ausnahme: trotzdem zur Nachbereitung'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<header
|
||||
className="card training-coach-hero training-coach-hero--compact"
|
||||
style={{
|
||||
|
|
@ -509,44 +863,93 @@ export default function TrainingCoachPage() {
|
|||
>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
Coach ·{' '}
|
||||
{coachDebriefPhase ? 'Nachbereitung · abschließend speichern' : `Schritt ${(step || 0) + 1} / ${Math.max(timeline.length, 1)}`}
|
||||
{coachDebriefPhase ? 'Nachbereitung · abschließend speichern' : `Schritt ${(safeStep || 0) + 1} / ${Math.max(timeline.length, 1)}`}
|
||||
</div>
|
||||
<h1 style={{ fontSize: '1.1rem', margin: '4px 0 0', lineHeight: 1.28 }}>
|
||||
{unit.planned_date}
|
||||
{unit.planned_time_start && ` · ${String(unit.planned_time_start).slice(0, 5)}`}
|
||||
{unit.planned_focus ? ` · ${unit.planned_focus}` : ''}
|
||||
</h1>
|
||||
{streamFocusOptions.length > 0 ? (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', margin: '8px 0 0', lineHeight: 1.4 }}>
|
||||
Parallele Phasen erscheinen als Schritt <strong>Gruppe wählen</strong> — kein Durcheinander mehr aus
|
||||
verschränkten Streams. Pro paralleler Phase entscheiden Sie (oder der Co-Trainer auf dem eigenen Gerät), welcher
|
||||
Stream coacht wird. Kurzwahl oben springt die betreffende Phase sofort auf einen Stream (z. B. zwischen Gruppen
|
||||
wechseln).
|
||||
{Object.keys(mergedPicks).length > 0 ? (
|
||||
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text3)', fontSize: '0.78rem' }}>
|
||||
Aktuell festgelegt:{' '}
|
||||
{Object.keys(mergedPicks)
|
||||
.map((pk) => {
|
||||
const po = parseInt(pk, 10)
|
||||
const so = mergedPicks[pk]
|
||||
const o = streamFocusOptions.find((x) => x.phaseOrder === po && x.streamOrder === so)
|
||||
return o?.label ?? `Phase ${po} · Stream ${so}`
|
||||
})
|
||||
.join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{outlineOpen && (
|
||||
<div className="card training-coach-outline" style={{ flexShrink: 0, marginBottom: '8px', padding: '10px 12px', maxHeight: 'min(28vh, 260px)', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>Ablauf · Antippen zum Springen</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', overflowY: 'auto', minHeight: 0 }}>
|
||||
{timeline.map((ent, ix) => {
|
||||
const lbl = summarizeTimelineEntry(ent)
|
||||
const secTitle = ent.sec.title || `Abschnitt ${ent.si + 1}`
|
||||
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === step
|
||||
return (
|
||||
<button
|
||||
key={`${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`}
|
||||
type="button"
|
||||
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text3)', marginBottom: '6px', flexShrink: 0 }}>
|
||||
Trainingsrahmen · nach Blöcken und Streams
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', overflowY: 'auto', minHeight: 0 }}>
|
||||
{outlineGroups.map((grp) => (
|
||||
<div key={grp.mergeKey} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
justifyContent: 'flex-start',
|
||||
opacity: active ? 1 : 0.92,
|
||||
fontWeight: active ? 700 : 500
|
||||
}}
|
||||
onClick={() => {
|
||||
setCoachDebriefPhase(false)
|
||||
setStep(ix)
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent-dark)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: 0.75 }}>{secTitle.substring(0, 14)} › </span>
|
||||
<span>{lbl}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{grp.heading}
|
||||
{grp.sub ? <span style={{ fontWeight: 600, color: 'var(--text3)', textTransform: 'none' }}> · {grp.sub}</span> : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{grp.entries.map(({ ix, ent }) => {
|
||||
const lbl = summarizeTimelineEntry(ent)
|
||||
const ctx = ent.coachContext || ''
|
||||
const active = coachDebriefPhase ? ix === timeline.length - 1 : ix === safeStep
|
||||
const rowKey =
|
||||
ent.entryKind === COACH_ENTRY_BRANCH_GATE
|
||||
? `gate-${ix}`
|
||||
: `${itemStableKey(ent.item, ent.secOrder, ent.ii)}-${ix}`
|
||||
return (
|
||||
<button
|
||||
key={rowKey}
|
||||
type="button"
|
||||
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
justifyContent: 'flex-start',
|
||||
opacity: active ? 1 : 0.92,
|
||||
fontWeight: active ? 700 : 500,
|
||||
}}
|
||||
onClick={() => {
|
||||
setCoachDebriefPhase(false)
|
||||
setStep(ix)
|
||||
}}
|
||||
>
|
||||
{ctx ? (
|
||||
<span style={{ opacity: 0.8, display: 'block', fontSize: '0.72rem', marginBottom: '2px' }}>
|
||||
{ctx.length > 42 ? `${ctx.slice(0, 40)}…` : ctx}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{lbl}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -566,13 +969,16 @@ export default function TrainingCoachPage() {
|
|||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
|
||||
{timeline
|
||||
.filter((e) => e.item.item_type === 'exercise')
|
||||
.filter((e) => e.item?.item_type === 'exercise')
|
||||
.map((ent) => {
|
||||
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
|
||||
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
|
||||
return (
|
||||
<label key={`db-${k}`} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
|
||||
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
|
||||
<span style={{ wordBreak: 'break-word' }}>
|
||||
{ent.coachContext ? <span style={{ color: 'var(--text3)' }}>{ent.coachContext} · </span> : null}
|
||||
{summarizeTimelineEntry(ent)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
|
@ -635,6 +1041,8 @@ export default function TrainingCoachPage() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!splitRejoinPrompt ? (
|
||||
<>
|
||||
<div
|
||||
className="training-coach-assist training-coach-assist--compact"
|
||||
style={{
|
||||
|
|
@ -648,7 +1056,12 @@ export default function TrainingCoachPage() {
|
|||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--accent-dark)', marginBottom: '6px' }}>
|
||||
Als Nächstes
|
||||
</div>
|
||||
{nextEntry ? (
|
||||
{atBranchGate ? (
|
||||
<p style={{ margin: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
Wählen Sie unten eine Gruppe. Danach zeigt der Coach fortlaufend nur die Übungen dieses Streams in dieser
|
||||
parallelen Phase.
|
||||
</p>
|
||||
) : nextEntry ? (
|
||||
<>
|
||||
<p style={{ margin: '0', fontSize: '0.9rem', color: 'var(--text1)', lineHeight: 1.4 }}>
|
||||
<strong>Nächste:</strong> {summarizeTimelineEntry(nextEntry)}
|
||||
|
|
@ -667,7 +1080,7 @@ export default function TrainingCoachPage() {
|
|||
</div>
|
||||
|
||||
<CoachControlsBand
|
||||
step={step}
|
||||
step={safeStep}
|
||||
timelineLength={timeline.length}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
|
|
@ -683,15 +1096,99 @@ export default function TrainingCoachPage() {
|
|||
isLastCoachStep={isLastCoachStep}
|
||||
showJumpToTimerOwner={showJumpToTimerOwner}
|
||||
showJumpToTimerOwnerRow
|
||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
|
||||
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
||||
branchGateMode={atBranchGate}
|
||||
/>
|
||||
|
||||
<div className="training-coach-scroll">
|
||||
{currentEntry?.item?.item_type === 'note' ? (
|
||||
{atBranchGate && currentEntry?.branchMeta ? (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: '18px 16px',
|
||||
marginBottom: '12px',
|
||||
borderRadius: '14px',
|
||||
border: '2px solid var(--accent)',
|
||||
background: 'linear-gradient(180deg, var(--accent-light) 0%, var(--surface) 38%)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.74rem', fontWeight: 800, color: 'var(--accent-dark)', marginBottom: '6px', letterSpacing: '0.04em' }}>
|
||||
⑂ SPLIT — GRUPPE WÄHLEN
|
||||
</div>
|
||||
<p style={{ fontSize: '1.05rem', fontWeight: 800, margin: '0 0 8px', color: 'var(--text1)' }}>
|
||||
{currentEntry.branchMeta.phaseTitle != null && String(currentEntry.branchMeta.phaseTitle).trim()
|
||||
? String(currentEntry.branchMeta.phaseTitle).trim()
|
||||
: `Phase ${currentEntry.branchMeta.phaseOrderIndex}`}
|
||||
</p>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', margin: '0 0 16px', lineHeight: 1.45 }}>
|
||||
Tippen Sie auf eine Kachel, um <strong>diese Gruppe</strong> zu coachen. Andere Trainer:innen wählen auf
|
||||
ihrem Gerät parallel eine andere Kachel.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '12px' }}>
|
||||
{(currentEntry.branchMeta.streams || []).map((st) => {
|
||||
const hinted =
|
||||
streamChoiceHint?.phaseOrder === currentEntry.branchMeta.phaseOrderIndex &&
|
||||
streamChoiceHint?.streamOrder === st.streamOrder
|
||||
const label = st.streamTitle?.trim() ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`
|
||||
const baseBg = hinted ? 'var(--accent)' : 'var(--surface2)'
|
||||
const baseColor = hinted ? '#fff' : 'var(--text1)'
|
||||
const borderCol = hinted ? 'var(--accent-dark)' : 'var(--border)'
|
||||
return (
|
||||
<button
|
||||
key={st.printStreamId || `s-${st.streamOrder}`}
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
justifyContent: 'flex-start',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
padding: '14px 16px',
|
||||
minHeight: '96px',
|
||||
fontWeight: 700,
|
||||
background: baseBg,
|
||||
color: baseColor,
|
||||
border: `2px solid ${borderCol}`,
|
||||
borderRadius: '12px',
|
||||
boxShadow: hinted ? '0 4px 0 var(--accent-dark)' : '0 3px 0 hsl(200 20% 78%)',
|
||||
transition: 'transform 0.08s ease, box-shadow 0.08s ease',
|
||||
}}
|
||||
onClick={() => pickStreamForPhase(currentEntry.branchMeta.phaseOrderIndex, st.streamOrder)}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1rem' }}>
|
||||
<span style={{ fontSize: '1.35rem', lineHeight: 1 }} aria-hidden="true">
|
||||
{hinted ? '◉' : '○'}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 600,
|
||||
opacity: hinted ? 0.95 : 0.88,
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
≈ {st.minutes} Min. Üb. · Jetzt aktivieren
|
||||
</span>
|
||||
{hinted ? (
|
||||
<span style={{ display: 'block', fontSize: '0.74rem', fontWeight: 600, marginTop: '8px', opacity: 0.92 }}>
|
||||
Vorschlag aus Planansicht — trotzdem andere Kachel möglich
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : currentEntry?.item?.item_type === 'note' ? (
|
||||
<div className="card" style={{ padding: '16px 14px' }}>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
{currentEntry.sec.title || 'Abschnitt'} · Coach-Notiz · Teil {step + 1}
|
||||
{currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Coach-Notiz · Teil{' '}
|
||||
{safeStep + 1}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginBottom: '6px' }}>Coach-Notiz</div>
|
||||
<p style={{ fontSize: '1.05rem', lineHeight: 1.52, whiteSpace: 'pre-wrap', margin: 0 }}>{currentEntry.item.note_body || ''}</p>
|
||||
|
|
@ -700,7 +1197,8 @@ export default function TrainingCoachPage() {
|
|||
<>
|
||||
<div className="card training-coach-plan-strip" style={{ padding: '12px 14px', marginBottom: '12px', borderRadius: '12px', borderLeft: `3px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text3)', marginBottom: '6px' }}>
|
||||
In diesem Training · {currentEntry?.sec.title || 'Abschnitt'} · Teil {step + 1}
|
||||
In diesem Training · {currentEntry?.coachContext || currentEntry?.sec?.title || 'Abschnitt'} · Teil{' '}
|
||||
{safeStep + 1}
|
||||
</div>
|
||||
{currentEntry?.item && (
|
||||
<>
|
||||
|
|
@ -788,13 +1286,16 @@ export default function TrainingCoachPage() {
|
|||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
|
||||
{timeline
|
||||
.filter((e) => e.item.item_type === 'exercise')
|
||||
.filter((e) => e.item?.item_type === 'exercise')
|
||||
.map((ent) => {
|
||||
const k = itemStableKey(ent.item, ent.secOrder, ent.ii)
|
||||
const val = deltas[k]?.actual_duration_min ?? ent.item.actual_duration_min ?? ''
|
||||
return (
|
||||
<label key={k} style={{ display: 'grid', gridTemplateColumns: '1fr 88px', gap: '10px', alignItems: 'center', fontSize: '0.88rem' }}>
|
||||
<span style={{ wordBreak: 'break-word' }}>{summarizeTimelineEntry(ent)}</span>
|
||||
<span style={{ wordBreak: 'break-word' }}>
|
||||
{ent.coachContext ? <span style={{ color: 'var(--text3)' }}>{ent.coachContext} · </span> : null}
|
||||
{summarizeTimelineEntry(ent)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
|
|
@ -840,7 +1341,7 @@ export default function TrainingCoachPage() {
|
|||
</div>
|
||||
|
||||
<CoachControlsBand
|
||||
step={step}
|
||||
step={safeStep}
|
||||
timelineLength={timeline.length}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
|
|
@ -856,9 +1357,12 @@ export default function TrainingCoachPage() {
|
|||
isLastCoachStep={isLastCoachStep}
|
||||
showJumpToTimerOwner={showJumpToTimerOwner}
|
||||
showJumpToTimerOwnerRow={false}
|
||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? step)}
|
||||
onJumpToTimerOwner={() => setStep(timerOwningStep ?? safeStep)}
|
||||
timerOwnerLabelIndex={timerOwningStep ?? 0}
|
||||
branchGateMode={atBranchGate}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
enrichSectionsWithVariants,
|
||||
buildSectionsPayload,
|
||||
hydrateExercisePlanningRow,
|
||||
reorderBlockIntoParallelStreamEnd,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
|
||||
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
||||
|
|
@ -553,7 +554,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
}
|
||||
|
||||
const moveSectionsAcrossFrameworkSlots = useCallback(
|
||||
({ fromSlot, fromSectionIdx, toSlot, toSectionIdx }) => {
|
||||
({ fromSlot, fromSectionIdx, toSlot, toSectionIdx, toParallelStream }) => {
|
||||
setForm((prev) => {
|
||||
const slots = prev.slots.map((sl) => ({
|
||||
...sl,
|
||||
|
|
@ -581,17 +582,32 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
|
||||
const [block] = fromSecs.splice(fromSectionIdx, 1)
|
||||
|
||||
const applyParallelStreamEnd =
|
||||
toParallelStream != null && toParallelStream.po != null && toParallelStream.so != null
|
||||
? (secs, insertedAt) => {
|
||||
const po = Number(toParallelStream.po) || 0
|
||||
const so = Number(toParallelStream.so) || 0
|
||||
return reorderBlockIntoParallelStreamEnd(secs, insertedAt, po, so)
|
||||
}
|
||||
: null
|
||||
|
||||
if (fromSlot === toSlot) {
|
||||
let insertAt = toSectionIdx
|
||||
if (fromSectionIdx < toSectionIdx) insertAt = toSectionIdx - 1
|
||||
insertAt = Math.max(0, Math.min(insertAt, fromSecs.length))
|
||||
fromSecs.splice(insertAt, 0, block)
|
||||
if (applyParallelStreamEnd) {
|
||||
slots[fromSlot].sections = applyParallelStreamEnd(fromSecs, insertAt)
|
||||
}
|
||||
return { ...prev, slots }
|
||||
}
|
||||
|
||||
const toSecs = slots[toSlot].sections
|
||||
const ia = Math.max(0, Math.min(toSectionIdx, toSecs.length))
|
||||
toSecs.splice(ia, 0, block)
|
||||
if (applyParallelStreamEnd) {
|
||||
slots[toSlot].sections = applyParallelStreamEnd(toSecs, ia)
|
||||
}
|
||||
return { ...prev, slots }
|
||||
})
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,18 @@
|
|||
/**
|
||||
* Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert).
|
||||
* Phasen: Ganzgruppe vs. Split (planLoc); Druck mit optional getrennten Breakout-Seiten.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
||||
import { itemStableKey, sortedSections, sortedItems } from '../utils/trainingPlanUtils'
|
||||
import {
|
||||
buildPlanRunViewModelFromSections,
|
||||
itemStableKey,
|
||||
sectionsWithPlanLocForDisplay,
|
||||
sortedItems,
|
||||
} from '../utils/trainingPlanUtils'
|
||||
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
||||
|
||||
function storageKey(unitId) {
|
||||
|
|
@ -36,6 +42,8 @@ export default function TrainingUnitRunPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [checked, setChecked] = useState(() => new Set())
|
||||
const [peekCtx, setPeekCtx] = useState(null)
|
||||
/** null | printStreamId z. B. p0-s1 — nur dieser Split-Stream in @media print */
|
||||
const [printOnlyStreamId, setPrintOnlyStreamId] = useState(null)
|
||||
|
||||
const loadChecked = useCallback((uid) => {
|
||||
try {
|
||||
|
|
@ -107,20 +115,245 @@ export default function TrainingUnitRunPage() {
|
|||
}
|
||||
}, [idNum, persistChecked])
|
||||
|
||||
const sections = useMemo(() => sortedSections(unit), [unit])
|
||||
const sections = useMemo(() => sectionsWithPlanLocForDisplay(unit), [unit])
|
||||
const planModel = useMemo(() => buildPlanRunViewModelFromSections(sections), [sections])
|
||||
|
||||
const totalPlannedMin = useMemo(() => {
|
||||
let t = 0
|
||||
for (const sec of sections) {
|
||||
for (const it of sortedItems(sec)) {
|
||||
if (it.item_type === 'exercise' && it.planned_duration_min != null) {
|
||||
const n = Number(it.planned_duration_min)
|
||||
if (Number.isFinite(n)) t += n
|
||||
}
|
||||
const printStreamOptions = useMemo(() => {
|
||||
const opts = []
|
||||
for (const run of planModel.runs) {
|
||||
if (run.kind !== 'parallel' || !run.streams) continue
|
||||
for (const st of run.streams) {
|
||||
opts.push({
|
||||
id: st.printStreamId,
|
||||
label: `${run.phaseTitle ? String(run.phaseTitle).trim().slice(0, 28) : `Phase ${run.phaseOrderIndex}`} · ${st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return t
|
||||
}, [sections])
|
||||
return opts
|
||||
}, [planModel.runs])
|
||||
|
||||
const totalPlannedMin = planModel.totalMin
|
||||
const showWholeGroupInView = !printOnlyStreamId
|
||||
const showStreamColumn = (streamPrintId) => !printOnlyStreamId || streamPrintId === printOnlyStreamId
|
||||
|
||||
const renderSectionCard = (sec, siInUnit) => {
|
||||
const secOrder = sec.order_index ?? siInUnit
|
||||
const items = sortedItems(sec)
|
||||
return (
|
||||
<section
|
||||
key={sec.id ?? `sec-${secOrder}-${siInUnit}`}
|
||||
className="card training-run-section"
|
||||
style={{ padding: '1.15rem 1.25rem' }}
|
||||
>
|
||||
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.65rem', color: 'var(--accent-dark)' }}>
|
||||
{sec.title || `Abschnitt ${siInUnit + 1}`}
|
||||
</h2>
|
||||
{sec.guidance_notes && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--text2)',
|
||||
marginBottom: '0.85rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{sec.guidance_notes}
|
||||
</p>
|
||||
)}
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{items.map((it, ii) => {
|
||||
const ck = itemStableKey(it, secOrder, ii)
|
||||
const done = checked.has(ck)
|
||||
|
||||
if (it.item_type === 'note') {
|
||||
return (
|
||||
<li
|
||||
key={ck}
|
||||
className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.92rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="training-run-checkbox training-run-checkbox--printable"
|
||||
checked={done}
|
||||
onChange={() => toggle(ck)}
|
||||
style={{ marginTop: '4px', width: '20px', height: '20px' }}
|
||||
/>
|
||||
<span style={{ whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>
|
||||
<em style={{ fontStyle: 'normal', color: 'var(--text3)', fontSize: '0.8rem' }}>
|
||||
Notiz
|
||||
</em>
|
||||
<br />
|
||||
{it.note_body || ''}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const title =
|
||||
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : 'Übung')
|
||||
const variant = it.exercise_variant_name ? ` (${it.exercise_variant_name})` : ''
|
||||
const plan = formatMin(it.planned_duration_min)
|
||||
const extras = []
|
||||
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
|
||||
const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
||||
const isComboRow = exKind === 'combination'
|
||||
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
|
||||
const comboEffectiveProfile = isComboRow
|
||||
? effectiveComboMethodProfile(
|
||||
it.catalog_method_profile || {},
|
||||
it.planning_method_profile ?? null
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<li
|
||||
key={ck}
|
||||
className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="training-run-checkbox training-run-checkbox--printable"
|
||||
checked={done}
|
||||
onChange={() => toggle(ck)}
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
width: '22px',
|
||||
height: '22px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: '1.02rem', fontWeight: 600 }}>
|
||||
{title}
|
||||
{variant}
|
||||
</span>
|
||||
{metaParts.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text3)',
|
||||
marginTop: '3px',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{metaParts.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{(it.notes || it.modifications) && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.35rem',
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--text2)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{it.notes && (
|
||||
<>
|
||||
<strong style={{ fontWeight: 600 }}>Coach:</strong> {it.notes}
|
||||
</>
|
||||
)}
|
||||
{it.modifications && (
|
||||
<>
|
||||
{it.notes ? <br /> : null}
|
||||
<strong style={{ fontWeight: 600 }}>Anpassung:</strong> {it.modifications}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isComboRow && it.exercise_id ? (
|
||||
<div className="training-run-combo-embed">
|
||||
<CombinationPlanBracket
|
||||
methodArchetype={String(it.catalog_method_archetype || '').trim()}
|
||||
methodProfile={comboEffectiveProfile || {}}
|
||||
combinationSlots={
|
||||
Array.isArray(it.combination_slots) ? it.combination_slots : []
|
||||
}
|
||||
planningAdjusted={
|
||||
it.planning_method_profile != null &&
|
||||
typeof it.planning_method_profile === 'object' &&
|
||||
!Array.isArray(it.planning_method_profile)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{it.exercise_id && (
|
||||
<div
|
||||
className="no-print"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
marginTop: '0.55rem',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.82rem', margin: 0 }}
|
||||
onClick={() =>
|
||||
setPeekCtx({
|
||||
exerciseId: it.exercise_id,
|
||||
variantId:
|
||||
it.exercise_variant_id != null
|
||||
? Number(it.exercise_variant_id)
|
||||
: null,
|
||||
peekExtras: isComboRow
|
||||
? {
|
||||
catalog_method_profile: it.catalog_method_profile,
|
||||
planning_method_profile: it.planning_method_profile,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Katalog (Popup)
|
||||
</button>
|
||||
<Link
|
||||
to={`/exercises/${it.exercise_id}`}
|
||||
style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
|
||||
>
|
||||
Vollständige Seite öffnen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -152,21 +385,66 @@ export default function TrainingUnitRunPage() {
|
|||
onClose={() => setPeekCtx(null)}
|
||||
/>
|
||||
|
||||
<nav className="training-run-toolbar no-print" style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<nav
|
||||
className="training-run-toolbar no-print"
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/planning')}>
|
||||
Zur Planung
|
||||
</button>
|
||||
<Link
|
||||
to={`/planning/run/${unitId}/coach`}
|
||||
className="btn btn-primary"
|
||||
style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Im Training (Coach)
|
||||
</Link>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => window.print()}>
|
||||
Drucken / PDF
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" title="Alle Häkchen auf dieser Matte zurücksetzen" onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}>
|
||||
{printStreamOptions.length > 0 ? (
|
||||
<label
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '0.86rem',
|
||||
color: 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
Druck / Vorschau
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ marginBottom: 0, minWidth: '200px', fontSize: '0.86rem' }}
|
||||
value={printOnlyStreamId || ''}
|
||||
onChange={(e) => setPrintOnlyStreamId(e.target.value || null)}
|
||||
>
|
||||
<option value="">Gesamter Plan (empfohlen)</option>
|
||||
{printStreamOptions.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
Nur: {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
title="Alle Häkchen auf dieser Matte zurücksetzen"
|
||||
onClick={() => confirm('Fortschritt wirklich zurücksetzen?') && clearProgress()}
|
||||
>
|
||||
Fortschritt leeren
|
||||
</button>
|
||||
</nav>
|
||||
|
|
@ -200,10 +478,81 @@ export default function TrainingUnitRunPage() {
|
|||
</span>
|
||||
{totalPlannedMin > 0 && (
|
||||
<span>
|
||||
<strong>Geplante Zeit (Übungen):</strong> ca. {totalPlannedMin} Min.
|
||||
<strong>Geplante Zeit (Übungen, gesamt):</strong> ca. {totalPlannedMin} Min.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{printOnlyStreamId ? (
|
||||
<div
|
||||
className="no-print"
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'hsl(200 40% 94%)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.82rem',
|
||||
color: 'hsl(200 25% 28%)',
|
||||
}}
|
||||
>
|
||||
Vorschau wie fürs Drucken: nur die gewählte Split-Gruppe. Ganzgruppen-Blöcke sind ausgeblendet.
|
||||
</div>
|
||||
) : null}
|
||||
{planModel.mode === 'phased' && planModel.runs.length > 0 && showWholeGroupInView && (
|
||||
<div
|
||||
className="training-run-phase-schedule training-run-notes-print"
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.75rem 0.9rem',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.86rem',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text1)' }}>
|
||||
Zeitplanung (Summe Übungsminuten)
|
||||
</strong>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '280px' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--text3)', fontSize: '0.78rem' }}>
|
||||
<th style={{ padding: '4px 8px 4px 0', fontWeight: 600 }}>Block</th>
|
||||
<th style={{ padding: '4px 8px', fontWeight: 600 }}>Art</th>
|
||||
<th style={{ padding: '4px 8px', fontWeight: 600 }}> Min</th>
|
||||
<th style={{ padding: '4px 0 4px 8px', fontWeight: 600 }}>∑ bis hier</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{planModel.runs.map((run, ri) => {
|
||||
const cum = planModel.runCumulativeEnds[ri]
|
||||
const label =
|
||||
run.phaseTitle != null && String(run.phaseTitle).trim()
|
||||
? String(run.phaseTitle).trim()
|
||||
: run.kind === 'legacy'
|
||||
? 'Ablauf'
|
||||
: run.kind === 'whole_group'
|
||||
? `Ganzgruppe (Phase ${run.phaseOrderIndex})`
|
||||
: `Parallel (Phase ${run.phaseOrderIndex})`
|
||||
return (
|
||||
<tr key={`sched-${ri}-${run.kind}`} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 8px 6px 0', verticalAlign: 'top' }}>{label}</td>
|
||||
<td style={{ padding: '6px 8px', color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||
{run.kind === 'parallel' ? 'Split' : run.kind === 'legacy' ? '—' : 'Ganzgruppe'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 8px' }}>{run.minutes || '—'}</td>
|
||||
<td style={{ padding: '6px 0 6px 8px', fontWeight: 600 }}>{cum}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{unit.planned_time_start && (
|
||||
<p style={{ margin: '0.55rem 0 0', fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4 }}>
|
||||
Hinweis: Startzeit oben im Kopf; Minuten sind aus den Übungseinplanungen — ohne Pausen und ohne
|
||||
automatische Uhrzeitliste.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{unit.notes && (
|
||||
<div
|
||||
className="training-run-notes-print"
|
||||
|
|
@ -212,7 +561,7 @@ export default function TrainingUnitRunPage() {
|
|||
padding: '0.65rem 0.85rem',
|
||||
background: 'var(--accent-light)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.92rem'
|
||||
fontSize: '0.92rem',
|
||||
}}
|
||||
>
|
||||
<strong>Hinweis Teilnehmer:</strong> {unit.notes}
|
||||
|
|
@ -225,171 +574,175 @@ export default function TrainingUnitRunPage() {
|
|||
Noch keine Abschnitte in diesem Plan. Unter <Link to="/planning">Planung</Link> bearbeiten.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{sections.map((sec, si) => {
|
||||
const secOrder = sec.order_index ?? si
|
||||
const items = sortedItems(sec)
|
||||
return (
|
||||
<section key={sec.id ?? `sec-${si}`} className="card training-run-section" style={{ padding: '1.15rem 1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.65rem', color: 'var(--accent-dark)' }}>
|
||||
{sec.title || `Abschnitt ${si + 1}`}
|
||||
</h2>
|
||||
{sec.guidance_notes && (
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', marginBottom: '0.85rem', whiteSpace: 'pre-wrap' }}>
|
||||
{sec.guidance_notes}
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||
{items.map((it, ii) => {
|
||||
const ck = itemStableKey(it, secOrder, ii)
|
||||
const done = checked.has(ck)
|
||||
|
||||
if (it.item_type === 'note') {
|
||||
<div className="training-run-body" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{planModel.runs.map((run, runIdx) => {
|
||||
if (run.kind === 'parallel') {
|
||||
const visibleStreams = run.streams.filter((st) => showStreamColumn(st.printStreamId))
|
||||
if (!visibleStreams.length) return null
|
||||
return (
|
||||
<div
|
||||
key={`run-p-${run.phaseOrderIndex}-${runIdx}`}
|
||||
className="training-run-phase training-run-phase--parallel"
|
||||
>
|
||||
<div
|
||||
className="card training-run-phase-banner"
|
||||
style={{
|
||||
padding: '0.85rem 1rem',
|
||||
background: 'linear-gradient(135deg, hsl(200 35% 94%), var(--surface2))',
|
||||
border: '1px solid hsl(200 40% 80%)',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
|
||||
PARALLELE PHASE
|
||||
</div>
|
||||
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
|
||||
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
|
||||
{printOnlyStreamId
|
||||
? `Auszug eine Gruppe · ca. ${visibleStreams[0]?.minutes ?? run.minutes} Min. (Üb.)`
|
||||
: `Geplante Übungszeit (gesamt): ca. ${run.minutes} Min. · Jede Spalte kann separat gedruckt werden (Dropdown oder Seitenumbruch).`}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="training-run-parallel-columns"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: '1rem',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{visibleStreams.map((st, streamIdx) => {
|
||||
const siUnit = (sec) => Math.max(0, sections.indexOf(sec))
|
||||
return (
|
||||
<li key={ck} className={`training-run-item training-run-item--note${done ? ' training-run-item--done' : ''}`}>
|
||||
<label
|
||||
<div
|
||||
key={st.printStreamId}
|
||||
className={
|
||||
'training-run-breakout-stream' +
|
||||
(!printOnlyStreamId && streamIdx > 0
|
||||
? ' training-run-breakout-stream--page-break'
|
||||
: '')
|
||||
}
|
||||
data-print-id={st.printStreamId}
|
||||
>
|
||||
<div
|
||||
className="training-run-stream-ribbon"
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
background: 'hsl(200 38% 92%)',
|
||||
border: '1px dashed hsl(200 42% 58%)',
|
||||
fontSize: '0.88rem',
|
||||
fontWeight: 700,
|
||||
color: 'hsl(200 30% 28%)',
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.92rem'
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '4px', width: '20px', height: '20px' }} />
|
||||
<span style={{ whiteSpace: 'pre-wrap', color: 'var(--text2)' }}>
|
||||
<em style={{ fontStyle: 'normal', color: 'var(--text3)', fontSize: '0.8rem' }}>Notiz</em>
|
||||
<br />
|
||||
{it.note_body || ''}
|
||||
<span>
|
||||
{st.streamTitle ? String(st.streamTitle).trim() : `Gruppe ${st.streamOrder + 1}`}
|
||||
<span style={{ fontWeight: 500, marginLeft: '8px', opacity: 0.85 }}>
|
||||
· ca. {st.minutes} Min. (Üb.)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<Link
|
||||
className="no-print"
|
||||
to={`/planning/run/${unitId}/coach?atBranch=${run.phaseOrderIndex}&preferSo=${st.streamOrder}`}
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent-dark)',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Coach · Split-Punkt (Vorschlag)
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{st.sections.map((sec) => renderSectionCard(sec, siUnit(sec)))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const title =
|
||||
it.exercise_title ||
|
||||
(it.exercise_id ? `Übung #${it.exercise_id}` : 'Übung')
|
||||
const variant = it.exercise_variant_name ? ` (${it.exercise_variant_name})` : ''
|
||||
const plan = formatMin(it.planned_duration_min)
|
||||
const extras = []
|
||||
if (it.exercise_focus_area) extras.push(it.exercise_focus_area)
|
||||
const exKind = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
||||
const isComboRow = exKind === 'combination'
|
||||
const metaParts = [...extras, isComboRow ? 'Kombination' : null, plan].filter(Boolean)
|
||||
const comboEffectiveProfile = isComboRow
|
||||
? effectiveComboMethodProfile(
|
||||
it.catalog_method_profile || {},
|
||||
it.planning_method_profile ?? null,
|
||||
)
|
||||
: null
|
||||
if (!showWholeGroupInView) return null
|
||||
|
||||
return (
|
||||
<li key={ck} className={`training-run-item training-run-item--exercise${done ? ' training-run-item--done' : ''}`}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" className="training-run-checkbox" checked={done} onChange={() => toggle(ck)} style={{ marginTop: '6px', width: '22px', height: '22px', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ fontSize: '1.02rem', fontWeight: 600 }}>
|
||||
{title}
|
||||
{variant}
|
||||
</span>
|
||||
{metaParts.length > 0 && (
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text3)', marginTop: '3px', lineHeight: 1.35 }}>
|
||||
{metaParts.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
{(it.notes || it.modifications) && (
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '0.88rem', color: 'var(--text2)', whiteSpace: 'pre-wrap' }}>
|
||||
{it.notes && (
|
||||
<>
|
||||
<strong style={{ fontWeight: 600 }}>Coach:</strong> {it.notes}
|
||||
</>
|
||||
)}
|
||||
{it.modifications && (
|
||||
<>
|
||||
{it.notes ? <br /> : null}
|
||||
<strong style={{ fontWeight: 600 }}>Anpassung:</strong> {it.modifications}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isComboRow && it.exercise_id ? (
|
||||
<div className="training-run-combo-embed">
|
||||
<CombinationPlanBracket
|
||||
methodArchetype={String(it.catalog_method_archetype || '').trim()}
|
||||
methodProfile={comboEffectiveProfile || {}}
|
||||
combinationSlots={
|
||||
Array.isArray(it.combination_slots) ? it.combination_slots : []
|
||||
}
|
||||
planningAdjusted={
|
||||
it.planning_method_profile != null &&
|
||||
typeof it.planning_method_profile === 'object' &&
|
||||
!Array.isArray(it.planning_method_profile)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{it.exercise_id && (
|
||||
<div className="no-print" style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginTop: '0.55rem', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.82rem', margin: 0 }}
|
||||
onClick={() =>
|
||||
setPeekCtx({
|
||||
exerciseId: it.exercise_id,
|
||||
variantId:
|
||||
it.exercise_variant_id != null
|
||||
? Number(it.exercise_variant_id)
|
||||
: null,
|
||||
peekExtras: isComboRow
|
||||
? {
|
||||
catalog_method_profile: it.catalog_method_profile,
|
||||
planning_method_profile: it.planning_method_profile,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Katalog (Popup)
|
||||
</button>
|
||||
<Link
|
||||
to={`/exercises/${it.exercise_id}`}
|
||||
style={{ fontSize: '0.82rem', color: 'var(--accent)' }}
|
||||
>
|
||||
Vollständige Seite öffnen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
return (
|
||||
<div
|
||||
key={`run-wg-${run.phaseOrderIndex}-${runIdx}`}
|
||||
className="training-run-phase training-run-phase--whole training-run-wg-block"
|
||||
>
|
||||
{run.kind !== 'legacy' ? (
|
||||
<div
|
||||
className="card training-run-phase-banner"
|
||||
style={{
|
||||
padding: '0.85rem 1rem',
|
||||
marginBottom: '0.25rem',
|
||||
background: 'color-mix(in srgb, var(--accent) 12%, var(--surface2))',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 28%, var(--border))',
|
||||
borderRadius: '10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text3)', letterSpacing: '0.04em' }}>
|
||||
GANZGRUPPE
|
||||
</div>
|
||||
<div style={{ fontSize: '1.02rem', fontWeight: 700, color: 'var(--accent-dark)', marginTop: '4px' }}>
|
||||
{run.phaseTitle ? String(run.phaseTitle).trim() : `Phase ${run.phaseOrderIndex}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text2)', marginTop: '4px' }}>
|
||||
Geplante Übungszeit: ca. {run.minutes} Min.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
{run.sections.map((sec) => renderSectionCard(sec, Math.max(0, sections.indexOf(sec))))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unit.trainer_notes && (
|
||||
<div className="card training-run-trainer-note no-print" style={{ marginTop: '1.25rem', padding: '1rem', borderLeft: `4px solid var(--accent)` }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--text3)', marginBottom: '0.35rem', textTransform: 'uppercase' }}>
|
||||
<div
|
||||
className="card training-run-trainer-note no-print"
|
||||
style={{
|
||||
marginTop: '1.25rem',
|
||||
padding: '1rem',
|
||||
borderLeft: `4px solid var(--accent)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text3)',
|
||||
marginBottom: '0.35rem',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Nur Trainer
|
||||
</div>
|
||||
<p style={{ fontSize: '0.92rem', whiteSpace: 'pre-wrap' }}>{unit.trainer_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="no-print training-run-footer" style={{ marginTop: '1.75rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--text3)' }}>
|
||||
<footer
|
||||
className="no-print training-run-footer"
|
||||
style={{ marginTop: '1.75rem', textAlign: 'center', fontSize: '0.82rem', color: 'var(--text3)' }}
|
||||
>
|
||||
Haken werden nur auf diesem Gerät gespeichert (Session — Tab schließen kann sie löschen).
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,86 +4,13 @@
|
|||
* Zentrale API-Kommunikation mit automatischer Token-Injektion
|
||||
*/
|
||||
|
||||
import { stripHtmlToText } from './htmlUtils'
|
||||
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from './combinationMethodProfileUi'
|
||||
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
|
||||
import * as exercises from '../api/exercises.js'
|
||||
import * as planning from '../api/planning.js'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
/** LocalStorage + Request-Header für Mandanten-Kontext */
|
||||
export const ACTIVE_CLUB_STORAGE_KEY = 'shinkan_active_club_id'
|
||||
|
||||
function mergeActiveClubHeader(headers = {}) {
|
||||
const cid = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
|
||||
if (cid && /^\d+$/.test(String(cid).trim())) {
|
||||
return { ...headers, 'X-Active-Club-Id': String(cid).trim() }
|
||||
}
|
||||
return { ...headers }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API request with automatic token injection
|
||||
*/
|
||||
async function request(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
|
||||
const headers = mergeActiveClubHeader({
|
||||
...options.headers,
|
||||
})
|
||||
// GET ohne Body: kein Content-Type: application/json (manche Proxies/Headers stören sich)
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['X-Auth-Token'] = token
|
||||
}
|
||||
|
||||
const url = `${API_URL}${endpoint}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const d = parsed.detail
|
||||
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
|
||||
}
|
||||
if (response.status === 502) {
|
||||
throw new Error(
|
||||
'HTTP 502 (Bad Gateway): Der Reverse-Proxy hat die API nicht korrekt erreicht. Ist `shinkan-api` aktiv (`docker compose ps`, `docker logs shinkan-api`)? Bei Host-Routing nur einen Weg verwenden — alles auf Port 3003 (Nginx nach `backend:8000`) oder sauber `/api` → Backend-Port.'
|
||||
)
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) {
|
||||
const hint =
|
||||
API_URL && API_URL.length > 0
|
||||
? `Verbindung zum API unter ${API_URL} fehlgeschlagen. Läuft das Backend (z. B. Port 8098) und ist CORS erlaubt?`
|
||||
: 'Kein VITE_API_URL gesetzt: Anfragen gehen an die Frontend-URL und schlagen oft fehl. Setze in .env z. B. VITE_API_URL=http://localhost:8098 und starte Vite neu.'
|
||||
// Ursache oft: CORS (v. a. bei Fehler-Antworten ohne CORS-Header), Adblocker, falsche URL —
|
||||
// Login kann trotzdem klappen wenn nur eine andere Route betroffen ist.
|
||||
throw new Error(`${hint} [Technisch: ${e.message}; URL war ${endpoint}]`)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
export { ACTIVE_CLUB_STORAGE_KEY }
|
||||
export * from '../api/exercises.js'
|
||||
export * from '../api/planning.js'
|
||||
|
||||
// ============================================================================
|
||||
// Auth
|
||||
|
|
@ -399,583 +326,6 @@ export async function deleteMethod(id) {
|
|||
return request(`/api/methods/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exercises
|
||||
// ============================================================================
|
||||
|
||||
export async function listExercises(filters = {}) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null) return
|
||||
if (typeof v === 'boolean') {
|
||||
q.set(k, v ? 'true' : 'false')
|
||||
return
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length === 0) return
|
||||
v.forEach((item) => {
|
||||
if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') {
|
||||
q.append(k, String(item))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (String(v).trim() !== '') q.set(k, String(v))
|
||||
})
|
||||
const query = q.toString()
|
||||
return request(`/api/exercises${query ? '?' + query : ''}`)
|
||||
}
|
||||
|
||||
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
|
||||
export function buildExerciseApiPayload(formData, extras = {}) {
|
||||
const num = (v) => (v === '' || v == null ? null : Number(v))
|
||||
|
||||
const goalHtml = formData.goal || ''
|
||||
const execHtml = formData.execution || ''
|
||||
const goalText = stripHtmlToText(goalHtml)
|
||||
const execText = stripHtmlToText(execHtml)
|
||||
|
||||
if (!goalText && !execText) {
|
||||
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).')
|
||||
}
|
||||
|
||||
const mapFocus = (formData.focus_areas_multi || [])
|
||||
.filter((x) => x && x.focus_area_id)
|
||||
.map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary }))
|
||||
const mapStyles = (formData.training_styles_multi || [])
|
||||
.filter((x) => x && x.training_style_id)
|
||||
.map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary }))
|
||||
const mapTTypes = (formData.training_types_multi || [])
|
||||
.filter((x) => x && x.training_type_id)
|
||||
.map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary }))
|
||||
const mapTg = (formData.target_groups_multi || [])
|
||||
.filter((x) => x && x.target_group_id)
|
||||
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
|
||||
|
||||
const visibilityNorm = String(formData.visibility || 'private').trim().toLowerCase()
|
||||
|
||||
const payload = {
|
||||
title: (formData.title || '').trim(),
|
||||
summary: formData.summary || null,
|
||||
goal: goalHtml.trim() ? goalHtml : null,
|
||||
execution: execHtml.trim() ? execHtml : null,
|
||||
preparation: formData.preparation || null,
|
||||
trainer_notes: formData.trainer_notes || null,
|
||||
duration_min: num(formData.duration_min),
|
||||
duration_max: num(formData.duration_max),
|
||||
group_size_min: num(formData.group_size_min),
|
||||
group_size_max: num(formData.group_size_max),
|
||||
equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
|
||||
focus_areas_multi: mapFocus,
|
||||
training_styles_multi: mapStyles,
|
||||
training_types_multi: mapTTypes,
|
||||
target_groups_multi: mapTg,
|
||||
age_groups: [],
|
||||
skills: (formData.skills || []).map((s) => ({
|
||||
skill_id: s.skill_id,
|
||||
is_primary: !!s.is_primary,
|
||||
intensity: s.intensity || null,
|
||||
required_level: s.required_level || null,
|
||||
target_level: s.target_level || null,
|
||||
})),
|
||||
visibility: visibilityNorm,
|
||||
status: formData.status || 'draft',
|
||||
club_id: visibilityNorm === 'club' ? num(formData.club_id) : null,
|
||||
exercise_kind:
|
||||
String(formData.exercise_kind || 'simple').toLowerCase() === 'combination'
|
||||
? 'combination'
|
||||
: 'simple',
|
||||
...extras,
|
||||
}
|
||||
|
||||
const isCombo = payload.exercise_kind === 'combination'
|
||||
|
||||
if (isCombo) {
|
||||
let mpObj = {}
|
||||
const mpRaw = typeof formData.method_profile_json === 'string' ? formData.method_profile_json.trim() : ''
|
||||
if (mpRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(mpRaw)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Ablaufprofil muss ein JSON-Objekt sein.')
|
||||
}
|
||||
mpObj = parsed
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
throw new Error('Ablaufprofil (JSON): Syntax ungültig.')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const slotRows = Array.isArray(formData.combination_slots) ? formData.combination_slots : []
|
||||
const combination_slots = []
|
||||
|
||||
function parseTimingField(raw) {
|
||||
if (raw === '' || raw == null || raw === undefined) return undefined
|
||||
const n = parseInt(String(raw), 10)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
for (let i = 0; i < slotRows.length; i += 1) {
|
||||
const row = slotRows[i] || {}
|
||||
let ids = Array.isArray(row.candidate_exercise_ids)
|
||||
? row.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
|
||||
: []
|
||||
|
||||
/** Legacy: noch idsText Unterstützung für Import von älteren FormStand */
|
||||
if ((!ids || ids.length === 0) && typeof row.idsText === 'string' && row.idsText.trim()) {
|
||||
ids = row.idsText
|
||||
.split(/[\s,;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
}
|
||||
|
||||
combination_slots.push({
|
||||
slot_index: i,
|
||||
title: (typeof row.title === 'string' && row.title.trim()) || null,
|
||||
candidate_exercise_ids: ids,
|
||||
})
|
||||
}
|
||||
|
||||
const slot_profiles_v1_next = []
|
||||
for (let i = 0; i < slotRows.length; i += 1) {
|
||||
const row = slotRows[i] || {}
|
||||
const o = { slot_index: i }
|
||||
const advanceMode = normalizeAdvanceMode(row.advance_mode)
|
||||
if (advanceMode !== 'timed') o.advance_mode = advanceMode
|
||||
const load = parseTimingField(row.load_sec)
|
||||
const crs = parseTimingField(row.consecutive_reps)
|
||||
const rsc = parseTimingField(row.rep_series_count)
|
||||
const intra = parseTimingField(row.intra_rep_rest_sec)
|
||||
const tran = parseTimingField(row.transition_after_sec)
|
||||
const serienUi = parseComboRepSeriesCountUi(row.rep_series_count)
|
||||
const allowInterSeriesPause =
|
||||
advanceMode === 'timed' ||
|
||||
((advanceMode === 'rep' || advanceMode === 'manual') && serienUi >= 2)
|
||||
|
||||
if (advanceMode === 'timed' && load !== undefined && load >= 0) o.load_sec = Math.round(load)
|
||||
if (crs !== undefined && crs >= 1) o.consecutive_reps = Math.round(crs)
|
||||
if (
|
||||
rsc !== undefined &&
|
||||
rsc >= 1 &&
|
||||
(advanceMode === 'rep' || advanceMode === 'manual')
|
||||
) {
|
||||
o.rep_series_count = Math.round(rsc)
|
||||
}
|
||||
if (intra !== undefined && intra >= 0 && allowInterSeriesPause) o.intra_rep_rest_sec = Math.round(intra)
|
||||
if (tran !== undefined && tran >= 0) o.transition_after_sec = Math.round(tran)
|
||||
if (Object.keys(o).length > 1) slot_profiles_v1_next.push(o)
|
||||
}
|
||||
|
||||
payload.method_archetype = (formData.method_archetype || '').trim() || null
|
||||
if (slot_profiles_v1_next.length > 0) mpObj.slot_profiles_v1 = slot_profiles_v1_next
|
||||
else delete mpObj.slot_profiles_v1
|
||||
payload.method_profile = mpObj
|
||||
payload.combination_slots = combination_slots
|
||||
} else {
|
||||
payload.method_archetype = null
|
||||
payload.method_profile = {}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export async function uploadExerciseMedia(exerciseId, formData) {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = mergeActiveClubHeader({})
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
const d = parsed?.detail
|
||||
if (
|
||||
response.status === 409 &&
|
||||
d &&
|
||||
typeof d === 'object' &&
|
||||
!Array.isArray(d) &&
|
||||
typeof d.code === 'string'
|
||||
) {
|
||||
const e = new Error(
|
||||
typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden',
|
||||
)
|
||||
e.code = d.code
|
||||
e.status = 409
|
||||
e.payload = d
|
||||
throw e
|
||||
}
|
||||
if (response.status === 413) {
|
||||
const nginx = (text || '').toLowerCase().includes('nginx')
|
||||
throw new Error(
|
||||
nginx
|
||||
? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).'
|
||||
: 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.',
|
||||
)
|
||||
}
|
||||
const msg =
|
||||
typeof d === 'string'
|
||||
? d
|
||||
: d != null && typeof d === 'object' && typeof d.message === 'string'
|
||||
? d.message
|
||||
: d != null
|
||||
? JSON.stringify(d)
|
||||
: text && text.length < 400 && !/^\s*</.test(text)
|
||||
? `HTTP ${response.status}: ${text.trim()}`
|
||||
: `HTTP ${response.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function updateExerciseMedia(exerciseId, mediaId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseMedia(exerciseId, mediaId) {
|
||||
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function reorderExerciseMedia(exerciseId, mediaIds) {
|
||||
return request(`/api/exercises/${exerciseId}/media/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ media_ids: mediaIds }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
|
||||
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
|
||||
return request(`/api/media-assets/${assetId}/lifecycle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, ...extra }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
|
||||
export async function listMediaAssets(params = {}) {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.q) sp.set('q', params.q)
|
||||
if (params.limit != null) sp.set('limit', String(params.limit))
|
||||
if (params.offset != null) sp.set('offset', String(params.offset))
|
||||
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
|
||||
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
|
||||
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
|
||||
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
|
||||
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
|
||||
const qs = sp.toString()
|
||||
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function patchMediaAsset(assetId, data) {
|
||||
return request(`/api/media-assets/${assetId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function bulkMediaLifecycle(data) {
|
||||
return request('/api/media-assets/bulk-lifecycle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function bulkPatchMediaAssets(data) {
|
||||
return request('/api/media-assets/bulk-patch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
|
||||
* @param {File[]} files
|
||||
* @param {{ visibility?: string, club_id?: number }} [options]
|
||||
*/
|
||||
export async function bulkUploadMediaAssets(files, options = {}) {
|
||||
const visibility = options.visibility || 'private'
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = mergeActiveClubHeader({})
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const formData = new FormData()
|
||||
formData.append('visibility', String(visibility))
|
||||
if (options.club_id != null && options.club_id !== '') {
|
||||
formData.append('club_id', String(options.club_id))
|
||||
}
|
||||
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
|
||||
if (options.copyright_notice != null && String(options.copyright_notice).trim())
|
||||
formData.append('copyright_notice', String(options.copyright_notice).trim())
|
||||
const p06Fields = [
|
||||
'rights_holder_confirmed',
|
||||
'contains_identifiable_persons',
|
||||
'person_consent_confirmed',
|
||||
'person_consent_context',
|
||||
'contains_minors',
|
||||
'parental_consent_confirmed',
|
||||
'parental_consent_context',
|
||||
'contains_music',
|
||||
'music_rights_confirmed',
|
||||
'music_rights_context',
|
||||
'contains_third_party_content',
|
||||
'third_party_rights_confirmed',
|
||||
'third_party_rights_context',
|
||||
]
|
||||
for (const f of p06Fields) {
|
||||
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
|
||||
}
|
||||
const arr = Array.isArray(files) ? files : [files]
|
||||
for (const f of arr) {
|
||||
if (f) formData.append('files', f)
|
||||
}
|
||||
const url = `${API_URL}/api/media-assets/bulk-upload`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const d = parsed.detail
|
||||
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
|
||||
export async function getMediaAssetJournal(assetId) {
|
||||
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
|
||||
}
|
||||
|
||||
export async function addMediaAssetDeclarationCorrection(assetId, body) {
|
||||
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function attachExerciseMediaFromAsset(exerciseId, body) {
|
||||
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// P-11: Legal-Hold-Endpunkte
|
||||
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
|
||||
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
|
||||
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ release_note: releaseNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
|
||||
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
|
||||
}
|
||||
|
||||
export async function getExercise(id) {
|
||||
return request(`/api/exercises/${id}`)
|
||||
}
|
||||
|
||||
export async function createExercise(data) {
|
||||
return request('/api/exercises', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExercise(id, data) {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = mergeActiveClubHeader({ 'Content-Type': 'application/json' })
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const url = `${API_URL}/api/exercises/${id}`
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
const d = parsed?.detail
|
||||
if (
|
||||
response.status === 422 &&
|
||||
d &&
|
||||
typeof d === 'object' &&
|
||||
!Array.isArray(d) &&
|
||||
typeof d.code === 'string'
|
||||
) {
|
||||
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
|
||||
e.status = 422
|
||||
e.code = d.code
|
||||
e.payload = d
|
||||
throw e
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
|
||||
throw new Error(msg)
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
|
||||
export async function bulkPatchExercisesMetadata(data) {
|
||||
return request('/api/exercises/bulk-metadata', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExercise(id) {
|
||||
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function createExerciseVariant(exerciseId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/variants`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseVariant(exerciseId, variantId, data) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseVariant(exerciseId, variantId) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function reorderExerciseVariants(exerciseId, variantIds) {
|
||||
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ variant_ids: variantIds }),
|
||||
})
|
||||
}
|
||||
|
||||
// Progressionsgraphen (Übung → Übung), Migration 032/033
|
||||
export async function listExerciseProgressionGraphs() {
|
||||
return request('/api/exercise-progression-graphs')
|
||||
}
|
||||
|
||||
export async function getExerciseProgressionGraph(id, { includeEdges = false } = {}) {
|
||||
const q = includeEdges ? '?include_edges=true' : ''
|
||||
return request(`/api/exercise-progression-graphs/${id}${q}`)
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionGraph(data) {
|
||||
return request('/api/exercise-progression-graphs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseProgressionGraph(id, data) {
|
||||
return request(`/api/exercise-progression-graphs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionGraph(id) {
|
||||
return request(`/api/exercise-progression-graphs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function listExerciseProgressionEdges(graphId, query = {}) {
|
||||
const q = new URLSearchParams()
|
||||
if (query.from_exercise_id != null) q.set('from_exercise_id', String(query.from_exercise_id))
|
||||
if (query.to_exercise_id != null) q.set('to_exercise_id', String(query.to_exercise_id))
|
||||
const qs = q.toString()
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionEdge(graphId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateExerciseProgressionEdge(graphId, edgeId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionEdge(graphId, edgeId) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/${edgeId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function createExerciseProgressionSequence(graphId, data) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/sequence`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteExerciseProgressionEdgesBatch(graphId, edgeIds) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/edges/delete-batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ edge_ids: edgeIds }),
|
||||
})
|
||||
}
|
||||
|
||||
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
|
||||
export async function suggestExerciseAi(payload) {
|
||||
return request('/api/exercises/ai/suggest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function regenerateExerciseAi(exerciseId, payload) {
|
||||
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Catalogs (Admin-verwaltbare Stammdaten)
|
||||
// ============================================================================
|
||||
|
|
@ -1328,176 +678,6 @@ export async function deleteTrainerContext(id) {
|
|||
return request(`/api/trainer-contexts/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Training Planning
|
||||
// ============================================================================
|
||||
|
||||
/** Query-Parameter wie GET /api/training-units. */
|
||||
export async function listTrainingUnits(filters = {}) {
|
||||
const q = new URLSearchParams()
|
||||
if (filters.group_id != null && filters.group_id !== '') {
|
||||
q.set('group_id', String(filters.group_id))
|
||||
}
|
||||
if (filters.club_id != null && filters.club_id !== '') {
|
||||
q.set('club_id', String(filters.club_id))
|
||||
}
|
||||
if (filters.start_date) q.set('start_date', filters.start_date)
|
||||
if (filters.end_date) q.set('end_date', filters.end_date)
|
||||
if (filters.status) q.set('status', filters.status)
|
||||
if (filters.debrief_pending === true) q.set('debrief_pending', 'true')
|
||||
if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true')
|
||||
if (filters.sort) q.set('sort', String(filters.sort))
|
||||
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
|
||||
if (filters.cursor_planned_date) q.set('cursor_planned_date', String(filters.cursor_planned_date))
|
||||
if (filters.cursor_planned_time != null && filters.cursor_planned_time !== '') {
|
||||
q.set('cursor_planned_time', String(filters.cursor_planned_time))
|
||||
}
|
||||
if (filters.cursor_id != null && filters.cursor_id !== '') q.set('cursor_id', String(filters.cursor_id))
|
||||
const qs = q.toString()
|
||||
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
/** Dashboard Kurzüberblick: Entwürfe / meine Übungen / YTD abgeschlossene Einheiten (ein Roundtrip). */
|
||||
export async function getDashboardKpis() {
|
||||
return request('/api/dashboard/kpis')
|
||||
}
|
||||
|
||||
/** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
|
||||
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
|
||||
const q = new URLSearchParams()
|
||||
if (filters.start_date) q.set('start_date', String(filters.start_date))
|
||||
if (filters.end_date) q.set('end_date', String(filters.end_date))
|
||||
if (filters.assigned_to_me === false) q.set('assigned_to_me', 'false')
|
||||
if (filters.limit_units != null && filters.limit_units !== '') {
|
||||
q.set('limit_units', String(filters.limit_units))
|
||||
}
|
||||
const qs = q.toString()
|
||||
return request(`/api/training-units/exercises-club-visibility-queue${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function getTrainingUnit(id) {
|
||||
return request(`/api/training-units/${id}`)
|
||||
}
|
||||
|
||||
export async function createTrainingUnit(data) {
|
||||
return request('/api/training-units', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateTrainingUnit(id, data) {
|
||||
return request(`/api/training-units/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteTrainingUnit(id) {
|
||||
return request(`/api/training-units/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function quickCreateTrainingUnit(data) {
|
||||
return request('/api/training-units/quick-create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
|
||||
export async function createTrainingUnitFromFrameworkSlot(data) {
|
||||
return request('/api/training-units/from-framework-slot', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function listTrainingPlanTemplates() {
|
||||
return request('/api/training-plan-templates')
|
||||
}
|
||||
|
||||
export async function getTrainingPlanTemplate(id) {
|
||||
return request(`/api/training-plan-templates/${id}`)
|
||||
}
|
||||
|
||||
export async function createTrainingPlanTemplate(data) {
|
||||
return request('/api/training-plan-templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateTrainingPlanTemplate(id, data) {
|
||||
return request(`/api/training-plan-templates/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteTrainingPlanTemplate(id) {
|
||||
return request(`/api/training-plan-templates/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function listTrainingModules() {
|
||||
return request('/api/training-modules')
|
||||
}
|
||||
|
||||
export async function getTrainingModule(id) {
|
||||
return request(`/api/training-modules/${id}`)
|
||||
}
|
||||
|
||||
export async function createTrainingModule(data) {
|
||||
return request('/api/training-modules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateTrainingModule(id, data) {
|
||||
return request(`/api/training-modules/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteTrainingModule(id) {
|
||||
return request(`/api/training-modules/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Kopiert Modul-Inhalte ans Ende eines Abschnitts (section_order_index 0-basiert). */
|
||||
export async function applyTrainingModuleToTrainingUnit(unitId, data) {
|
||||
return request(`/api/training-units/${unitId}/apply-training-module`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listTrainingFrameworkPrograms() {
|
||||
return request('/api/training-framework-programs')
|
||||
}
|
||||
|
||||
export async function getTrainingFrameworkProgram(id) {
|
||||
return request(`/api/training-framework-programs/${id}`)
|
||||
}
|
||||
|
||||
export async function createTrainingFrameworkProgram(data) {
|
||||
return request('/api/training-framework-programs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateTrainingFrameworkProgram(id, data) {
|
||||
return request(`/api/training-framework-programs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteTrainingFrameworkProgram(id) {
|
||||
return request(`/api/training-framework-programs/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Version & Health
|
||||
// ============================================================================
|
||||
|
|
@ -1567,74 +747,11 @@ export const api = {
|
|||
updateMethod,
|
||||
deleteMethod,
|
||||
|
||||
// Exercises
|
||||
listExercises,
|
||||
getExercise,
|
||||
createExercise,
|
||||
updateExercise,
|
||||
bulkPatchExercisesMetadata,
|
||||
deleteExercise,
|
||||
createExerciseVariant,
|
||||
updateExerciseVariant,
|
||||
deleteExerciseVariant,
|
||||
reorderExerciseVariants,
|
||||
buildExerciseApiPayload,
|
||||
suggestExerciseAi,
|
||||
regenerateExerciseAi,
|
||||
uploadExerciseMedia,
|
||||
updateExerciseMedia,
|
||||
deleteExerciseMedia,
|
||||
reorderExerciseMedia,
|
||||
postMediaAssetLifecycle,
|
||||
listMediaAssets,
|
||||
patchMediaAsset,
|
||||
bulkMediaLifecycle,
|
||||
bulkPatchMediaAssets,
|
||||
bulkUploadMediaAssets,
|
||||
getMediaAssetJournal,
|
||||
addMediaAssetDeclarationCorrection,
|
||||
attachExerciseMediaFromAsset,
|
||||
setMediaAssetLegalHold,
|
||||
releaseMediaAssetLegalHold,
|
||||
listMediaAssetsWithLegalHold,
|
||||
listExerciseProgressionGraphs,
|
||||
getExerciseProgressionGraph,
|
||||
createExerciseProgressionGraph,
|
||||
updateExerciseProgressionGraph,
|
||||
deleteExerciseProgressionGraph,
|
||||
listExerciseProgressionEdges,
|
||||
createExerciseProgressionEdge,
|
||||
updateExerciseProgressionEdge,
|
||||
deleteExerciseProgressionEdge,
|
||||
createExerciseProgressionSequence,
|
||||
deleteExerciseProgressionEdgesBatch,
|
||||
// Exercises + Medien/Archiv (Progression, KI) → frontend/src/api/exercises.js
|
||||
...exercises,
|
||||
|
||||
// Training Planning
|
||||
listTrainingUnits,
|
||||
getDashboardKpis,
|
||||
getTrainingExerciseClubVisibilityQueue,
|
||||
getTrainingUnit,
|
||||
createTrainingUnit,
|
||||
updateTrainingUnit,
|
||||
deleteTrainingUnit,
|
||||
quickCreateTrainingUnit,
|
||||
createTrainingUnitFromFrameworkSlot,
|
||||
listTrainingPlanTemplates,
|
||||
getTrainingPlanTemplate,
|
||||
createTrainingPlanTemplate,
|
||||
updateTrainingPlanTemplate,
|
||||
deleteTrainingPlanTemplate,
|
||||
listTrainingModules,
|
||||
getTrainingModule,
|
||||
createTrainingModule,
|
||||
updateTrainingModule,
|
||||
deleteTrainingModule,
|
||||
applyTrainingModuleToTrainingUnit,
|
||||
listTrainingFrameworkPrograms,
|
||||
getTrainingFrameworkProgram,
|
||||
createTrainingFrameworkProgram,
|
||||
updateTrainingFrameworkProgram,
|
||||
deleteTrainingFrameworkProgram,
|
||||
// Training Planning → frontend/src/api/planning.js
|
||||
...planning,
|
||||
|
||||
// Catalogs
|
||||
listFocusAreas,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,15 @@
|
|||
* Hilfen für Trainingsplan-Ansicht, Coach-Modus und API-Payload für PUT /training-units/:id.
|
||||
*/
|
||||
|
||||
import { cloneJsonSerializablePlanningProfile } from './trainingUnitSectionsForm'
|
||||
import {
|
||||
buildPlanPayloadForSave,
|
||||
cloneJsonSerializablePlanningProfile,
|
||||
defaultPlanLocWholeGroup,
|
||||
inheritPlanLocForPhasedSave,
|
||||
phaseRunsFromSections,
|
||||
sectionIndicesForParallelStream,
|
||||
streamsForParallelPhaseOrders,
|
||||
} from './trainingUnitSectionsForm'
|
||||
export function sortedSections(unit) {
|
||||
const raw = unit?.sections
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
|
@ -20,10 +28,352 @@ export function itemStableKey(it, secOrder, ix) {
|
|||
return `${secOrder}-${it?.item_type || 'row'}-${ix}`
|
||||
}
|
||||
|
||||
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander. */
|
||||
export function flattenPlanTimeline(unit) {
|
||||
export function sumExerciseMinutesInSection(sec) {
|
||||
let t = 0
|
||||
for (const it of sortedItems(sec)) {
|
||||
if (it.item_type === 'exercise' && it.planned_duration_min != null) {
|
||||
const n = Number(it.planned_duration_min)
|
||||
if (Number.isFinite(n)) t += n
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/**
|
||||
* GET liefert `planLoc` oft nicht auf flachen `sections`, aber `unit.phases` (verschachtelt).
|
||||
* Baut pro Abschnitt `planLoc` für phaseRuns / Darstellung (camelCase wie im Editor).
|
||||
*/
|
||||
function planLocBySectionIdFromPhases(phases) {
|
||||
const byId = new Map()
|
||||
if (!Array.isArray(phases)) return byId
|
||||
for (const ph of phases) {
|
||||
const po = Number(ph.order_index ?? ph.orderIndex ?? 0) || 0
|
||||
const pk = String(ph.phase_kind ?? ph.phaseKind ?? '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
const phaseTitle = ph.title ?? ph.phaseTitle ?? null
|
||||
const phaseGuidanceNotes = ph.guidance_notes ?? ph.guidanceNotes ?? null
|
||||
if (pk === 'whole_group') {
|
||||
for (const sec of ph.sections || []) {
|
||||
const sid = sec.id != null ? Number(sec.id) : NaN
|
||||
if (!Number.isFinite(sid)) continue
|
||||
byId.set(sid, {
|
||||
phaseKind: 'whole_group',
|
||||
phaseOrderIndex: po,
|
||||
parallelStreamOrderIndex: null,
|
||||
phaseTitle,
|
||||
phaseGuidanceNotes,
|
||||
streamTitle: null,
|
||||
streamNotes: null,
|
||||
streamAssignedTrainerProfileIds: null,
|
||||
})
|
||||
}
|
||||
} else if (pk === 'parallel') {
|
||||
for (const st of ph.streams || []) {
|
||||
const so = Number(st.order_index ?? st.orderIndex ?? 0) || 0
|
||||
const streamTitle = st.title ?? st.streamTitle ?? null
|
||||
const streamNotes = st.notes ?? st.streamNotes ?? null
|
||||
const streamAssignedTrainerProfileIds =
|
||||
st.assigned_trainer_profile_ids ?? st.streamAssignedTrainerProfileIds ?? null
|
||||
for (const sec of st.sections || []) {
|
||||
const sid = sec.id != null ? Number(sec.id) : NaN
|
||||
if (!Number.isFinite(sid)) continue
|
||||
byId.set(sid, {
|
||||
phaseKind: 'parallel',
|
||||
phaseOrderIndex: po,
|
||||
parallelStreamOrderIndex: so,
|
||||
phaseTitle,
|
||||
phaseGuidanceNotes,
|
||||
streamTitle,
|
||||
streamNotes,
|
||||
streamAssignedTrainerProfileIds,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return byId
|
||||
}
|
||||
|
||||
function maxPhaseOrderIndexFromNestedPhases(phases) {
|
||||
let m = -1
|
||||
if (!Array.isArray(phases)) return m
|
||||
for (const ph of phases) {
|
||||
const po = Number(ph.order_index ?? ph.orderIndex ?? 0)
|
||||
if (Number.isFinite(po)) m = Math.max(m, po)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export function sectionsWithPlanLocForDisplay(unit) {
|
||||
const sorted = sortedSections(unit)
|
||||
const byId = planLocBySectionIdFromPhases(unit?.phases)
|
||||
const merged = sorted.map((s) => {
|
||||
const sid = s.id != null ? Number(s.id) : NaN
|
||||
if (Number.isFinite(sid) && byId.has(sid)) {
|
||||
return { ...s, planLoc: { ...byId.get(sid) } }
|
||||
}
|
||||
if (s.planLoc && s.planLoc.phaseKind) return s
|
||||
return { ...s }
|
||||
})
|
||||
const inherited = inheritPlanLocForPhasedSave(merged)
|
||||
const maxPhPo = maxPhaseOrderIndexFromNestedPhases(unit?.phases)
|
||||
return inherited.map((s) => {
|
||||
const sid = s.id != null ? Number(s.id) : NaN
|
||||
if (!Number.isFinite(sid) || byId.has(sid)) return s
|
||||
const pl = s.planLoc
|
||||
if (pl?.phaseKind === 'parallel') {
|
||||
const po = maxPhPo >= 0 ? maxPhPo + 1 : 0
|
||||
return {
|
||||
...s,
|
||||
planLoc: {
|
||||
...defaultPlanLocWholeGroup(po),
|
||||
phaseTitle: pl.phaseTitle ?? null,
|
||||
phaseGuidanceNotes: pl.phaseGuidanceNotes ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Läuft auf bereits angereichter Abschnittsliste (gleiche Objektreferenzen wie in Slices).
|
||||
*/
|
||||
export function buildPlanRunViewModelFromSections(sections) {
|
||||
if (!sections.length) {
|
||||
return { mode: 'empty', runs: [], totalMin: 0, runCumulativeEnds: [] }
|
||||
}
|
||||
|
||||
const runsMeta = phaseRunsFromSections(sections)
|
||||
const runs = []
|
||||
let cum = 0
|
||||
const runCumulativeEnds = []
|
||||
|
||||
if (runsMeta.length === 0) {
|
||||
const minutes = sections.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
|
||||
cum = minutes
|
||||
runCumulativeEnds.push(cum)
|
||||
runs.push({
|
||||
kind: 'legacy',
|
||||
phaseOrderIndex: 0,
|
||||
phaseTitle: null,
|
||||
minutes,
|
||||
sections,
|
||||
streams: null,
|
||||
globalOrderSections: sections,
|
||||
})
|
||||
return { mode: 'legacy', runs, totalMin: minutes, runCumulativeEnds }
|
||||
}
|
||||
|
||||
for (const r of runsMeta) {
|
||||
const slice = sections.slice(r.start, r.end)
|
||||
if (r.phaseKind === 'whole_group') {
|
||||
const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
|
||||
cum += minutes
|
||||
runCumulativeEnds.push(cum)
|
||||
const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null
|
||||
runs.push({
|
||||
kind: 'whole_group',
|
||||
phaseOrderIndex: r.phaseOrderIndex,
|
||||
phaseTitle,
|
||||
minutes,
|
||||
sections: slice,
|
||||
streams: null,
|
||||
globalOrderSections: slice,
|
||||
})
|
||||
} else {
|
||||
const po = r.phaseOrderIndex
|
||||
const streamOrders = streamsForParallelPhaseOrders(slice, po)
|
||||
const streams = streamOrders.map((so) => {
|
||||
const idxs = sectionIndicesForParallelStream(slice, po, so)
|
||||
const streamSecs = idxs
|
||||
.map((i) => slice[i])
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
const first = streamSecs[0]
|
||||
const streamTitle = first?.planLoc?.streamTitle ?? null
|
||||
const minutes = streamSecs.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
|
||||
return {
|
||||
streamOrder: so,
|
||||
streamTitle,
|
||||
minutes,
|
||||
sections: streamSecs,
|
||||
printStreamId: `p${po}-s${so}`,
|
||||
}
|
||||
})
|
||||
const minutes = slice.reduce((s, sec) => s + sumExerciseMinutesInSection(sec), 0)
|
||||
cum += minutes
|
||||
runCumulativeEnds.push(cum)
|
||||
const phaseTitle = slice[0]?.planLoc?.phaseTitle ?? null
|
||||
runs.push({
|
||||
kind: 'parallel',
|
||||
phaseOrderIndex: po,
|
||||
phaseTitle,
|
||||
minutes,
|
||||
streams,
|
||||
sections: null,
|
||||
globalOrderSections: slice,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: 'phased', runs, totalMin: cum, runCumulativeEnds }
|
||||
}
|
||||
|
||||
/** @param {object} unit Trainingseinheit inkl. `sections`, optional `phases` (GET) */
|
||||
export function buildPlanRunViewModel(unit) {
|
||||
return buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit))
|
||||
}
|
||||
|
||||
function coachContextLabelForSection(sec, sectionsList) {
|
||||
const pl = sec?.planLoc
|
||||
if (!pl?.phaseKind) return 'Ablauf'
|
||||
if (pl.phaseKind === 'whole_group') {
|
||||
const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : null
|
||||
return pt ? `Ganzgruppe · ${pt}` : 'Ganzgruppe'
|
||||
}
|
||||
const pt = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : `Phase ${pl.phaseOrderIndex ?? 0}`
|
||||
const so = pl.parallelStreamOrderIndex ?? 0
|
||||
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : `Gruppe ${so + 1}`
|
||||
return `Parallel · ${pt} · ${st}`
|
||||
}
|
||||
|
||||
export const COACH_ENTRY_BRANCH_GATE = 'branch_gate'
|
||||
|
||||
/** Normalisierte Stream-Wahl pro paralleler Phase (Phase-Index → Stream-Order). */
|
||||
export function normalizeCoachBranchPicks(raw) {
|
||||
const out = {}
|
||||
if (!raw || typeof raw !== 'object') return out
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const pk = parseInt(String(k), 10)
|
||||
const sv = typeof v === 'number' ? v : parseInt(String(v), 10)
|
||||
if (Number.isFinite(pk) && Number.isFinite(sv)) out[pk] = sv
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-/Dropdown-Fokus `coachFocus` in Pick-Map mergen (fest gewählter Stream für eine Phase).
|
||||
* @param {object} branchPicks Roh-Picks (z. B. aus Session)
|
||||
* @param {{ phaseOrder: number, streamOrder: number }|null} coachFocusUrl z. B. ?po=&so=
|
||||
*/
|
||||
export function mergeCoachBranchPicksWithUrlFocus(branchPicks, coachFocusUrl) {
|
||||
const m = normalizeCoachBranchPicks(branchPicks)
|
||||
if (
|
||||
coachFocusUrl != null &&
|
||||
Number.isFinite(coachFocusUrl.phaseOrder) &&
|
||||
Number.isFinite(coachFocusUrl.streamOrder)
|
||||
) {
|
||||
m[coachFocusUrl.phaseOrder] = coachFocusUrl.streamOrder
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/** Kurzstring für SessionStorage-Schlüssel (Sortierung stabil). */
|
||||
export function coachBranchPicksStepStorageSuffix(mergedPicks) {
|
||||
const keys = Object.keys(mergedPicks)
|
||||
.map((k) => parseInt(String(k), 10))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
.sort((a, b) => a - b)
|
||||
if (!keys.length) return 'full'
|
||||
return keys.map((k) => `p${k}-s${mergedPicks[k]}`).join('_')
|
||||
}
|
||||
|
||||
export function coachBranchPicksStorageKey(unitId) {
|
||||
return `sj_coach_branches_${unitId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Index zum Springen: Co-Trainer-Link atBranch+preferSo — Gate falls noch offen, sonst erste Kachel im Stream.
|
||||
*/
|
||||
export function findCoachTimelineJumpIndexForPhase(timeline, phaseOrder, preferStreamOrder = null) {
|
||||
const po = Number(phaseOrder)
|
||||
if (!Number.isFinite(po) || !Array.isArray(timeline)) return -1
|
||||
const hint = preferStreamOrder != null && Number.isFinite(Number(preferStreamOrder)) ? Number(preferStreamOrder) : null
|
||||
if (hint != null) {
|
||||
const ixStream = timeline.findIndex(
|
||||
(e) =>
|
||||
e.entryKind !== COACH_ENTRY_BRANCH_GATE &&
|
||||
e.runMeta?.kind === 'parallel' &&
|
||||
e.runMeta.phaseOrderIndex === po &&
|
||||
e.runMeta.streamOrder === hint
|
||||
)
|
||||
if (ixStream >= 0) return ixStream
|
||||
}
|
||||
const ixGate = timeline.findIndex(
|
||||
(e) => e.entryKind === COACH_ENTRY_BRANCH_GATE && e.branchMeta?.phaseOrderIndex === po
|
||||
)
|
||||
return ixGate
|
||||
}
|
||||
|
||||
/** Gruppiert die flache Coach-Timeline für den Trainingsrahmen (Überschriften + Einträge). */
|
||||
export function coachOutlineGroupsFromTimeline(timeline) {
|
||||
const groups = []
|
||||
for (let ix = 0; ix < timeline.length; ix++) {
|
||||
const ent = timeline[ix]
|
||||
let mergeKey = `ix-${ix}`
|
||||
let heading = 'Ablauf'
|
||||
let sub = ''
|
||||
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
|
||||
const po = ent.branchMeta?.phaseOrderIndex ?? 0
|
||||
mergeKey = `gate-${po}`
|
||||
heading = 'Split · Gruppe wählen'
|
||||
const pt = ent.branchMeta?.phaseTitle
|
||||
sub =
|
||||
pt != null && String(pt).trim()
|
||||
? String(pt).trim()
|
||||
: `Parallele Phase ${po}`
|
||||
} else {
|
||||
const rm = ent.runMeta
|
||||
if (rm?.kind === 'whole_group') {
|
||||
mergeKey = `wg-${rm.phaseOrderIndex}`
|
||||
const pl = ent.sec?.planLoc
|
||||
const ptt = pl?.phaseTitle
|
||||
heading = 'Ganzgruppe'
|
||||
sub = ptt != null && String(ptt).trim() ? String(ptt).trim() : ''
|
||||
} else if (rm?.kind === 'parallel' && rm.streamOrder != null) {
|
||||
mergeKey = `par-${rm.phaseOrderIndex}-s${rm.streamOrder}`
|
||||
const pl = ent.sec?.planLoc
|
||||
const ptt = pl?.phaseTitle
|
||||
const stt = pl?.streamTitle
|
||||
const ptl = ptt != null && String(ptt).trim() ? String(ptt).trim() : `Phase ${rm.phaseOrderIndex}`
|
||||
const stl = stt != null && String(stt).trim() ? String(stt).trim() : `Gruppe ${rm.streamOrder + 1}`
|
||||
heading = `Parallel · ${ptl}`
|
||||
sub = stl
|
||||
} else if (rm?.kind === 'legacy') {
|
||||
mergeKey = 'legacy'
|
||||
heading = 'Ablauf'
|
||||
sub = ''
|
||||
} else {
|
||||
mergeKey = `misc-${ix}`
|
||||
}
|
||||
}
|
||||
const prev = groups[groups.length - 1]
|
||||
if (!prev || prev.mergeKey !== mergeKey) {
|
||||
groups.push({ mergeKey, heading, sub, entries: [{ ix, ent }] })
|
||||
} else {
|
||||
prev.entries.push({ ix, ent })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Flache Coach-Reihenfolge. Pro paralleler Phase ohne Eintrag in branchPicks: ein sichtbarer branch_gate,
|
||||
* damit keine verschränkten Split-Übungen, bis eine Gruppe gewählt wurde.
|
||||
* @param {object} unit
|
||||
* @param {object} branchPicks z. B. { 0: 1 } = Phase 0 → Stream 1
|
||||
*/
|
||||
export function flattenPlanTimeline(unit, branchPicks = {}) {
|
||||
const sections = sectionsWithPlanLocForDisplay(unit)
|
||||
const model = buildPlanRunViewModelFromSections(sections)
|
||||
if (model.mode === 'empty') return []
|
||||
|
||||
const picks = normalizeCoachBranchPicks(branchPicks)
|
||||
const list = []
|
||||
sortedSections(unit).forEach((sec, si) => {
|
||||
|
||||
const pushSectionItems = (sec, coachCtx, runMeta) => {
|
||||
const si = Math.max(0, sections.indexOf(sec))
|
||||
const secOrder = sec.order_index ?? si
|
||||
sortedItems(sec).forEach((item, ii) => {
|
||||
list.push({
|
||||
|
|
@ -33,13 +383,165 @@ export function flattenPlanTimeline(unit) {
|
|||
flatIndex: list.length,
|
||||
sec,
|
||||
item,
|
||||
coachContext: coachCtx,
|
||||
runMeta: runMeta || null,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const run of model.runs) {
|
||||
if (run.kind === 'legacy') {
|
||||
const meta = { kind: 'legacy', phaseOrderIndex: 0, streamOrder: null }
|
||||
for (const sec of run.globalOrderSections) {
|
||||
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (run.kind === 'whole_group') {
|
||||
const meta = { kind: 'whole_group', phaseOrderIndex: run.phaseOrderIndex, streamOrder: null }
|
||||
for (const sec of run.globalOrderSections) {
|
||||
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (run.kind === 'parallel') {
|
||||
const po = run.phaseOrderIndex
|
||||
const chosen = picks[po]
|
||||
const hasPick = chosen !== undefined && chosen !== null && Number.isFinite(Number(chosen))
|
||||
if (!hasPick) {
|
||||
list.push({
|
||||
entryKind: COACH_ENTRY_BRANCH_GATE,
|
||||
si: -1,
|
||||
ii: -1,
|
||||
secOrder: -1,
|
||||
flatIndex: list.length,
|
||||
sec: null,
|
||||
item: null,
|
||||
coachContext: '',
|
||||
branchMeta: {
|
||||
phaseOrderIndex: po,
|
||||
phaseTitle: run.phaseTitle,
|
||||
streams: run.streams || [],
|
||||
},
|
||||
runMeta: { kind: 'parallel', phaseOrderIndex: po, streamOrder: null },
|
||||
})
|
||||
} else {
|
||||
const st = run.streams?.find((x) => x.streamOrder === Number(chosen))
|
||||
const meta = { kind: 'parallel', phaseOrderIndex: po, streamOrder: Number(chosen) }
|
||||
if (st?.sections?.length) {
|
||||
for (const sec of st.sections) {
|
||||
pushSectionItems(sec, coachContextLabelForSection(sec, sections), meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
export function summarizeTimelineEntry({ item }) {
|
||||
/** Optionen für Coach-Stream-Auswahl (phases · Gruppe). */
|
||||
export function listCoachStreamFocusOptions(unit) {
|
||||
const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit))
|
||||
const opts = []
|
||||
for (const run of model.runs) {
|
||||
if (run.kind !== 'parallel' || !run.streams?.length) continue
|
||||
const phaseLabel =
|
||||
run.phaseTitle != null && String(run.phaseTitle).trim()
|
||||
? String(run.phaseTitle).trim()
|
||||
: `Phase ${run.phaseOrderIndex}`
|
||||
for (const st of run.streams) {
|
||||
const gl =
|
||||
st.streamTitle != null && String(st.streamTitle).trim()
|
||||
? String(st.streamTitle).trim()
|
||||
: `Gruppe ${st.streamOrder + 1}`
|
||||
opts.push({
|
||||
phaseOrder: run.phaseOrderIndex,
|
||||
streamOrder: st.streamOrder,
|
||||
label: `${phaseLabel} · ${gl}`,
|
||||
valueKey: `${run.phaseOrderIndex}-${st.streamOrder}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
/** Alle Ist-Minuten-Overrides aus lokalem Delta-State für PUT (unabhängig von gefilterter Coach-Timeline). */
|
||||
export function durationOverridesMapFromDeltas(unit, deltas) {
|
||||
const out = {}
|
||||
if (!unit || !deltas || typeof deltas !== 'object') return out
|
||||
const sections = sortedSections(unit)
|
||||
sections.forEach((sec, si) => {
|
||||
const secOrder = sec.order_index ?? si
|
||||
sortedItems(sec).forEach((it, ii) => {
|
||||
if (it.item_type !== 'exercise' || it.id == null) return
|
||||
const k = itemStableKey(it, secOrder, ii)
|
||||
const dv = deltas[k]?.actual_duration_min
|
||||
if (dv !== undefined && dv !== '' && dv !== null && !Number.isNaN(Number(dv))) {
|
||||
out[String(it.id)] = { actual_duration_min: Number(dv) }
|
||||
}
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** PUT-Body für Coach-Speichern: `phases` wenn Plan Phasen hat, sonst `sections` (wie Planungseditor). */
|
||||
export function buildCoachSavePlanPayload(unit, durationOverridesByItemId = {}) {
|
||||
const withLoc = sectionsWithPlanLocForDisplay(unit)
|
||||
const withDur = withLoc.map((sec) => ({
|
||||
...sec,
|
||||
items: sortedItems(sec).map((it) => {
|
||||
if (it.item_type !== 'exercise' || it.id == null) return it
|
||||
const o = durationOverridesByItemId[String(it.id)]
|
||||
const av = o?.actual_duration_min
|
||||
if (av !== undefined && av !== '' && av !== null && Number.isFinite(Number(av))) {
|
||||
return { ...it, actual_duration_min: Number(av) }
|
||||
}
|
||||
return it
|
||||
}),
|
||||
}))
|
||||
return buildPlanPayloadForSave(withDur)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach dem letzten Block eines Streams: Rückfrage, wenn die parallele Phase mehrere Gruppen hat.
|
||||
*/
|
||||
export function coachShouldPromptSplitRejoin(unit, lastTimelineEntry) {
|
||||
const rm = lastTimelineEntry?.runMeta
|
||||
if (!rm || rm.kind !== 'parallel' || rm.streamOrder == null) return null
|
||||
const model = buildPlanRunViewModelFromSections(sectionsWithPlanLocForDisplay(unit))
|
||||
const run = model.runs.find((r) => r.kind === 'parallel' && r.phaseOrderIndex === rm.phaseOrderIndex)
|
||||
if (!run?.streams || run.streams.length <= 1) return null
|
||||
return {
|
||||
phaseOrderIndex: run.phaseOrderIndex,
|
||||
phaseTitle: run.phaseTitle,
|
||||
streams: run.streams,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach dem letzten Block eines gewählten Streams: Rückfrage vor Ganzgruppenphase oder vor dem nächsten Split,
|
||||
* wenn die aktuelle Parallelphase mehrere Streams hat.
|
||||
*/
|
||||
export function coachShouldPromptSplitRejoinTransition(unit, currentEntry, nextEntry) {
|
||||
if (!currentEntry || !nextEntry) return null
|
||||
const cRm = currentEntry.runMeta
|
||||
if (!cRm || cRm.kind !== 'parallel' || cRm.streamOrder == null) return null
|
||||
const intoWholeGroup = nextEntry.runMeta?.kind === 'whole_group'
|
||||
const intoNextSplit = nextEntry.entryKind === COACH_ENTRY_BRANCH_GATE
|
||||
if (!intoWholeGroup && !intoNextSplit) return null
|
||||
return coachShouldPromptSplitRejoin(unit, currentEntry)
|
||||
}
|
||||
|
||||
export function summarizeTimelineEntry(ent) {
|
||||
if (!ent) return ''
|
||||
if (ent.entryKind === COACH_ENTRY_BRANCH_GATE) {
|
||||
const t = ent.branchMeta?.phaseTitle != null && String(ent.branchMeta.phaseTitle).trim()
|
||||
? String(ent.branchMeta.phaseTitle).trim()
|
||||
: ''
|
||||
return t ? `Split wählen · ${t}` : 'Split · Gruppe wählen'
|
||||
}
|
||||
const { item } = ent
|
||||
if (!item) return ''
|
||||
if (item.item_type === 'note') {
|
||||
const t = String(item.note_body || '').trim()
|
||||
|
|
|
|||
115
frontend/src/utils/trainingPlanningPageHelpers.js
Normal file
115
frontend/src/utils/trainingPlanningPageHelpers.js
Normal 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 (Mo–So) */
|
||||
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 Co‑Option */
|
||||
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 }
|
||||
}
|
||||
|
|
@ -5,6 +5,201 @@ export function defaultSection(title = 'Hauptteil') {
|
|||
return { title, guidance_notes: '', items: [] }
|
||||
}
|
||||
|
||||
/** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */
|
||||
export function defaultPlanLocWholeGroup(phaseOrderIndex = 0) {
|
||||
return {
|
||||
phaseKind: 'whole_group',
|
||||
phaseOrderIndex,
|
||||
parallelStreamOrderIndex: null,
|
||||
phaseTitle: null,
|
||||
phaseGuidanceNotes: null,
|
||||
streamTitle: null,
|
||||
streamNotes: null,
|
||||
streamAssignedTrainerProfileIds: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard-`planLoc` für einen Stream innerhalb einer parallelen Phase. */
|
||||
export function defaultPlanLocParallel(phaseOrderIndex, streamOrderIndex) {
|
||||
return {
|
||||
phaseKind: 'parallel',
|
||||
phaseOrderIndex,
|
||||
parallelStreamOrderIndex: streamOrderIndex,
|
||||
phaseTitle: null,
|
||||
phaseGuidanceNotes: null,
|
||||
streamTitle: null,
|
||||
streamNotes: null,
|
||||
streamAssignedTrainerProfileIds: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function planLocKey(pl) {
|
||||
if (!pl || !pl.phaseKind) return ''
|
||||
if (pl.phaseKind === 'whole_group') return `wg:${pl.phaseOrderIndex ?? 0}`
|
||||
return `par:${pl.phaseOrderIndex ?? 0}:${pl.parallelStreamOrderIndex ?? 0}`
|
||||
}
|
||||
|
||||
export function maxPhaseOrderIndexFromSections(sections) {
|
||||
let m = -1
|
||||
for (const s of sections || []) {
|
||||
const pl = s?.planLoc
|
||||
if (!pl || typeof pl.phaseOrderIndex !== 'number') continue
|
||||
if (pl.phaseOrderIndex > m) m = pl.phaseOrderIndex
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
/**
|
||||
* Eindeutige Ziele für die Zuordnung eines Abschnitts (Dropdown).
|
||||
* `template` ist ein vollständiges planLoc-Objekt zum Kopieren.
|
||||
*/
|
||||
export function buildPlanTargetOptions(sections) {
|
||||
const map = new Map()
|
||||
for (const s of sections || []) {
|
||||
const pl = s?.planLoc
|
||||
if (!pl?.phaseKind) continue
|
||||
if (pl.phaseKind === 'whole_group') {
|
||||
const po = pl.phaseOrderIndex ?? 0
|
||||
const k = `wg:${po}`
|
||||
if (!map.has(k)) {
|
||||
const title = pl.phaseTitle != null && String(pl.phaseTitle).trim() ? String(pl.phaseTitle).trim() : ''
|
||||
map.set(k, {
|
||||
key: k,
|
||||
label: title || `Ganzgruppe · Phase ${po}`,
|
||||
template: { ...pl },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const po = pl.phaseOrderIndex ?? 0
|
||||
const so = pl.parallelStreamOrderIndex ?? 0
|
||||
const k = `par:${po}:${so}`
|
||||
if (!map.has(k)) {
|
||||
const st = pl.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
|
||||
map.set(k, {
|
||||
key: k,
|
||||
label: st || `Parallel · Phase ${po} · Stream ${so}`,
|
||||
template: { ...pl },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
/** Max. Streams pro paralleler Phase (UI + API-Schutz). */
|
||||
export const MAX_PARALLEL_STREAMS_PER_PHASE = 5
|
||||
|
||||
/** Farben pro Stream-Index (max. 5 unterschiedliche Farbzyklen). */
|
||||
export function parallelStreamVisual(streamOrderIndex) {
|
||||
const n = Math.max(0, Number(streamOrderIndex) || 0)
|
||||
const hues = [200, 135, 38, 285, 22]
|
||||
const h = hues[n % hues.length]
|
||||
return {
|
||||
border: `hsl(${h} 50% 36%)`,
|
||||
soft: `hsl(${h} 36% 94%)`,
|
||||
tabBg: `hsl(${h} 34% 92%)`,
|
||||
tabBgActive: `hsl(${h} 40% 82%)`,
|
||||
}
|
||||
}
|
||||
|
||||
export function streamTabLabelFromIndices(sections, globalIndices) {
|
||||
const first = globalIndices?.[0]
|
||||
if (first === undefined || !sections?.[first]) return 'Stream'
|
||||
const pl = sections[first].planLoc
|
||||
const t = pl?.streamTitle != null && String(pl.streamTitle).trim() ? String(pl.streamTitle).trim() : ''
|
||||
if (t) return t
|
||||
const so = pl?.parallelStreamOrderIndex ?? 0
|
||||
return `Stream ${so + 1}`
|
||||
}
|
||||
|
||||
/** Sortierte Stream-Indizes innerhalb einer parallelen Phase (für Reiter). */
|
||||
export function streamsForParallelPhaseOrders(sections, phaseOrderIndex) {
|
||||
const set = new Set()
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
for (const s of sections || []) {
|
||||
const L = s?.planLoc
|
||||
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) {
|
||||
set.add(L.parallelStreamOrderIndex ?? 0)
|
||||
}
|
||||
}
|
||||
return [...set].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/** Globale Abschnitts-Indizes eines Streams. */
|
||||
export function sectionIndicesForParallelStream(sections, phaseOrderIndex, streamOrderIndex) {
|
||||
const out = []
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const so = Number(streamOrderIndex) || 0
|
||||
;(sections || []).forEach((s, i) => {
|
||||
const L = s?.planLoc
|
||||
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po && (L.parallelStreamOrderIndex ?? 0) === so) {
|
||||
out.push(i)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Abschnitte an den angegebenen globalen Indizes entfernen (mindestens ein Abschnitt bleibt). */
|
||||
export function reorderWithoutIndices(prev, removeGlobalIndices) {
|
||||
const set = new Set(removeGlobalIndices || [])
|
||||
const next = (prev || []).filter((_, i) => !set.has(i))
|
||||
return next.length ? next : [defaultSection()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob in den Abschnitten eines Stream-Buckets planerisch etwas steht (Übungen, Text-Anmerkungen).
|
||||
* Trennlinien-Marker (---) zählen nicht als Inhalt.
|
||||
*/
|
||||
export function parallelStreamBucketHasContent(sections, globalIndices, separatorBody = '---') {
|
||||
for (const gi of globalIndices || []) {
|
||||
const sec = (sections || [])[gi]
|
||||
if (!sec) continue
|
||||
for (const it of sec.items || []) {
|
||||
if ((it.item_type || '') === 'note') {
|
||||
const b = (it.note_body || '').trim()
|
||||
if (b && b !== separatorBody) return true
|
||||
} else {
|
||||
if (it.exercise_id) return true
|
||||
if ((it.exercise_title || '').trim()) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Parallele Phase auflösen: alle Abschnitte dieser Phase werden Ganzgruppe (gleicher phaseOrderIndex). */
|
||||
export function dissolveParallelPhaseToWholeGroup(sections, phaseOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
return (sections || []).map((s) => {
|
||||
const L = s?.planLoc
|
||||
if (L?.phaseKind !== 'parallel' || (L.phaseOrderIndex ?? 0) !== po) return s
|
||||
return {
|
||||
...s,
|
||||
planLoc: {
|
||||
...defaultPlanLocWholeGroup(po),
|
||||
phaseTitle: L.phaseTitle ?? null,
|
||||
phaseGuidanceNotes: L.phaseGuidanceNotes ?? null,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function reorderWithinBucketIndices(prev, bucketGlobalIndicesSorted, oldPos, newPos) {
|
||||
const sortedIdx = [...bucketGlobalIndicesSorted].sort((a, b) => a - b)
|
||||
if (oldPos === newPos || oldPos < 0 || newPos < 0 || oldPos >= sortedIdx.length || newPos >= sortedIdx.length) {
|
||||
return prev
|
||||
}
|
||||
const values = sortedIdx.map((gi) => prev[gi])
|
||||
const arr = [...values]
|
||||
const [x] = arr.splice(oldPos, 1)
|
||||
arr.splice(newPos, 0, x)
|
||||
const next = [...prev]
|
||||
sortedIdx.forEach((gi, k) => {
|
||||
next[gi] = arr[k]
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeCatalogMethodProfile(cp) {
|
||||
if (cp && typeof cp === 'object' && !Array.isArray(cp)) return { ...cp }
|
||||
return {}
|
||||
|
|
@ -156,65 +351,132 @@ function parseOptionalSourceTrainingModuleIdForPayload(v) {
|
|||
return Number.isFinite(n) && n >= 1 ? n : null
|
||||
}
|
||||
|
||||
function sortByOrderIndex(arr) {
|
||||
return [...(arr || [])].sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
}
|
||||
|
||||
/** Katalog-Abschnitt (GET) → Editor-Zeilen inkl. Kombi/Modul-Meta — wird von Legacy `sections` und von `phases` genutzt. */
|
||||
function formItemsFromApiItems(items) {
|
||||
return (items || []).map((it) => {
|
||||
if (it.item_type === 'note') {
|
||||
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||
const rowNote = {
|
||||
item_type: 'note',
|
||||
note_body: it.note_body || '',
|
||||
source_training_module_id: '',
|
||||
source_module_title: '',
|
||||
}
|
||||
if (sm != null) {
|
||||
rowNote.source_training_module_id = sm
|
||||
rowNote.source_module_title = (
|
||||
it.source_module_title ||
|
||||
it.source_training_module_title ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
return rowNote
|
||||
}
|
||||
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
||||
const isCombo = ek === 'combination'
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
exercise_id: it.exercise_id,
|
||||
exercise_kind: isCombo ? 'combination' : 'simple',
|
||||
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
|
||||
exercise_title: it.exercise_title || '',
|
||||
variants: [],
|
||||
planned_duration_min:
|
||||
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
||||
? String(it.planned_duration_min)
|
||||
: '',
|
||||
actual_duration_min:
|
||||
it.actual_duration_min !== null && it.actual_duration_min !== undefined
|
||||
? String(it.actual_duration_min)
|
||||
: '',
|
||||
notes: it.notes ?? '',
|
||||
modifications: it.modifications ?? '',
|
||||
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
|
||||
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
|
||||
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
|
||||
...(smEx != null
|
||||
? {
|
||||
source_training_module_id: smEx,
|
||||
source_module_title: (
|
||||
it.source_module_title ||
|
||||
it.source_training_module_title ||
|
||||
''
|
||||
).trim(),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** GET `phases` → flache Editor-Abschnitte mit `planLoc` (für PUT `phases` Roundtrip). */
|
||||
function normalizePhasesToFormSections(fullUnit) {
|
||||
const phases = sortByOrderIndex(fullUnit.phases || [])
|
||||
const out = []
|
||||
for (const ph of phases) {
|
||||
const pOi = ph.order_index ?? 0
|
||||
const pk = String(ph.phase_kind || 'whole_group').toLowerCase().trim()
|
||||
const basePhaseLoc = {
|
||||
phaseTitle: ph.title ?? null,
|
||||
phaseGuidanceNotes: ph.guidance_notes ?? null,
|
||||
}
|
||||
if (pk === 'parallel') {
|
||||
for (const st of sortByOrderIndex(ph.streams || [])) {
|
||||
const sOi = st.order_index ?? 0
|
||||
const streamLoc = {
|
||||
phaseKind: 'parallel',
|
||||
phaseOrderIndex: pOi,
|
||||
parallelStreamOrderIndex: sOi,
|
||||
...basePhaseLoc,
|
||||
streamTitle: st.title ?? null,
|
||||
streamNotes: st.notes ?? null,
|
||||
streamAssignedTrainerProfileIds: st.assigned_trainer_profile_ids ?? null,
|
||||
}
|
||||
for (const sec of sortByOrderIndex(st.sections || [])) {
|
||||
out.push({
|
||||
title: sec.title,
|
||||
guidance_notes: sec.guidance_notes || '',
|
||||
items: formItemsFromApiItems(sec.items),
|
||||
planLoc: { ...streamLoc },
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const loc = {
|
||||
phaseKind: 'whole_group',
|
||||
phaseOrderIndex: pOi,
|
||||
parallelStreamOrderIndex: null,
|
||||
...basePhaseLoc,
|
||||
streamTitle: null,
|
||||
streamNotes: null,
|
||||
streamAssignedTrainerProfileIds: null,
|
||||
}
|
||||
for (const sec of sortByOrderIndex(ph.sections || [])) {
|
||||
out.push({
|
||||
title: sec.title,
|
||||
guidance_notes: sec.guidance_notes || '',
|
||||
items: formItemsFromApiItems(sec.items),
|
||||
planLoc: { ...loc },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.length ? out : [defaultSection()]
|
||||
}
|
||||
|
||||
export function normalizeUnitToForm(fullUnit) {
|
||||
if (Array.isArray(fullUnit?.phases) && fullUnit.phases.length > 0) {
|
||||
return normalizePhasesToFormSections(fullUnit)
|
||||
}
|
||||
if (fullUnit.sections && fullUnit.sections.length) {
|
||||
return fullUnit.sections.map((sec) => ({
|
||||
title: sec.title,
|
||||
guidance_notes: sec.guidance_notes || '',
|
||||
items: (sec.items || []).map((it) => {
|
||||
if (it.item_type === 'note') {
|
||||
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||
const rowNote = {
|
||||
item_type: 'note',
|
||||
note_body: it.note_body || '',
|
||||
source_training_module_id: '',
|
||||
source_module_title: '',
|
||||
}
|
||||
if (sm != null) {
|
||||
rowNote.source_training_module_id = sm
|
||||
rowNote.source_module_title = (
|
||||
it.source_module_title ||
|
||||
it.source_training_module_title ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
return rowNote
|
||||
}
|
||||
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||
const ek = String(it.exercise_kind || 'simple').toLowerCase().trim()
|
||||
const isCombo = ek === 'combination'
|
||||
return {
|
||||
item_type: 'exercise',
|
||||
exercise_id: it.exercise_id,
|
||||
exercise_kind: isCombo ? 'combination' : 'simple',
|
||||
exercise_variant_id: isCombo ? '' : it.exercise_variant_id ?? '',
|
||||
exercise_title: it.exercise_title || '',
|
||||
variants: [],
|
||||
planned_duration_min:
|
||||
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
||||
? String(it.planned_duration_min)
|
||||
: '',
|
||||
actual_duration_min:
|
||||
it.actual_duration_min !== null && it.actual_duration_min !== undefined
|
||||
? String(it.actual_duration_min)
|
||||
: '',
|
||||
notes: it.notes ?? '',
|
||||
modifications: it.modifications ?? '',
|
||||
catalog_method_archetype: String(it.catalog_method_archetype ?? '').trim(),
|
||||
catalog_method_profile: normalizeCatalogMethodProfile(it.catalog_method_profile),
|
||||
planning_method_profile: normalizePlanningMethodProfile(it.planning_method_profile),
|
||||
...(smEx != null
|
||||
? {
|
||||
source_training_module_id: smEx,
|
||||
source_module_title: (
|
||||
it.source_module_title ||
|
||||
it.source_training_module_title ||
|
||||
''
|
||||
).trim(),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}),
|
||||
items: formItemsFromApiItems(sec.items),
|
||||
}))
|
||||
}
|
||||
if (fullUnit.exercises && fullUnit.exercises.length) {
|
||||
|
|
@ -398,9 +660,9 @@ export function parseMin(v) {
|
|||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
export function buildSectionsPayload(sections) {
|
||||
return sections.map((sec, si) => ({
|
||||
order_index: si,
|
||||
export function buildOneSectionPayload(sec, orderIndex) {
|
||||
return {
|
||||
order_index: orderIndex,
|
||||
title: (sec.title || '').trim() || 'Abschnitt',
|
||||
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
||||
items: (sec.items || [])
|
||||
|
|
@ -445,7 +707,431 @@ export function buildSectionsPayload(sections) {
|
|||
return rowEx
|
||||
})
|
||||
.filter(Boolean),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** PUT /api/training-units: Legacy `sections` (eine whole_group-Phase) wenn kein `planLoc` gesetzt ist. */
|
||||
export function buildSectionsPayload(sections) {
|
||||
return sections.map((sec, si) => buildOneSectionPayload(sec, si))
|
||||
}
|
||||
|
||||
function stripPlanLoc(sec) {
|
||||
if (!sec || typeof sec !== 'object') return sec
|
||||
const { planLoc: _pl, ...rest } = sec
|
||||
return rest
|
||||
}
|
||||
|
||||
export function inheritPlanLocForPhasedSave(sections) {
|
||||
let prev = {
|
||||
phaseKind: 'whole_group',
|
||||
phaseOrderIndex: 0,
|
||||
parallelStreamOrderIndex: null,
|
||||
phaseTitle: null,
|
||||
phaseGuidanceNotes: null,
|
||||
streamTitle: null,
|
||||
streamNotes: null,
|
||||
streamAssignedTrainerProfileIds: null,
|
||||
}
|
||||
return (sections || []).map((s) => {
|
||||
if (s?.planLoc && s.planLoc.phaseKind) {
|
||||
prev = { ...s.planLoc }
|
||||
return { ...s, planLoc: prev }
|
||||
}
|
||||
return { ...s, planLoc: { ...prev } }
|
||||
})
|
||||
}
|
||||
|
||||
/** Phasen-„Runs“ in der flachen Abschnittsliste (Reihenfolge wie beim Speichern). */
|
||||
export function phaseRunsFromSections(sections) {
|
||||
const norm = inheritPlanLocForPhasedSave(sections)
|
||||
const runs = []
|
||||
let i = 0
|
||||
while (i < norm.length) {
|
||||
const loc0 = norm[i]?.planLoc
|
||||
if (!loc0?.phaseKind) {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
const pOi = loc0.phaseOrderIndex ?? 0
|
||||
const pk = loc0.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
|
||||
const start = i
|
||||
while (i < norm.length) {
|
||||
const L = norm[i]?.planLoc
|
||||
if (!L?.phaseKind) break
|
||||
const pk2 = L.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
|
||||
if ((L.phaseOrderIndex ?? 0) !== pOi || pk2 !== pk) break
|
||||
i += 1
|
||||
}
|
||||
runs.push({ phaseKind: pk, phaseOrderIndex: pOi, start, end: i })
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
/** Vertauscht zwei unmittelbar benachbarte Runs (upperRunIndex = erste der beiden). */
|
||||
export function swapAdjacentPhaseRuns(prev, upperRunIndex) {
|
||||
const runs = phaseRunsFromSections(prev)
|
||||
const a = upperRunIndex
|
||||
const b = upperRunIndex + 1
|
||||
if (a < 0 || b >= runs.length) return prev
|
||||
const rgA = runs[a]
|
||||
const rgB = runs[b]
|
||||
const head = prev.slice(0, rgA.start)
|
||||
const blA = prev.slice(rgA.start, rgA.end)
|
||||
const blB = prev.slice(rgB.start, rgB.end)
|
||||
const tail = prev.slice(rgB.end)
|
||||
return [...head, ...blB, ...blA, ...tail]
|
||||
}
|
||||
|
||||
export function movePhaseRunUpByPhaseOrder(prev, phaseOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const runs = phaseRunsFromSections(prev)
|
||||
const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po)
|
||||
if (rIdx <= 0) return prev
|
||||
return swapAdjacentPhaseRuns(prev, rIdx - 1)
|
||||
}
|
||||
|
||||
export function movePhaseRunDownByPhaseOrder(prev, phaseOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const runs = phaseRunsFromSections(prev)
|
||||
const rIdx = runs.findIndex((r) => r.phaseKind === 'parallel' && r.phaseOrderIndex === po)
|
||||
if (rIdx < 0 || rIdx >= runs.length - 1) return prev
|
||||
return swapAdjacentPhaseRuns(prev, rIdx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt verschieben und planLoc an der Einfügestelle an Nachbarn anpassen.
|
||||
* Regel: Einfügen vor Abschnitt X übernimmt X.planLoc; am Listenende nach Parallel → neue Ganzgruppen-Phase.
|
||||
*/
|
||||
export function reorderBlocksImmutableWithPlanLoc(prev, fromI, toBeforeIdx) {
|
||||
const arr = [...prev]
|
||||
if (fromI < 0 || fromI >= arr.length) return prev
|
||||
const [moved] = arr.splice(fromI, 1)
|
||||
let insertAt = toBeforeIdx
|
||||
if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
|
||||
insertAt = Math.max(0, Math.min(insertAt, arr.length))
|
||||
|
||||
const below = insertAt < arr.length ? arr[insertAt] : undefined
|
||||
const above = insertAt > 0 ? arr[insertAt - 1] : undefined
|
||||
|
||||
let planLocNext = null
|
||||
const belowKind = below?.planLoc?.phaseKind
|
||||
const aboveKind = above?.planLoc?.phaseKind
|
||||
const movedKind = moved?.planLoc?.phaseKind
|
||||
const belowPo = below?.planLoc?.phaseOrderIndex ?? 0
|
||||
const movedPo = moved?.planLoc?.phaseOrderIndex ?? 0
|
||||
|
||||
if (belowKind === 'parallel' && aboveKind === 'whole_group') {
|
||||
if (movedKind !== 'parallel') {
|
||||
planLocNext = { ...above.planLoc }
|
||||
} else if (movedPo !== belowPo) {
|
||||
planLocNext = { ...below.planLoc }
|
||||
}
|
||||
} else if (belowKind === 'parallel' && !above) {
|
||||
if (movedKind !== 'parallel') {
|
||||
planLocNext = defaultPlanLocWholeGroup(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (!planLocNext && belowKind) {
|
||||
planLocNext = { ...below.planLoc }
|
||||
}
|
||||
if (!planLocNext && insertAt === arr.length) {
|
||||
if (!above) {
|
||||
planLocNext = defaultPlanLocWholeGroup(0)
|
||||
} else if (above.planLoc?.phaseKind === 'parallel') {
|
||||
const mx = maxPhaseOrderIndexFromSections(arr)
|
||||
planLocNext = defaultPlanLocWholeGroup(mx + 1)
|
||||
} else if (above.planLoc?.phaseKind === 'whole_group') {
|
||||
planLocNext = { ...above.planLoc }
|
||||
}
|
||||
}
|
||||
if (!planLocNext && above?.planLoc?.phaseKind === 'whole_group') {
|
||||
planLocNext = { ...above.planLoc }
|
||||
}
|
||||
if (!planLocNext && above?.planLoc?.phaseKind === 'parallel') {
|
||||
const mx = maxPhaseOrderIndexFromSections(arr)
|
||||
planLocNext = defaultPlanLocWholeGroup(mx + 1)
|
||||
}
|
||||
|
||||
let nextMoved = { ...moved }
|
||||
if (planLocNext) {
|
||||
nextMoved = { ...moved, planLoc: planLocNext }
|
||||
} else {
|
||||
nextMoved = stripPlanLoc(moved)
|
||||
}
|
||||
arr.splice(insertAt, 0, nextMoved)
|
||||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt direkt vor den Parallel-Lauf setzen (immer Ganzgruppe oberhalb der Split-Phase).
|
||||
*/
|
||||
export function reorderSectionBeforeParallelRunAsWholeGroup(prev, fromI, phaseOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const idxs = indicesOfParallelPhase(prev, po)
|
||||
if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev
|
||||
const fg = idxs[0]
|
||||
const arr = [...prev]
|
||||
const [moved] = arr.splice(fromI, 1)
|
||||
let insertAt = fg
|
||||
if (fromI < insertAt) insertAt -= 1
|
||||
insertAt = Math.max(0, Math.min(insertAt, arr.length))
|
||||
const above = insertAt > 0 ? arr[insertAt - 1] : undefined
|
||||
let planLocNext
|
||||
if (above?.planLoc?.phaseKind === 'whole_group') {
|
||||
planLocNext = { ...above.planLoc }
|
||||
} else if (!above) {
|
||||
planLocNext = defaultPlanLocWholeGroup(0)
|
||||
} else {
|
||||
const mx = maxPhaseOrderIndexFromSections(arr)
|
||||
planLocNext = defaultPlanLocWholeGroup(mx + 1)
|
||||
}
|
||||
arr.splice(insertAt, 0, { ...moved, planLoc: planLocNext })
|
||||
return arr
|
||||
}
|
||||
|
||||
/** Abschnitt als neuen ersten Eintrag der Parallel-Phase (gleiche Phasen-/Stream-Metadaten wie bisheriger Kopf). */
|
||||
export function reorderSectionAsFirstInParallelPhase(prev, fromI, phaseOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const idxs = indicesOfParallelPhase(prev, po)
|
||||
if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev
|
||||
let fg = idxs[0]
|
||||
const arr = [...prev]
|
||||
const headTpl = { ...arr[fg].planLoc }
|
||||
const [moved] = arr.splice(fromI, 1)
|
||||
if (fromI < fg) fg -= 1
|
||||
fg = Math.max(0, Math.min(fg, arr.length))
|
||||
arr.splice(fg, 0, { ...moved, planLoc: { ...headTpl } })
|
||||
return arr
|
||||
}
|
||||
|
||||
/** Abschnitt als ersten Eintrag eines parallelen Streams setzen (planLoc wie erster Abschnitt dieses Streams, bzw. leerer Stream wie reorderBlockIntoParallelStreamEnd). */
|
||||
export function reorderSectionAsFirstInParallelStream(prev, fromI, phaseOrderIndex, streamOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const so = Number(streamOrderIndex) || 0
|
||||
const len = prev?.length ?? 0
|
||||
if (fromI < 0 || fromI >= len) return prev
|
||||
|
||||
const arr = [...prev]
|
||||
const [moved] = arr.splice(fromI, 1)
|
||||
|
||||
const streamIdx = sectionIndicesForParallelStream(arr, po, so)
|
||||
let insertAt
|
||||
let headTpl
|
||||
let skipFromIAdjust = false
|
||||
|
||||
if (streamIdx.length) {
|
||||
const first = Math.min(...streamIdx)
|
||||
headTpl = { ...arr[first].planLoc }
|
||||
insertAt = first
|
||||
} else {
|
||||
const phaseIdx = indicesOfParallelPhase(arr, po)
|
||||
if (!phaseIdx.length) {
|
||||
const ml = moved?.planLoc
|
||||
if (ml?.phaseKind !== 'parallel' || (ml.phaseOrderIndex ?? 0) !== po) return prev
|
||||
headTpl = {
|
||||
...ml,
|
||||
parallelStreamOrderIndex: so,
|
||||
streamTitle: null,
|
||||
streamNotes: null,
|
||||
streamAssignedTrainerProfileIds: null,
|
||||
}
|
||||
insertAt = Math.min(fromI, arr.length)
|
||||
skipFromIAdjust = true
|
||||
} else {
|
||||
const ref = arr[phaseIdx[phaseIdx.length - 1]]
|
||||
headTpl = {
|
||||
...ref.planLoc,
|
||||
parallelStreamOrderIndex: so,
|
||||
streamTitle: null,
|
||||
streamNotes: null,
|
||||
streamAssignedTrainerProfileIds: null,
|
||||
}
|
||||
insertAt = phaseIdx[phaseIdx.length - 1] + 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipFromIAdjust && fromI < insertAt) insertAt -= 1
|
||||
insertAt = Math.max(0, Math.min(insertAt, arr.length))
|
||||
arr.splice(insertAt, 0, { ...moved, planLoc: { ...headTpl } })
|
||||
return arr
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschnitt ans Ende eines parallelen Streams setzen (planLoc wie dieser Stream).
|
||||
* Leerer Stream: Einfügen hinter den letzten Abschnitt der zugehörigen parallelen Phase, planLoc vom Referenz-Abschnitt mit angepasstem streamIndex.
|
||||
*/
|
||||
export function reorderBlockIntoParallelStreamEnd(prev, fromI, phaseOrderIndex, streamOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const so = Number(streamOrderIndex) || 0
|
||||
const len = prev?.length ?? 0
|
||||
if (fromI < 0 || fromI >= len) return prev
|
||||
const arr = [...prev]
|
||||
const [moved] = arr.splice(fromI, 1)
|
||||
const streamIdx = sectionIndicesForParallelStream(arr, po, so)
|
||||
let insertAt
|
||||
let planLocTemplate
|
||||
|
||||
if (streamIdx.length) {
|
||||
const last = Math.max(...streamIdx)
|
||||
planLocTemplate = { ...arr[last].planLoc }
|
||||
insertAt = last + 1
|
||||
} else {
|
||||
const phaseIdx = indicesOfParallelPhase(arr, po)
|
||||
if (!phaseIdx.length) return prev
|
||||
const ref = arr[phaseIdx[phaseIdx.length - 1]]
|
||||
planLocTemplate = {
|
||||
...ref.planLoc,
|
||||
parallelStreamOrderIndex: so,
|
||||
streamTitle: null,
|
||||
streamNotes: null,
|
||||
streamAssignedTrainerProfileIds: null,
|
||||
}
|
||||
insertAt = phaseIdx[phaseIdx.length - 1] + 1
|
||||
}
|
||||
|
||||
insertAt = Math.max(0, Math.min(insertAt, arr.length))
|
||||
arr.splice(insertAt, 0, { ...moved, planLoc: { ...planLocTemplate } })
|
||||
return arr
|
||||
}
|
||||
|
||||
/** Globales insertBeforeIndex, um einen Abschnitt hinter den letzten des Streams einzufügen (z. B. Rahmen-Slots). */
|
||||
export function globalInsertBeforeIndexForParallelStreamEnd(sections, phaseOrderIndex, streamOrderIndex) {
|
||||
const arr = sections || []
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const so = Number(streamOrderIndex) || 0
|
||||
const streamIdx = sectionIndicesForParallelStream(arr, po, so)
|
||||
if (streamIdx.length) return Math.max(...streamIdx) + 1
|
||||
const phaseIdx = indicesOfParallelPhase(arr, po)
|
||||
if (!phaseIdx.length) return arr.length
|
||||
return Math.max(...phaseIdx) + 1
|
||||
}
|
||||
|
||||
/** Alle globalen Indizes einer parallelen Phase (alle Streams), sortiert. */
|
||||
export function indicesOfParallelPhase(sections, phaseOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const out = []
|
||||
;(sections || []).forEach((s, i) => {
|
||||
const L = s?.planLoc
|
||||
if (L?.phaseKind === 'parallel' && (L.phaseOrderIndex ?? 0) === po) out.push(i)
|
||||
})
|
||||
return out.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/** Gesamten Parallel-Block an neue Position (insertBefore globale Liste) schieben. */
|
||||
export function moveParallelPhaseRunToInsertBefore(prev, phaseOrderIndex, toBeforeIdx) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const indices = indicesOfParallelPhase(prev, po)
|
||||
if (!indices.length) return prev
|
||||
const indexSet = new Set(indices)
|
||||
const blocks = indices.map((i) => prev[i])
|
||||
const without = prev.filter((_, i) => !indexSet.has(i))
|
||||
let ins = toBeforeIdx
|
||||
for (const i of indices) {
|
||||
if (i < toBeforeIdx) ins -= 1
|
||||
}
|
||||
ins = Math.max(0, Math.min(ins, without.length))
|
||||
return [...without.slice(0, ins), ...blocks, ...without.slice(ins)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach Drag&Drop: wenn aus einer Parallelphase noch ≤1 Stream übrig ist (vorher ≥2), Rückfrage wie beim Stream-Löschen.
|
||||
*/
|
||||
export function afterSectionReorderParallelGuard(prev, next) {
|
||||
const seenPo = new Set()
|
||||
for (const s of prev || []) {
|
||||
const L = s?.planLoc
|
||||
if (L?.phaseKind !== 'parallel') continue
|
||||
seenPo.add(L.phaseOrderIndex ?? 0)
|
||||
}
|
||||
let out = next
|
||||
for (const po of seenPo) {
|
||||
const prevN = streamsForParallelPhaseOrders(prev, po).length
|
||||
if (prevN < 2) continue
|
||||
const nowN = streamsForParallelPhaseOrders(out, po).length
|
||||
if (nowN <= 1) {
|
||||
if (
|
||||
window.confirm(
|
||||
'In dieser parallelen Phase ist nur noch eine Gruppe übrig. Parallelaufbau auflösen und alle zugehörigen Abschnitte als gemeinsame Ganzgruppen-Phase führen?'
|
||||
)
|
||||
) {
|
||||
out = dissolveParallelPhaseToWholeGroup(out, po)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function buildPhasesPayloadFromFlat(sections) {
|
||||
const norm = inheritPlanLocForPhasedSave(sections)
|
||||
const phases = []
|
||||
let i = 0
|
||||
while (i < norm.length) {
|
||||
const loc0 = norm[i].planLoc
|
||||
const pOi = loc0.phaseOrderIndex ?? 0
|
||||
const pk = loc0.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
|
||||
|
||||
const run = []
|
||||
while (i < norm.length) {
|
||||
const L = norm[i].planLoc
|
||||
const pk2 = L.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
|
||||
if ((L.phaseOrderIndex ?? 0) !== pOi || pk2 !== pk) break
|
||||
run.push(norm[i])
|
||||
i += 1
|
||||
}
|
||||
|
||||
const head = run[0].planLoc
|
||||
if (pk === 'whole_group') {
|
||||
phases.push({
|
||||
phase_kind: 'whole_group',
|
||||
order_index: pOi,
|
||||
title: head.phaseTitle ?? null,
|
||||
guidance_notes: head.phaseGuidanceNotes ?? null,
|
||||
sections: run.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)),
|
||||
})
|
||||
} else {
|
||||
const byStream = new Map()
|
||||
for (const s of run) {
|
||||
const soi = s.planLoc.parallelStreamOrderIndex ?? 0
|
||||
if (!byStream.has(soi)) byStream.set(soi, [])
|
||||
byStream.get(soi).push(s)
|
||||
}
|
||||
const streamOrder = [...byStream.keys()].sort((a, b) => a - b)
|
||||
const streams = streamOrder.map((soi) => {
|
||||
const bucket = byStream.get(soi)
|
||||
const h = bucket[0].planLoc
|
||||
const st = {
|
||||
order_index: soi,
|
||||
title: h.streamTitle ?? null,
|
||||
notes: h.streamNotes ?? null,
|
||||
sections: bucket.map((s, idx) => buildOneSectionPayload(stripPlanLoc(s), idx)),
|
||||
}
|
||||
const asst = h.streamAssignedTrainerProfileIds
|
||||
if (asst !== null && asst !== undefined) st.assigned_trainer_profile_ids = asst
|
||||
return st
|
||||
})
|
||||
phases.push({
|
||||
phase_kind: 'parallel',
|
||||
order_index: pOi,
|
||||
title: head.phaseTitle ?? null,
|
||||
guidance_notes: head.phaseGuidanceNotes ?? null,
|
||||
streams,
|
||||
})
|
||||
}
|
||||
}
|
||||
return { phases }
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichern einer Einheit: flache `sections` oder verschachtelte `phases`, sobald `planLoc` gesetzt ist.
|
||||
*/
|
||||
export function buildPlanPayloadForSave(sections) {
|
||||
const list = Array.isArray(sections) ? sections : []
|
||||
const anyPhased = list.some((s) => s && s.planLoc && s.planLoc.phaseKind)
|
||||
if (!anyPhased) {
|
||||
return { sections: buildSectionsPayload(list) }
|
||||
}
|
||||
return buildPhasesPayloadFromFlat(list)
|
||||
}
|
||||
|
||||
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"d6ae548bbe32e0652471-c2435d34f500841a9fcc",
|
||||
"d6ae548bbe32e0652471-6495823d1677ce34da5c",
|
||||
"d6ae548bbe32e0652471-b581d2777c999619d7af"
|
||||
]
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -184,6 +184,17 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async
|
|||
|
||||
page.on('request', onRequest);
|
||||
|
||||
const kpisStatuses = [];
|
||||
const onResponse = (response) => {
|
||||
try {
|
||||
const u = response.url();
|
||||
if (u.includes('/api/dashboard/kpis')) kpisStatuses.push(response.status());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
page.on('response', onResponse);
|
||||
|
||||
try {
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
|
|
@ -199,11 +210,13 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, dashboard/kpis)', async
|
|||
expect(profilesMe).toBe(1);
|
||||
expect(trainingUnits).toBe(0);
|
||||
expect(dashboardKpis).toBe(1);
|
||||
expect(kpisStatuses.some((s) => s === 200)).toBe(true);
|
||||
} finally {
|
||||
page.off('request', onRequest);
|
||||
page.off('response', onResponse);
|
||||
}
|
||||
|
||||
console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis');
|
||||
console.log('✓ Dashboard API-Budget: 1× profiles/me, 0× training-units, 1× dashboard/kpis (HTTP 200)');
|
||||
});
|
||||
|
||||
test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', async ({ page }) => {
|
||||
|
|
@ -237,6 +250,61 @@ test('10. Übungsliste: Filter-Dialog öffnet und schließt', async ({ page }) =
|
|||
console.log('✓ Übungsliste: Filter-Dialog Smoke');
|
||||
});
|
||||
|
||||
test('11. Übungsliste: Massenauswahl zeigt Bulk-Toolbar', async ({ page }, testInfo) => {
|
||||
await login(page);
|
||||
await page.goto('/exercises', { waitUntil: 'networkidle' });
|
||||
const main = page.locator('.app-main');
|
||||
await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 20000 });
|
||||
const grid = main.getByTestId('exercises-list-grid');
|
||||
const checks = grid.locator('input[type="checkbox"]');
|
||||
const n = await checks.count();
|
||||
if (n < 1) {
|
||||
testInfo.skip(true, 'Keine Übung in der Liste (Bulk-Smoke braucht mind. einen Treffer)');
|
||||
return;
|
||||
}
|
||||
await checks.first().click();
|
||||
const bulk = main.getByTestId('exercise-list-bulk-toolbar');
|
||||
await expect(bulk).toBeVisible({ timeout: 5000 });
|
||||
await expect(bulk.getByRole('button', { name: /Massenänderung/i })).toBeVisible();
|
||||
console.log('✓ Übungsliste: Bulk-Toolbar nach Auswahl');
|
||||
});
|
||||
|
||||
test('12. Trainingsplanung: Seite lädt mit Überschrift', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto('/planning', { waitUntil: 'networkidle' });
|
||||
const main = page.locator('.app-main');
|
||||
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 });
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Trainingsplanung' })).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
console.log('✓ Trainingsplanung: Grundansicht');
|
||||
});
|
||||
|
||||
test('13. Trainingsplanung: Rahmen-Import-Dialog öffnet und schließt', async ({ page }, testInfo) => {
|
||||
await login(page);
|
||||
await page.goto('/planning', { waitUntil: 'networkidle' });
|
||||
const main = page.locator('.app-main');
|
||||
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 });
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Trainingsplanung' })).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
const openBtn = main.getByRole('button', { name: /Aus Rahmen übernehmen/i });
|
||||
if (await openBtn.isDisabled()) {
|
||||
testInfo.skip(true, 'Keine Trainingsgruppe — Button bleibt deaktiviert');
|
||||
return;
|
||||
}
|
||||
await openBtn.click();
|
||||
const dlg = page.getByTestId('planning-framework-import-modal');
|
||||
await expect(dlg).toBeVisible({ timeout: 10000 });
|
||||
await expect(dlg.getByRole('heading', { name: /Sessions aus Rahmen übernehmen/i })).toBeVisible();
|
||||
await dlg.getByRole('button', { name: 'Abbrechen' }).click();
|
||||
await expect(dlg).toHaveCount(0);
|
||||
console.log('✓ Trainingsplanung: Rahmen-Import-Dialog Smoke');
|
||||
});
|
||||
|
||||
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await login(page);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user