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)

+
+ + +

+ 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. +

+
+
+ +
+ {!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 ( + + ) + })} +
+ ) : 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) => ( - + {showOrgTrainerAssignControls ? ( + ) : 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() { + {showOrgTrainerAssignControls ? ( + + ) : null} {showTakeLead ? ( + + + + + ) : 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) ? ( - {showOrgTrainerAssignControls ? ( + {mayConfigureSessionAssignments(unit) ? (
{!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 ? ( -