Compare commits

..

No commits in common. "d04ebee1f6232befc460591442244329e88dc1c4" and "0c1fbab0ef44445443b7e79c575905786492c5d8" have entirely different histories.

43 changed files with 1616 additions and 5230 deletions

View File

@ -1,7 +1,7 @@
# Einheitliche Zugriffsschicht & Governance Umsetzungsplan # Einheitliche Zugriffsschicht & Governance Umsetzungsplan
**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`) **Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`)
**Stand:** 2026-05-06 **Stand:** 2026-05-05
**Zweck:** Drift vermeiden eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen. **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. **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? | | **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. | | **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). | | **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). |
| **Beziehung zu MULTI_TENANCY-Doc** | Zielbild und Gap-Analyse §3 dort pflegen (**§3.0** = aktueller Umsetzungsstand); **operative Reihenfolge** hier. | | **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. |
--- ---
@ -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. 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). 2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen).
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. 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.
**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` **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-06 **Letzte Aktualisierung:** 2026-05-05

View File

@ -1,7 +1,7 @@
# Multi-Tenancy, Vereins-Membership und Rollenmodell Zielarchitektur & Umsetzungsplan # Multi-Tenancy, Vereins-Membership und Rollenmodell Zielarchitektur & Umsetzungsplan
**Status:** verbindliche Zielrichtung (Architekturpapier) **Status:** verbindliche Zielrichtung (Architekturpapier)
**Stand:** 2026-05-06 **Stand:** 2026-05-05
**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §1718 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004008) **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**. **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,68 +20,60 @@ 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** | | `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** | | §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** |
| §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 | | §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; **technische Durchsetzung** ist noch lückenhaft |
| §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung | | §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` | | `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 | | `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 | | `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 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`. **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.
--- ---
## 3. Ist-Stand im Code (Gap-Analyse) ## 3. Ist-Stand im Code (Gap-Analyse)
> **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). > **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.
### 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 ### 3.1 Identität und Rollen
- `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …). - `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …).
- *(Historisch)* Fehlende Abbildung von Vereinsrollen **ohne** eigene Tabellen. - **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**.
- **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. - Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`).
**Konsequenz:** Globale Rolle und Vereinsrollen **koexistieren**; Produkt und Code sollten langfristig klar trennen, was nur global vs. nur über Mitgliedschaft gilt (vgl. Zielarchitektur §4). **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.
### 3.2 Organisation & APIs ### 3.2 Organisation & APIs
- *(Historisch)* Zu offene Vereinsliste und Club-Anlage für jeden Trainer/User. - `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`).
- **Ist:** siehe **3.0** — gefilterte Liste, eingeschränktes Anlegen, kontextbezogene Organisationsrechte. - `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“.
**Konsequenz:** Offene Punkte verlagern sich in **feine Produktregeln** und **Sparten-/Community-Stufen** (ACCESS_LAYER Stufe D bzw. spätere Epics). **Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt.
### 3.3 Trainingsplanung ### 3.3 Trainingsplanung
- Zugriff auf Einheiten weiterhin stark **gruppenbezogen** (`training_groups`, optional **`lead_trainer_profile_id`** auf Einheiten). - Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`).
- 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`**). - `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ **kein** generelles Mitgliedschaftsmodell.
**Konsequenz:** Vereinsweite oder „Administrations“-Planungsaufgaben können weiter ausgebaut werden (eigenes Produkt-Thema; nicht identisch mit Bibliotheks-Governance). **Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht.
### 3.4 Governance / Sichtbarkeit (Bibliothek) ### 3.4 Governance / Sichtbarkeit (kritisch)
- *(Historisch)* Risiko: **`club`**-Objekte ohne Bindung an **`club_id`** / Mitgliedschaft → mögliche Cross-Tenant-Sicht. - Ü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.
- **Ist:** Listen und Detail für die genannten Bibliotheksmodule nutzen die **einheitliche** Logik in **`club_tenancy`** / **`tenant_context`** (siehe **3.0**). - 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).
**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**. **Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen.
### 3.5 Frontend ### 3.5 Frontend
- `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. - **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`.
### 3.6 Membership (kommerziell/limits) ### 3.6 Membership (kommerziell/limits)
- Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**. - Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**.
- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema — bewusst nach ACCESS_LAYER-Plan zurückgestellt. - Kein konzipiertes **`club_subscription` / `club_plan`** im Schema.
**Letzte Überarbeitung dieses Abschnitts (3.x):** 2026-05-06.
--- ---

View File

@ -5,7 +5,6 @@ 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 | | 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/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 | | 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 | | 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 | | | club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
@ -25,7 +24,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. **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-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt. 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.
--- ---

View File

@ -52,33 +52,6 @@ def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool:
return cur.fetchone() is not None 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: def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
"""Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin.""" """Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin."""
if is_platform_admin(global_role): if is_platform_admin(global_role):

View File

@ -1,9 +0,0 @@
-- 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

@ -1,3 +0,0 @@
-- 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 Request/Response schemas for all endpoints
""" """
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List, Dict, Any from typing import Optional, List
from datetime import date, time, datetime from datetime import date, time, datetime
# ============================================================================ # ============================================================================
@ -43,10 +43,6 @@ class ProfileUpdate(BaseModel):
description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)", description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)",
) )
tier: Optional[str] = Field(default=None, max_length=50) 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): class ProfileResponse(BaseModel):
id: int id: int

View File

