UX - Filter #12

Merged
Lars merged 22 commits from develop into main 2026-05-06 21:25:56 +02:00
43 changed files with 5235 additions and 1621 deletions

View File

@ -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 14 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

View File

@ -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, §1718 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004008)
**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) dort sind Stufen AF, 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-004008: 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.13.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.
---

View File

@ -5,6 +5,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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 AC.
**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.
---

View File

@ -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):

View File

@ -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);

View File

@ -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;

View File

@ -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

View File

@ -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,
)
@ -26,6 +28,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 15 (Übung ↔ Skill-Zeile), siehe Migration 029
_CANONICAL_SKILL_LEVELS = frozenset(
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
@ -214,21 +234,38 @@ 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
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 +493,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 +576,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()
@ -555,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 = []
@ -571,13 +676,107 @@ 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,
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 +802,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 +838,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 +917,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()
@ -721,6 +959,56 @@ 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",
),
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 („−“)",
),
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)",
),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
@ -760,13 +1048,83 @@ def list_exercises(
where.append(f"e.status IN ({ph})")
params.extend(st_list)
fa_ids = _merge_ids(focus_area_ids, focus_area)
if fa_ids:
ph = ",".join(["%s"] * len(fa_ids))
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)
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)
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:
@ -776,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
@ -860,7 +1263,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
@ -879,6 +1309,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):
@ -1082,38 +1515,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()

View File

@ -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":

View File

@ -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,26 +179,53 @@ def _assert_training_unit_permission(
return
raise HTTPException(status_code=403, detail="Keine Berechtigung")
co_trainers = unit_row["co_trainer_ids"] or []
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 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:
co_eff = _effective_co_trainer_ids_for_row(unit_row)
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: 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 +248,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 +264,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 +982,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 +1016,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
@ -820,12 +1033,27 @@ def list_training_units(
where = []
params = []
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)))"
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,),
)
params.extend([profile_id, profile_id, profile_id])
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) "
"@> jsonb_build_array(%s::int))"
)
params.extend([profile_id, profile_id, profile_id, profile_id])
where.append("tu.framework_slot_id IS NULL")
@ -840,7 +1068,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])
@ -890,7 +1119,10 @@ 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,
leadp.name AS lead_trainer_name,
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
FROM training_units tu
@ -957,27 +1189,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 +1348,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 +1362,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 +1401,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 +1419,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tpl_id_val,
)
+ tuple(lead_params)
+ tuple(assist_params)
+ (unit_id,),
)
@ -1152,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,),
)
@ -1167,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()

View File

@ -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

View File

@ -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

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.36"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041"
APP_VERSION = "0.8.40"
BUILD_DATE = "2026-05-06"
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,8 +15,8 @@ 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
"training_units": "0.1.0",
"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
"import_wiki": "1.0.0",
@ -27,6 +27,42 @@ 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",
"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",
"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",
"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",

View File

