From c778d21b260a65538d2da56e63ee22dba718d464 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 5 May 2026 23:35:41 +0200
Subject: [PATCH 01/22] feat: update application version to 0.8.37 and enhance
training planning features
- Bumped application version to 0.8.37 in both backend and frontend files.
- Updated training planning API to include new session assignment features, allowing for lead trainer and assistant trainer assignments.
- Enhanced the TrainingPlanningPage to support dynamic loading of club member directories based on selected groups.
- Improved validation for trainer assignments, ensuring only active club members can be assigned as trainers.
- Updated changelog to reflect the new version and changes made in this release.
---
.../ACCESS_LAYER_AND_GOVERNANCE_PLAN.md | 8 +-
.../MULTI_TENANCY_RBAC_ARCHITECTURE.md | 52 ++-
.../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 3 +-
.../042_training_unit_session_assignments.sql | 9 +
backend/routers/training_planning.py | 377 +++++++++++++++---
.../tests/test_training_unit_assignments.py | 17 +
backend/version.py | 16 +-
frontend/src/pages/Dashboard.jsx | 5 +-
frontend/src/pages/TrainingPlanningPage.jsx | 199 ++++++++-
frontend/src/version.js | 2 +-
10 files changed, 597 insertions(+), 91 deletions(-)
create mode 100644 backend/migrations/042_training_unit_session_assignments.sql
create mode 100644 backend/tests/test_training_unit_assignments.py
diff --git a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
index 51b392e..d8d4e67 100644
--- a/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
+++ b/.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md
@@ -1,7 +1,7 @@
# Einheitliche Zugriffsschicht & Governance – Umsetzungsplan
**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`)
-**Stand:** 2026-05-05
+**Stand:** 2026-05-06
**Zweck:** Drift vermeiden – eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen.
**Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) – kommt nach stabiler Zugriffs- und Datenisolationsbasis.
@@ -101,7 +101,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
| **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? |
| **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. |
| **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). |
-| **Beziehung zu MULTI_TENANCY-Doc** | Phasen 1–4 dort größtenteils umgesetzt; **Gap-Analyse §3** im alten Dokument historisch lesen – fachlicher Zielabgleich bleibt dort, **operative Reihenfolge** hier. |
+| **Beziehung zu MULTI_TENANCY-Doc** | Zielbild und Gap-Analyse §3 dort pflegen (**§3.0** = aktueller Umsetzungsstand); **operative Reihenfolge** hier. |
---
@@ -109,7 +109,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin.
2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen).
-3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — erste rein-funktionale Tests unter `backend/tests/test_access_layer.py` (ohne DB); Integration folgt.
+3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — Unit-Tests `backend/tests/test_access_layer.py`; Integration `backend/tests/test_access_layer_integration.py` bei `ACCESS_LAYER_INTEGRATION=1` / CI im Backend-Container.
**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
@@ -122,4 +122,4 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
---
-**Letzte Aktualisierung:** 2026-05-05
+**Letzte Aktualisierung:** 2026-05-06
diff --git a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
index 3f26dbf..6ad6284 100644
--- a/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
+++ b/.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
@@ -1,7 +1,7 @@
# Multi-Tenancy, Vereins-Membership und Rollenmodell – Zielarchitektur & Umsetzungsplan
**Status:** verbindliche Zielrichtung (Architekturpapier)
-**Stand:** 2026-05-05
+**Stand:** 2026-05-06
**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008)
**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**.
@@ -20,60 +20,68 @@ Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Ma
|--------|-----------------------------------|-------------------------|
| `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** |
| §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** |
-| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; **technische Durchsetzung** ist noch lückenhaft |
+| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; Bibliothek **`private` \| `club` \| `official`** technisch über Zugriffsschicht durchgesetzt; **Sparte/community** folgt |
| §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung |
| `DOMAIN_MODEL.md` §5.5 | Freigabeebenen inkl. Sparte | Zielbild; DB/API nutzen derzeit überwiegend `private` \| `club` \| `official` |
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004–008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt |
| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel – UI-Komplexität kontrolliert einführen |
-**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten.
+**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs skizziert**. Für die **Kernteile Bibliothek und Vereinskontext** ist eine **technische Zugriffsschicht** (`TenantContext`, `club_members`, einheitliche Sichtbarkeits-SQL/-Prüfungen) umgesetzt — Details und Restarbeit (**Sparte**, Konsolidierung der Hilfen, Planungs-/Admin-Flows) siehe §3 und `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
---
## 3. Ist-Stand im Code (Gap-Analyse)
-> **Hinweis:** Dieser Abschnitt beschreibt den Ausgangspunkt vor Ausbauschritten (**Mitgliedschaften, gefilterte Vereinsliste, Teilen von Governance für Übungen/Rahmen/Planung** sind bereits angegangen). Verbindliche **offene Arbeit und Reihenfolge** sind im Dokument [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) festgehalten.
+> **Hinweis:** Die Unterabschnitte **3.1–3.6** enthalten weiterhin **historische Problemstellungen** (Ausgangsbild). Ergänzend beschreibt **3.0** den **aktualisierten Umsetzungsstand** nach Mitgliedschafts-, Tenant- und Bibliotheksarbeit. Verbindliche **offene Arbeit und Reihenfolge:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md).
+
+### 3.0 Aktualisierung des Umsetzungsstands (kurz)
+
+- **Mitgliedschaft:** Tabellen `club_members` und `club_member_roles`; aktiver Verein über Profilfeld und Header `X-Active-Club-Id`; Auflösung in **`TenantContext`** (`tenant_context.py`).
+- **Bibliothek** (Übungen, Trainingsplan-Vorlagen, Rahmenprogramme u. a.): gemeinsame Leselogik **`library_content_visibility_sql`** / **`library_content_visible_to_profile`** — Vereinsinhalte **`club`** nur bei passendem **`club_id`** und **aktiver Mitgliedschaft** im Objekt-Verein (normale Nutzer ohne gültigen Vereinskontext: kein „beliebiges club“).
+- **`GET /api/clubs`:** Nicht-Admins sehen nur Vereine mit Mitgliedschaft; **`POST /api/clubs`:** nur **Plattform-Admin**, mit Pflicht **`primary_admin_profile_id`**.
+- **Organisation** (Sparten/Gruppen): Schreibzugriff über **`can_manage_club_org`** / **`can_plan_in_club`** auf Basis von **`club_member_roles`** (nicht mehr nur globales `admin`).
+- **Profil-API:** eingeschränktes **`GET /profiles/{id}`**, **`DELETE`**, **`POST /profiles`** (Plattform-Admin / Selbstzugriff) — Details `backend/routers/profiles.py`.
+- **Tests:** pytest inkl. optionaler Mandanten-Integration (`ACCESS_LAYER_INTEGRATION`); CI-Anbindung siehe `.gitea/workflows/test.yml` (Ausführung im Backend-Container wie Schwesterprojekt).
### 3.1 Identität und Rollen
- `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …).
-- **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**.
-- Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`).
+- *(Historisch)* Fehlende Abbildung von Vereinsrollen **ohne** eigene Tabellen.
+- **Ist:** Zusätzlich **`club_member_roles`** pro Verein (z. B. `club_admin`, `trainer`, …); Sessions liefern weiter **`profile_id`** + globale **`role`** (`auth.py` → `get_session`), Vereinsrechte werden aus Mitgliedschaft abgeleitet.
-**Konsequenz:** Mehrere Vereine mit unterschiedlichen Rollen pro User sind **nicht modelliert**; ein „Vereinsadmin“ kann nicht sauber von einem reinen Trainer unterschieden werden, sobald beides nur über `profiles.role` laufen soll.
+**Konsequenz:** Globale Rolle und Vereinsrollen **koexistieren**; Produkt und Code sollten langfristig klar trennen, was nur global vs. nur über Mitgliedschaft gilt (vgl. Zielarchitektur §4).
### 3.2 Organisation & APIs
-- `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`).
-- `GET /api/clubs` listiert **alle** Vereine für jeden eingeloggten Nutzer.
-- `POST /api/clubs` erlaubt Anlage für `trainer` und `user` – **nicht** nur Systemadmin.
-- Sparten/Gruppen: Schreibzugriff über globale `admin`/`superadmin`, nicht über **Vereinsadmin** im Kontext „sein Verein“.
+- *(Historisch)* Zu offene Vereinsliste und Club-Anlage für jeden Trainer/User.
+- **Ist:** siehe **3.0** — gefilterte Liste, eingeschränktes Anlegen, kontextbezogene Organisationsrechte.
-**Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt.
+**Konsequenz:** Offene Punkte verlagern sich in **feine Produktregeln** und **Sparten-/Community-Stufen** (ACCESS_LAYER Stufe D bzw. spätere Epics).
### 3.3 Trainingsplanung
-- Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`).
-- `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ – **kein** generelles Mitgliedschaftsmodell.
+- Zugriff auf Einheiten weiterhin stark **gruppenbezogen** (`training_groups`, optional **`lead_trainer_profile_id`** auf Einheiten).
+- Mitgliedschaft/`TenantContext` unterstützen andere Endpoints; **`GET /training-units`** hat **keinen** impliziten Filter nur auf **`effective_club_id`** (Multi-Verein-Kalender; bei Bedarf Query **`club_id`**).
-**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht.
+**Konsequenz:** Vereinsweite oder „Administrations“-Planungsaufgaben können weiter ausgebaut werden (eigenes Produkt-Thema; nicht identisch mit Bibliotheks-Governance).
-### 3.4 Governance / Sichtbarkeit (kritisch)
+### 3.4 Governance / Sichtbarkeit (Bibliothek)
-- Übungen (`list_exercises`): Bedingung sinngemäß „official OR club OR created_by = ich“ – **`club` gilt für alle Mandanten**, ohne Prüfung `exercise.club_id` ∈ Vereine des Nutzers.
-- Detailzugriff `private`: nur Owner – **ok**.
-- Rahmenprogramme (`training_framework_programs`): Lesen fremder Rahmen über `visibility=club` ist in `_framework_access` **nicht** gelöst (faktisch stark creator-basiert für Nicht-Admins).
+- *(Historisch)* Risiko: **`club`**-Objekte ohne Bindung an **`club_id`** / Mitgliedschaft → mögliche Cross-Tenant-Sicht.
+- **Ist:** Listen und Detail für die genannten Bibliotheksmodule nutzen die **einheitliche** Logik in **`club_tenancy`** / **`tenant_context`** (siehe **3.0**).
-**Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen.
+**Konsequenz:** Die historische „Leak“-Diagnose für **Übungen und Rahmenprogramme** in dieser Form ist **überholt**. Verbleibend: **Konsolidierung auf wenige Hilfsfunktionen** (ACCESS_LAYER Stufe C), **Sparte** als eigene Stufe, ggf. **community**.
### 3.5 Frontend
-- **Stand 2026-05:** `GET /api/profiles/me` liefert `clubs[]`, `active_club_id`; Frontend setzt `X-Active-Club-Id`. Details und Pflicht zur serverseitigen **TenantContext**-Validierung siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
+- `GET /api/profiles/me` liefert u. a. **`clubs[]`**, **`active_club_id`**; Client setzt **`X-Active-Club-Id`**. Geschützte Backend-Routen nutzen **`Depends(get_tenant_context)`** wo im Audit festgehalten.
### 3.6 Membership (kommerziell/limits)
- Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**.
-- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema.
+- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema — bewusst nach ACCESS_LAYER-Plan zurückgestellt.
+
+**Letzte Überarbeitung dieses Abschnitts (3.x):** 2026-05-06.
---
diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
index 87ac058..9168685 100644
--- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
+++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md
@@ -5,6 +5,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen |
|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------|
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
+| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
@@ -24,7 +25,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
-Letzte Änderung: 2026-05-05 — Cursor-Regel + Architektur-/Coding-Pflicht + Script `backend/scripts/check_access_layer_hints.py`; Katalog-Router im Audit als global dokumentiert.
+Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt.
---
diff --git a/backend/migrations/042_training_unit_session_assignments.sql b/backend/migrations/042_training_unit_session_assignments.sql
new file mode 100644
index 0000000..816368e
--- /dev/null
+++ b/backend/migrations/042_training_unit_session_assignments.sql
@@ -0,0 +1,9 @@
+-- Session-spezifische Co-Trainer: NULL = wie training_groups.co_trainer_ids; [] = explizit keine Co-Trainer
+ALTER TABLE training_units
+ADD COLUMN IF NOT EXISTS assistant_trainer_profile_ids JSONB;
+
+COMMENT ON COLUMN training_units.assistant_trainer_profile_ids IS
+ 'Co-Trainer nur für diese Einheit; NULL vererbt training_groups.co_trainer_ids; leeres Array = keine Co-Trainer';
+
+CREATE INDEX IF NOT EXISTS idx_training_units_assistant_trainers
+ ON training_units USING GIN (assistant_trainer_profile_ids);
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index e664209..0ccfb80 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -12,6 +12,7 @@ from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_valid_governance_visibility,
+ can_manage_club_org,
is_platform_admin,
library_content_visible_to_profile,
)
@@ -53,7 +54,7 @@ def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id:
def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None:
cur.execute(
- "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
+ "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,),
)
group = cur.fetchone()
@@ -64,9 +65,83 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
if role not in ["admin", "superadmin"]:
if group["trainer_id"] != profile_id and profile_id not in co_trainers:
- raise HTTPException(
- status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
- )
+ if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role):
+ raise HTTPException(
+ status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
+ )
+
+
+def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool:
+ cur.execute(
+ """
+ SELECT 1 FROM club_members
+ WHERE club_id = %s AND profile_id = %s AND status = 'active'
+ LIMIT 1
+ """,
+ (club_id, profile_id),
+ )
+ return cur.fetchone() is not None
+
+
+def _caller_may_assign_session_trainers(
+ cur,
+ group_row: Dict[str, Any],
+ profile_id: int,
+ role: str,
+ unit_created_by: Optional[int],
+) -> bool:
+ if is_platform_admin(role):
+ return True
+ cid = group_row.get("club_id")
+ if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role):
+ return True
+ if unit_created_by is not None and unit_created_by == profile_id:
+ return True
+ if group_row.get("trainer_id") == profile_id:
+ return True
+ co = group_row.get("co_trainer_ids") or []
+ return profile_id in co
+
+
+def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]:
+ """Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard."""
+ unit_asst = unit_row.get("assistant_trainer_profile_ids")
+ if unit_asst is not None:
+ src = unit_asst
+ else:
+ src = unit_row.get("co_trainer_ids") or []
+ seen: set = set()
+ out: List[int] = []
+ for x in src:
+ try:
+ i = int(x)
+ except (TypeError, ValueError):
+ continue
+ if i not in seen:
+ seen.add(i)
+ out.append(i)
+ return sorted(out)
+
+
+def effective_co_trainer_profile_ids_for_merge(
+ unit_assistant: Any, group_co: Any
+) -> List[int]:
+ """Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row."""
+ if unit_assistant is not None:
+ src = unit_assistant
+ else:
+ src = group_co or []
+ seen: set = set()
+ out: List[int] = []
+ for x in src:
+ try:
+ i = int(x)
+ except (TypeError, ValueError):
+ continue
+ if i not in seen:
+ seen.add(i)
+ out.append(i)
+ return sorted(out)
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
@@ -74,7 +149,8 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
"""
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
tu.lead_trainer_profile_id,
- tg.trainer_id, tg.co_trainer_ids,
+ tu.assistant_trainer_profile_ids,
+ tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id,
fwp.created_by AS framework_created_by
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
@@ -103,12 +179,12 @@ def _assert_training_unit_permission(
return
raise HTTPException(status_code=403, detail="Keine Berechtigung")
- co_trainers = unit_row["co_trainer_ids"] or []
+ co_eff = _effective_co_trainer_ids_for_row(unit_row)
if role not in ["admin", "superadmin"]:
if (
unit_row["created_by"] != profile_id
and unit_row["trainer_id"] != profile_id
- and profile_id not in co_trainers
+ and profile_id not in co_eff
and unit_row.get("lead_trainer_profile_id") != profile_id
):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
@@ -120,9 +196,21 @@ def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) ->
def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
- """Nicht-Admin: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer."""
+ """Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
if role in ("admin", "superadmin"):
return
+ if can_manage_club_org(cur, profile_id, club_id, role):
+ return
+ cur.execute(
+ """
+ SELECT 1 FROM club_members
+ WHERE club_id = %s AND profile_id = %s AND status = 'active'
+ LIMIT 1
+ """,
+ (club_id, profile_id),
+ )
+ if cur.fetchone():
+ return
cur.execute(
"""
SELECT 1 FROM training_groups g
@@ -145,8 +233,9 @@ def _normalize_lead_trainer_profile_id(
raw_lead: Any,
profile_id: int,
role: str,
+ unit_created_by: Optional[int],
) -> Optional[int]:
- """NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext."""
+ """NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i. d. R. mit Vereinsbezug."""
if raw_lead is None:
return None
if raw_lead in ("", []):
@@ -160,27 +249,130 @@ def _normalize_lead_trainer_profile_id(
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")
- if role in ("admin", "superadmin"):
- return nid
- if nid == profile_id:
- return nid
+
cur.execute(
- "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
+ "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")
- eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set()
- for x in gr.get("co_trainer_ids") or []:
- eligible.add(x)
- if nid in eligible:
- return nid
- raise HTTPException(
- status_code=403,
- detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
- )
+ 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 is_platform_admin(role):
+ return nid
+
+ 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
+
+ if nid == profile_id:
+ if not _profile_active_in_club(cur, club_i, profile_id):
+ raise HTTPException(
+ status_code=403,
+ detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen",
+ )
+ return nid
+
+ if nid not in eligible:
+ if not _profile_active_in_club(cur, club_i, nid):
+ raise HTTPException(
+ status_code=400,
+ detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe",
+ )
+ if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by):
+ raise HTTPException(
+ status_code=403,
+ detail="Keine Berechtigung, die Leitung zuzuweisen",
+ )
+ return nid
+
+ if nid != profile_id and not _caller_may_assign_session_trainers(
+ cur, grd, profile_id, role, unit_created_by
+ ):
+ raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen")
+ return nid
+
+
+def _normalize_assistant_trainer_profile_ids(
+ cur,
+ group_id: int,
+ raw_val: Any,
+ profile_id: int,
+ role: str,
+ unit_created_by: Optional[int],
+ lead_nid: Optional[int],
+) -> Any:
+ """
+ None = Vererbung aus training_groups.co_trainer_ids (SQL NULL);
+ Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.)
+ """
+ if raw_val is None:
+ return None
+ if not isinstance(raw_val, list):
+ raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids 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="assistant_trainer_profile_ids ungültig")
+ if i < 1:
+ raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
+ ids_in.append(i)
+ uniq = sorted(set(ids_in))
+
+ 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 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
+
+ eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None)
+
+ 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 Co-Trainer nicht gefunden")
+ if eff_lead is not None and nid == eff_lead:
+ raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden")
+ 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="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
+ )
+ return uniq
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
_ORIGIN_LINEAGE_JOIN = """
@@ -775,14 +967,18 @@ def list_training_units(
if gid and role not in ["admin", "superadmin"]:
cur.execute(
- "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'",
+ "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'",
(gid,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
- cob = gr["co_trainer_ids"] or []
- if gr["trainer_id"] != profile_id and profile_id not in cob:
+ gd = dict(gr)
+ cob = gd.get("co_trainer_ids") or []
+ ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob
+ ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role)
+ ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id)
+ if not (ok_staff or ok_org or ok_member):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
@@ -805,6 +1001,8 @@ def list_training_units(
p.name as trainer_name,
p.name as creator_name,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
+ COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
+ AS effective_assistant_trainer_profile_ids,
leadp.name AS lead_trainer_name
"""
query += "," + _ORIGIN_LINEAGE_FIELDS
@@ -822,10 +1020,11 @@ def list_training_units(
if role not in ["admin", "superadmin"]:
where.append(
- "(tu.created_by = %s OR tg.trainer_id = %s OR "
- "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
+ "(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
+ "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
+ "@> jsonb_build_array(%s::int))"
)
- params.extend([profile_id, profile_id, profile_id])
+ params.extend([profile_id, profile_id, profile_id, profile_id])
where.append("tu.framework_slot_id IS NULL")
@@ -840,7 +1039,8 @@ def list_training_units(
if assigned_to_me:
where.append(
"(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
- "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
+ "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
+ "@> jsonb_build_array(%s::int))"
)
params.extend([profile_id, profile_id])
@@ -891,6 +1091,8 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
tg.trainer_id AS trainer_id,
tg.co_trainer_ids AS co_trainer_ids,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
+ COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
+ AS effective_assistant_trainer_profile_ids,
leadp.name AS lead_trainer_name,
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
FROM training_units tu
@@ -957,27 +1159,77 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
tpl_id_safe = plan_template_id
cur.execute(
- """
- INSERT INTO training_units (
- group_id, planned_date, planned_time_start, planned_time_end,
- planned_focus, status, notes, trainer_notes, created_by,
- plan_template_id
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
- RETURNING id
- """,
- (
- group_id,
- planned_date,
- data.get("planned_time_start"),
- data.get("planned_time_end"),
- data.get("planned_focus"),
- data.get("status", "planned"),
- data.get("notes"),
- data.get("trainer_notes"),
- profile_id,
- tpl_id_safe,
- ),
+ "SELECT trainer_id FROM training_groups WHERE id = %s",
+ (int(group_id),),
)
+ g0 = cur.fetchone()
+ default_group_trainer = g0["trainer_id"] if g0 else None
+
+ lead_ins: Optional[int] = None
+ if "lead_trainer_profile_id" in data:
+ lead_ins = _normalize_lead_trainer_profile_id(
+ cur,
+ int(group_id),
+ data.get("lead_trainer_profile_id"),
+ profile_id,
+ role,
+ profile_id,
+ )
+ assistant_val: Any = None
+ assistant_set = False
+ if "assistant_trainer_profile_ids" in data:
+ assistant_set = True
+ eff_lead_for_co = lead_ins if lead_ins is not None else default_group_trainer
+ assistant_val = _normalize_assistant_trainer_profile_ids(
+ cur,
+ int(group_id),
+ data.get("assistant_trainer_profile_ids"),
+ profile_id,
+ role,
+ profile_id,
+ eff_lead_for_co,
+ )
+
+ base_params = (
+ group_id,
+ planned_date,
+ data.get("planned_time_start"),
+ data.get("planned_time_end"),
+ data.get("planned_focus"),
+ data.get("status", "planned"),
+ data.get("notes"),
+ data.get("trainer_notes"),
+ profile_id,
+ tpl_id_safe,
+ lead_ins,
+ )
+ if assistant_set:
+ cur.execute(
+ """
+ INSERT INTO training_units (
+ group_id, planned_date, planned_time_start, planned_time_end,
+ planned_focus, status, notes, trainer_notes, created_by,
+ plan_template_id,
+ lead_trainer_profile_id,
+ assistant_trainer_profile_ids
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING id
+ """,
+ base_params + (assistant_val,),
+ )
+ else:
+ cur.execute(
+ """
+ INSERT INTO training_units (
+ group_id, planned_date, planned_time_start, planned_time_end,
+ planned_focus, status, notes, trainer_notes, created_by,
+ plan_template_id,
+ lead_trainer_profile_id
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING id
+ """,
+ base_params,
+ )
unit_id = cur.fetchone()["id"]
@@ -1066,8 +1318,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tuple(blueprint_params),
)
else:
+ cur_lead = unit_row.get("lead_trainer_profile_id")
+ base_tr = unit_row.get("trainer_id")
lead_sql = ""
lead_params: List[Any] = []
+ assist_sql = ""
+ assist_params: List[Any] = []
+ nl: Optional[int]
if "lead_trainer_profile_id" in data:
nl = _normalize_lead_trainer_profile_id(
cur,
@@ -1075,9 +1332,27 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
data.get("lead_trainer_profile_id"),
profile_id,
role,
+ unit_row.get("created_by"),
)
lead_sql = ", lead_trainer_profile_id = %s"
lead_params.append(nl)
+ eff_lead_for_co = nl if nl is not None else base_tr
+ else:
+ nl = cur_lead if cur_lead is not None else base_tr
+ eff_lead_for_co = nl
+
+ if "assistant_trainer_profile_ids" in data:
+ na = _normalize_assistant_trainer_profile_ids(
+ cur,
+ unit_row["group_id"],
+ data.get("assistant_trainer_profile_ids"),
+ profile_id,
+ role,
+ unit_row.get("created_by"),
+ eff_lead_for_co,
+ )
+ assist_sql = ", assistant_trainer_profile_ids = %s"
+ assist_params.append(na)
cur.execute(
f"""
@@ -1096,6 +1371,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
plan_template_id = COALESCE(%s, plan_template_id),
updated_at = NOW()
{lead_sql}
+ {assist_sql}
WHERE id = %s
""",
(
@@ -1113,6 +1389,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tpl_id_val,
)
+ tuple(lead_params)
+ + tuple(assist_params)
+ (unit_id,),
)
diff --git a/backend/tests/test_training_unit_assignments.py b/backend/tests/test_training_unit_assignments.py
new file mode 100644
index 0000000..4a41b6f
--- /dev/null
+++ b/backend/tests/test_training_unit_assignments.py
@@ -0,0 +1,17 @@
+"""Unit-Tests ohne DB: Zusammenführung Session-Co vs. Gruppe."""
+import pytest
+
+from routers.training_planning import effective_co_trainer_profile_ids_for_merge
+
+
+@pytest.mark.parametrize(
+ "unit_side,group_side,expected",
+ [
+ (None, [10, 22], [10, 22]),
+ (None, None, []),
+ ([], [10, 22], []),
+ ([7, "8", 7], None, [7, 8]),
+ ],
+)
+def test_effective_co_trainer_profile_ids_for_merge(unit_side, group_side, expected):
+ assert effective_co_trainer_profile_ids_for_merge(unit_side, group_side) == expected
diff --git a/backend/version.py b/backend/version.py
index b4065d0..ae0835e 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.36"
+APP_VERSION = "0.8.37"
BUILD_DATE = "2026-05-05"
-DB_SCHEMA_VERSION = "20260505041"
+DB_SCHEMA_VERSION = "20260505042"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
@@ -16,7 +16,7 @@ MODULE_VERSIONS = {
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
- "training_units": "0.1.0",
+ "training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
"import_wiki": "1.0.0",
@@ -27,6 +27,16 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.37",
+ "date": "2026-05-05",
+ "changes": [
+ "DB 042: training_units.assistant_trainer_profile_ids (Co-Trainer-Zuweisung je Termin; NULL = Gruppen-Standard)",
+ "Trainingseinheiten: POST/PUT lead_trainer_profile_id & assistant_trainer_profile_ids; Leitung für Vereinsmitglieder (Vertretung); GET-Listen inkl. Zuweisung für Sichtbarkeit/assigned_to_me",
+ "Frontend Trainingsplanung: Leitung/Co-Trainer pro Einheit; Dashboard-Text",
+ "pytest: tests/test_training_unit_assignments.py",
+ ],
+ },
{
"version": "0.8.36",
"date": "2026-05-05",
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index f941894..e7bab56 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -153,11 +153,12 @@ function Dashboard() {
) : (
- Keine anstehenden Termine mit dir als Leitung oder Co‑Trainer. Unter{' '}
+ Keine anstehenden Termine, bei denen du als Leitung oder Co-Trainer dieser Einheit eingetragen
+ bist. Unter{' '}
Trainingsplanung
{' '}
- kannst du den Vereins‑ oder Gruppen‑Zeitraum einblenden.
+ kannst du Zeiträume und Zuordnungen bearbeiten.
)}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 9ad4c97..c4b2049 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -72,6 +72,21 @@ function enumerateIsoDays(fromIso, toIso) {
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
+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
+}
+
+const sessionAssignDefaults = () => ({
+ lead_trainer_profile_id: '',
+ session_assistants_inherit: true,
+ session_assistant_profile_ids: [],
+})
function TrainingPlanningPage() {
const { user } = useAuth()
const [groups, setGroups] = useState([])
@@ -107,6 +122,7 @@ function TrainingPlanningPage() {
const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7))
const [planScope, setPlanScope] = useState('group')
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
+ const [clubDirectory, setClubDirectory] = useState([])
const [formData, setFormData] = useState({
group_id: '',
@@ -121,7 +137,8 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
- sections: [defaultSection()]
+ sections: [defaultSection()],
+ ...sessionAssignDefaults()
})
const loadPlanTemplates = useCallback(async () => {
@@ -206,6 +223,39 @@ function TrainingPlanningPage() {
}
}, [selectedGroupId, loadUnits])
+ useEffect(() => {
+ if (!showModal) {
+ setClubDirectory([])
+ return undefined
+ }
+ const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
+ if (!Number.isFinite(gid) || gid < 1) {
+ setClubDirectory([])
+ return undefined
+ }
+ const g = groups.find((x) => x.id === gid)
+ const cid = g?.club_id
+ if (!cid) {
+ setClubDirectory([])
+ return undefined
+ }
+ let cancelled = false
+ ;(async () => {
+ try {
+ const d = await api.clubMembersDirectory(cid)
+ if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
+ } catch (err) {
+ if (!cancelled) {
+ console.error('Mitgliederverzeichnis:', err)
+ setClubDirectory([])
+ }
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [showModal, formData.group_id, selectedGroupId, groups])
+
useEffect(() => {
if (!frameworkImportOpen) return
let cancelled = false
@@ -380,7 +430,8 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
- sections: [defaultSection('Hauptteil')]
+ sections: [defaultSection('Hauptteil')],
+ ...sessionAssignDefaults()
})
setShowModal(true)
}
@@ -406,12 +457,9 @@ function TrainingPlanningPage() {
status: 'planned',
notes: '',
trainer_notes: '',
- sections: [defaultSection('Hauptteil')]
- })
- setShowModal(true)
- }
-
- const applyTemplateFromSelect = async (templateId) => {
+ sections: [defaultSection('Hauptteil')],
+ ...sessionAssignDefaults()
+ }) = async (templateId) => {
setDraftPlanTemplateId(templateId)
if (!templateId) return
try {
@@ -452,6 +500,14 @@ function TrainingPlanningPage() {
notes: fullUnit.notes || '',
trainer_notes: fullUnit.trainer_notes || '',
sections,
+ lead_trainer_profile_id:
+ fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
+ ? String(fullUnit.lead_trainer_profile_id)
+ : '',
+ session_assistants_inherit:
+ fullUnit.assistant_trainer_profile_ids == null ||
+ fullUnit.assistant_trainer_profile_ids === undefined,
+ session_assistant_profile_ids: toNumList(fullUnit.assistant_trainer_profile_ids),
})
setShowModal(true)
} catch (err) {
@@ -519,6 +575,19 @@ function TrainingPlanningPage() {
trainer_notes: formData.trainer_notes || null,
sections: sectionsPayload
}
+ const leadStr = String(formData.lead_trainer_profile_id || '').trim()
+ if (leadStr) {
+ payload.lead_trainer_profile_id = parseInt(leadStr, 10)
+ } else if (editingUnit) {
+ payload.lead_trainer_profile_id = null
+ }
+ if (formData.session_assistants_inherit) {
+ if (editingUnit) payload.assistant_trainer_profile_ids = null
+ } else {
+ payload.assistant_trainer_profile_ids = [...formData.session_assistant_profile_ids].sort(
+ (a, b) => a - b
+ )
+ }
if (!editingUnit) {
payload.group_id = parseInt(formData.group_id, 10)
if (draftPlanTemplateId) {
@@ -1189,6 +1258,28 @@ function TrainingPlanningPage() {
Leitung: {(unit.lead_trainer_name || '').trim() || '—'}
+ {(() => {
+ const coRaw = unit.effective_assistant_trainer_profile_ids
+ const co = Array.isArray(coRaw)
+ ? coRaw.map(Number).filter((x) => Number.isFinite(x) && x >= 1)
+ : []
+ if (!co.length) return null
+ const src =
+ unit.assistant_trainer_profile_ids != null
+ ? 'Session-Zuweisung'
+ : 'über Trainingsgruppe'
+ return (
+
+ Co-Trainer ({src}): {co.length}
+
+ )
+ })()}
{unit.planned_focus && (
+
+
Trainerzuordnung (diese Einheit)
+
+
Leitung
+
updateFormField('lead_trainer_profile_id', e.target.value)}
+ disabled={!editingUnit && !formData.group_id}
+ >
+ Standard (Haupttrainer der Gruppe)
+ {clubDirectory.map((m) => {
+ const idStr = String(m.id)
+ return (
+
+ {(m.name || '').trim() || m.email || `Profil ${m.id}`}
+
+ )
+ })}
+
+
+ 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.
+
+
+
+
+
+ updateFormField('session_assistants_inherit', e.target.checked)
+ }
+ />
+
+ Co-Trainer wie in der Trainingsgruppe (Standard)
+
+
+
+ {!formData.session_assistants_inherit ? (
+
+ {clubDirectory.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 (
+
+ {
+ 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 }
+ })
+ }}
+ />
+ {labelText}
+
+ )
+ })}
+
+ ) : null}
+ {!clubDirectory.length && showModal ? (
+
+ Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).
+
+ ) : null}
+
+
Date: Wed, 6 May 2026 07:18:30 +0200
Subject: [PATCH 02/22] feat: update application version to 0.8.38 and enhance
training planning features
- Bumped application version to 0.8.38 in both backend and frontend files.
- Updated training planning API to improve permission checks for trainer assignments, allowing club admins to manage training units more effectively.
- Enhanced the TrainingPlanningPage with new modal functionality for assigning trainers and improved loading of club member directories.
- Updated changelog to reflect the new version and changes made in this release.
---
backend/routers/training_planning.py | 71 +++-
backend/version.py | 12 +-
frontend/src/pages/TrainingPlanningPage.jsx | 430 ++++++++++++++++----
frontend/src/version.js | 6 +-
4 files changed, 428 insertions(+), 91 deletions(-)
diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py
index 0ccfb80..1eddfea 100644
--- a/backend/routers/training_planning.py
+++ b/backend/routers/training_planning.py
@@ -180,21 +180,36 @@ def _assert_training_unit_permission(
raise HTTPException(status_code=403, detail="Keine Berechtigung")
co_eff = _effective_co_trainer_ids_for_row(unit_row)
- if role not in ["admin", "superadmin"]:
- if (
- unit_row["created_by"] != profile_id
- and unit_row["trainer_id"] != profile_id
- and profile_id not in co_eff
- and unit_row.get("lead_trainer_profile_id") != profile_id
- ):
- raise HTTPException(status_code=403, detail="Keine Berechtigung")
-
-
-def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None:
- if role not in ["admin", "superadmin"] and created_by != profile_id:
+ if role in ["admin", "superadmin"]:
+ return
+ gcid = unit_row.get("group_club_id")
+ if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role):
+ return
+ if (
+ unit_row["created_by"] != profile_id
+ and unit_row["trainer_id"] != profile_id
+ and profile_id not in co_eff
+ and unit_row.get("lead_trainer_profile_id") != profile_id
+ ):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
+def _assert_delete_training_unit(
+ cur,
+ role: str,
+ created_by: int,
+ profile_id: int,
+ group_club_id: Optional[int],
+) -> None:
+ if role in ["admin", "superadmin"]:
+ return
+ if created_by == profile_id:
+ return
+ if group_club_id is not None and can_manage_club_org(cur, profile_id, int(group_club_id), role):
+ return
+ raise HTTPException(status_code=403, detail="Keine Berechtigung")
+
+
def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
"""Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
if role in ("admin", "superadmin"):
@@ -1018,7 +1033,21 @@ def list_training_units(
where = []
params = []
- if role not in ["admin", "superadmin"]:
+ skip_involvement_filter = role in ("admin", "superadmin")
+ if not skip_involvement_filter and cid is not None:
+ if can_manage_club_org(cur, profile_id, cid, role):
+ skip_involvement_filter = True
+ if not skip_involvement_filter and gid is not None:
+ cur.execute(
+ "SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'",
+ (gid,),
+ )
+ gcx = cur.fetchone()
+ if gcx and gcx.get("club_id") is not None:
+ if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role):
+ skip_involvement_filter = True
+
+ if not skip_involvement_filter:
where.append(
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
@@ -1090,6 +1119,7 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
p.name as creator_name,
tg.trainer_id AS trainer_id,
tg.co_trainer_ids AS co_trainer_ids,
+ tg.club_id AS group_club_id,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
AS effective_assistant_trainer_profile_ids,
@@ -1429,7 +1459,12 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
cur = get_cursor(conn)
cur.execute(
- "SELECT created_by, framework_slot_id FROM training_units WHERE id = %s",
+ """
+ SELECT tu.created_by, tu.framework_slot_id, tg.club_id AS group_club_id
+ FROM training_units tu
+ LEFT JOIN training_groups tg ON tu.group_id = tg.id
+ WHERE tu.id = %s
+ """,
(unit_id,),
)
@@ -1444,7 +1479,13 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
)
- _assert_delete_training_unit(role, unit["created_by"], profile_id)
+ _assert_delete_training_unit(
+ cur,
+ role,
+ unit["created_by"],
+ profile_id,
+ unit.get("group_club_id"),
+ )
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
conn.commit()
diff --git a/backend/version.py b/backend/version.py
index ae0835e..ca0e8a0 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.37"
-BUILD_DATE = "2026-05-05"
+APP_VERSION = "0.8.38"
+BUILD_DATE = "2026-05-06"
DB_SCHEMA_VERSION = "20260505042"
MODULE_VERSIONS = {
@@ -27,6 +27,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.38",
+ "date": "2026-05-06",
+ "changes": [
+ "Trainingsplanung: Vereinsadmins sehen alle Einheiten bei club_id-/Gruppenliste; GET/PUT Einheit & Löschen mit can_manage_club_org",
+ "Planung UI: „Trainer zuweisen“ in Vereins-Ansicht (Liste + Kalender) + eigener Modal; Mitgliederverzeichnis für Vereinsorganisation",
+ ],
+ },
{
"version": "0.8.37",
"date": "2026-05-05",
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index c4b2049..057b05d 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -123,6 +123,15 @@ function TrainingPlanningPage() {
const [planScope, setPlanScope] = useState('group')
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
const [clubDirectory, setClubDirectory] = useState([])
+ const [meProfile, setMeProfile] = useState(null)
+ const [assignModalOpen, setAssignModalOpen] = useState(false)
+ const [assignDraft, setAssignDraft] = useState({
+ unit: null,
+ lead_trainer_profile_id: '',
+ session_assistants_inherit: true,
+ session_assistant_profile_ids: [],
+ })
+ const [assignSaving, setAssignSaving] = useState(false)
const [formData, setFormData] = useState({
group_id: '',
@@ -223,26 +232,57 @@ function TrainingPlanningPage() {
}
}, [selectedGroupId, loadUnits])
+ const selectedGroupClubIdMemo = useMemo(() => {
+ const g = groups.find((gr) => gr.id === parseInt(selectedGroupId, 10))
+ return g?.club_id != null ? Number(g.club_id) : null
+ }, [groups, selectedGroupId])
+
+ const canClubOrgTraining = useMemo(() => {
+ const r = (user?.role || '').toLowerCase()
+ if (r === 'admin' || r === 'superadmin') return true
+ if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
+ const row = (meProfile?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo)
+ return Array.isArray(row?.roles) && row.roles.includes('club_admin')
+ }, [user?.role, selectedGroupClubIdMemo, meProfile])
+
useEffect(() => {
- if (!showModal) {
- setClubDirectory([])
+ if (!user?.id) {
+ setMeProfile(null)
return undefined
}
+ let cancelled = false
+ api
+ .getCurrentProfile()
+ .then((p) => {
+ if (!cancelled) setMeProfile(p)
+ })
+ .catch(() => {
+ if (!cancelled) setMeProfile(null)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [user?.id])
+
+ useEffect(() => {
const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
- if (!Number.isFinite(gid) || gid < 1) {
- setClubDirectory([])
- return undefined
- }
- const g = groups.find((x) => x.id === gid)
- const cid = g?.club_id
- if (!cid) {
+ const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null
+ const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null
+ const loadClubId =
+ showModal && clubForModal != null && Number.isFinite(clubForModal)
+ ? clubForModal
+ : planScope === 'club' && canClubOrgTraining && selectedGroupClubIdMemo != null
+ ? selectedGroupClubIdMemo
+ : null
+
+ if (loadClubId == null || !Number.isFinite(loadClubId)) {
setClubDirectory([])
return undefined
}
let cancelled = false
;(async () => {
try {
- const d = await api.clubMembersDirectory(cid)
+ const d = await api.clubMembersDirectory(loadClubId)
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
} catch (err) {
if (!cancelled) {
@@ -254,7 +294,15 @@ function TrainingPlanningPage() {
return () => {
cancelled = true
}
- }, [showModal, formData.group_id, selectedGroupId, groups])
+ }, [
+ showModal,
+ formData.group_id,
+ selectedGroupId,
+ groups,
+ planScope,
+ canClubOrgTraining,
+ selectedGroupClubIdMemo,
+ ])
useEffect(() => {
if (!frameworkImportOpen) return
@@ -543,6 +591,47 @@ function TrainingPlanningPage() {
}
}
+ const openTrainerAssignModal = (unit) => {
+ setAssignDraft({
+ unit,
+ lead_trainer_profile_id:
+ unit.lead_trainer_profile_id != null && unit.lead_trainer_profile_id !== ''
+ ? String(unit.lead_trainer_profile_id)
+ : '',
+ session_assistants_inherit:
+ unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined,
+ session_assistant_profile_ids: toNumList(unit.assistant_trainer_profile_ids),
+ })
+ setAssignModalOpen(true)
+ }
+
+ const saveTrainerAssignModal = async () => {
+ if (!assignDraft.unit) return
+ setAssignSaving(true)
+ try {
+ const payload = {}
+ const leadStr = String(assignDraft.lead_trainer_profile_id || '').trim()
+ if (leadStr) payload.lead_trainer_profile_id = parseInt(leadStr, 10)
+ else payload.lead_trainer_profile_id = null
+ if (assignDraft.session_assistants_inherit) {
+ payload.assistant_trainer_profile_ids = null
+ } else {
+ payload.assistant_trainer_profile_ids = [...assignDraft.session_assistant_profile_ids].sort((a, b) => a - b)
+ }
+ await api.updateTrainingUnit(assignDraft.unit.id, payload)
+ setAssignModalOpen(false)
+ setAssignDraft({
+ unit: null,
+ ...sessionAssignDefaults(),
+ })
+ await loadUnits()
+ } catch (err) {
+ alert(err.message || 'Zuweisung konnte nicht gespeichert werden')
+ } finally {
+ setAssignSaving(false)
+ }
+ }
+
const handleDelete = async (unit) => {
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
try {
@@ -646,6 +735,7 @@ function TrainingPlanningPage() {
}
const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
+ const showOrgTrainerAssignControls = planScope === 'club' && canClubOrgTraining
return (
@@ -943,8 +1033,14 @@ function TrainingPlanningPage() {
/>
Nur meine Zuordnung (Leitung / Co)
-
+
„Ganzer Verein“ nutzt den Verein der gewählten Gruppe; neue Termine unten gelten weiter für die gewählte Gruppe.
+ {planScope === 'club' && canClubOrgTraining ? (
+
+ Vereinsorganisation: Über Trainer zuweisen kannst du pro Termin die Leitung und Co-Trainer
+ anpassen (auch in der Kalenderansicht).
+
+ ) : null}
@@ -1133,69 +1229,94 @@ function TrainingPlanningPage() {
{dayUnits.slice(0, 3).map((unit) => (
- handleEdit(unit)}
- title={[
- planScope === 'club' && unit.group_name ? unit.group_name : '',
- unit.planned_time_start?.slice(0, 5) || '',
- unit.lead_trainer_name?.trim(),
- unit.planned_focus?.trim(),
- unit.status === 'completed'
- ? 'Durchgeführt'
- : unit.status === 'cancelled'
- ? 'Abgesagt'
- : 'Geplant',
- ]
- .filter(Boolean)
- .join(' · ')}
- style={{
- border: 'none',
- cursor: 'pointer',
- textAlign: 'left',
- padding: '4px 5px',
- borderRadius: '4px',
- fontSize: '0.7rem',
- lineHeight: 1.25,
- width: '100%',
- borderLeftWidth: '3px',
- borderLeftStyle: 'solid',
- borderLeftColor:
- unit.status === 'completed'
- ? '#2ea44f'
- : unit.status === 'cancelled'
- ? 'var(--danger)'
- : 'var(--accent-dark)',
- background: 'var(--surface2)',
- color: 'var(--text1)',
- }}
+ style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%' }}
>
-
- {unit.planned_time_start
- ? `${unit.planned_time_start.slice(0, 5)}`
- : 'Ganztags'}
-
- {planScope === 'club' && (unit.group_name || '').trim() ? (
-
- {(unit.group_name || '').trim().length > 22
- ? `${(unit.group_name || '').trim().slice(0, 22)}…`
- : unit.group_name}
+ handleEdit(unit)}
+ title={[
+ planScope === 'club' && unit.group_name ? unit.group_name : '',
+ unit.planned_time_start?.slice(0, 5) || '',
+ unit.lead_trainer_name?.trim(),
+ unit.planned_focus?.trim(),
+ unit.status === 'completed'
+ ? 'Durchgeführt'
+ : unit.status === 'cancelled'
+ ? 'Abgesagt'
+ : 'Geplant',
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+ style={{
+ border: 'none',
+ cursor: 'pointer',
+ textAlign: 'left',
+ padding: '4px 5px',
+ borderRadius: '4px',
+ fontSize: '0.7rem',
+ lineHeight: 1.25,
+ width: '100%',
+ borderLeftWidth: '3px',
+ borderLeftStyle: 'solid',
+ borderLeftColor:
+ unit.status === 'completed'
+ ? '#2ea44f'
+ : unit.status === 'cancelled'
+ ? 'var(--danger)'
+ : 'var(--accent-dark)',
+ background: 'var(--surface2)',
+ color: 'var(--text1)',
+ }}
+ >
+
+ {unit.planned_time_start
+ ? `${unit.planned_time_start.slice(0, 5)}`
+ : 'Ganztags'}
+ {planScope === 'club' && (unit.group_name || '').trim() ? (
+
+ {(unit.group_name || '').trim().length > 22
+ ? `${(unit.group_name || '').trim().slice(0, 22)}…`
+ : unit.group_name}
+
+ ) : null}
+ {unit.lead_trainer_name?.trim() ? (
+
+ {unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] ||
+ unit.lead_trainer_name.trim()}
+
+ ) : null}
+ {unit.planned_focus?.trim() ? (
+
+ {(unit.planned_focus || '').trim().length > 24
+ ? `${(unit.planned_focus || '').trim().slice(0, 24)}…`
+ : unit.planned_focus}
+
+ ) : null}
+
+ {showOrgTrainerAssignControls ? (
+ {
+ ev.stopPropagation()
+ openTrainerAssignModal(unit)
+ }}
+ >
+ Trainer
+
) : null}
- {unit.lead_trainer_name?.trim() ? (
-
- {unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] || unit.lead_trainer_name.trim()}
-
- ) : null}
- {unit.planned_focus?.trim() ? (
-
- {(unit.planned_focus || '').trim().length > 24
- ? `${(unit.planned_focus || '').trim().slice(0, 24)}…`
- : unit.planned_focus}
-
- ) : null}
-
+
))}
{dayUnits.length > 3 ? (
@@ -1379,6 +1500,16 @@ function TrainingPlanningPage() {
handleEdit(unit)}>
Bearbeiten
+ {showOrgTrainerAssignControls ? (
+ openTrainerAssignModal(unit)}
+ title="Nur organisatorisch: Leitung und Co für diese Einheit"
+ >
+ Trainer zuweisen
+
+ ) : null}
{showTakeLead ? (
handleTakeLead(unit)}>
Ich übernehme
@@ -1405,6 +1536,163 @@ function TrainingPlanningPage() {
)}
+ {assignModalOpen && assignDraft.unit ? (
+ {
+ if (!assignSaving) setAssignModalOpen(false)
+ }}
+ >
+
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',
+ }}
+ >
+
+ Trainer zuweisen (organisatorisch)
+
+
+ {(assignDraft.unit.planned_date || '').toString().slice(0, 10)}
+ {assignDraft.unit.planned_time_start
+ ? ` · ${String(assignDraft.unit.planned_time_start).slice(0, 5)}`
+ : ''}
+ {(assignDraft.unit.group_name || '').trim()
+ ? ` · ${(assignDraft.unit.group_name || '').trim()}`
+ : null}
+
+
+ Leitung (diese Einheit)
+
+ setAssignDraft((prev) => ({ ...prev, lead_trainer_profile_id: e.target.value }))
+ }
+ disabled={assignSaving}
+ >
+ Standard (Haupttrainer der Gruppe)
+ {clubDirectory.map((m) => {
+ const idStr = String(m.id)
+ return (
+
+ {(m.name || '').trim() || m.email || `Profil ${m.id}`}
+
+ )
+ })}
+
+
+
+
+
+ setAssignDraft((prev) => ({
+ ...prev,
+ session_assistants_inherit: e.target.checked,
+ }))
+ }
+ />
+
+ Co-Trainer wie in der Trainingsgruppe
+
+
+
+ {!assignDraft.session_assistants_inherit ? (
+
+ {clubDirectory.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) && assignDraft.session_assistant_profile_ids.includes(mid)
+ return (
+
+ {
+ setAssignDraft((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 }
+ })
+ }}
+ />
+ {labelText}
+
+ )
+ })}
+
+ ) : null}
+ {!clubDirectory.length ? (
+
+ Mitgliederverzeichnis konnte nicht geladen werden.
+
+ ) : null}
+
+ setAssignModalOpen(false)}
+ >
+ Abbrechen
+
+
+ {assignSaving ? 'Speichern …' : 'Speichern'}
+
+
+
+
+ ) : null}
+
{frameworkImportOpen && (
Date: Wed, 6 May 2026 07:25:11 +0200
Subject: [PATCH 03/22] feat: enhance TrainingPlanningPage with template
application functionality
- Added a new function to apply selected training templates, improving user experience in the training planning process.
- Introduced modal display logic to enhance interaction when applying templates.
- Updated the state management to handle template ID selection more effectively.
---
frontend/src/pages/TrainingPlanningPage.jsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 057b05d..6576d58 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -507,7 +507,11 @@ function TrainingPlanningPage() {
trainer_notes: '',
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
- }) = async (templateId) => {
+ })
+ setShowModal(true)
+ }
+
+ const applyTemplateFromSelect = async (templateId) => {
setDraftPlanTemplateId(templateId)
if (!templateId) return
try {
--
2.43.0
From 56ea36ea25bf431f3f43901b4f408ee07cb08965 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 07:42:39 +0200
Subject: [PATCH 04/22] feat: enhance TrainingPlanningPage with new utility
functions and state management improvements
- Added utility functions to normalize co-trainer IDs and filter directory entries, improving data handling for training groups.
- Updated state management to remove reliance on the user profile for club admin checks, enhancing performance and clarity.
- Improved session assignment logic to ensure effective lead trainers are excluded from assistant trainer lists.
- Enhanced form field updates to manage session assistant IDs more effectively, streamlining the assignment process.
---
frontend/src/pages/TrainingPlanningPage.jsx | 213 ++++++++++++++++----
1 file changed, 174 insertions(+), 39 deletions(-)
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 6576d58..dfedb74 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -87,6 +87,28 @@ const sessionAssignDefaults = () => ({
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
+
+/** Co_trainer_ids aus TrainingGroups (Liste/JSON) → Zahlenliste */
+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 */
+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)
+}
function TrainingPlanningPage() {
const { user } = useAuth()
const [groups, setGroups] = useState([])
@@ -123,7 +145,6 @@ function TrainingPlanningPage() {
const [planScope, setPlanScope] = useState('group')
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
const [clubDirectory, setClubDirectory] = useState([])
- const [meProfile, setMeProfile] = useState(null)
const [assignModalOpen, setAssignModalOpen] = useState(false)
const [assignDraft, setAssignDraft] = useState({
unit: null,
@@ -241,39 +262,41 @@ function TrainingPlanningPage() {
const r = (user?.role || '').toLowerCase()
if (r === 'admin' || r === 'superadmin') return true
if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
- const row = (meProfile?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo)
+ const row = (user?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo)
return Array.isArray(row?.roles) && row.roles.includes('club_admin')
- }, [user?.role, selectedGroupClubIdMemo, meProfile])
+ }, [user?.role, user?.clubs, selectedGroupClubIdMemo])
- useEffect(() => {
- if (!user?.id) {
- setMeProfile(null)
- return undefined
+ const clubAdminClubIdSet = useMemo(() => {
+ const ids = []
+ for (const c of user?.clubs || []) {
+ if (Array.isArray(c.roles) && c.roles.includes('club_admin')) {
+ const id = Number(c.id)
+ if (Number.isFinite(id)) ids.push(id)
+ }
}
- let cancelled = false
- api
- .getCurrentProfile()
- .then((p) => {
- if (!cancelled) setMeProfile(p)
- })
- .catch(() => {
- if (!cancelled) setMeProfile(null)
- })
- return () => {
- cancelled = true
- }
- }, [user?.id])
+ return new Set(ids)
+ }, [user?.clubs])
useEffect(() => {
const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null
const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null
+
+ let assignModalClubId = null
+ if (assignModalOpen && assignDraft.unit?.group_id != null) {
+ const ug = Number(assignDraft.unit.group_id)
+ const gAssign = Number.isFinite(ug) ? groups.find((x) => x.id === ug) : null
+ if (gAssign?.club_id != null) assignModalClubId = Number(gAssign.club_id)
+ }
+
const loadClubId =
showModal && clubForModal != null && Number.isFinite(clubForModal)
? clubForModal
- : planScope === 'club' && canClubOrgTraining && selectedGroupClubIdMemo != null
- ? selectedGroupClubIdMemo
- : null
+ : assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId)
+ ? assignModalClubId
+ : canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo)
+ ? selectedGroupClubIdMemo
+ : null
if (loadClubId == null || !Number.isFinite(loadClubId)) {
setClubDirectory([])
@@ -296,10 +319,11 @@ function TrainingPlanningPage() {
}
}, [
showModal,
+ assignModalOpen,
+ assignDraft.unit,
formData.group_id,
selectedGroupId,
groups,
- planScope,
canClubOrgTraining,
selectedGroupClubIdMemo,
])
@@ -559,7 +583,15 @@ function TrainingPlanningPage() {
session_assistants_inherit:
fullUnit.assistant_trainer_profile_ids == null ||
fullUnit.assistant_trainer_profile_ids === undefined,
- session_assistant_profile_ids: toNumList(fullUnit.assistant_trainer_profile_ids),
+ session_assistant_profile_ids: (() => {
+ const efLead =
+ fullUnit.effective_lead_trainer_profile_id != null
+ ? Number(fullUnit.effective_lead_trainer_profile_id)
+ : null
+ let xs = toNumList(fullUnit.assistant_trainer_profile_ids)
+ if (efLead != null && Number.isFinite(efLead)) xs = xs.filter((id) => id !== efLead)
+ return xs
+ })(),
})
setShowModal(true)
} catch (err) {
@@ -596,6 +628,14 @@ function TrainingPlanningPage() {
}
const openTrainerAssignModal = (unit) => {
+ const effLead =
+ unit.effective_lead_trainer_profile_id != null
+ ? Number(unit.effective_lead_trainer_profile_id)
+ : null
+ let coIds = toNumList(unit.assistant_trainer_profile_ids)
+ if (effLead != null && Number.isFinite(effLead)) {
+ coIds = coIds.filter((id) => id !== effLead)
+ }
setAssignDraft({
unit,
lead_trainer_profile_id:
@@ -604,7 +644,7 @@ function TrainingPlanningPage() {
: '',
session_assistants_inherit:
unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined,
- session_assistant_profile_ids: toNumList(unit.assistant_trainer_profile_ids),
+ session_assistant_profile_ids: coIds,
})
setAssignModalOpen(true)
}
@@ -701,7 +741,27 @@ function TrainingPlanningPage() {
}
const updateFormField = (field, value) => {
- setFormData((prev) => ({ ...prev, [field]: value }))
+ setFormData((prev) => {
+ if (field !== 'lead_trainer_profile_id') return { ...prev, [field]: value }
+ const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim()
+ const strip = new Set()
+ if (ts !== '') {
+ const nid = parseInt(ts, 10)
+ if (Number.isFinite(nid)) strip.add(nid)
+ } else {
+ const gidParsed = parseInt(prev.group_id || selectedGroupId || '0', 10)
+ const gr =
+ Number.isFinite(gidParsed) && gidParsed >= 1
+ ? groups.find((xg) => xg.id === gidParsed)
+ : null
+ if (gr?.trainer_id != null) {
+ const ht = Number(gr.trainer_id)
+ if (Number.isFinite(ht)) strip.add(ht)
+ }
+ }
+ const assistants = prev.session_assistant_profile_ids.filter((id) => !strip.has(id))
+ return { ...prev, lead_trainer_profile_id: value, session_assistant_profile_ids: assistants }
+ })
}
const calendarGridDays = useMemo(() => {
@@ -729,6 +789,32 @@ function TrainingPlanningPage() {
return new Date(y, mo - 1, 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
}, [calendarMonthStr])
+ const mayConfigureSessionAssignments = useCallback(
+ (unit) => {
+ if (!unit) return false
+ const pid = Number(user?.id)
+ if (!Number.isFinite(pid)) return false
+ const r = (user?.role || '').toLowerCase()
+ if (r === 'admin' || r === 'superadmin') return true
+
+ const gClub = unit.group_club_id != null ? Number(unit.group_club_id) : null
+ if (Number.isFinite(gClub) && clubAdminClubIdSet.has(gClub)) return true
+
+ const gid = Number(unit.group_id)
+ const g = groups.find((gr) => gr.id === gid)
+ if (!g) return false
+
+ const cb = unit.created_by != null ? Number(unit.created_by) : NaN
+ if (Number.isFinite(cb) && cb === pid) return true
+
+ const ht = g.trainer_id != null ? Number(g.trainer_id) : NaN
+ if (Number.isFinite(ht) && ht === pid) return true
+
+ return normalizeGroupCoTrainerIds(g.co_trainer_ids).includes(pid)
+ },
+ [user?.id, user?.role, groups, clubAdminClubIdSet]
+ )
+
if (loading) {
return (
@@ -739,7 +825,39 @@ function TrainingPlanningPage() {
}
const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
- const showOrgTrainerAssignControls = planScope === 'club' && canClubOrgTraining
+
+ const gidTrainerForm = parseInt(formData.group_id || selectedGroupId || '0', 10)
+ const groupForTrainerForm =
+ Number.isFinite(gidTrainerForm) && gidTrainerForm >= 1
+ ? groups.find((gr) => gr.id === gidTrainerForm)
+ : null
+
+ let formTrainerAssignLeadExcludeId = null
+ if (groupForTrainerForm?.trainer_id != null) formTrainerAssignLeadExcludeId = Number(groupForTrainerForm.trainer_id)
+ const leadDraftTrim = String(formData.lead_trainer_profile_id || '').trim()
+ if (leadDraftTrim !== '') {
+ const nl = parseInt(leadDraftTrim, 10)
+ if (Number.isFinite(nl)) formTrainerAssignLeadExcludeId = nl
+ }
+ if (editingUnit?.effective_lead_trainer_profile_id != null && leadDraftTrim === '') {
+ const el = Number(editingUnit.effective_lead_trainer_profile_id)
+ if (Number.isFinite(el)) formTrainerAssignLeadExcludeId = el
+ }
+
+ const clubDirectoryForCo = filterDirectoryExcludingLead(clubDirectory, formTrainerAssignLeadExcludeId)
+
+ let assignExcludeLeadPid = null
+ if (assignModalOpen && assignDraft.unit) {
+ const dl = String(assignDraft.lead_trainer_profile_id || '').trim()
+ if (dl !== '') {
+ const n = parseInt(dl, 10)
+ assignExcludeLeadPid = Number.isFinite(n) ? n : null
+ } else if (assignDraft.unit.effective_lead_trainer_profile_id != null) {
+ const n = Number(assignDraft.unit.effective_lead_trainer_profile_id)
+ assignExcludeLeadPid = Number.isFinite(n) ? n : null
+ }
+ }
+ const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid)
return (
@@ -1038,11 +1156,12 @@ function TrainingPlanningPage() {
Nur meine Zuordnung (Leitung / Co)
- „Ganzer Verein“ nutzt den Verein der gewählten Gruppe; neue Termine unten gelten weiter für die gewählte Gruppe.
- {planScope === 'club' && canClubOrgTraining ? (
+ „Ganzer Verein“ bezieht sich auf denselben Verein wie die gewählte Gruppe: Dort siehst du Termine mehrerer Gruppen; neu angelegte Termine gelten weiter für die gesondert gewählte Gruppe.
+ {selectedGroupId ? (
- Vereinsorganisation: Über Trainer zuweisen kannst du pro Termin die Leitung und Co-Trainer
- anpassen (auch in der Kalenderansicht).
+ Über Trainer oder Trainer zuweisen : Leitung und Co je Einheit bearbeitbar (berechtigt: Vereinsorganisation, Haupt-/Co‑Trainer der Gruppe sowie Erstellung der Einheit).
+ Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder ; die Leitung erscheint nicht unter Co‑Trainer.
+ Gasttrainer aus anderen Vereinen (Zugriff nur auf eine Session, nicht auf den Verein insgesamt) sind für später vorgesehen.
) : null}
@@ -1307,7 +1426,7 @@ function TrainingPlanningPage() {
) : null}
- {showOrgTrainerAssignControls ? (
+ {mayConfigureSessionAssignments(unit) ? (
handleEdit(unit)}>
Bearbeiten
- {showOrgTrainerAssignControls ? (
+ {mayConfigureSessionAssignments(unit) ? (
- setAssignDraft((prev) => ({ ...prev, lead_trainer_profile_id: e.target.value }))
- }
+ onChange={(e) => {
+ const v = e.target.value
+ setAssignDraft((prev) => {
+ const exclude = []
+ const tr = String(v || '').trim()
+ if (tr !== '') {
+ const n = parseInt(tr, 10)
+ if (Number.isFinite(n)) exclude.push(n)
+ } else if (prev.unit?.effective_lead_trainer_profile_id != null) {
+ const ef = Number(prev.unit.effective_lead_trainer_profile_id)
+ if (Number.isFinite(ef)) exclude.push(ef)
+ }
+ const exSet = new Set(exclude)
+ const co = exclude.length
+ ? prev.session_assistant_profile_ids.filter((x) => !exSet.has(x))
+ : prev.session_assistant_profile_ids
+ return { ...prev, lead_trainer_profile_id: v, session_assistant_profile_ids: co }
+ })
+ }}
disabled={assignSaving}
>
Standard (Haupttrainer der Gruppe)
@@ -1630,7 +1765,7 @@ function TrainingPlanningPage() {
{!assignDraft.session_assistants_inherit ? (
- {clubDirectory.map((m) => {
+ {clubDirectoryForAssignCo.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) && assignDraft.session_assistant_profile_ids.includes(mid)
@@ -2075,7 +2210,7 @@ function TrainingPlanningPage() {
{!formData.session_assistants_inherit ? (
- {clubDirectory.map((m) => {
+ {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)
--
2.43.0
From 00b22a756ffc661f660b0834fac79469e947b2c5 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 07:55:35 +0200
Subject: [PATCH 05/22] feat: refactor TrainingUnitSectionsEditor and enhance
TrainingPlanningPage layout
- Replaced the tu-ex-run-block with a new tu-ex-debrief layout using CSS Grid for improved structure and responsiveness.
- Updated the TrainingUnitSectionsEditor component to utilize the new layout, enhancing the user experience for inputting modifications and actual durations.
- Introduced a sectionsEditMode state in TrainingPlanningPage to manage different editing modes (planning, refine, debrief) for better user guidance.
- Adjusted the visibility of execution extras based on the current editing mode, streamlining the interface for users.
---
frontend/src/app.css | 54 ++++++++++++---
.../components/TrainingUnitSectionsEditor.jsx | 36 +++++-----
frontend/src/pages/TrainingPlanningPage.jsx | 68 ++++++++++++++++++-
3 files changed, 133 insertions(+), 25 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 4ebd9a0..f916c44 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -3445,22 +3445,60 @@ a.analysis-split__nav-item {
white-space: nowrap;
}
-.tu-ex-run-block {
- display: block;
+.tu-ex-debrief {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 4.75rem;
+ gap: 10px 14px;
+ align-items: start;
width: 100%;
margin-top: 10px;
- font-size: 0.78rem;
+ padding-top: 10px;
+ border-top: 1px solid var(--border2, rgba(0, 0, 0, 0.08));
+ box-sizing: border-box;
}
-.tu-ex-run-block__controls {
+.tu-ex-debrief__grow {
+ min-width: 0;
display: flex;
flex-direction: column;
- gap: 6px;
- margin-top: 5px;
+ gap: 5px;
}
-.tu-ex-run-block__controls .form-input:first-of-type {
- max-width: 120px;
+.tu-ex-debrief__textarea {
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ min-height: 4.75rem;
+ resize: vertical;
+ font-size: 0.88rem;
+ line-height: 1.45;
+}
+
+.tu-ex-debrief__ist {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ align-items: stretch;
+ justify-self: end;
+ width: 4.75rem;
+ flex-shrink: 0;
+}
+
+.tu-ex-debrief__ist .tu-ex-duration {
+ width: 100%;
+ margin: 0;
+}
+
+@media (max-width: 520px) {
+ .tu-ex-debrief {
+ grid-template-columns: 1fr;
+ }
+
+ .tu-ex-debrief__ist {
+ width: 100%;
+ max-width: 10rem;
+ justify-self: start;
+ }
}
.tu-textedit-backdrop {
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index c355446..31daf70 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -763,30 +763,34 @@ export default function TrainingUnitSectionsEditor({
{showExecutionExtras ? (
-
- Ist-Dauer / Anpassungen
-
+
+
+ Abweichungen beim Durchführen
+
+
+ Ist (Min)
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
}
- placeholder="IST min"
+ placeholder="IST"
+ title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
/>
-
+
) : null}
)
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index dfedb74..54709f0 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -118,6 +118,8 @@ function TrainingPlanningPage() {
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUnit, setEditingUnit] = useState(null)
+ /** Abschnitts-Editor: Planung / strukturelle Überarbeitung (ohne Ist-Felder) / Nachbereitung (Ist & Abweichungen) */
+ const [sectionsEditMode, setSectionsEditMode] = useState('planning')
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
const [quickTemplateId, setQuickTemplateId] = useState('')
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
@@ -505,6 +507,7 @@ function TrainingPlanningPage() {
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
})
+ setSectionsEditMode('planning')
setShowModal(true)
}
@@ -532,6 +535,7 @@ function TrainingPlanningPage() {
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
})
+ setSectionsEditMode('planning')
setShowModal(true)
}
@@ -593,6 +597,7 @@ function TrainingPlanningPage() {
return xs
})(),
})
+ setSectionsEditMode(fullUnit.status === 'completed' ? 'debrief' : 'planning')
setShowModal(true)
} catch (err) {
alert('Fehler beim Laden: ' + err.message)
@@ -2254,6 +2259,67 @@ function TrainingPlanningPage() {
+ {editingUnit ? (
+
+
+
+ Ablauf bearbeiten als
+
+
+ {[
+ { id: 'planning', label: 'Planung' },
+ { id: 'refine', label: 'Überarbeiten' },
+ { id: 'debrief', label: 'Nachbereitung' },
+ ].map((opt, i) => (
+ 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}
+
+ ))}
+
+
+
+ {sectionsEditMode === 'debrief'
+ ? 'Ist‑Minuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.'
+ : sectionsEditMode === 'refine'
+ ? 'Struktur, Übungen und geplante Zeiten anpassen — ohne Ist‑Angaben in den Zeilen (wie Planung).'
+ : 'Ablauf und geplante Minuten bearbeiten. Ist‑Werte und Abweichungen nur unter „Nachbereitung“.'}
+
+
+ ) : null}
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null })
}
- showExecutionExtras={!!editingUnit}
+ showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
/>
--
2.43.0
From d4b9db952099a0e5b6f363447e8fffc3536ac02a Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 08:00:27 +0200
Subject: [PATCH 06/22] refactor: update sectionsEditMode logic and remove
refine option in TrainingPlanningPage
- Revised the sectionsEditMode state to clarify the editing modes available, focusing on 'planning' and 'debrief'.
- Removed the 'refine' option from the editing mode buttons to streamline the user interface.
- Updated related text descriptions to reflect the changes in editing modes, enhancing user guidance during the training planning process.
---
frontend/src/pages/TrainingPlanningPage.jsx | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 54709f0..a1b5725 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -118,7 +118,7 @@ function TrainingPlanningPage() {
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUnit, setEditingUnit] = useState(null)
- /** Abschnitts-Editor: Planung / strukturelle Überarbeitung (ohne Ist-Felder) / Nachbereitung (Ist & Abweichungen) */
+ /** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
const [quickTemplateId, setQuickTemplateId] = useState('')
@@ -2285,7 +2285,6 @@ function TrainingPlanningPage() {
>
{[
{ id: 'planning', label: 'Planung' },
- { id: 'refine', label: 'Überarbeiten' },
{ id: 'debrief', label: 'Nachbereitung' },
].map((opt, i) => (
{sectionsEditMode === 'debrief'
? 'Ist‑Minuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.'
- : sectionsEditMode === 'refine'
- ? 'Struktur, Übungen und geplante Zeiten anpassen — ohne Ist‑Angaben in den Zeilen (wie Planung).'
- : 'Ablauf und geplante Minuten bearbeiten. Ist‑Werte und Abweichungen nur unter „Nachbereitung“.'}
+ : 'Ablauf, Übungen und geplante Minuten. Ist‑Werte und Abweichungen unter „Nachbereitung“.'}
) : null}
--
2.43.0
From 2007f3f659aa0c4921b19d1ef19b7128c3b2073c Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 08:44:15 +0200
Subject: [PATCH 07/22] feat: enhance TrainingPlanningPage with new training
unit creation UI
- Introduced a new layout for creating training units within a card format, improving visual organization and user experience.
- Added CSS styles for various elements related to training unit creation, including titles, hints, and action buttons.
- Removed the quick template ID state and related functionality to streamline the creation process.
- Updated user prompts and hints to guide users more effectively in selecting training groups and creating new training units.
---
frontend/src/app.css | 92 ++++++++++++++
frontend/src/pages/TrainingPlanningPage.jsx | 130 ++++++--------------
2 files changed, 130 insertions(+), 92 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index f916c44..da9d417 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -3824,6 +3824,98 @@ a.analysis-split__nav-item {
}
}
+/* ── Trainingsplanung: Abschnitt „Neue Trainingseinheit“ + Vorlage im Modal ───────── */
+.training-planning-create--in-card {
+ margin-top: 1.25rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid var(--border, rgba(0, 0, 0, 0.08));
+}
+
+.training-planning-create__intro {
+ margin-bottom: 1rem;
+}
+
+.training-planning-create__title {
+ margin: 0 0 0.45rem;
+ font-size: 1.06rem;
+ font-weight: 700;
+ color: var(--text1);
+ letter-spacing: -0.01em;
+}
+
+.training-planning-create__lede {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.55;
+ color: var(--text2);
+ max-width: 52rem;
+}
+
+.training-planning-create__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: center;
+}
+
+.training-planning-create__cta {
+ min-height: 44px;
+ padding-left: 1.35rem;
+ padding-right: 1.35rem;
+ font-weight: 600;
+}
+
+.training-planning-create__secondary {
+ min-height: 44px;
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.training-planning-create__hint {
+ margin: 0.85rem 0 0;
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: var(--text3);
+ max-width: 48rem;
+}
+
+.training-planning-create__hint--warn {
+ color: var(--text2);
+ margin-top: 0.65rem;
+ padding: 0.55rem 0.7rem;
+ border-radius: 8px;
+ background: var(--surface2);
+ border: 1px solid var(--border2);
+}
+
+.training-planning-template-panel {
+ padding: 1rem 1.1rem;
+ border-radius: 12px;
+ border: 1px solid var(--border2);
+ background: linear-gradient(165deg, var(--surface2) 0%, var(--surface) 100%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.training-planning-template-panel__label {
+ font-weight: 600;
+ margin-bottom: 0.4rem;
+ display: block;
+}
+
+.training-planning-template-panel__select {
+ font-size: 0.94rem;
+ padding: 0.55rem 0.65rem;
+ width: 100%;
+ max-width: 100%;
+}
+
+.training-planning-template-panel__help {
+ margin: 0.65rem 0 0;
+ font-size: 0.82rem;
+ color: var(--text2);
+ line-height: 1.48;
+}
+
@media print {
.desktop-sidebar,
.bottom-nav,
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index a1b5725..e6e5726 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -121,7 +121,6 @@ function TrainingPlanningPage() {
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
- const [quickTemplateId, setQuickTemplateId] = useState('')
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
@@ -461,28 +460,6 @@ function TrainingPlanningPage() {
return { fpTitle, slotBit, fpId: unit.origin_framework_program_id }
}
- const handleQuickCreate = async () => {
- if (!selectedGroupId) {
- alert('Bitte wähle zuerst eine Trainingsgruppe')
- return
- }
- const date = prompt('Datum für neue Trainingseinheit (YYYY-MM-DD):', today)
- if (!date) return
- try {
- const body = {
- group_id: parseInt(selectedGroupId, 10),
- planned_date: date
- }
- if (quickTemplateId) {
- body.plan_template_id = parseInt(quickTemplateId, 10)
- }
- await api.quickCreateTrainingUnit(body)
- await loadUnits()
- } catch (err) {
- alert('Fehler beim Erstellen: ' + err.message)
- }
- }
-
const handleCreate = () => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
@@ -1191,80 +1168,39 @@ function TrainingPlanningPage() {
)}
-
-
- Plan anlegen: neue Trainingseinheit mit Datum, Zeit und Ablauf — oder schnell nur mit Datum (Zeiten aus der Gruppe).
+
+
+
Neue Trainingseinheit
+
+ Termin mit Datum, Zeiten und Ablauf (Abschnitte & Übungen) festlegen — optional eine{' '}
+ Trainingsvorlage für die Gliederung wählen oder Inhalte aus einem{' '}
+ Rahmenprogramm übernehmen.
+
{!selectedGroupId && (
-
- Wähle oben eine Trainingsgruppe, um die Schaltflächen zu aktivieren.
-
+
+ Wähle oben eine Trainingsgruppe, um fortzufahren.
+
)}
{groups.length === 0 && (
-
+
Es gibt noch keine aktive Trainingsgruppe — unter{' '}
-
- Vereine
- {' '}
- anlegen oder aktivieren.
-
+
Vereine anlegen oder aktivieren.
+
)}
-
-
+
+
- + Neue Trainingseinheit planen
+ Trainingseinheit planen…
-
-
- Schnell (+ optional Vorlage):
-
- setQuickTemplateId(e.target.value)}
- disabled={!selectedGroupId}
- title={!selectedGroupId ? 'Zuerst Trainingsgruppe wählen' : undefined}
- >
- Standard (leer)
- {planTemplates.map((t) => (
-
- {t.name}
- {typeof t.sections_count === 'number' ? ` (${t.sections_count} Abschn.)` : ''}
-
- ))}
-
-
- Schnell erstellen
-
-
+
+ Vorlage („Ohne Vorlage“ oder gespeicherte Gliederung) stellst du im sich öffnenden Dialog ein; dort auch
+ Kalenderdatum und Zeiten.
+
{!selectedGroupId ? (
- Wähle oben eine Trainingsgruppe — danach kannst du mit „Neue Trainingseinheit planen“ starten.
+ Wähle oben eine Trainingsgruppe — danach kannst du unter{' '}
+ „Trainingseinheit planen…“ einen Termin anlegen.
) : planView === 'calendar' ? (
@@ -1460,8 +1401,8 @@ function TrainingPlanningPage() {
) : units.length === 0 ? (
- Keine Trainingseinheiten in diesem Zeitraum. Nutze oben „Neue Trainingseinheit planen“ oder{' '}
- „Schnell erstellen“ , um den ersten Termin anzulegen.
+ Keine Trainingseinheiten in diesem Zeitraum. Unten unter „Neue Trainingseinheit“ einen
+ Termin anlegen — optional mit Vorlage im Dialog.
) : (
@@ -2099,22 +2040,27 @@ function TrainingPlanningPage() {
})() : null}
{!editingUnit && (
-
-
Gliederungsvorlage (optional)
+
+
+ Vorlage für den Ablauf
+
applyTemplateFromSelect(e.target.value)}
>
- Keine Vorlage
+ Ohne Vorlage — leere Gliederung (ein Abschnitt)
{planTemplates.map((t) => (
{t.name}
+ {typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
))}
-
- Lädt die Abschnitte und Hinweise aus der Vorlage; Übungen fügst du hier ein.
+
+ Übernimmt nur die Sektionsstruktur aus der Bibliothek; Übungen trägst du unten bei
+ den Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
)}
--
2.43.0
From 14884e6e55bcdadabf029ef6e72b15d77297a943 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 10:37:01 +0200
Subject: [PATCH 08/22] UX. refactor: simplify AdminPageNav component by
removing unused hooks and improving styling
- Removed the useLocation hook as it was unnecessary for the component's functionality.
- Updated the navigation styling to use CSS classes instead of inline styles, enhancing maintainability and readability.
- Improved accessibility by adding aria-labels to navigation elements.
---
frontend/src/app.css | 115 +++++++++++++++++++-
frontend/src/components/AdminPageNav.jsx | 49 ++-------
frontend/src/components/AppSubnavShell.jsx | 50 +++++++++
frontend/src/pages/AdminHierarchyPage.jsx | 85 +++------------
frontend/src/pages/Dashboard.jsx | 82 ++++----------
frontend/src/pages/TrainingPlanningPage.jsx | 95 ++++++++--------
6 files changed, 255 insertions(+), 221 deletions(-)
create mode 100644 frontend/src/components/AppSubnavShell.jsx
diff --git a/frontend/src/app.css b/frontend/src/app.css
index da9d417..cd5bc65 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -1,7 +1,7 @@
:root {
- --bg: #f4f3ef;
+ --bg: #f6f5f0;
--surface: #ffffff;
- --surface2: #f9f8f5;
+ --surface2: #fafaf6;
--border: rgba(0,0,0,0.09);
--border2: rgba(0,0,0,0.16);
--text1: #1c1b18;
@@ -1075,6 +1075,15 @@ a.analysis-split__nav-item {
}
}
+button.capture-shell__nav-item {
+ font-family: inherit;
+ text-align: left;
+ -webkit-tap-highlight-color: transparent;
+}
+.capture-shell__nav-item svg.capture-shell__nav-icon {
+ flex-shrink: 0;
+}
+
/* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */
.settings-shell {
width: 100%;
@@ -1135,6 +1144,108 @@ a.analysis-split__nav-item {
background: var(--surface2);
}
+/* Admin: horizontale Seiten-Weiche (Hierarchie · Nutzer · …) */
+.admin-top-nav {
+ display: flex;
+ gap: 8px;
+ border-bottom: 2px solid var(--border);
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+}
+.admin-top-nav__link {
+ padding: 12px 18px;
+ background: transparent;
+ border: none;
+ border-bottom: 3px solid transparent;
+ margin-bottom: -2px;
+ cursor: pointer;
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--text2);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
+ font-family: inherit;
+ border-radius: 8px 8px 0 0;
+ box-sizing: border-box;
+}
+.admin-top-nav__link:hover {
+ color: var(--text1);
+ background: var(--surface2);
+}
+.admin-top-nav__link--active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ background: transparent;
+}
+
+/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
+.planning-segment-group {
+ display: inline-flex;
+ border-radius: 10px;
+ border: 1.5px solid var(--border2);
+ overflow: hidden;
+ background: var(--surface2);
+}
+.planning-segment-group__btn {
+ border: none;
+ padding: 8px 14px;
+ font-weight: 600;
+ font-size: 0.85rem;
+ cursor: pointer;
+ font-family: inherit;
+ background: transparent;
+ color: var(--text1);
+ white-space: nowrap;
+ transition: background 0.12s, color 0.12s;
+}
+.planning-segment-group__btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+.planning-segment-group__btn--active {
+ background: var(--accent);
+ color: #fff;
+}
+.planning-segment-group__btn:not(:first-child) {
+ border-left: 1.5px solid var(--border2);
+}
+
+/* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */
+.planning-filter-help {
+ flex: 1 1 100%;
+ margin-top: 4px;
+ max-width: 100%;
+}
+.planning-filter-help__summary {
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--accent-dark);
+ list-style: none;
+ user-select: none;
+}
+.planning-filter-help__summary::-webkit-details-marker {
+ display: none;
+}
+.planning-filter-help__body {
+ margin-top: 10px;
+ padding: 12px 14px;
+ font-size: 0.82rem;
+ line-height: 1.5;
+ color: var(--text2);
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+@media (prefers-color-scheme: dark) {
+ .planning-filter-help__summary {
+ color: var(--accent);
+ }
+}
+
/* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */
.admin-shell {
width: 100%;
diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx
index 4ad7a50..2e6aa4c 100644
--- a/frontend/src/components/AdminPageNav.jsx
+++ b/frontend/src/components/AdminPageNav.jsx
@@ -1,4 +1,4 @@
-import { NavLink, useLocation } from 'react-router-dom'
+import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
/**
@@ -6,8 +6,6 @@ import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
* Wechselt zwischen verschiedenen Admin-Seiten
*/
export default function AdminPageNav() {
- const location = useLocation()
-
const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users },
@@ -17,51 +15,18 @@ export default function AdminPageNav() {
]
return (
-
- {pages.map(page => {
+
+ {pages.map((page) => {
const Icon = page.icon
- const isActive = location.pathname === page.to
-
return (
{
- if (!isActive) {
- e.currentTarget.style.color = 'var(--text1)'
- e.currentTarget.style.background = 'var(--surface2)'
- }
- }}
- onMouseLeave={(e) => {
- if (!isActive) {
- e.currentTarget.style.color = 'var(--text2)'
- e.currentTarget.style.background = 'transparent'
- }
- }}
+ className={({ isActive }) =>
+ 'admin-top-nav__link' + (isActive ? ' admin-top-nav__link--active' : '')
+ }
>
-
+
{page.label}
)
diff --git a/frontend/src/components/AppSubnavShell.jsx b/frontend/src/components/AppSubnavShell.jsx
new file mode 100644
index 0000000..e40342a
--- /dev/null
+++ b/frontend/src/components/AppSubnavShell.jsx
@@ -0,0 +1,50 @@
+/**
+ * Einheitliche Sub-Navigation (Jinkendo-Muster):
+ * Mobil = horizontale Chips, Desktop ≥1024px = linke Spalte (sticky).
+ * Nutzt .capture-shell* aus app.css.
+ */
+export default function AppSubnavShell({
+ ariaLabel,
+ items,
+ value,
+ onChange,
+ children,
+ iconSize = 18,
+}) {
+ return (
+
+
+
+
+ {items.map((item) => {
+ const Icon = item.icon
+ const active = value === item.id
+ return (
+ onChange(item.id)}
+ >
+ {Icon ? (
+
+ ) : null}
+ {item.label}
+
+ )
+ })}
+
+
+
{children}
+
+
+ )
+}
diff --git a/frontend/src/pages/AdminHierarchyPage.jsx b/frontend/src/pages/AdminHierarchyPage.jsx
index e09061f..bb89763 100644
--- a/frontend/src/pages/AdminHierarchyPage.jsx
+++ b/frontend/src/pages/AdminHierarchyPage.jsx
@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react'
+import { TreePine, FolderTree, Link2 } from 'lucide-react'
import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
+import AppSubnavShell from '../components/AppSubnavShell'
import HierarchyTab from '../components/admin/HierarchyTab'
import CatalogsTab from '../components/admin/CatalogsTab'
import AssignmentsTab from '../components/admin/AssignmentsTab'
@@ -10,17 +12,14 @@ function AdminHierarchyPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
- // Hierarchy Tab State
const [hierarchy, setHierarchy] = useState([])
const [expandedNodes, setExpandedNodes] = useState(new Set())
const [selectedItem, setSelectedItem] = useState(null)
- // Catalogs Tab State
const [targetGroups, setTargetGroups] = useState([])
const [skillCategories, setSkillCategories] = useState([])
const [trainingCharacters, setTrainingCharacters] = useState([])
- // Assignments Tab State
const [styleDirections, setStyleDirections] = useState([])
const [assignments, setAssignments] = useState([])
@@ -62,7 +61,7 @@ function AdminHierarchyPage() {
}
function handleToggleNode(nodeId) {
- setExpandedNodes(prev => {
+ setExpandedNodes((prev) => {
const newSet = new Set(prev)
if (newSet.has(nodeId)) {
newSet.delete(nodeId)
@@ -86,33 +85,26 @@ function AdminHierarchyPage() {
loadData()
}
- const tabs = [
- { id: 'hierarchy', label: '🌳 Hierarchie', icon: '🌳' },
- { id: 'catalogs', label: '📋 Kataloge', icon: '📋' },
- { id: 'assignments', label: '🔗 Zuordnungen', icon: '🔗' }
+ const subnavItems = [
+ { id: 'hierarchy', label: 'Hierarchie', icon: TreePine },
+ { id: 'catalogs', label: 'Kataloge', icon: FolderTree },
+ { id: 'assignments', label: 'Zuordnungen', icon: Link2 }
]
return (
-
+
-
Admin: Katalog-Hierarchie
+
+ Katalog & Hierarchie
+
- {/* Tab Navigation */}
-
- {tabs.map(tab => (
- setActiveTab(tab.id)}
- >
- {tab.icon} {tab.label}
-
- ))}
-
-
- {/* Tab Content */}
-
+
{activeTab === 'hierarchy' && (
)}
-
-
-
+
)
}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index e7bab56..86cb278 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -106,31 +106,32 @@ function Dashboard() {
}
return (
-
-
Dashboard
-
- Willkommen, {user?.name || user?.email}!
-
- {profile &&
}
- {/* Welcome Card */}
-
-
Willkommen bei Shinkan Jinkendo
-
- Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
+
+
+
+
+ Dashboard
+
+
+ Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und Vereinsstruktur.
+
+ {profile &&
}
- {user?.id && (
-
-
-
Deine nächsten Trainings
+ {user?.id && (
+
+
+
Deine nächsten Trainings
{trainingHomeErr ? (
{trainingHomeErr}
) : trainingHome?.upcoming?.length ? (
@@ -216,43 +217,6 @@ function Dashboard() {
)}
- {/* Status Grid */}
-
-
-
✅ Fertig
-
- Backend-Basis
- Datenbank-Schema
- Auth-System
- Login & Registrierung
-
-
-
-
-
🚧 In Arbeit
-
- Übungsverwaltung
- Trainingsplanung
- Kataloge (Skills, Methods)
-
-
-
-
-
📋 Geplant
-
- MediaWiki-Import
- Trainingsprogramme
- Admin-Panel
-
-
-
-
- {/* System Info */}
{version && (
System-Information
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index e6e5726..f2f73f1 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -1067,53 +1067,28 @@ function TrainingPlanningPage() {
Einblenden
-
+
setPlanScope('group')}
- style={{
- border: 'none',
- padding: '8px 14px',
- fontWeight: 600,
- fontSize: '0.85rem',
- cursor: selectedGroupId ? 'pointer' : 'not-allowed',
- opacity: selectedGroupId ? 1 : 0.55,
- background: planScope === 'group' ? 'var(--accent-dark)' : 'transparent',
- color: planScope === 'group' ? '#fff' : 'var(--text1)',
- whiteSpace: 'nowrap',
- }}
>
Nur diese Gruppe
setPlanScope('club')}
- style={{
- border: 'none',
- borderLeft: '1.5px solid var(--border2)',
- padding: '8px 14px',
- fontWeight: 600,
- fontSize: '0.85rem',
- cursor: selectedGroupId ? 'pointer' : 'not-allowed',
- opacity: selectedGroupId ? 1 : 0.55,
- background: planScope === 'club' ? 'var(--accent-dark)' : 'transparent',
- color: planScope === 'club' ? '#fff' : 'var(--text1)',
- whiteSpace: 'nowrap',
- }}
>
Ganzer Verein
@@ -1137,16 +1112,39 @@ function TrainingPlanningPage() {
/>
Nur meine Zuordnung (Leitung / Co)
-
- „Ganzer Verein“ bezieht sich auf denselben Verein wie die gewählte Gruppe: Dort siehst du Termine mehrerer Gruppen; neu angelegte Termine gelten weiter für die gesondert gewählte Gruppe.
- {selectedGroupId ? (
-
- Über Trainer oder Trainer zuweisen : Leitung und Co je Einheit bearbeitbar (berechtigt: Vereinsorganisation, Haupt-/Co‑Trainer der Gruppe sowie Erstellung der Einheit).
- Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder ; die Leitung erscheint nicht unter Co‑Trainer.
- Gasttrainer aus anderen Vereinen (Zugriff nur auf eine Session, nicht auf den Verein insgesamt) sind für später vorgesehen.
-
- ) : null}
-
+
+ Neue Termine gelten immer für die gewählte Gruppe. „Ganzer Verein“ zeigt zusätzlich Termine
+ anderer Gruppen desselben Vereins.
+
+
+ Mehr zu Ansicht & Trainerzuordnung
+
+
+ „Ganzer Verein“ bezieht sich auf denselben Verein wie die gewählte Gruppe. Neu angelegte Termine
+ beziehen sich weiterhin auf die Gruppe, die du oben gewählt hast.
+
+ {selectedGroupId ? (
+
+ Über Trainer oder Trainer zuweisen bearbeitest du Leitung und
+ Co je Einheit (berechtigt: Vereinsorganisation, Haupt-/Co‑Trainer der Gruppe sowie Erstellung
+ der Einheit). Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder; die Leitung erscheint
+ nicht unter Co‑Trainer.
+
+ ) : (
+
+ Wähle zuerst eine Gruppe — dann erweitert sich die Hilfe zu Trainer und Berechtigungen.
+
+ )}
+
+
@@ -1172,9 +1170,8 @@ function TrainingPlanningPage() {
Neue Trainingseinheit
- Termin mit Datum, Zeiten und Ablauf (Abschnitte & Übungen) festlegen — optional eine{' '}
- Trainingsvorlage für die Gliederung wählen oder Inhalte aus einem{' '}
- Rahmenprogramm übernehmen.
+ Datum, Zeiten und Ablauf (Abschnitte & Übungen) — optional{' '}
+ Trainingsvorlage oder Inhalte aus einem Rahmenprogramm im Dialog.
{!selectedGroupId && (
@@ -1208,10 +1205,6 @@ function TrainingPlanningPage() {
Aus Rahmen übernehmen…
-
- Vorlage („Ohne Vorlage“ oder gespeicherte Gliederung) stellst du im sich öffnenden Dialog ein; dort auch
- Kalenderdatum und Zeiten.
-
--
2.43.0
From 18a58cb5a53e11422326e3ef0479c69698762c22 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 10:46:40 +0200
Subject: [PATCH 09/22] feat: enhance UI and functionality in Training
Framework pages
- Added new CSS styles for segment buttons and admin assignment matrix, improving layout and responsiveness.
- Refactored AssignmentsTab component to utilize new styles and improve accessibility with aria-labels.
- Introduced collapsible details for framework edit introduction, enhancing user guidance.
- Updated TrainingPlanningPage to streamline button styling and improve visual consistency across components.
---
frontend/src/app.css | 147 +++++++++++++++---
.../src/components/admin/AssignmentsTab.jsx | 94 ++++-------
.../TrainingFrameworkProgramEditPage.jsx | 69 ++++----
.../TrainingFrameworkProgramsListPage.jsx | 21 ++-
frontend/src/pages/TrainingPlanningPage.jsx | 42 ++---
5 files changed, 212 insertions(+), 161 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index cd5bc65..eb93c94 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -1213,6 +1213,22 @@ button.capture-shell__nav-item {
border-left: 1.5px solid var(--border2);
}
+/* Gleich breite Segment-Buttons (z. B. mobile Rahmenprogramm-Tabs) */
+.planning-segment-group--equal {
+ flex: 1;
+ min-width: 0;
+}
+.planning-segment-group--equal .planning-segment-group__btn {
+ flex: 1;
+ min-width: 0;
+}
+
+/* Etwas größere Segmente (Planung: Liste / Kalender) */
+.planning-segment-group--comfort .planning-segment-group__btn {
+ padding: 10px 18px;
+ font-size: 0.92rem;
+}
+
/* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */
.planning-filter-help {
flex: 1 1 100%;
@@ -1246,6 +1262,109 @@ button.capture-shell__nav-item {
}
}
+/* Rahmenprogramm-Editor: Kurz-Einstieg ausklappbar */
+.framework-edit-intro {
+ margin-bottom: 1rem;
+}
+.framework-edit-intro__summary {
+ cursor: pointer;
+ font-size: 0.88rem;
+ font-weight: 600;
+ color: var(--accent-dark);
+ list-style: none;
+ user-select: none;
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 1px dashed var(--border2);
+ background: var(--surface2);
+}
+.framework-edit-intro__summary::-webkit-details-marker {
+ display: none;
+}
+.framework-edit-intro__body {
+ margin-top: 10px;
+ padding: 12px 14px;
+ font-size: 0.88rem;
+ line-height: 1.55;
+ color: var(--text2);
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+}
+@media (prefers-color-scheme: dark) {
+ .framework-edit-intro__summary {
+ color: var(--accent);
+ }
+}
+
+/* Admin: Zuordnungsmatrix (Stilrichtungen ↔ Zielgruppen) */
+.admin-assignments-wrap {
+ background: var(--surface);
+ border-radius: 12px;
+ padding: 20px;
+}
+.admin-assignments-wrap__title {
+ margin-top: 0;
+ font-size: 1.15rem;
+ font-weight: 700;
+}
+.admin-assignments-matrix-container {
+ overflow-x: auto;
+ margin-top: 20px;
+ -webkit-overflow-scrolling: touch;
+}
+.admin-assignments-matrix {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 600px;
+}
+.admin-assignments-matrix th,
+.admin-assignments-matrix td {
+ border: 1px solid var(--border);
+ padding: 12px;
+}
+.admin-assignments-matrix th {
+ background: var(--surface2);
+ font-weight: 600;
+ color: var(--text1);
+}
+.admin-assignments-matrix__corner {
+ position: sticky;
+ left: 0;
+ background: var(--surface);
+ z-index: 2;
+}
+.admin-assignments-matrix__row-label {
+ position: sticky;
+ left: 0;
+ background: var(--surface);
+ z-index: 1;
+ padding: 12px;
+ font-weight: 500;
+}
+.admin-assignments-matrix tbody tr:hover {
+ background: var(--surface2);
+}
+.admin-assignments-matrix__focus-header td {
+ background: var(--surface2);
+ padding: 8px 12px;
+ font-weight: 600;
+ color: var(--text2);
+}
+.admin-assignments-matrix__th-narrow {
+ text-align: center;
+ padding: 12px;
+}
+@media (max-width: 768px) {
+ .admin-assignments-matrix {
+ font-size: 14px;
+ }
+ .admin-assignments-matrix th,
+ .admin-assignments-matrix td {
+ padding: 8px;
+ }
+}
+
/* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */
.admin-shell {
width: 100%;
@@ -2962,36 +3081,26 @@ button.capture-shell__nav-item {
}
.framework-edit__tabbar {
display: flex;
- gap: 6px;
+ align-items: stretch;
+ gap: 8px;
margin-bottom: 14px;
- padding: 2px 0 12px;
+ padding: 6px 0 12px;
border-bottom: 1px solid var(--border);
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
+ position: sticky;
+ top: 0;
+ z-index: 6;
+ background: var(--bg);
}
.framework-edit__tabbar::-webkit-scrollbar {
display: none;
}
-.framework-edit__tab {
- flex: 1 1 0;
+.framework-edit__tabbar .planning-segment-group {
+ flex: 1;
min-width: 0;
- padding: 10px 8px;
- border: 1px solid var(--border2);
- border-radius: 10px;
- background: var(--surface2);
- color: var(--text2);
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- white-space: nowrap;
- font-family: var(--font);
-}
-.framework-edit__tab--active {
- background: var(--accent-light);
- color: var(--accent-dark);
- border-color: var(--accent);
}
.framework-edit__plan-stack {
display: flex;
diff --git a/frontend/src/components/admin/AssignmentsTab.jsx b/frontend/src/components/admin/AssignmentsTab.jsx
index 83e00b6..8fcd875 100644
--- a/frontend/src/components/admin/AssignmentsTab.jsx
+++ b/frontend/src/components/admin/AssignmentsTab.jsx
@@ -5,22 +5,24 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
const [saving, setSaving] = useState(false)
if (loading) {
- return
+ return (
+
+ )
}
async function toggleAssignment(styleDirectionId, targetGroupId, currentlyAssigned) {
setSaving(true)
try {
if (currentlyAssigned) {
- // Find and delete the assignment
const assignment = assignments.find(
- a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
+ (a) => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
)
if (assignment) {
await api.deleteStyleDirectionTargetGroup(assignment.id)
}
} else {
- // Create new assignment
await api.createStyleDirectionTargetGroup({
style_direction_id: styleDirectionId,
target_group_id: targetGroupId,
@@ -37,11 +39,10 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
function isAssigned(styleDirectionId, targetGroupId) {
return assignments.some(
- a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
+ (a) => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
)
}
- // Group style directions by focus area
const groupedStyles = styleDirections.reduce((acc, sd) => {
const key = sd.focus_area_name || 'Ohne Fokusbereich'
if (!acc[key]) acc[key] = []
@@ -50,30 +51,30 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
}, {})
return (
-
-
Zuordnungen: Stilrichtungen ↔ Zielgruppen
- {error &&
{error}
}
+
+
Zuordnungen: Stilrichtungen ↔ Zielgruppen
+ {error &&
{error}
}
{targetGroups.length === 0 && (
-
- Keine Zielgruppen vorhanden. Bitte erst im Tab "Kataloge" anlegen.
+
+ Keine Zielgruppen vorhanden. Bitte zuerst unter Kataloge anlegen.
)}
{styleDirections.length === 0 && (
-
- Keine Stilrichtungen vorhanden. Bitte erst im Tab "Hierarchie" anlegen.
+
+ Keine Stilrichtungen vorhanden. Bitte zuerst unter Hierarchie anlegen.
)}
{targetGroups.length > 0 && styleDirections.length > 0 && (
-
-
+
+
- Stilrichtung
- {targetGroups.map(tg => (
-
+ Stilrichtung
+ {targetGroups.map((tg) => (
+
{tg.name}
))}
@@ -82,17 +83,18 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
{Object.entries(groupedStyles).map(([focusAreaName, styles]) => (
-
-
+
+
{focusAreaName}
- {styles.map(sd => (
+ {styles.map((sd) => (
-
- {sd.name}
-
- {targetGroups.map(tg => {
+ {sd.name}
+ {targetGroups.map((tg) => {
const assigned = isAssigned(sd.id, tg.id)
return (
@@ -101,7 +103,8 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
checked={assigned}
onChange={() => toggleAssignment(sd.id, tg.id, assigned)}
disabled={saving}
- style={{ width: '20px', height: '20px', cursor: 'pointer' }}
+ aria-label={`${sd.name} — ${tg.name}`}
+ style={{ width: '20px', height: '20px', cursor: 'pointer', accentColor: 'var(--accent)' }}
/>
)
@@ -114,45 +117,6 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
)}
-
-
)
}
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 8c2de40..8d7cb57 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -663,52 +663,39 @@ export default function TrainingFrameworkProgramEditPage() {
{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}
-
-
+
+
+ Kurz erklärt: Was ist ein Rahmenprogramm?
+
+
Rahmenprogramm (Bibliothek): Wiederverwendbare Vorlage mit
- Zielen und Session‑Slots. Zuordnung zu Gruppe oder Kalendertermin erfolgt aus der{' '}
+ Zielen und Session‑Slots. Die Zuordnung zu Gruppe oder Kalendertermin erfolgt aus der{' '}
Gruppen‑Planung („Übernahme“). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
Abschnitte , Übungen mit Varianten und Dauer, Zwischen‑Anmerkungen .
-
-
+
+
-
+
+ {[
+ { id: 'meta', label: 'Stammdaten' },
+ { id: 'plan', label: 'Plan (Ziele & Sessions)' },
+ ].map((t) => (
+
- {[
- { id: 'meta', label: 'Stammdaten' },
- { id: 'plan', label: 'Plan (Ziele & Sessions)' },
- ].map((t) => (
- setFrameworkTab(t.id)}
- >
- {t.label}
-
- ))}
+ onClick={() => setFrameworkTab(t.id)}
+ >
+ {t.label}
+
+ ))}
+
-
Trainingsrahmenprogramme
-
- Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
- konkreten Gruppeneinheiten erfolgt aus der Planung der Gruppe (Übernahme
- mit Bezug zum Rahmen).
+
+ Trainingsrahmenprogramme
+
+
+ Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
+
+ Trainingsplanung
+
+ .
+
+ Mehr zur Übernahme in die Planung
+
+ Unter Planung wählst du eine Gruppe und übernimmst Slots aus einem Rahmenprogramm in
+ echte Termine. So bleibt die Bibliothek wiederverwendbar, ohne dass Einzelgruppen fest verdrahtet sind.
+
+
setPlanView('list')}
- style={{
- border: 'none',
- padding: '10px 20px',
- fontWeight: 600,
- fontSize: '0.92rem',
- cursor: 'pointer',
- background: planView === 'list' ? 'var(--accent-dark)' : 'transparent',
- color: planView === 'list' ? '#fff' : 'var(--text1)',
- whiteSpace: 'nowrap',
- }}
+ className={
+ 'planning-segment-group__btn' +
+ (planView === 'list' ? ' planning-segment-group__btn--active' : '')
+ }
>
Liste
@@ -904,17 +891,10 @@ function TrainingPlanningPage() {
return prev || new Date().toISOString().slice(0, 7)
})
}}
- style={{
- border: 'none',
- borderLeft: '1.5px solid var(--border2)',
- padding: '10px 20px',
- fontWeight: 600,
- fontSize: '0.92rem',
- cursor: 'pointer',
- background: planView === 'calendar' ? 'var(--accent-dark)' : 'transparent',
- color: planView === 'calendar' ? '#fff' : 'var(--text1)',
- whiteSpace: 'nowrap',
- }}
+ className={
+ 'planning-segment-group__btn' +
+ (planView === 'calendar' ? ' planning-segment-group__btn--active' : '')
+ }
>
Kalender
@@ -927,8 +907,8 @@ function TrainingPlanningPage() {
- Wähle eine Trainingsgruppe, lege dann Termine mit Inhalt (Abschnitte und Übungen) an — ein Plan entsteht aus einer oder mehreren{' '}
- Trainingseinheiten im gewählten Zeitraum.
+ Wähle eine Trainingsgruppe und lege Trainingseinheiten für den Zeitraum an (Inhalt: Abschnitte
+ und Übungen).
--
2.43.0
From 35a3f6e18d76cd48471e67f26c9aa05133fd2c9c Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 10:55:36 +0200
Subject: [PATCH 10/22] feat: enhance mobile responsiveness and UI components
in app.css
- Added new CSS styles for mobile layout adjustments, improving spacing and readability for various UI elements.
- Implemented horizontal scrollable navigation for main and admin top navigation, enhancing usability on smaller screens.
- Updated button and card styles for better visual consistency and compactness in mobile view.
- Enhanced tab and segment button styles to improve user interaction and accessibility.
---
frontend/src/app.css | 232 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 232 insertions(+)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index eb93c94..e60fc97 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -1181,6 +1181,238 @@ button.capture-shell__nav-item {
background: transparent;
}
+/*
+ * Mobile (≤1023px): geringerer Platzbedarf bei Schaltern und Menüs.
+ * Haupt- + Sub-Navigation: horizontale, scrollbare Chip-Leisten (kein Umbruch).
+ */
+@media (max-width: 1023px) {
+ .app-main {
+ padding-top: 12px;
+ padding-left: max(12px, env(safe-area-inset-left, 0px));
+ padding-right: max(12px, env(safe-area-inset-right, 0px));
+ }
+
+ .page-title {
+ font-size: 1.125rem;
+ margin-bottom: 10px;
+ letter-spacing: -0.02em;
+ }
+
+ .btn {
+ padding: 8px 14px;
+ font-size: 13px;
+ border-radius: 9px;
+ }
+
+ .card {
+ padding: 12px 14px;
+ }
+
+ .card + .card {
+ margin-top: 10px;
+ }
+
+ /* globale Tab-Zeile: bei Bedarf horizontal scrollbar */
+ .tabs {
+ display: flex;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ gap: 2px;
+ padding: 2px;
+ margin-bottom: 12px;
+ border-radius: 8px;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ }
+
+ .tabs::-webkit-scrollbar {
+ display: none;
+ }
+
+ .tab {
+ flex: 0 0 auto;
+ min-height: 38px;
+ padding: 6px 12px;
+ font-size: 12px;
+ border-radius: 7px;
+ }
+
+ /* Capture-Shell / AppSubnavShell: kompakte Chips + Scroll-Snap */
+ .capture-shell__layout {
+ gap: 10px;
+ }
+
+ .capture-shell__nav-wrap {
+ width: 100%;
+ max-width: none;
+ margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
+ margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
+ padding-left: max(12px, env(safe-area-inset-left, 0px));
+ padding-right: max(12px, env(safe-area-inset-right, 0px));
+ box-sizing: border-box;
+ }
+
+ .capture-shell__nav {
+ gap: 5px;
+ padding-bottom: 4px;
+ scroll-snap-type: x proximity;
+ scroll-padding-inline: max(12px, env(safe-area-inset-left, 0px));
+ }
+
+ .capture-shell__nav-item {
+ padding: 6px 11px;
+ min-height: 36px;
+ font-size: 12px;
+ font-weight: 600;
+ gap: 5px;
+ border-radius: 999px;
+ border-width: 1px;
+ scroll-snap-align: start;
+ box-sizing: border-box;
+ }
+
+ .capture-shell__nav-item svg {
+ width: 15px !important;
+ height: 15px !important;
+ }
+
+ /* Admin-Seitenleiste oben: dieselbe Chip-Idee wie Subnav */
+ .admin-top-nav {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ gap: 6px;
+ border-bottom: none;
+ margin-bottom: 14px;
+ padding-bottom: 4px;
+ margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
+ margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
+ padding-left: max(12px, env(safe-area-inset-left, 0px));
+ padding-right: max(12px, env(safe-area-inset-right, 0px));
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ scroll-snap-type: x proximity;
+ }
+
+ .admin-top-nav::-webkit-scrollbar {
+ display: none;
+ }
+
+ .admin-top-nav__link {
+ flex: 0 0 auto;
+ scroll-snap-align: start;
+ min-height: 36px;
+ padding: 6px 11px;
+ font-size: 12px;
+ font-weight: 600;
+ border-bottom: none;
+ margin-bottom: 0;
+ border-radius: 999px;
+ background: var(--surface2);
+ border: 1px solid var(--border2);
+ gap: 5px;
+ box-sizing: border-box;
+ align-items: center;
+ }
+
+ .admin-top-nav__link svg {
+ width: 15px;
+ height: 15px;
+ flex-shrink: 0;
+ }
+
+ .admin-top-nav__link--active {
+ background: var(--accent);
+ color: #fff !important;
+ border-color: var(--accent);
+ }
+
+ .admin-top-nav__link:hover {
+ background: var(--surface);
+ color: var(--text1);
+ }
+
+ .admin-top-nav__link--active:hover {
+ color: #fff !important;
+ background: color-mix(in srgb, var(--accent) 90%, #000);
+ }
+
+ /* Segment-Schalter (Liste/Kalender, Gruppe/Verein, Rahmen-Tabs) */
+ .planning-segment-group {
+ border-radius: 8px;
+ }
+
+ .planning-segment-group__btn {
+ padding: 6px 10px;
+ font-size: 0.78rem;
+ min-height: 34px;
+ box-sizing: border-box;
+ }
+
+ .planning-segment-group--comfort .planning-segment-group__btn {
+ padding: 7px 11px;
+ font-size: 0.8125rem;
+ min-height: 36px;
+ }
+
+ .planning-segment-group--equal .planning-segment-group__btn {
+ min-width: 0;
+ flex: 1 1 0;
+ }
+
+ .framework-edit__tabbar {
+ padding: 4px 0 8px;
+ margin-bottom: 10px;
+ gap: 6px;
+ }
+
+ .framework-edit__tabbar .planning-segment-group__btn {
+ padding: 6px 8px;
+ font-size: 11px;
+ line-height: 1.25;
+ min-height: 34px;
+ }
+
+ .training-planning-create__cta,
+ .training-planning-create__secondary {
+ min-height: 42px;
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+
+ .admin-assignments-wrap {
+ padding: 14px;
+ }
+
+ .admin-assignments-wrap__title {
+ font-size: 1.05rem;
+ }
+
+ /* Framework-Slot-Chips (falls sehr breit): leicht kompakter */
+ .framework-slot-chip {
+ padding: 7px 12px;
+ font-size: 0.8rem;
+ }
+
+ /* Untere Hauptnavigation: etwas kompakter */
+ .bottom-nav {
+ gap: 0;
+ padding-top: 5px;
+ }
+
+ .nav-item {
+ min-width: 52px;
+ max-width: 90px;
+ padding: 1px 2px 3px;
+ gap: 2px;
+ }
+
+ .nav-item span {
+ font-size: 9.5px;
+ }
+}
+
/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
.planning-segment-group {
display: inline-flex;
--
2.43.0
From 8b8602129311124cf4062993c8549c3e7d8bb462 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 11:02:46 +0200
Subject: [PATCH 11/22] feat: update bulk metadata patch functionality for
exercises
- Bumped the version of exercises to 2.8.0, reflecting new features in the bulk metadata patch.
- Enhanced the ExerciseBulkMetadataPatch model to include focus area, style direction, training type, and target group IDs.
- Updated the bulk patch endpoint to support replacing catalog associations for exercises.
- Improved the ExercisesListPage to handle new relation fields and updated UI for bulk operations.
- Adjusted API documentation to reflect changes in the bulk patch functionality.
---
backend/routers/exercises.py | 125 +++++++++++++++++-
backend/version.py | 2 +-
frontend/src/pages/ExercisesListPage.jsx | 155 ++++++++++++++++++++++-
frontend/src/utils/api.js | 2 +-
frontend/src/version.js | 2 +-
5 files changed, 272 insertions(+), 14 deletions(-)
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index e5b0e2b..fc4cef0 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -215,20 +215,35 @@ class ExerciseVariantsReorder(BaseModel):
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
_MAX_BULK_METADATA_IDS = 500
+_MAX_BULK_RELATION_IDS_PER_KIND = 80
class ExerciseBulkMetadataPatch(BaseModel):
- """Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein)."""
+ """Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge)."""
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
status: Optional[str] = None
club_id: Optional[int] = Field(default=None, ge=1)
+ focus_area_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
+ style_direction_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
+ training_type_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
+ target_group_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
@model_validator(mode="after")
- def at_least_visibility_or_status(self):
- if self.visibility is None and self.status is None:
- raise ValueError("Mindestens eines der Felder visibility oder status angeben")
+ def at_least_one_patch_field(self):
+ if (
+ self.visibility is None
+ and self.status is None
+ and self.focus_area_ids is None
+ and self.style_direction_ids is None
+ and self.training_type_ids is None
+ and self.target_group_ids is None
+ ):
+ raise ValueError(
+ "Mindestens eines der Felder visibility, status, focus_area_ids, style_direction_ids, "
+ "training_type_ids oder target_group_ids angeben"
+ )
return self
@@ -456,7 +471,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
return exercise
-def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
+def assign_exercise_relations(
+ cur,
+ conn,
+ exercise_id: int,
+ data: dict,
+ *,
+ do_commit: bool = True,
+):
"""
Weist M:N Relations für eine Übung zu.
Löscht alte Zuordnungen und legt neue an (REPLACE-Logik).
@@ -532,13 +554,59 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
)
)
- conn.commit()
+ if do_commit:
+ conn.commit()
# ============================================================================
# Endpoints
# ============================================================================
+
+def _normalize_bulk_id_list(raw: Optional[list]) -> list[int]:
+ """Positive IDs, Reihenfolge beibehalten, Duplikate entfernen."""
+ if not raw:
+ return []
+ seen: set[int] = set()
+ out: list[int] = []
+ for x in raw:
+ try:
+ xi = int(x)
+ except (TypeError, ValueError):
+ continue
+ if xi < 1 or xi in seen:
+ continue
+ seen.add(xi)
+ out.append(xi)
+ return out
+
+
+def _assert_catalog_ids_exist(cur, kind: str, ids: list[int]) -> None:
+ if not ids:
+ return
+ table_by_kind = {
+ "focus_areas": "focus_areas",
+ "style_directions": "style_directions",
+ "training_types": "training_types",
+ "target_groups": "target_groups",
+ }
+ table = table_by_kind.get(kind)
+ if not table:
+ raise HTTPException(status_code=500, detail="Interner Fehler: unbekannter Katalog")
+ ph = ",".join(["%s"] * len(ids))
+ cur.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", tuple(ids))
+ found = {
+ int(r["id"]) if isinstance(r, dict) else int(r[0])
+ for r in cur.fetchall()
+ }
+ missing = [i for i in ids if i not in found]
+ if missing:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unbekannte {kind}-IDs (Beispiele): {missing[:12]}",
+ )
+
+
def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
"""Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate)."""
seen: set[int] = set()
@@ -577,7 +645,11 @@ def bulk_patch_exercises_metadata(
tenant: TenantContext = Depends(get_tenant_context),
):
"""
- Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal.
+ Ändert Sichtbarkeit, Status und/oder Katalog-Zuordnungen für viele Übungen auf einmal (REPLACE je Kategorie).
+
+ Zuordnung: Sind z. B. focus_area_ids im Body gesetzt, werden die Fokusbereiche bei den bearbeiteten
+ Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
+
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
"""
@@ -603,6 +675,33 @@ def bulk_patch_exercises_metadata(
patch_visibility = body.visibility is not None
patch_status = status_val is not None
+ patch_focus_areas = body.focus_area_ids is not None
+ fa_ids = _normalize_bulk_id_list(body.focus_area_ids or []) if patch_focus_areas else []
+ patch_style_dirs = body.style_direction_ids is not None
+ sd_ids = _normalize_bulk_id_list(body.style_direction_ids or []) if patch_style_dirs else []
+ patch_training_types = body.training_type_ids is not None
+ tt_ids = _normalize_bulk_id_list(body.training_type_ids or []) if patch_training_types else []
+ patch_target_groups = body.target_group_ids is not None
+ tg_ids = _normalize_bulk_id_list(body.target_group_ids or []) if patch_target_groups else []
+
+ relation_data: Dict[str, Any] = {}
+ if patch_focus_areas:
+ relation_data["focus_areas_multi"] = [
+ {"focus_area_id": i, "is_primary": idx == 0} for idx, i in enumerate(fa_ids)
+ ]
+ if patch_style_dirs:
+ relation_data["training_styles_multi"] = [
+ {"training_style_id": i, "is_primary": idx == 0} for idx, i in enumerate(sd_ids)
+ ]
+ if patch_training_types:
+ relation_data["training_types_multi"] = [
+ {"training_type_id": i, "is_primary": idx == 0} for idx, i in enumerate(tt_ids)
+ ]
+ if patch_target_groups:
+ relation_data["target_groups_multi"] = [
+ {"target_group_id": i, "is_primary": idx == 0} for idx, i in enumerate(tg_ids)
+ ]
+
updated: List[int] = []
failed: List[Dict[str, Any]] = []
@@ -612,6 +711,16 @@ def bulk_patch_exercises_metadata(
with get_db() as conn:
cur = get_cursor(conn)
+
+ if patch_focus_areas:
+ _assert_catalog_ids_exist(cur, "focus_areas", fa_ids)
+ if patch_style_dirs:
+ _assert_catalog_ids_exist(cur, "style_directions", sd_ids)
+ if patch_training_types:
+ _assert_catalog_ids_exist(cur, "training_types", tt_ids)
+ if patch_target_groups:
+ _assert_catalog_ids_exist(cur, "target_groups", tg_ids)
+
for ex_id in unique_ids:
cur.execute(
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
@@ -681,6 +790,8 @@ def bulk_patch_exercises_metadata(
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
+ if relation_data:
+ assign_exercise_relations(cur, conn, ex_id, relation_data, do_commit=False)
updated.append(ex_id)
conn.commit()
diff --git a/backend/version.py b/backend/version.py
index ca0e8a0..5902c86 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
+ "exercises": "2.8.0", # PATCH bulk-metadata: Sichtbarkeit/Status + Katalog-Zuordnungen (REPLACE je Feld)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 38603ec..6cacfd9 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -59,6 +59,14 @@ function ExercisesListPage() {
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(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@@ -421,12 +429,27 @@ function ExercisesListPage() {
setBulkStatus('')
setBulkClubSelect('')
setBulkClubManual('')
+ setBulkPatchFocusAreas(false)
+ setBulkFocusAreaIds([])
+ setBulkPatchStyleDirections(false)
+ setBulkStyleDirectionIds([])
+ setBulkPatchTrainingTypes(false)
+ setBulkTrainingTypeIds([])
+ setBulkPatchTargetGroups(false)
+ setBulkTargetGroupIds([])
setBulkModalOpen(true)
}
const handleBulkSubmit = async () => {
- if (!bulkVisibility && !bulkStatus) {
- alert('Bitte mindestens Sichtbarkeit oder Status wählen (nicht „nicht ändern“ bei beiden).')
+ 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)
@@ -441,6 +464,18 @@ function ExercisesListPage() {
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)
@@ -460,6 +495,15 @@ function ExercisesListPage() {
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
@@ -470,6 +514,10 @@ function ExercisesListPage() {
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
})
)
@@ -650,7 +698,7 @@ function ExercisesListPage() {
Auswahl aufheben
- Sichtbarkeit / Status ändern…
+ Massenänderung…
Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext (
@@ -841,7 +889,7 @@ function ExercisesListPage() {
>
- Massenänderung: Sichtbarkeit / Status
+ Massenänderung
+
+ Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen
+ Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt
+ als Primärzuordnung.
+
Sichtbarkeit
+
+
+ Zuordnung (optional)
+
+
Date: Wed, 6 May 2026 11:12:59 +0200
Subject: [PATCH 12/22] feat: enhance admin catalog UI and functionality
- Added new CSS styles for admin catalog sections, improving layout and responsiveness.
- Implemented icon support for catalog section titles, enhancing visual clarity.
- Refactored loading and error states for better user experience in the CatalogsTab and HierarchyTab components.
- Updated AdminCatalogsPage to utilize new styles and improve tab navigation.
- Enhanced accessibility with appropriate ARIA roles and attributes for better usability.
---
frontend/src/app.css | 188 ++++++++++++++++++
frontend/src/components/admin/CatalogsTab.jsx | 130 ++++++++----
.../src/components/admin/HierarchyTab.jsx | 44 ++--
frontend/src/pages/AdminCatalogsPage.jsx | 29 +--
4 files changed, 303 insertions(+), 88 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index e60fc97..ab83190 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -1411,6 +1411,20 @@ button.capture-shell__nav-item {
.nav-item span {
font-size: 9.5px;
}
+
+ .admin-page-subtabs {
+ margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
+ margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
+ padding-left: max(12px, env(safe-area-inset-left, 0px));
+ padding-right: max(12px, env(safe-area-inset-right, 0px));
+ margin-bottom: 14px;
+ border-bottom: none;
+ padding-bottom: 6px;
+ }
+
+ .admin-catalog-section {
+ padding: 14px;
+ }
}
/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
@@ -1461,6 +1475,180 @@ button.capture-shell__nav-item {
font-size: 0.92rem;
}
+/* Admin-Kataloge: Seite „Stammdaten“ — viele Unter-Tabs, Chip-Scroll */
+.admin-page-subtabs {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 6px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ margin-bottom: 18px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ scroll-snap-type: x proximity;
+}
+.admin-page-subtabs::-webkit-scrollbar {
+ display: none;
+}
+.admin-page-subtabs__btn {
+ flex: 0 0 auto;
+ scroll-snap-align: start;
+ margin: 0;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ white-space: nowrap;
+ padding: 8px 13px;
+ border-radius: 999px;
+ border: 1px solid var(--border2);
+ background: var(--surface2);
+ color: var(--text2);
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
+ -webkit-tap-highlight-color: transparent;
+}
+.admin-page-subtabs__btn:hover {
+ border-color: var(--accent);
+ color: var(--text1);
+}
+.admin-page-subtabs__btn--active {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+.admin-page-subtabs__btn--active:hover {
+ color: #fff;
+ background: color-mix(in srgb, var(--accent) 92%, #000);
+}
+@media (min-width: 1024px) {
+ .admin-page-subtabs__btn {
+ font-size: 13px;
+ padding: 9px 15px;
+ }
+}
+
+/* Admin Hierarchy & Catalog Section (Komponenten) */
+.admin-hierarchy-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.admin-hierarchy-pane {
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 14px 16px;
+ background: var(--surface);
+}
+.admin-hierarchy-pane__title {
+ margin-top: 0;
+ margin-bottom: 12px;
+ font-size: 1.15rem;
+ font-weight: 700;
+}
+.admin-hierarchy-back {
+ margin-bottom: 14px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.admin-catalog-stack {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+}
+@media (min-width: 1024px) {
+ .admin-catalog-stack {
+ gap: 1.25rem;
+ }
+}
+.admin-catalog-section {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 16px;
+}
+@media (min-width: 1024px) {
+ .admin-catalog-section {
+ padding: 20px;
+ }
+}
+.admin-catalog-section__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+.admin-catalog-section__title {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 700;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+.admin-catalog-section__icon {
+ flex-shrink: 0;
+ color: var(--accent-dark);
+}
+@media (prefers-color-scheme: dark) {
+ .admin-catalog-section__icon {
+ color: var(--accent);
+ }
+}
+.admin-catalog-inline-form {
+ margin-bottom: 16px;
+ padding: 14px;
+ background: var(--surface2);
+ border-radius: 10px;
+ border: 1px solid var(--border);
+}
+.admin-catalog-inline-form h4 {
+ margin-top: 0;
+ margin-bottom: 12px;
+ font-size: 0.95rem;
+ font-weight: 700;
+}
+.admin-catalog-list {
+ display: grid;
+ gap: 10px;
+}
+.admin-catalog-item {
+ padding: 12px;
+ background: var(--surface2);
+ border-radius: 10px;
+ border: 1px solid var(--border);
+}
+.admin-catalog-item__name-row {
+ margin-bottom: 8px;
+}
+.admin-catalog-meta {
+ margin-left: 10px;
+ color: var(--text3);
+ font-size: 0.875rem;
+}
+.admin-catalog-desc {
+ color: var(--text2);
+ font-size: 0.875rem;
+ margin: 8px 0;
+}
+.admin-catalog-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 10px;
+}
+.admin-catalog-empty {
+ text-align: center;
+ color: var(--text3);
+ padding: 1.25rem;
+ font-size: 0.9rem;
+}
+
/* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */
.planning-filter-help {
flex: 1 1 100%;
diff --git a/frontend/src/components/admin/CatalogsTab.jsx b/frontend/src/components/admin/CatalogsTab.jsx
index 1ffc4ff..35110f6 100644
--- a/frontend/src/components/admin/CatalogsTab.jsx
+++ b/frontend/src/components/admin/CatalogsTab.jsx
@@ -1,18 +1,23 @@
import React, { useState } from 'react'
+import { Target, Tags, Dumbbell } from 'lucide-react'
import { api } from '../../utils/api'
function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loading, error, onUpdate }) {
if (loading) {
- return
+ return (
+
+ )
}
return (
-
- {error &&
{error}
}
+
+ {error &&
{error}
}
{ emptyForm[f.key] = '' })
+ fields.forEach((f) => { emptyForm[f.key] = '' })
setForm(emptyForm)
setCreating(true)
}
function startEdit(item) {
const editForm = {}
- fields.forEach(f => { editForm[f.key] = item[f.key] || '' })
+ fields.forEach((f) => { editForm[f.key] = item[f.key] || '' })
setEditing(item.id)
setForm(editForm)
}
async function handleCreate() {
- const required = fields.filter(f => f.required)
+ const required = fields.filter((f) => f.required)
for (const field of required) {
if (!form[field.key]) {
alert(`${field.label} ist erforderlich`)
@@ -116,75 +121,116 @@ function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, dele
}
return (
-
-
-
{icon} {title}
-
+ Neu
+
+
+
+ {Icon ? (
+
+ ) : null}
+ {title}
+
+
+ + Neu
+
{creating && (
-
-
Neu erstellen
- {fields.map(field => (
+
+
Neu erstellen
+ {fields.map((field) => (
- {field.label} {field.required && '*'}
+
+ {field.label} {field.required && '*'}
+
{field.type === 'textarea' ? (
-
))}
-
-
Erstellen
-
setCreating(false)}>Abbrechen
+
+
+ Erstellen
+
+ setCreating(false)}>
+ Abbrechen
+
)}
-
- {items.map(item => (
-
+
+ {items.map((item) => (
+
{editing === item.id ? (
- {fields.map(field => (
+ {fields.map((field) => (
{field.label}
{field.type === 'textarea' ? (
- setForm({ ...form, [field.key]: e.target.value })} rows={3} />
+ setForm({ ...form, [field.key]: e.target.value })}
+ rows={3}
+ />
) : (
- setForm({ ...form, [field.key]: e.target.value })} />
+ setForm({ ...form, [field.key]: e.target.value })}
+ />
)}
))}
-
-
handleUpdate(item.id)}>Speichern
-
setEditing(null)}>Abbrechen
+
+ handleUpdate(item.id)}>
+ Speichern
+
+ setEditing(null)}>
+ Abbrechen
+
) : (
-
+
{item.name}
- {item.min_age !== null && item.max_age !== null && (
-
+ {item.min_age != null && item.max_age != null && (
+
Alter: {item.min_age}-{item.max_age}
)}
- {item.description &&
{item.description}
}
-
-
startEdit(item)}>Bearbeiten
-
handleDelete(item.id, item.name)}>Löschen
+ {item.description ? (
+
{item.description}
+ ) : null}
+
+ startEdit(item)}>
+ Bearbeiten
+
+ handleDelete(item.id, item.name)}>
+ Löschen
+
)}
))}
{items.length === 0 && !creating && (
-
- Noch keine Einträge vorhanden
-
+
Noch keine Einträge vorhanden
)}
diff --git a/frontend/src/components/admin/HierarchyTab.jsx b/frontend/src/components/admin/HierarchyTab.jsx
index 2b5d724..ad49e9f 100644
--- a/frontend/src/components/admin/HierarchyTab.jsx
+++ b/frontend/src/components/admin/HierarchyTab.jsx
@@ -1,29 +1,24 @@
import React from 'react'
+import { ArrowLeft } from 'lucide-react'
import FocusAreaNode from './FocusAreaNode'
import DetailPanel from './DetailPanel'
function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error, onToggleNode, onSelectItem, onUpdate }) {
if (loading && hierarchy.length === 0) {
- return
+ return (
+
+ )
}
return (
-
- {/* Tree View */}
-
-
Katalog-Hierarchie
- {error &&
{error}
}
+
+
+
Katalog-Hierarchie
+ {error &&
{error}
}
- {hierarchy.map(fa => (
+ {hierarchy.map((fa) => (
- {/* Detail Panel */}
{selectedItem && (
-
+
onSelectItem(null)}
- style={{ marginBottom: '16px' }}
>
- ← Zurück zur Übersicht
+
+ Zurück zur Übersicht
diff --git a/frontend/src/pages/AdminCatalogsPage.jsx b/frontend/src/pages/AdminCatalogsPage.jsx
index 766a378..51d70ad 100644
--- a/frontend/src/pages/AdminCatalogsPage.jsx
+++ b/frontend/src/pages/AdminCatalogsPage.jsx
@@ -316,10 +316,9 @@ export default function AdminCatalogsPage() {
-
Stammdaten-Kataloge
+
Stammdaten-Kataloge
- {/* Tabs */}
-
+
{[
{ id: 'focus-areas', label: 'Fokusbereiche' },
{ id: 'training-styles', label: 'Stilrichtungen' },
@@ -330,30 +329,24 @@ export default function AdminCatalogsPage() {
{ id: 'training-characters', label: 'Trainingscharakter' },
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' }
- ].map(tab => (
+ ].map((tab) => (
setActiveTab(tab.id)}
- className="btn"
- style={{
- borderBottom: activeTab === tab.id ? '3px solid var(--accent)' : 'none',
- borderRadius: 0,
- fontWeight: activeTab === tab.id ? 600 : 400,
- color: activeTab === tab.id ? 'var(--accent)' : 'var(--text2)',
- padding: '12px 16px',
- whiteSpace: 'nowrap'
- }}
+ className={
+ 'admin-page-subtabs__btn' +
+ (activeTab === tab.id ? ' admin-page-subtabs__btn--active' : '')
+ }
>
{tab.label}
))}
- {error && (
-
- {error}
-
- )}
+ {error &&
{error}
}
{loading ? (
--
2.43.0
From 68923b03646199b8e61a9d76835f5a72432f6ac1 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 11:24:44 +0200
Subject: [PATCH 13/22] feat: enhance UI and functionality for Skills and
Exercises pages
- Added new CSS styles for Skills and Exercises pages, improving layout and responsiveness.
- Refactored components to utilize new styles, enhancing visual consistency and user experience.
- Implemented horizontal scrollable navigation for exercises and skills tabs, improving usability on smaller screens.
- Updated button styles and introduced new class names for better maintainability and accessibility.
- Enhanced loading states and empty messages for improved user feedback during data fetching.
---
frontend/src/app.css | 444 ++++++++++++++++++
frontend/src/components/admin/DetailPanel.jsx | 32 +-
.../src/components/admin/FocusAreaNode.jsx | 175 +++----
frontend/src/pages/ExercisesListPage.jsx | 159 +++----
frontend/src/pages/SkillsPage.jsx | 208 ++++----
5 files changed, 706 insertions(+), 312 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index ab83190..bea00ec 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -1425,6 +1425,29 @@ button.capture-shell__nav-item {
.admin-catalog-section {
padding: 14px;
}
+
+ .exercises-page-toolbar-tabs,
+ .skills-page__tabs-scroll {
+ margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
+ margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
+ padding-left: max(12px, env(safe-area-inset-left, 0px));
+ padding-right: max(12px, env(safe-area-inset-right, 0px));
+ padding-bottom: 4px;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ }
+
+ .exercises-page-toolbar-tabs::-webkit-scrollbar,
+ .skills-page__tabs-scroll::-webkit-scrollbar {
+ display: none;
+ }
+
+ .exercises-page-mode-switch,
+ .skills-page-mode-switch {
+ width: max(100%, min(20rem, 100vw - 24px));
+ max-width: none;
+ }
}
/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
@@ -2302,6 +2325,427 @@ button.capture-shell__nav-item {
overscroll-behavior: contain;
}
+.skills-page-modal.admin-modal-sheet {
+ max-width: min(600px, 100vw - 32px);
+}
+
+/* Admin Hierarchie: Detail-Panel */
+.detail-panel__title {
+ margin-top: 0;
+}
+.detail-panel__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 20px;
+}
+.detail-panel__context {
+ padding: 12px;
+ margin-bottom: 20px;
+ border-radius: 8px;
+ background: var(--surface2);
+ color: var(--text2);
+}
+.detail-panel__unknown {
+ padding: 20px;
+ color: var(--text3);
+}
+
+/* Seite Fähigkeiten & Methoden */
+.skills-page__loading {
+ padding: 2rem;
+ text-align: center;
+}
+.skills-page__tabs-scroll {
+ margin-bottom: 1.5rem;
+}
+.skills-page__intro-row {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 1rem;
+}
+.skills-page__intro-row p {
+ margin: 0;
+ flex: 1 1 12rem;
+ color: var(--text2);
+}
+.skills-page__empty {
+ margin: 0;
+ text-align: center;
+ color: var(--text2);
+}
+.skills-page__category {
+ margin-bottom: 2rem;
+}
+.skills-page__category-title {
+ margin: 0 0 1rem;
+ text-transform: capitalize;
+}
+.skills-page__card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1rem;
+}
+.skills-page__card-grid--methods {
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+}
+.skills-page-card {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+.skills-page-card__head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 8px;
+ margin-bottom: 0.5rem;
+}
+.skills-page-card__meta-block {
+ margin-bottom: 0.5rem;
+}
+.skills-page-card__title {
+ margin: 0;
+ font-size: 1rem;
+}
+.skills-page-card__title--method {
+ margin: 0 0 0.25rem;
+}
+.skills-page-card__abbr {
+ color: var(--text2);
+ font-size: 0.875rem;
+ margin-left: 0.5rem;
+ font-weight: 400;
+}
+.skills-page-card__badge {
+ flex-shrink: 0;
+ font-size: 0.875rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ background: var(--accent);
+ color: #fff;
+}
+.skills-page-card__meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+.skills-page-card__chip {
+ font-size: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ background: var(--surface2);
+ color: var(--text2);
+}
+.skills-page-card__desc {
+ margin: 0 0 1rem;
+ color: var(--text2);
+ font-size: 0.875rem;
+}
+.skills-page-card__actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: auto;
+}
+.skills-page-card__grow {
+ flex: 1;
+}
+.skills-page-modal__footer {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 1.5rem;
+}
+.skills-page-modal__submit {
+ flex: 1;
+}
+
+/* Übungsliste: Kopf, Modus-Segmente, Hinweise */
+.exercises-page__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.exercises-page__title {
+ margin: 0;
+}
+.exercises-page-toolbar-tabs {
+ margin-bottom: 14px;
+}
+.exercises-page-mode-switch,
+.skills-page-mode-switch {
+ width: 100%;
+ max-width: min(100%, 28rem);
+}
+.exercise-search-hint {
+ font-size: 12px;
+ color: var(--text3);
+ margin-top: 10px;
+ margin-bottom: 0;
+ line-height: 1.45;
+}
+.exercise-search-hint .btn {
+ margin-left: 6px;
+ vertical-align: middle;
+}
+.exercise-search-bar {
+ margin-bottom: 12px;
+}
+.exercise-search-bar__primary {
+ margin-bottom: 10px;
+}
+.exercise-bulk-toolbar {
+ margin-bottom: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+}
+.exercise-bulk-toolbar__meta {
+ font-size: 12px;
+ color: var(--text3);
+ line-height: 1.4;
+ flex: 1 1 200px;
+}
+.exercises-list-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
+ gap: 12px;
+}
+.exercise-card-layout {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+}
+.exercise-card-layout__check {
+ margin-top: 4px;
+ flex-shrink: 0;
+ accent-color: var(--accent);
+}
+.exercise-card-body-flex {
+ flex: 1;
+ min-width: 0;
+}
+.exercise-card-title {
+ margin: 0 0 8px;
+ font-size: 1.05rem;
+ line-height: 1.3;
+ font-weight: 700;
+}
+.exercise-card-title a {
+ color: inherit;
+ text-decoration: none;
+}
+.exercise-card-title a:hover {
+ color: var(--accent-dark);
+}
+@media (prefers-color-scheme: dark) {
+ .exercise-card-title a:hover {
+ color: var(--accent);
+ }
+}
+.exercise-card-tags {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-bottom: 8px;
+}
+.exercise-card-summary {
+ color: var(--text2);
+ font-size: 13px;
+ line-height: 1.4;
+ margin: 0;
+}
+.exercises-meta-line {
+ font-size: 13px;
+ color: var(--text2);
+ margin: 0 0 10px;
+}
+.exercises-meta-line--muted {
+ color: var(--text3);
+ margin-bottom: 8px;
+}
+.exercises-load-more {
+ text-align: center;
+ margin-top: 16px;
+}
+.exercises-empty-text {
+ margin: 0;
+ color: var(--text2);
+ text-align: center;
+}
+
+/* Admin Hierarchie-Baum (Fokusbereich) */
+.focus-tree-root {
+ margin-bottom: 12px;
+}
+.focus-tree-header {
+ display: flex;
+ align-items: stretch;
+ gap: 2px;
+ padding: 4px 4px 4px 6px;
+ border-radius: 10px;
+ border: 1px solid transparent;
+ background: transparent;
+ transition: background 0.12s, border-color 0.12s;
+}
+.focus-tree-header:hover {
+ background: var(--surface2);
+ border-color: var(--border);
+}
+.focus-tree-header--selected {
+ background: var(--accent);
+ border-color: var(--accent);
+}
+.focus-tree-header--selected:hover {
+ background: color-mix(in srgb, var(--accent) 94%, #000);
+ border-color: var(--accent);
+}
+.focus-tree-toggle {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ min-height: 36px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--text2);
+ cursor: pointer;
+ align-self: center;
+ -webkit-tap-highlight-color: transparent;
+}
+.focus-tree-toggle:hover {
+ background: rgba(0, 0, 0, 0.05);
+}
+.focus-tree-header--selected .focus-tree-toggle {
+ color: #fff;
+}
+.focus-tree-header--selected .focus-tree-toggle:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+.focus-tree-header__label {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px 6px 2px;
+ margin: 0;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-weight: 600;
+ text-align: left;
+ cursor: pointer;
+ border-radius: 8px;
+ -webkit-tap-highlight-color: transparent;
+}
+.focus-tree-emoji {
+ flex-shrink: 0;
+ line-height: 1;
+}
+.focus-tree-children {
+ margin-top: 8px;
+ margin-left: 8px;
+ padding-left: 12px;
+ border-left: 2px solid var(--border);
+}
+.focus-tree-group {
+ margin-bottom: 12px;
+}
+.focus-tree-group:last-child {
+ margin-bottom: 0;
+}
+.focus-tree-group__head {
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: var(--text3);
+ margin-bottom: 6px;
+ text-transform: uppercase;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
+.focus-tree-add-btn {
+ flex-shrink: 0;
+}
+.focus-tree-item {
+ padding: 8px 12px;
+ margin-bottom: 4px;
+ border-radius: 8px;
+ cursor: pointer;
+ background: var(--surface2);
+ color: var(--text1);
+ font-size: 14px;
+ border: 1px solid var(--border);
+ line-height: 1.35;
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
+}
+.focus-tree-item:last-child {
+ margin-bottom: 0;
+}
+.focus-tree-item:hover {
+ border-color: var(--accent);
+}
+.focus-tree-item--selected {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+.focus-tree-item--selected:hover {
+ border-color: var(--accent);
+}
+.focus-tree-item__abbr {
+ margin-left: 8px;
+ font-size: 12px;
+ opacity: 0.85;
+}
+.focus-tree-item__meta {
+ font-size: 11px;
+ margin-top: 4px;
+ line-height: 1.35;
+ opacity: 0.88;
+}
+.focus-tree-item--selected .focus-tree-item__abbr,
+.focus-tree-item--selected .focus-tree-item__meta {
+ opacity: 0.95;
+ color: #fff;
+}
+
+@media (max-width: 1023px) {
+ .exercise-filter-chips-row {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ padding-bottom: 4px;
+ margin-left: -2px;
+ margin-right: -2px;
+ padding-left: 2px;
+ padding-right: 2px;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ }
+ .exercise-filter-chips-row::-webkit-scrollbar {
+ display: none;
+ }
+ .focus-tree-children {
+ margin-left: 4px;
+ padding-left: 8px;
+ }
+}
+
.exercise-filter-modal.admin-modal-sheet {
max-width: min(920px, calc(100dvw - 16px));
}
diff --git a/frontend/src/components/admin/DetailPanel.jsx b/frontend/src/components/admin/DetailPanel.jsx
index 4de3552..d4726be 100644
--- a/frontend/src/components/admin/DetailPanel.jsx
+++ b/frontend/src/components/admin/DetailPanel.jsx
@@ -20,7 +20,7 @@ function DetailPanel({ item, onUpdate, focusAreas }) {
return
}
- return Unbekannter Typ: {type}
+ return Unbekannter Typ: {type}
}
function FocusAreaDetail({ item, onUpdate }) {
@@ -57,7 +57,7 @@ function FocusAreaDetail({ item, onUpdate }) {
return (
-
Fokusbereich bearbeiten
+
Fokusbereich bearbeiten
Name *
setForm({ ...form, name: e.target.value })} />
@@ -81,11 +81,11 @@ function FocusAreaDetail({ item, onUpdate }) {
Inaktiv
-
+
{saving ? 'Speichert...' : 'Speichern'}
- Löschen
+ Löschen
)
@@ -130,7 +130,7 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
return (
-
Stilrichtung bearbeiten
+
Stilrichtung bearbeiten
Name *
setForm({ ...form, name: e.target.value })} />
@@ -163,11 +163,11 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
Inaktiv
-
+
{saving ? 'Speichert...' : 'Speichern'}
- Löschen
+ Löschen
)
@@ -212,7 +212,7 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
return (
-
Trainingstyp bearbeiten
+
Trainingstyp bearbeiten
Name *
setForm({ ...form, name: e.target.value })} />
@@ -245,11 +245,11 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
Inaktiv
-
+
{saving ? 'Speichert...' : 'Speichern'}
- Löschen
+ Löschen
)
@@ -284,8 +284,8 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
return (
-
Neue Stilrichtung erstellen
-
+
Neue Stilrichtung erstellen
+
Fokusbereich: {item.focus_area_name}
@@ -304,7 +304,7 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
Sortierung
setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
-
+
{saving ? 'Erstellt...' : 'Erstellen'}
@@ -342,8 +342,8 @@ function CreateTrainingTypeForm({ item, onUpdate }) {
return (
-
Neuen Trainingstyp erstellen
-
+
Neuen Trainingstyp erstellen
+
Fokusbereich: {item.focus_area_name}
@@ -362,7 +362,7 @@ function CreateTrainingTypeForm({ item, onUpdate }) {
Sortierung
setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
-
+
{saving ? 'Erstellt...' : 'Erstellen'}
diff --git a/frontend/src/components/admin/FocusAreaNode.jsx b/frontend/src/components/admin/FocusAreaNode.jsx
index 104cbef..3f5636f 100644
--- a/frontend/src/components/admin/FocusAreaNode.jsx
+++ b/frontend/src/components/admin/FocusAreaNode.jsx
@@ -1,4 +1,5 @@
import React from 'react'
+import { ChevronDown, ChevronRight } from 'lucide-react'
function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, selectedType }) {
const nodeId = `fa-${focusArea.id}`
@@ -6,82 +7,94 @@ function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, se
const isSelected = selectedType === 'focus_area' && selectedId === focusArea.id
return (
-
- {/* Focus Area Header */}
-
onSelect(focusArea, 'focus_area')}
- style={{
- display: 'flex',
- alignItems: 'center',
- padding: '8px 12px',
- borderRadius: '8px',
- cursor: 'pointer',
- background: isSelected ? 'var(--accent)' : 'transparent',
- color: isSelected ? 'white' : 'var(--text1)',
- fontWeight: 600
- }}
- >
-
{ e.stopPropagation(); onToggle(nodeId) }}
- style={{ marginRight: '8px', cursor: 'pointer', fontSize: '18px' }}
+
+
+ {
+ e.stopPropagation()
+ onToggle(nodeId)
+ }}
>
- {isExpanded ? '▼' : '▶'}
-
- {focusArea.icon}
- {focusArea.name}
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ onSelect(focusArea, 'focus_area')}
+ >
+ {focusArea.icon ? (
+
+ {focusArea.icon}
+
+ ) : null}
+ {focusArea.name}
+
- {/* Children: Style Directions + Training Types */}
{isExpanded && (
-
- {/* Style Directions Section */}
-
-
+
+
+
Stilrichtungen
{
e.stopPropagation()
- onSelect({ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_style_direction')
+ onSelect(
+ { _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name },
+ 'create_style_direction'
+ )
}}
>
+ Neu
- {focusArea.style_directions && focusArea.style_directions.map(sd => (
-
- ))}
+ {focusArea.style_directions &&
+ focusArea.style_directions.map((sd) => (
+
+ ))}
- {/* Training Types Section */}
-
-
+
+
Trainingstypen
{
e.stopPropagation()
- onSelect({ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_training_type')
+ onSelect(
+ { _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name },
+ 'create_training_type'
+ )
}}
>
+ Neu
- {focusArea.training_types && focusArea.training_types.map(tt => (
-
- ))}
+ {focusArea.training_types &&
+ focusArea.training_types.map((tt) => (
+
+ ))}
)}
@@ -92,28 +105,26 @@ function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, se
function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
return (
onSelect(styleDirection, 'style_direction')}
- style={{
- padding: '6px 12px',
- marginBottom: '4px',
- borderRadius: '6px',
- cursor: 'pointer',
- background: isSelected ? 'var(--accent)' : 'var(--surface2)',
- color: isSelected ? 'white' : 'var(--text1)',
- fontSize: '14px'
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onSelect(styleDirection, 'style_direction')
+ }
}}
>
{styleDirection.name}
- {styleDirection.abbreviation && (
-
- ({styleDirection.abbreviation})
-
- )}
- {styleDirection.target_groups && styleDirection.target_groups.length > 0 && (
-
- Zielgruppen: {styleDirection.target_groups.map(tg => tg.name).join(', ')}
+ {styleDirection.abbreviation ? (
+
({styleDirection.abbreviation})
+ ) : null}
+ {styleDirection.target_groups && styleDirection.target_groups.length > 0 ? (
+
+ Zielgruppen: {styleDirection.target_groups.map((tg) => tg.name).join(', ')}
- )}
+ ) : null}
)
}
@@ -121,25 +132,23 @@ function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
function TrainingTypeNode({ trainingType, onSelect, isSelected }) {
return (
onSelect(trainingType, 'training_type')}
- style={{
- padding: '6px 12px',
- marginBottom: '4px',
- borderRadius: '6px',
- cursor: 'pointer',
- background: isSelected ? 'var(--accent)' : 'var(--surface2)',
- color: isSelected ? 'white' : 'var(--text1)',
- fontSize: '14px'
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onSelect(trainingType, 'training_type')
+ }
}}
>
{trainingType.name}
- {trainingType.abbreviation && (
-
- ({trainingType.abbreviation})
-
- )}
+ {trainingType.abbreviation ? (
+ ({trainingType.abbreviation})
+ ) : null}
)
}
-export default FocusAreaNode
+export default FocusAreaNode
\ No newline at end of file
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 6cacfd9..e90a586 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -545,65 +545,64 @@ function ExercisesListPage() {
if (!catalogsReady && pageTab === 'list') {
return (
-
-
-
Lade Kataloge…
+
)
}
return (
-
-
Übungen
+
+
Übungen
{pageTab === 'list' ? (
+ Neu
) : (
-
+
)}
-
-
setPageTab('list')}
- >
- Liste
-
-
setPageTab('progression')}
- >
- Progressionsgraphen
-
+
+
+ setPageTab('list')}
+ >
+ Liste
+
+ setPageTab('progression')}
+ >
+ Progressionsgraphen
+
+
{pageTab === 'progression' ? (
) : (
<>
-
+
{selectedIds.size > 0 ? (
-
+
{selectedIds.size} ausgewählt
-
+
Auswahl aufheben
-
+
Massenänderung…
-
+
Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext (
X-Active-Club-Id
).
@@ -736,7 +725,7 @@ function ExercisesListPage() {
-
+
Zwischen den Bereichen gilt UND . Innerhalb eines Feldes werden mehrere Einträge mit{' '}
ODER verknüpft.
@@ -900,12 +889,12 @@ function ExercisesListPage() {
-
+
Es werden {selectedIds.size} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
Speichern).
-
+
Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen
Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt
als Primärzuordnung.
@@ -1066,7 +1055,7 @@ function ExercisesListPage() {
-
+
-
- Lade Übungen…
+
) : exercises.length === 0 ? (
-
- Keine Übungen gefunden.
-
+
Keine Übungen gefunden.
) : (
<>
{listFetching ? (
- Aktualisiere Treffer…
+ Aktualisiere Treffer…
) : null}
-
+
{exercises.length} angezeigt
{hasMore ? ' · es gibt weitere Einträge' : ''}
-
+
{exercises.map((exercise) => (
-
+
toggleSelect(exercise.id)}
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
- style={{ marginTop: '4px', flexShrink: 0 }}
+ className="exercise-card-layout__check"
/>
-
-
-
+
+
+
{exercise.title}
-
+
{exercise.focus_area && (
{exercise.focus_area}
)}
@@ -1137,7 +1117,7 @@ function ExercisesListPage() {
{exercise.status}
{exercise.summary && (
-
+
{exercise.summary.length > 160
? `${exercise.summary.slice(0, 160)}…`
: exercise.summary}
@@ -1154,12 +1134,7 @@ function ExercisesListPage() {
handleDelete(exercise)}
>
Löschen
@@ -1169,7 +1144,7 @@ function ExercisesListPage() {
))}
{hasMore && (
-
+
{loadingMore ? 'Laden…' : 'Mehr laden'}
diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx
index 09e66ba..ab977a0 100644
--- a/frontend/src/pages/SkillsPage.jsx
+++ b/frontend/src/pages/SkillsPage.jsx
@@ -132,7 +132,7 @@ function SkillsPage() {
if (loading) {
return (
-
+
@@ -143,40 +143,38 @@ function SkillsPage() {
const methodsByCategory = groupByCategory(methods)
return (
-
-
Fähigkeiten & Methoden
+
+
Fähigkeiten & Methoden
- {/* Tabs */}
-
+
+
{['skills', 'methods'].map(tab => (
setActiveTab(tab)}
- style={{
- padding: '0.75rem 1.5rem',
- background: activeTab === tab ? 'var(--accent)' : 'transparent',
- color: activeTab === tab ? 'white' : 'var(--text1)',
- border: 'none',
- borderRadius: '8px 8px 0 0',
- cursor: 'pointer',
- fontWeight: activeTab === tab ? 'bold' : 'normal'
- }}
>
{tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
))}
+
{/* Skills Tab */}
{activeTab === 'skills' && (
<>
-
-
+
+
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
{isAdmin && (
@@ -188,60 +186,46 @@ function SkillsPage() {
{Object.keys(skillsByCategory).length === 0 ? (
-
+
Keine Fähigkeiten gefunden
) : (
Object.keys(skillsByCategory).sort().map(category => (
-
-
+
+
{category}
-
+
{skillsByCategory[category].map(skill => (
-
-
-
{skill.name}
+
+
+
{skill.name}
{skill.importance && (
-
+
⭐ {skill.importance}/5
)}
{skill.description && (
-
+
{skill.description}
)}
{isAdmin && (
-
+
handleEdit(skill, 'skill')}
>
Bearbeiten
handleDelete(skill, 'skill')}
>
Löschen
@@ -260,8 +244,8 @@ function SkillsPage() {
{/* Methods Tab */}
{activeTab === 'methods' && (
<>
-
-
+
+
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
{isAdmin && (
@@ -273,52 +257,36 @@ function SkillsPage() {
{Object.keys(methodsByCategory).length === 0 ? (
-
+
Keine Trainingsmethoden gefunden
) : (
Object.keys(methodsByCategory).sort().map(category => (
-
-
+
+
{category}
-
+
{methodsByCategory[category].map(method => (
-
-
-
+
+
+
{method.name}
{method.abbreviation && (
-
+
({method.abbreviation})
)}
-
+
{method.typical_duration && (
-
+
⏱️ {method.typical_duration} min
)}
{method.typical_group_size && (
-
+
👥 {method.typical_group_size}
)}
@@ -326,27 +294,23 @@ function SkillsPage() {
{method.description && (
-
+
{method.description}
)}
{isAdmin && (
-
+
handleEdit(method, 'method')}
>
Bearbeiten
handleDelete(method, 'method')}
>
Löschen
@@ -364,36 +328,37 @@ function SkillsPage() {
{/* Modal */}
{showModal && isAdmin && (
-
-
-
- {editing
- ? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
- : (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
- }
-
-
-
+ {
+ if (e.target === e.currentTarget) setShowModal(false)
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
+ {editing
+ ? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
+ : (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
+ }
+
+ setShowModal(false)}
+ >
+ Schließen
+
+
+
+
Name *
-
+
Typische Dauer (min)
-
-
+
+
{editing ? 'Speichern' : 'Erstellen'}
+
)}
--
2.43.0
From 5096eec16b6390d52a7a91c57b0086f406ceac58 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 12:20:22 +0200
Subject: [PATCH 14/22] feat: enhance Exercises and Clubs pages with improved
UI and functionality
- Added new utility functions for handling exercise focus areas, style directions, and training types, improving data presentation.
- Refactored ExercisesListPage to utilize new card layouts and improved visibility labels for exercises.
- Updated ClubsPage and SkillsPage to implement a consistent tab navigation style, enhancing user experience.
- Enhanced CSS styles for better responsiveness and visual consistency across various components.
- Improved loading states and accessibility features for better user feedback and interaction.
---
backend/routers/exercises.py | 50 ++++-
frontend/src/app.css | 144 +++++++++++--
.../src/pages/AdminMaturityModelsPage.jsx | 18 +-
frontend/src/pages/ClubsPage.jsx | 70 +++---
frontend/src/pages/ExercisesListPage.jsx | 202 +++++++++++-------
frontend/src/pages/SkillsPage.jsx | 43 ++--
frontend/src/utils/sanitizeHtml.js | 52 +++++
7 files changed, 425 insertions(+), 154 deletions(-)
create mode 100644 frontend/src/utils/sanitizeHtml.js
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index fc4cef0..6ac2b89 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -26,6 +26,24 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"])
+
+def _coerce_json_str_list(val: Any) -> List[str]:
+ """JSON-Aggregat oder JSON-String aus PG in eine saubere str-Liste für die Listen-API."""
+ if val is None:
+ return []
+ if isinstance(val, list):
+ return [str(x) for x in val if x is not None and str(x).strip()]
+ if isinstance(val, str):
+ try:
+ parsed = json.loads(val)
+ if isinstance(parsed, list):
+ return [str(x) for x in parsed if x is not None and str(x).strip()]
+ except Exception:
+ return []
+ return []
+ return []
+
+
# Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029
_CANONICAL_SKILL_LEVELS = frozenset(
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
@@ -971,7 +989,34 @@ def list_exercises(
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
- ) AS primary_focus_name
+ ) AS primary_focus_name,
+ (
+ SELECT COALESCE(
+ json_agg(fa.name ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC),
+ '[]'::json
+ )
+ FROM exercise_focus_areas efa
+ JOIN focus_areas fa ON fa.id = efa.focus_area_id
+ WHERE efa.exercise_id = e.id
+ ) AS focus_area_names,
+ (
+ SELECT COALESCE(
+ json_agg(sd.name ORDER BY esd.is_primary DESC NULLS LAST, sd.name ASC),
+ '[]'::json
+ )
+ FROM exercise_style_directions esd
+ JOIN style_directions sd ON sd.id = esd.style_direction_id
+ WHERE esd.exercise_id = e.id
+ ) AS style_direction_names,
+ (
+ SELECT COALESCE(
+ json_agg(tt.name ORDER BY ett.is_primary DESC NULLS LAST, tt.sort_order NULLS LAST, tt.name ASC),
+ '[]'::json
+ )
+ FROM exercise_training_types ett
+ JOIN training_types tt ON tt.id = ett.training_type_id
+ WHERE ett.exercise_id = e.id
+ ) AS training_type_names
{variants_sql}
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
@@ -990,6 +1035,9 @@ def list_exercises(
d = r2d(r)
pfn = d.get("primary_focus_name")
d["focus_area"] = pfn
+ d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
+ d["style_direction_names"] = _coerce_json_str_list(d.get("style_direction_names"))
+ d["training_type_names"] = _coerce_json_str_list(d.get("training_type_names"))
if include_variants:
v = d.get("variants")
if isinstance(v, str):
diff --git a/frontend/src/app.css b/frontend/src/app.css
index bea00ec..e3a636a 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -1442,12 +1442,6 @@ button.capture-shell__nav-item {
.skills-page__tabs-scroll::-webkit-scrollbar {
display: none;
}
-
- .exercises-page-mode-switch,
- .skills-page-mode-switch {
- width: max(100%, min(20rem, 100vw - 24px));
- max-width: none;
- }
}
/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
@@ -2463,6 +2457,12 @@ button.capture-shell__nav-item {
flex: 1;
}
+.clubs-page__intro {
+ margin: 0 0 1.25rem;
+ max-width: 46rem;
+ line-height: 1.55;
+}
+
/* Übungsliste: Kopf, Modus-Segmente, Hinweise */
.exercises-page__header {
display: flex;
@@ -2478,11 +2478,6 @@ button.capture-shell__nav-item {
.exercises-page-toolbar-tabs {
margin-bottom: 14px;
}
-.exercises-page-mode-switch,
-.skills-page-mode-switch {
- width: 100%;
- max-width: min(100%, 28rem);
-}
.exercise-search-hint {
font-size: 12px;
color: var(--text3);
@@ -2515,14 +2510,24 @@ button.capture-shell__nav-item {
}
.exercises-list-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
- gap: 12px;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
+ gap: 14px;
+ align-items: stretch;
+}
+.exercises-list-grid > .exercise-card {
+ height: 100%;
+ min-height: 0;
}
.exercise-card-layout {
display: flex;
gap: 10px;
align-items: flex-start;
}
+.exercise-card-layout--grow {
+ flex: 1 1 auto;
+ min-height: 0;
+ width: 100%;
+}
.exercise-card-layout__check {
margin-top: 4px;
flex-shrink: 0;
@@ -2562,6 +2567,28 @@ button.capture-shell__nav-item {
line-height: 1.4;
margin: 0;
}
+.exercise-card-summary--rich {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 4;
+ overflow: hidden;
+ word-break: break-word;
+}
+.exercise-card-summary--rich b,
+.exercise-card-summary--rich strong {
+ font-weight: 700;
+ color: var(--text1);
+}
+.exercise-card-summary--rich i,
+.exercise-card-summary--rich em {
+ font-style: italic;
+}
+.exercise-card-summary--rich p {
+ margin: 0 0 0.35em;
+}
+.exercise-card-summary--rich p:last-child {
+ margin-bottom: 0;
+}
.exercises-meta-line {
font-size: 13px;
color: var(--text2);
@@ -3665,7 +3692,31 @@ button.capture-shell__nav-item {
.exercise-card {
display: flex;
flex-direction: column;
- min-height: 200px;
+ min-height: 0;
+ border-left: 4px solid var(--border2);
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+.exercise-card--scope-official {
+ border-left-color: var(--accent);
+ background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--surface)) 0%, var(--surface) 64%);
+}
+.exercise-card--scope-club {
+ border-left-color: var(--warn);
+ background: linear-gradient(180deg, color-mix(in srgb, var(--warn) 10%, var(--surface)) 0%, var(--surface) 64%);
+}
+.exercise-card--scope-private {
+ border-left-color: var(--text3);
+}
+.exercise-card--mine {
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, var(--border));
+}
+@media (prefers-color-scheme: dark) {
+ .exercise-card--scope-official {
+ background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 12%, var(--surface)) 0%, var(--surface) 64%);
+ }
+ .exercise-card--scope-club {
+ background: linear-gradient(180deg, color-mix(in srgb, var(--warn) 12%, var(--surface)) 0%, var(--surface) 64%);
+ }
}
.exercise-card__body {
flex: 1 1 auto;
@@ -3714,10 +3765,51 @@ button.capture-shell__nav-item {
display: flex;
gap: 6px;
flex-wrap: wrap;
- margin-top: 12px;
+ margin-top: auto;
padding-top: 10px;
border-top: 1px solid var(--border);
}
+.exercise-card__actions--icons {
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: nowrap;
+}
+.exercise-card__icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border-radius: 8px;
+ border: 1px solid var(--border2);
+ background: var(--surface2);
+ color: var(--text1);
+ text-decoration: none;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
+ -webkit-tap-highlight-color: transparent;
+}
+.exercise-card__icon-btn:hover {
+ background: var(--surface);
+ border-color: var(--accent);
+ color: var(--accent-dark);
+}
+@media (prefers-color-scheme: dark) {
+ .exercise-card__icon-btn:hover {
+ color: var(--accent);
+ }
+}
+.exercise-card__icon-btn--danger {
+ color: var(--danger);
+ border-color: color-mix(in srgb, var(--danger) 35%, var(--border2));
+}
+.exercise-card__icon-btn--danger:hover {
+ background: color-mix(in srgb, var(--danger) 10%, var(--surface2));
+ border-color: var(--danger);
+ color: var(--danger);
+}
.exercise-card__actions .btn,
.exercise-card__actions a.btn {
flex: 1 1 auto;
@@ -3747,6 +3839,28 @@ button.capture-shell__nav-item {
color: var(--accent-dark);
border-color: transparent;
}
+.exercise-tag--style {
+ background: color-mix(in srgb, var(--accent) 12%, var(--surface2));
+ color: var(--accent-dark);
+ border-color: color-mix(in srgb, var(--accent) 22%, var(--border));
+}
+.exercise-tag--training {
+ background: var(--surface2);
+ color: var(--text1);
+ border-color: var(--border2);
+}
+.exercise-tag--scope {
+ font-weight: 700;
+ background: var(--surface);
+ color: var(--text2);
+}
+.exercise-tag--meta {
+ font-weight: 600;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text3);
+}
.exercise-detail-shell {
max-width: none;
diff --git a/frontend/src/pages/AdminMaturityModelsPage.jsx b/frontend/src/pages/AdminMaturityModelsPage.jsx
index a1087cf..109a29f 100644
--- a/frontend/src/pages/AdminMaturityModelsPage.jsx
+++ b/frontend/src/pages/AdminMaturityModelsPage.jsx
@@ -27,12 +27,14 @@ export default function AdminMaturityModelsPage() {
-
+
setTab('catalog')}
>
Katalog und Hierarchie
@@ -41,7 +43,9 @@ export default function AdminMaturityModelsPage() {
type="button"
role="tab"
aria-selected={tab === 'models'}
- className={'admin-tabs__tab' + (tab === 'models' ? ' admin-tabs__tab--active' : '')}
+ className={
+ 'admin-page-subtabs__btn' + (tab === 'models' ? ' admin-page-subtabs__btn--active' : '')
+ }
onClick={() => setTab('models')}
>
Reifegradmodelle
@@ -50,7 +54,9 @@ export default function AdminMaturityModelsPage() {
type="button"
role="tab"
aria-selected={tab === 'bindings'}
- className={'admin-tabs__tab' + (tab === 'bindings' ? ' admin-tabs__tab--active' : '')}
+ className={
+ 'admin-page-subtabs__btn' + (tab === 'bindings' ? ' admin-page-subtabs__btn--active' : '')
+ }
onClick={() => setTab('bindings')}
>
Kontext-Zuordnung
@@ -59,7 +65,9 @@ export default function AdminMaturityModelsPage() {
type="button"
role="tab"
aria-selected={tab === 'matrixviz'}
- className={'admin-tabs__tab' + (tab === 'matrixviz' ? ' admin-tabs__tab--active' : '')}
+ className={
+ 'admin-page-subtabs__btn' + (tab === 'matrixviz' ? ' admin-page-subtabs__btn--active' : '')
+ }
onClick={() => setTab('matrixviz')}
>
Matrix-Ansicht und Export
diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx
index 92379ec..1cfc127 100644
--- a/frontend/src/pages/ClubsPage.jsx
+++ b/frontend/src/pages/ClubsPage.jsx
@@ -287,52 +287,44 @@ function ClubsPage() {
if (loading) {
return (
-
+
)
}
- return (
-
-
Vereinsverwaltung
-
- Für die Trainingsplanung wird mindestens ein Verein und eine Trainingsgruppe gebraucht.
- Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
-
+ const clubTabIds = canManageOrgSomewhere
+ ? ['clubs', 'divisions', 'groups', 'members']
+ : ['clubs', 'divisions', 'groups']
- {/* Tabs */}
-
- {(canManageOrgSomewhere
- ? ['clubs', 'divisions', 'groups', 'members']
- : ['clubs', 'divisions', 'groups']
- ).map(tab => (
- setActiveTab(tab)}
- style={{
- padding: '0.75rem 1.5rem',
- background: activeTab === tab ? 'var(--accent)' : 'transparent',
- color: activeTab === tab ? 'white' : 'var(--text1)',
- border: 'none',
- borderRadius: '8px 8px 0 0',
- cursor: 'pointer',
- fontWeight: activeTab === tab ? 'bold' : 'normal'
- }}
- >
- {tab === 'clubs' && 'Vereine'}
- {tab === 'divisions' && 'Sparten'}
- {tab === 'groups' && 'Trainingsgruppen'}
- {tab === 'members' && 'Mitglieder'}
-
- ))}
-
+ return (
+
+
Vereinsverwaltung
+
+ Für die Trainingsplanung wird mindestens ein Verein und eine Trainingsgruppe gebraucht.
+ Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
+
+
+
+ {clubTabIds.map((tab) => (
+ setActiveTab(tab)}
+ >
+ {tab === 'clubs' && 'Vereine'}
+ {tab === 'divisions' && 'Sparten'}
+ {tab === 'groups' && 'Trainingsgruppen'}
+ {tab === 'members' && 'Mitglieder'}
+
+ ))}
+
{/* Clubs Tab */}
{activeTab === 'clubs' && (
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index e90a586..2bfaa37 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -1,10 +1,12 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Link } from 'react-router-dom'
+import { Eye, Pencil, Trash2 } from 'lucide-react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
+import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
@@ -22,6 +24,38 @@ const INITIAL_FILTERS = {
status_any: [],
}
+const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
+const STATUS_LABELS = {
+ draft: 'Entwurf',
+ in_review: 'In Prüfung',
+ approved: 'Freigegeben',
+ archived: 'Archiv',
+}
+
+function visibilityLabel(v) {
+ return VIS_LABELS[v] || v || '—'
+}
+
+function statusLabel(s) {
+ return STATUS_LABELS[s] || s || '—'
+}
+
+function exerciseFocusNames(ex) {
+ const fromApi = coerceApiNameList(ex.focus_area_names)
+ if (fromApi.length) return fromApi
+ if (ex.focus_area) return [ex.focus_area]
+ return []
+}
+
+function exerciseCardClassName(exercise, userId) {
+ const vis = exercise.visibility || 'private'
+ const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
+ const mine = userId != null && Number(exercise.created_by) === Number(userId)
+ return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
+ .filter(Boolean)
+ .join(' ')
+}
+
function levelOptionShort(levelStr) {
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
return o ? String(o.level) : String(levelStr)
@@ -569,33 +603,30 @@ function ExercisesListPage() {
)}
-
-
- setPageTab('list')}
- >
- Liste
-
- setPageTab('progression')}
- >
- Progressionsgraphen
-
-
+
+ setPageTab('list')}
+ >
+ Liste
+
+ setPageTab('progression')}
+ >
+ Progressionsgraphen
+
{pageTab === 'progression' ? (
@@ -1093,55 +1124,80 @@ function ExercisesListPage() {
{hasMore ? ' · es gibt weitere Einträge' : ''}
- {exercises.map((exercise) => (
-
-
-
toggleSelect(exercise.id)}
- aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
- className="exercise-card-layout__check"
- />
-
-
-
- {exercise.title}
+ {exercises.map((exercise) => {
+ const focusNames = exerciseFocusNames(exercise)
+ const styleNames = coerceApiNameList(exercise.style_direction_names)
+ const typeNames = coerceApiNameList(exercise.training_type_names)
+ const summaryHtml = exercise.summary
+ ? sanitizeExerciseRichText(exercise.summary)
+ : ''
+ return (
+
+
+
toggleSelect(exercise.id)}
+ aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
+ className="exercise-card-layout__check"
+ />
+
+
+
+ {exercise.title}
+
+
+
+ {focusNames.map((name) => (
+ {name}
+ ))}
+ {styleNames.map((name) => (
+ {name}
+ ))}
+ {typeNames.map((name) => (
+ {name}
+ ))}
+ {visibilityLabel(exercise.visibility)}
+ {statusLabel(exercise.status)}
+
+ {summaryHtml ? (
+
+ ) : null}
+
+
+
+
+
-
-
- {exercise.focus_area && (
- {exercise.focus_area}
- )}
- {exercise.visibility}
- {exercise.status}
-
- {exercise.summary && (
-
- {exercise.summary.length > 160
- ? `${exercise.summary.slice(0, 160)}…`
- : exercise.summary}
-
- )}
+
+
+
+
handleDelete(exercise)}
+ >
+
+
-
-
- Ansehen
-
-
- Bearbeiten
-
- handleDelete(exercise)}
- >
- Löschen
-
-
-
- ))}
+ )
+ })}
{hasMore && (
diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx
index ab977a0..626c5b0 100644
--- a/frontend/src/pages/SkillsPage.jsx
+++ b/frontend/src/pages/SkillsPage.jsx
@@ -146,28 +146,29 @@ function SkillsPage() {
Fähigkeiten & Methoden
-
-
+ setActiveTab('skills')}
>
- {['skills', 'methods'].map(tab => (
- setActiveTab(tab)}
- >
- {tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
-
- ))}
-
+ Fähigkeiten
+
+
setActiveTab('methods')}
+ >
+ Trainingsmethoden
+
{/* Skills Tab */}
diff --git a/frontend/src/utils/sanitizeHtml.js b/frontend/src/utils/sanitizeHtml.js
new file mode 100644
index 0000000..a2b5a31
--- /dev/null
+++ b/frontend/src/utils/sanitizeHtml.js
@@ -0,0 +1,52 @@
+/**
+ * Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute).
+ * Für Anzeige mit dangerouslySetInnerHTML.
+ */
+const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li'])
+
+function cleanTree(parent) {
+ const nodes = Array.from(parent.childNodes)
+ for (const node of nodes) {
+ if (node.nodeType === Node.TEXT_NODE) continue
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ parent.removeChild(node)
+ continue
+ }
+ const tag = node.tagName.toLowerCase()
+ if (!ALLOWED_TAGS.has(tag)) {
+ while (node.firstChild) {
+ parent.insertBefore(node.firstChild, node)
+ }
+ parent.removeChild(node)
+ continue
+ }
+ while (node.attributes.length > 0) {
+ node.removeAttribute(node.attributes[0].name)
+ }
+ cleanTree(node)
+ }
+}
+
+export function sanitizeExerciseRichText(html) {
+ if (html == null || typeof html !== 'string') return ''
+ const trimmed = html.trim()
+ if (!trimmed) return ''
+
+ const tpl = document.createElement('template')
+ tpl.innerHTML = trimmed
+ cleanTree(tpl.content)
+ return tpl.innerHTML
+}
+
+export function coerceApiNameList(value) {
+ if (Array.isArray(value)) return value.map(String).filter((s) => s.trim())
+ if (typeof value === 'string') {
+ try {
+ const p = JSON.parse(value)
+ if (Array.isArray(p)) return p.map(String).filter((s) => s.trim())
+ } catch {
+ return []
+ }
+ }
+ return []
+}
--
2.43.0
From b9ef0395c1f1d981a4fcb59c2ad864b3e92ab8d0 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 12:31:39 +0200
Subject: [PATCH 15/22] feat: enhance app.css and AppSubnavShell for improved
navigation and layout
- Updated CSS styles to refine card spacing in grid layouts, ensuring consistent margins.
- Enhanced the capture-shell component for better responsiveness and sticky navigation across all viewports.
- Improved sub-navigation structure for both mobile and desktop, promoting a unified user experience.
- Added detailed comments in CSS for better clarity on navigation layers and layout intentions.
---
frontend/src/app.css | 61 ++++++++++++++--------
frontend/src/components/AppSubnavShell.jsx | 6 ++-
2 files changed, 44 insertions(+), 23 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index e3a636a..a9ef5e3 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -215,6 +215,11 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
/* Cards */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
.card + .card { margin-top: 12px; }
+/* In CSS-Grids: Abstände nur über gap, nicht über Adjacent-Sibling-Margin */
+.exercises-list-grid > .card + .card,
+.ref-value-tiles-grid > .card + .card {
+ margin-top: 0;
+}
.card-title { font-size: 13px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
/* Stats grid */
@@ -961,7 +966,7 @@ a.analysis-split__nav-item {
padding: 8px 10px;
}
-/* Erfassung: Sub-Navigation (Mobil = Chips, Desktop = linke Spalte) */
+/* Erfassung: Sub-Navigation — oben Chip-Zeile (alle Viewports); wie admin-page-subtabs */
.capture-shell {
width: 100%;
}
@@ -970,11 +975,26 @@ a.analysis-split__nav-item {
display: flex;
flex-direction: column;
gap: 16px;
+ align-items: stretch;
+}
+
+.capture-shell__nav-wrap {
+ width: 100%;
+ min-width: 0;
+ position: sticky;
+ top: var(--header-h);
+ z-index: 4;
+ background: var(--bg);
+ padding-bottom: 4px;
+ margin-bottom: 0;
+ border-bottom: 1px solid var(--border);
}
.capture-shell__nav {
display: flex;
flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
gap: 6px;
overflow-x: auto;
padding-bottom: 6px;
@@ -1036,42 +1056,32 @@ a.analysis-split__nav-item {
.capture-shell__main {
min-width: 0;
+ flex: 1;
}
@media (min-width: 1024px) {
.capture-shell__layout {
- flex-direction: row;
- align-items: flex-start;
- gap: 24px;
+ gap: 20px;
}
.capture-shell__nav-wrap {
- flex: 0 0 260px;
- max-width: 280px;
- position: sticky;
- top: 16px;
- align-self: flex-start;
+ top: var(--header-h);
+ padding-top: 2px;
+ padding-bottom: 10px;
}
.capture-shell__nav {
- flex-direction: column;
+ flex-wrap: wrap;
overflow-x: visible;
- overflow-y: auto;
- max-height: calc(100vh - 140px);
padding-bottom: 0;
gap: 8px;
}
.capture-shell__nav-item {
- width: 100%;
- justify-content: flex-start;
- border-radius: 10px;
- white-space: normal;
- padding: 9px 12px;
- }
-
- .capture-shell__main {
- flex: 1;
+ padding: 9px 14px;
+ border-radius: 999px;
+ font-size: 13px;
+ font-weight: 600;
}
}
@@ -1492,6 +1502,15 @@ button.capture-shell__nav-item {
font-size: 0.92rem;
}
+/* ---------- Navigations-Ebenen (Kurzreferenz) ----------
+ * Hauptbereiche: Bottom-Nav / App-Header.
+ * Sektionsumschalter auf einer Seite (2–n Einträge): horizontale Chip-Zeile oben —
+ * • AppSubnavShell → .capture-shell (sticky unter dem Header, Wrap auf großen Screens)
+ * • viele flache Tabs (z. B. Stammdaten) → .admin-page-subtabs (gleiche Chip-Idee, Edge-Scroll mobil)
+ * Wechsel zwischen Admin-Seiten → .admin-top-nav
+ * „Sub-Sub“ (dritte Ebene, z. B. Editor-Spalten): bewusst in jeweiligen Feature-Layouts (Seitenleiste / Panel).
+ * ---------- */
+
/* Admin-Kataloge: Seite „Stammdaten“ — viele Unter-Tabs, Chip-Scroll */
.admin-page-subtabs {
display: flex;
diff --git a/frontend/src/components/AppSubnavShell.jsx b/frontend/src/components/AppSubnavShell.jsx
index e40342a..637625d 100644
--- a/frontend/src/components/AppSubnavShell.jsx
+++ b/frontend/src/components/AppSubnavShell.jsx
@@ -1,6 +1,8 @@
/**
- * Einheitliche Sub-Navigation (Jinkendo-Muster):
- * Mobil = horizontale Chips, Desktop ≥1024px = linke Spalte (sticky).
+ * Sub-Navigation (einheitlich mit Admin-Sektionsschaltern):
+ * — Mobil & Desktop: eine horizontale Chip-/Registerzeile oben (scroll auf schmalen Viewports).
+ * — Eine darunter liegende „Sub-Sub“-Ebene (z. B. Rahmen bearbeiten) bleibt bewusst
+ * seitlich / in eigenen Layout-Komponenten (z. B. Trainingseinheit, Framework-Editor).
* Nutzt .capture-shell* aus app.css.
*/
export default function AppSubnavShell({
--
2.43.0
From 1e1fd80fb7e3366e19fd6c88258629dfbb394611 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 12:41:04 +0200
Subject: [PATCH 16/22] feat: enhance card layouts and UI components across
multiple pages
- Updated CSS styles to improve card spacing and layout consistency in grid formats.
- Introduced a new card-grid class for better handling of card arrangements in ClubsPage and TrainingFrameworkProgramsListPage.
- Added ExerciseCardScopeStatus component to display visibility and status icons in ExercisesListPage, enhancing user feedback.
- Refactored exercise card actions and footer for improved layout and accessibility.
- Enhanced overall responsiveness and visual clarity across various components.
---
frontend/src/app.css | 80 +++++++++++++++----
frontend/src/pages/ClubsPage.jsx | 12 +--
frontend/src/pages/ExercisesListPage.jsx | 52 +++++++++++-
.../TrainingFrameworkProgramsListPage.jsx | 4 +-
4 files changed, 122 insertions(+), 26 deletions(-)
diff --git a/frontend/src/app.css b/frontend/src/app.css
index a9ef5e3..9946aa7 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -214,12 +214,26 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
/* Cards */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
+/* Vertikaler Rhythmus nur im normalen Blockfluss — in Grids/Flex mit gap stört margin-top zwischen Geschwistern */
.card + .card { margin-top: 12px; }
-/* In CSS-Grids: Abstände nur über gap, nicht über Adjacent-Sibling-Margin */
+ul > li.card + li.card,
.exercises-list-grid > .card + .card,
-.ref-value-tiles-grid > .card + .card {
+.ref-value-tiles-grid > .card + .card,
+.skills-page__card-grid > .card + .card,
+.dashboard-training-grid > .card + .card,
+.framework-slots-board > .card + .card,
+[class*="slots-board"] > .card + .card,
+.card-grid > .card + .card,
+.clubs-groups-card-grid > .card + .card {
margin-top: 0;
}
+/* Optional: Raster für Karten (Abstände nur über gap); Spalten per Modifier oder inline grid-template-columns */
+.card-grid {
+ display: grid;
+ gap: 14px;
+ align-items: stretch;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
+}
.card-title { font-size: 13px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
/* Stats grid */
@@ -1509,6 +1523,7 @@ button.capture-shell__nav-item {
* • viele flache Tabs (z. B. Stammdaten) → .admin-page-subtabs (gleiche Chip-Idee, Edge-Scroll mobil)
* Wechsel zwischen Admin-Seiten → .admin-top-nav
* „Sub-Sub“ (dritte Ebene, z. B. Editor-Spalten): bewusst in jeweiligen Feature-Layouts (Seitenleiste / Panel).
+ * Karten-Raster: .card-grid oder Klassen mit *list-grid* / *slots-board* / .dashboard-training-grid — dort kein .card+.card-Abstand (nur gap).
* ---------- */
/* Admin-Kataloge: Seite „Stammdaten“ — viele Unter-Tabs, Chip-Scroll */
@@ -3779,19 +3794,53 @@ button.capture-shell__nav-item {
line-height: 1.45;
}
+.exercise-card__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-top: auto;
+ padding-top: 10px;
+ border-top: 1px solid var(--border);
+ flex-wrap: nowrap;
+ min-height: 44px;
+ box-sizing: border-box;
+}
+.exercise-card__meta-compact {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ flex-shrink: 0;
+ color: var(--text3);
+ font-size: 0;
+ line-height: 0;
+}
+.exercise-card__meta-glyph {
+ display: inline-flex;
+ color: var(--text3);
+ opacity: 0.9;
+}
+.exercise-card__meta-sep {
+ font-size: 11px;
+ line-height: 1;
+ opacity: 0.45;
+ user-select: none;
+ padding: 0 1px;
+}
.exercise-card__actions {
flex-shrink: 0;
display: flex;
gap: 6px;
flex-wrap: wrap;
- margin-top: auto;
- padding-top: 10px;
- border-top: 1px solid var(--border);
+ margin-top: 0;
+ padding-top: 0;
+ border-top: none;
}
.exercise-card__actions--icons {
justify-content: flex-end;
gap: 8px;
flex-wrap: nowrap;
+ margin-left: auto;
}
.exercise-card__icon-btn {
display: inline-flex;
@@ -3868,17 +3917,18 @@ button.capture-shell__nav-item {
color: var(--text1);
border-color: var(--border2);
}
-.exercise-tag--scope {
- font-weight: 700;
- background: var(--surface);
- color: var(--text2);
+
+/* Liste Rahmenprogramme: Abstand nur über gap (kein .card+.card zwischen li) */
+.framework-programs-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
}
-.exercise-tag--meta {
- font-weight: 600;
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.03em;
- color: var(--text3);
+.framework-programs-list > li.card {
+ margin-bottom: 0;
}
.exercise-detail-shell {
diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx
index 1cfc127..153dce7 100644
--- a/frontend/src/pages/ClubsPage.jsx
+++ b/frontend/src/pages/ClubsPage.jsx
@@ -504,11 +504,13 @@ function ClubsPage() {
) : (
-
+
{groups.map(group => (
{group.name}
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 2bfaa37..807b04d 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -1,6 +1,17 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Link } from 'react-router-dom'
-import { Eye, Pencil, Trash2 } from 'lucide-react'
+import {
+ Eye,
+ Pencil,
+ Trash2,
+ Globe,
+ Users,
+ Lock,
+ CheckCircle2,
+ Archive,
+ CircleDot,
+ FilePenLine,
+} from 'lucide-react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
@@ -56,6 +67,38 @@ function exerciseCardClassName(exercise, userId) {
.join(' ')
}
+function ExerciseCardScopeStatus({ exercise }) {
+ const v = exercise.visibility || 'private'
+ const s = exercise.status || 'draft'
+ const visLabel = visibilityLabel(v)
+ const stLabel = statusLabel(s)
+ const tip = `${visLabel} · ${stLabel}`
+ let VisIcon = Lock
+ if (v === 'official') VisIcon = Globe
+ else if (v === 'club') VisIcon = Users
+ let StatIcon = FilePenLine
+ if (s === 'approved') StatIcon = CheckCircle2
+ else if (s === 'archived') StatIcon = Archive
+ else if (s === 'in_review') StatIcon = CircleDot
+ return (
+
+
+
+
+
+ ·
+
+
+
+
+
+ )
+}
+
function levelOptionShort(levelStr) {
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
return o ? String(o.level) : String(levelStr)
@@ -1157,8 +1200,6 @@ function ExercisesListPage() {
{typeNames.map((name) => (
{name}
))}
-
{visibilityLabel(exercise.visibility)}
-
{statusLabel(exercise.status)}
{summaryHtml ? (
-
)
diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
index d4ba42e..0194980 100644
--- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
@@ -159,9 +159,9 @@ export default function TrainingFrameworkProgramsListPage() {
) : (
-
+
{rows.map((r) => (
-
+
Date: Wed, 6 May 2026 12:49:35 +0200
Subject: [PATCH 17/22] refactor: update navigation components and styles for
improved consistency
- Replaced legacy .capture-shell with .app-subnav-shell and integrated PageSectionNav for a unified navigation experience across multiple pages.
- Refactored AdminCatalogsPage, AdminMaturityModelsPage, ClubsPage, ExercisesListPage, MediaWikiImportPage, SkillsPage, and TrainingFrameworkProgramEditPage to utilize the new PageSectionNav component for tab navigation.
- Enhanced CSS styles for better responsiveness and visual clarity in navigation elements.
- Improved accessibility features with appropriate ARIA roles and attributes for better usability.
---
frontend/src/app.css | 215 ++++--------------
frontend/src/components/AppSubnavShell.jsx | 52 ++---
frontend/src/components/PageSectionNav.jsx | 50 ++++
frontend/src/pages/AdminCatalogsPage.jsx | 46 ++--
.../src/pages/AdminMaturityModelsPage.jsx | 60 ++---
frontend/src/pages/ClubsPage.jsx | 47 ++--
frontend/src/pages/ExercisesListPage.jsx | 37 +--
frontend/src/pages/MediaWikiImportPage.jsx | 40 ++--
frontend/src/pages/SkillsPage.jsx | 37 ++-
.../TrainingFrameworkProgramEditPage.jsx | 29 +--
frontend/src/pages/TrainingPlanningPage.jsx | 81 +++----
11 files changed, 243 insertions(+), 451 deletions(-)
create mode 100644 frontend/src/components/PageSectionNav.jsx
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 9946aa7..de4db83 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -980,132 +980,15 @@ a.analysis-split__nav-item {
padding: 8px 10px;
}
-/* Erfassung: Sub-Navigation — oben Chip-Zeile (alle Viewports); wie admin-page-subtabs */
-.capture-shell {
+/* Legacy .capture-shell entfällt — Sektionsnav: PageSectionNav → .admin-page-subtabs */
+.app-subnav-shell {
width: 100%;
-}
-
-.capture-shell__layout {
display: flex;
flex-direction: column;
gap: 16px;
- align-items: stretch;
}
-
-.capture-shell__nav-wrap {
- width: 100%;
+.app-subnav-shell__main {
min-width: 0;
- position: sticky;
- top: var(--header-h);
- z-index: 4;
- background: var(--bg);
- padding-bottom: 4px;
- margin-bottom: 0;
- border-bottom: 1px solid var(--border);
-}
-
-.capture-shell__nav {
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- align-items: center;
- gap: 6px;
- overflow-x: auto;
- padding-bottom: 6px;
- -ms-overflow-style: none;
- scrollbar-width: none;
-}
-
-.capture-shell__nav::-webkit-scrollbar {
- display: none;
-}
-
-.capture-shell__nav-item {
- flex-shrink: 0;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 7px 12px;
- border-radius: 20px;
- border: 1.5px solid var(--border2);
- background: var(--surface);
- color: var(--text2);
- font-family: var(--font);
- font-size: 13px;
- font-weight: 500;
- text-decoration: none;
- white-space: nowrap;
- cursor: pointer;
- box-sizing: border-box;
-}
-
-.capture-shell__nav-item:hover {
- border-color: var(--accent);
- color: var(--text1);
-}
-
-.capture-shell__nav-item--active {
- border-color: var(--accent);
- background: var(--accent);
- color: white;
-}
-
-.capture-shell__nav-item--active:hover {
- color: white;
-}
-
-.capture-shell__nav-item--highlight:not(.capture-shell__nav-item--active) {
- border-color: #7f77dd88;
- background: #7f77dd14;
-}
-
-.capture-shell__nav-icon {
- font-size: 15px;
- line-height: 1;
-}
-
-.capture-shell__nav-label {
- line-height: 1.2;
-}
-
-.capture-shell__main {
- min-width: 0;
- flex: 1;
-}
-
-@media (min-width: 1024px) {
- .capture-shell__layout {
- gap: 20px;
- }
-
- .capture-shell__nav-wrap {
- top: var(--header-h);
- padding-top: 2px;
- padding-bottom: 10px;
- }
-
- .capture-shell__nav {
- flex-wrap: wrap;
- overflow-x: visible;
- padding-bottom: 0;
- gap: 8px;
- }
-
- .capture-shell__nav-item {
- padding: 9px 14px;
- border-radius: 999px;
- font-size: 13px;
- font-weight: 600;
- }
-}
-
-button.capture-shell__nav-item {
- font-family: inherit;
- text-align: left;
- -webkit-tap-highlight-color: transparent;
-}
-.capture-shell__nav-item svg.capture-shell__nav-icon {
- flex-shrink: 0;
}
/* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */
@@ -1262,46 +1145,6 @@ button.capture-shell__nav-item {
border-radius: 7px;
}
- /* Capture-Shell / AppSubnavShell: kompakte Chips + Scroll-Snap */
- .capture-shell__layout {
- gap: 10px;
- }
-
- .capture-shell__nav-wrap {
- width: 100%;
- max-width: none;
- margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
- margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
- padding-left: max(12px, env(safe-area-inset-left, 0px));
- padding-right: max(12px, env(safe-area-inset-right, 0px));
- box-sizing: border-box;
- }
-
- .capture-shell__nav {
- gap: 5px;
- padding-bottom: 4px;
- scroll-snap-type: x proximity;
- scroll-padding-inline: max(12px, env(safe-area-inset-left, 0px));
- }
-
- .capture-shell__nav-item {
- padding: 6px 11px;
- min-height: 36px;
- font-size: 12px;
- font-weight: 600;
- gap: 5px;
- border-radius: 999px;
- border-width: 1px;
- scroll-snap-align: start;
- box-sizing: border-box;
- }
-
- .capture-shell__nav-item svg {
- width: 15px !important;
- height: 15px !important;
- }
-
- /* Admin-Seitenleiste oben: dieselbe Chip-Idee wie Subnav */
.admin-top-nav {
flex-wrap: nowrap;
overflow-x: auto;
@@ -1391,7 +1234,7 @@ button.capture-shell__nav-item {
gap: 6px;
}
- .framework-edit__tabbar .planning-segment-group__btn {
+ .framework-edit__tabbar .admin-page-subtabs__btn {
padding: 6px 8px;
font-size: 11px;
line-height: 1.25;
@@ -1518,12 +1361,11 @@ button.capture-shell__nav-item {
/* ---------- Navigations-Ebenen (Kurzreferenz) ----------
* Hauptbereiche: Bottom-Nav / App-Header.
- * Sektionsumschalter auf einer Seite (2–n Einträge): horizontale Chip-Zeile oben —
- * • AppSubnavShell → .capture-shell (sticky unter dem Header, Wrap auf großen Screens)
- * • viele flache Tabs (z. B. Stammdaten) → .admin-page-subtabs (gleiche Chip-Idee, Edge-Scroll mobil)
+ * Sektionsumschalter auf einer Seite: PageSectionNav → .admin-page-subtabs (Chips, mobil Edge-Scroll).
+ * AppSubnavShell = PageSectionNav + Inhalt (z. B. Hierarchie-Admin).
* Wechsel zwischen Admin-Seiten → .admin-top-nav
- * „Sub-Sub“ (dritte Ebene, z. B. Editor-Spalten): bewusst in jeweiligen Feature-Layouts (Seitenleiste / Panel).
- * Karten-Raster: .card-grid oder Klassen mit *list-grid* / *slots-board* / .dashboard-training-grid — dort kein .card+.card-Abstand (nur gap).
+ * „Sub-Sub“ (dritte Ebene): Feature-Layouts (Rahmen-Editor, Slots).
+ * Karten-Raster: .card-grid / *list-grid* / *slots-board* — nur gap, kein .card+.card.
* ---------- */
/* Admin-Kataloge: Seite „Stammdaten“ — viele Unter-Tabs, Chip-Scroll */
@@ -1559,6 +1401,19 @@ button.capture-shell__nav-item {
color: var(--text2);
transition: background 0.12s, color 0.12s, border-color 0.12s;
-webkit-tap-highlight-color: transparent;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+}
+.admin-page-subtabs__btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+.admin-page-subtabs__btn:disabled:hover {
+ border-color: var(--border2);
+ color: var(--text2);
+ background: var(--surface2);
}
.admin-page-subtabs__btn:hover {
border-color: var(--accent);
@@ -1578,6 +1433,31 @@ button.capture-shell__nav-item {
font-size: 13px;
padding: 9px 15px;
}
+ .page-section-nav--wrap.admin-page-subtabs {
+ flex-wrap: wrap;
+ }
+}
+
+.page-section-nav__icon {
+ flex-shrink: 0;
+}
+/* Eingebettet in z. B. framework-edit__tabbar — keine zweite Unterlinie */
+.page-section-nav--embedded.admin-page-subtabs {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+ flex: 1;
+ min-width: 0;
+}
+/* Inline neben Labeln (Planung: Ansicht / Einblenden) */
+.page-section-nav--inline.admin-page-subtabs {
+ display: inline-flex;
+ width: auto;
+ max-width: 100%;
+ margin-bottom: 0;
+ flex: 0 1 auto;
+ border-bottom: none;
+ padding-bottom: 0;
}
/* Admin Hierarchy & Catalog Section (Komponenten) */
@@ -4145,7 +4025,8 @@ button.capture-shell__nav-item {
.framework-edit__tabbar::-webkit-scrollbar {
display: none;
}
-.framework-edit__tabbar .planning-segment-group {
+.framework-edit__tabbar .admin-page-subtabs,
+.framework-edit__tabbar .page-section-nav {
flex: 1;
min-width: 0;
}
diff --git a/frontend/src/components/AppSubnavShell.jsx b/frontend/src/components/AppSubnavShell.jsx
index 637625d..6fef51e 100644
--- a/frontend/src/components/AppSubnavShell.jsx
+++ b/frontend/src/components/AppSubnavShell.jsx
@@ -1,9 +1,8 @@
+import PageSectionNav from './PageSectionNav'
+
/**
- * Sub-Navigation (einheitlich mit Admin-Sektionsschaltern):
- * — Mobil & Desktop: eine horizontale Chip-/Registerzeile oben (scroll auf schmalen Viewports).
- * — Eine darunter liegende „Sub-Sub“-Ebene (z. B. Rahmen bearbeiten) bleibt bewusst
- * seitlich / in eigenen Layout-Komponenten (z. B. Trainingseinheit, Framework-Editor).
- * Nutzt .capture-shell* aus app.css.
+ * Sub-Navigation mit Icon-Chips: gleiche Darstellung wie Stammdaten / Vereine (PageSectionNav).
+ * „Sub-Sub“ (z. B. Editor) bleibt in den jeweiligen Feature-Layouts.
*/
export default function AppSubnavShell({
ariaLabel,
@@ -14,39 +13,16 @@ export default function AppSubnavShell({
iconSize = 18,
}) {
return (
-
-
-
-
- {items.map((item) => {
- const Icon = item.icon
- const active = value === item.id
- return (
- onChange(item.id)}
- >
- {Icon ? (
-
- ) : null}
- {item.label}
-
- )
- })}
-
-
-
{children}
-
+
)
}
diff --git a/frontend/src/components/PageSectionNav.jsx b/frontend/src/components/PageSectionNav.jsx
new file mode 100644
index 0000000..8c95513
--- /dev/null
+++ b/frontend/src/components/PageSectionNav.jsx
@@ -0,0 +1,50 @@
+/**
+ * Einheitliche Sektions-Navigation: Chip-Zeile wie Admin-Stammdaten (.admin-page-subtabs).
+ * Für „Tabs“ (role=tablist) oder kompakte Umschalter (aria-pressed, role=group).
+ */
+export default function PageSectionNav({
+ ariaLabel,
+ value,
+ onChange,
+ items,
+ className = '',
+ iconSize = 16,
+ semantics = 'tabs',
+}) {
+ const isToggle = semantics === 'toggle'
+ return (
+
+ {items.map((item) => {
+ const Icon = item.icon
+ const active = value === item.id
+ const disabled = Boolean(item.disabled)
+ return (
+ {
+ if (!disabled) onChange(item.id)
+ }}
+ >
+ {Icon ? (
+
+ ) : null}
+ {item.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/pages/AdminCatalogsPage.jsx b/frontend/src/pages/AdminCatalogsPage.jsx
index 51d70ad..5ee10ef 100644
--- a/frontend/src/pages/AdminCatalogsPage.jsx
+++ b/frontend/src/pages/AdminCatalogsPage.jsx
@@ -1,6 +1,19 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
+import PageSectionNav from '../components/PageSectionNav'
+
+const CATALOG_SUBTABS = [
+ { id: 'focus-areas', label: 'Fokusbereiche' },
+ { id: 'training-styles', label: 'Stilrichtungen' },
+ { id: 'training-types', label: 'Trainingsstil' },
+ { id: 'hierarchy', label: 'Hierarchie' },
+ { id: 'target-groups', label: 'Zielgruppen' },
+ { id: 'target-groups-matrix', label: 'Zuordnungen' },
+ { id: 'training-characters', label: 'Trainingscharakter' },
+ { id: 'skill-categories', label: 'Fähigkeitskategorien' },
+ { id: 'trainer-assignments', label: 'Trainer-Zuordnungen' },
+]
export default function AdminCatalogsPage() {
const [activeTab, setActiveTab] = useState('focus-areas')
@@ -318,33 +331,12 @@ export default function AdminCatalogsPage() {
Stammdaten-Kataloge
-
- {[
- { id: 'focus-areas', label: 'Fokusbereiche' },
- { id: 'training-styles', label: 'Stilrichtungen' },
- { id: 'training-types', label: 'Trainingsstil' },
- { id: 'hierarchy', label: 'Hierarchie' },
- { id: 'target-groups', label: 'Zielgruppen' },
- { id: 'target-groups-matrix', label: 'Zuordnungen' },
- { id: 'training-characters', label: 'Trainingscharakter' },
- { id: 'skill-categories', label: 'Fähigkeitskategorien' },
- { id: 'trainer-assignments', label: 'Trainer-Zuordnungen' }
- ].map((tab) => (
- setActiveTab(tab.id)}
- className={
- 'admin-page-subtabs__btn' +
- (activeTab === tab.id ? ' admin-page-subtabs__btn--active' : '')
- }
- >
- {tab.label}
-
- ))}
-
+
{error &&
{error}
}
diff --git a/frontend/src/pages/AdminMaturityModelsPage.jsx b/frontend/src/pages/AdminMaturityModelsPage.jsx
index 109a29f..357b566 100644
--- a/frontend/src/pages/AdminMaturityModelsPage.jsx
+++ b/frontend/src/pages/AdminMaturityModelsPage.jsx
@@ -6,6 +6,14 @@ import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin'
import MaturityMatrixToolsAdmin from '../components/admin/MaturityMatrixToolsAdmin'
+import PageSectionNav from '../components/PageSectionNav'
+
+const MATURITY_SECTION_TABS = [
+ { id: 'catalog', label: 'Katalog und Hierarchie' },
+ { id: 'models', label: 'Reifegradmodelle' },
+ { id: 'bindings', label: 'Kontext-Zuordnung' },
+ { id: 'matrixviz', label: 'Matrix-Ansicht und Export' },
+]
export default function AdminMaturityModelsPage() {
const { user } = useAuth()
@@ -27,52 +35,12 @@ export default function AdminMaturityModelsPage() {
-
- setTab('catalog')}
- >
- Katalog und Hierarchie
-
- setTab('models')}
- >
- Reifegradmodelle
-
- setTab('bindings')}
- >
- Kontext-Zuordnung
-
- setTab('matrixviz')}
- >
- Matrix-Ansicht und Export
-
-
+
{tab === 'catalog' ? (
diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx
index 153dce7..64347f2 100644
--- a/frontend/src/pages/ClubsPage.jsx
+++ b/frontend/src/pages/ClubsPage.jsx
@@ -1,6 +1,7 @@
-import React, { useState, useEffect } from 'react'
+import React, { useState, useEffect, useMemo } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
+import PageSectionNav from '../components/PageSectionNav'
const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' },
@@ -285,6 +286,19 @@ function ClubsPage() {
setFormData(prev => ({ ...prev, [field]: value }))
}
+ const clubTabItems = useMemo(() => {
+ const ids = canManageOrgSomewhere
+ ? ['clubs', 'divisions', 'groups', 'members']
+ : ['clubs', 'divisions', 'groups']
+ const labels = {
+ clubs: 'Vereine',
+ divisions: 'Sparten',
+ groups: 'Trainingsgruppen',
+ members: 'Mitglieder',
+ }
+ return ids.map((id) => ({ id, label: labels[id] }))
+ }, [canManageOrgSomewhere])
+
if (loading) {
return (
@@ -294,10 +308,6 @@ function ClubsPage() {
)
}
- const clubTabIds = canManageOrgSomewhere
- ? ['clubs', 'divisions', 'groups', 'members']
- : ['clubs', 'divisions', 'groups']
-
return (
Vereinsverwaltung
@@ -306,27 +316,14 @@ function ClubsPage() {
Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
-
- {clubTabIds.map((tab) => (
- setActiveTab(tab)}
- >
- {tab === 'clubs' && 'Vereine'}
- {tab === 'divisions' && 'Sparten'}
- {tab === 'groups' && 'Trainingsgruppen'}
- {tab === 'members' && 'Mitglieder'}
-
- ))}
-
+
- {/* Clubs Tab */}
+ {/* Clubs Tab */}
{activeTab === 'clubs' && (
<>
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 807b04d..e092c46 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -17,10 +17,15 @@ import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
+import PageSectionNav from '../components/PageSectionNav'
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
+const EXERCISES_PAGE_TABS = [
+ { id: 'list', label: 'Liste' },
+ { id: 'progression', label: 'Progressionsgraphen' },
+]
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = {
@@ -646,31 +651,13 @@ function ExercisesListPage() {
)}
-
- setPageTab('list')}
- >
- Liste
-
- setPageTab('progression')}
- >
- Progressionsgraphen
-
-
+
{pageTab === 'progression' ? (
diff --git a/frontend/src/pages/MediaWikiImportPage.jsx b/frontend/src/pages/MediaWikiImportPage.jsx
index 2be6dd5..8b91937 100644
--- a/frontend/src/pages/MediaWikiImportPage.jsx
+++ b/frontend/src/pages/MediaWikiImportPage.jsx
@@ -1,6 +1,14 @@
import React, { useState, useEffect } from 'react'
+import { Eye, Play, History } from 'lucide-react'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
+import PageSectionNav from '../components/PageSectionNav'
+
+const WIKI_IMPORT_TABS = [
+ { id: 'preview', label: 'Vorschau', icon: Eye },
+ { id: 'execute', label: 'Ausführen', icon: Play },
+ { id: 'history', label: 'Historie', icon: History },
+]
export default function MediaWikiImportPage() {
const [activeTab, setActiveTab] = useState('preview')
@@ -111,32 +119,12 @@ export default function MediaWikiImportPage() {
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
- {/* Tabs */}
-
-
- {['preview', 'execute', 'history'].map(tab => (
- setActiveTab(tab)}
- style={{
- padding: '12px 24px',
- background: activeTab === tab ? 'var(--accent)' : 'transparent',
- color: activeTab === tab ? 'white' : 'var(--text1)',
- border: 'none',
- borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
- cursor: 'pointer',
- fontSize: '16px',
- fontWeight: activeTab === tab ? 'bold' : 'normal',
- transition: 'all 0.2s'
- }}
- >
- {tab === 'preview' && '👁️ Vorschau'}
- {tab === 'execute' && '▶️ Ausführen'}
- {tab === 'history' && '📜 Historie'}
-
- ))}
-
-
+
{/* Error Display */}
{error && (
diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx
index 626c5b0..0de662e 100644
--- a/frontend/src/pages/SkillsPage.jsx
+++ b/frontend/src/pages/SkillsPage.jsx
@@ -1,6 +1,12 @@
import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
+import PageSectionNav from '../components/PageSectionNav'
+
+const SKILLS_SECTION_TABS = [
+ { id: 'skills', label: 'Fähigkeiten' },
+ { id: 'methods', label: 'Trainingsmethoden' },
+]
function SkillsPage() {
const { user } = useAuth()
@@ -146,30 +152,13 @@ function SkillsPage() {
Fähigkeiten & Methoden
-
- setActiveTab('skills')}
- >
- Fähigkeiten
-
- setActiveTab('methods')}
- >
- Trainingsmethoden
-
-
+
{/* Skills Tab */}
{activeTab === 'skills' && (
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 8d7cb57..05879ff 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -4,6 +4,7 @@ import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
+import PageSectionNav from '../components/PageSectionNav'
import {
defaultSection,
normalizeUnitToForm,
@@ -675,27 +676,17 @@ export default function TrainingFrameworkProgramEditPage() {
-
-
- {[
+
+
(
- setFrameworkTab(t.id)}
- >
- {t.label}
-
- ))}
-
+ ]}
+ className="page-section-nav--embedded framework-edit__section-nav"
+ />
Ansicht
-
-
setPlanView('list')}
- className={
- 'planning-segment-group__btn' +
- (planView === 'list' ? ' planning-segment-group__btn--active' : '')
- }
- >
- Liste
-
-
{
+ {
+ if (id === 'calendar') {
setPlanView('calendar')
setCalendarMonthStr((prev) => {
const fromList = (startDate || '').slice(0, 7)
if (/^\d{4}-\d{2}$/.test(fromList)) return fromList
return prev || new Date().toISOString().slice(0, 7)
})
- }}
- className={
- 'planning-segment-group__btn' +
- (planView === 'calendar' ? ' planning-segment-group__btn--active' : '')
+ } else {
+ setPlanView('list')
}
- >
- Kalender
-
-
+ }}
+ items={[
+ { id: 'list', label: 'Liste' },
+ { id: 'calendar', label: 'Kalender' },
+ ]}
+ className="page-section-nav--inline planning-ansicht-nav"
+ />
{planView === 'list'
? 'Zeitraum unten mit Von/Bis filtern.'
@@ -1047,32 +1035,17 @@ function TrainingPlanningPage() {
Einblenden
-
- setPlanScope('group')}
- >
- Nur diese Gruppe
-
- setPlanScope('club')}
- >
- Ganzer Verein
-
-
+
Date: Wed, 6 May 2026 12:52:35 +0200
Subject: [PATCH 18/22] style: update navigation styles for improved layout and
consistency
- Adjusted the height and padding of the navigation bar for better alignment with design standards.
- Enhanced the bottom navigation with a new box shadow for improved visual separation.
- Updated nav-item dimensions and styles for better responsiveness and user interaction.
- Increased icon size in navigation items for better visibility and accessibility.
---
frontend/src/App.jsx | 2 +-
frontend/src/app.css | 53 +++++++++++++++++++-------------------------
2 files changed, 24 insertions(+), 31 deletions(-)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index b545339..95c5db3 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -55,7 +55,7 @@ function Nav({ isAdmin }) {
'nav-item' + (navItemActive(loc.pathname, item, isActive) ? ' active' : '')
}
>
-
+
{item.shortLabel || item.label}
))}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index de4db83..058430f 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -12,9 +12,9 @@
--accent-dark: #0a5c43;
--danger: #D85A30;
--warn: #EF9F27;
- /* Höhe der eigentlichen Tab-Zeile (ohne Abstand/Home-Indicator) */
- --nav-h: 56px;
- --nav-pad-top: 8px;
+ /* Höhe der Tab-Zeile (Icon + Beschriftung, ohne Home-Indicator) — an Mitai/iOS-Tabbar angelehnt */
+ --nav-h: 58px;
+ --nav-pad-top: 10px;
--header-h: 52px;
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
--capture-content-max: 800px;
@@ -27,6 +27,9 @@
--text1: #eeecea; --text2: #aaa9a4; --text3: #686762;
--accent-light: #04342C; --accent-dark: #5DCAA5;
}
+ .bottom-nav {
+ box-shadow: 0 -6px 28px rgba(0, 0, 0, 0.55);
+ }
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; }
@@ -168,18 +171,21 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
width: auto;
max-width: none;
display: flex;
- align-items: center;
+ align-items: stretch;
background: var(--surface);
border-top: 1px solid var(--border);
+ box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
z-index: 20;
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
+ scroll-snap-type: x proximity;
+ overscroll-behavior-x: contain;
justify-content: flex-start;
- gap: 2px;
- padding: var(--nav-pad-top) max(6px, env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px) max(6px, env(safe-area-inset-left, 0px));
+ gap: 4px;
+ padding: var(--nav-pad-top) max(10px, env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px) max(10px, env(safe-area-inset-left, 0px));
min-height: calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px));
height: auto;
box-sizing: border-box;
@@ -189,25 +195,29 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
}
.nav-item {
flex: 0 0 auto;
- min-width: 56px;
- max-width: 96px;
+ min-width: 68px;
+ max-width: 108px;
+ min-height: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 3px;
+ gap: 4px;
color: var(--text3);
text-decoration: none;
- font-weight: 500;
+ font-weight: 600;
transition: color 0.15s;
- padding: 2px 4px 4px;
+ padding: 6px 10px 8px;
box-sizing: border-box;
+ scroll-snap-align: center;
+ -webkit-tap-highlight-color: transparent;
}
.nav-item span {
- font-size: 10px;
- line-height: 1.15;
+ font-size: 11px;
+ line-height: 1.2;
text-align: center;
max-width: 100%;
+ letter-spacing: 0.01em;
}
.nav-item.active { color: var(--accent); }
.nav-item svg { flex-shrink: 0; }
@@ -1262,23 +1272,6 @@ a.analysis-split__nav-item {
font-size: 0.8rem;
}
- /* Untere Hauptnavigation: etwas kompakter */
- .bottom-nav {
- gap: 0;
- padding-top: 5px;
- }
-
- .nav-item {
- min-width: 52px;
- max-width: 90px;
- padding: 1px 2px 3px;
- gap: 2px;
- }
-
- .nav-item span {
- font-size: 9.5px;
- }
-
.admin-page-subtabs {
margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
--
2.43.0
From 585ee8c90ded8694e7c6eb015135d79802465ff1 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 13:52:24 +0200
Subject: [PATCH 19/22] feat: enhance exercise management features and UI
- Introduced new function `club_admin_shares_club_with_creator` to check club admin permissions for shared clubs.
- Updated `can_manage_club_org` to incorporate new role checks.
- Enhanced exercise deletion logic to include checks for club admin roles and shared club memberships.
- Added new filters for exercise visibility and status in the ExercisesListPage, allowing users to exclude specific criteria.
- Implemented functionality to save user-specific exercise list preferences, improving user experience.
- Updated API interactions to support new filtering options and preferences for exercise management.
---
backend/club_tenancy.py | 27 +++
.../043_profiles_exercise_list_prefs.sql | 3 +
backend/models.py | 6 +-
backend/routers/exercises.py | 176 +++++++++++++++---
backend/routers/profiles.py | 11 ++
backend/tests/test_exercises_delete_policy.py | 131 +++++++++++++
backend/version.py | 19 +-
.../src/components/ExercisePickerModal.jsx | 64 +++++--
frontend/src/constants/exerciseListFilters.js | 54 ++++++
frontend/src/pages/ExercisesListPage.jsx | 154 +++++++++++++--
frontend/src/utils/api.js | 2 +-
frontend/src/utils/exercisePermissions.js | 27 +++
frontend/src/version.js | 4 +-
13 files changed, 619 insertions(+), 59 deletions(-)
create mode 100644 backend/migrations/043_profiles_exercise_list_prefs.sql
create mode 100644 backend/tests/test_exercises_delete_policy.py
create mode 100644 frontend/src/constants/exerciseListFilters.js
create mode 100644 frontend/src/utils/exercisePermissions.js
diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py
index e81e374..4da0747 100644
--- a/backend/club_tenancy.py
+++ b/backend/club_tenancy.py
@@ -52,6 +52,33 @@ def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool:
return cur.fetchone() is not None
+def club_admin_shares_club_with_creator(
+ cur, club_admin_profile_id: int, creator_profile_id: int
+) -> bool:
+ """
+ True, wenn club_admin_profile_id in mindestens einem Verein die Rolle club_admin hat und
+ creator_profile_id dort ebenfalls aktives Mitglied ist (z. B. Löschen fremder privater Übungen).
+ """
+ if club_admin_profile_id == creator_profile_id:
+ return False
+ cur.execute(
+ """
+ SELECT 1
+ FROM club_members cm_admin
+ INNER JOIN club_member_roles r
+ ON r.club_member_id = cm_admin.id AND r.role_code = 'club_admin'
+ INNER JOIN club_members cm_creator
+ ON cm_creator.club_id = cm_admin.club_id
+ AND cm_creator.profile_id = %s
+ AND cm_creator.status = 'active'
+ WHERE cm_admin.profile_id = %s AND cm_admin.status = 'active'
+ LIMIT 1
+ """,
+ (creator_profile_id, club_admin_profile_id),
+ )
+ return cur.fetchone() is not None
+
+
def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
"""Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin."""
if is_platform_admin(global_role):
diff --git a/backend/migrations/043_profiles_exercise_list_prefs.sql b/backend/migrations/043_profiles_exercise_list_prefs.sql
new file mode 100644
index 0000000..02180b7
--- /dev/null
+++ b/backend/migrations/043_profiles_exercise_list_prefs.sql
@@ -0,0 +1,3 @@
+-- Gespeicherte Standard-Filter für die Übungsliste (pro Nutzer)
+ALTER TABLE profiles
+ ADD COLUMN IF NOT EXISTS exercise_list_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;
diff --git a/backend/models.py b/backend/models.py
index 791d48c..68f0ac5 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -4,7 +4,7 @@ Pydantic Models for Shinkan Jinkendo API
Request/Response schemas for all endpoints
"""
from pydantic import BaseModel, EmailStr, Field
-from typing import Optional, List
+from typing import Optional, List, Dict, Any
from datetime import date, time, datetime
# ============================================================================
@@ -43,6 +43,10 @@ class ProfileUpdate(BaseModel):
description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)",
)
tier: Optional[str] = Field(default=None, max_length=50)
+ exercise_list_prefs: Optional[Dict[str, Any]] = Field(
+ default=None,
+ description="JSON: gespeicherte Standardfilter für die Übungsliste",
+ )
class ProfileResponse(BaseModel):
id: int
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 6ac2b89..86fa2df 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -17,6 +17,8 @@ from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
from club_tenancy import (
assert_valid_governance_visibility,
+ club_admin_shares_club_with_creator,
+ has_club_role,
is_platform_admin,
library_content_visible_to_profile,
)
@@ -232,6 +234,8 @@ class ExerciseVariantsReorder(BaseModel):
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
+_LIST_FILTER_VISIBILITY = frozenset({"private", "club", "official"})
+_LIST_FILTER_STATUS = frozenset({"draft", "in_review", "approved", "archived"})
_MAX_BULK_METADATA_IDS = 500
_MAX_BULK_RELATION_IDS_PER_KIND = 80
@@ -657,6 +661,96 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
return out
+def _normalize_choice_list(raw: list[str], allowed: frozenset, label: str) -> list[str]:
+ out = []
+ seen = set()
+ for x in raw or []:
+ s = str(x).strip().lower()
+ if not s or s in seen:
+ continue
+ if s not in allowed:
+ raise HTTPException(status_code=400, detail=f"Ungültiger Wert in {label}")
+ seen.add(s)
+ out.append(s)
+ return out
+
+
+def _exercise_delete_usage_counts(cur, exercise_id: int) -> dict:
+ cur.execute(
+ """
+ SELECT
+ (SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items,
+ (SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items,
+ (SELECT COUNT(*)::int FROM exercise_progression_edges
+ WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges
+ """,
+ (exercise_id, exercise_id, exercise_id, exercise_id),
+ )
+ row = cur.fetchone()
+ return dict(row) if row else {"block_items": 0, "section_items": 0, "prog_edges": 0}
+
+
+def _exercise_delete_usage_message(counts: dict) -> str:
+ bi = int(counts.get("block_items") or 0)
+ si = int(counts.get("section_items") or 0)
+ pe = int(counts.get("prog_edges") or 0)
+ parts = []
+ if bi:
+ parts.append(f"{bi}× in Übungsblöcken")
+ if si:
+ parts.append(f"{si}× in Trainingsplänen oder Rahmenabläufen")
+ if pe:
+ parts.append(f"{pe}× in Progressionsgraphen (Kanten)")
+ if not parts:
+ return ""
+ return (
+ "Die Übung wird noch verwendet und kann nicht gelöscht werden. Bitte auf „archiviert“ setzen. "
+ "Verwendung: " + ", ".join(parts) + "."
+ )
+
+
+def _assert_can_delete_exercise(cur, tenant: TenantContext, row: dict) -> None:
+ pid = tenant.profile_id
+ role = tenant.global_role
+ if is_platform_admin(role):
+ return
+ vis = str(row.get("visibility") or "private").strip().lower()
+ cid = row.get("club_id")
+ creator = row.get("created_by")
+ try:
+ creator_int = int(creator) if creator is not None else None
+ except (TypeError, ValueError):
+ creator_int = None
+
+ if vis == "official":
+ raise HTTPException(
+ status_code=403,
+ detail="Globale Übungen dürfen nur von Plattform-Admins gelöscht werden.",
+ )
+ if vis == "club":
+ try:
+ ex_club = int(cid) if cid is not None else None
+ except (TypeError, ValueError):
+ ex_club = None
+ if ex_club is None:
+ raise HTTPException(status_code=400, detail="Vereins-Übung ohne gültige Vereinszuordnung")
+ if not has_club_role(cur, pid, ex_club, "club_admin"):
+ raise HTTPException(
+ status_code=403,
+ detail="Nur Vereins-Admins dürfen Vereins-Übungen löschen.",
+ )
+ return
+
+ if creator_int is not None and creator_int == pid:
+ return
+ if creator_int is not None and club_admin_shares_club_with_creator(cur, pid, creator_int):
+ return
+ raise HTTPException(
+ status_code=403,
+ detail="Keine Berechtigung zum Löschen dieser Übung.",
+ )
+
+
@router.patch("/exercises/bulk-metadata")
def bulk_patch_exercises_metadata(
body: ExerciseBulkMetadataPatch,
@@ -850,6 +944,20 @@ def list_exercises(
default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
),
+ visibility_exclude_any: list[str] = Query(
+ default=[], description="Keine dieser Sichtbarkeiten (Negativliste)"
+ ),
+ status_exclude_any: list[str] = Query(
+ default=[], description="Keiner dieser Statuswerte (Negativliste)"
+ ),
+ exclude_without_focus: bool = Query(
+ default=False,
+ description="Wenn true: nur Übungen mit mindestens einem Fokusbereich",
+ ),
+ include_archived: bool = Query(
+ default=False,
+ description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
+ ),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
@@ -889,6 +997,36 @@ def list_exercises(
where.append(f"e.status IN ({ph})")
params.extend(st_list)
+ includes_archived = any(str(x).strip().lower() == "archived" for x in st_list)
+ if not include_archived and not includes_archived:
+ where.append("COALESCE(e.status, '') <> %s")
+ params.append("archived")
+
+ vis_excl = _normalize_choice_list(
+ list(visibility_exclude_any),
+ _LIST_FILTER_VISIBILITY,
+ "visibility_exclude_any",
+ )
+ if vis_excl:
+ ph = ",".join(["%s"] * len(vis_excl))
+ where.append(f"(e.visibility IS NULL OR LOWER(TRIM(e.visibility::text)) NOT IN ({ph}))")
+ params.extend(vis_excl)
+
+ st_excl = _normalize_choice_list(
+ list(status_exclude_any),
+ _LIST_FILTER_STATUS,
+ "status_exclude_any",
+ )
+ if st_excl:
+ ph = ",".join(["%s"] * len(st_excl))
+ where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
+ params.extend(st_excl)
+
+ if exclude_without_focus:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
+ )
+
fa_ids = _merge_ids(focus_area_ids, focus_area)
if fa_ids:
ph = ",".join(["%s"] * len(fa_ids))
@@ -1241,38 +1379,32 @@ def delete_exercise(
):
"""
Löscht eine Übung.
- Nur Owner oder Admin darf löschen.
- """
- profile_id = tenant.profile_id
- role = tenant.global_role
+ Berechtigung: Plattform-Admin (alle); Vereins-Admin Vereins-Übungen seines Vereins;
+ Ersteller nur eigene private Übungen; Vereins-Admin zusätzlich private Übungen von Mitgliedern,
+ mit denen er einen Verein teilt.
+
+ Bei Verwendung in Blöcken, Trainingsplänen oder Progressionsgraphen: 409 — bitte archivieren.
+ """
with get_db() as conn:
cur = get_cursor(conn)
- # Existiert die Übung?
- cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
+ cur.execute(
+ "SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
+ (exercise_id,),
+ )
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
+ ex = r2d(row)
- # Permission Check
- if _row_created_by(row) != profile_id and not is_platform_admin(role):
- raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
+ _assert_can_delete_exercise(cur, tenant, ex)
- # Prüfen ob Übung in Block-Items verwendet wird
- cur.execute(
- "SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s",
- (exercise_id,)
- )
- crow = cur.fetchone()
- count = crow["cnt"] if isinstance(crow, dict) else crow[0]
- if count > 0:
- raise HTTPException(
- status_code=409,
- detail=f"Übung wird in {count} Block-Item(s) verwendet und kann nicht gelöscht werden"
- )
+ counts = _exercise_delete_usage_counts(cur, exercise_id)
+ usage_msg = _exercise_delete_usage_message(counts)
+ if usage_msg:
+ raise HTTPException(status_code=409, detail=usage_msg)
- # DELETE (Cascade löscht M:N Zuordnungen automatisch)
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
conn.commit()
diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py
index 9825854..e6cefec 100644
--- a/backend/routers/profiles.py
+++ b/backend/routers/profiles.py
@@ -9,6 +9,8 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends
+from psycopg2.extras import Json
+
from db import get_db, get_cursor, r2d
from auth import require_auth, hash_pin
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
@@ -258,6 +260,15 @@ def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> di
assert_club_member(cur, int(pid), cid)
data["active_club_id"] = cid
+ if "exercise_list_prefs" in patch:
+ ep = patch.pop("exercise_list_prefs")
+ if ep is None:
+ data["exercise_list_prefs"] = Json({})
+ elif isinstance(ep, dict):
+ data["exercise_list_prefs"] = Json(ep)
+ else:
+ raise HTTPException(400, "exercise_list_prefs muss ein JSON-Objekt sein")
+
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items():
if k == "email":
diff --git a/backend/tests/test_exercises_delete_policy.py b/backend/tests/test_exercises_delete_policy.py
new file mode 100644
index 0000000..a3296fb
--- /dev/null
+++ b/backend/tests/test_exercises_delete_policy.py
@@ -0,0 +1,131 @@
+"""
+DELETE /api/exercises/{id}: Mandanten-/Rollenlogik und Verwendungsblock (409).
+
+TestClient mit Overrides für Auth und TenantContext; DB via get_db/get_cursor gemockt.
+"""
+from __future__ import annotations
+
+import os
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ.setdefault("SKIP_DB_MIGRATE", "1")
+
+from auth import require_auth
+from main import app
+from tenant_context import TenantContext, get_tenant_context
+
+
+@pytest.fixture
+def client() -> TestClient:
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def _clear_overrides() -> None:
+ yield
+ app.dependency_overrides.pop(require_auth, None)
+ app.dependency_overrides.pop(get_tenant_context, None)
+
+
+def _mock_db_cm(mock_cur: MagicMock):
+ mock_conn = MagicMock()
+ mock_cm = MagicMock()
+ mock_cm.__enter__.return_value = mock_conn
+ mock_cm.__exit__.return_value = False
+ return mock_cm
+
+
+def test_delete_trainer_private_own_ok(client: TestClient) -> None:
+ mock_cur = MagicMock()
+ mock_cur.fetchone.side_effect = [
+ {"id": 7, "created_by": 42, "visibility": "private", "club_id": None},
+ {"block_items": 0, "section_items": 0, "prog_edges": 0},
+ ]
+ mock_cm = _mock_db_cm(mock_cur)
+
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=42,
+ global_role="trainer",
+ effective_club_id=5,
+ club_ids=frozenset({5}),
+ memberships=[],
+ )
+ with patch("routers.exercises.get_db", return_value=mock_cm), patch(
+ "routers.exercises.get_cursor", return_value=mock_cur
+ ):
+ r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
+ assert r.status_code == 200
+ assert r.json().get("ok") is True
+
+
+def test_delete_trainer_club_exercise_forbidden_without_club_admin(client: TestClient) -> None:
+ mock_cur = MagicMock()
+ mock_cur.fetchone.side_effect = [
+ {"id": 7, "created_by": 42, "visibility": "club", "club_id": 5},
+ ]
+ mock_cm = _mock_db_cm(mock_cur)
+
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=42,
+ global_role="trainer",
+ effective_club_id=5,
+ club_ids=frozenset({5}),
+ memberships=[],
+ )
+ with patch("routers.exercises.get_db", return_value=mock_cm), patch(
+ "routers.exercises.get_cursor", return_value=mock_cur
+ ), patch("routers.exercises.has_club_role", return_value=False):
+ r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
+ assert r.status_code == 403
+
+
+def test_delete_usage_returns_409(client: TestClient) -> None:
+ mock_cur = MagicMock()
+ mock_cur.fetchone.side_effect = [
+ {"id": 7, "created_by": 42, "visibility": "private", "club_id": None},
+ {"block_items": 1, "section_items": 2, "prog_edges": 3},
+ ]
+ mock_cm = _mock_db_cm(mock_cur)
+
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=42,
+ global_role="trainer",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ )
+ with patch("routers.exercises.get_db", return_value=mock_cm), patch(
+ "routers.exercises.get_cursor", return_value=mock_cur
+ ):
+ r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
+ assert r.status_code == 409
+ detail = r.json().get("detail", "")
+ assert "Übungsblöcken" in detail or "Trainingsplänen" in detail
+
+
+def test_delete_official_forbidden_non_platform_admin(client: TestClient) -> None:
+ mock_cur = MagicMock()
+ mock_cur.fetchone.side_effect = [
+ {"id": 99, "created_by": 1, "visibility": "official", "club_id": None},
+ ]
+ mock_cm = _mock_db_cm(mock_cur)
+
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=42,
+ global_role="trainer",
+ effective_club_id=None,
+ club_ids=frozenset(),
+ memberships=[],
+ )
+ with patch("routers.exercises.get_db", return_value=mock_cm), patch(
+ "routers.exercises.get_cursor", return_value=mock_cur
+ ):
+ r = client.delete("/api/exercises/99", headers={"X-Auth-Token": "dummy"})
+ assert r.status_code == 403
diff --git a/backend/version.py b/backend/version.py
index 5902c86..73e271a 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.38"
+APP_VERSION = "0.8.39"
BUILD_DATE = "2026-05-06"
-DB_SCHEMA_VERSION = "20260505042"
+DB_SCHEMA_VERSION = "20260506043"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
- "profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests
+ "profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL)
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context)
@@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.8.0", # PATCH bulk-metadata: Sichtbarkeit/Status + Katalog-Zuordnungen (REPLACE je Feld)
+ "exercises": "2.9.0", # DELETE RBAC (Trainer/Vereinsadmin/Plattform); Nutzungs-409; Listenfilter Negativlisten + Archiv-Standard; exercise_list_prefs
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@@ -27,6 +27,17 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.39",
+ "date": "2026-05-06",
+ "changes": [
+ "Übungen DELETE: Nur eigene private / Vereinsadmin für Vereins-Übungen / Plattform für globale; keine harte Löschung bei Verwendung in Blöcken, Plan-Abschnitten oder Progressionskanten (409 → archivieren)",
+ "GET /api/exercises: Negativfilter (visibility_exclude_any, status_exclude_any), exclude_without_focus, include_archived; archivierte standardmäßig ausgeblendet",
+ "Profile exercise_list_prefs (JSONB, Migration 043): gespeicherte Standardfilter; Frontend Übungsliste Filterdialog + „Als Standard speichern“",
+ "Übungspicker: gleiche Negativfilter; Planung lädt archivierte Übungen immer mit (bestehende Zuordnungen)",
+ "pytest: tests/test_exercises_delete_policy.py",
+ ],
+ },
{
"version": "0.8.38",
"date": "2026-05-06",
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index 7424b95..8e4af3e 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -4,23 +4,18 @@
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import api from '../utils/api'
+import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
+import {
+ INITIAL_EXERCISE_LIST_FILTERS,
+ mergeExerciseListPrefsFromApi,
+} from '../constants/exerciseListFilters'
import MultiSelectCombo from './MultiSelectCombo'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
-const INITIAL_FILTERS = {
- focus_area_ids: [],
- style_direction_ids: [],
- training_type_ids: [],
- target_group_ids: [],
- skill_ids: [],
- skill_min_level: '',
- skill_max_level: '',
- visibility_any: [],
- status_any: [],
-}
+const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
export default function ExercisePickerModal({
open,
@@ -29,6 +24,7 @@ export default function ExercisePickerModal({
multiSelect = false,
onSelectExercises = null,
}) {
+ const { user } = useAuth()
const [catalogs, setCatalogs] = useState({
focusAreas: [],
styleDirections: [],
@@ -110,8 +106,10 @@ export default function ExercisePickerModal({
setOffset(0)
setHasMore(false)
setMultiPicked([])
+ return
}
- }, [open])
+ setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
+ }, [open, user?.exercise_list_prefs])
const focusOptions = useMemo(
() => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim() })),
@@ -170,6 +168,11 @@ export default function ExercisePickerModal({
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
if (filters.status_any?.length) q.status_any = [...filters.status_any]
+ if (filters.visibility_exclude_any?.length)
+ q.visibility_exclude_any = [...filters.visibility_exclude_any]
+ if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any]
+ if (filters.exclude_without_focus) q.exclude_without_focus = true
+ if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
if (debouncedAi) q.ai_search = debouncedAi
return q
@@ -182,6 +185,7 @@ export default function ExercisePickerModal({
try {
const batch = await api.listExercises({
...queryBase,
+ include_archived: true,
include_variants: true,
limit: PAGE_SIZE,
offset: 0,
@@ -209,6 +213,7 @@ export default function ExercisePickerModal({
try {
const batch = await api.listExercises({
...queryBase,
+ include_archived: true,
include_variants: true,
limit: PAGE_SIZE,
offset,
@@ -389,6 +394,41 @@ export default function ExercisePickerModal({
/>
+
Ausblenden
+
+
+ Sichtbarkeit nicht
+ setFilters((f) => ({ ...f, visibility_exclude_any: v }))}
+ options={visibilityOptions}
+ placeholder="ausblenden …"
+ />
+
+
+ Status nicht
+ setFilters((f) => ({ ...f, status_exclude_any: v }))}
+ options={statusOptions}
+ placeholder="ausblenden …"
+ />
+
+
+
+
+ setFilters((f) => ({ ...f, exclude_without_focus: e.target.checked }))}
+ />
+ Ohne Fokus ausblenden
+
+
+ Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende
+ Zuordnungen).
+
+
)}
diff --git a/frontend/src/constants/exerciseListFilters.js b/frontend/src/constants/exerciseListFilters.js
new file mode 100644
index 0000000..31c92f2
--- /dev/null
+++ b/frontend/src/constants/exerciseListFilters.js
@@ -0,0 +1,54 @@
+/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
+export const INITIAL_EXERCISE_LIST_FILTERS = {
+ focus_area_ids: [],
+ style_direction_ids: [],
+ training_type_ids: [],
+ target_group_ids: [],
+ skill_ids: [],
+ skill_min_level: '',
+ skill_max_level: '',
+ visibility_any: [],
+ status_any: [],
+ visibility_exclude_any: [],
+ status_exclude_any: [],
+ exclude_without_focus: false,
+ include_archived: false,
+}
+
+const PREFS_KEYS = Object.keys(INITIAL_EXERCISE_LIST_FILTERS)
+
+/**
+ * Ruft aus dem Profilfeld exercise_list_prefs einen gültigen Filter-State ab.
+ */
+export function mergeExerciseListPrefsFromApi(raw) {
+ const out = { ...INITIAL_EXERCISE_LIST_FILTERS }
+ if (!raw || typeof raw !== 'object') return out
+ for (const k of PREFS_KEYS) {
+ if (raw[k] === undefined) continue
+ if (k.endsWith('_ids') || k.endsWith('_any')) {
+ if (Array.isArray(raw[k])) out[k] = raw[k].map(String)
+ continue
+ }
+ if (k === 'exclude_without_focus' || k === 'include_archived') {
+ out[k] = !!raw[k]
+ continue
+ }
+ if (k === 'skill_min_level' || k === 'skill_max_level') {
+ out[k] = raw[k] === '' || raw[k] == null ? '' : String(raw[k])
+ }
+ }
+ return out
+}
+
+/** Nur von den Defaults abweichende Werte — kompaktes Profil-JSON. */
+export function compactExerciseListPrefsPayload(filters) {
+ const full = { ...INITIAL_EXERCISE_LIST_FILTERS, ...filters }
+ const o = {}
+ for (const k of PREFS_KEYS) {
+ const v = full[k]
+ const ini = INITIAL_EXERCISE_LIST_FILTERS[k]
+ if (JSON.stringify(v) === JSON.stringify(ini)) continue
+ o[k] = v
+ }
+ return o
+}
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index e092c46..51a075a 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useMemo, useCallback } from 'react'
+import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { Link } from 'react-router-dom'
import {
Eye,
@@ -18,7 +18,12 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import PageSectionNav from '../components/PageSectionNav'
-import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
+import {
+ INITIAL_EXERCISE_LIST_FILTERS,
+ mergeExerciseListPrefsFromApi,
+ compactExerciseListPrefsPayload,
+} from '../constants/exerciseListFilters'
+import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
@@ -28,18 +33,6 @@ const EXERCISES_PAGE_TABS = [
]
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
-const INITIAL_FILTERS = {
- focus_area_ids: [],
- style_direction_ids: [],
- training_type_ids: [],
- target_group_ids: [],
- skill_ids: [],
- skill_min_level: '',
- skill_max_level: '',
- visibility_any: [],
- status_any: [],
-}
-
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
const STATUS_LABELS = {
draft: 'Entwurf',
@@ -110,7 +103,7 @@ function levelOptionShort(levelStr) {
}
function ExercisesListPage() {
- const { user } = useAuth()
+ const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [exercises, setExercises] = useState([])
@@ -130,9 +123,11 @@ function ExercisesListPage() {
const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
- const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
+ 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)
@@ -150,6 +145,17 @@ function ExercisesListPage() {
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
+ useEffect(() => {
+ if (!user?.id) return
+ if (prefsAppliedRef.current) return
+ setFilters(mergeExerciseListPrefsFromApi(user.exercise_list_prefs))
+ 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)
@@ -315,6 +321,46 @@ function ExercisesListPage() {
})
})
+ ;(filters.visibility_exclude_any || []).forEach((id) => {
+ const opt = visibilityOptions.find((o) => String(o.id) === String(id))
+ chips.push({
+ key: `vex-${id}`,
+ label: `Sichtbarkeit ausblenden: ${opt?.label ?? id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ visibility_exclude_any: prev.visibility_exclude_any.filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+ ;(filters.status_exclude_any || []).forEach((id) => {
+ const opt = statusOptions.find((o) => String(o.id) === String(id))
+ chips.push({
+ key: `sex-${id}`,
+ label: `Status ausblenden: ${opt?.label ?? id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ status_exclude_any: prev.status_exclude_any.filter((x) => String(x) !== String(id)),
+ })),
+ })
+ })
+
+ if (filters.exclude_without_focus) {
+ chips.push({
+ key: 'ex-no-focus',
+ label: 'Ohne Fokus ausblenden',
+ onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })),
+ })
+ }
+ if (filters.include_archived) {
+ chips.push({
+ key: 'inc-arch',
+ label: 'Archivierte anzeigen',
+ onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })),
+ })
+ }
+
return chips
}, [
filters,
@@ -352,6 +398,11 @@ function ExercisesListPage() {
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
if (filters.status_any?.length) q.status_any = [...filters.status_any]
+ if (filters.visibility_exclude_any?.length)
+ q.visibility_exclude_any = [...filters.visibility_exclude_any]
+ if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any]
+ if (filters.exclude_without_focus) q.exclude_without_focus = true
+ if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
if (debouncedAiSearch) q.ai_search = debouncedAiSearch
return q
@@ -504,7 +555,26 @@ function ExercisesListPage() {
}
}
- const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
+ const resetAllFilters = useCallback(() => 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('')
@@ -886,6 +956,51 @@ function ExercisesListPage() {
+
+ Ausblenden
+
+ Negativlisten schließen Treffer aus (weitere Felder weiterhin mit UND verknüpft).
+
+
+
+ Sichtbarkeit nicht anzeigen
+ setFilters({ ...filters, visibility_exclude_any: v })}
+ options={visibilityOptions}
+ placeholder="z. B. Global ausblenden …"
+ />
+
+
+ Status nicht anzeigen
+ setFilters({ ...filters, status_exclude_any: v })}
+ options={statusOptions}
+ placeholder="z. B. Entwurf ausblenden …"
+ />
+
+
+
+
+ setFilters({ ...filters, exclude_without_focus: e.target.checked })}
+ />
+ Übungen ohne Fokusbereich ausblenden
+
+
+ setFilters({ ...filters, include_archived: e.target.checked })}
+ />
+ Archivierte Übungen einblenden (ohne Haken werden sie standardmäßig ausgeblendet)
+
+
+
+
Freigabe
@@ -911,6 +1026,9 @@ function ExercisesListPage() {
+
+ {savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
+
Alle Filter zurücksetzen
@@ -1215,6 +1333,7 @@ function ExercisesListPage() {
>
+ {canUserRequestExerciseDelete(user, exercise) ? (
+ ) : null}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 5c67634..447a7d1 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -375,7 +375,7 @@ export async function listExercises(filters = {}) {
Object.entries(filters).forEach(([k, v]) => {
if (v === undefined || v === null) return
if (typeof v === 'boolean') {
- if (v) q.set(k, 'true')
+ q.set(k, v ? 'true' : 'false')
return
}
if (Array.isArray(v)) {
diff --git a/frontend/src/utils/exercisePermissions.js b/frontend/src/utils/exercisePermissions.js
new file mode 100644
index 0000000..c66e297
--- /dev/null
+++ b/frontend/src/utils/exercisePermissions.js
@@ -0,0 +1,27 @@
+function userIsClubAdminForClub(user, clubId) {
+ if (clubId == null || user == null) return false
+ const cid = Number(clubId)
+ const row = (user.clubs || []).find((c) => Number(c.id) === cid)
+ return Array.isArray(row?.roles) && row.roles.includes('club_admin')
+}
+
+function userHasAnyClubAdminRole(user) {
+ return (user?.clubs || []).some((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
+}
+
+/**
+ * Ob die Löschen-Aktion in der Liste sinnvoll angeboten werden kann (Server hat letzte Instanz).
+ */
+export function canUserRequestExerciseDelete(user, exercise) {
+ if (!user || !exercise) return false
+ const role = String(user.role || '').toLowerCase()
+ if (role === 'admin' || role === 'superadmin') return true
+ const vis = exercise.visibility || 'private'
+ const mine = Number(exercise.created_by) === Number(user.id)
+ if (vis === 'official') return false
+ if (vis === 'club') {
+ return userIsClubAdminForClub(user, exercise.club_id)
+ }
+ if (mine) return true
+ return userHasAnyClubAdminRole(user)
+}
diff --git a/frontend/src/version.js b/frontend/src/version.js
index c61688f..ec76d77 100644
--- a/frontend/src/version.js
+++ b/frontend/src/version.js
@@ -1,13 +1,13 @@
// Shinkan Jinkendo Frontend Version
-export const APP_VERSION = "0.8.38"
+export const APP_VERSION = "0.8.39"
export const BUILD_DATE = "2026-05-06"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0",
- ExercisesPage: "1.3.0", // Massenänderung inkl. Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen
+ ExercisesPage: "1.4.0", // Negativfilter, Archiv-Standard, gespeicherte Standardfilter (exercise_list_prefs); Löschen-UX
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.4.0",
--
2.43.0
From cfd40889ac031be61b5c67d259ba6c49b6e4e4db Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 15:15:26 +0200
Subject: [PATCH 20/22] feat: add utility functions for exercise data
sanitization
- Introduced `sanitizeExerciseRichText` and `coerceApiNameList` utility functions to enhance data handling in ExercisesListPage.
- Improved overall code organization by importing new utilities for better maintainability and readability.
---
frontend/src/pages/ExercisesListPage.jsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 51a075a..e5fc226 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -23,6 +23,7 @@ import {
mergeExerciseListPrefsFromApi,
compactExerciseListPrefsPayload,
} from '../constants/exerciseListFilters'
+import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
const PAGE_SIZE = 100
--
2.43.0
From 518918a6e52df12e2f136417a7d8ce409562b309 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 17:15:44 +0200
Subject: [PATCH 21/22] feat: update version and enhance exercise filtering
features
- Bumped application version to 0.8.40 and updated module versions accordingly.
- Introduced new focus area filtering options in the ExercisesListPage, allowing users to include or exclude exercises based on specified focus areas.
- Added utility functions for deduplicating and merging focus area IDs to improve filtering logic.
- Enhanced the ExercisePickerModal and ExercisesListPage components to support new focus rules and improve user experience with focus area selections.
---
backend/routers/exercises.py | 85 ++++++++--
backend/version.py | 11 +-
.../components/ExerciseFocusRulePicker.jsx | 145 ++++++++++++++++++
.../src/components/ExercisePickerModal.jsx | 54 +++++--
frontend/src/constants/exerciseListFilters.js | 19 +++
frontend/src/pages/ExercisesListPage.jsx | 80 ++++++++--
frontend/src/version.js | 4 +-
7 files changed, 357 insertions(+), 41 deletions(-)
create mode 100644 frontend/src/components/ExerciseFocusRulePicker.jsx
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 86fa2df..04bd1f6 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -645,6 +645,21 @@ def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
return out
+def _dedupe_positive_ids(ids: list[int]) -> list[int]:
+ seen: set[int] = set()
+ out: list[int] = []
+ for raw in ids or []:
+ try:
+ xi = int(raw)
+ except (TypeError, ValueError):
+ continue
+ if xi < 1 or xi in seen:
+ continue
+ seen.add(xi)
+ out.append(xi)
+ return out
+
+
def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
seen = set()
out = []
@@ -954,6 +969,18 @@ def list_exercises(
default=False,
description="Wenn true: nur Übungen mit mindestens einem Fokusbereich",
),
+ focus_only_without_focus_areas: bool = Query(
+ default=False,
+ description="Nur Übungen ohne einen einzigen Fokusbereich (M:N exercise_focus_areas leer)",
+ ),
+ focus_area_must_include_ids: list[int] = Query(
+ default=[],
+ description="Alle genannten Fokusbereiche müssen gesetzt sein (UND / „+“)",
+ ),
+ focus_area_must_exclude_ids: list[int] = Query(
+ default=[],
+ description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
+ ),
include_archived: bool = Query(
default=False,
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
@@ -1022,18 +1049,58 @@ def list_exercises(
where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
params.extend(st_excl)
- if exclude_without_focus:
- where.append(
- "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
- )
+ focus_only = focus_only_without_focus_areas
+ must_inc = _dedupe_positive_ids(list(focus_area_must_include_ids))
+ must_exc = _dedupe_positive_ids(list(focus_area_must_exclude_ids))
+ fa_or = _merge_ids(focus_area_ids, focus_area)
- fa_ids = _merge_ids(focus_area_ids, focus_area)
- if fa_ids:
- ph = ",".join(["%s"] * len(fa_ids))
+ if focus_only:
+ if exclude_without_focus:
+ raise HTTPException(
+ status_code=400,
+ detail="focus_only_without_focus_areas schließt exclude_without_focus aus.",
+ )
+ if fa_or:
+ raise HTTPException(
+ status_code=400,
+ detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_ids (ODER-Liste) verwendet werden.",
+ )
+ if must_inc:
+ raise HTTPException(
+ status_code=400,
+ detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_include_ids verwendet werden.",
+ )
+ if must_exc:
+ raise HTTPException(
+ status_code=400,
+ detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_exclude_ids verwendet werden.",
+ )
where.append(
- f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
+ "NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
)
- params.extend(fa_ids)
+ else:
+ if exclude_without_focus:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
+ )
+ if fa_or:
+ ph = ",".join(["%s"] * len(fa_or))
+ where.append(
+ f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
+ )
+ params.extend(fa_or)
+ for fid in must_inc:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
+ )
+ params.append(fid)
+ if must_exc:
+ ph = ",".join(["%s"] * len(must_exc))
+ where.append(
+ f"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa "
+ f"WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
+ )
+ params.extend(must_exc)
sk_ids = _merge_ids(skill_ids, skill_id)
if sk_ids:
diff --git a/backend/version.py b/backend/version.py
index 73e271a..6e41b32 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.39"
+APP_VERSION = "0.8.40"
BUILD_DATE = "2026-05-06"
DB_SCHEMA_VERSION = "20260506043"
@@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.9.0", # DELETE RBAC (Trainer/Vereinsadmin/Plattform); Nutzungs-409; Listenfilter Negativlisten + Archiv-Standard; exercise_list_prefs
+ "exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@@ -27,6 +27,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.40",
+ "date": "2026-05-06",
+ "changes": [
+ "Übungen Liste: Fokusfilter mit UND-+ (must_include) und UND-− (must_exclude), nur ohne Fokusbereich (focus_only_without); Frontend Dropdown + Mit / − Ohne",
+ ],
+ },
{
"version": "0.8.39",
"date": "2026-05-06",
diff --git a/frontend/src/components/ExerciseFocusRulePicker.jsx b/frontend/src/components/ExerciseFocusRulePicker.jsx
new file mode 100644
index 0000000..8146f55
--- /dev/null
+++ b/frontend/src/components/ExerciseFocusRulePicker.jsx
@@ -0,0 +1,145 @@
+import React, { useMemo, useState } from 'react'
+
+function newRuleKey() {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
+ return `fr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+/**
+ * Fokusbereiche mit „+“ (muss haben) und „−“ (darf nicht haben); optional nur Übungen ohne Fokus-Zuordnung.
+ */
+export default function ExerciseFocusRulePicker({
+ focusOptions,
+ focusRules,
+ focusOnlyWithout,
+ excludeWithoutFocus,
+ legacyFocusAreaIds = [],
+ onPatch,
+}) {
+ const [pendingId, setPendingId] = useState('')
+
+ const legacyWarning = useMemo(
+ () => Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0,
+ [legacyFocusAreaIds]
+ )
+
+ const addRule = (mode) => {
+ const id = String(pendingId || '').trim()
+ if (!id || focusOnlyWithout) return
+ const dup = (focusRules || []).some(
+ (r) => String(r.focus_area_id) === id && r.mode === mode
+ )
+ if (dup) return
+ onPatch({
+ focus_rules: [...(focusRules || []), { key: newRuleKey(), focus_area_id: id, mode }],
+ })
+ setPendingId('')
+ }
+
+ const removeRule = (key) => {
+ onPatch({
+ focus_rules: (focusRules || []).filter((r) => r.key !== key),
+ })
+ }
+
+ const setFocusOnly = (on) => {
+ if (on) {
+ onPatch({
+ focus_only_without: true,
+ exclude_without_focus: false,
+ focus_rules: [],
+ focus_area_ids: [],
+ })
+ setPendingId('')
+ return
+ }
+ onPatch({ focus_only_without: false })
+ }
+
+ return (
+
+
+ setFocusOnly(e.target.checked)}
+ />
+ Nur Übungen ohne Fokusbereich (keine Zuordnung)
+
+
+ {!focusOnlyWithout ? (
+ <>
+ {legacyWarning ? (
+
+ Es sind noch ältere Fokusfilter (ODER-Liste) aktiv — bitte über die Chips entfernen oder durch Regeln
+ ersetzen.
+
+ ) : null}
+
Fokusbereiche (+ mit / − ohne)
+
+ Mehrere „+“: alle müssen gesetzt sein (UND). Mehrere „−“: keiner davon darf gesetzt sein.
+ Kombination z. B. „+ Karate“ und „− Fitness“ = hat Karate, aber nicht zusätzlich Fitness.
+
+
+ setPendingId(e.target.value)}
+ aria-label="Fokusbereich wählen"
+ >
+ Fokusbereich wählen …
+ {focusOptions.map((o) => (
+
+ {o.label || o.id}
+
+ ))}
+
+ addRule('require')}>
+ + Mit
+
+ addRule('forbid')}>
+ − Ohne
+
+
+ {(focusRules || []).length > 0 ? (
+
+ {(focusRules || []).map((r) => {
+ const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
+ const name = opt?.label || r.focus_area_id
+ return (
+
+
+ {r.mode === 'forbid' ? '−' : '+'} {name}
+
+ removeRule(r.key)}>
+ Entfernen
+
+
+ )
+ })}
+
+ ) : null}
+ >
+ ) : (
+
+ Solange diese Option aktiv ist, sind Fokus-Regeln und die ODER-Fokusliste deaktiviert.
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index 8e4af3e..1f36dd4 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -11,6 +11,7 @@ import {
mergeExerciseListPrefsFromApi,
} from '../constants/exerciseListFilters'
import MultiSelectCombo from './MultiSelectCombo'
+import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@@ -154,6 +155,19 @@ export default function ExercisePickerModal({
const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
+ const mustInc = []
+ const mustExc = []
+ for (const r of filters.focus_rules || []) {
+ const id = Number(r.focus_area_id)
+ if (!Number.isFinite(id) || id < 1) continue
+ if (r.mode === 'forbid') mustExc.push(id)
+ else mustInc.push(id)
+ }
+ const uniqNums = (arr) => [...new Set(arr)]
+ if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
+ if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
+ if (filters.focus_only_without) q.focus_only_without_focus_areas = true
+
const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa
const sd = ids(filters.style_direction_ids)
@@ -297,18 +311,17 @@ export default function ExercisePickerModal({
{filterOpen && (
- Zwischen den Bereichen gilt UND , innerhalb ODER wie in der Übungsübersicht.
+ Zwischen den Bereichen gilt UND . Fokus: „+ mit“ / „− ohne“ kombinierbar (mehrere „+“ =
+ alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit ODER .
-
-
- Fokus
- setFilters((f) => ({ ...f, focus_area_ids: v }))}
- options={focusOptions}
- placeholder="Fokus …"
- />
-
+
setFilters((f) => ({ ...f, ...patch }))}
+ />
+
Stilrichtung
-
+
setFilters((f) => ({ ...f, exclude_without_focus: e.target.checked }))}
+ onChange={(e) =>
+ setFilters((f) => ({
+ ...f,
+ exclude_without_focus: e.target.checked,
+ ...(e.target.checked ? { focus_only_without: false } : {}),
+ }))
+ }
/>
Ohne Fokus ausblenden
diff --git a/frontend/src/constants/exerciseListFilters.js b/frontend/src/constants/exerciseListFilters.js
index 31c92f2..a84e74d 100644
--- a/frontend/src/constants/exerciseListFilters.js
+++ b/frontend/src/constants/exerciseListFilters.js
@@ -1,6 +1,11 @@
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
export const INITIAL_EXERCISE_LIST_FILTERS = {
+ /** Legacy: ODER über mehrere Fokus-IDs (wird nur noch aus gespeicherten Presets oder Chips genutzt). */
focus_area_ids: [],
+ /** Regeln: mode require = muss Fokus haben, forbid = darf diesen Fokus nicht haben (UND über alle require). */
+ focus_rules: [],
+ /** Nur Übungen ohne einen Eintrag in exercise_focus_areas. */
+ focus_only_without: false,
style_direction_ids: [],
training_type_ids: [],
target_group_ids: [],
@@ -25,6 +30,20 @@ export function mergeExerciseListPrefsFromApi(raw) {
if (!raw || typeof raw !== 'object') return out
for (const k of PREFS_KEYS) {
if (raw[k] === undefined) continue
+ if (k === 'focus_rules' && Array.isArray(raw[k])) {
+ out.focus_rules = raw[k]
+ .filter((r) => r && r.focus_area_id != null && (r.mode === 'require' || r.mode === 'forbid'))
+ .map((r, i) => ({
+ key: r.key || `imp-${i}-${r.focus_area_id}-${r.mode}`,
+ focus_area_id: String(r.focus_area_id),
+ mode: r.mode,
+ }))
+ continue
+ }
+ if (k === 'focus_only_without') {
+ out.focus_only_without = !!raw[k]
+ continue
+ }
if (k.endsWith('_ids') || k.endsWith('_any')) {
if (Array.isArray(raw[k])) out[k] = raw[k].map(String)
continue
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index e5fc226..7acac57 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -16,6 +16,7 @@ import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
+import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import PageSectionNav from '../components/PageSectionNav'
import {
@@ -221,11 +222,33 @@ function ExercisesListPage() {
const filterChips = useMemo(() => {
const chips = []
+ ;(filters.focus_rules || []).forEach((r) => {
+ const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
+ const verb = r.mode === 'forbid' ? 'Fokus ohne' : 'Fokus mit'
+ chips.push({
+ key: `fr-${r.key}`,
+ label: `${verb}: ${opt?.label ?? r.focus_area_id}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ focus_rules: prev.focus_rules.filter((x) => x.key !== r.key),
+ })),
+ })
+ })
+
+ if (filters.focus_only_without) {
+ chips.push({
+ key: 'focus-only-none',
+ label: 'Nur ohne Fokusbereich',
+ onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })),
+ })
+ }
+
;(filters.focus_area_ids || []).forEach((id) => {
const opt = focusOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `fa-${id}`,
- label: `Fokus: ${opt?.label ?? id}`,
+ label: `Fokus (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -385,6 +408,19 @@ function ExercisesListPage() {
const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
+ const mustInc = []
+ const mustExc = []
+ for (const r of filters.focus_rules || []) {
+ const id = Number(r.focus_area_id)
+ if (!Number.isFinite(id) || id < 1) continue
+ if (r.mode === 'forbid') mustExc.push(id)
+ else mustInc.push(id)
+ }
+ const uniqNums = (arr) => [...new Set(arr)]
+ if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
+ if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
+ if (filters.focus_only_without) q.focus_only_without_focus_areas = true
+
const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa
const sd = ids(filters.style_direction_ids)
@@ -858,22 +894,21 @@ function ExercisesListPage() {
- Zwischen den Bereichen gilt UND . Innerhalb eines Feldes werden mehrere Einträge mit{' '}
- ODER verknüpft.
+ Zwischen den Bereichen gilt UND . Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
+ gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
+ Trainingsstil / Zielgruppe: mehrere Werte pro Feld mit ODER .
Zuordnung
-
-
- Fokus
- setFilters({ ...filters, focus_area_ids: v })}
- options={focusOptions}
- placeholder="Fokus suchen oder „▼ Alle“ …"
- />
-
+
setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+
Stilrichtung
-
+
setFilters({ ...filters, exclude_without_focus: e.target.checked })}
+ onChange={(e) =>
+ setFilters((prev) => ({
+ ...prev,
+ exclude_without_focus: e.target.checked,
+ ...(e.target.checked ? { focus_only_without: false } : {}),
+ }))
+ }
/>
Übungen ohne Fokusbereich ausblenden
diff --git a/frontend/src/version.js b/frontend/src/version.js
index ec76d77..bc8f7cd 100644
--- a/frontend/src/version.js
+++ b/frontend/src/version.js
@@ -1,13 +1,13 @@
// Shinkan Jinkendo Frontend Version
-export const APP_VERSION = "0.8.39"
+export const APP_VERSION = "0.8.40"
export const BUILD_DATE = "2026-05-06"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0",
- ExercisesPage: "1.4.0", // Negativfilter, Archiv-Standard, gespeicherte Standardfilter (exercise_list_prefs); Löschen-UX
+ ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.4.0",
--
2.43.0
From b9d27b59b0b8963179c08c1ec39f07c46c31c456 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 6 May 2026 21:20:19 +0200
Subject: [PATCH 22/22] feat: enhance exercise filtering capabilities with new
catalog rules
- Introduced new filtering options for style directions, training types, and target groups in the exercise list.
- Implemented catalog rule picker components to manage inclusion and exclusion of exercise attributes.
- Updated utility functions to handle new catalog rules for improved filtering logic.
- Enhanced the ExercisesListPage and ExercisePickerModal to support the new filtering features, improving user experience.
---
backend/routers/exercises.py | 93 +++++-
frontend/src/app.css | 91 ++++++
frontend/src/components/CatalogRulePicker.jsx | 109 +++++++
.../components/ExerciseFocusRulePicker.jsx | 127 ++------
.../src/components/ExercisePickerModal.jsx | 174 +++++-----
frontend/src/constants/exerciseListFilters.js | 122 ++++++-
frontend/src/pages/ExercisesListPage.jsx | 303 +++++++++---------
7 files changed, 637 insertions(+), 382 deletions(-)
create mode 100644 frontend/src/components/CatalogRulePicker.jsx
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 04bd1f6..8ef0bef 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -981,6 +981,30 @@ def list_exercises(
default=[],
description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
),
+ style_direction_must_include_ids: list[int] = Query(
+ default=[],
+ description="Alle genannten Stilrichtungen müssen der Übung zugeordnet sein (UND)",
+ ),
+ style_direction_must_exclude_ids: list[int] = Query(
+ default=[],
+ description="Keine dieser Stilrichtungen darf zugeordnet sein",
+ ),
+ training_type_must_include_ids: list[int] = Query(
+ default=[],
+ description="Alle genannten Trainingsstile müssen zugeordnet sein (UND)",
+ ),
+ training_type_must_exclude_ids: list[int] = Query(
+ default=[],
+ description="Keiner dieser Trainingsstile darf zugeordnet sein",
+ ),
+ target_group_must_include_ids: list[int] = Query(
+ default=[],
+ description="Alle genannten Zielgruppen müssen zugeordnet sein (UND)",
+ ),
+ target_group_must_exclude_ids: list[int] = Query(
+ default=[],
+ description="Keine dieser Zielgruppen darf zugeordnet sein",
+ ),
include_archived: bool = Query(
default=False,
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
@@ -1110,32 +1134,77 @@ def list_exercises(
)
params.extend(sk_ids)
- sd_ids = _merge_ids(style_direction_ids, style_direction_id)
- if sd_ids:
- ph = ",".join(["%s"] * len(sd_ids))
+ sd_or = _merge_ids(style_direction_ids, style_direction_id)
+ sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids))
+ sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids))
+ if sd_or:
+ ph = ",".join(["%s"] * len(sd_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
)
- params.extend(sd_ids)
+ params.extend(sd_or)
+ for sid in sd_inc:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_style_directions esd "
+ "WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
+ )
+ params.append(sid)
+ if sd_exc:
+ ph = ",".join(["%s"] * len(sd_exc))
+ where.append(
+ "NOT EXISTS (SELECT 1 FROM exercise_style_directions esd "
+ f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
+ )
+ params.extend(sd_exc)
- tt_ids = _merge_ids(training_type_ids, training_type_id)
- if tt_ids:
- ph = ",".join(["%s"] * len(tt_ids))
+ tt_or = _merge_ids(training_type_ids, training_type_id)
+ tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids))
+ tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids))
+ if tt_or:
+ ph = ",".join(["%s"] * len(tt_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_training_types ett "
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
)
- params.extend(tt_ids)
+ params.extend(tt_or)
+ for tid in tt_inc:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_training_types ett "
+ "WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
+ )
+ params.append(tid)
+ if tt_exc:
+ ph = ",".join(["%s"] * len(tt_exc))
+ where.append(
+ "NOT EXISTS (SELECT 1 FROM exercise_training_types ett "
+ f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
+ )
+ params.extend(tt_exc)
- tg_ids = _merge_ids(target_group_ids, target_group_id)
- if tg_ids:
- ph = ",".join(["%s"] * len(tg_ids))
+ tg_or = _merge_ids(target_group_ids, target_group_id)
+ tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids))
+ tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids))
+ if tg_or:
+ ph = ",".join(["%s"] * len(tg_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
)
- params.extend(tg_ids)
+ params.extend(tg_or)
+ for gid in tg_inc:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_target_groups etg "
+ "WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
+ )
+ params.append(gid)
+ if tg_exc:
+ ph = ",".join(["%s"] * len(tg_exc))
+ where.append(
+ "NOT EXISTS (SELECT 1 FROM exercise_target_groups etg "
+ f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
+ )
+ params.extend(tg_exc)
if skill_min_level is not None or skill_max_level is not None:
lo = skill_min_level if skill_min_level is not None else 1
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 058430f..b9f372a 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2732,6 +2732,10 @@ a.analysis-split__nav-item {
.exercise-filters-modal-grid--two {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
+.exercise-filters-modal-grid--catalog {
+ grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
+ gap: 10px 12px;
+}
.exercise-filter-chips-row {
display: flex;
flex-wrap: wrap;
@@ -2823,6 +2827,93 @@ a.analysis-split__nav-item {
font-weight: 600;
}
+/* Übungsfilter: kompakte +/- Katalogregeln */
+.catalog-rule-picker {
+ margin-bottom: 10px;
+}
+.catalog-rule-picker--disabled {
+ opacity: 0.55;
+ pointer-events: none;
+}
+.catalog-rule-picker__label {
+ margin-bottom: 0;
+ font-size: 13px;
+}
+.catalog-rule-picker__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ min-height: 0;
+ margin-bottom: 6px;
+}
+.catalog-rule-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ max-width: 100%;
+ padding: 2px 6px 2px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+ font-size: 12px;
+ line-height: 1.35;
+}
+.catalog-rule-chip__sign {
+ font-weight: 700;
+ font-size: 12px;
+ flex-shrink: 0;
+ opacity: 0.85;
+}
+.catalog-rule-chip__sign--require {
+ color: var(--accent-dark);
+}
+.catalog-rule-chip__sign--forbid {
+ color: var(--danger);
+}
+.catalog-rule-chip__text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 180px;
+}
+.catalog-rule-chip__x {
+ flex-shrink: 0;
+ margin: 0;
+ padding: 0 4px;
+ border: none;
+ background: transparent;
+ color: var(--text2);
+ font-size: 15px;
+ line-height: 1;
+ cursor: pointer;
+ border-radius: 4px;
+}
+.catalog-rule-chip__x:hover {
+ color: var(--text1);
+ background: var(--border);
+}
+.catalog-rule-picker__row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+.catalog-rule-picker__select {
+ flex: 0 1 132px;
+ max-width: 160px;
+ min-width: 96px;
+ padding: 6px 8px;
+ font-size: 13px;
+}
+.catalog-rule-picker__sign-btn {
+ min-width: 32px;
+ padding-left: 10px;
+ padding-right: 10px;
+ font-weight: 700;
+ font-size: 14px;
+}
+
/* Reifegradmodell-Admin: klare Schritte, responsives Raster */
.admin-matrix-alert {
border: 1px solid var(--danger);
diff --git a/frontend/src/components/CatalogRulePicker.jsx b/frontend/src/components/CatalogRulePicker.jsx
new file mode 100644
index 0000000..4a8d40a
--- /dev/null
+++ b/frontend/src/components/CatalogRulePicker.jsx
@@ -0,0 +1,109 @@
+import React, { useState } from 'react'
+import { newCatalogRuleKey } from '../constants/exerciseListFilters'
+
+/**
+ * Kompakte +/- Regeln für Katalogwerte (numerische IDs oder Slugs).
+ * Chips oben, schmales Dropdown, Schalter nur „+“ und „−“.
+ */
+export default function CatalogRulePicker({
+ label,
+ hint,
+ options = [],
+ rules = [],
+ rulesFieldName,
+ disabled = false,
+ placeholder = 'Auswählen …',
+ idKind = 'numeric',
+ onPatch,
+}) {
+ const [pendingId, setPendingId] = useState('')
+
+ const labelFor = (id) => options.find((o) => String(o.id) === String(id))?.label ?? id
+
+ const addRule = (mode) => {
+ const raw = String(pendingId || '').trim()
+ if (!raw || disabled) return
+ if (idKind === 'numeric') {
+ const n = Number(raw)
+ if (!Number.isFinite(n) || n < 1) return
+ }
+ const dup = (rules || []).some((r) => String(r.id) === raw && r.mode === mode)
+ if (dup) return
+ onPatch({
+ [rulesFieldName]: [
+ ...(rules || []),
+ { key: newCatalogRuleKey(rulesFieldName), id: raw, mode },
+ ],
+ })
+ setPendingId('')
+ }
+
+ const removeRule = (key) => {
+ onPatch({
+ [rulesFieldName]: (rules || []).filter((r) => r.key !== key),
+ })
+ }
+
+ return (
+
+
{label}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ {(rules || []).map((r) => (
+
+
+ {r.mode === 'forbid' ? '−' : '+'}
+
+ {labelFor(r.id)}
+ removeRule(r.key)}
+ >
+ ×
+
+
+ ))}
+
+
+ setPendingId(e.target.value)}
+ aria-label={label}
+ >
+ {placeholder}
+ {options.map((o) => (
+
+ {o.label || o.id}
+
+ ))}
+
+ addRule('require')}
+ >
+ +
+
+ addRule('forbid')}
+ >
+ −
+
+
+
+ )
+}
diff --git a/frontend/src/components/ExerciseFocusRulePicker.jsx b/frontend/src/components/ExerciseFocusRulePicker.jsx
index 8146f55..a96e556 100644
--- a/frontend/src/components/ExerciseFocusRulePicker.jsx
+++ b/frontend/src/components/ExerciseFocusRulePicker.jsx
@@ -1,46 +1,18 @@
-import React, { useMemo, useState } from 'react'
-
-function newRuleKey() {
- if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
- return `fr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
-}
+import React from 'react'
+import CatalogRulePicker from './CatalogRulePicker'
/**
- * Fokusbereiche mit „+“ (muss haben) und „−“ (darf nicht haben); optional nur Übungen ohne Fokus-Zuordnung.
+ * Fokusbereiche inkl. „nur ohne Zuordnung“; Regeln über CatalogRulePicker (+/−).
*/
export default function ExerciseFocusRulePicker({
focusOptions,
focusRules,
focusOnlyWithout,
- excludeWithoutFocus,
legacyFocusAreaIds = [],
onPatch,
}) {
- const [pendingId, setPendingId] = useState('')
-
- const legacyWarning = useMemo(
- () => Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0,
- [legacyFocusAreaIds]
- )
-
- const addRule = (mode) => {
- const id = String(pendingId || '').trim()
- if (!id || focusOnlyWithout) return
- const dup = (focusRules || []).some(
- (r) => String(r.focus_area_id) === id && r.mode === mode
- )
- if (dup) return
- onPatch({
- focus_rules: [...(focusRules || []), { key: newRuleKey(), focus_area_id: id, mode }],
- })
- setPendingId('')
- }
-
- const removeRule = (key) => {
- onPatch({
- focus_rules: (focusRules || []).filter((r) => r.key !== key),
- })
- }
+ const legacyWarning =
+ Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0 && !focusOnlyWithout
const setFocusOnly = (on) => {
if (on) {
@@ -50,7 +22,6 @@ export default function ExerciseFocusRulePicker({
focus_rules: [],
focus_area_ids: [],
})
- setPendingId('')
return
}
onPatch({ focus_only_without: false })
@@ -59,85 +30,33 @@ export default function ExerciseFocusRulePicker({
return (
- setFocusOnly(e.target.checked)}
- />
- Nur Übungen ohne Fokusbereich (keine Zuordnung)
+ setFocusOnly(e.target.checked)} />
+
+ Nur Übungen ohne Fokusbereich (keine Zuordnung)
+
{!focusOnlyWithout ? (
<>
{legacyWarning ? (
-
- Es sind noch ältere Fokusfilter (ODER-Liste) aktiv — bitte über die Chips entfernen oder durch Regeln
- ersetzen.
+
+ Ältere ODER-Fokusliste aktiv — über die Chips auf der Übersicht entfernen.
) : null}
-
Fokusbereiche (+ mit / − ohne)
-
- Mehrere „+“: alle müssen gesetzt sein (UND). Mehrere „−“: keiner davon darf gesetzt sein.
- Kombination z. B. „+ Karate“ und „− Fitness“ = hat Karate, aber nicht zusätzlich Fitness.
-
-
- setPendingId(e.target.value)}
- aria-label="Fokusbereich wählen"
- >
- Fokusbereich wählen …
- {focusOptions.map((o) => (
-
- {o.label || o.id}
-
- ))}
-
- addRule('require')}>
- + Mit
-
- addRule('forbid')}>
- − Ohne
-
-
- {(focusRules || []).length > 0 ? (
-
- {(focusRules || []).map((r) => {
- const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
- const name = opt?.label || r.focus_area_id
- return (
-
-
- {r.mode === 'forbid' ? '−' : '+'} {name}
-
- removeRule(r.key)}>
- Entfernen
-
-
- )
- })}
-
- ) : null}
+
>
) : (
-
- Solange diese Option aktiv ist, sind Fokus-Regeln und die ODER-Fokusliste deaktiviert.
+
+ Fokus-Regeln sind deaktiviert.
)}
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index 1f36dd4..658dc2f 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -9,9 +9,12 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
+ splitMnCatalogRules,
+ splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import MultiSelectCombo from './MultiSelectCombo'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
+import CatalogRulePicker from './CatalogRulePicker'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@@ -155,36 +158,44 @@ export default function ExercisePickerModal({
const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
- const mustInc = []
- const mustExc = []
- for (const r of filters.focus_rules || []) {
- const id = Number(r.focus_area_id)
- if (!Number.isFinite(id) || id < 1) continue
- if (r.mode === 'forbid') mustExc.push(id)
- else mustInc.push(id)
- }
- const uniqNums = (arr) => [...new Set(arr)]
- if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
- if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
+ const fMn = splitMnCatalogRules(filters.focus_rules)
+ if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
+ if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa
- const sd = ids(filters.style_direction_ids)
- if (sd?.length) q.style_direction_ids = sd
- const tt = ids(filters.training_type_ids)
- if (tt?.length) q.training_type_ids = tt
- const tg = ids(filters.target_group_ids)
- if (tg?.length) q.target_group_ids = tg
+
+ const sdMn = splitMnCatalogRules(filters.style_direction_rules)
+ if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
+ if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
+ const sdLegacy = ids(filters.style_direction_ids)
+ if (sdLegacy?.length) q.style_direction_ids = sdLegacy
+
+ const ttMn = splitMnCatalogRules(filters.training_type_rules)
+ if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
+ if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
+ const ttLegacy = ids(filters.training_type_ids)
+ if (ttLegacy?.length) q.training_type_ids = ttLegacy
+
+ const tgMn = splitMnCatalogRules(filters.target_group_rules)
+ if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
+ if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
+ const tgLegacy = ids(filters.target_group_ids)
+ if (tgLegacy?.length) q.target_group_ids = tgLegacy
+
+ const visMn = splitScalarCatalogRules(filters.visibility_rules)
+ if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
+ if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
+
+ const stMn = splitScalarCatalogRules(filters.status_rules)
+ if (stMn.includeVals.length) q.status_any = stMn.includeVals
+ if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
+
const sk = ids(filters.skill_ids)
if (sk?.length) q.skill_ids = sk
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
- if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
- if (filters.status_any?.length) q.status_any = [...filters.status_any]
- if (filters.visibility_exclude_any?.length)
- q.visibility_exclude_any = [...filters.visibility_exclude_any]
- if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any]
if (filters.exclude_without_focus) q.exclude_without_focus = true
if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
@@ -311,8 +322,8 @@ export default function ExercisePickerModal({
{filterOpen && (
- Zwischen den Bereichen gilt UND . Fokus: „+ mit“ / „− ohne“ kombinierbar (mehrere „+“ =
- alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit ODER .
+ Felder gelten mit UND . Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
+ Sichtbarkeit/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
setFilters((f) => ({ ...f, ...patch }))}
/>
-
-
- Stilrichtung
- setFilters((f) => ({ ...f, style_direction_ids: v }))}
- options={styleOptions}
- placeholder="Stilrichtung …"
- />
-
-
- Trainingsstil
- setFilters((f) => ({ ...f, training_type_ids: v }))}
- options={trainingTypeOptions}
- placeholder="Trainingsstil …"
- />
-
-
- Zielgruppe
- setFilters((f) => ({ ...f, target_group_ids: v }))}
- options={targetGroupOptions}
- placeholder="Zielgruppe …"
- />
-
+
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
Fähigkeit
@@ -387,46 +398,25 @@ export default function ExercisePickerModal({
-
-
- Sichtbarkeit
- setFilters((f) => ({ ...f, visibility_any: v }))}
- options={visibilityOptions}
- placeholder="Sichtbarkeit …"
- />
-
-
- Status
- setFilters((f) => ({ ...f, status_any: v }))}
- options={statusOptions}
- placeholder="Status …"
- />
-
-
- Ausblenden
-
-
- Sichtbarkeit nicht
- setFilters((f) => ({ ...f, visibility_exclude_any: v }))}
- options={visibilityOptions}
- placeholder="ausblenden …"
- />
-
-
- Status nicht
- setFilters((f) => ({ ...f, status_exclude_any: v }))}
- options={statusOptions}
- placeholder="ausblenden …"
- />
-
+
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
normalizeCatalogRule(r, i, key)).filter(Boolean)
+ }
+
+ if (raw.focus_only_without !== undefined) out.focus_only_without = !!raw.focus_only_without
+
+ if (!out.visibility_rules.length) {
+ const vr = []
+ ;(raw.visibility_any || []).forEach((id, i) => {
+ const n = normalizeCatalogRule({ id, mode: 'require', key: `lv-${i}` }, i, 'visibility_rules')
+ if (n) vr.push(n)
+ })
+ ;(raw.visibility_exclude_any || []).forEach((id, i) => {
+ const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lve-${i}` }, i, 'visibility_rules')
+ if (n) vr.push(n)
+ })
+ if (vr.length) out.visibility_rules = vr
+ }
+
+ if (!out.status_rules.length) {
+ const sr = []
+ ;(raw.status_any || []).forEach((id, i) => {
+ const n = normalizeCatalogRule({ id, mode: 'require', key: `ls-${i}` }, i, 'status_rules')
+ if (n) sr.push(n)
+ })
+ ;(raw.status_exclude_any || []).forEach((id, i) => {
+ const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lse-${i}` }, i, 'status_rules')
+ if (n) sr.push(n)
+ })
+ if (sr.length) out.status_rules = sr
+ }
+
for (const k of PREFS_KEYS) {
+ if (CATALOG_RULE_FIELD_KEYS.includes(k)) continue
+ if (k === 'focus_only_without') continue
if (raw[k] === undefined) continue
- if (k === 'focus_rules' && Array.isArray(raw[k])) {
- out.focus_rules = raw[k]
- .filter((r) => r && r.focus_area_id != null && (r.mode === 'require' || r.mode === 'forbid'))
- .map((r, i) => ({
- key: r.key || `imp-${i}-${r.focus_area_id}-${r.mode}`,
- focus_area_id: String(r.focus_area_id),
- mode: r.mode,
- }))
- continue
- }
- if (k === 'focus_only_without') {
- out.focus_only_without = !!raw[k]
+ if (
+ k === 'visibility_any' ||
+ k === 'visibility_exclude_any' ||
+ k === 'status_any' ||
+ k === 'status_exclude_any'
+ ) {
continue
}
if (k.endsWith('_ids') || k.endsWith('_any')) {
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 7acac57..5bc40ca 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -17,12 +17,15 @@ import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
+import CatalogRulePicker from '../components/CatalogRulePicker'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import PageSectionNav from '../components/PageSectionNav'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
compactExerciseListPrefsPayload,
+ splitMnCatalogRules,
+ splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
@@ -51,6 +54,22 @@ function statusLabel(s) {
return STATUS_LABELS[s] || s || '—'
}
+function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) {
+ ;(rules || []).forEach((r) => {
+ const rid = String(r.id ?? r.focus_area_id ?? '')
+ const opt = options.find((o) => String(o.id) === rid)
+ chips.push({
+ key: `${field}-${r.key}`,
+ label: `${topicLabel}: ${r.mode === 'forbid' ? '−' : '+'} ${opt?.label ?? rid}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ [field]: (prev[field] || []).filter((x) => x.key !== r.key),
+ })),
+ })
+ })
+}
+
function exerciseFocusNames(ex) {
const fromApi = coerceApiNameList(ex.focus_area_names)
if (fromApi.length) return fromApi
@@ -222,19 +241,7 @@ function ExercisesListPage() {
const filterChips = useMemo(() => {
const chips = []
- ;(filters.focus_rules || []).forEach((r) => {
- const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
- const verb = r.mode === 'forbid' ? 'Fokus ohne' : 'Fokus mit'
- chips.push({
- key: `fr-${r.key}`,
- label: `${verb}: ${opt?.label ?? r.focus_area_id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- focus_rules: prev.focus_rules.filter((x) => x.key !== r.key),
- })),
- })
- })
+ pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
if (filters.focus_only_without) {
chips.push({
@@ -256,11 +263,37 @@ function ExercisesListPage() {
})),
})
})
+
+ pushCatalogRuleFilterChips(
+ chips,
+ 'style_direction_rules',
+ filters.style_direction_rules,
+ styleOptions,
+ 'Stil',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(
+ chips,
+ 'training_type_rules',
+ filters.training_type_rules,
+ trainingTypeOptions,
+ 'Trainingsstil',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(
+ chips,
+ 'target_group_rules',
+ filters.target_group_rules,
+ targetGroupOptions,
+ 'Zielgruppe',
+ setFilters
+ )
+
;(filters.style_direction_ids || []).forEach((id) => {
const opt = styleOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `sd-${id}`,
- label: `Stil: ${opt?.label ?? id}`,
+ label: `Stil (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -272,7 +305,7 @@ function ExercisesListPage() {
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tt-${id}`,
- label: `Trainingsstil: ${opt?.label ?? id}`,
+ label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -284,7 +317,7 @@ function ExercisesListPage() {
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tg-${id}`,
- label: `Zielgruppe: ${opt?.label ?? id}`,
+ label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -292,6 +325,7 @@ function ExercisesListPage() {
})),
})
})
+
;(filters.skill_ids || []).forEach((id) => {
const opt = skillOptions.find((o) => String(o.id) === String(id))
chips.push({
@@ -320,55 +354,15 @@ function ExercisesListPage() {
})
}
- ;(filters.visibility_any || []).forEach((id) => {
- const opt = visibilityOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `vis-${id}`,
- label: `Sichtbarkeit: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)),
- })),
- })
- })
- ;(filters.status_any || []).forEach((id) => {
- const opt = statusOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `st-${id}`,
- label: `Status: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- status_any: prev.status_any.filter((x) => String(x) !== String(id)),
- })),
- })
- })
-
- ;(filters.visibility_exclude_any || []).forEach((id) => {
- const opt = visibilityOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `vex-${id}`,
- label: `Sichtbarkeit ausblenden: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- visibility_exclude_any: prev.visibility_exclude_any.filter((x) => String(x) !== String(id)),
- })),
- })
- })
- ;(filters.status_exclude_any || []).forEach((id) => {
- const opt = statusOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `sex-${id}`,
- label: `Status ausblenden: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- status_exclude_any: prev.status_exclude_any.filter((x) => String(x) !== String(id)),
- })),
- })
- })
+ pushCatalogRuleFilterChips(
+ chips,
+ 'visibility_rules',
+ filters.visibility_rules,
+ visibilityOptions,
+ 'Sichtbarkeit',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters)
if (filters.exclude_without_focus) {
chips.push({
@@ -395,6 +389,7 @@ function ExercisesListPage() {
skillOptions,
visibilityOptions,
statusOptions,
+ setFilters,
])
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
@@ -408,36 +403,44 @@ function ExercisesListPage() {
const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
- const mustInc = []
- const mustExc = []
- for (const r of filters.focus_rules || []) {
- const id = Number(r.focus_area_id)
- if (!Number.isFinite(id) || id < 1) continue
- if (r.mode === 'forbid') mustExc.push(id)
- else mustInc.push(id)
- }
- const uniqNums = (arr) => [...new Set(arr)]
- if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
- if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
+ const fMn = splitMnCatalogRules(filters.focus_rules)
+ if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
+ if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa
- const sd = ids(filters.style_direction_ids)
- if (sd?.length) q.style_direction_ids = sd
- const tt = ids(filters.training_type_ids)
- if (tt?.length) q.training_type_ids = tt
- const tg = ids(filters.target_group_ids)
- if (tg?.length) q.target_group_ids = tg
+
+ const sdMn = splitMnCatalogRules(filters.style_direction_rules)
+ if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
+ if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
+ const sdLegacy = ids(filters.style_direction_ids)
+ if (sdLegacy?.length) q.style_direction_ids = sdLegacy
+
+ const ttMn = splitMnCatalogRules(filters.training_type_rules)
+ if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
+ if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
+ const ttLegacy = ids(filters.training_type_ids)
+ if (ttLegacy?.length) q.training_type_ids = ttLegacy
+
+ const tgMn = splitMnCatalogRules(filters.target_group_rules)
+ if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
+ if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
+ const tgLegacy = ids(filters.target_group_ids)
+ if (tgLegacy?.length) q.target_group_ids = tgLegacy
+
+ const visMn = splitScalarCatalogRules(filters.visibility_rules)
+ if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
+ if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
+
+ const stMn = splitScalarCatalogRules(filters.status_rules)
+ if (stMn.includeVals.length) q.status_any = stMn.includeVals
+ if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
+
const sk = ids(filters.skill_ids)
if (sk?.length) q.skill_ids = sk
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
- if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
- if (filters.status_any?.length) q.status_any = [...filters.status_any]
- if (filters.visibility_exclude_any?.length)
- q.visibility_exclude_any = [...filters.visibility_exclude_any]
- if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any]
if (filters.exclude_without_focus) q.exclude_without_focus = true
if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
@@ -896,7 +899,8 @@ function ExercisesListPage() {
Zwischen den Bereichen gilt UND . Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
- Trainingsstil / Zielgruppe: mehrere Werte pro Feld mit ODER .
+ Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter
+ „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus.
@@ -908,34 +912,34 @@ function ExercisesListPage() {
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
-
-
- Stilrichtung
- setFilters({ ...filters, style_direction_ids: v })}
- options={styleOptions}
- placeholder="Stilrichtung suchen …"
- />
-
-
- Trainingsstil
- setFilters({ ...filters, training_type_ids: v })}
- options={trainingTypeOptions}
- placeholder="Trainingsstil suchen …"
- />
-
-
- Zielgruppe
- setFilters({ ...filters, target_group_ids: v })}
- options={targetGroupOptions}
- placeholder="Zielgruppe suchen …"
- />
-
+
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
@@ -993,31 +997,11 @@ function ExercisesListPage() {
- Ausblenden
+ Ausblenden / Liste
- Negativlisten schließen Treffer aus (weitere Felder weiterhin mit UND verknüpft).
+ Sichtbarkeit und Status steuern Sie unter „Freigabe“ mit + und −. Hier nur globale Listen-Optionen.
-
-
- Sichtbarkeit nicht anzeigen
- setFilters({ ...filters, visibility_exclude_any: v })}
- options={visibilityOptions}
- placeholder="z. B. Global ausblenden …"
- />
-
-
- Status nicht anzeigen
- setFilters({ ...filters, status_exclude_any: v })}
- options={statusOptions}
- placeholder="z. B. Entwurf ausblenden …"
- />
-
-
-
+
Freigabe
-
-
- Sichtbarkeit
- setFilters({ ...filters, visibility_any: v })}
- options={visibilityOptions}
- placeholder="Sichtbarkeit wählen …"
- />
-
-
- Status
- setFilters({ ...filters, status_any: v })}
- options={statusOptions}
- placeholder="Status wählen …"
- />
-
+
+ Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus.
+
+
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
--
2.43.0