@ -17,8 +17,6 @@ from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
club_admin_shares_club_with_creator,
has_club_role,
is_platform_admin, is_platform_admin,
library_content_visible_to_profile, library_content_visible_to_profile,
) )
@ -28,24 +26,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"]) 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 # Kanonische Fähigkeitsstufen 15 (Übung ↔ Skill-Zeile), siehe Migration 029
_CANONICAL_SKILL_LEVELS = frozenset( _CANONICAL_SKILL_LEVELS = frozenset(
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"} {"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
@ -234,38 +214,21 @@ class ExerciseVariantsReorder(BaseModel):
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"}) _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_METADATA_IDS = 500
_MAX_BULK_RELATION_IDS_PER_KIND = 80
class ExerciseBulkMetadataPatch(BaseModel): class ExerciseBulkMetadataPatch(BaseModel):
"""Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge).""" """Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein)."""
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS) exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
status: Optional[str] = None status: Optional[str] = None
club_id: Optional[int] = Field(default=None, ge=1) 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") @model_validator(mode="after")
def at_least_one_patch_field(self): def at_least_visibility_or_status(self):
if ( if self.visibility is None and self.status is None:
self.visibility is None raise ValueError("Mindestens eines der Felder visibility oder status angeben")
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 return self
@ -493,14 +456,7 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
return exercise return exercise
def assign_exercise_relations( def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
cur,
conn,
exercise_id: int,
data: dict,
*,
do_commit: bool = True,
):
""" """
Weist M:N Relations für eine Übung zu. Weist M:N Relations für eine Übung zu.
Löscht alte Zuordnungen und legt neue an (REPLACE-Logik). Löscht alte Zuordnungen und legt neue an (REPLACE-Logik).
@ -576,59 +532,13 @@ def assign_exercise_relations(
) )
) )
if do_commit: conn.commit()
conn.commit()
# ============================================================================ # ============================================================================
# Endpoints # 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]: def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
"""Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate).""" """Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate)."""
seen: set[int] = set() seen: set[int] = set()
@ -645,21 +555,6 @@ def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
return out 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]: def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
seen = set() seen = set()
out = [] out = []
@ -676,107 +571,13 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
return out 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") @router.patch("/exercises/bulk-metadata")
def bulk_patch_exercises_metadata( def bulk_patch_exercises_metadata(
body: ExerciseBulkMetadataPatch, body: ExerciseBulkMetadataPatch,
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
Ändert Sichtbarkeit, Status und/oder Katalog-Zuordnungen für viele Übungen auf einmal (REPLACE je Kategorie). Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal.
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). Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin). Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
""" """
@ -802,33 +603,6 @@ def bulk_patch_exercises_metadata(
patch_visibility = body.visibility is not None patch_visibility = body.visibility is not None
patch_status = status_val 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] = [] updated: List[int] = []
failed: List[Dict[str, Any]] = [] failed: List[Dict[str, Any]] = []
@ -838,16 +612,6 @@ def bulk_patch_exercises_metadata(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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: for ex_id in unique_ids:
cur.execute( cur.execute(
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s", "SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
@ -917,8 +681,6 @@ def bulk_patch_exercises_metadata(
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s", f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
tuple(vals), tuple(vals),
) )
if relation_data:
assign_exercise_relations(cur, conn, ex_id, relation_data, do_commit=False)
updated.append(ex_id) updated.append(ex_id)
conn.commit() conn.commit()
@ -959,56 +721,6 @@ def list_exercises(
default=False, default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", 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), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
@ -1048,83 +760,13 @@ def list_exercises(
where.append(f"e.status IN ({ph})") where.append(f"e.status IN ({ph})")
params.extend(st_list) params.extend(st_list)
includes_archived = any(str(x).strip().lower() == "archived" for x in st_list) fa_ids = _merge_ids(focus_area_ids, focus_area)
if not include_archived and not includes_archived: if fa_ids:
where.append("COALESCE(e.status, '') <> %s") ph = ",".join(["%s"] * len(fa_ids))
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( where.append(
"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)" f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
) )
else: params.extend(fa_ids)
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) sk_ids = _merge_ids(skill_ids, skill_id)
if sk_ids: if sk_ids:
@ -1134,77 +776,32 @@ def list_exercises(
) )
params.extend(sk_ids) params.extend(sk_ids)
sd_or = _merge_ids(style_direction_ids, style_direction_id) sd_ids = _merge_ids(style_direction_ids, style_direction_id)
sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids)) if sd_ids:
sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids)) ph = ",".join(["%s"] * len(sd_ids))
if sd_or:
ph = ",".join(["%s"] * len(sd_or))
where.append( where.append(
"EXISTS (SELECT 1 FROM exercise_style_directions esd " "EXISTS (SELECT 1 FROM exercise_style_directions esd "
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))" f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
) )
params.extend(sd_or) params.extend(sd_ids)
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_or = _merge_ids(training_type_ids, training_type_id) tt_ids = _merge_ids(training_type_ids, training_type_id)
tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids)) if tt_ids:
tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids)) ph = ",".join(["%s"] * len(tt_ids))
if tt_or:
ph = ",".join(["%s"] * len(tt_or))
where.append( where.append(
"EXISTS (SELECT 1 FROM exercise_training_types ett " "EXISTS (SELECT 1 FROM exercise_training_types ett "
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))" f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
) )
params.extend(tt_or) params.extend(tt_ids)
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_or = _merge_ids(target_group_ids, target_group_id) tg_ids = _merge_ids(target_group_ids, target_group_id)
tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids)) if tg_ids:
tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids)) ph = ",".join(["%s"] * len(tg_ids))
if tg_or:
ph = ",".join(["%s"] * len(tg_or))
where.append( where.append(
"EXISTS (SELECT 1 FROM exercise_target_groups etg " "EXISTS (SELECT 1 FROM exercise_target_groups etg "
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))" f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
) )
params.extend(tg_or) params.extend(tg_ids)
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: 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 lo = skill_min_level if skill_min_level is not None else 1
@ -1263,34 +860,7 @@ def list_exercises(
WHERE efa.exercise_id = e.id WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1 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} {variants_sql}
FROM exercises e FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id LEFT JOIN profiles p ON e.created_by = p.id
@ -1309,9 +879,6 @@ def list_exercises(
d = r2d(r) d = r2d(r)
pfn = d.get("primary_focus_name") pfn = d.get("primary_focus_name")
d["focus_area"] = pfn 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: if include_variants:
v = d.get("variants") v = d.get("variants")
if isinstance(v, str): if isinstance(v, str):
@ -1515,32 +1082,38 @@ def delete_exercise(
): ):
""" """
Löscht eine Übung. Löscht eine Übung.
Nur Owner oder Admin darf löschen.
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.
""" """
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( # Existiert die Übung?
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s", cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
(exercise_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden") raise HTTPException(status_code=404, detail="Übung nicht gefunden")
ex = r2d(row)
_assert_can_delete_exercise(cur, tenant, ex) # 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")
counts = _exercise_delete_usage_counts(cur, exercise_id) # Prüfen ob Übung in Block-Items verwendet wird
usage_msg = _exercise_delete_usage_message(counts) cur.execute(
if usage_msg: "SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s",
raise HTTPException(status_code=409, detail=usage_msg) (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"
)
# DELETE (Cascade löscht M:N Zuordnungen automatisch)
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,)) cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
conn.commit() conn.commit()

View File

@ -9,8 +9,6 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends from fastapi import APIRouter, HTTPException, Header, Depends
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, hash_pin from auth import require_auth, hash_pin
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
@ -260,15 +258,6 @@ def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> di
assert_club_member(cur, int(pid), cid) assert_club_member(cur, int(pid), cid)
data["active_club_id"] = 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"} nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items(): for k, v in patch.items():
if k == "email": if k == "email":

View File

@ -12,7 +12,6 @@ from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
can_manage_club_org,
is_platform_admin, is_platform_admin,
library_content_visible_to_profile, library_content_visible_to_profile,
) )
@ -54,7 +53,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: def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None:
cur.execute( cur.execute(
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
(group_id,), (group_id,),
) )
group = cur.fetchone() group = cur.fetchone()
@ -65,83 +64,9 @@ 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") raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
if role not in ["admin", "superadmin"]: if role not in ["admin", "superadmin"]:
if group["trainer_id"] != profile_id and profile_id not in co_trainers: if group["trainer_id"] != profile_id and profile_id not in co_trainers:
if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role): raise HTTPException(
raise HTTPException( status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
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]: def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
@ -149,8 +74,7 @@ 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, SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
tu.lead_trainer_profile_id, tu.lead_trainer_profile_id,
tu.assistant_trainer_profile_ids, tg.trainer_id, tg.co_trainer_ids,
tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id,
fwp.created_by AS framework_created_by fwp.created_by AS framework_created_by
FROM training_units tu FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN training_groups tg ON tu.group_id = tg.id
@ -179,53 +103,26 @@ def _assert_training_unit_permission(
return return
raise HTTPException(status_code=403, detail="Keine Berechtigung") raise HTTPException(status_code=403, detail="Keine Berechtigung")
co_eff = _effective_co_trainer_ids_for_row(unit_row) co_trainers = unit_row["co_trainer_ids"] or []
if role in ["admin", "superadmin"]: if role not in ["admin", "superadmin"]:
return if (
gcid = unit_row.get("group_club_id") unit_row["created_by"] != profile_id
if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role): and unit_row["trainer_id"] != profile_id
return and profile_id not in co_trainers
if ( and unit_row.get("lead_trainer_profile_id") != profile_id
unit_row["created_by"] != profile_id ):
and unit_row["trainer_id"] != profile_id raise HTTPException(status_code=403, detail="Keine Berechtigung")
and profile_id not in co_eff
and unit_row.get("lead_trainer_profile_id") != profile_id
): 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:
raise HTTPException(status_code=403, detail="Keine Berechtigung") 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: def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
"""Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE).""" """Nicht-Admin: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer."""
if role in ("admin", "superadmin"): if role in ("admin", "superadmin"):
return 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( cur.execute(
""" """
SELECT 1 FROM training_groups g SELECT 1 FROM training_groups g
@ -248,9 +145,8 @@ def _normalize_lead_trainer_profile_id(
raw_lead: Any, raw_lead: Any,
profile_id: int, profile_id: int,
role: str, role: str,
unit_created_by: Optional[int],
) -> Optional[int]: ) -> Optional[int]:
"""NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i.d.R. mit Vereinsbezug.""" """NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext."""
if raw_lead is None: if raw_lead is None:
return None return None
if raw_lead in ("", []): if raw_lead in ("", []):
@ -264,130 +160,27 @@ def _normalize_lead_trainer_profile_id(
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,)) cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
if not cur.fetchone(): if not cur.fetchone():
raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden") raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")
if role in ("admin", "superadmin"):
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 is_platform_admin(role):
return nid 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 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 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( cur.execute(
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
(group_id,), (group_id,),
) )
gr = cur.fetchone() gr = cur.fetchone()
if not gr: if not gr:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
grd = dict(gr) eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set()
cid = grd.get("club_id") for x in gr.get("co_trainer_ids") or []:
if cid is None: eligible.add(x)
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") if nid in eligible:
club_i = int(cid) return nid
raise HTTPException(
status_code=403,
detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
)
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 # Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
_ORIGIN_LINEAGE_JOIN = """ _ORIGIN_LINEAGE_JOIN = """
@ -982,18 +775,14 @@ def list_training_units(
if gid and role not in ["admin", "superadmin"]: if gid and role not in ["admin", "superadmin"]:
cur.execute( cur.execute(
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'", "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'",
(gid,), (gid,),
) )
gr = cur.fetchone() gr = cur.fetchone()
if not gr: if not gr:
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
gd = dict(gr) cob = gr["co_trainer_ids"] or []
cob = gd.get("co_trainer_ids") or [] if gr["trainer_id"] != profile_id and profile_id not in cob:
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") raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC" order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
@ -1016,8 +805,6 @@ def list_training_units(
p.name as trainer_name, p.name as trainer_name,
p.name as creator_name, p.name as creator_name,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_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 leadp.name AS lead_trainer_name
""" """
query += "," + _ORIGIN_LINEAGE_FIELDS query += "," + _ORIGIN_LINEAGE_FIELDS
@ -1033,27 +820,12 @@ def list_training_units(
where = [] where = []
params = [] params = []
skip_involvement_filter = role in ("admin", "superadmin") if role not in ["admin", "superadmin"]:
if not skip_involvement_filter and cid is not None:
if can_manage_club_org(cur, profile_id, cid, role):
skip_involvement_filter = True
if not skip_involvement_filter and gid is not None:
cur.execute(
"SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'",
(gid,),
)
gcx = cur.fetchone()
if gcx and gcx.get("club_id") is not None:
if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role):
skip_involvement_filter = True
if not skip_involvement_filter:
where.append( where.append(
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR " "(tu.created_by = %s OR tg.trainer_id = %s OR "
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) " "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
"@> jsonb_build_array(%s::int))"
) )
params.extend([profile_id, profile_id, profile_id, profile_id]) params.extend([profile_id, profile_id, profile_id])
where.append("tu.framework_slot_id IS NULL") where.append("tu.framework_slot_id IS NULL")
@ -1068,8 +840,7 @@ def list_training_units(
if assigned_to_me: if assigned_to_me:
where.append( where.append(
"(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR " "(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) " "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
"@> jsonb_build_array(%s::int))"
) )
params.extend([profile_id, profile_id]) params.extend([profile_id, profile_id])
@ -1119,10 +890,7 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
p.name as creator_name, p.name as creator_name,
tg.trainer_id AS trainer_id, tg.trainer_id AS trainer_id,
tg.co_trainer_ids AS co_trainer_ids, 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.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, leadp.name AS lead_trainer_name,
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """ """ + _ORIGIN_LINEAGE_FIELDS.strip() + """
FROM training_units tu FROM training_units tu
@ -1189,77 +957,27 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
tpl_id_safe = plan_template_id tpl_id_safe = plan_template_id
cur.execute( cur.execute(
"SELECT trainer_id FROM training_groups WHERE id = %s", """
(int(group_id),), 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,
),
) )
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"] unit_id = cur.fetchone()["id"]
@ -1348,13 +1066,8 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tuple(blueprint_params), tuple(blueprint_params),
) )
else: else:
cur_lead = unit_row.get("lead_trainer_profile_id")
base_tr = unit_row.get("trainer_id")
lead_sql = "" lead_sql = ""
lead_params: List[Any] = [] lead_params: List[Any] = []
assist_sql = ""
assist_params: List[Any] = []
nl: Optional[int]
if "lead_trainer_profile_id" in data: if "lead_trainer_profile_id" in data:
nl = _normalize_lead_trainer_profile_id( nl = _normalize_lead_trainer_profile_id(
cur, cur,
@ -1362,27 +1075,9 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
data.get("lead_trainer_profile_id"), data.get("lead_trainer_profile_id"),
profile_id, profile_id,
role, role,
unit_row.get("created_by"),
) )
lead_sql = ", lead_trainer_profile_id = %s" lead_sql = ", lead_trainer_profile_id = %s"
lead_params.append(nl) 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( cur.execute(
f""" f"""
@ -1401,7 +1096,6 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
plan_template_id = COALESCE(%s, plan_template_id), plan_template_id = COALESCE(%s, plan_template_id),
updated_at = NOW() updated_at = NOW()
{lead_sql} {lead_sql}
{assist_sql}
WHERE id = %s WHERE id = %s
""", """,
( (
@ -1419,7 +1113,6 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tpl_id_val, tpl_id_val,
) )
+ tuple(lead_params) + tuple(lead_params)
+ tuple(assist_params)
+ (unit_id,), + (unit_id,),
) )
@ -1459,12 +1152,7 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( 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,), (unit_id,),
) )
@ -1479,13 +1167,7 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.", detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
) )
_assert_delete_training_unit( _assert_delete_training_unit(role, unit["created_by"], profile_id)
cur,
role,
unit["created_by"],
profile_id,
unit.get("group_club_id"),
)
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
conn.commit() conn.commit()