@ -55,7 +55,7 @@ function Nav({ isAdmin }) {
'nav-item' + (navItemActive(loc.pathname, item, isActive) ? ' active' : '')
}
>
<item.Icon size={20} strokeWidth={2} />
<item.Icon size={26} strokeWidth={2} />
<span>{item.shortLabel || item.label}</span>
</NavLink>
))}

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<nav style={{
display: 'flex',
gap: '8px',
borderBottom: '2px solid var(--border)',
marginBottom: '24px',
flexWrap: 'wrap'
}}>
{pages.map(page => {
<nav className="admin-top-nav" aria-label="Administration">
{pages.map((page) => {
const Icon = page.icon
const isActive = location.pathname === page.to
return (
<NavLink
key={page.to}
to={page.to}
style={{
padding: '12px 20px',
background: 'transparent',
border: 'none',
borderBottom: '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: 500,
color: isActive ? 'var(--accent)' : 'var(--text2)',
borderBottomColor: isActive ? 'var(--accent)' : 'transparent',
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
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' : '')
}
>
<Icon size={18} />
<Icon size={18} strokeWidth={2} aria-hidden />
<span>{page.label}</span>
</NavLink>
)

View File

@ -0,0 +1,28 @@
import PageSectionNav from './PageSectionNav'
/**
* 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,
items,
value,
onChange,
children,
iconSize = 18,
}) {
return (
<div className="app-subnav-shell">
<PageSectionNav
ariaLabel={ariaLabel}
value={value}
onChange={onChange}
items={items}
iconSize={iconSize}
className="page-section-nav--wrap"
/>
<div className="app-subnav-shell__main">{children}</div>
</div>
)
}

View File

@ -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 (
<div className={`catalog-rule-picker${disabled ? ' catalog-rule-picker--disabled' : ''}`}>
<label className="form-label catalog-rule-picker__label">{label}</label>
{hint ? (
<p className="muted catalog-rule-picker__hint" style={{ fontSize: '11px', marginTop: '2px', marginBottom: '6px' }}>
{hint}
</p>
) : null}
<div className="catalog-rule-picker__chips" aria-live="polite">
{(rules || []).map((r) => (
<span key={r.key} className="catalog-rule-chip">
<span className={`catalog-rule-chip__sign catalog-rule-chip__sign--${r.mode}`}>
{r.mode === 'forbid' ? '' : '+'}
</span>
<span className="catalog-rule-chip__text">{labelFor(r.id)}</span>
<button
type="button"
className="catalog-rule-chip__x"
aria-label={`${label}: Regel entfernen`}
onClick={() => removeRule(r.key)}
>
×
</button>
</span>
))}
</div>
<div className="catalog-rule-picker__row">
<select
className="form-input catalog-rule-picker__select"
value={pendingId}
disabled={disabled}
onChange={(e) => setPendingId(e.target.value)}
aria-label={label}
>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.id} value={String(o.id)}>
{o.label || o.id}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
disabled={disabled || !pendingId}
title="Muss zutreffen"
onClick={() => addRule('require')}
>
+
</button>
<button
type="button"
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
disabled={disabled || !pendingId}
title="Darf nicht zutreffen"
onClick={() => addRule('forbid')}
>
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,64 @@
import React from 'react'
import CatalogRulePicker from './CatalogRulePicker'
/**
* Fokusbereiche inkl. nur ohne Zuordnung; Regeln über CatalogRulePicker (+/).
*/
export default function ExerciseFocusRulePicker({
focusOptions,
focusRules,
focusOnlyWithout,
legacyFocusAreaIds = [],
onPatch,
}) {
const legacyWarning =
Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0 && !focusOnlyWithout
const setFocusOnly = (on) => {
if (on) {
onPatch({
focus_only_without: true,
exclude_without_focus: false,
focus_rules: [],
focus_area_ids: [],
})
return
}
onPatch({ focus_only_without: false })
}
return (
<div className="exercise-focus-rule-picker">
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', marginBottom: '10px' }}>
<input type="checkbox" checked={!!focusOnlyWithout} onChange={(e) => setFocusOnly(e.target.checked)} />
<span>
Nur Übungen <strong>ohne</strong> Fokusbereich (keine Zuordnung)
</span>
</label>
{!focusOnlyWithout ? (
<>
{legacyWarning ? (
<p className="muted" style={{ fontSize: '12px', marginTop: 0, marginBottom: '8px' }}>
Ältere ODER-Fokusliste aktiv über die Chips auf der Übersicht entfernen.
</p>
) : null}
<CatalogRulePicker
label="Fokusbereiche"
hint="+ alle erforderlich (UND). keine dieser Zuordnungen."
options={focusOptions}
rules={focusRules}
rulesFieldName="focus_rules"
idKind="numeric"
placeholder="Fokus …"
onPatch={onPatch}
/>
</>
) : (
<p className="muted" style={{ fontSize: '12px', marginTop: 0 }}>
Fokus-Regeln sind deaktiviert.
</p>
)}
</div>
)
}

View File

@ -4,23 +4,22 @@
*/
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,
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)
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 +28,7 @@ export default function ExercisePickerModal({
multiSelect = false,
onSelectExercises = null,
}) {
const { user } = useAuth()
const [catalogs, setCatalogs] = useState({
focusAreas: [],
styleDirections: [],
@ -110,8 +110,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() })),
@ -156,20 +158,46 @@ 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 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.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 +210,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 +238,7 @@ export default function ExercisePickerModal({
try {
const batch = await api.listExercises({
...queryBase,
include_archived: true,
include_variants: true,
limit: PAGE_SIZE,
offset,
@ -292,45 +322,44 @@ export default function ExercisePickerModal({
{filterOpen && (
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
<p style={{ margin: '0 0 12px 0' }}>
Zwischen den Bereichen gilt <strong>UND</strong>, innerhalb ODER wie in der Übungsübersicht.
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere + = alle zutreffend; schließt aus.
Sichtbarkeit/Status: mehrere + = eine davon (ODER); blendet aus.
</p>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label">Fokus</label>
<MultiSelectCombo
value={filters.focus_area_ids}
onChange={(v) => setFilters((f) => ({ ...f, focus_area_ids: v }))}
options={focusOptions}
placeholder="Fokus …"
/>
</div>
<div>
<label className="form-label">Stilrichtung</label>
<MultiSelectCombo
value={filters.style_direction_ids}
onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
options={styleOptions}
placeholder="Stilrichtung …"
/>
</div>
<div>
<label className="form-label">Trainingsstil</label>
<MultiSelectCombo
value={filters.training_type_ids}
onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
options={trainingTypeOptions}
placeholder="Trainingsstil …"
/>
</div>
<div>
<label className="form-label">Zielgruppe</label>
<MultiSelectCombo
value={filters.target_group_ids}
onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
options={targetGroupOptions}
placeholder="Zielgruppe …"
/>
</div>
<ExerciseFocusRulePicker
focusOptions={focusOptions}
focusRules={filters.focus_rules}
focusOnlyWithout={filters.focus_only_without}
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
<CatalogRulePicker
label="Stilrichtung"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={styleOptions}
rules={filters.style_direction_rules}
rulesFieldName="style_direction_rules"
placeholder="Stil …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Trainingsstil"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={trainingTypeOptions}
rules={filters.training_type_rules}
rulesFieldName="training_type_rules"
placeholder="Trainingsstil …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Zielgruppe"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={targetGroupOptions}
rules={filters.target_group_rules}
rulesFieldName="target_group_rules"
placeholder="Gruppe …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
</div>
<div style={{ marginTop: 12 }}>
<label className="form-label">Fähigkeit</label>
@ -369,25 +398,54 @@ export default function ExercisePickerModal({
</select>
</div>
</div>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<MultiSelectCombo
value={filters.visibility_any}
onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
options={visibilityOptions}
placeholder="Sichtbarkeit …"
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
<CatalogRulePicker
label="Sichtbarkeit"
options={visibilityOptions}
rules={filters.visibility_rules}
rulesFieldName="visibility_rules"
idKind="string"
placeholder="Sichtbarkeit …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Status"
options={statusOptions}
rules={filters.status_rules}
rulesFieldName="status_rules"
idKind="string"
placeholder="Status …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
</div>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
opacity: filters.focus_only_without ? 0.55 : 1,
}}
>
<input
type="checkbox"
disabled={!!filters.focus_only_without}
checked={!!filters.exclude_without_focus}
onChange={(e) =>
setFilters((f) => ({
...f,
exclude_without_focus: e.target.checked,
...(e.target.checked ? { focus_only_without: false } : {}),
}))
}
/>
</div>
<div>
<label className="form-label">Status</label>
<MultiSelectCombo
value={filters.status_any}
onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
options={statusOptions}
placeholder="Status …"
/>
</div>
<span>Ohne Fokus ausblenden</span>
</label>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende
Zuordnungen).
</p>
</div>
</div>
)}

View File

@ -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 (
<div
className={`admin-page-subtabs page-section-nav ${className}`.trim()}
role={isToggle ? 'group' : 'tablist'}
aria-label={ariaLabel}
>
{items.map((item) => {
const Icon = item.icon
const active = value === item.id
const disabled = Boolean(item.disabled)
return (
<button
key={item.id}
type="button"
role={isToggle ? undefined : 'tab'}
aria-selected={isToggle ? undefined : active}
aria-pressed={isToggle ? active : undefined}
disabled={disabled}
className={
'admin-page-subtabs__btn' +
(active ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => {
if (!disabled) onChange(item.id)
}}
>
{Icon ? (
<Icon size={iconSize} strokeWidth={2} className="page-section-nav__icon" aria-hidden />
) : null}
<span>{item.label}</span>
</button>
)
})}
</div>
)
}

View File

@ -763,30 +763,34 @@ export default function TrainingUnitSectionsEditor({
</div>
{showExecutionExtras ? (
<label className="tu-ex-run-block form-label">
Ist-Dauer / Anpassungen
<span className="tu-ex-run-block__controls">
<div className="tu-ex-debrief">
<div className="tu-ex-debrief__grow">
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span>
<textarea
className="form-input tu-ex-debrief__textarea"
rows={3}
value={it.modifications || ''}
onChange={(e) =>
updateItem(sIdx, iIdx, 'modifications', e.target.value)
}
placeholder="Was lief anders? Anpassungen für spätere Planung…"
/>
</div>
<div className="tu-ex-debrief__ist">
<span className="tu-item-row__meta-label">Ist (Min)</span>
<input
type="number"
className="form-input"
className="form-input tu-ex-duration"
min={1}
value={it.actual_duration_min}
onChange={(e) =>
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"
/>
<textarea
className="form-input"
rows={2}
value={it.modifications || ''}
onChange={(e) =>
updateItem(sIdx, iIdx, 'modifications', e.target.value)
}
placeholder="Abweichungen beim Durchführen"
/>
</span>
</label>
</div>
</div>
) : null}
</div>
)

View File

@ -5,22 +5,24 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
const [saving, setSaving] = useState(false)
if (loading) {
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
return (
<div className="empty-state" style={{ padding: '2.5rem' }}>
<div className="spinner" />
</div>
)
}
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 (
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
<h2 style={{ marginTop: 0 }}>Zuordnungen: Stilrichtungen Zielgruppen</h2>
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '16px' }}>{error}</div>}
<div className="admin-assignments-wrap">
<h2 className="admin-assignments-wrap__title">Zuordnungen: Stilrichtungen Zielgruppen</h2>
{error && <div className="admin-matrix-alert">{error}</div>}
{targetGroups.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
Keine Zielgruppen vorhanden. Bitte erst im Tab "Kataloge" anlegen.
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
Keine Zielgruppen vorhanden. Bitte zuerst unter <strong>Kataloge</strong> anlegen.
</div>
)}
{styleDirections.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
Keine Stilrichtungen vorhanden. Bitte erst im Tab "Hierarchie" anlegen.
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
Keine Stilrichtungen vorhanden. Bitte zuerst unter <strong>Hierarchie</strong> anlegen.
</div>
)}
{targetGroups.length > 0 && styleDirections.length > 0 && (
<div className="assignment-matrix-container">
<table className="assignment-matrix">
<div className="admin-assignments-matrix-container">
<table className="admin-assignments-matrix">
<thead>
<tr>
<th style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 2 }}>Stilrichtung</th>
{targetGroups.map(tg => (
<th key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
<th className="admin-assignments-matrix__corner">Stilrichtung</th>
{targetGroups.map((tg) => (
<th key={tg.id} className="admin-assignments-matrix__th-narrow">
{tg.name}
</th>
))}
@ -82,17 +83,18 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
<tbody>
{Object.entries(groupedStyles).map(([focusAreaName, styles]) => (
<React.Fragment key={focusAreaName}>
<tr className="focus-area-header">
<td colSpan={targetGroups.length + 1} style={{ background: 'var(--surface2)', padding: '8px 12px', fontWeight: 600, color: 'var(--text2)' }}>
<tr>
<td
className="admin-assignments-matrix__focus-header"
colSpan={targetGroups.length + 1}
>
{focusAreaName}
</td>
</tr>
{styles.map(sd => (
{styles.map((sd) => (
<tr key={sd.id}>
<td style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 1, padding: '12px', fontWeight: 500 }}>
{sd.name}
</td>
{targetGroups.map(tg => {
<td className="admin-assignments-matrix__row-label">{sd.name}</td>
{targetGroups.map((tg) => {
const assigned = isAssigned(sd.id, tg.id)
return (
<td key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
@ -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)' }}
/>
</td>
)
@ -114,45 +117,6 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
</table>
</div>
)}
<style>{`
.assignment-matrix-container {
overflow-x: auto;
margin-top: 20px;
}
.assignment-matrix {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
.assignment-matrix th,
.assignment-matrix td {
border: 1px solid var(--border);
padding: 12px;
}
.assignment-matrix th {
background: var(--surface2);
font-weight: 600;
color: var(--text1);
}
.assignment-matrix tbody tr:hover {
background: var(--surface2);
}
@media (max-width: 768px) {
.assignment-matrix {
font-size: 14px;
}
.assignment-matrix th,
.assignment-matrix td {
padding: 8px;
}
}
`}</style>
</div>
)
}

View File

@ -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 <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
return (
<div className="empty-state" style={{ padding: '2.5rem' }}>
<div className="spinner" />
</div>
)
}
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '24px' }}>
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface)', borderRadius: '8px' }}>{error}</div>}
<div className="admin-catalog-stack">
{error && <div className="admin-matrix-alert">{error}</div>}
<CatalogSection
title="Zielgruppen"
icon="🎯"
Icon={Target}
items={targetGroups}
onUpdate={onUpdate}
createFn={api.createTargetGroup}
@ -28,7 +33,7 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
<CatalogSection
title="Fähigkeitskategorien"
icon="⚡"
Icon={Tags}
items={skillCategories}
onUpdate={onUpdate}
createFn={api.createSkillCategory}
@ -42,7 +47,7 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
<CatalogSection
title="Trainingscharakter"
icon="💪"
Icon={Dumbbell}
items={trainingCharacters}
onUpdate={onUpdate}
createFn={api.createTrainingCharacter}
@ -57,27 +62,27 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
)
}
function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
function CatalogSection({ title, Icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null)
const [form, setForm] = useState({})
function startCreate() {
const emptyForm = {}
fields.forEach(f => { 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 (
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0 }}>{icon} {title}</h3>
<button className="btn btn-primary" onClick={startCreate}>+ Neu</button>
<div className="admin-catalog-section">
<div className="admin-catalog-section__head">
<h3 className="admin-catalog-section__title">
{Icon ? (
<Icon className="admin-catalog-section__icon" size={20} strokeWidth={2} aria-hidden />
) : null}
{title}
</h3>
<button type="button" className="btn btn-primary btn-small" onClick={startCreate}>
+ Neu
</button>
</div>
{creating && (
<div style={{ marginBottom: '20px', padding: '16px', background: 'var(--surface2)', borderRadius: '8px' }}>
<h4 style={{ marginTop: 0 }}>Neu erstellen</h4>
{fields.map(field => (
<div className="admin-catalog-inline-form">
<h4>Neu erstellen</h4>
{fields.map((field) => (
<div key={field.key} className="form-row">
<label className="form-label">{field.label} {field.required && '*'}</label>
<label className="form-label">
{field.label} {field.required && '*'}
</label>
{field.type === 'textarea' ? (
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
<textarea
className="form-input"
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
rows={3}
/>
) : (
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
<input
className="form-input"
type={field.type}
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
/>
)}
</div>
))}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button className="btn btn-primary" onClick={handleCreate}>Erstellen</button>
<button className="btn" onClick={() => setCreating(false)}>Abbrechen</button>
<div className="admin-catalog-actions">
<button type="button" className="btn btn-primary" onClick={handleCreate}>
Erstellen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>
Abbrechen
</button>
</div>
</div>
)}
<div style={{ display: 'grid', gap: '12px' }}>
{items.map(item => (
<div key={item.id} style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px' }}>
<div className="admin-catalog-list">
{items.map((item) => (
<div key={item.id} className="admin-catalog-item">
{editing === item.id ? (
<div>
{fields.map(field => (
{fields.map((field) => (
<div key={field.key} className="form-row">
<label className="form-label">{field.label}</label>
{field.type === 'textarea' ? (
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
<textarea
className="form-input"
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
rows={3}
/>
) : (
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
<input
className="form-input"
type={field.type}
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
/>
)}
</div>
))}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button className="btn btn-primary" onClick={() => handleUpdate(item.id)}>Speichern</button>
<button className="btn" onClick={() => setEditing(null)}>Abbrechen</button>
<div className="admin-catalog-actions">
<button type="button" className="btn btn-primary" onClick={() => handleUpdate(item.id)}>
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditing(null)}>
Abbrechen
</button>
</div>
</div>
) : (
<div>
<div style={{ marginBottom: '8px' }}>
<div className="admin-catalog-item__name-row">
<strong>{item.name}</strong>
{item.min_age !== null && item.max_age !== null && (
<span style={{ marginLeft: '12px', color: 'var(--text3)', fontSize: '14px' }}>
{item.min_age != null && item.max_age != null && (
<span className="admin-catalog-meta">
Alter: {item.min_age}-{item.max_age}
</span>
)}
</div>
{item.description && <p style={{ color: 'var(--text2)', fontSize: '14px', margin: '8px 0' }}>{item.description}</p>}
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<button className="btn" onClick={() => startEdit(item)}>Bearbeiten</button>
<button className="btn" onClick={() => handleDelete(item.id, item.name)}>Löschen</button>
{item.description ? (
<p className="admin-catalog-desc">{item.description}</p>
) : null}
<div className="admin-catalog-actions">
<button type="button" className="btn btn-secondary btn-small" onClick={() => startEdit(item)}>
Bearbeiten
</button>
<button type="button" className="btn btn-danger btn-small" onClick={() => handleDelete(item.id, item.name)}>
Löschen
</button>
</div>
</div>
)}
</div>
))}
{items.length === 0 && !creating && (
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '20px' }}>
Noch keine Einträge vorhanden
</div>
<div className="admin-catalog-empty">Noch keine Einträge vorhanden</div>
)}
</div>
</div>

View File

@ -20,7 +20,7 @@ function DetailPanel({ item, onUpdate, focusAreas }) {
return <TrainingTypeDetail item={item} onUpdate={onUpdate} focusAreas={focusAreas} />
}
return <div style={{ padding: '20px', color: 'var(--text3)' }}>Unbekannter Typ: {type}</div>
return <div className="detail-panel__unknown">Unbekannter Typ: {type}</div>
}
function FocusAreaDetail({ item, onUpdate }) {
@ -57,7 +57,7 @@ function FocusAreaDetail({ item, onUpdate }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Fokusbereich bearbeiten</h2>
<h2 className="detail-panel__title">Fokusbereich bearbeiten</h2>
<div className="form-row">
<label className="form-label">Name *</label>
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
@ -81,11 +81,11 @@ function FocusAreaDetail({ item, onUpdate }) {
<option value="inactive">Inaktiv</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
<button className="btn" onClick={handleDelete}>Löschen</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
</div>
)
@ -130,7 +130,7 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Stilrichtung bearbeiten</h2>
<h2 className="detail-panel__title">Stilrichtung bearbeiten</h2>
<div className="form-row">
<label className="form-label">Name *</label>
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
@ -163,11 +163,11 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
<option value="inactive">Inaktiv</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
<button className="btn" onClick={handleDelete}>Löschen</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
</div>
)
@ -212,7 +212,7 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Trainingstyp bearbeiten</h2>
<h2 className="detail-panel__title">Trainingstyp bearbeiten</h2>
<div className="form-row">
<label className="form-label">Name *</label>
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
@ -245,11 +245,11 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
<option value="inactive">Inaktiv</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
<button className="btn" onClick={handleDelete}>Löschen</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
</div>
)
@ -284,8 +284,8 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Neue Stilrichtung erstellen</h2>
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
<h2 className="detail-panel__title">Neue Stilrichtung erstellen</h2>
<div className="detail-panel__context">
Fokusbereich: <strong>{item.focus_area_name}</strong>
</div>
<div className="form-row">
@ -304,7 +304,7 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
<label className="form-label">Sortierung</label>
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleCreate} disabled={saving || !form.name}>
{saving ? 'Erstellt...' : 'Erstellen'}
</button>
@ -342,8 +342,8 @@ function CreateTrainingTypeForm({ item, onUpdate }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Neuen Trainingstyp erstellen</h2>
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
<h2 className="detail-panel__title">Neuen Trainingstyp erstellen</h2>
<div className="detail-panel__context">
Fokusbereich: <strong>{item.focus_area_name}</strong>
</div>
<div className="form-row">
@ -362,7 +362,7 @@ function CreateTrainingTypeForm({ item, onUpdate }) {
<label className="form-label">Sortierung</label>
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleCreate} disabled={saving || !form.name}>
{saving ? 'Erstellt...' : 'Erstellen'}
</button>

View File

@ -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 (
<div style={{ marginBottom: '12px' }}>
{/* Focus Area Header */}
<div
onClick={() => 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
}}
>
<span
onClick={(e) => { e.stopPropagation(); onToggle(nodeId) }}
style={{ marginRight: '8px', cursor: 'pointer', fontSize: '18px' }}
<div className="focus-tree-root">
<div className={'focus-tree-header' + (isSelected ? ' focus-tree-header--selected' : '')}>
<button
type="button"
className="focus-tree-toggle"
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Bereich einklappen' : 'Bereich aufklappen'}
onClick={(e) => {
e.stopPropagation()
onToggle(nodeId)
}}
>
{isExpanded ? '▼' : '▶'}
</span>
<span style={{ marginRight: '8px' }}>{focusArea.icon}</span>
<span>{focusArea.name}</span>
{isExpanded ? (
<ChevronDown size={18} strokeWidth={2} aria-hidden />
) : (
<ChevronRight size={18} strokeWidth={2} aria-hidden />
)}
</button>
<button
type="button"
className="focus-tree-header__label"
onClick={() => onSelect(focusArea, 'focus_area')}
>
{focusArea.icon ? (
<span className="focus-tree-emoji" aria-hidden>
{focusArea.icon}
</span>
) : null}
<span>{focusArea.name}</span>
</button>
</div>
{/* Children: Style Directions + Training Types */}
{isExpanded && (
<div style={{ marginLeft: '28px', marginTop: '8px' }}>
{/* Style Directions Section */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="focus-tree-children">
<div className="focus-tree-group">
<div className="focus-tree-group__head">
<span>Stilrichtungen</span>
<button
className="btn"
style={{ fontSize: '11px', padding: '4px 8px' }}
type="button"
className="btn btn-secondary btn-tiny focus-tree-add-btn"
onClick={(e) => {
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
</button>
</div>
{focusArea.style_directions && focusArea.style_directions.map(sd => (
<StyleDirectionNode
key={sd.id}
styleDirection={sd}
onSelect={onSelect}
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
/>
))}
{focusArea.style_directions &&
focusArea.style_directions.map((sd) => (
<StyleDirectionNode
key={sd.id}
styleDirection={sd}
onSelect={onSelect}
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
/>
))}
</div>
{/* Training Types Section */}
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="focus-tree-group">
<div className="focus-tree-group__head">
<span>Trainingstypen</span>
<button
className="btn"
style={{ fontSize: '11px', padding: '4px 8px' }}
type="button"
className="btn btn-secondary btn-tiny focus-tree-add-btn"
onClick={(e) => {
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
</button>
</div>
{focusArea.training_types && focusArea.training_types.map(tt => (
<TrainingTypeNode
key={tt.id}
trainingType={tt}
onSelect={onSelect}
isSelected={selectedType === 'training_type' && selectedId === tt.id}
/>
))}
{focusArea.training_types &&
focusArea.training_types.map((tt) => (
<TrainingTypeNode
key={tt.id}
trainingType={tt}
onSelect={onSelect}
isSelected={selectedType === 'training_type' && selectedId === tt.id}
/>
))}
</div>
</div>
)}
@ -92,28 +105,26 @@ function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, se
function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
return (
<div
role="button"
tabIndex={0}
className={'focus-tree-item' + (isSelected ? ' focus-tree-item--selected' : '')}
onClick={() => 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 && (
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
({styleDirection.abbreviation})
</span>
)}
{styleDirection.target_groups && styleDirection.target_groups.length > 0 && (
<div style={{ fontSize: '11px', opacity: 0.8, marginTop: '4px' }}>
Zielgruppen: {styleDirection.target_groups.map(tg => tg.name).join(', ')}
{styleDirection.abbreviation ? (
<span className="focus-tree-item__abbr">({styleDirection.abbreviation})</span>
) : null}
{styleDirection.target_groups && styleDirection.target_groups.length > 0 ? (
<div className="focus-tree-item__meta">
Zielgruppen: {styleDirection.target_groups.map((tg) => tg.name).join(', ')}
</div>
)}
) : null}
</div>
)
}
@ -121,25 +132,23 @@ function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
function TrainingTypeNode({ trainingType, onSelect, isSelected }) {
return (
<div
role="button"
tabIndex={0}
className={'focus-tree-item' + (isSelected ? ' focus-tree-item--selected' : '')}
onClick={() => 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 && (
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
({trainingType.abbreviation})
</span>
)}
{trainingType.abbreviation ? (
<span className="focus-tree-item__abbr">({trainingType.abbreviation})</span>
) : null}
</div>
)
}
export default FocusAreaNode
export default FocusAreaNode

View File

@ -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 <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
return (
<div className="empty-state" style={{ padding: '2.5rem' }}>
<div className="spinner" />
</div>
)
}
return (
<div className="admin-hierarchy-container">
{/* Tree View */}
<div
className="admin-tree-view"
style={{
display: selectedItem ? 'none' : 'block',
border: '1px solid var(--border)',
borderRadius: '12px',
padding: '16px',
background: 'var(--surface)'
}}
>
<h2 style={{ marginTop: 0 }}>Katalog-Hierarchie</h2>
{error && <div style={{ color: 'var(--danger)', marginBottom: '16px' }}>{error}</div>}
<div className="admin-hierarchy-container admin-hierarchy-layout">
<div className="admin-hierarchy-pane admin-hierarchy-pane--tree" hidden={!!selectedItem}>
<h2 className="admin-hierarchy-pane__title">Katalog-Hierarchie</h2>
{error && <div className="admin-matrix-alert">{error}</div>}
{hierarchy.map(fa => (
{hierarchy.map((fa) => (
<FocusAreaNode
key={fa.id}
focusArea={fa}
@ -36,22 +31,15 @@ function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error,
))}
</div>
{/* Detail Panel */}
{selectedItem && (
<div
style={{
border: '1px solid var(--border)',
borderRadius: '12px',
padding: '20px',
background: 'var(--surface)'
}}
>
<div className="admin-hierarchy-pane admin-hierarchy-pane--detail">
<button
className="btn admin-back-button"
type="button"
className="btn btn-secondary btn-small admin-hierarchy-back"
onClick={() => onSelectItem(null)}
style={{ marginBottom: '16px' }}
>
Zurück zur Übersicht
<ArrowLeft size={16} strokeWidth={2} aria-hidden />
Zurück zur Übersicht
</button>
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
</div>

View File

@ -0,0 +1,163 @@
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
export const INITIAL_EXERCISE_LIST_FILTERS = {
focus_area_ids: [],
focus_rules: [],
focus_only_without: false,
style_direction_ids: [],
style_direction_rules: [],
training_type_ids: [],
training_type_rules: [],
target_group_ids: [],
target_group_rules: [],
skill_ids: [],
skill_min_level: '',
skill_max_level: '',
visibility_any: [],
visibility_exclude_any: [],
visibility_rules: [],
status_any: [],
status_exclude_any: [],
status_rules: [],
exclude_without_focus: false,
include_archived: false,
}
export const CATALOG_RULE_FIELD_KEYS = [
'focus_rules',
'style_direction_rules',
'training_type_rules',
'target_group_rules',
'visibility_rules',
'status_rules',
]
const PREFS_KEYS = Object.keys(INITIAL_EXERCISE_LIST_FILTERS)
export function newCatalogRuleKey(prefix = 'r') {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
/** Einheitliche Regel-Zeile: { key, id, mode }. Legacy: focus_area_id. */
export function normalizeCatalogRule(r, i, prefix = 'r') {
if (!r || typeof r !== 'object') return null
const id = String(r.id ?? r.focus_area_id ?? '').trim()
if (!id) return null
const mode = r.mode === 'forbid' ? 'forbid' : 'require'
return {
key: r.key || newCatalogRuleKey(prefix),
id,
mode,
}
}
export function splitMnCatalogRules(rules) {
const inc = []
const exc = []
for (const r of rules || []) {
const id = Number(r.id ?? r.focus_area_id)
if (!Number.isFinite(id) || id < 1) continue
if (r.mode === 'forbid') exc.push(id)
else inc.push(id)
}
return {
includeIds: [...new Set(inc)],
excludeIds: [...new Set(exc)],
}
}
/** Für visibility/status (einfaches Feld mit einem Wert pro Übung): + → OR (Liste), → Ausschlussliste. */
export function splitScalarCatalogRules(rules) {
const inc = []
const exc = []
for (const r of rules || []) {
let id = String(r.id ?? '').trim().toLowerCase()
if (!id) continue
if (r.mode === 'forbid') exc.push(id)
else inc.push(id)
}
return {
includeVals: [...new Set(inc)],
excludeVals: [...new Set(exc)],
}
}
/**
* 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 key of CATALOG_RULE_FIELD_KEYS) {
if (!Array.isArray(raw[key])) continue
out[key] = raw[key].map((r, i) => 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 === 'visibility_any' ||
k === 'visibility_exclude_any' ||
k === 'status_any' ||
k === 'status_exclude_any'
) {
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
}

View File

@ -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')
@ -316,44 +329,16 @@ export default function AdminCatalogsPage() {
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginBottom: '24px' }}>Stammdaten-Kataloge</h1>
<h1 className="page-title">Stammdaten-Kataloge</h1>
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', borderBottom: '2px solid var(--border)', marginBottom: '24px', overflowX: 'auto' }}>
{[
{ 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 => (
<button
key={tab.id}
onClick={() => 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'
}}
>
{tab.label}
</button>
))}
</div>
<PageSectionNav
ariaLabel="Katalogbereiche"
value={activeTab}
onChange={setActiveTab}
items={CATALOG_SUBTABS}
/>
{error && (
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '16px' }}>
{error}
</div>
)}
{error && <div className="admin-matrix-alert">{error}</div>}
{loading ? (
<div className="spinner" />

View File

@ -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 (
<div className="app-page">
<div className="app-page admin-hierarchy-page">
<AdminPageNav />
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1>
<h1 className="page-title" style={{ marginBottom: '12px' }}>
Katalog &amp; Hierarchie
</h1>
{/* Tab Navigation */}
<div className="tab-navigation">
{tabs.map(tab => (
<button
key={tab.id}
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
onClick={() => setActiveTab(tab.id)}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div style={{ marginTop: '20px' }}>
<AppSubnavShell
ariaLabel="Bereich Katalogadministration"
items={subnavItems}
value={activeTab}
onChange={setActiveTab}
>
{activeTab === 'hierarchy' && (
<HierarchyTab
hierarchy={hierarchy}
@ -147,48 +139,7 @@ function AdminHierarchyPage() {
onUpdate={handleUpdate}
/>
)}
</div>
<style>{`
.tab-navigation {
display: flex;
gap: 8px;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
flex-wrap: wrap;
}
.tab-button {
padding: 12px 20px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: var(--text2);
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text1);
background: var(--surface2);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
@media (max-width: 768px) {
.tab-button {
flex: 1 1 auto;
min-width: 120px;
font-size: 14px;
padding: 10px 12px;
}
}
`}</style>
</AppSubnavShell>
</div>
)
}

View File

@ -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,44 +35,12 @@ export default function AdminMaturityModelsPage() {
</p>
</header>
<div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten">
<button
type="button"
role="tab"
aria-selected={tab === 'catalog'}
className={'admin-tabs__tab' + (tab === 'catalog' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('catalog')}
>
Katalog und Hierarchie
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'models'}
className={'admin-tabs__tab' + (tab === 'models' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('models')}
>
Reifegradmodelle
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'bindings'}
className={'admin-tabs__tab' + (tab === 'bindings' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('bindings')}
>
Kontext-Zuordnung
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'matrixviz'}
className={'admin-tabs__tab' + (tab === 'matrixviz' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('matrixviz')}
>
Matrix-Ansicht und Export
</button>
</div>
<PageSectionNav
ariaLabel="Bereiche Fähigkeiten"
value={tab}
onChange={setTab}
items={MATURITY_SECTION_TABS}
/>
<div className="admin-tabs__panel" role="tabpanel">
{tab === 'catalog' ? (

View File

@ -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,9 +286,22 @@ 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 (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="skills-page__loading">
<div className="spinner"></div>
<p>Laden...</p>
</div>
@ -295,46 +309,21 @@ function ClubsPage() {
}
return (
<div className="app-page">
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
Sparten sind optional typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
</p>
<div className="app-page clubs-page">
<h1 className="page-title">Vereinsverwaltung</h1>
<p className="clubs-page__intro muted">
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
Sparten sind optional typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
</p>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
borderBottom: '2px solid var(--border)'
}}>
{(canManageOrgSomewhere
? ['clubs', 'divisions', 'groups', 'members']
: ['clubs', 'divisions', 'groups']
).map(tab => (
<button
key={tab}
onClick={() => 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'}
</button>
))}
</div>
<PageSectionNav
ariaLabel="Vereinsverwaltung"
value={activeTab}
onChange={setActiveTab}
items={clubTabItems}
/>
{/* Clubs Tab */}
{/* Clubs Tab */}
{activeTab === 'clubs' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
@ -512,11 +501,13 @@ function ClubsPage() {
</p>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1rem'
}}>
<div
className="card-grid clubs-groups-card-grid"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1rem'
}}
>
{groups.map(group => (
<div key={group.id} className="card">
<h3 style={{ marginBottom: '0.5rem' }}>{group.name}</h3>

View File

@ -106,31 +106,32 @@ function Dashboard() {
}
return (
<div className="app-page">
<h1>Dashboard</h1>
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
Willkommen, {user?.name || user?.email}!
</p>
{profile && <EmailVerificationBanner profile={profile} />}
{/* Welcome Card */}
<div className="card" style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
<h2>Willkommen bei Shinkan Jinkendo</h2>
<p style={{ color: 'var(--text2)' }}>
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
<div className="app-page dashboard-page">
<div className="dashboard-greeting">
<div>
<h1 className="page-title" style={{ marginBottom: '6px' }}>
Dashboard
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und Vereinsstruktur.
</p>
</div>
</div>
{profile && <EmailVerificationBanner profile={profile} />}
{user?.id && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gap: '1rem',
marginBottom: '1.5rem'
}}
>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
{user?.id && (
<div
className="dashboard-training-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gap: '1rem',
alignItems: 'stretch',
marginBottom: '1.5rem',
}}
>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
{trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.upcoming?.length ? (
@ -153,11 +154,12 @@ function Dashboard() {
</ul>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
Keine anstehenden Termine mit dir als Leitung oder CoTrainer. Unter{' '}
Keine anstehenden Termine, bei denen du als Leitung oder Co-Trainer dieser Einheit eingetragen
bist. Unter{' '}
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>{' '}
kannst du den Vereins oder GruppenZeitraum einblenden.
kannst du Zeiträume und Zuordnungen bearbeiten.
</p>
)}
</div>
@ -215,43 +217,6 @@ function Dashboard() {
</div>
)}
{/* Status Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 260px), 1fr))',
gap: '1rem',
marginBottom: '1.5rem'
}}>
<div className="card">
<h3> Fertig</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>Backend-Basis</li>
<li>Datenbank-Schema</li>
<li>Auth-System</li>
<li>Login & Registrierung</li>
</ul>
</div>
<div className="card">
<h3>🚧 In Arbeit</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>Übungsverwaltung</li>
<li>Trainingsplanung</li>
<li>Kataloge (Skills, Methods)</li>
</ul>
</div>
<div className="card">
<h3>📋 Geplant</h3>
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
<li>MediaWiki-Import</li>
<li>Trainingsprogramme</li>
<li>Admin-Panel</li>
</ul>
</div>
</div>
{/* System Info */}
{version && (
<div className="card">
<h3>System-Information</h3>

File diff suppressed because it is too large Load Diff

View File

@ -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
</p>
{/* Tabs */}
<div style={{ borderBottom: '2px solid var(--border)', marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
{['preview', 'execute', 'history'].map(tab => (
<button
key={tab}
onClick={() => 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'}
</button>
))}
</div>
</div>
<PageSectionNav
ariaLabel="Import-Schritte"
value={activeTab}
onChange={setActiveTab}
items={WIKI_IMPORT_TABS}
/>
{/* Error Display */}
{error && (

View File

@ -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()
@ -132,7 +138,7 @@ function SkillsPage() {
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="skills-page__loading">
<div className="spinner"></div>
<p>Laden...</p>
</div>
@ -143,40 +149,22 @@ function SkillsPage() {
const methodsByCategory = groupByCategory(methods)
return (
<div className="app-page">
<h1 style={{ marginBottom: '1.5rem' }}>Fähigkeiten & Methoden</h1>
<div className="app-page skills-page">
<h1 className="page-title">Fähigkeiten & Methoden</h1>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
borderBottom: '2px solid var(--border)'
}}>
{['skills', 'methods'].map(tab => (
<button
key={tab}
onClick={() => 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'}
</button>
))}
</div>
<PageSectionNav
ariaLabel="Bereich wählen"
value={activeTab}
onChange={setActiveTab}
items={SKILLS_SECTION_TABS}
className="skills-page__tabs-scroll"
/>
{/* Skills Tab */}
{activeTab === 'skills' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<p style={{ color: 'var(--text2)' }}>
<div className="skills-page__intro-row">
<p>
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
</p>
{isAdmin && (
@ -188,60 +176,46 @@ function SkillsPage() {
{Object.keys(skillsByCategory).length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
<p className="skills-page__empty">
Keine Fähigkeiten gefunden
</p>
</div>
) : (
Object.keys(skillsByCategory).sort().map(category => (
<div key={category} style={{ marginBottom: '2rem' }}>
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
<div key={category} className="skills-page__category">
<h2 className="skills-page__category-title">
{category}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1rem'
}}>
<div className="skills-page__card-grid">
{skillsByCategory[category].map(skill => (
<div key={skill.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
<h3 style={{ fontSize: '1rem' }}>{skill.name}</h3>
<div key={skill.id} className="card skills-page-card">
<div className="skills-page-card__head">
<h3 className="skills-page-card__title">{skill.name}</h3>
{skill.importance && (
<span style={{
fontSize: '0.875rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--accent)',
color: 'white'
}}>
<span className="skills-page-card__badge">
{skill.importance}/5
</span>
)}
</div>
{skill.description && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
<p className="skills-page-card__desc">
{skill.description}
</p>
)}
{isAdmin && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<div className="skills-page-card__actions">
<button
className="btn btn-secondary"
style={{ flex: 1 }}
type="button"
className="btn btn-secondary skills-page-card__grow"
onClick={() => handleEdit(skill, 'skill')}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
type="button"
className="btn btn-danger"
onClick={() => handleDelete(skill, 'skill')}
>
Löschen
@ -260,8 +234,8 @@ function SkillsPage() {
{/* Methods Tab */}
{activeTab === 'methods' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<p style={{ color: 'var(--text2)' }}>
<div className="skills-page__intro-row">
<p>
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
</p>
{isAdmin && (
@ -273,52 +247,36 @@ function SkillsPage() {
{Object.keys(methodsByCategory).length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
<p className="skills-page__empty">
Keine Trainingsmethoden gefunden
</p>
</div>
) : (
Object.keys(methodsByCategory).sort().map(category => (
<div key={category} style={{ marginBottom: '2rem' }}>
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
<div key={category} className="skills-page__category">
<h2 className="skills-page__category-title">
{category}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '1rem'
}}>
<div className="skills-page__card-grid skills-page__card-grid--methods">
{methodsByCategory[category].map(method => (
<div key={method.id} className="card">
<div style={{ marginBottom: '0.5rem' }}>
<h3 style={{ fontSize: '1rem', marginBottom: '0.25rem' }}>
<div key={method.id} className="card skills-page-card">
<div className="skills-page-card__meta-block">
<h3 className="skills-page-card__title skills-page-card__title--method">
{method.name}
{method.abbreviation && (
<span style={{ color: 'var(--text2)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
<span className="skills-page-card__abbr">
({method.abbreviation})
</span>
)}
</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<div className="skills-page-card__meta-row">
{method.typical_duration && (
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
<span className="skills-page-card__chip">
{method.typical_duration} min
</span>
)}
{method.typical_group_size && (
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
<span className="skills-page-card__chip">
👥 {method.typical_group_size}
</span>
)}
@ -326,27 +284,23 @@ function SkillsPage() {
</div>
{method.description && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
<p className="skills-page-card__desc">
{method.description}
</p>
)}
{isAdmin && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<div className="skills-page-card__actions">
<button
className="btn btn-secondary"
style={{ flex: 1 }}
type="button"
className="btn btn-secondary skills-page-card__grow"
onClick={() => handleEdit(method, 'method')}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
type="button"
className="btn btn-danger"
onClick={() => handleDelete(method, 'method')}
>
Löschen
@ -364,36 +318,37 @@ function SkillsPage() {
{/* Modal */}
{showModal && isAdmin && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem'
}}>
<div style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '2rem',
maxWidth: '600px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ marginBottom: '1.5rem' }}>
{editing
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
}
</h2>
<form onSubmit={handleSubmit}>
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setShowModal(false)
}}
>
<div
className="admin-modal-sheet skills-page-modal"
role="dialog"
aria-modal="true"
aria-labelledby="skills-page-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h2 id="skills-page-modal-title" className="admin-modal-sheet__title">
{editing
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
}
</h2>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setShowModal(false)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body">
<form onSubmit={handleSubmit}>
<div className="form-row">
<label className="form-label">Name *</label>
<input
@ -455,7 +410,7 @@ function SkillsPage() {
{modalType === 'method' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
<div className="form-row">
<label className="form-label">Typische Dauer (min)</label>
<input
@ -492,8 +447,8 @@ function SkillsPage() {
</select>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
<div className="skills-page-modal__footer">
<button type="submit" className="btn btn-primary skills-page-modal__submit">
{editing ? 'Speichern' : 'Erstellen'}
</button>
<button
@ -505,6 +460,7 @@ function SkillsPage() {
</button>
</div>
</form>
</div>
</div>
</div>
)}

View File

@ -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,
@ -663,52 +664,29 @@ export default function TrainingFrameworkProgramEditPage() {
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
<details className="framework-edit-intro">
<summary className="framework-edit-intro__summary">
Kurz erklärt: Was ist ein Rahmenprogramm?
</summary>
<div className="framework-edit-intro__body">
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
Zielen und SessionSlots. <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
Zielen und SessionSlots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
<strong>GruppenPlanung</strong> (Übernahme). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>.
</p>
</div>
</div>
</details>
<div
className="framework-edit__tabbar"
role="tablist"
aria-label="Bereiche"
style={
desktopLayout
? { display: 'none' }
: {
display: 'flex',
gap: 6,
marginBottom: 14,
padding: '6px 0 12px',
borderBottom: '2px solid var(--accent)',
flexWrap: 'nowrap',
overflowX: 'auto',
position: 'sticky',
top: 0,
zIndex: 6,
background: 'var(--bg)',
}
}
>
{[
{ id: 'meta', label: 'Stammdaten' },
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
].map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={frameworkTab === t.id}
className={'framework-edit__tab' + (frameworkTab === t.id ? ' framework-edit__tab--active' : '')}
onClick={() => setFrameworkTab(t.id)}
>
{t.label}
</button>
))}
<div className="framework-edit__tabbar">
<PageSectionNav
ariaLabel="Bereiche"
value={frameworkTab}
onChange={setFrameworkTab}
items={[
{ id: 'meta', label: 'Stammdaten' },
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
]}
className="page-section-nav--embedded framework-edit__section-nav"
/>
</div>
<div

View File

@ -100,12 +100,23 @@ export default function TrainingFrameworkProgramsListPage() {
}}
>
<div>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
<strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme
mit Bezug zum Rahmen).
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsrahmenprogramme
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
Vorlagen für Ziele und Sessions die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
.
</p>
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
<div className="planning-filter-help__body">
Unter <strong>Planung</strong> 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.
</div>
</details>
</div>
<Link
to="/planning/framework-programs/new"
@ -148,9 +159,9 @@ export default function TrainingFrameworkProgramsListPage() {
</Link>
</div>
) : (
<ul style={{ listStyle: 'none' }}>
<ul className="framework-programs-list">
{rows.map((r) => (
<li key={r.id} className="card" style={{ marginBottom: '12px' }}>
<li key={r.id} className="card">
<div
style={{
display: 'flex',

File diff suppressed because it is too large Load Diff

View File

@ -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)) {
@ -508,7 +508,7 @@ export async function updateExercise(id, data) {
})
}
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',

View File

@ -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)
}

View File

@ -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 []
}

View File

@ -1,16 +1,16 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.36"
export const BUILD_DATE = "2026-05-05"
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.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.3.1",
TrainingPlanningPage: "1.4.0",
TrainingFrameworkProgramsListPage: "1.1.0",
TrainingFrameworkProgramEditPage: "1.5.0",
TrainingUnitRunPage: "1.1.0",