Compare commits
23 Commits
0c1fbab0ef
...
d04ebee1f6
| Author | SHA1 | Date | |
|---|---|---|---|
| d04ebee1f6 | |||
| b9d27b59b0 | |||
| 518918a6e5 | |||
| cfd40889ac | |||
| 585ee8c90d | |||
| 8eec145393 | |||
| db8af53652 | |||
| 1e1fd80fb7 | |||
| b9ef0395c1 | |||
| 5096eec16b | |||
| 68923b0364 | |||
| 657f73d2c5 | |||
| 8b86021293 | |||
| 35a3f6e18d | |||
| 18a58cb5a5 | |||
| 14884e6e55 | |||
| 2007f3f659 | |||
| d4b9db9520 | |||
| 00b22a756f | |||
| 56ea36ea25 | |||
| 9dbd3cbd5f | |||
| 9e759a28c6 | |||
| c778d21b26 |
|
|
@ -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-05
|
**Stand:** 2026-05-06
|
||||||
**Zweck:** Drift vermeiden – eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen.
|
**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** | Phasen 1–4 dort größtenteils umgesetzt; **Gap-Analyse §3** im alten Dokument historisch lesen – fachlicher Zielabgleich bleibt dort, **operative Reihenfolge** hier. |
|
| **Beziehung zu MULTI_TENANCY-Doc** | Zielbild und Gap-Analyse §3 dort pflegen (**§3.0** = aktueller Umsetzungsstand); **operative Reihenfolge** hier. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -109,7 +109,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
||||||
|
|
||||||
1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin.
|
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 — erste rein-funktionale Tests unter `backend/tests/test_access_layer.py` (ohne DB); Integration folgt.
|
3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — Unit-Tests `backend/tests/test_access_layer.py`; Integration `backend/tests/test_access_layer_integration.py` bei `ACCESS_LAYER_INTEGRATION=1` / CI im Backend-Container.
|
||||||
|
|
||||||
**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||||
|
|
||||||
|
|
@ -122,4 +122,4 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-05-05
|
**Letzte Aktualisierung:** 2026-05-06
|
||||||
|
|
|
||||||
|
|
@ -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-05
|
**Stand:** 2026-05-06
|
||||||
**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008)
|
**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008)
|
||||||
|
|
||||||
**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**.
|
**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**.
|
||||||
|
|
@ -20,60 +20,68 @@ Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Ma
|
||||||
|--------|-----------------------------------|-------------------------|
|
|--------|-----------------------------------|-------------------------|
|
||||||
| `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** |
|
| `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; **technische Durchsetzung** ist noch lückenhaft |
|
| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; Bibliothek **`private` \| `club` \| `official`** technisch über Zugriffsschicht durchgesetzt; **Sparte/community** folgt |
|
||||||
| §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung |
|
| §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-004–008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt |
|
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004–008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt |
|
||||||
| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel – UI-Komplexität kontrolliert einführen |
|
| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel – UI-Komplexität kontrolliert einführen |
|
||||||
|
|
||||||
**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten.
|
**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs skizziert**. Für die **Kernteile Bibliothek und Vereinskontext** ist eine **technische Zugriffsschicht** (`TenantContext`, `club_members`, einheitliche Sichtbarkeits-SQL/-Prüfungen) umgesetzt — Details und Restarbeit (**Sparte**, Konsolidierung der Hilfen, Planungs-/Admin-Flows) siehe §3 und `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Ist-Stand im Code (Gap-Analyse)
|
## 3. Ist-Stand im Code (Gap-Analyse)
|
||||||
|
|
||||||
> **Hinweis:** Dieser Abschnitt beschreibt den Ausgangspunkt vor Ausbauschritten (**Mitgliedschaften, gefilterte Vereinsliste, Teilen von Governance für Übungen/Rahmen/Planung** sind bereits angegangen). Verbindliche **offene Arbeit und Reihenfolge** sind im Dokument [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) festgehalten.
|
> **Hinweis:** Die Unterabschnitte **3.1–3.6** enthalten weiterhin **historische Problemstellungen** (Ausgangsbild). Ergänzend beschreibt **3.0** den **aktualisierten Umsetzungsstand** nach Mitgliedschafts-, Tenant- und Bibliotheksarbeit. Verbindliche **offene Arbeit und Reihenfolge:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md).
|
||||||
|
|
||||||
|
### 3.0 Aktualisierung des Umsetzungsstands (kurz)
|
||||||
|
|
||||||
|
- **Mitgliedschaft:** Tabellen `club_members` und `club_member_roles`; aktiver Verein über Profilfeld und Header `X-Active-Club-Id`; Auflösung in **`TenantContext`** (`tenant_context.py`).
|
||||||
|
- **Bibliothek** (Übungen, Trainingsplan-Vorlagen, Rahmenprogramme u. a.): gemeinsame Leselogik **`library_content_visibility_sql`** / **`library_content_visible_to_profile`** — Vereinsinhalte **`club`** nur bei passendem **`club_id`** und **aktiver Mitgliedschaft** im Objekt-Verein (normale Nutzer ohne gültigen Vereinskontext: kein „beliebiges club“).
|
||||||
|
- **`GET /api/clubs`:** Nicht-Admins sehen nur Vereine mit Mitgliedschaft; **`POST /api/clubs`:** nur **Plattform-Admin**, mit Pflicht **`primary_admin_profile_id`**.
|
||||||
|
- **Organisation** (Sparten/Gruppen): Schreibzugriff über **`can_manage_club_org`** / **`can_plan_in_club`** auf Basis von **`club_member_roles`** (nicht mehr nur globales `admin`).
|
||||||
|
- **Profil-API:** eingeschränktes **`GET /profiles/{id}`**, **`DELETE`**, **`POST /profiles`** (Plattform-Admin / Selbstzugriff) — Details `backend/routers/profiles.py`.
|
||||||
|
- **Tests:** pytest inkl. optionaler Mandanten-Integration (`ACCESS_LAYER_INTEGRATION`); CI-Anbindung siehe `.gitea/workflows/test.yml` (Ausführung im Backend-Container wie Schwesterprojekt).
|
||||||
|
|
||||||
### 3.1 Identität und Rollen
|
### 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`, …).
|
||||||
- **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**.
|
- *(Historisch)* Fehlende Abbildung von Vereinsrollen **ohne** eigene Tabellen.
|
||||||
- Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`).
|
- **Ist:** Zusätzlich **`club_member_roles`** pro Verein (z. B. `club_admin`, `trainer`, …); Sessions liefern weiter **`profile_id`** + globale **`role`** (`auth.py` → `get_session`), Vereinsrechte werden aus Mitgliedschaft abgeleitet.
|
||||||
|
|
||||||
**Konsequenz:** Mehrere Vereine mit unterschiedlichen Rollen pro User sind **nicht modelliert**; ein „Vereinsadmin“ kann nicht sauber von einem reinen Trainer unterschieden werden, sobald beides nur über `profiles.role` laufen soll.
|
**Konsequenz:** Globale Rolle und Vereinsrollen **koexistieren**; Produkt und Code sollten langfristig klar trennen, was nur global vs. nur über Mitgliedschaft gilt (vgl. Zielarchitektur §4).
|
||||||
|
|
||||||
### 3.2 Organisation & APIs
|
### 3.2 Organisation & APIs
|
||||||
|
|
||||||
- `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`).
|
- *(Historisch)* Zu offene Vereinsliste und Club-Anlage für jeden Trainer/User.
|
||||||
- `GET /api/clubs` listiert **alle** Vereine für jeden eingeloggten Nutzer.
|
- **Ist:** siehe **3.0** — gefilterte Liste, eingeschränktes Anlegen, kontextbezogene Organisationsrechte.
|
||||||
- `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:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt.
|
**Konsequenz:** Offene Punkte verlagern sich in **feine Produktregeln** und **Sparten-/Community-Stufen** (ACCESS_LAYER Stufe D bzw. spätere Epics).
|
||||||
|
|
||||||
### 3.3 Trainingsplanung
|
### 3.3 Trainingsplanung
|
||||||
|
|
||||||
- Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`).
|
- Zugriff auf Einheiten weiterhin stark **gruppenbezogen** (`training_groups`, optional **`lead_trainer_profile_id`** auf Einheiten).
|
||||||
- `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ – **kein** generelles Mitgliedschaftsmodell.
|
- Mitgliedschaft/`TenantContext` unterstützen andere Endpoints; **`GET /training-units`** hat **keinen** impliziten Filter nur auf **`effective_club_id`** (Multi-Verein-Kalender; bei Bedarf Query **`club_id`**).
|
||||||
|
|
||||||
**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht.
|
**Konsequenz:** Vereinsweite oder „Administrations“-Planungsaufgaben können weiter ausgebaut werden (eigenes Produkt-Thema; nicht identisch mit Bibliotheks-Governance).
|
||||||
|
|
||||||
### 3.4 Governance / Sichtbarkeit (kritisch)
|
### 3.4 Governance / Sichtbarkeit (Bibliothek)
|
||||||
|
|
||||||
- Übungen (`list_exercises`): Bedingung sinngemäß „official OR club OR created_by = ich“ – **`club` gilt für alle Mandanten**, ohne Prüfung `exercise.club_id` ∈ Vereine des Nutzers.
|
- *(Historisch)* Risiko: **`club`**-Objekte ohne Bindung an **`club_id`** / Mitgliedschaft → mögliche Cross-Tenant-Sicht.
|
||||||
- Detailzugriff `private`: nur Owner – **ok**.
|
- **Ist:** Listen und Detail für die genannten Bibliotheksmodule nutzen die **einheitliche** Logik in **`club_tenancy`** / **`tenant_context`** (siehe **3.0**).
|
||||||
- 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:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen.
|
**Konsequenz:** Die historische „Leak“-Diagnose für **Übungen und Rahmenprogramme** in dieser Form ist **überholt**. Verbleibend: **Konsolidierung auf wenige Hilfsfunktionen** (ACCESS_LAYER Stufe C), **Sparte** als eigene Stufe, ggf. **community**.
|
||||||
|
|
||||||
### 3.5 Frontend
|
### 3.5 Frontend
|
||||||
|
|
||||||
- **Stand 2026-05:** `GET /api/profiles/me` liefert `clubs[]`, `active_club_id`; Frontend setzt `X-Active-Club-Id`. Details und Pflicht zur serverseitigen **TenantContext**-Validierung siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
|
- `GET /api/profiles/me` liefert u. a. **`clubs[]`**, **`active_club_id`**; Client setzt **`X-Active-Club-Id`**. Geschützte Backend-Routen nutzen **`Depends(get_tenant_context)`** wo im Audit festgehalten.
|
||||||
|
|
||||||
### 3.6 Membership (kommerziell/limits)
|
### 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.
|
- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema — bewusst nach ACCESS_LAYER-Plan zurückgestellt.
|
||||||
|
|
||||||
|
**Letzte Überarbeitung dieses Abschnitts (3.x):** 2026-05-06.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen |
|
| 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 | |
|
||||||
|
|
@ -24,7 +25,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
|
|
||||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||||
|
|
||||||
Letzte Änderung: 2026-05-05 — Cursor-Regel + Architektur-/Coding-Pflicht + Script `backend/scripts/check_access_layer_hints.py`; Katalog-Router im Audit als global dokumentiert.
|
Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,33 @@ def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool:
|
||||||
return cur.fetchone() is not None
|
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):
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Session-spezifische Co-Trainer: NULL = wie training_groups.co_trainer_ids; [] = explizit keine Co-Trainer
|
||||||
|
ALTER TABLE training_units
|
||||||
|
ADD COLUMN IF NOT EXISTS assistant_trainer_profile_ids JSONB;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN training_units.assistant_trainer_profile_ids IS
|
||||||
|
'Co-Trainer nur für diese Einheit; NULL vererbt training_groups.co_trainer_ids; leeres Array = keine Co-Trainer';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_training_units_assistant_trainers
|
||||||
|
ON training_units USING GIN (assistant_trainer_profile_ids);
|
||||||
3
backend/migrations/043_profiles_exercise_list_prefs.sql
Normal file
3
backend/migrations/043_profiles_exercise_list_prefs.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Gespeicherte Standard-Filter für die Übungsliste (pro Nutzer)
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS exercise_list_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
@ -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
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import date, time, datetime
|
from datetime import date, time, datetime
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -43,6 +43,10 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
@ -26,6 +28,24 @@ 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 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029
|
# Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029
|
||||||
_CANONICAL_SKILL_LEVELS = frozenset(
|
_CANONICAL_SKILL_LEVELS = frozenset(
|
||||||
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
|
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
|
||||||
|
|
@ -214,21 +234,38 @@ 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 von Sichtbarkeit und/oder Status (z. B. Private → Verein)."""
|
"""Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge)."""
|
||||||
|
|
||||||
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
|
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_visibility_or_status(self):
|
def at_least_one_patch_field(self):
|
||||||
if self.visibility is None and self.status is None:
|
if (
|
||||||
raise ValueError("Mindestens eines der Felder visibility oder status angeben")
|
self.visibility is None
|
||||||
|
and self.status is None
|
||||||
|
and self.focus_area_ids is None
|
||||||
|
and self.style_direction_ids is None
|
||||||
|
and self.training_type_ids is None
|
||||||
|
and self.target_group_ids is None
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Mindestens eines der Felder visibility, status, focus_area_ids, style_direction_ids, "
|
||||||
|
"training_type_ids oder target_group_ids angeben"
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -456,7 +493,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||||||
return exercise
|
return exercise
|
||||||
|
|
||||||
|
|
||||||
def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
|
def assign_exercise_relations(
|
||||||
|
cur,
|
||||||
|
conn,
|
||||||
|
exercise_id: int,
|
||||||
|
data: dict,
|
||||||
|
*,
|
||||||
|
do_commit: bool = True,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Weist M:N Relations für eine Übung zu.
|
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).
|
||||||
|
|
@ -532,13 +576,59 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
if do_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()
|
||||||
|
|
@ -555,6 +645,21 @@ 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 = []
|
||||||
|
|
@ -571,13 +676,107 @@ 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 und/oder Status für viele Übungen auf einmal.
|
Ändert Sichtbarkeit, Status und/oder Katalog-Zuordnungen für viele Übungen auf einmal (REPLACE je Kategorie).
|
||||||
|
|
||||||
|
Zuordnung: Sind z. B. focus_area_ids im Body gesetzt, werden die Fokusbereiche bei den bearbeiteten
|
||||||
|
Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
|
||||||
|
|
||||||
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
|
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).
|
||||||
"""
|
"""
|
||||||
|
|
@ -603,6 +802,33 @@ 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]] = []
|
||||||
|
|
||||||
|
|
@ -612,6 +838,16 @@ 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",
|
||||||
|
|
@ -681,6 +917,8 @@ 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()
|
||||||
|
|
||||||
|
|
@ -721,6 +959,56 @@ 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),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -760,13 +1048,83 @@ 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)
|
||||||
|
|
||||||
fa_ids = _merge_ids(focus_area_ids, focus_area)
|
includes_archived = any(str(x).strip().lower() == "archived" for x in st_list)
|
||||||
if fa_ids:
|
if not include_archived and not includes_archived:
|
||||||
ph = ",".join(["%s"] * len(fa_ids))
|
where.append("COALESCE(e.status, '') <> %s")
|
||||||
|
params.append("archived")
|
||||||
|
|
||||||
|
vis_excl = _normalize_choice_list(
|
||||||
|
list(visibility_exclude_any),
|
||||||
|
_LIST_FILTER_VISIBILITY,
|
||||||
|
"visibility_exclude_any",
|
||||||
|
)
|
||||||
|
if vis_excl:
|
||||||
|
ph = ",".join(["%s"] * len(vis_excl))
|
||||||
|
where.append(f"(e.visibility IS NULL OR LOWER(TRIM(e.visibility::text)) NOT IN ({ph}))")
|
||||||
|
params.extend(vis_excl)
|
||||||
|
|
||||||
|
st_excl = _normalize_choice_list(
|
||||||
|
list(status_exclude_any),
|
||||||
|
_LIST_FILTER_STATUS,
|
||||||
|
"status_exclude_any",
|
||||||
|
)
|
||||||
|
if st_excl:
|
||||||
|
ph = ",".join(["%s"] * len(st_excl))
|
||||||
|
where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
|
||||||
|
params.extend(st_excl)
|
||||||
|
|
||||||
|
focus_only = focus_only_without_focus_areas
|
||||||
|
must_inc = _dedupe_positive_ids(list(focus_area_must_include_ids))
|
||||||
|
must_exc = _dedupe_positive_ids(list(focus_area_must_exclude_ids))
|
||||||
|
fa_or = _merge_ids(focus_area_ids, focus_area)
|
||||||
|
|
||||||
|
if focus_only:
|
||||||
|
if exclude_without_focus:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="focus_only_without_focus_areas schließt exclude_without_focus aus.",
|
||||||
|
)
|
||||||
|
if fa_or:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_ids (ODER-Liste) verwendet werden.",
|
||||||
|
)
|
||||||
|
if must_inc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_include_ids verwendet werden.",
|
||||||
|
)
|
||||||
|
if must_exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_exclude_ids verwendet werden.",
|
||||||
|
)
|
||||||
where.append(
|
where.append(
|
||||||
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
||||||
)
|
)
|
||||||
params.extend(fa_ids)
|
else:
|
||||||
|
if exclude_without_focus:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
||||||
|
)
|
||||||
|
if fa_or:
|
||||||
|
ph = ",".join(["%s"] * len(fa_or))
|
||||||
|
where.append(
|
||||||
|
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(fa_or)
|
||||||
|
for fid in must_inc:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
|
||||||
|
)
|
||||||
|
params.append(fid)
|
||||||
|
if must_exc:
|
||||||
|
ph = ",".join(["%s"] * len(must_exc))
|
||||||
|
where.append(
|
||||||
|
f"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa "
|
||||||
|
f"WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(must_exc)
|
||||||
|
|
||||||
sk_ids = _merge_ids(skill_ids, skill_id)
|
sk_ids = _merge_ids(skill_ids, skill_id)
|
||||||
if sk_ids:
|
if sk_ids:
|
||||||
|
|
@ -776,32 +1134,77 @@ def list_exercises(
|
||||||
)
|
)
|
||||||
params.extend(sk_ids)
|
params.extend(sk_ids)
|
||||||
|
|
||||||
sd_ids = _merge_ids(style_direction_ids, style_direction_id)
|
sd_or = _merge_ids(style_direction_ids, style_direction_id)
|
||||||
if sd_ids:
|
sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids))
|
||||||
ph = ",".join(["%s"] * len(sd_ids))
|
sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_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_ids)
|
params.extend(sd_or)
|
||||||
|
for sid in sd_inc:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||||||
|
"WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
|
||||||
|
)
|
||||||
|
params.append(sid)
|
||||||
|
if sd_exc:
|
||||||
|
ph = ",".join(["%s"] * len(sd_exc))
|
||||||
|
where.append(
|
||||||
|
"NOT EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||||||
|
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(sd_exc)
|
||||||
|
|
||||||
tt_ids = _merge_ids(training_type_ids, training_type_id)
|
tt_or = _merge_ids(training_type_ids, training_type_id)
|
||||||
if tt_ids:
|
tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids))
|
||||||
ph = ",".join(["%s"] * len(tt_ids))
|
tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_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_ids)
|
params.extend(tt_or)
|
||||||
|
for tid in tt_inc:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||||||
|
"WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
|
||||||
|
)
|
||||||
|
params.append(tid)
|
||||||
|
if tt_exc:
|
||||||
|
ph = ",".join(["%s"] * len(tt_exc))
|
||||||
|
where.append(
|
||||||
|
"NOT EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||||||
|
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(tt_exc)
|
||||||
|
|
||||||
tg_ids = _merge_ids(target_group_ids, target_group_id)
|
tg_or = _merge_ids(target_group_ids, target_group_id)
|
||||||
if tg_ids:
|
tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids))
|
||||||
ph = ",".join(["%s"] * len(tg_ids))
|
tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_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_ids)
|
params.extend(tg_or)
|
||||||
|
for gid in tg_inc:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||||||
|
"WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
|
||||||
|
)
|
||||||
|
params.append(gid)
|
||||||
|
if tg_exc:
|
||||||
|
ph = ",".join(["%s"] * len(tg_exc))
|
||||||
|
where.append(
|
||||||
|
"NOT EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||||||
|
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(tg_exc)
|
||||||
|
|
||||||
if skill_min_level is not None or skill_max_level is not None:
|
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
|
||||||
|
|
@ -860,7 +1263,34 @@ 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
|
||||||
|
|
@ -879,6 +1309,9 @@ 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):
|
||||||
|
|
@ -1082,38 +1515,32 @@ def delete_exercise(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Löscht eine Übung.
|
Löscht eine Übung.
|
||||||
Nur Owner oder Admin darf löschen.
|
|
||||||
"""
|
|
||||||
profile_id = tenant.profile_id
|
|
||||||
role = tenant.global_role
|
|
||||||
|
|
||||||
|
Berechtigung: Plattform-Admin (alle); Vereins-Admin Vereins-Übungen seines Vereins;
|
||||||
|
Ersteller nur eigene private Übungen; Vereins-Admin zusätzlich private Übungen von Mitgliedern,
|
||||||
|
mit denen er einen Verein teilt.
|
||||||
|
|
||||||
|
Bei Verwendung in Blöcken, Trainingsplänen oder Progressionsgraphen: 409 — bitte archivieren.
|
||||||
|
"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Existiert die Übung?
|
cur.execute(
|
||||||
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
|
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
|
||||||
|
(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)
|
||||||
|
|
||||||
# Permission Check
|
_assert_can_delete_exercise(cur, tenant, ex)
|
||||||
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")
|
|
||||||
|
|
||||||
# Prüfen ob Übung in Block-Items verwendet wird
|
counts = _exercise_delete_usage_counts(cur, exercise_id)
|
||||||
cur.execute(
|
usage_msg = _exercise_delete_usage_message(counts)
|
||||||
"SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s",
|
if usage_msg:
|
||||||
(exercise_id,)
|
raise HTTPException(status_code=409, detail=usage_msg)
|
||||||
)
|
|
||||||
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ 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
|
||||||
|
|
@ -258,6 +260,15 @@ 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":
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from db import get_db, get_cursor, r2d
|
||||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
from 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,
|
||||||
)
|
)
|
||||||
|
|
@ -53,7 +54,7 @@ def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id:
|
||||||
|
|
||||||
def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None:
|
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 FROM training_groups WHERE id = %s",
|
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||||||
(group_id,),
|
(group_id,),
|
||||||
)
|
)
|
||||||
group = cur.fetchone()
|
group = cur.fetchone()
|
||||||
|
|
@ -64,9 +65,83 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
|
||||||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
|
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:
|
||||||
raise HTTPException(
|
if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role):
|
||||||
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
|
raise HTTPException(
|
||||||
)
|
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM club_members
|
||||||
|
WHERE club_id = %s AND profile_id = %s AND status = 'active'
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(club_id, profile_id),
|
||||||
|
)
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _caller_may_assign_session_trainers(
|
||||||
|
cur,
|
||||||
|
group_row: Dict[str, Any],
|
||||||
|
profile_id: int,
|
||||||
|
role: str,
|
||||||
|
unit_created_by: Optional[int],
|
||||||
|
) -> bool:
|
||||||
|
if is_platform_admin(role):
|
||||||
|
return True
|
||||||
|
cid = group_row.get("club_id")
|
||||||
|
if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role):
|
||||||
|
return True
|
||||||
|
if unit_created_by is not None and unit_created_by == profile_id:
|
||||||
|
return True
|
||||||
|
if group_row.get("trainer_id") == profile_id:
|
||||||
|
return True
|
||||||
|
co = group_row.get("co_trainer_ids") or []
|
||||||
|
return profile_id in co
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]:
|
||||||
|
"""Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard."""
|
||||||
|
unit_asst = unit_row.get("assistant_trainer_profile_ids")
|
||||||
|
if unit_asst is not None:
|
||||||
|
src = unit_asst
|
||||||
|
else:
|
||||||
|
src = unit_row.get("co_trainer_ids") or []
|
||||||
|
seen: set = set()
|
||||||
|
out: List[int] = []
|
||||||
|
for x in src:
|
||||||
|
try:
|
||||||
|
i = int(x)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if i not in seen:
|
||||||
|
seen.add(i)
|
||||||
|
out.append(i)
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def effective_co_trainer_profile_ids_for_merge(
|
||||||
|
unit_assistant: Any, group_co: Any
|
||||||
|
) -> List[int]:
|
||||||
|
"""Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row."""
|
||||||
|
if unit_assistant is not None:
|
||||||
|
src = unit_assistant
|
||||||
|
else:
|
||||||
|
src = group_co or []
|
||||||
|
seen: set = set()
|
||||||
|
out: List[int] = []
|
||||||
|
for x in src:
|
||||||
|
try:
|
||||||
|
i = int(x)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if i not in seen:
|
||||||
|
seen.add(i)
|
||||||
|
out.append(i)
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
||||||
|
|
@ -74,7 +149,8 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
|
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,
|
||||||
tg.trainer_id, tg.co_trainer_ids,
|
tu.assistant_trainer_profile_ids,
|
||||||
|
tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id,
|
||||||
fwp.created_by AS framework_created_by
|
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
|
||||||
|
|
@ -103,26 +179,53 @@ def _assert_training_unit_permission(
|
||||||
return
|
return
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||||
|
|
||||||
co_trainers = unit_row["co_trainer_ids"] or []
|
co_eff = _effective_co_trainer_ids_for_row(unit_row)
|
||||||
if role not in ["admin", "superadmin"]:
|
if role in ["admin", "superadmin"]:
|
||||||
if (
|
return
|
||||||
unit_row["created_by"] != profile_id
|
gcid = unit_row.get("group_club_id")
|
||||||
and unit_row["trainer_id"] != profile_id
|
if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role):
|
||||||
and profile_id not in co_trainers
|
return
|
||||||
and unit_row.get("lead_trainer_profile_id") != profile_id
|
if (
|
||||||
):
|
unit_row["created_by"] != profile_id
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
and unit_row["trainer_id"] != profile_id
|
||||||
|
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: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer."""
|
"""Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
|
||||||
if role in ("admin", "superadmin"):
|
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
|
||||||
|
|
@ -145,8 +248,9 @@ 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 = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext."""
|
"""NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i. d. R. mit Vereinsbezug."""
|
||||||
if raw_lead is None:
|
if raw_lead is None:
|
||||||
return None
|
return None
|
||||||
if raw_lead in ("", []):
|
if raw_lead in ("", []):
|
||||||
|
|
@ -160,27 +264,130 @@ 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"):
|
|
||||||
return nid
|
|
||||||
if nid == profile_id:
|
|
||||||
return nid
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
|
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||||||
(group_id,),
|
(group_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")
|
||||||
eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set()
|
grd = dict(gr)
|
||||||
for x in gr.get("co_trainer_ids") or []:
|
cid = grd.get("club_id")
|
||||||
eligible.add(x)
|
if cid is None:
|
||||||
if nid in eligible:
|
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||||
return nid
|
club_i = int(cid)
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if is_platform_admin(role):
|
||||||
|
return nid
|
||||||
|
|
||||||
|
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
|
||||||
|
for x in grd.get("co_trainer_ids") or []:
|
||||||
|
try:
|
||||||
|
eligible.add(int(x))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if nid == profile_id:
|
||||||
|
if not _profile_active_in_club(cur, club_i, profile_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen",
|
||||||
|
)
|
||||||
|
return nid
|
||||||
|
|
||||||
|
if nid not in eligible:
|
||||||
|
if not _profile_active_in_club(cur, club_i, nid):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe",
|
||||||
|
)
|
||||||
|
if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Keine Berechtigung, die Leitung zuzuweisen",
|
||||||
|
)
|
||||||
|
return nid
|
||||||
|
|
||||||
|
if nid != profile_id and not _caller_may_assign_session_trainers(
|
||||||
|
cur, grd, profile_id, role, unit_created_by
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen")
|
||||||
|
return nid
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_assistant_trainer_profile_ids(
|
||||||
|
cur,
|
||||||
|
group_id: int,
|
||||||
|
raw_val: Any,
|
||||||
|
profile_id: int,
|
||||||
|
role: str,
|
||||||
|
unit_created_by: Optional[int],
|
||||||
|
lead_nid: Optional[int],
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
None = Vererbung aus training_groups.co_trainer_ids (SQL NULL);
|
||||||
|
Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.)
|
||||||
|
"""
|
||||||
|
if raw_val is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw_val, list):
|
||||||
|
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids muss Liste oder null sein")
|
||||||
|
|
||||||
|
ids_in: List[int] = []
|
||||||
|
for x in raw_val:
|
||||||
|
try:
|
||||||
|
i = int(x)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
|
||||||
|
if i < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
|
||||||
|
ids_in.append(i)
|
||||||
|
uniq = sorted(set(ids_in))
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||||||
|
(group_id,),
|
||||||
|
)
|
||||||
|
gr = cur.fetchone()
|
||||||
|
if not gr:
|
||||||
|
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||||
|
grd = dict(gr)
|
||||||
|
cid = grd.get("club_id")
|
||||||
|
if cid is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||||
|
club_i = int(cid)
|
||||||
|
|
||||||
|
if not is_platform_admin(role) and not _caller_may_assign_session_trainers(
|
||||||
|
cur, grd, profile_id, role, unit_created_by
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer zuzuweisen")
|
||||||
|
|
||||||
|
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
|
||||||
|
for x in grd.get("co_trainer_ids") or []:
|
||||||
|
try:
|
||||||
|
eligible.add(int(x))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None)
|
||||||
|
|
||||||
|
for nid in uniq:
|
||||||
|
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=400, detail="Profil für Co-Trainer nicht gefunden")
|
||||||
|
if eff_lead is not None and nid == eff_lead:
|
||||||
|
raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden")
|
||||||
|
if is_platform_admin(role):
|
||||||
|
continue
|
||||||
|
if nid in eligible:
|
||||||
|
continue
|
||||||
|
if not _profile_active_in_club(cur, club_i, nid):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
|
||||||
|
)
|
||||||
|
return uniq
|
||||||
|
|
||||||
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
|
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
|
||||||
_ORIGIN_LINEAGE_JOIN = """
|
_ORIGIN_LINEAGE_JOIN = """
|
||||||
|
|
@ -775,14 +982,18 @@ 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 FROM training_groups WHERE id = %s AND status = 'active'",
|
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'",
|
||||||
(gid,),
|
(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")
|
||||||
cob = gr["co_trainer_ids"] or []
|
gd = dict(gr)
|
||||||
if gr["trainer_id"] != profile_id and profile_id not in cob:
|
cob = gd.get("co_trainer_ids") or []
|
||||||
|
ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob
|
||||||
|
ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role)
|
||||||
|
ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id)
|
||||||
|
if not (ok_staff or ok_org or ok_member):
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
|
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"
|
||||||
|
|
@ -805,6 +1016,8 @@ 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
|
||||||
|
|
@ -820,12 +1033,27 @@ def list_training_units(
|
||||||
where = []
|
where = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if role not in ["admin", "superadmin"]:
|
skip_involvement_filter = role in ("admin", "superadmin")
|
||||||
where.append(
|
if not skip_involvement_filter and cid is not None:
|
||||||
"(tu.created_by = %s OR tg.trainer_id = %s OR "
|
if can_manage_club_org(cur, profile_id, cid, role):
|
||||||
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
|
skip_involvement_filter = True
|
||||||
|
if not skip_involvement_filter and gid is not None:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'",
|
||||||
|
(gid,),
|
||||||
)
|
)
|
||||||
params.extend([profile_id, profile_id, profile_id])
|
gcx = cur.fetchone()
|
||||||
|
if gcx and gcx.get("club_id") is not None:
|
||||||
|
if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role):
|
||||||
|
skip_involvement_filter = True
|
||||||
|
|
||||||
|
if not skip_involvement_filter:
|
||||||
|
where.append(
|
||||||
|
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
|
||||||
|
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
|
||||||
|
"@> jsonb_build_array(%s::int))"
|
||||||
|
)
|
||||||
|
params.extend([profile_id, profile_id, profile_id, profile_id])
|
||||||
|
|
||||||
where.append("tu.framework_slot_id IS NULL")
|
where.append("tu.framework_slot_id IS NULL")
|
||||||
|
|
||||||
|
|
@ -840,7 +1068,8 @@ 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 "
|
||||||
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
|
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
|
||||||
|
"@> jsonb_build_array(%s::int))"
|
||||||
)
|
)
|
||||||
params.extend([profile_id, profile_id])
|
params.extend([profile_id, profile_id])
|
||||||
|
|
||||||
|
|
@ -890,7 +1119,10 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
|
||||||
p.name as creator_name,
|
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
|
||||||
|
|
@ -957,27 +1189,77 @@ 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",
|
||||||
INSERT INTO training_units (
|
(int(group_id),),
|
||||||
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"]
|
||||||
|
|
||||||
|
|
@ -1066,8 +1348,13 @@ 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,
|
||||||
|
|
@ -1075,9 +1362,27 @@ 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"""
|
||||||
|
|
@ -1096,6 +1401,7 @@ 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
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -1113,6 +1419,7 @@ 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,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1152,7 +1459,12 @@ 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,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1167,7 +1479,13 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
|
||||||
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
|
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
|
||||||
)
|
)
|
||||||
|
|
||||||
_assert_delete_training_unit(role, unit["created_by"], profile_id)
|
_assert_delete_training_unit(
|
||||||
|
cur,
|
||||||
|
role,
|
||||||
|
unit["created_by"],
|
||||||
|
profile_id,
|
||||||
|
unit.get("group_club_id"),
|
||||||
|
)
|
||||||
|
|
||||||
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
|
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
131
backend/tests/test_exercises_delete_policy.py
Normal file
131
backend/tests/test_exercises_delete_policy.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""
|
||||||
|
DELETE /api/exercises/{id}: Mandanten-/Rollenlogik und Verwendungsblock (409).
|
||||||
|
|
||||||
|
TestClient mit Overrides für Auth und TenantContext; DB via get_db/get_cursor gemockt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||||
|
|
||||||
|
from auth import require_auth
|
||||||
|
from main import app
|
||||||
|
from tenant_context import TenantContext, get_tenant_context
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_overrides() -> None:
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.pop(require_auth, None)
|
||||||
|
app.dependency_overrides.pop(get_tenant_context, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_db_cm(mock_cur: MagicMock):
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_cm = MagicMock()
|
||||||
|
mock_cm.__enter__.return_value = mock_conn
|
||||||
|
mock_cm.__exit__.return_value = False
|
||||||
|
return mock_cm
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_trainer_private_own_ok(client: TestClient) -> None:
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_cur.fetchone.side_effect = [
|
||||||
|
{"id": 7, "created_by": 42, "visibility": "private", "club_id": None},
|
||||||
|
{"block_items": 0, "section_items": 0, "prog_edges": 0},
|
||||||
|
]
|
||||||
|
mock_cm = _mock_db_cm(mock_cur)
|
||||||
|
|
||||||
|
app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
|
||||||
|
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||||
|
profile_id=42,
|
||||||
|
global_role="trainer",
|
||||||
|
effective_club_id=5,
|
||||||
|
club_ids=frozenset({5}),
|
||||||
|
memberships=[],
|
||||||
|
)
|
||||||
|
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||||
|
"routers.exercises.get_cursor", return_value=mock_cur
|
||||||
|
):
|
||||||
|
r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json().get("ok") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_trainer_club_exercise_forbidden_without_club_admin(client: TestClient) -> None:
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_cur.fetchone.side_effect = [
|
||||||
|
{"id": 7, "created_by": 42, "visibility": "club", "club_id": 5},
|
||||||
|
]
|
||||||
|
mock_cm = _mock_db_cm(mock_cur)
|
||||||
|
|
||||||
|
app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
|
||||||
|
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||||
|
profile_id=42,
|
||||||
|
global_role="trainer",
|
||||||
|
effective_club_id=5,
|
||||||
|
club_ids=frozenset({5}),
|
||||||
|
memberships=[],
|
||||||
|
)
|
||||||
|
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||||
|
"routers.exercises.get_cursor", return_value=mock_cur
|
||||||
|
), patch("routers.exercises.has_club_role", return_value=False):
|
||||||
|
r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_usage_returns_409(client: TestClient) -> None:
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_cur.fetchone.side_effect = [
|
||||||
|
{"id": 7, "created_by": 42, "visibility": "private", "club_id": None},
|
||||||
|
{"block_items": 1, "section_items": 2, "prog_edges": 3},
|
||||||
|
]
|
||||||
|
mock_cm = _mock_db_cm(mock_cur)
|
||||||
|
|
||||||
|
app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
|
||||||
|
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||||
|
profile_id=42,
|
||||||
|
global_role="trainer",
|
||||||
|
effective_club_id=None,
|
||||||
|
club_ids=frozenset(),
|
||||||
|
memberships=[],
|
||||||
|
)
|
||||||
|
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||||
|
"routers.exercises.get_cursor", return_value=mock_cur
|
||||||
|
):
|
||||||
|
r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
detail = r.json().get("detail", "")
|
||||||
|
assert "Übungsblöcken" in detail or "Trainingsplänen" in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_official_forbidden_non_platform_admin(client: TestClient) -> None:
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_cur.fetchone.side_effect = [
|
||||||
|
{"id": 99, "created_by": 1, "visibility": "official", "club_id": None},
|
||||||
|
]
|
||||||
|
mock_cm = _mock_db_cm(mock_cur)
|
||||||
|
|
||||||
|
app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
|
||||||
|
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||||
|
profile_id=42,
|
||||||
|
global_role="trainer",
|
||||||
|
effective_club_id=None,
|
||||||
|
club_ids=frozenset(),
|
||||||
|
memberships=[],
|
||||||
|
)
|
||||||
|
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||||
|
"routers.exercises.get_cursor", return_value=mock_cur
|
||||||
|
):
|
||||||
|
r = client.delete("/api/exercises/99", headers={"X-Auth-Token": "dummy"})
|
||||||
|
assert r.status_code == 403
|
||||||
17
backend/tests/test_training_unit_assignments.py
Normal file
17
backend/tests/test_training_unit_assignments.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Unit-Tests ohne DB: Zusammenführung Session-Co vs. Gruppe."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from routers.training_planning import effective_co_trainer_profile_ids_for_merge
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"unit_side,group_side,expected",
|
||||||
|
[
|
||||||
|
(None, [10, 22], [10, 22]),
|
||||||
|
(None, None, []),
|
||||||
|
([], [10, 22], []),
|
||||||
|
([7, "8", 7], None, [7, 8]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_effective_co_trainer_profile_ids_for_merge(unit_side, group_side, expected):
|
||||||
|
assert effective_co_trainer_profile_ids_for_merge(unit_side, group_side) == expected
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.36"
|
APP_VERSION = "0.8.40"
|
||||||
BUILD_DATE = "2026-05-05"
|
BUILD_DATE = "2026-05-06"
|
||||||
DB_SCHEMA_VERSION = "20260505041"
|
DB_SCHEMA_VERSION = "20260506043"
|
||||||
|
|
||||||
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.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests
|
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
|
||||||
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL)
|
"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.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
|
"exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
|
||||||
"training_units": "0.1.0",
|
"training_units": "0.2.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,6 +27,42 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -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={20} strokeWidth={2} />
|
<item.Icon size={26} strokeWidth={2} />
|
||||||
<span>{item.shortLabel || item.label}</span>
|
<span>{item.shortLabel || item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
1666
frontend/src/app.css
1666
frontend/src/app.css
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,4 @@
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
|
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -6,8 +6,6 @@ import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
|
||||||
* Wechselt zwischen verschiedenen Admin-Seiten
|
* 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 },
|
||||||
|
|
@ -17,51 +15,18 @@ export default function AdminPageNav() {
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{
|
<nav className="admin-top-nav" aria-label="Administration">
|
||||||
display: 'flex',
|
{pages.map((page) => {
|
||||||
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}
|
||||||
style={{
|
className={({ isActive }) =>
|
||||||
padding: '12px 20px',
|
'admin-top-nav__link' + (isActive ? ' admin-top-nav__link--active' : '')
|
||||||
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} />
|
<Icon size={18} strokeWidth={2} aria-hidden />
|
||||||
<span>{page.label}</span>
|
<span>{page.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
28
frontend/src/components/AppSubnavShell.jsx
Normal file
28
frontend/src/components/AppSubnavShell.jsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import PageSectionNav from './PageSectionNav'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-Navigation mit Icon-Chips: gleiche Darstellung wie Stammdaten / Vereine (PageSectionNav).
|
||||||
|
* „Sub-Sub“ (z. B. Editor) bleibt in den jeweiligen Feature-Layouts.
|
||||||
|
*/
|
||||||
|
export default function AppSubnavShell({
|
||||||
|
ariaLabel,
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
iconSize = 18,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="app-subnav-shell">
|
||||||
|
<PageSectionNav
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
items={items}
|
||||||
|
iconSize={iconSize}
|
||||||
|
className="page-section-nav--wrap"
|
||||||
|
/>
|
||||||
|
<div className="app-subnav-shell__main">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
frontend/src/components/CatalogRulePicker.jsx
Normal file
109
frontend/src/components/CatalogRulePicker.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { newCatalogRuleKey } from '../constants/exerciseListFilters'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakte +/- Regeln für Katalogwerte (numerische IDs oder Slugs).
|
||||||
|
* Chips oben, schmales Dropdown, Schalter nur „+“ und „−“.
|
||||||
|
*/
|
||||||
|
export default function CatalogRulePicker({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
options = [],
|
||||||
|
rules = [],
|
||||||
|
rulesFieldName,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = 'Auswählen …',
|
||||||
|
idKind = 'numeric',
|
||||||
|
onPatch,
|
||||||
|
}) {
|
||||||
|
const [pendingId, setPendingId] = useState('')
|
||||||
|
|
||||||
|
const labelFor = (id) => options.find((o) => String(o.id) === String(id))?.label ?? id
|
||||||
|
|
||||||
|
const addRule = (mode) => {
|
||||||
|
const raw = String(pendingId || '').trim()
|
||||||
|
if (!raw || disabled) return
|
||||||
|
if (idKind === 'numeric') {
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isFinite(n) || n < 1) return
|
||||||
|
}
|
||||||
|
const dup = (rules || []).some((r) => String(r.id) === raw && r.mode === mode)
|
||||||
|
if (dup) return
|
||||||
|
onPatch({
|
||||||
|
[rulesFieldName]: [
|
||||||
|
...(rules || []),
|
||||||
|
{ key: newCatalogRuleKey(rulesFieldName), id: raw, mode },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
setPendingId('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRule = (key) => {
|
||||||
|
onPatch({
|
||||||
|
[rulesFieldName]: (rules || []).filter((r) => r.key !== key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`catalog-rule-picker${disabled ? ' catalog-rule-picker--disabled' : ''}`}>
|
||||||
|
<label className="form-label catalog-rule-picker__label">{label}</label>
|
||||||
|
{hint ? (
|
||||||
|
<p className="muted catalog-rule-picker__hint" style={{ fontSize: '11px', marginTop: '2px', marginBottom: '6px' }}>
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="catalog-rule-picker__chips" aria-live="polite">
|
||||||
|
{(rules || []).map((r) => (
|
||||||
|
<span key={r.key} className="catalog-rule-chip">
|
||||||
|
<span className={`catalog-rule-chip__sign catalog-rule-chip__sign--${r.mode}`}>
|
||||||
|
{r.mode === 'forbid' ? '−' : '+'}
|
||||||
|
</span>
|
||||||
|
<span className="catalog-rule-chip__text">{labelFor(r.id)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="catalog-rule-chip__x"
|
||||||
|
aria-label={`${label}: Regel entfernen`}
|
||||||
|
onClick={() => removeRule(r.key)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="catalog-rule-picker__row">
|
||||||
|
<select
|
||||||
|
className="form-input catalog-rule-picker__select"
|
||||||
|
value={pendingId}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setPendingId(e.target.value)}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<option value="">{placeholder}</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.id} value={String(o.id)}>
|
||||||
|
{o.label || o.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
|
||||||
|
disabled={disabled || !pendingId}
|
||||||
|
title="Muss zutreffen"
|
||||||
|
onClick={() => addRule('require')}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
|
||||||
|
disabled={disabled || !pendingId}
|
||||||
|
title="Darf nicht zutreffen"
|
||||||
|
onClick={() => addRule('forbid')}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
frontend/src/components/ExerciseFocusRulePicker.jsx
Normal file
64
frontend/src/components/ExerciseFocusRulePicker.jsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React from 'react'
|
||||||
|
import CatalogRulePicker from './CatalogRulePicker'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fokusbereiche inkl. „nur ohne Zuordnung“; Regeln über CatalogRulePicker (+/−).
|
||||||
|
*/
|
||||||
|
export default function ExerciseFocusRulePicker({
|
||||||
|
focusOptions,
|
||||||
|
focusRules,
|
||||||
|
focusOnlyWithout,
|
||||||
|
legacyFocusAreaIds = [],
|
||||||
|
onPatch,
|
||||||
|
}) {
|
||||||
|
const legacyWarning =
|
||||||
|
Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0 && !focusOnlyWithout
|
||||||
|
|
||||||
|
const setFocusOnly = (on) => {
|
||||||
|
if (on) {
|
||||||
|
onPatch({
|
||||||
|
focus_only_without: true,
|
||||||
|
exclude_without_focus: false,
|
||||||
|
focus_rules: [],
|
||||||
|
focus_area_ids: [],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onPatch({ focus_only_without: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="exercise-focus-rule-picker">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', marginBottom: '10px' }}>
|
||||||
|
<input type="checkbox" checked={!!focusOnlyWithout} onChange={(e) => setFocusOnly(e.target.checked)} />
|
||||||
|
<span>
|
||||||
|
Nur Übungen <strong>ohne</strong> Fokusbereich (keine Zuordnung)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!focusOnlyWithout ? (
|
||||||
|
<>
|
||||||
|
{legacyWarning ? (
|
||||||
|
<p className="muted" style={{ fontSize: '12px', marginTop: 0, marginBottom: '8px' }}>
|
||||||
|
Ältere ODER-Fokusliste aktiv — über die Chips auf der Übersicht entfernen.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<CatalogRulePicker
|
||||||
|
label="Fokusbereiche"
|
||||||
|
hint="+ alle erforderlich (UND). − keine dieser Zuordnungen."
|
||||||
|
options={focusOptions}
|
||||||
|
rules={focusRules}
|
||||||
|
rulesFieldName="focus_rules"
|
||||||
|
idKind="numeric"
|
||||||
|
placeholder="Fokus …"
|
||||||
|
onPatch={onPatch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="muted" style={{ fontSize: '12px', marginTop: 0 }}>
|
||||||
|
Fokus-Regeln sind deaktiviert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,23 +4,22 @@
|
||||||
*/
|
*/
|
||||||
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 = {
|
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||||
focus_area_ids: [],
|
|
||||||
style_direction_ids: [],
|
|
||||||
training_type_ids: [],
|
|
||||||
target_group_ids: [],
|
|
||||||
skill_ids: [],
|
|
||||||
skill_min_level: '',
|
|
||||||
skill_max_level: '',
|
|
||||||
visibility_any: [],
|
|
||||||
status_any: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExercisePickerModal({
|
export default function ExercisePickerModal({
|
||||||
open,
|
open,
|
||||||
|
|
@ -29,6 +28,7 @@ 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,8 +110,10 @@ export default function ExercisePickerModal({
|
||||||
setOffset(0)
|
setOffset(0)
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
setMultiPicked([])
|
setMultiPicked([])
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}, [open])
|
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
||||||
|
}, [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() })),
|
||||||
|
|
@ -156,20 +158,46 @@ 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)
|
|
||||||
if (sd?.length) q.style_direction_ids = sd
|
const sdMn = splitMnCatalogRules(filters.style_direction_rules)
|
||||||
const tt = ids(filters.training_type_ids)
|
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
|
||||||
if (tt?.length) q.training_type_ids = tt
|
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
|
||||||
const tg = ids(filters.target_group_ids)
|
const sdLegacy = ids(filters.style_direction_ids)
|
||||||
if (tg?.length) q.target_group_ids = tg
|
if (sdLegacy?.length) q.style_direction_ids = sdLegacy
|
||||||
|
|
||||||
|
const ttMn = splitMnCatalogRules(filters.training_type_rules)
|
||||||
|
if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
|
||||||
|
if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
|
||||||
|
const ttLegacy = ids(filters.training_type_ids)
|
||||||
|
if (ttLegacy?.length) q.training_type_ids = ttLegacy
|
||||||
|
|
||||||
|
const tgMn = splitMnCatalogRules(filters.target_group_rules)
|
||||||
|
if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
|
||||||
|
if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
|
||||||
|
const tgLegacy = ids(filters.target_group_ids)
|
||||||
|
if (tgLegacy?.length) q.target_group_ids = tgLegacy
|
||||||
|
|
||||||
|
const visMn = splitScalarCatalogRules(filters.visibility_rules)
|
||||||
|
if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
|
||||||
|
if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
|
||||||
|
|
||||||
|
const stMn = splitScalarCatalogRules(filters.status_rules)
|
||||||
|
if (stMn.includeVals.length) q.status_any = stMn.includeVals
|
||||||
|
if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
|
||||||
|
|
||||||
const sk = ids(filters.skill_ids)
|
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.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
|
if (filters.exclude_without_focus) q.exclude_without_focus = true
|
||||||
if (filters.status_any?.length) q.status_any = [...filters.status_any]
|
if (filters.include_archived) q.include_archived = true
|
||||||
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
|
||||||
|
|
@ -182,6 +210,7 @@ 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,
|
||||||
|
|
@ -209,6 +238,7 @@ 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,
|
||||||
|
|
@ -292,45 +322,44 @@ 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' }}>
|
||||||
Zwischen den Bereichen gilt <strong>UND</strong>, innerhalb ODER wie in der Übungsübersicht.
|
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
|
||||||
|
Sichtbarkeit/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
|
||||||
</p>
|
</p>
|
||||||
<div className="exercise-filters-modal-grid">
|
<ExerciseFocusRulePicker
|
||||||
<div>
|
focusOptions={focusOptions}
|
||||||
<label className="form-label">Fokus</label>
|
focusRules={filters.focus_rules}
|
||||||
<MultiSelectCombo
|
focusOnlyWithout={filters.focus_only_without}
|
||||||
value={filters.focus_area_ids}
|
legacyFocusAreaIds={filters.focus_area_ids}
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, focus_area_ids: v }))}
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
options={focusOptions}
|
/>
|
||||||
placeholder="Fokus …"
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||||||
/>
|
<CatalogRulePicker
|
||||||
</div>
|
label="Stilrichtung"
|
||||||
<div>
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
<label className="form-label">Stilrichtung</label>
|
options={styleOptions}
|
||||||
<MultiSelectCombo
|
rules={filters.style_direction_rules}
|
||||||
value={filters.style_direction_ids}
|
rulesFieldName="style_direction_rules"
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
|
placeholder="Stil …"
|
||||||
options={styleOptions}
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
placeholder="Stilrichtung …"
|
/>
|
||||||
/>
|
<CatalogRulePicker
|
||||||
</div>
|
label="Trainingsstil"
|
||||||
<div>
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
<label className="form-label">Trainingsstil</label>
|
options={trainingTypeOptions}
|
||||||
<MultiSelectCombo
|
rules={filters.training_type_rules}
|
||||||
value={filters.training_type_ids}
|
rulesFieldName="training_type_rules"
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
|
placeholder="Trainingsstil …"
|
||||||
options={trainingTypeOptions}
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
placeholder="Trainingsstil …"
|
/>
|
||||||
/>
|
<CatalogRulePicker
|
||||||
</div>
|
label="Zielgruppe"
|
||||||
<div>
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
<label className="form-label">Zielgruppe</label>
|
options={targetGroupOptions}
|
||||||
<MultiSelectCombo
|
rules={filters.target_group_rules}
|
||||||
value={filters.target_group_ids}
|
rulesFieldName="target_group_rules"
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
|
placeholder="Gruppe …"
|
||||||
options={targetGroupOptions}
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
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>
|
||||||
|
|
@ -369,25 +398,54 @@ export default function ExercisePickerModal({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||||||
<div>
|
<CatalogRulePicker
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
label="Sichtbarkeit"
|
||||||
<MultiSelectCombo
|
options={visibilityOptions}
|
||||||
value={filters.visibility_any}
|
rules={filters.visibility_rules}
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
|
rulesFieldName="visibility_rules"
|
||||||
options={visibilityOptions}
|
idKind="string"
|
||||||
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 } : {}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
<span>Ohne Fokus ausblenden</span>
|
||||||
<div>
|
</label>
|
||||||
<label className="form-label">Status</label>
|
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>
|
||||||
<MultiSelectCombo
|
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende
|
||||||
value={filters.status_any}
|
Zuordnungen).
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
|
</p>
|
||||||
options={statusOptions}
|
|
||||||
placeholder="Status …"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
50
frontend/src/components/PageSectionNav.jsx
Normal file
50
frontend/src/components/PageSectionNav.jsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Einheitliche Sektions-Navigation: Chip-Zeile wie Admin-Stammdaten (.admin-page-subtabs).
|
||||||
|
* Für „Tabs“ (role=tablist) oder kompakte Umschalter (aria-pressed, role=group).
|
||||||
|
*/
|
||||||
|
export default function PageSectionNav({
|
||||||
|
ariaLabel,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
items,
|
||||||
|
className = '',
|
||||||
|
iconSize = 16,
|
||||||
|
semantics = 'tabs',
|
||||||
|
}) {
|
||||||
|
const isToggle = semantics === 'toggle'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`admin-page-subtabs page-section-nav ${className}`.trim()}
|
||||||
|
role={isToggle ? 'group' : 'tablist'}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const active = value === item.id
|
||||||
|
const disabled = Boolean(item.disabled)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role={isToggle ? undefined : 'tab'}
|
||||||
|
aria-selected={isToggle ? undefined : active}
|
||||||
|
aria-pressed={isToggle ? active : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
className={
|
||||||
|
'admin-page-subtabs__btn' +
|
||||||
|
(active ? ' admin-page-subtabs__btn--active' : '')
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) onChange(item.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Icon ? (
|
||||||
|
<Icon size={iconSize} strokeWidth={2} className="page-section-nav__icon" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -763,30 +763,34 @@ export default function TrainingUnitSectionsEditor({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showExecutionExtras ? (
|
{showExecutionExtras ? (
|
||||||
<label className="tu-ex-run-block form-label">
|
<div className="tu-ex-debrief">
|
||||||
Ist-Dauer / Anpassungen
|
<div className="tu-ex-debrief__grow">
|
||||||
<span className="tu-ex-run-block__controls">
|
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span>
|
||||||
|
<textarea
|
||||||
|
className="form-input tu-ex-debrief__textarea"
|
||||||
|
rows={3}
|
||||||
|
value={it.modifications || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Was lief anders? Anpassungen für spätere Planung…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tu-ex-debrief__ist">
|
||||||
|
<span className="tu-item-row__meta-label">Ist (Min)</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-input"
|
className="form-input tu-ex-duration"
|
||||||
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 min"
|
placeholder="IST"
|
||||||
|
title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
|
||||||
/>
|
/>
|
||||||
<textarea
|
</div>
|
||||||
className="form-input"
|
</div>
|
||||||
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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,24 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
return (
|
||||||
|
<div className="empty-state" style={{ padding: '2.5rem' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleAssignment(styleDirectionId, targetGroupId, currentlyAssigned) {
|
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,
|
||||||
|
|
@ -37,11 +39,10 @@ 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] = []
|
||||||
|
|
@ -50,30 +51,30 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
|
<div className="admin-assignments-wrap">
|
||||||
<h2 style={{ marginTop: 0 }}>Zuordnungen: Stilrichtungen ↔ Zielgruppen</h2>
|
<h2 className="admin-assignments-wrap__title">Zuordnungen: Stilrichtungen ↔ Zielgruppen</h2>
|
||||||
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '16px' }}>{error}</div>}
|
{error && <div className="admin-matrix-alert">{error}</div>}
|
||||||
|
|
||||||
{targetGroups.length === 0 && (
|
{targetGroups.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
|
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
|
||||||
Keine Zielgruppen vorhanden. Bitte erst im Tab "Kataloge" anlegen.
|
Keine Zielgruppen vorhanden. Bitte zuerst unter <strong>Kataloge</strong> anlegen.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{styleDirections.length === 0 && (
|
{styleDirections.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
|
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
|
||||||
Keine Stilrichtungen vorhanden. Bitte erst im Tab "Hierarchie" anlegen.
|
Keine Stilrichtungen vorhanden. Bitte zuerst unter <strong>Hierarchie</strong> anlegen.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{targetGroups.length > 0 && styleDirections.length > 0 && (
|
{targetGroups.length > 0 && styleDirections.length > 0 && (
|
||||||
<div className="assignment-matrix-container">
|
<div className="admin-assignments-matrix-container">
|
||||||
<table className="assignment-matrix">
|
<table className="admin-assignments-matrix">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 2 }}>Stilrichtung</th>
|
<th className="admin-assignments-matrix__corner">Stilrichtung</th>
|
||||||
{targetGroups.map(tg => (
|
{targetGroups.map((tg) => (
|
||||||
<th key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
|
<th key={tg.id} className="admin-assignments-matrix__th-narrow">
|
||||||
{tg.name}
|
{tg.name}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|
@ -82,17 +83,18 @@ 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 className="focus-area-header">
|
<tr>
|
||||||
<td colSpan={targetGroups.length + 1} style={{ background: 'var(--surface2)', padding: '8px 12px', fontWeight: 600, color: 'var(--text2)' }}>
|
<td
|
||||||
|
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 style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 1, padding: '12px', fontWeight: 500 }}>
|
<td className="admin-assignments-matrix__row-label">{sd.name}</td>
|
||||||
{sd.name}
|
{targetGroups.map((tg) => {
|
||||||
</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' }}>
|
||||||
|
|
@ -101,7 +103,8 @@ 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}
|
||||||
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
|
aria-label={`${sd.name} — ${tg.name}`}
|
||||||
|
style={{ width: '20px', height: '20px', cursor: 'pointer', accentColor: 'var(--accent)' }}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
|
|
@ -114,45 +117,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
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 <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
return (
|
||||||
|
<div className="empty-state" style={{ padding: '2.5rem' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '24px' }}>
|
<div className="admin-catalog-stack">
|
||||||
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface)', borderRadius: '8px' }}>{error}</div>}
|
{error && <div className="admin-matrix-alert">{error}</div>}
|
||||||
|
|
||||||
<CatalogSection
|
<CatalogSection
|
||||||
title="Zielgruppen"
|
title="Zielgruppen"
|
||||||
icon="🎯"
|
Icon={Target}
|
||||||
items={targetGroups}
|
items={targetGroups}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
createFn={api.createTargetGroup}
|
createFn={api.createTargetGroup}
|
||||||
|
|
@ -28,7 +33,7 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
|
||||||
|
|
||||||
<CatalogSection
|
<CatalogSection
|
||||||
title="Fähigkeitskategorien"
|
title="Fähigkeitskategorien"
|
||||||
icon="⚡"
|
Icon={Tags}
|
||||||
items={skillCategories}
|
items={skillCategories}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
createFn={api.createSkillCategory}
|
createFn={api.createSkillCategory}
|
||||||
|
|
@ -42,7 +47,7 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
|
||||||
|
|
||||||
<CatalogSection
|
<CatalogSection
|
||||||
title="Trainingscharakter"
|
title="Trainingscharakter"
|
||||||
icon="💪"
|
Icon={Dumbbell}
|
||||||
items={trainingCharacters}
|
items={trainingCharacters}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
createFn={api.createTrainingCharacter}
|
createFn={api.createTrainingCharacter}
|
||||||
|
|
@ -57,27 +62,27 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
|
function CatalogSection({ title, Icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
|
||||||
const [creating, setCreating] = useState(false)
|
const [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`)
|
||||||
|
|
@ -116,75 +121,116 @@ function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, dele
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
|
<div className="admin-catalog-section">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
<div className="admin-catalog-section__head">
|
||||||
<h3 style={{ margin: 0 }}>{icon} {title}</h3>
|
<h3 className="admin-catalog-section__title">
|
||||||
<button className="btn btn-primary" onClick={startCreate}>+ Neu</button>
|
{Icon ? (
|
||||||
|
<Icon className="admin-catalog-section__icon" size={20} strokeWidth={2} aria-hidden />
|
||||||
|
) : null}
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<button type="button" className="btn btn-primary btn-small" onClick={startCreate}>
|
||||||
|
+ Neu
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{creating && (
|
{creating && (
|
||||||
<div style={{ marginBottom: '20px', padding: '16px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
<div className="admin-catalog-inline-form">
|
||||||
<h4 style={{ marginTop: 0 }}>Neu erstellen</h4>
|
<h4>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">{field.label} {field.required && '*'}</label>
|
<label className="form-label">
|
||||||
|
{field.label} {field.required && '*'}
|
||||||
|
</label>
|
||||||
{field.type === 'textarea' ? (
|
{field.type === 'textarea' ? (
|
||||||
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
value={form[field.key] || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type={field.type}
|
||||||
|
value={form[field.key] || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
<div className="admin-catalog-actions">
|
||||||
<button className="btn btn-primary" onClick={handleCreate}>Erstellen</button>
|
<button type="button" className="btn btn-primary" onClick={handleCreate}>
|
||||||
<button className="btn" onClick={() => setCreating(false)}>Abbrechen</button>
|
Erstellen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '12px' }}>
|
<div className="admin-catalog-list">
|
||||||
{items.map(item => (
|
{items.map((item) => (
|
||||||
<div key={item.id} style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
<div key={item.id} className="admin-catalog-item">
|
||||||
{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 className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
value={form[field.key] || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type={field.type}
|
||||||
|
value={form[field.key] || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
<div className="admin-catalog-actions">
|
||||||
<button className="btn btn-primary" onClick={() => handleUpdate(item.id)}>Speichern</button>
|
<button type="button" className="btn btn-primary" onClick={() => handleUpdate(item.id)}>
|
||||||
<button className="btn" onClick={() => setEditing(null)}>Abbrechen</button>
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setEditing(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div className="admin-catalog-item__name-row">
|
||||||
<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 style={{ marginLeft: '12px', color: 'var(--text3)', fontSize: '14px' }}>
|
<span className="admin-catalog-meta">
|
||||||
Alter: {item.min_age}-{item.max_age}
|
Alter: {item.min_age}-{item.max_age}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{item.description && <p style={{ color: 'var(--text2)', fontSize: '14px', margin: '8px 0' }}>{item.description}</p>}
|
{item.description ? (
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
<p className="admin-catalog-desc">{item.description}</p>
|
||||||
<button className="btn" onClick={() => startEdit(item)}>Bearbeiten</button>
|
) : null}
|
||||||
<button className="btn" onClick={() => handleDelete(item.id, item.name)}>Löschen</button>
|
<div className="admin-catalog-actions">
|
||||||
|
<button type="button" className="btn btn-secondary btn-small" onClick={() => startEdit(item)}>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger btn-small" onClick={() => handleDelete(item.id, item.name)}>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && !creating && (
|
{items.length === 0 && !creating && (
|
||||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '20px' }}>
|
<div className="admin-catalog-empty">Noch keine Einträge vorhanden</div>
|
||||||
Noch keine Einträge vorhanden
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 style={{ padding: '20px', color: 'var(--text3)' }}>Unbekannter Typ: {type}</div>
|
return <div className="detail-panel__unknown">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 style={{ marginTop: 0 }}>Fokusbereich bearbeiten</h2>
|
<h2 className="detail-panel__title">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 style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
<div className="detail-panel__actions">
|
||||||
<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 className="btn" onClick={handleDelete}>Löschen</button>
|
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -130,7 +130,7 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>Stilrichtung bearbeiten</h2>
|
<h2 className="detail-panel__title">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 style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
<div className="detail-panel__actions">
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
|
||||||
{saving ? 'Speichert...' : 'Speichern'}
|
{saving ? 'Speichert...' : 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn" onClick={handleDelete}>Löschen</button>
|
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -212,7 +212,7 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>Trainingstyp bearbeiten</h2>
|
<h2 className="detail-panel__title">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 style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
<div className="detail-panel__actions">
|
||||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
|
||||||
{saving ? 'Speichert...' : 'Speichern'}
|
{saving ? 'Speichert...' : 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn" onClick={handleDelete}>Löschen</button>
|
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -284,8 +284,8 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>Neue Stilrichtung erstellen</h2>
|
<h2 className="detail-panel__title">Neue Stilrichtung erstellen</h2>
|
||||||
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
|
<div className="detail-panel__context">
|
||||||
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 style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
<div className="detail-panel__actions">
|
||||||
<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 style={{ marginTop: 0 }}>Neuen Trainingstyp erstellen</h2>
|
<h2 className="detail-panel__title">Neuen Trainingstyp erstellen</h2>
|
||||||
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
|
<div className="detail-panel__context">
|
||||||
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 style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
<div className="detail-panel__actions">
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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}`
|
||||||
|
|
@ -6,82 +7,94 @@ 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 style={{ marginBottom: '12px' }}>
|
<div className="focus-tree-root">
|
||||||
{/* Focus Area Header */}
|
<div className={'focus-tree-header' + (isSelected ? ' focus-tree-header--selected' : '')}>
|
||||||
<div
|
<button
|
||||||
onClick={() => onSelect(focusArea, 'focus_area')}
|
type="button"
|
||||||
style={{
|
className="focus-tree-toggle"
|
||||||
display: 'flex',
|
aria-expanded={isExpanded}
|
||||||
alignItems: 'center',
|
aria-label={isExpanded ? 'Bereich einklappen' : 'Bereich aufklappen'}
|
||||||
padding: '8px 12px',
|
onClick={(e) => {
|
||||||
borderRadius: '8px',
|
e.stopPropagation()
|
||||||
cursor: 'pointer',
|
onToggle(nodeId)
|
||||||
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 ? (
|
||||||
</span>
|
<ChevronDown size={18} strokeWidth={2} aria-hidden />
|
||||||
<span style={{ marginRight: '8px' }}>{focusArea.icon}</span>
|
) : (
|
||||||
<span>{focusArea.name}</span>
|
<ChevronRight size={18} strokeWidth={2} aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-tree-header__label"
|
||||||
|
onClick={() => onSelect(focusArea, 'focus_area')}
|
||||||
|
>
|
||||||
|
{focusArea.icon ? (
|
||||||
|
<span className="focus-tree-emoji" aria-hidden>
|
||||||
|
{focusArea.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span>{focusArea.name}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Children: Style Directions + Training Types */}
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div style={{ marginLeft: '28px', marginTop: '8px' }}>
|
<div className="focus-tree-children">
|
||||||
{/* Style Directions Section */}
|
<div className="focus-tree-group">
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div className="focus-tree-group__head">
|
||||||
<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
|
||||||
className="btn"
|
type="button"
|
||||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
className="btn btn-secondary btn-tiny focus-tree-add-btn"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onSelect({ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_style_direction')
|
onSelect(
|
||||||
|
{ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name },
|
||||||
|
'create_style_direction'
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ Neu
|
+ Neu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{focusArea.style_directions && focusArea.style_directions.map(sd => (
|
{focusArea.style_directions &&
|
||||||
<StyleDirectionNode
|
focusArea.style_directions.map((sd) => (
|
||||||
key={sd.id}
|
<StyleDirectionNode
|
||||||
styleDirection={sd}
|
key={sd.id}
|
||||||
onSelect={onSelect}
|
styleDirection={sd}
|
||||||
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
|
onSelect={onSelect}
|
||||||
/>
|
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Training Types Section */}
|
<div className="focus-tree-group">
|
||||||
<div>
|
<div className="focus-tree-group__head">
|
||||||
<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
|
||||||
className="btn"
|
type="button"
|
||||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
className="btn btn-secondary btn-tiny focus-tree-add-btn"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onSelect({ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_training_type')
|
onSelect(
|
||||||
|
{ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name },
|
||||||
|
'create_training_type'
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ Neu
|
+ Neu
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{focusArea.training_types && focusArea.training_types.map(tt => (
|
{focusArea.training_types &&
|
||||||
<TrainingTypeNode
|
focusArea.training_types.map((tt) => (
|
||||||
key={tt.id}
|
<TrainingTypeNode
|
||||||
trainingType={tt}
|
key={tt.id}
|
||||||
onSelect={onSelect}
|
trainingType={tt}
|
||||||
isSelected={selectedType === 'training_type' && selectedId === tt.id}
|
onSelect={onSelect}
|
||||||
/>
|
isSelected={selectedType === 'training_type' && selectedId === tt.id}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -92,28 +105,26 @@ 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')}
|
||||||
style={{
|
onKeyDown={(e) => {
|
||||||
padding: '6px 12px',
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
marginBottom: '4px',
|
e.preventDefault()
|
||||||
borderRadius: '6px',
|
onSelect(styleDirection, 'style_direction')
|
||||||
cursor: 'pointer',
|
}
|
||||||
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
|
|
||||||
color: isSelected ? 'white' : 'var(--text1)',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{styleDirection.name}
|
{styleDirection.name}
|
||||||
{styleDirection.abbreviation && (
|
{styleDirection.abbreviation ? (
|
||||||
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
|
<span className="focus-tree-item__abbr">({styleDirection.abbreviation})</span>
|
||||||
({styleDirection.abbreviation})
|
) : null}
|
||||||
</span>
|
{styleDirection.target_groups && styleDirection.target_groups.length > 0 ? (
|
||||||
)}
|
<div className="focus-tree-item__meta">
|
||||||
{styleDirection.target_groups && styleDirection.target_groups.length > 0 && (
|
Zielgruppen: {styleDirection.target_groups.map((tg) => tg.name).join(', ')}
|
||||||
<div style={{ fontSize: '11px', opacity: 0.8, marginTop: '4px' }}>
|
|
||||||
Zielgruppen: {styleDirection.target_groups.map(tg => tg.name).join(', ')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -121,23 +132,21 @@ 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')}
|
||||||
style={{
|
onKeyDown={(e) => {
|
||||||
padding: '6px 12px',
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
marginBottom: '4px',
|
e.preventDefault()
|
||||||
borderRadius: '6px',
|
onSelect(trainingType, 'training_type')
|
||||||
cursor: 'pointer',
|
}
|
||||||
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
|
|
||||||
color: isSelected ? 'white' : 'var(--text1)',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{trainingType.name}
|
{trainingType.name}
|
||||||
{trainingType.abbreviation && (
|
{trainingType.abbreviation ? (
|
||||||
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
|
<span className="focus-tree-item__abbr">({trainingType.abbreviation})</span>
|
||||||
({trainingType.abbreviation})
|
) : null}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,24 @@
|
||||||
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 <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
return (
|
||||||
|
<div className="empty-state" style={{ padding: '2.5rem' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-hierarchy-container">
|
<div className="admin-hierarchy-container admin-hierarchy-layout">
|
||||||
{/* Tree View */}
|
<div className="admin-hierarchy-pane admin-hierarchy-pane--tree" hidden={!!selectedItem}>
|
||||||
<div
|
<h2 className="admin-hierarchy-pane__title">Katalog-Hierarchie</h2>
|
||||||
className="admin-tree-view"
|
{error && <div className="admin-matrix-alert">{error}</div>}
|
||||||
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}
|
||||||
|
|
@ -36,22 +31,15 @@ function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error,
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail Panel */}
|
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<div
|
<div className="admin-hierarchy-pane admin-hierarchy-pane--detail">
|
||||||
style={{
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '20px',
|
|
||||||
background: 'var(--surface)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
className="btn admin-back-button"
|
type="button"
|
||||||
|
className="btn btn-secondary btn-small admin-hierarchy-back"
|
||||||
onClick={() => onSelectItem(null)}
|
onClick={() => onSelectItem(null)}
|
||||||
style={{ marginBottom: '16px' }}
|
|
||||||
>
|
>
|
||||||
← Zurück zur Übersicht
|
<ArrowLeft size={16} strokeWidth={2} aria-hidden />
|
||||||
|
Zurück zur Übersicht
|
||||||
</button>
|
</button>
|
||||||
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
|
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
163
frontend/src/constants/exerciseListFilters.js
Normal file
163
frontend/src/constants/exerciseListFilters.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
|
||||||
|
export const INITIAL_EXERCISE_LIST_FILTERS = {
|
||||||
|
focus_area_ids: [],
|
||||||
|
focus_rules: [],
|
||||||
|
focus_only_without: false,
|
||||||
|
style_direction_ids: [],
|
||||||
|
style_direction_rules: [],
|
||||||
|
training_type_ids: [],
|
||||||
|
training_type_rules: [],
|
||||||
|
target_group_ids: [],
|
||||||
|
target_group_rules: [],
|
||||||
|
skill_ids: [],
|
||||||
|
skill_min_level: '',
|
||||||
|
skill_max_level: '',
|
||||||
|
visibility_any: [],
|
||||||
|
visibility_exclude_any: [],
|
||||||
|
visibility_rules: [],
|
||||||
|
status_any: [],
|
||||||
|
status_exclude_any: [],
|
||||||
|
status_rules: [],
|
||||||
|
exclude_without_focus: false,
|
||||||
|
include_archived: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATALOG_RULE_FIELD_KEYS = [
|
||||||
|
'focus_rules',
|
||||||
|
'style_direction_rules',
|
||||||
|
'training_type_rules',
|
||||||
|
'target_group_rules',
|
||||||
|
'visibility_rules',
|
||||||
|
'status_rules',
|
||||||
|
]
|
||||||
|
|
||||||
|
const PREFS_KEYS = Object.keys(INITIAL_EXERCISE_LIST_FILTERS)
|
||||||
|
|
||||||
|
export function newCatalogRuleKey(prefix = 'r') {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einheitliche Regel-Zeile: { key, id, mode }. Legacy: focus_area_id. */
|
||||||
|
export function normalizeCatalogRule(r, i, prefix = 'r') {
|
||||||
|
if (!r || typeof r !== 'object') return null
|
||||||
|
const id = String(r.id ?? r.focus_area_id ?? '').trim()
|
||||||
|
if (!id) return null
|
||||||
|
const mode = r.mode === 'forbid' ? 'forbid' : 'require'
|
||||||
|
return {
|
||||||
|
key: r.key || newCatalogRuleKey(prefix),
|
||||||
|
id,
|
||||||
|
mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitMnCatalogRules(rules) {
|
||||||
|
const inc = []
|
||||||
|
const exc = []
|
||||||
|
for (const r of rules || []) {
|
||||||
|
const id = Number(r.id ?? r.focus_area_id)
|
||||||
|
if (!Number.isFinite(id) || id < 1) continue
|
||||||
|
if (r.mode === 'forbid') exc.push(id)
|
||||||
|
else inc.push(id)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
includeIds: [...new Set(inc)],
|
||||||
|
excludeIds: [...new Set(exc)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Für visibility/status (einfaches Feld mit einem Wert pro Übung): + → OR (Liste), − → Ausschlussliste. */
|
||||||
|
export function splitScalarCatalogRules(rules) {
|
||||||
|
const inc = []
|
||||||
|
const exc = []
|
||||||
|
for (const r of rules || []) {
|
||||||
|
let id = String(r.id ?? '').trim().toLowerCase()
|
||||||
|
if (!id) continue
|
||||||
|
if (r.mode === 'forbid') exc.push(id)
|
||||||
|
else inc.push(id)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
includeVals: [...new Set(inc)],
|
||||||
|
excludeVals: [...new Set(exc)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruft aus dem Profilfeld exercise_list_prefs einen gültigen Filter-State ab.
|
||||||
|
*/
|
||||||
|
export function mergeExerciseListPrefsFromApi(raw) {
|
||||||
|
const out = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||||
|
if (!raw || typeof raw !== 'object') return out
|
||||||
|
|
||||||
|
for (const key of CATALOG_RULE_FIELD_KEYS) {
|
||||||
|
if (!Array.isArray(raw[key])) continue
|
||||||
|
out[key] = raw[key].map((r, i) => normalizeCatalogRule(r, i, key)).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.focus_only_without !== undefined) out.focus_only_without = !!raw.focus_only_without
|
||||||
|
|
||||||
|
if (!out.visibility_rules.length) {
|
||||||
|
const vr = []
|
||||||
|
;(raw.visibility_any || []).forEach((id, i) => {
|
||||||
|
const n = normalizeCatalogRule({ id, mode: 'require', key: `lv-${i}` }, i, 'visibility_rules')
|
||||||
|
if (n) vr.push(n)
|
||||||
|
})
|
||||||
|
;(raw.visibility_exclude_any || []).forEach((id, i) => {
|
||||||
|
const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lve-${i}` }, i, 'visibility_rules')
|
||||||
|
if (n) vr.push(n)
|
||||||
|
})
|
||||||
|
if (vr.length) out.visibility_rules = vr
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.status_rules.length) {
|
||||||
|
const sr = []
|
||||||
|
;(raw.status_any || []).forEach((id, i) => {
|
||||||
|
const n = normalizeCatalogRule({ id, mode: 'require', key: `ls-${i}` }, i, 'status_rules')
|
||||||
|
if (n) sr.push(n)
|
||||||
|
})
|
||||||
|
;(raw.status_exclude_any || []).forEach((id, i) => {
|
||||||
|
const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lse-${i}` }, i, 'status_rules')
|
||||||
|
if (n) sr.push(n)
|
||||||
|
})
|
||||||
|
if (sr.length) out.status_rules = sr
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of PREFS_KEYS) {
|
||||||
|
if (CATALOG_RULE_FIELD_KEYS.includes(k)) continue
|
||||||
|
if (k === 'focus_only_without') continue
|
||||||
|
if (raw[k] === undefined) continue
|
||||||
|
if (
|
||||||
|
k === 'visibility_any' ||
|
||||||
|
k === 'visibility_exclude_any' ||
|
||||||
|
k === 'status_any' ||
|
||||||
|
k === 'status_exclude_any'
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (k.endsWith('_ids') || k.endsWith('_any')) {
|
||||||
|
if (Array.isArray(raw[k])) out[k] = raw[k].map(String)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (k === 'exclude_without_focus' || k === 'include_archived') {
|
||||||
|
out[k] = !!raw[k]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (k === 'skill_min_level' || k === 'skill_max_level') {
|
||||||
|
out[k] = raw[k] === '' || raw[k] == null ? '' : String(raw[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur von den Defaults abweichende Werte — kompaktes Profil-JSON. */
|
||||||
|
export function compactExerciseListPrefsPayload(filters) {
|
||||||
|
const full = { ...INITIAL_EXERCISE_LIST_FILTERS, ...filters }
|
||||||
|
const o = {}
|
||||||
|
for (const k of PREFS_KEYS) {
|
||||||
|
const v = full[k]
|
||||||
|
const ini = INITIAL_EXERCISE_LIST_FILTERS[k]
|
||||||
|
if (JSON.stringify(v) === JSON.stringify(ini)) continue
|
||||||
|
o[k] = v
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
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')
|
||||||
|
|
@ -316,44 +329,16 @@ export default function AdminCatalogsPage() {
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<AdminPageNav />
|
<AdminPageNav />
|
||||||
|
|
||||||
<h1 style={{ marginBottom: '24px' }}>Stammdaten-Kataloge</h1>
|
<h1 className="page-title">Stammdaten-Kataloge</h1>
|
||||||
|
|
||||||
{/* Tabs */}
|
<PageSectionNav
|
||||||
<div style={{ display: 'flex', gap: '8px', borderBottom: '2px solid var(--border)', marginBottom: '24px', overflowX: 'auto' }}>
|
ariaLabel="Katalogbereiche"
|
||||||
{[
|
value={activeTab}
|
||||||
{ id: 'focus-areas', label: 'Fokusbereiche' },
|
onChange={setActiveTab}
|
||||||
{ id: 'training-styles', label: 'Stilrichtungen' },
|
items={CATALOG_SUBTABS}
|
||||||
{ 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 && (
|
{error && <div className="admin-matrix-alert">{error}</div>}
|
||||||
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '16px' }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
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'
|
||||||
|
|
@ -10,17 +12,14 @@ 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([])
|
||||||
|
|
||||||
|
|
@ -62,7 +61,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)
|
||||||
|
|
@ -86,33 +85,26 @@ function AdminHierarchyPage() {
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const subnavItems = [
|
||||||
{ id: 'hierarchy', label: '🌳 Hierarchie', icon: '🌳' },
|
{ id: 'hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||||
{ id: 'catalogs', label: '📋 Kataloge', icon: '📋' },
|
{ id: 'catalogs', label: 'Kataloge', icon: FolderTree },
|
||||||
{ id: 'assignments', label: '🔗 Zuordnungen', icon: '🔗' }
|
{ id: 'assignments', label: 'Zuordnungen', icon: Link2 }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page admin-hierarchy-page">
|
||||||
<AdminPageNav />
|
<AdminPageNav />
|
||||||
|
|
||||||
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1>
|
<h1 className="page-title" style={{ marginBottom: '12px' }}>
|
||||||
|
Katalog & Hierarchie
|
||||||
|
</h1>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
<AppSubnavShell
|
||||||
<div className="tab-navigation">
|
ariaLabel="Bereich Katalogadministration"
|
||||||
{tabs.map(tab => (
|
items={subnavItems}
|
||||||
<button
|
value={activeTab}
|
||||||
key={tab.id}
|
onChange={setActiveTab}
|
||||||
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}
|
||||||
|
|
@ -147,48 +139,7 @@ function AdminHierarchyPage() {
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</AppSubnavShell>
|
||||||
|
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,14 @@ 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()
|
||||||
|
|
@ -27,44 +35,12 @@ export default function AdminMaturityModelsPage() {
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten">
|
<PageSectionNav
|
||||||
<button
|
ariaLabel="Bereiche Fähigkeiten"
|
||||||
type="button"
|
value={tab}
|
||||||
role="tab"
|
onChange={setTab}
|
||||||
aria-selected={tab === 'catalog'}
|
items={MATURITY_SECTION_TABS}
|
||||||
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' ? (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useMemo } 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' },
|
||||||
|
|
@ -285,9 +286,22 @@ 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 style={{ padding: '2rem', textAlign: 'center' }}>
|
<div className="skills-page__loading">
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
<p>Laden...</p>
|
<p>Laden...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -295,46 +309,21 @@ function ClubsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page clubs-page">
|
||||||
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
|
<h1 className="page-title">Vereinsverwaltung</h1>
|
||||||
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
|
<p className="clubs-page__intro muted">
|
||||||
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>
|
||||||
|
|
||||||
{/* Tabs */}
|
<PageSectionNav
|
||||||
<div style={{
|
ariaLabel="Vereinsverwaltung"
|
||||||
display: 'flex',
|
value={activeTab}
|
||||||
gap: '0.5rem',
|
onChange={setActiveTab}
|
||||||
marginBottom: '1.5rem',
|
items={clubTabItems}
|
||||||
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' }}>
|
||||||
|
|
@ -512,11 +501,13 @@ function ClubsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div
|
||||||
display: 'grid',
|
className="card-grid clubs-groups-card-grid"
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
style={{
|
||||||
gap: '1rem'
|
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||||
}}>
|
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>
|
||||||
|
|
|
||||||
|
|
@ -106,31 +106,32 @@ function Dashboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page dashboard-page">
|
||||||
<h1>Dashboard</h1>
|
<div className="dashboard-greeting">
|
||||||
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
|
<div>
|
||||||
Willkommen, {user?.name || user?.email}!
|
<h1 className="page-title" style={{ marginBottom: '6px' }}>
|
||||||
</p>
|
Dashboard
|
||||||
{profile && <EmailVerificationBanner profile={profile} />}
|
</h1>
|
||||||
{/* Welcome Card */}
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
<div className="card" style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
|
Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und Vereinsstruktur.
|
||||||
<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
|
||||||
style={{
|
className="dashboard-training-grid"
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
|
display: 'grid',
|
||||||
gap: '1rem',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
|
||||||
marginBottom: '1.5rem'
|
gap: '1rem',
|
||||||
}}
|
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 ? (
|
||||||
|
|
@ -153,11 +154,12 @@ 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 mit dir als Leitung oder Co‑Trainer. Unter{' '}
|
Keine anstehenden Termine, bei denen du als Leitung oder Co-Trainer dieser Einheit eingetragen
|
||||||
|
bist. Unter{' '}
|
||||||
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
|
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
|
||||||
Trainingsplanung
|
Trainingsplanung
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
kannst du den Vereins‑ oder Gruppen‑Zeitraum einblenden.
|
kannst du Zeiträume und Zuordnungen bearbeiten.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -215,43 +217,6 @@ 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
|
|
@ -1,6 +1,14 @@
|
||||||
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')
|
||||||
|
|
@ -111,32 +119,12 @@ export default function MediaWikiImportPage() {
|
||||||
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
|
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Tabs */}
|
<PageSectionNav
|
||||||
<div style={{ borderBottom: '2px solid var(--border)', marginBottom: '24px' }}>
|
ariaLabel="Import-Schritte"
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
value={activeTab}
|
||||||
{['preview', 'execute', 'history'].map(tab => (
|
onChange={setActiveTab}
|
||||||
<button
|
items={WIKI_IMPORT_TABS}
|
||||||
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 && (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
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()
|
||||||
|
|
@ -132,7 +138,7 @@ function SkillsPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div className="skills-page__loading">
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
<p>Laden...</p>
|
<p>Laden...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -143,40 +149,22 @@ function SkillsPage() {
|
||||||
const methodsByCategory = groupByCategory(methods)
|
const methodsByCategory = groupByCategory(methods)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page skills-page">
|
||||||
<h1 style={{ marginBottom: '1.5rem' }}>Fähigkeiten & Methoden</h1>
|
<h1 className="page-title">Fähigkeiten & Methoden</h1>
|
||||||
|
|
||||||
{/* Tabs */}
|
<PageSectionNav
|
||||||
<div style={{
|
ariaLabel="Bereich wählen"
|
||||||
display: 'flex',
|
value={activeTab}
|
||||||
gap: '0.5rem',
|
onChange={setActiveTab}
|
||||||
marginBottom: '1.5rem',
|
items={SKILLS_SECTION_TABS}
|
||||||
borderBottom: '2px solid var(--border)'
|
className="skills-page__tabs-scroll"
|
||||||
}}>
|
/>
|
||||||
{['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 style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
<div className="skills-page__intro-row">
|
||||||
<p style={{ color: 'var(--text2)' }}>
|
<p>
|
||||||
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
|
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
|
||||||
</p>
|
</p>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|
@ -188,60 +176,46 @@ function SkillsPage() {
|
||||||
|
|
||||||
{Object.keys(skillsByCategory).length === 0 ? (
|
{Object.keys(skillsByCategory).length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
<p className="skills-page__empty">
|
||||||
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} style={{ marginBottom: '2rem' }}>
|
<div key={category} className="skills-page__category">
|
||||||
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
|
<h2 className="skills-page__category-title">
|
||||||
{category}
|
{category}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{
|
<div className="skills-page__card-grid">
|
||||||
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">
|
<div key={skill.id} className="card skills-page-card">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
|
<div className="skills-page-card__head">
|
||||||
<h3 style={{ fontSize: '1rem' }}>{skill.name}</h3>
|
<h3 className="skills-page-card__title">{skill.name}</h3>
|
||||||
{skill.importance && (
|
{skill.importance && (
|
||||||
<span style={{
|
<span className="skills-page-card__badge">
|
||||||
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 style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
<p className="skills-page-card__desc">
|
||||||
{skill.description}
|
{skill.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
<div className="skills-page-card__actions">
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
type="button"
|
||||||
style={{ flex: 1 }}
|
className="btn btn-secondary skills-page-card__grow"
|
||||||
onClick={() => handleEdit(skill, 'skill')}
|
onClick={() => handleEdit(skill, 'skill')}
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn"
|
type="button"
|
||||||
style={{
|
className="btn btn-danger"
|
||||||
background: 'var(--danger)',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none'
|
|
||||||
}}
|
|
||||||
onClick={() => handleDelete(skill, 'skill')}
|
onClick={() => handleDelete(skill, 'skill')}
|
||||||
>
|
>
|
||||||
Löschen
|
Löschen
|
||||||
|
|
@ -260,8 +234,8 @@ function SkillsPage() {
|
||||||
{/* Methods Tab */}
|
{/* Methods Tab */}
|
||||||
{activeTab === 'methods' && (
|
{activeTab === 'methods' && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
<div className="skills-page__intro-row">
|
||||||
<p style={{ color: 'var(--text2)' }}>
|
<p>
|
||||||
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
|
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
|
||||||
</p>
|
</p>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|
@ -273,52 +247,36 @@ function SkillsPage() {
|
||||||
|
|
||||||
{Object.keys(methodsByCategory).length === 0 ? (
|
{Object.keys(methodsByCategory).length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
<p className="skills-page__empty">
|
||||||
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} style={{ marginBottom: '2rem' }}>
|
<div key={category} className="skills-page__category">
|
||||||
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
|
<h2 className="skills-page__category-title">
|
||||||
{category}
|
{category}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{
|
<div className="skills-page__card-grid skills-page__card-grid--methods">
|
||||||
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">
|
<div key={method.id} className="card skills-page-card">
|
||||||
<div style={{ marginBottom: '0.5rem' }}>
|
<div className="skills-page-card__meta-block">
|
||||||
<h3 style={{ fontSize: '1rem', marginBottom: '0.25rem' }}>
|
<h3 className="skills-page-card__title skills-page-card__title--method">
|
||||||
{method.name}
|
{method.name}
|
||||||
{method.abbreviation && (
|
{method.abbreviation && (
|
||||||
<span style={{ color: 'var(--text2)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
|
<span className="skills-page-card__abbr">
|
||||||
({method.abbreviation})
|
({method.abbreviation})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div className="skills-page-card__meta-row">
|
||||||
{method.typical_duration && (
|
{method.typical_duration && (
|
||||||
<span style={{
|
<span className="skills-page-card__chip">
|
||||||
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 style={{
|
<span className="skills-page-card__chip">
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -326,27 +284,23 @@ function SkillsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{method.description && (
|
{method.description && (
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
<p className="skills-page-card__desc">
|
||||||
{method.description}
|
{method.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
<div className="skills-page-card__actions">
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
type="button"
|
||||||
style={{ flex: 1 }}
|
className="btn btn-secondary skills-page-card__grow"
|
||||||
onClick={() => handleEdit(method, 'method')}
|
onClick={() => handleEdit(method, 'method')}
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn"
|
type="button"
|
||||||
style={{
|
className="btn btn-danger"
|
||||||
background: 'var(--danger)',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none'
|
|
||||||
}}
|
|
||||||
onClick={() => handleDelete(method, 'method')}
|
onClick={() => handleDelete(method, 'method')}
|
||||||
>
|
>
|
||||||
Löschen
|
Löschen
|
||||||
|
|
@ -364,36 +318,37 @@ function SkillsPage() {
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{showModal && isAdmin && (
|
{showModal && isAdmin && (
|
||||||
<div style={{
|
<div
|
||||||
position: 'fixed',
|
className="admin-modal-backdrop"
|
||||||
top: 0,
|
role="presentation"
|
||||||
left: 0,
|
onClick={(e) => {
|
||||||
right: 0,
|
if (e.target === e.currentTarget) setShowModal(false)
|
||||||
bottom: 0,
|
}}
|
||||||
background: 'rgba(0,0,0,0.5)',
|
>
|
||||||
display: 'flex',
|
<div
|
||||||
alignItems: 'center',
|
className="admin-modal-sheet skills-page-modal"
|
||||||
justifyContent: 'center',
|
role="dialog"
|
||||||
zIndex: 1000,
|
aria-modal="true"
|
||||||
padding: '1rem'
|
aria-labelledby="skills-page-modal-title"
|
||||||
}}>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<div style={{
|
>
|
||||||
background: 'var(--surface)',
|
<div className="admin-modal-sheet__header">
|
||||||
borderRadius: '12px',
|
<h2 id="skills-page-modal-title" className="admin-modal-sheet__title">
|
||||||
padding: '2rem',
|
{editing
|
||||||
maxWidth: '600px',
|
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
|
||||||
width: '100%',
|
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
|
||||||
maxHeight: '90vh',
|
}
|
||||||
overflowY: 'auto'
|
</h2>
|
||||||
}}>
|
<button
|
||||||
<h2 style={{ marginBottom: '1.5rem' }}>
|
type="button"
|
||||||
{editing
|
className="btn btn-secondary admin-modal-sheet__close"
|
||||||
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
|
onClick={() => setShowModal(false)}
|
||||||
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
|
>
|
||||||
}
|
Schließen
|
||||||
</h2>
|
</button>
|
||||||
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<div className="admin-modal-sheet__body">
|
||||||
|
<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
|
||||||
|
|
@ -455,7 +410,7 @@ function SkillsPage() {
|
||||||
|
|
||||||
{modalType === 'method' && (
|
{modalType === 'method' && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Typische Dauer (min)</label>
|
<label className="form-label">Typische Dauer (min)</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -492,8 +447,8 @@ function SkillsPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
<div className="skills-page-modal__footer">
|
||||||
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
<button type="submit" className="btn btn-primary skills-page-modal__submit">
|
||||||
{editing ? 'Speichern' : 'Erstellen'}
|
{editing ? 'Speichern' : 'Erstellen'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -505,6 +460,7 @@ function SkillsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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,
|
||||||
|
|
@ -663,52 +664,29 @@ 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>
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
|
<details className="framework-edit-intro">
|
||||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
|
<summary className="framework-edit-intro__summary">
|
||||||
|
Kurz erklärt: Was ist ein Rahmenprogramm?
|
||||||
|
</summary>
|
||||||
|
<div className="framework-edit-intro__body">
|
||||||
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
|
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
|
||||||
Zielen und Session‑Slots. <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
|
Zielen und Session‑Slots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
|
||||||
<strong>Gruppen‑Planung</strong> („Übernahme“). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
|
<strong>Gruppen‑Planung</strong> („Übernahme“). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
|
||||||
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>Zwischen‑Anmerkungen</strong>.
|
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>Zwischen‑Anmerkungen</strong>.
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
<div
|
<div className="framework-edit__tabbar">
|
||||||
className="framework-edit__tabbar"
|
<PageSectionNav
|
||||||
role="tablist"
|
ariaLabel="Bereiche"
|
||||||
aria-label="Bereiche"
|
value={frameworkTab}
|
||||||
style={
|
onChange={setFrameworkTab}
|
||||||
desktopLayout
|
items={[
|
||||||
? { display: 'none' }
|
{ id: 'meta', label: 'Stammdaten' },
|
||||||
: {
|
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
|
||||||
display: 'flex',
|
]}
|
||||||
gap: 6,
|
className="page-section-nav--embedded framework-edit__section-nav"
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -100,12 +100,23 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
|
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
|
Trainingsrahmenprogramme
|
||||||
Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
|
</h1>
|
||||||
<strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme
|
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
|
||||||
mit Bezug zum Rahmen).
|
Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
|
||||||
|
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||||
|
Trainingsplanung
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
</p>
|
</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"
|
||||||
|
|
@ -148,9 +159,9 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ listStyle: 'none' }}>
|
<ul className="framework-programs-list">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<li key={r.id} className="card" style={{ marginBottom: '12px' }}>
|
<li key={r.id} className="card">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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') {
|
||||||
if (v) q.set(k, 'true')
|
q.set(k, v ? 'true' : 'false')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
|
|
@ -508,7 +508,7 @@ export async function updateExercise(id, data) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
|
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
|
||||||
export async function bulkPatchExercisesMetadata(data) {
|
export async function bulkPatchExercisesMetadata(data) {
|
||||||
return request('/api/exercises/bulk-metadata', {
|
return request('/api/exercises/bulk-metadata', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|
|
||||||
27
frontend/src/utils/exercisePermissions.js
Normal file
27
frontend/src/utils/exercisePermissions.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
function userIsClubAdminForClub(user, clubId) {
|
||||||
|
if (clubId == null || user == null) return false
|
||||||
|
const cid = Number(clubId)
|
||||||
|
const row = (user.clubs || []).find((c) => Number(c.id) === cid)
|
||||||
|
return Array.isArray(row?.roles) && row.roles.includes('club_admin')
|
||||||
|
}
|
||||||
|
|
||||||
|
function userHasAnyClubAdminRole(user) {
|
||||||
|
return (user?.clubs || []).some((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ob die Löschen-Aktion in der Liste sinnvoll angeboten werden kann (Server hat letzte Instanz).
|
||||||
|
*/
|
||||||
|
export function canUserRequestExerciseDelete(user, exercise) {
|
||||||
|
if (!user || !exercise) return false
|
||||||
|
const role = String(user.role || '').toLowerCase()
|
||||||
|
if (role === 'admin' || role === 'superadmin') return true
|
||||||
|
const vis = exercise.visibility || 'private'
|
||||||
|
const mine = Number(exercise.created_by) === Number(user.id)
|
||||||
|
if (vis === 'official') return false
|
||||||
|
if (vis === 'club') {
|
||||||
|
return userIsClubAdminForClub(user, exercise.club_id)
|
||||||
|
}
|
||||||
|
if (mine) return true
|
||||||
|
return userHasAnyClubAdminRole(user)
|
||||||
|
}
|
||||||
52
frontend/src/utils/sanitizeHtml.js
Normal file
52
frontend/src/utils/sanitizeHtml.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute).
|
||||||
|
* Für Anzeige mit dangerouslySetInnerHTML.
|
||||||
|
*/
|
||||||
|
const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li'])
|
||||||
|
|
||||||
|
function cleanTree(parent) {
|
||||||
|
const nodes = Array.from(parent.childNodes)
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) continue
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
parent.removeChild(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const tag = node.tagName.toLowerCase()
|
||||||
|
if (!ALLOWED_TAGS.has(tag)) {
|
||||||
|
while (node.firstChild) {
|
||||||
|
parent.insertBefore(node.firstChild, node)
|
||||||
|
}
|
||||||
|
parent.removeChild(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
while (node.attributes.length > 0) {
|
||||||
|
node.removeAttribute(node.attributes[0].name)
|
||||||
|
}
|
||||||
|
cleanTree(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeExerciseRichText(html) {
|
||||||
|
if (html == null || typeof html !== 'string') return ''
|
||||||
|
const trimmed = html.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
|
||||||
|
const tpl = document.createElement('template')
|
||||||
|
tpl.innerHTML = trimmed
|
||||||
|
cleanTree(tpl.content)
|
||||||
|
return tpl.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceApiNameList(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(String).filter((s) => s.trim())
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(value)
|
||||||
|
if (Array.isArray(p)) return p.map(String).filter((s) => s.trim())
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// Shinkan Jinkendo Frontend Version
|
||||||
|
|
||||||
export const APP_VERSION = "0.8.36"
|
export const APP_VERSION = "0.8.40"
|
||||||
export const BUILD_DATE = "2026-05-05"
|
export const BUILD_DATE = "2026-05-06"
|
||||||
|
|
||||||
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.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
|
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
|
||||||
ClubsPage: "1.1.0",
|
ClubsPage: "1.1.0",
|
||||||
SkillsPage: "1.0.0",
|
SkillsPage: "1.0.0",
|
||||||
TrainingPlanningPage: "1.3.1",
|
TrainingPlanningPage: "1.4.0",
|
||||||
TrainingFrameworkProgramsListPage: "1.1.0",
|
TrainingFrameworkProgramsListPage: "1.1.0",
|
||||||
TrainingFrameworkProgramEditPage: "1.5.0",
|
TrainingFrameworkProgramEditPage: "1.5.0",
|
||||||
TrainingUnitRunPage: "1.1.0",
|
TrainingUnitRunPage: "1.1.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user