View File

@ -1,131 +0,0 @@
"""
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

@ -1,17 +0,0 @@
"""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 # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.40" APP_VERSION = "0.8.36"
BUILD_DATE = "2026-05-06" BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260506043" DB_SCHEMA_VERSION = "20260505041"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json() "profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL) "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 "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) "club_memberships": "1.0.1", # Depends(get_tenant_context)
@ -15,8 +15,8 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln "exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
"training_units": "0.2.0", "training_units": "0.1.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
"import_wiki": "1.0.0", "import_wiki": "1.0.0",
@ -27,42 +27,6 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.36",
"date": "2026-05-05", "date": "2026-05-05",

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { NavLink } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
/** /**
@ -6,6 +6,8 @@ import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
* Wechselt zwischen verschiedenen Admin-Seiten * Wechselt zwischen verschiedenen Admin-Seiten
*/ */
export default function AdminPageNav() { export default function AdminPageNav() {
const location = useLocation()
const pages = [ const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users }, { to: '/admin/users', label: 'Nutzer', icon: Users },
@ -15,18 +17,51 @@ export default function AdminPageNav() {
] ]
return ( return (
<nav className="admin-top-nav" aria-label="Administration"> <nav style={{
{pages.map((page) => { display: 'flex',
gap: '8px',
borderBottom: '2px solid var(--border)',
marginBottom: '24px',
flexWrap: 'wrap'
}}>
{pages.map(page => {
const Icon = page.icon const Icon = page.icon
const isActive = location.pathname === page.to
return ( return (
<NavLink <NavLink
key={page.to} key={page.to}
to={page.to} to={page.to}
className={({ isActive }) => style={{
'admin-top-nav__link' + (isActive ? ' admin-top-nav__link--active' : '') 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'
}
}}
> >
<Icon size={18} strokeWidth={2} aria-hidden /> <Icon size={18} />
<span>{page.label}</span> <span>{page.label}</span>
</NavLink> </NavLink>
) )

View File

@ -1,28 +0,0 @@
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

@ -1,109 +0,0 @@
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

@ -1,64 +0,0 @@
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,22 +4,23 @@
*/ */
import React, { useState, useEffect, useMemo, useCallback } from 'react' import React, { useState, useEffect, useMemo, useCallback } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
splitMnCatalogRules,
splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import MultiSelectCombo from './MultiSelectCombo' import MultiSelectCombo from './MultiSelectCombo'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker'
const PAGE_SIZE = 100 const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } 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: [],
}
export default function ExercisePickerModal({ export default function ExercisePickerModal({
open, open,
@ -28,7 +29,6 @@ export default function ExercisePickerModal({
multiSelect = false, multiSelect = false,
onSelectExercises = null, onSelectExercises = null,
}) { }) {
const { user } = useAuth()
const [catalogs, setCatalogs] = useState({ const [catalogs, setCatalogs] = useState({
focusAreas: [], focusAreas: [],
styleDirections: [], styleDirections: [],
@ -110,10 +110,8 @@ export default function ExercisePickerModal({
setOffset(0) setOffset(0)
setHasMore(false) setHasMore(false)
setMultiPicked([]) setMultiPicked([])
return
} }
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) }, [open])
}, [open, user?.exercise_list_prefs])
const focusOptions = useMemo( const focusOptions = useMemo(
() => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim() })), () => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim() })),
@ -158,46 +156,20 @@ export default function ExercisePickerModal({
const n = (v) => (v === '' || v == null ? undefined : Number(v)) const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) => const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined 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) const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa if (fa?.length) q.focus_area_ids = fa
const sd = ids(filters.style_direction_ids)
const sdMn = splitMnCatalogRules(filters.style_direction_rules) if (sd?.length) q.style_direction_ids = sd
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds const tt = ids(filters.training_type_ids)
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds if (tt?.length) q.training_type_ids = tt
const sdLegacy = ids(filters.style_direction_ids) const tg = ids(filters.target_group_ids)
if (sdLegacy?.length) q.style_direction_ids = sdLegacy if (tg?.length) q.target_group_ids = tg
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) const sk = ids(filters.skill_ids)
if (sk?.length) q.skill_ids = sk if (sk?.length) q.skill_ids = sk
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level) 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.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
if (filters.exclude_without_focus) q.exclude_without_focus = true if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
if (filters.include_archived) q.include_archived = true if (filters.status_any?.length) q.status_any = [...filters.status_any]
if (debouncedSearch) q.search = debouncedSearch if (debouncedSearch) q.search = debouncedSearch
if (debouncedAi) q.ai_search = debouncedAi if (debouncedAi) q.ai_search = debouncedAi
return q return q
@ -210,7 +182,6 @@ export default function ExercisePickerModal({
try { try {
const batch = await api.listExercises({ const batch = await api.listExercises({
...queryBase, ...queryBase,
include_archived: true,
include_variants: true, include_variants: true,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset: 0, offset: 0,
@ -238,7 +209,6 @@ export default function ExercisePickerModal({
try { try {
const batch = await api.listExercises({ const batch = await api.listExercises({
...queryBase, ...queryBase,
include_archived: true,
include_variants: true, include_variants: true,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, offset,
@ -322,44 +292,45 @@ export default function ExercisePickerModal({
{filterOpen && ( {filterOpen && (
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}> <div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
<p style={{ margin: '0 0 12px 0' }}> <p style={{ margin: '0 0 12px 0' }}>
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere + = alle zutreffend; schließt aus. Zwischen den Bereichen gilt <strong>UND</strong>, innerhalb ODER wie in der Übungsübersicht.
Sichtbarkeit/Status: mehrere + = eine davon (ODER); blendet aus.
</p> </p>
<ExerciseFocusRulePicker <div className="exercise-filters-modal-grid">
focusOptions={focusOptions} <div>
focusRules={filters.focus_rules} <label className="form-label">Fokus</label>
focusOnlyWithout={filters.focus_only_without} <MultiSelectCombo
legacyFocusAreaIds={filters.focus_area_ids} value={filters.focus_area_ids}
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))} onChange={(v) => setFilters((f) => ({ ...f, focus_area_ids: v }))}
/> options={focusOptions}
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}> placeholder="Fokus …"
<CatalogRulePicker />
label="Stilrichtung" </div>
hint="+ alle nötig (UND). verbietet Zuordnung." <div>
options={styleOptions} <label className="form-label">Stilrichtung</label>
rules={filters.style_direction_rules} <MultiSelectCombo
rulesFieldName="style_direction_rules" value={filters.style_direction_ids}
placeholder="Stil …" onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))} options={styleOptions}
/> placeholder="Stilrichtung …"
<CatalogRulePicker />
label="Trainingsstil" </div>
hint="+ alle nötig (UND). verbietet Zuordnung." <div>
options={trainingTypeOptions} <label className="form-label">Trainingsstil</label>
rules={filters.training_type_rules} <MultiSelectCombo
rulesFieldName="training_type_rules" value={filters.training_type_ids}
placeholder="Trainingsstil …" onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))} options={trainingTypeOptions}
/> placeholder="Trainingsstil …"
<CatalogRulePicker />
label="Zielgruppe" </div>
hint="+ alle nötig (UND). verbietet Zuordnung." <div>
options={targetGroupOptions} <label className="form-label">Zielgruppe</label>
rules={filters.target_group_rules} <MultiSelectCombo
rulesFieldName="target_group_rules" value={filters.target_group_ids}
placeholder="Gruppe …" onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))} options={targetGroupOptions}
/> placeholder="Zielgruppe …"
/>
</div>
</div> </div>
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<label className="form-label">Fähigkeit</label> <label className="form-label">Fähigkeit</label>
@ -398,54 +369,25 @@ export default function ExercisePickerModal({
</select> </select>
</div> </div>
</div> </div>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}> <div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
<CatalogRulePicker <div>
label="Sichtbarkeit" <label className="form-label">Sichtbarkeit</label>
options={visibilityOptions} <MultiSelectCombo
rules={filters.visibility_rules} value={filters.visibility_any}
rulesFieldName="visibility_rules" onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
idKind="string" options={visibilityOptions}
placeholder="Sichtbarkeit …" 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 } : {}),
}))
}
/> />
<span>Ohne Fokus ausblenden</span> </div>
</label> <div>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}> <label className="form-label">Status</label>
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende <MultiSelectCombo
Zuordnungen). value={filters.status_any}
</p> onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
options={statusOptions}
placeholder="Status …"
/>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -1,50 +0,0 @@
/**
* 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,34 +763,30 @@ export default function TrainingUnitSectionsEditor({
</div> </div>
{showExecutionExtras ? ( {showExecutionExtras ? (
<div className="tu-ex-debrief"> <label className="tu-ex-run-block form-label">
<div className="tu-ex-debrief__grow"> Ist-Dauer / Anpassungen
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span> <span className="tu-ex-run-block__controls">
<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 <input
type="number" type="number"
className="form-input tu-ex-duration" className="form-input"
min={1} min={1}
value={it.actual_duration_min} value={it.actual_duration_min}
onChange={(e) => onChange={(e) =>
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value) updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
} }
placeholder="IST" placeholder="IST min"
title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
/> />
</div> <textarea
</div> className="form-input"
rows={2}
value={it.modifications || ''}
onChange={(e) =>
updateItem(sIdx, iIdx, 'modifications', e.target.value)
}
placeholder="Abweichungen beim Durchführen"
/>
</span>
</label>
) : null} ) : null}
</div> </div>
) )

View File

@ -5,24 +5,22 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
if (loading) { if (loading) {
return ( return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
<div className="empty-state" style={{ padding: '2.5rem' }}>
<div className="spinner" />
</div>
)
} }
async function toggleAssignment(styleDirectionId, targetGroupId, currentlyAssigned) { async function toggleAssignment(styleDirectionId, targetGroupId, currentlyAssigned) {
setSaving(true) setSaving(true)
try { try {
if (currentlyAssigned) { if (currentlyAssigned) {
// Find and delete the assignment
const assignment = assignments.find( 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) { if (assignment) {
await api.deleteStyleDirectionTargetGroup(assignment.id) await api.deleteStyleDirectionTargetGroup(assignment.id)
} }
} else { } else {
// Create new assignment
await api.createStyleDirectionTargetGroup({ await api.createStyleDirectionTargetGroup({
style_direction_id: styleDirectionId, style_direction_id: styleDirectionId,
target_group_id: targetGroupId, target_group_id: targetGroupId,
@ -39,10 +37,11 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
function isAssigned(styleDirectionId, targetGroupId) { function isAssigned(styleDirectionId, targetGroupId) {
return assignments.some( 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 groupedStyles = styleDirections.reduce((acc, sd) => {
const key = sd.focus_area_name || 'Ohne Fokusbereich' const key = sd.focus_area_name || 'Ohne Fokusbereich'
if (!acc[key]) acc[key] = [] if (!acc[key]) acc[key] = []
@ -51,30 +50,30 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
}, {}) }, {})
return ( return (
<div className="admin-assignments-wrap"> <div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
<h2 className="admin-assignments-wrap__title">Zuordnungen: Stilrichtungen Zielgruppen</h2> <h2 style={{ marginTop: 0 }}>Zuordnungen: Stilrichtungen Zielgruppen</h2>
{error && <div className="admin-matrix-alert">{error}</div>} {error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '16px' }}>{error}</div>}
{targetGroups.length === 0 && ( {targetGroups.length === 0 && (
<div className="empty-state" style={{ padding: '2rem 1rem' }}> <div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
Keine Zielgruppen vorhanden. Bitte zuerst unter <strong>Kataloge</strong> anlegen. Keine Zielgruppen vorhanden. Bitte erst im Tab "Kataloge" anlegen.
</div> </div>
)} )}
{styleDirections.length === 0 && ( {styleDirections.length === 0 && (
<div className="empty-state" style={{ padding: '2rem 1rem' }}> <div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
Keine Stilrichtungen vorhanden. Bitte zuerst unter <strong>Hierarchie</strong> anlegen. Keine Stilrichtungen vorhanden. Bitte erst im Tab "Hierarchie" anlegen.
</div> </div>
)} )}
{targetGroups.length > 0 && styleDirections.length > 0 && ( {targetGroups.length > 0 && styleDirections.length > 0 && (
<div className="admin-assignments-matrix-container"> <div className="assignment-matrix-container">
<table className="admin-assignments-matrix"> <table className="assignment-matrix">
<thead> <thead>
<tr> <tr>
<th className="admin-assignments-matrix__corner">Stilrichtung</th> <th style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 2 }}>Stilrichtung</th>
{targetGroups.map((tg) => ( {targetGroups.map(tg => (
<th key={tg.id} className="admin-assignments-matrix__th-narrow"> <th key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
{tg.name} {tg.name}
</th> </th>
))} ))}
@ -83,18 +82,17 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
<tbody> <tbody>
{Object.entries(groupedStyles).map(([focusAreaName, styles]) => ( {Object.entries(groupedStyles).map(([focusAreaName, styles]) => (
<React.Fragment key={focusAreaName}> <React.Fragment key={focusAreaName}>
<tr> <tr className="focus-area-header">
<td <td colSpan={targetGroups.length + 1} style={{ background: 'var(--surface2)', padding: '8px 12px', fontWeight: 600, color: 'var(--text2)' }}>
className="admin-assignments-matrix__focus-header"
colSpan={targetGroups.length + 1}
>
{focusAreaName} {focusAreaName}
</td> </td>
</tr> </tr>
{styles.map((sd) => ( {styles.map(sd => (
<tr key={sd.id}> <tr key={sd.id}>
<td className="admin-assignments-matrix__row-label">{sd.name}</td> <td style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 1, padding: '12px', fontWeight: 500 }}>
{targetGroups.map((tg) => { {sd.name}
</td>
{targetGroups.map(tg => {
const assigned = isAssigned(sd.id, tg.id) const assigned = isAssigned(sd.id, tg.id)
return ( return (
<td key={tg.id} style={{ textAlign: 'center', padding: '12px' }}> <td key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
@ -103,8 +101,7 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
checked={assigned} checked={assigned}
onChange={() => toggleAssignment(sd.id, tg.id, assigned)} onChange={() => toggleAssignment(sd.id, tg.id, assigned)}
disabled={saving} disabled={saving}
aria-label={`${sd.name}${tg.name}`} style={{ width: '20px', height: '20px', cursor: 'pointer' }}
style={{ width: '20px', height: '20px', cursor: 'pointer', accentColor: 'var(--accent)' }}
/> />
</td> </td>
) )
@ -117,6 +114,45 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
</table> </table>
</div> </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> </div>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,163 +0,0 @@
/** 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,19 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav' 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() { export default function AdminCatalogsPage() {
const [activeTab, setActiveTab] = useState('focus-areas') const [activeTab, setActiveTab] = useState('focus-areas')
@ -329,16 +316,44 @@ export default function AdminCatalogsPage() {
<div className="app-page"> <div className="app-page">
<AdminPageNav /> <AdminPageNav />
<h1 className="page-title">Stammdaten-Kataloge</h1> <h1 style={{ marginBottom: '24px' }}>Stammdaten-Kataloge</h1>
<PageSectionNav {/* Tabs */}
ariaLabel="Katalogbereiche" <div style={{ display: 'flex', gap: '8px', borderBottom: '2px solid var(--border)', marginBottom: '24px', overflowX: 'auto' }}>
value={activeTab} {[
onChange={setActiveTab} { id: 'focus-areas', label: 'Fokusbereiche' },
items={CATALOG_SUBTABS} { 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>
{error && <div className="admin-matrix-alert">{error}</div>} {error && (
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '16px' }}>
{error}
</div>
)}
{loading ? ( {loading ? (
<div className="spinner" /> <div className="spinner" />

View File

@ -1,8 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { TreePine, FolderTree, Link2 } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav' import AdminPageNav from '../components/AdminPageNav'
import AppSubnavShell from '../components/AppSubnavShell'
import HierarchyTab from '../components/admin/HierarchyTab' import HierarchyTab from '../components/admin/HierarchyTab'
import CatalogsTab from '../components/admin/CatalogsTab' import CatalogsTab from '../components/admin/CatalogsTab'
import AssignmentsTab from '../components/admin/AssignmentsTab' import AssignmentsTab from '../components/admin/AssignmentsTab'
@ -12,14 +10,17 @@ function AdminHierarchyPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
// Hierarchy Tab State
const [hierarchy, setHierarchy] = useState([]) const [hierarchy, setHierarchy] = useState([])
const [expandedNodes, setExpandedNodes] = useState(new Set()) const [expandedNodes, setExpandedNodes] = useState(new Set())
const [selectedItem, setSelectedItem] = useState(null) const [selectedItem, setSelectedItem] = useState(null)
// Catalogs Tab State
const [targetGroups, setTargetGroups] = useState([]) const [targetGroups, setTargetGroups] = useState([])
const [skillCategories, setSkillCategories] = useState([]) const [skillCategories, setSkillCategories] = useState([])
const [trainingCharacters, setTrainingCharacters] = useState([]) const [trainingCharacters, setTrainingCharacters] = useState([])
// Assignments Tab State
const [styleDirections, setStyleDirections] = useState([]) const [styleDirections, setStyleDirections] = useState([])
const [assignments, setAssignments] = useState([]) const [assignments, setAssignments] = useState([])
@ -61,7 +62,7 @@ function AdminHierarchyPage() {
} }
function handleToggleNode(nodeId) { function handleToggleNode(nodeId) {
setExpandedNodes((prev) => { setExpandedNodes(prev => {
const newSet = new Set(prev) const newSet = new Set(prev)
if (newSet.has(nodeId)) { if (newSet.has(nodeId)) {
newSet.delete(nodeId) newSet.delete(nodeId)
@ -85,26 +86,33 @@ function AdminHierarchyPage() {
loadData() loadData()
} }
const subnavItems = [ const tabs = [
{ id: 'hierarchy', label: 'Hierarchie', icon: TreePine }, { id: 'hierarchy', label: '🌳 Hierarchie', icon: '🌳' },
{ id: 'catalogs', label: 'Kataloge', icon: FolderTree }, { id: 'catalogs', label: '📋 Kataloge', icon: '📋' },
{ id: 'assignments', label: 'Zuordnungen', icon: Link2 } { id: 'assignments', label: '🔗 Zuordnungen', icon: '🔗' }
] ]
return ( return (
<div className="app-page admin-hierarchy-page"> <div className="app-page">
<AdminPageNav /> <AdminPageNav />
<h1 className="page-title" style={{ marginBottom: '12px' }}> <h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1>
Katalog &amp; Hierarchie
</h1>
<AppSubnavShell {/* Tab Navigation */}
ariaLabel="Bereich Katalogadministration" <div className="tab-navigation">
items={subnavItems} {tabs.map(tab => (
value={activeTab} <button
onChange={setActiveTab} 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' }}>
{activeTab === 'hierarchy' && ( {activeTab === 'hierarchy' && (
<HierarchyTab <HierarchyTab
hierarchy={hierarchy} hierarchy={hierarchy}
@ -139,7 +147,48 @@ function AdminHierarchyPage() {
onUpdate={handleUpdate} onUpdate={handleUpdate}
/> />
)} )}
</AppSubnavShell> </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>
</div> </div>
) )
} }

View File

@ -6,14 +6,6 @@ import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel' import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin' import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin'
import MaturityMatrixToolsAdmin from '../components/admin/MaturityMatrixToolsAdmin' 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() { export default function AdminMaturityModelsPage() {
const { user } = useAuth() const { user } = useAuth()
@ -35,12 +27,44 @@ export default function AdminMaturityModelsPage() {
</p> </p>
</header> </header>
<PageSectionNav <div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten">
ariaLabel="Bereiche Fähigkeiten" <button
value={tab} type="button"
onChange={setTab} role="tab"
items={MATURITY_SECTION_TABS} 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>
<div className="admin-tabs__panel" role="tabpanel"> <div className="admin-tabs__panel" role="tabpanel">
{tab === 'catalog' ? ( {tab === 'catalog' ? (

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react' import React, { useState, useEffect } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import PageSectionNav from '../components/PageSectionNav'
const CLUB_ROLE_OPTIONS = [ const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' }, { code: 'club_admin', label: 'Vereinsadmin' },
@ -286,22 +285,9 @@ function ClubsPage() {
setFormData(prev => ({ ...prev, [field]: value })) 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) { if (loading) {
return ( return (
<div className="skills-page__loading"> <div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div> <div className="spinner"></div>
<p>Laden...</p> <p>Laden...</p>
</div> </div>
@ -309,21 +295,46 @@ function ClubsPage() {
} }
return ( return (
<div className="app-page clubs-page"> <div className="app-page">
<h1 className="page-title">Vereinsverwaltung</h1> <h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
<p className="clubs-page__intro muted"> <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. 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. Sparten sind optional typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
</p> </p>
<PageSectionNav {/* Tabs */}
ariaLabel="Vereinsverwaltung" <div style={{
value={activeTab} display: 'flex',
onChange={setActiveTab} gap: '0.5rem',
items={clubTabItems} 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>
{/* Clubs Tab */} {/* Clubs Tab */}
{activeTab === 'clubs' && ( {activeTab === 'clubs' && (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
@ -501,13 +512,11 @@ function ClubsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div <div style={{
className="card-grid clubs-groups-card-grid" display: 'grid',
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1rem'
gap: '1rem' }}>
}}
>
{groups.map(group => ( {groups.map(group => (
<div key={group.id} className="card"> <div key={group.id} className="card">
<h3 style={{ marginBottom: '0.5rem' }}>{group.name}</h3> <h3 style={{ marginBottom: '0.5rem' }}>{group.name}</h3>

View File

@ -106,32 +106,31 @@ function Dashboard() {
} }
return ( return (
<div className="app-page dashboard-page"> <div className="app-page">
<div className="dashboard-greeting"> <h1>Dashboard</h1>
<div> <p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
<h1 className="page-title" style={{ marginBottom: '6px' }}> Willkommen, {user?.name || user?.email}!
Dashboard </p>
</h1> {profile && <EmailVerificationBanner profile={profile} />}
<p className="muted" style={{ marginTop: 0 }}> {/* Welcome Card */}
Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und Vereinsstruktur. <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
</p> </p>
</div> </div>
</div>
{profile && <EmailVerificationBanner profile={profile} />}
{user?.id && ( {user?.id && (
<div <div
className="dashboard-training-grid" style={{
style={{ display: 'grid',
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))', gap: '1rem',
gap: '1rem', marginBottom: '1.5rem'
alignItems: 'stretch', }}
marginBottom: '1.5rem', >
}} <div className="card">
> <h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
<div className="card">
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
{trainingHomeErr ? ( {trainingHomeErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p> <p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
) : trainingHome?.upcoming?.length ? ( ) : trainingHome?.upcoming?.length ? (
@ -154,12 +153,11 @@ function Dashboard() {
</ul> </ul>
) : ( ) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}> <p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
Keine anstehenden Termine, bei denen du als Leitung oder Co-Trainer dieser Einheit eingetragen Keine anstehenden Termine mit dir als Leitung oder CoTrainer. Unter{' '}
bist. Unter{' '}
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}> <Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Trainingsplanung Trainingsplanung
</Link>{' '} </Link>{' '}
kannst du Zeiträume und Zuordnungen bearbeiten. kannst du den Vereins oder GruppenZeitraum einblenden.
</p> </p>
)} )}
</div> </div>
@ -217,6 +215,43 @@ function Dashboard() {
</div> </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 && ( {version && (
<div className="card"> <div className="card">
<h3>System-Information</h3> <h3>System-Information</h3>

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Eye, Play, History } from 'lucide-react'
import api from '../utils/api' import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav' 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() { export default function MediaWikiImportPage() {
const [activeTab, setActiveTab] = useState('preview') const [activeTab, setActiveTab] = useState('preview')
@ -119,12 +111,32 @@ export default function MediaWikiImportPage() {
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
</p> </p>
<PageSectionNav {/* Tabs */}
ariaLabel="Import-Schritte" <div style={{ borderBottom: '2px solid var(--border)', marginBottom: '24px' }}>
value={activeTab} <div style={{ display: 'flex', gap: '8px' }}>
onChange={setActiveTab} {['preview', 'execute', 'history'].map(tab => (
items={WIKI_IMPORT_TABS} <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>
{/* Error Display */} {/* Error Display */}
{error && ( {error && (

View File

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

View File

@ -4,7 +4,6 @@ import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal' import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import PageSectionNav from '../components/PageSectionNav'
import { import {
defaultSection, defaultSection,
normalizeUnitToForm, normalizeUnitToForm,
@ -664,29 +663,52 @@ export default function TrainingFrameworkProgramEditPage() {
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1> <h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
<details className="framework-edit-intro"> <div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
<summary className="framework-edit-intro__summary"> <p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
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 <strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
Zielen und SessionSlots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '} Zielen und SessionSlots. <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>GruppenPlanung</strong> (Übernahme). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>. <strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>.
</div> </p>
</details> </div>
<div className="framework-edit__tabbar"> <div
<PageSectionNav className="framework-edit__tabbar"
ariaLabel="Bereiche" role="tablist"
value={frameworkTab} aria-label="Bereiche"
onChange={setFrameworkTab} style={
items={[ desktopLayout
{ id: 'meta', label: 'Stammdaten' }, ? { display: 'none' }
{ id: 'plan', label: 'Plan (Ziele & Sessions)' }, : {
]} display: 'flex',
className="page-section-nav--embedded framework-edit__section-nav" 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> </div>
<div <div

View File

@ -100,23 +100,12 @@ export default function TrainingFrameworkProgramsListPage() {
}} }}
> >
<div> <div>
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}> <h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
Trainingsrahmenprogramme <p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
</h1> Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}> <strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme
Vorlagen für Ziele und Sessions die Verknüpfung mit Gruppenterminen erfolgt in der{' '} mit Bezug zum Rahmen).
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
.
</p> </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> </div>
<Link <Link
to="/planning/framework-programs/new" to="/planning/framework-programs/new"
@ -159,9 +148,9 @@ export default function TrainingFrameworkProgramsListPage() {
</Link> </Link>
</div> </div>
) : ( ) : (
<ul className="framework-programs-list"> <ul style={{ listStyle: 'none' }}>
{rows.map((r) => ( {rows.map((r) => (
<li key={r.id} className="card"> <li key={r.id} className="card" style={{ marginBottom: '12px' }}>
<div <div
style={{ style={{
display: 'flex', 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]) => { Object.entries(filters).forEach(([k, v]) => {
if (v === undefined || v === null) return if (v === undefined || v === null) return
if (typeof v === 'boolean') { if (typeof v === 'boolean') {
q.set(k, v ? 'true' : 'false') if (v) q.set(k, 'true')
return return
} }
if (Array.isArray(v)) { if (Array.isArray(v)) {
@ -508,7 +508,7 @@ export async function updateExercise(id, data) {
}) })
} }
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */ /** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) { export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', { return request('/api/exercises/bulk-metadata', {
method: 'PATCH', method: 'PATCH',

View File

@ -1,27 +0,0 @@
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

@ -1,52 +0,0 @@
/**
* 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 // Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.40" export const APP_VERSION = "0.8.36"
export const BUILD_DATE = "2026-05-06" export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = { export const PAGE_VERSIONS = {
LoginPage: "1.0.0", LoginPage: "1.0.0",
Dashboard: "1.0.0", Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0", AccountSettingsPage: "1.0.0",
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
ClubsPage: "1.1.0", ClubsPage: "1.1.0",
SkillsPage: "1.0.0", SkillsPage: "1.0.0",
TrainingPlanningPage: "1.4.0", TrainingPlanningPage: "1.3.1",
TrainingFrameworkProgramsListPage: "1.1.0", TrainingFrameworkProgramsListPage: "1.1.0",
TrainingFrameworkProgramEditPage: "1.5.0", TrainingFrameworkProgramEditPage: "1.5.0",
TrainingUnitRunPage: "1.1.0", TrainingUnitRunPage: "1.1.0",