UX - Filter #12
|
|
@ -1,7 +1,7 @@
|
|||
# Einheitliche Zugriffsschicht & Governance – Umsetzungsplan
|
||||
|
||||
**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`)
|
||||
**Stand:** 2026-05-05
|
||||
**Stand:** 2026-05-06
|
||||
**Zweck:** Drift vermeiden – eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen.
|
||||
|
||||
**Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) – kommt nach stabiler Zugriffs- und Datenisolationsbasis.
|
||||
|
|
@ -101,7 +101,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
| **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? |
|
||||
| **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. |
|
||||
| **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). |
|
||||
| **Beziehung zu MULTI_TENANCY-Doc** | Phasen 1–4 dort größtenteils umgesetzt; **Gap-Analyse §3** im alten Dokument historisch lesen – fachlicher Zielabgleich bleibt dort, **operative Reihenfolge** hier. |
|
||||
| **Beziehung zu MULTI_TENANCY-Doc** | Zielbild und Gap-Analyse §3 dort pflegen (**§3.0** = aktueller Umsetzungsstand); **operative Reihenfolge** hier. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
|
||||
1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin.
|
||||
2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen).
|
||||
3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — erste rein-funktionale Tests unter `backend/tests/test_access_layer.py` (ohne DB); Integration folgt.
|
||||
3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — Unit-Tests `backend/tests/test_access_layer.py`; Integration `backend/tests/test_access_layer_integration.py` bei `ACCESS_LAYER_INTEGRATION=1` / CI im Backend-Container.
|
||||
|
||||
**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||
|
||||
|
|
@ -122,4 +122,4 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-05-05
|
||||
**Letzte Aktualisierung:** 2026-05-06
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Multi-Tenancy, Vereins-Membership und Rollenmodell – Zielarchitektur & Umsetzungsplan
|
||||
|
||||
**Status:** verbindliche Zielrichtung (Architekturpapier)
|
||||
**Stand:** 2026-05-05
|
||||
**Stand:** 2026-05-06
|
||||
**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §17–18 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004–008)
|
||||
|
||||
**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) – dort sind Stufen A–F, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**.
|
||||
|
|
@ -20,60 +20,68 @@ Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Ma
|
|||
|--------|-----------------------------------|-------------------------|
|
||||
| `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** |
|
||||
| §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** |
|
||||
| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; **technische Durchsetzung** ist noch lückenhaft |
|
||||
| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; Bibliothek **`private` \| `club` \| `official`** technisch über Zugriffsschicht durchgesetzt; **Sparte/community** folgt |
|
||||
| §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung |
|
||||
| `DOMAIN_MODEL.md` §5.5 | Freigabeebenen inkl. Sparte | Zielbild; DB/API nutzen derzeit überwiegend `private` \| `club` \| `official` |
|
||||
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004–008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt |
|
||||
| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel – UI-Komplexität kontrolliert einführen |
|
||||
|
||||
**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten.
|
||||
**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs skizziert**. Für die **Kernteile Bibliothek und Vereinskontext** ist eine **technische Zugriffsschicht** (`TenantContext`, `club_members`, einheitliche Sichtbarkeits-SQL/-Prüfungen) umgesetzt — Details und Restarbeit (**Sparte**, Konsolidierung der Hilfen, Planungs-/Admin-Flows) siehe §3 und `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Ist-Stand im Code (Gap-Analyse)
|
||||
|
||||
> **Hinweis:** Dieser Abschnitt beschreibt den Ausgangspunkt vor Ausbauschritten (**Mitgliedschaften, gefilterte Vereinsliste, Teilen von Governance für Übungen/Rahmen/Planung** sind bereits angegangen). Verbindliche **offene Arbeit und Reihenfolge** sind im Dokument [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) festgehalten.
|
||||
> **Hinweis:** Die Unterabschnitte **3.1–3.6** enthalten weiterhin **historische Problemstellungen** (Ausgangsbild). Ergänzend beschreibt **3.0** den **aktualisierten Umsetzungsstand** nach Mitgliedschafts-, Tenant- und Bibliotheksarbeit. Verbindliche **offene Arbeit und Reihenfolge:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md).
|
||||
|
||||
### 3.0 Aktualisierung des Umsetzungsstands (kurz)
|
||||
|
||||
- **Mitgliedschaft:** Tabellen `club_members` und `club_member_roles`; aktiver Verein über Profilfeld und Header `X-Active-Club-Id`; Auflösung in **`TenantContext`** (`tenant_context.py`).
|
||||
- **Bibliothek** (Übungen, Trainingsplan-Vorlagen, Rahmenprogramme u. a.): gemeinsame Leselogik **`library_content_visibility_sql`** / **`library_content_visible_to_profile`** — Vereinsinhalte **`club`** nur bei passendem **`club_id`** und **aktiver Mitgliedschaft** im Objekt-Verein (normale Nutzer ohne gültigen Vereinskontext: kein „beliebiges club“).
|
||||
- **`GET /api/clubs`:** Nicht-Admins sehen nur Vereine mit Mitgliedschaft; **`POST /api/clubs`:** nur **Plattform-Admin**, mit Pflicht **`primary_admin_profile_id`**.
|
||||
- **Organisation** (Sparten/Gruppen): Schreibzugriff über **`can_manage_club_org`** / **`can_plan_in_club`** auf Basis von **`club_member_roles`** (nicht mehr nur globales `admin`).
|
||||
- **Profil-API:** eingeschränktes **`GET /profiles/{id}`**, **`DELETE`**, **`POST /profiles`** (Plattform-Admin / Selbstzugriff) — Details `backend/routers/profiles.py`.
|
||||
- **Tests:** pytest inkl. optionaler Mandanten-Integration (`ACCESS_LAYER_INTEGRATION`); CI-Anbindung siehe `.gitea/workflows/test.yml` (Ausführung im Backend-Container wie Schwesterprojekt).
|
||||
|
||||
### 3.1 Identität und Rollen
|
||||
|
||||
- `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …).
|
||||
- **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**.
|
||||
- Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`).
|
||||
- *(Historisch)* Fehlende Abbildung von Vereinsrollen **ohne** eigene Tabellen.
|
||||
- **Ist:** Zusätzlich **`club_member_roles`** pro Verein (z. B. `club_admin`, `trainer`, …); Sessions liefern weiter **`profile_id`** + globale **`role`** (`auth.py` → `get_session`), Vereinsrechte werden aus Mitgliedschaft abgeleitet.
|
||||
|
||||
**Konsequenz:** Mehrere Vereine mit unterschiedlichen Rollen pro User sind **nicht modelliert**; ein „Vereinsadmin“ kann nicht sauber von einem reinen Trainer unterschieden werden, sobald beides nur über `profiles.role` laufen soll.
|
||||
**Konsequenz:** Globale Rolle und Vereinsrollen **koexistieren**; Produkt und Code sollten langfristig klar trennen, was nur global vs. nur über Mitgliedschaft gilt (vgl. Zielarchitektur §4).
|
||||
|
||||
### 3.2 Organisation & APIs
|
||||
|
||||
- `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`).
|
||||
- `GET /api/clubs` listiert **alle** Vereine für jeden eingeloggten Nutzer.
|
||||
- `POST /api/clubs` erlaubt Anlage für `trainer` und `user` – **nicht** nur Systemadmin.
|
||||
- Sparten/Gruppen: Schreibzugriff über globale `admin`/`superadmin`, nicht über **Vereinsadmin** im Kontext „sein Verein“.
|
||||
- *(Historisch)* Zu offene Vereinsliste und Club-Anlage für jeden Trainer/User.
|
||||
- **Ist:** siehe **3.0** — gefilterte Liste, eingeschränktes Anlegen, kontextbezogene Organisationsrechte.
|
||||
|
||||
**Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt.
|
||||
**Konsequenz:** Offene Punkte verlagern sich in **feine Produktregeln** und **Sparten-/Community-Stufen** (ACCESS_LAYER Stufe D bzw. spätere Epics).
|
||||
|
||||
### 3.3 Trainingsplanung
|
||||
|
||||
- Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`).
|
||||
- `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ – **kein** generelles Mitgliedschaftsmodell.
|
||||
- Zugriff auf Einheiten weiterhin stark **gruppenbezogen** (`training_groups`, optional **`lead_trainer_profile_id`** auf Einheiten).
|
||||
- Mitgliedschaft/`TenantContext` unterstützen andere Endpoints; **`GET /training-units`** hat **keinen** impliziten Filter nur auf **`effective_club_id`** (Multi-Verein-Kalender; bei Bedarf Query **`club_id`**).
|
||||
|
||||
**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht.
|
||||
**Konsequenz:** Vereinsweite oder „Administrations“-Planungsaufgaben können weiter ausgebaut werden (eigenes Produkt-Thema; nicht identisch mit Bibliotheks-Governance).
|
||||
|
||||
### 3.4 Governance / Sichtbarkeit (kritisch)
|
||||
### 3.4 Governance / Sichtbarkeit (Bibliothek)
|
||||
|
||||
- Übungen (`list_exercises`): Bedingung sinngemäß „official OR club OR created_by = ich“ – **`club` gilt für alle Mandanten**, ohne Prüfung `exercise.club_id` ∈ Vereine des Nutzers.
|
||||
- Detailzugriff `private`: nur Owner – **ok**.
|
||||
- Rahmenprogramme (`training_framework_programs`): Lesen fremder Rahmen über `visibility=club` ist in `_framework_access` **nicht** gelöst (faktisch stark creator-basiert für Nicht-Admins).
|
||||
- *(Historisch)* Risiko: **`club`**-Objekte ohne Bindung an **`club_id`** / Mitgliedschaft → mögliche Cross-Tenant-Sicht.
|
||||
- **Ist:** Listen und Detail für die genannten Bibliotheksmodule nutzen die **einheitliche** Logik in **`club_tenancy`** / **`tenant_context`** (siehe **3.0**).
|
||||
|
||||
**Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen.
|
||||
**Konsequenz:** Die historische „Leak“-Diagnose für **Übungen und Rahmenprogramme** in dieser Form ist **überholt**. Verbleibend: **Konsolidierung auf wenige Hilfsfunktionen** (ACCESS_LAYER Stufe C), **Sparte** als eigene Stufe, ggf. **community**.
|
||||
|
||||
### 3.5 Frontend
|
||||
|
||||
- **Stand 2026-05:** `GET /api/profiles/me` liefert `clubs[]`, `active_club_id`; Frontend setzt `X-Active-Club-Id`. Details und Pflicht zur serverseitigen **TenantContext**-Validierung siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
|
||||
- `GET /api/profiles/me` liefert u. a. **`clubs[]`**, **`active_club_id`**; Client setzt **`X-Active-Club-Id`**. Geschützte Backend-Routen nutzen **`Depends(get_tenant_context)`** wo im Audit festgehalten.
|
||||
|
||||
### 3.6 Membership (kommerziell/limits)
|
||||
|
||||
- Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**.
|
||||
- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema.
|
||||
- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema — bewusst nach ACCESS_LAYER-Plan zurückgestellt.
|
||||
|
||||
**Letzte Überarbeitung dieses Abschnitts (3.x):** 2026-05-06.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen |
|
||||
|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------|
|
||||
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
|
||||
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
|
||||
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
|
||||
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
|
||||
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
|
||||
|
|
@ -24,7 +25,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
|
||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||
|
||||
Letzte Änderung: 2026-05-05 — Cursor-Regel + Architektur-/Coding-Pflicht + Script `backend/scripts/check_access_layer_hints.py`; Katalog-Router im Audit als global dokumentiert.
|
||||
Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,33 @@ def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool:
|
|||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def club_admin_shares_club_with_creator(
|
||||
cur, club_admin_profile_id: int, creator_profile_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
True, wenn club_admin_profile_id in mindestens einem Verein die Rolle club_admin hat und
|
||||
creator_profile_id dort ebenfalls aktives Mitglied ist (z. B. Löschen fremder privater Übungen).
|
||||
"""
|
||||
if club_admin_profile_id == creator_profile_id:
|
||||
return False
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM club_members cm_admin
|
||||
INNER JOIN club_member_roles r
|
||||
ON r.club_member_id = cm_admin.id AND r.role_code = 'club_admin'
|
||||
INNER JOIN club_members cm_creator
|
||||
ON cm_creator.club_id = cm_admin.club_id
|
||||
AND cm_creator.profile_id = %s
|
||||
AND cm_creator.status = 'active'
|
||||
WHERE cm_admin.profile_id = %s AND cm_admin.status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(creator_profile_id, club_admin_profile_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
|
||||
"""Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin."""
|
||||
if is_platform_admin(global_role):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import date, time, datetime
|
||||
|
||||
# ============================================================================
|
||||
|
|
@ -43,6 +43,10 @@ class ProfileUpdate(BaseModel):
|
|||
description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)",
|
||||
)
|
||||
tier: Optional[str] = Field(default=None, max_length=50)
|
||||
exercise_list_prefs: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description="JSON: gespeicherte Standardfilter für die Übungsliste",
|
||||
)
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: int
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ from pydantic import BaseModel, Field, model_validator
|
|||
from db import get_db, get_cursor, r2d
|
||||
from club_tenancy import (
|
||||
assert_valid_governance_visibility,
|
||||
club_admin_shares_club_with_creator,
|
||||
has_club_role,
|
||||
is_platform_admin,
|
||||
library_content_visible_to_profile,
|
||||
)
|
||||
|
|
@ -26,6 +28,24 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter(prefix="/api", tags=["exercises"])
|
||||
|
||||
|
||||
def _coerce_json_str_list(val: Any) -> List[str]:
|
||||
"""JSON-Aggregat oder JSON-String aus PG in eine saubere str-Liste für die Listen-API."""
|
||||
if val is None:
|
||||
return []
|
||||
if isinstance(val, list):
|
||||
return [str(x) for x in val if x is not None and str(x).strip()]
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
if isinstance(parsed, list):
|
||||
return [str(x) for x in parsed if x is not None and str(x).strip()]
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
# Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029
|
||||
_CANONICAL_SKILL_LEVELS = frozenset(
|
||||
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
|
||||
|
|
@ -214,21 +234,38 @@ class ExerciseVariantsReorder(BaseModel):
|
|||
|
||||
|
||||
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
|
||||
_LIST_FILTER_VISIBILITY = frozenset({"private", "club", "official"})
|
||||
_LIST_FILTER_STATUS = frozenset({"draft", "in_review", "approved", "archived"})
|
||||
_MAX_BULK_METADATA_IDS = 500
|
||||
_MAX_BULK_RELATION_IDS_PER_KIND = 80
|
||||
|
||||
|
||||
class ExerciseBulkMetadataPatch(BaseModel):
|
||||
"""Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein)."""
|
||||
"""Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge)."""
|
||||
|
||||
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
|
||||
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
|
||||
status: Optional[str] = None
|
||||
club_id: Optional[int] = Field(default=None, ge=1)
|
||||
focus_area_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
|
||||
style_direction_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
|
||||
training_type_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
|
||||
target_group_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def at_least_visibility_or_status(self):
|
||||
if self.visibility is None and self.status is None:
|
||||
raise ValueError("Mindestens eines der Felder visibility oder status angeben")
|
||||
def at_least_one_patch_field(self):
|
||||
if (
|
||||
self.visibility is None
|
||||
and self.status is None
|
||||
and self.focus_area_ids is None
|
||||
and self.style_direction_ids is None
|
||||
and self.training_type_ids is None
|
||||
and self.target_group_ids is None
|
||||
):
|
||||
raise ValueError(
|
||||
"Mindestens eines der Felder visibility, status, focus_area_ids, style_direction_ids, "
|
||||
"training_type_ids oder target_group_ids angeben"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
|
|
@ -456,7 +493,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
|||
return exercise
|
||||
|
||||
|
||||
def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
|
||||
def assign_exercise_relations(
|
||||
cur,
|
||||
conn,
|
||||
exercise_id: int,
|
||||
data: dict,
|
||||
*,
|
||||
do_commit: bool = True,
|
||||
):
|
||||
"""
|
||||
Weist M:N Relations für eine Übung zu.
|
||||
Löscht alte Zuordnungen und legt neue an (REPLACE-Logik).
|
||||
|
|
@ -532,13 +576,59 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
|
|||
)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
if do_commit:
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _normalize_bulk_id_list(raw: Optional[list]) -> list[int]:
|
||||
"""Positive IDs, Reihenfolge beibehalten, Duplikate entfernen."""
|
||||
if not raw:
|
||||
return []
|
||||
seen: set[int] = set()
|
||||
out: list[int] = []
|
||||
for x in raw:
|
||||
try:
|
||||
xi = int(x)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if xi < 1 or xi in seen:
|
||||
continue
|
||||
seen.add(xi)
|
||||
out.append(xi)
|
||||
return out
|
||||
|
||||
|
||||
def _assert_catalog_ids_exist(cur, kind: str, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
return
|
||||
table_by_kind = {
|
||||
"focus_areas": "focus_areas",
|
||||
"style_directions": "style_directions",
|
||||
"training_types": "training_types",
|
||||
"target_groups": "target_groups",
|
||||
}
|
||||
table = table_by_kind.get(kind)
|
||||
if not table:
|
||||
raise HTTPException(status_code=500, detail="Interner Fehler: unbekannter Katalog")
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", tuple(ids))
|
||||
found = {
|
||||
int(r["id"]) if isinstance(r, dict) else int(r[0])
|
||||
for r in cur.fetchall()
|
||||
}
|
||||
missing = [i for i in ids if i not in found]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unbekannte {kind}-IDs (Beispiele): {missing[:12]}",
|
||||
)
|
||||
|
||||
|
||||
def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
|
||||
"""Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate)."""
|
||||
seen: set[int] = set()
|
||||
|
|
@ -555,6 +645,21 @@ def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
|
|||
return out
|
||||
|
||||
|
||||
def _dedupe_positive_ids(ids: list[int]) -> list[int]:
|
||||
seen: set[int] = set()
|
||||
out: list[int] = []
|
||||
for raw in ids or []:
|
||||
try:
|
||||
xi = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if xi < 1 or xi in seen:
|
||||
continue
|
||||
seen.add(xi)
|
||||
out.append(xi)
|
||||
return out
|
||||
|
||||
|
||||
def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
|
||||
seen = set()
|
||||
out = []
|
||||
|
|
@ -571,13 +676,107 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
|
|||
return out
|
||||
|
||||
|
||||
def _normalize_choice_list(raw: list[str], allowed: frozenset, label: str) -> list[str]:
|
||||
out = []
|
||||
seen = set()
|
||||
for x in raw or []:
|
||||
s = str(x).strip().lower()
|
||||
if not s or s in seen:
|
||||
continue
|
||||
if s not in allowed:
|
||||
raise HTTPException(status_code=400, detail=f"Ungültiger Wert in {label}")
|
||||
seen.add(s)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _exercise_delete_usage_counts(cur, exercise_id: int) -> dict:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items,
|
||||
(SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items,
|
||||
(SELECT COUNT(*)::int FROM exercise_progression_edges
|
||||
WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges
|
||||
""",
|
||||
(exercise_id, exercise_id, exercise_id, exercise_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else {"block_items": 0, "section_items": 0, "prog_edges": 0}
|
||||
|
||||
|
||||
def _exercise_delete_usage_message(counts: dict) -> str:
|
||||
bi = int(counts.get("block_items") or 0)
|
||||
si = int(counts.get("section_items") or 0)
|
||||
pe = int(counts.get("prog_edges") or 0)
|
||||
parts = []
|
||||
if bi:
|
||||
parts.append(f"{bi}× in Übungsblöcken")
|
||||
if si:
|
||||
parts.append(f"{si}× in Trainingsplänen oder Rahmenabläufen")
|
||||
if pe:
|
||||
parts.append(f"{pe}× in Progressionsgraphen (Kanten)")
|
||||
if not parts:
|
||||
return ""
|
||||
return (
|
||||
"Die Übung wird noch verwendet und kann nicht gelöscht werden. Bitte auf „archiviert“ setzen. "
|
||||
"Verwendung: " + ", ".join(parts) + "."
|
||||
)
|
||||
|
||||
|
||||
def _assert_can_delete_exercise(cur, tenant: TenantContext, row: dict) -> None:
|
||||
pid = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
vis = str(row.get("visibility") or "private").strip().lower()
|
||||
cid = row.get("club_id")
|
||||
creator = row.get("created_by")
|
||||
try:
|
||||
creator_int = int(creator) if creator is not None else None
|
||||
except (TypeError, ValueError):
|
||||
creator_int = None
|
||||
|
||||
if vis == "official":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Globale Übungen dürfen nur von Plattform-Admins gelöscht werden.",
|
||||
)
|
||||
if vis == "club":
|
||||
try:
|
||||
ex_club = int(cid) if cid is not None else None
|
||||
except (TypeError, ValueError):
|
||||
ex_club = None
|
||||
if ex_club is None:
|
||||
raise HTTPException(status_code=400, detail="Vereins-Übung ohne gültige Vereinszuordnung")
|
||||
if not has_club_role(cur, pid, ex_club, "club_admin"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur Vereins-Admins dürfen Vereins-Übungen löschen.",
|
||||
)
|
||||
return
|
||||
|
||||
if creator_int is not None and creator_int == pid:
|
||||
return
|
||||
if creator_int is not None and club_admin_shares_club_with_creator(cur, pid, creator_int):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Keine Berechtigung zum Löschen dieser Übung.",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/exercises/bulk-metadata")
|
||||
def bulk_patch_exercises_metadata(
|
||||
body: ExerciseBulkMetadataPatch,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal.
|
||||
Ändert Sichtbarkeit, Status und/oder Katalog-Zuordnungen für viele Übungen auf einmal (REPLACE je Kategorie).
|
||||
|
||||
Zuordnung: Sind z. B. focus_area_ids im Body gesetzt, werden die Fokusbereiche bei den bearbeiteten
|
||||
Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
|
||||
|
||||
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
|
||||
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
|
||||
"""
|
||||
|
|
@ -603,6 +802,33 @@ def bulk_patch_exercises_metadata(
|
|||
patch_visibility = body.visibility is not None
|
||||
patch_status = status_val is not None
|
||||
|
||||
patch_focus_areas = body.focus_area_ids is not None
|
||||
fa_ids = _normalize_bulk_id_list(body.focus_area_ids or []) if patch_focus_areas else []
|
||||
patch_style_dirs = body.style_direction_ids is not None
|
||||
sd_ids = _normalize_bulk_id_list(body.style_direction_ids or []) if patch_style_dirs else []
|
||||
patch_training_types = body.training_type_ids is not None
|
||||
tt_ids = _normalize_bulk_id_list(body.training_type_ids or []) if patch_training_types else []
|
||||
patch_target_groups = body.target_group_ids is not None
|
||||
tg_ids = _normalize_bulk_id_list(body.target_group_ids or []) if patch_target_groups else []
|
||||
|
||||
relation_data: Dict[str, Any] = {}
|
||||
if patch_focus_areas:
|
||||
relation_data["focus_areas_multi"] = [
|
||||
{"focus_area_id": i, "is_primary": idx == 0} for idx, i in enumerate(fa_ids)
|
||||
]
|
||||
if patch_style_dirs:
|
||||
relation_data["training_styles_multi"] = [
|
||||
{"training_style_id": i, "is_primary": idx == 0} for idx, i in enumerate(sd_ids)
|
||||
]
|
||||
if patch_training_types:
|
||||
relation_data["training_types_multi"] = [
|
||||
{"training_type_id": i, "is_primary": idx == 0} for idx, i in enumerate(tt_ids)
|
||||
]
|
||||
if patch_target_groups:
|
||||
relation_data["target_groups_multi"] = [
|
||||
{"target_group_id": i, "is_primary": idx == 0} for idx, i in enumerate(tg_ids)
|
||||
]
|
||||
|
||||
updated: List[int] = []
|
||||
failed: List[Dict[str, Any]] = []
|
||||
|
||||
|
|
@ -612,6 +838,16 @@ def bulk_patch_exercises_metadata(
|
|||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
if patch_focus_areas:
|
||||
_assert_catalog_ids_exist(cur, "focus_areas", fa_ids)
|
||||
if patch_style_dirs:
|
||||
_assert_catalog_ids_exist(cur, "style_directions", sd_ids)
|
||||
if patch_training_types:
|
||||
_assert_catalog_ids_exist(cur, "training_types", tt_ids)
|
||||
if patch_target_groups:
|
||||
_assert_catalog_ids_exist(cur, "target_groups", tg_ids)
|
||||
|
||||
for ex_id in unique_ids:
|
||||
cur.execute(
|
||||
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
|
||||
|
|
@ -681,6 +917,8 @@ def bulk_patch_exercises_metadata(
|
|||
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
|
||||
tuple(vals),
|
||||
)
|
||||
if relation_data:
|
||||
assign_exercise_relations(cur, conn, ex_id, relation_data, do_commit=False)
|
||||
updated.append(ex_id)
|
||||
conn.commit()
|
||||
|
||||
|
|
@ -721,6 +959,56 @@ def list_exercises(
|
|||
default=False,
|
||||
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
|
||||
),
|
||||
visibility_exclude_any: list[str] = Query(
|
||||
default=[], description="Keine dieser Sichtbarkeiten (Negativliste)"
|
||||
),
|
||||
status_exclude_any: list[str] = Query(
|
||||
default=[], description="Keiner dieser Statuswerte (Negativliste)"
|
||||
),
|
||||
exclude_without_focus: bool = Query(
|
||||
default=False,
|
||||
description="Wenn true: nur Übungen mit mindestens einem Fokusbereich",
|
||||
),
|
||||
focus_only_without_focus_areas: bool = Query(
|
||||
default=False,
|
||||
description="Nur Übungen ohne einen einzigen Fokusbereich (M:N exercise_focus_areas leer)",
|
||||
),
|
||||
focus_area_must_include_ids: list[int] = Query(
|
||||
default=[],
|
||||
description="Alle genannten Fokusbereiche müssen gesetzt sein (UND / „+“)",
|
||||
),
|
||||
focus_area_must_exclude_ids: list[int] = Query(
|
||||
default=[],
|
||||
description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
|
||||
),
|
||||
style_direction_must_include_ids: list[int] = Query(
|
||||
default=[],
|
||||
description="Alle genannten Stilrichtungen müssen der Übung zugeordnet sein (UND)",
|
||||
),
|
||||
style_direction_must_exclude_ids: list[int] = Query(
|
||||
default=[],
|
||||
description="Keine dieser Stilrichtungen darf zugeordnet sein",
|
||||
),
|
||||
training_type_must_include_ids: list[int] = Query(
|
||||
default=[],
|
||||
description="Alle genannten Trainingsstile müssen zugeordnet sein (UND)",
|
||||
),
|
||||
training_type_must_exclude_ids: list[int] = Query(
|
||||
default=[],
|
||||
description="Keiner dieser Trainingsstile darf zugeordnet sein",
|
||||
),
|
||||
target_group_must_include_ids: list[int] = Query(
|
||||
default=[],
|
||||
description="Alle genannten Zielgruppen müssen zugeordnet sein (UND)",
|
||||
),
|
||||
target_group_must_exclude_ids: list[int] = Query(
|
||||
default=[],
|
||||
description="Keine dieser Zielgruppen darf zugeordnet sein",
|
||||
),
|
||||
include_archived: bool = Query(
|
||||
default=False,
|
||||
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
|
||||
),
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
|
|
@ -760,13 +1048,83 @@ def list_exercises(
|
|||
where.append(f"e.status IN ({ph})")
|
||||
params.extend(st_list)
|
||||
|
||||
fa_ids = _merge_ids(focus_area_ids, focus_area)
|
||||
if fa_ids:
|
||||
ph = ",".join(["%s"] * len(fa_ids))
|
||||
includes_archived = any(str(x).strip().lower() == "archived" for x in st_list)
|
||||
if not include_archived and not includes_archived:
|
||||
where.append("COALESCE(e.status, '') <> %s")
|
||||
params.append("archived")
|
||||
|
||||
vis_excl = _normalize_choice_list(
|
||||
list(visibility_exclude_any),
|
||||
_LIST_FILTER_VISIBILITY,
|
||||
"visibility_exclude_any",
|
||||
)
|
||||
if vis_excl:
|
||||
ph = ",".join(["%s"] * len(vis_excl))
|
||||
where.append(f"(e.visibility IS NULL OR LOWER(TRIM(e.visibility::text)) NOT IN ({ph}))")
|
||||
params.extend(vis_excl)
|
||||
|
||||
st_excl = _normalize_choice_list(
|
||||
list(status_exclude_any),
|
||||
_LIST_FILTER_STATUS,
|
||||
"status_exclude_any",
|
||||
)
|
||||
if st_excl:
|
||||
ph = ",".join(["%s"] * len(st_excl))
|
||||
where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
|
||||
params.extend(st_excl)
|
||||
|
||||
focus_only = focus_only_without_focus_areas
|
||||
must_inc = _dedupe_positive_ids(list(focus_area_must_include_ids))
|
||||
must_exc = _dedupe_positive_ids(list(focus_area_must_exclude_ids))
|
||||
fa_or = _merge_ids(focus_area_ids, focus_area)
|
||||
|
||||
if focus_only:
|
||||
if exclude_without_focus:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="focus_only_without_focus_areas schließt exclude_without_focus aus.",
|
||||
)
|
||||
if fa_or:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_ids (ODER-Liste) verwendet werden.",
|
||||
)
|
||||
if must_inc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_include_ids verwendet werden.",
|
||||
)
|
||||
if must_exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_exclude_ids verwendet werden.",
|
||||
)
|
||||
where.append(
|
||||
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||||
"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
||||
)
|
||||
params.extend(fa_ids)
|
||||
else:
|
||||
if exclude_without_focus:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
||||
)
|
||||
if fa_or:
|
||||
ph = ",".join(["%s"] * len(fa_or))
|
||||
where.append(
|
||||
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||||
)
|
||||
params.extend(fa_or)
|
||||
for fid in must_inc:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
|
||||
)
|
||||
params.append(fid)
|
||||
if must_exc:
|
||||
ph = ",".join(["%s"] * len(must_exc))
|
||||
where.append(
|
||||
f"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa "
|
||||
f"WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||||
)
|
||||
params.extend(must_exc)
|
||||
|
||||
sk_ids = _merge_ids(skill_ids, skill_id)
|
||||
if sk_ids:
|
||||
|
|
@ -776,32 +1134,77 @@ def list_exercises(
|
|||
)
|
||||
params.extend(sk_ids)
|
||||
|
||||
sd_ids = _merge_ids(style_direction_ids, style_direction_id)
|
||||
if sd_ids:
|
||||
ph = ",".join(["%s"] * len(sd_ids))
|
||||
sd_or = _merge_ids(style_direction_ids, style_direction_id)
|
||||
sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids))
|
||||
sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids))
|
||||
if sd_or:
|
||||
ph = ",".join(["%s"] * len(sd_or))
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||||
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
|
||||
)
|
||||
params.extend(sd_ids)
|
||||
params.extend(sd_or)
|
||||
for sid in sd_inc:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||||
"WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
|
||||
)
|
||||
params.append(sid)
|
||||
if sd_exc:
|
||||
ph = ",".join(["%s"] * len(sd_exc))
|
||||
where.append(
|
||||
"NOT EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||||
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
|
||||
)
|
||||
params.extend(sd_exc)
|
||||
|
||||
tt_ids = _merge_ids(training_type_ids, training_type_id)
|
||||
if tt_ids:
|
||||
ph = ",".join(["%s"] * len(tt_ids))
|
||||
tt_or = _merge_ids(training_type_ids, training_type_id)
|
||||
tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids))
|
||||
tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids))
|
||||
if tt_or:
|
||||
ph = ",".join(["%s"] * len(tt_or))
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||||
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
|
||||
)
|
||||
params.extend(tt_ids)
|
||||
params.extend(tt_or)
|
||||
for tid in tt_inc:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||||
"WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
|
||||
)
|
||||
params.append(tid)
|
||||
if tt_exc:
|
||||
ph = ",".join(["%s"] * len(tt_exc))
|
||||
where.append(
|
||||
"NOT EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||||
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
|
||||
)
|
||||
params.extend(tt_exc)
|
||||
|
||||
tg_ids = _merge_ids(target_group_ids, target_group_id)
|
||||
if tg_ids:
|
||||
ph = ",".join(["%s"] * len(tg_ids))
|
||||
tg_or = _merge_ids(target_group_ids, target_group_id)
|
||||
tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids))
|
||||
tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids))
|
||||
if tg_or:
|
||||
ph = ",".join(["%s"] * len(tg_or))
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||||
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
|
||||
)
|
||||
params.extend(tg_ids)
|
||||
params.extend(tg_or)
|
||||
for gid in tg_inc:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||||
"WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
|
||||
)
|
||||
params.append(gid)
|
||||
if tg_exc:
|
||||
ph = ",".join(["%s"] * len(tg_exc))
|
||||
where.append(
|
||||
"NOT EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||||
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
|
||||
)
|
||||
params.extend(tg_exc)
|
||||
|
||||
if skill_min_level is not None or skill_max_level is not None:
|
||||
lo = skill_min_level if skill_min_level is not None else 1
|
||||
|
|
@ -860,7 +1263,34 @@ def list_exercises(
|
|||
WHERE efa.exercise_id = e.id
|
||||
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||||
LIMIT 1
|
||||
) AS primary_focus_name
|
||||
) AS primary_focus_name,
|
||||
(
|
||||
SELECT COALESCE(
|
||||
json_agg(fa.name ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC),
|
||||
'[]'::json
|
||||
)
|
||||
FROM exercise_focus_areas efa
|
||||
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||||
WHERE efa.exercise_id = e.id
|
||||
) AS focus_area_names,
|
||||
(
|
||||
SELECT COALESCE(
|
||||
json_agg(sd.name ORDER BY esd.is_primary DESC NULLS LAST, sd.name ASC),
|
||||
'[]'::json
|
||||
)
|
||||
FROM exercise_style_directions esd
|
||||
JOIN style_directions sd ON sd.id = esd.style_direction_id
|
||||
WHERE esd.exercise_id = e.id
|
||||
) AS style_direction_names,
|
||||
(
|
||||
SELECT COALESCE(
|
||||
json_agg(tt.name ORDER BY ett.is_primary DESC NULLS LAST, tt.sort_order NULLS LAST, tt.name ASC),
|
||||
'[]'::json
|
||||
)
|
||||
FROM exercise_training_types ett
|
||||
JOIN training_types tt ON tt.id = ett.training_type_id
|
||||
WHERE ett.exercise_id = e.id
|
||||
) AS training_type_names
|
||||
{variants_sql}
|
||||
FROM exercises e
|
||||
LEFT JOIN profiles p ON e.created_by = p.id
|
||||
|
|
@ -879,6 +1309,9 @@ def list_exercises(
|
|||
d = r2d(r)
|
||||
pfn = d.get("primary_focus_name")
|
||||
d["focus_area"] = pfn
|
||||
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
|
||||
d["style_direction_names"] = _coerce_json_str_list(d.get("style_direction_names"))
|
||||
d["training_type_names"] = _coerce_json_str_list(d.get("training_type_names"))
|
||||
if include_variants:
|
||||
v = d.get("variants")
|
||||
if isinstance(v, str):
|
||||
|
|
@ -1082,38 +1515,32 @@ def delete_exercise(
|
|||
):
|
||||
"""
|
||||
Löscht eine Übung.
|
||||
Nur Owner oder Admin darf löschen.
|
||||
"""
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
|
||||
Berechtigung: Plattform-Admin (alle); Vereins-Admin Vereins-Übungen seines Vereins;
|
||||
Ersteller nur eigene private Übungen; Vereins-Admin zusätzlich private Übungen von Mitgliedern,
|
||||
mit denen er einen Verein teilt.
|
||||
|
||||
Bei Verwendung in Blöcken, Trainingsplänen oder Progressionsgraphen: 409 — bitte archivieren.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Existiert die Übung?
|
||||
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
|
||||
cur.execute(
|
||||
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
|
||||
(exercise_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||
ex = r2d(row)
|
||||
|
||||
# Permission Check
|
||||
if _row_created_by(row) != profile_id and not is_platform_admin(role):
|
||||
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
|
||||
_assert_can_delete_exercise(cur, tenant, ex)
|
||||
|
||||
# Prüfen ob Übung in Block-Items verwendet wird
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s",
|
||||
(exercise_id,)
|
||||
)
|
||||
crow = cur.fetchone()
|
||||
count = crow["cnt"] if isinstance(crow, dict) else crow[0]
|
||||
if count > 0:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Übung wird in {count} Block-Item(s) verwendet und kann nicht gelöscht werden"
|
||||
)
|
||||
counts = _exercise_delete_usage_counts(cur, exercise_id)
|
||||
usage_msg = _exercise_delete_usage_message(counts)
|
||||
if usage_msg:
|
||||
raise HTTPException(status_code=409, detail=usage_msg)
|
||||
|
||||
# DELETE (Cascade löscht M:N Zuordnungen automatisch)
|
||||
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
|
||||
conn.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from datetime import datetime
|
|||
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, hash_pin
|
||||
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
|
||||
|
|
@ -258,6 +260,15 @@ def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> di
|
|||
assert_club_member(cur, int(pid), cid)
|
||||
data["active_club_id"] = cid
|
||||
|
||||
if "exercise_list_prefs" in patch:
|
||||
ep = patch.pop("exercise_list_prefs")
|
||||
if ep is None:
|
||||
data["exercise_list_prefs"] = Json({})
|
||||
elif isinstance(ep, dict):
|
||||
data["exercise_list_prefs"] = Json(ep)
|
||||
else:
|
||||
raise HTTPException(400, "exercise_list_prefs muss ein JSON-Objekt sein")
|
||||
|
||||
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
|
||||
for k, v in patch.items():
|
||||
if k == "email":
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from db import get_db, get_cursor, r2d
|
|||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||
from club_tenancy import (
|
||||
assert_valid_governance_visibility,
|
||||
can_manage_club_org,
|
||||
is_platform_admin,
|
||||
library_content_visible_to_profile,
|
||||
)
|
||||
|
|
@ -53,7 +54,7 @@ def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id:
|
|||
|
||||
def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None:
|
||||
cur.execute(
|
||||
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
|
||||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||||
(group_id,),
|
||||
)
|
||||
group = cur.fetchone()
|
||||
|
|
@ -64,9 +65,83 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
|
|||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
|
||||
if role not in ["admin", "superadmin"]:
|
||||
if group["trainer_id"] != profile_id and profile_id not in co_trainers:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
|
||||
)
|
||||
if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
|
||||
)
|
||||
|
||||
|
||||
def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM club_members
|
||||
WHERE club_id = %s AND profile_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(club_id, profile_id),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _caller_may_assign_session_trainers(
|
||||
cur,
|
||||
group_row: Dict[str, Any],
|
||||
profile_id: int,
|
||||
role: str,
|
||||
unit_created_by: Optional[int],
|
||||
) -> bool:
|
||||
if is_platform_admin(role):
|
||||
return True
|
||||
cid = group_row.get("club_id")
|
||||
if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role):
|
||||
return True
|
||||
if unit_created_by is not None and unit_created_by == profile_id:
|
||||
return True
|
||||
if group_row.get("trainer_id") == profile_id:
|
||||
return True
|
||||
co = group_row.get("co_trainer_ids") or []
|
||||
return profile_id in co
|
||||
|
||||
|
||||
def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]:
|
||||
"""Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard."""
|
||||
unit_asst = unit_row.get("assistant_trainer_profile_ids")
|
||||
if unit_asst is not None:
|
||||
src = unit_asst
|
||||
else:
|
||||
src = unit_row.get("co_trainer_ids") or []
|
||||
seen: set = set()
|
||||
out: List[int] = []
|
||||
for x in src:
|
||||
try:
|
||||
i = int(x)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
out.append(i)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def effective_co_trainer_profile_ids_for_merge(
|
||||
unit_assistant: Any, group_co: Any
|
||||
) -> List[int]:
|
||||
"""Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row."""
|
||||
if unit_assistant is not None:
|
||||
src = unit_assistant
|
||||
else:
|
||||
src = group_co or []
|
||||
seen: set = set()
|
||||
out: List[int] = []
|
||||
for x in src:
|
||||
try:
|
||||
i = int(x)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
out.append(i)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
||||
|
|
@ -74,7 +149,8 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
|||
"""
|
||||
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
|
||||
tu.lead_trainer_profile_id,
|
||||
tg.trainer_id, tg.co_trainer_ids,
|
||||
tu.assistant_trainer_profile_ids,
|
||||
tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id,
|
||||
fwp.created_by AS framework_created_by
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
|
|
@ -103,26 +179,53 @@ def _assert_training_unit_permission(
|
|||
return
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
co_trainers = unit_row["co_trainer_ids"] or []
|
||||
if role not in ["admin", "superadmin"]:
|
||||
if (
|
||||
unit_row["created_by"] != profile_id
|
||||
and unit_row["trainer_id"] != profile_id
|
||||
and profile_id not in co_trainers
|
||||
and unit_row.get("lead_trainer_profile_id") != profile_id
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
|
||||
def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None:
|
||||
if role not in ["admin", "superadmin"] and created_by != profile_id:
|
||||
co_eff = _effective_co_trainer_ids_for_row(unit_row)
|
||||
if role in ["admin", "superadmin"]:
|
||||
return
|
||||
gcid = unit_row.get("group_club_id")
|
||||
if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role):
|
||||
return
|
||||
if (
|
||||
unit_row["created_by"] != profile_id
|
||||
and unit_row["trainer_id"] != profile_id
|
||||
and profile_id not in co_eff
|
||||
and unit_row.get("lead_trainer_profile_id") != profile_id
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
|
||||
def _assert_delete_training_unit(
|
||||
cur,
|
||||
role: str,
|
||||
created_by: int,
|
||||
profile_id: int,
|
||||
group_club_id: Optional[int],
|
||||
) -> None:
|
||||
if role in ["admin", "superadmin"]:
|
||||
return
|
||||
if created_by == profile_id:
|
||||
return
|
||||
if group_club_id is not None and can_manage_club_org(cur, profile_id, int(group_club_id), role):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
|
||||
|
||||
def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
|
||||
"""Nicht-Admin: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer."""
|
||||
"""Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
|
||||
if role in ("admin", "superadmin"):
|
||||
return
|
||||
if can_manage_club_org(cur, profile_id, club_id, role):
|
||||
return
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM club_members
|
||||
WHERE club_id = %s AND profile_id = %s AND status = 'active'
|
||||
LIMIT 1
|
||||
""",
|
||||
(club_id, profile_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
return
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM training_groups g
|
||||
|
|
@ -145,8 +248,9 @@ def _normalize_lead_trainer_profile_id(
|
|||
raw_lead: Any,
|
||||
profile_id: int,
|
||||
role: str,
|
||||
unit_created_by: Optional[int],
|
||||
) -> Optional[int]:
|
||||
"""NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext."""
|
||||
"""NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i. d. R. mit Vereinsbezug."""
|
||||
if raw_lead is None:
|
||||
return None
|
||||
if raw_lead in ("", []):
|
||||
|
|
@ -160,27 +264,130 @@ def _normalize_lead_trainer_profile_id(
|
|||
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")
|
||||
if role in ("admin", "superadmin"):
|
||||
return nid
|
||||
if nid == profile_id:
|
||||
return nid
|
||||
|
||||
cur.execute(
|
||||
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
|
||||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||||
(group_id,),
|
||||
)
|
||||
gr = cur.fetchone()
|
||||
if not gr:
|
||||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||
eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set()
|
||||
for x in gr.get("co_trainer_ids") or []:
|
||||
eligible.add(x)
|
||||
if nid in eligible:
|
||||
return nid
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
|
||||
)
|
||||
grd = dict(gr)
|
||||
cid = grd.get("club_id")
|
||||
if cid is None:
|
||||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||
club_i = int(cid)
|
||||
|
||||
if is_platform_admin(role):
|
||||
return nid
|
||||
|
||||
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
|
||||
for x in grd.get("co_trainer_ids") or []:
|
||||
try:
|
||||
eligible.add(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if nid == profile_id:
|
||||
if not _profile_active_in_club(cur, club_i, profile_id):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen",
|
||||
)
|
||||
return nid
|
||||
|
||||
if nid not in eligible:
|
||||
if not _profile_active_in_club(cur, club_i, nid):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe",
|
||||
)
|
||||
if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Keine Berechtigung, die Leitung zuzuweisen",
|
||||
)
|
||||
return nid
|
||||
|
||||
if nid != profile_id and not _caller_may_assign_session_trainers(
|
||||
cur, grd, profile_id, role, unit_created_by
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen")
|
||||
return nid
|
||||
|
||||
|
||||
def _normalize_assistant_trainer_profile_ids(
|
||||
cur,
|
||||
group_id: int,
|
||||
raw_val: Any,
|
||||
profile_id: int,
|
||||
role: str,
|
||||
unit_created_by: Optional[int],
|
||||
lead_nid: Optional[int],
|
||||
) -> Any:
|
||||
"""
|
||||
None = Vererbung aus training_groups.co_trainer_ids (SQL NULL);
|
||||
Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.)
|
||||
"""
|
||||
if raw_val is None:
|
||||
return None
|
||||
if not isinstance(raw_val, list):
|
||||
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids muss Liste oder null sein")
|
||||
|
||||
ids_in: List[int] = []
|
||||
for x in raw_val:
|
||||
try:
|
||||
i = int(x)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
|
||||
if i < 1:
|
||||
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
|
||||
ids_in.append(i)
|
||||
uniq = sorted(set(ids_in))
|
||||
|
||||
cur.execute(
|
||||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||||
(group_id,),
|
||||
)
|
||||
gr = cur.fetchone()
|
||||
if not gr:
|
||||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||
grd = dict(gr)
|
||||
cid = grd.get("club_id")
|
||||
if cid is None:
|
||||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||||
club_i = int(cid)
|
||||
|
||||
if not is_platform_admin(role) and not _caller_may_assign_session_trainers(
|
||||
cur, grd, profile_id, role, unit_created_by
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer zuzuweisen")
|
||||
|
||||
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
|
||||
for x in grd.get("co_trainer_ids") or []:
|
||||
try:
|
||||
eligible.add(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None)
|
||||
|
||||
for nid in uniq:
|
||||
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Profil für Co-Trainer nicht gefunden")
|
||||
if eff_lead is not None and nid == eff_lead:
|
||||
raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden")
|
||||
if is_platform_admin(role):
|
||||
continue
|
||||
if nid in eligible:
|
||||
continue
|
||||
if not _profile_active_in_club(cur, club_i, nid):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
|
||||
)
|
||||
return uniq
|
||||
|
||||
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
|
||||
_ORIGIN_LINEAGE_JOIN = """
|
||||
|
|
@ -775,14 +982,18 @@ def list_training_units(
|
|||
|
||||
if gid and role not in ["admin", "superadmin"]:
|
||||
cur.execute(
|
||||
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'",
|
||||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'",
|
||||
(gid,),
|
||||
)
|
||||
gr = cur.fetchone()
|
||||
if not gr:
|
||||
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
||||
cob = gr["co_trainer_ids"] or []
|
||||
if gr["trainer_id"] != profile_id and profile_id not in cob:
|
||||
gd = dict(gr)
|
||||
cob = gd.get("co_trainer_ids") or []
|
||||
ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob
|
||||
ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role)
|
||||
ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id)
|
||||
if not (ok_staff or ok_org or ok_member):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
|
||||
|
||||
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
|
||||
|
|
@ -805,6 +1016,8 @@ def list_training_units(
|
|||
p.name as trainer_name,
|
||||
p.name as creator_name,
|
||||
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
|
||||
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
|
||||
AS effective_assistant_trainer_profile_ids,
|
||||
leadp.name AS lead_trainer_name
|
||||
"""
|
||||
query += "," + _ORIGIN_LINEAGE_FIELDS
|
||||
|
|
@ -820,12 +1033,27 @@ def list_training_units(
|
|||
where = []
|
||||
params = []
|
||||
|
||||
if role not in ["admin", "superadmin"]:
|
||||
where.append(
|
||||
"(tu.created_by = %s OR tg.trainer_id = %s OR "
|
||||
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
|
||||
skip_involvement_filter = role in ("admin", "superadmin")
|
||||
if not skip_involvement_filter and cid is not None:
|
||||
if can_manage_club_org(cur, profile_id, cid, role):
|
||||
skip_involvement_filter = True
|
||||
if not skip_involvement_filter and gid is not None:
|
||||
cur.execute(
|
||||
"SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'",
|
||||
(gid,),
|
||||
)
|
||||
params.extend([profile_id, profile_id, profile_id])
|
||||
gcx = cur.fetchone()
|
||||
if gcx and gcx.get("club_id") is not None:
|
||||
if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role):
|
||||
skip_involvement_filter = True
|
||||
|
||||
if not skip_involvement_filter:
|
||||
where.append(
|
||||
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
|
||||
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
|
||||
"@> jsonb_build_array(%s::int))"
|
||||
)
|
||||
params.extend([profile_id, profile_id, profile_id, profile_id])
|
||||
|
||||
where.append("tu.framework_slot_id IS NULL")
|
||||
|
||||
|
|
@ -840,7 +1068,8 @@ def list_training_units(
|
|||
if assigned_to_me:
|
||||
where.append(
|
||||
"(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
|
||||
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
|
||||
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
|
||||
"@> jsonb_build_array(%s::int))"
|
||||
)
|
||||
params.extend([profile_id, profile_id])
|
||||
|
||||
|
|
@ -890,7 +1119,10 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
|
|||
p.name as creator_name,
|
||||
tg.trainer_id AS trainer_id,
|
||||
tg.co_trainer_ids AS co_trainer_ids,
|
||||
tg.club_id AS group_club_id,
|
||||
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
|
||||
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
|
||||
AS effective_assistant_trainer_profile_ids,
|
||||
leadp.name AS lead_trainer_name,
|
||||
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
|
||||
FROM training_units tu
|
||||
|
|
@ -957,27 +1189,77 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
|||
tpl_id_safe = plan_template_id
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date, planned_time_start, planned_time_end,
|
||||
planned_focus, status, notes, trainer_notes, created_by,
|
||||
plan_template_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
group_id,
|
||||
planned_date,
|
||||
data.get("planned_time_start"),
|
||||
data.get("planned_time_end"),
|
||||
data.get("planned_focus"),
|
||||
data.get("status", "planned"),
|
||||
data.get("notes"),
|
||||
data.get("trainer_notes"),
|
||||
profile_id,
|
||||
tpl_id_safe,
|
||||
),
|
||||
"SELECT trainer_id FROM training_groups WHERE id = %s",
|
||||
(int(group_id),),
|
||||
)
|
||||
g0 = cur.fetchone()
|
||||
default_group_trainer = g0["trainer_id"] if g0 else None
|
||||
|
||||
lead_ins: Optional[int] = None
|
||||
if "lead_trainer_profile_id" in data:
|
||||
lead_ins = _normalize_lead_trainer_profile_id(
|
||||
cur,
|
||||
int(group_id),
|
||||
data.get("lead_trainer_profile_id"),
|
||||
profile_id,
|
||||
role,
|
||||
profile_id,
|
||||
)
|
||||
assistant_val: Any = None
|
||||
assistant_set = False
|
||||
if "assistant_trainer_profile_ids" in data:
|
||||
assistant_set = True
|
||||
eff_lead_for_co = lead_ins if lead_ins is not None else default_group_trainer
|
||||
assistant_val = _normalize_assistant_trainer_profile_ids(
|
||||
cur,
|
||||
int(group_id),
|
||||
data.get("assistant_trainer_profile_ids"),
|
||||
profile_id,
|
||||
role,
|
||||
profile_id,
|
||||
eff_lead_for_co,
|
||||
)
|
||||
|
||||
base_params = (
|
||||
group_id,
|
||||
planned_date,
|
||||
data.get("planned_time_start"),
|
||||
data.get("planned_time_end"),
|
||||
data.get("planned_focus"),
|
||||
data.get("status", "planned"),
|
||||
data.get("notes"),
|
||||
data.get("trainer_notes"),
|
||||
profile_id,
|
||||
tpl_id_safe,
|
||||
lead_ins,
|
||||
)
|
||||
if assistant_set:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date, planned_time_start, planned_time_end,
|
||||
planned_focus, status, notes, trainer_notes, created_by,
|
||||
plan_template_id,
|
||||
lead_trainer_profile_id,
|
||||
assistant_trainer_profile_ids
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
base_params + (assistant_val,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date, planned_time_start, planned_time_end,
|
||||
planned_focus, status, notes, trainer_notes, created_by,
|
||||
plan_template_id,
|
||||
lead_trainer_profile_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
base_params,
|
||||
)
|
||||
|
||||
unit_id = cur.fetchone()["id"]
|
||||
|
||||
|
|
@ -1066,8 +1348,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
tuple(blueprint_params),
|
||||
)
|
||||
else:
|
||||
cur_lead = unit_row.get("lead_trainer_profile_id")
|
||||
base_tr = unit_row.get("trainer_id")
|
||||
lead_sql = ""
|
||||
lead_params: List[Any] = []
|
||||
assist_sql = ""
|
||||
assist_params: List[Any] = []
|
||||
nl: Optional[int]
|
||||
if "lead_trainer_profile_id" in data:
|
||||
nl = _normalize_lead_trainer_profile_id(
|
||||
cur,
|
||||
|
|
@ -1075,9 +1362,27 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
data.get("lead_trainer_profile_id"),
|
||||
profile_id,
|
||||
role,
|
||||
unit_row.get("created_by"),
|
||||
)
|
||||
lead_sql = ", lead_trainer_profile_id = %s"
|
||||
lead_params.append(nl)
|
||||
eff_lead_for_co = nl if nl is not None else base_tr
|
||||
else:
|
||||
nl = cur_lead if cur_lead is not None else base_tr
|
||||
eff_lead_for_co = nl
|
||||
|
||||
if "assistant_trainer_profile_ids" in data:
|
||||
na = _normalize_assistant_trainer_profile_ids(
|
||||
cur,
|
||||
unit_row["group_id"],
|
||||
data.get("assistant_trainer_profile_ids"),
|
||||
profile_id,
|
||||
role,
|
||||
unit_row.get("created_by"),
|
||||
eff_lead_for_co,
|
||||
)
|
||||
assist_sql = ", assistant_trainer_profile_ids = %s"
|
||||
assist_params.append(na)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
|
|
@ -1096,6 +1401,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
plan_template_id = COALESCE(%s, plan_template_id),
|
||||
updated_at = NOW()
|
||||
{lead_sql}
|
||||
{assist_sql}
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
|
|
@ -1113,6 +1419,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
tpl_id_val,
|
||||
)
|
||||
+ tuple(lead_params)
|
||||
+ tuple(assist_params)
|
||||
+ (unit_id,),
|
||||
)
|
||||
|
||||
|
|
@ -1152,7 +1459,12 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
|
|||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"SELECT created_by, framework_slot_id FROM training_units WHERE id = %s",
|
||||
"""
|
||||
SELECT tu.created_by, tu.framework_slot_id, tg.club_id AS group_club_id
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
WHERE tu.id = %s
|
||||
""",
|
||||
(unit_id,),
|
||||
)
|
||||
|
||||
|
|
@ -1167,7 +1479,13 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
|
|||
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
|
||||
)
|
||||
|
||||
_assert_delete_training_unit(role, unit["created_by"], profile_id)
|
||||
_assert_delete_training_unit(
|
||||
cur,
|
||||
role,
|
||||
unit["created_by"],
|
||||
profile_id,
|
||||
unit.get("group_club_id"),
|
||||
)
|
||||
|
||||
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
|
||||
conn.commit()
|
||||
|
|
|
|||
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
|
||||
|
||||
APP_VERSION = "0.8.36"
|
||||
BUILD_DATE = "2026-05-05"
|
||||
DB_SCHEMA_VERSION = "20260505041"
|
||||
APP_VERSION = "0.8.40"
|
||||
BUILD_DATE = "2026-05-06"
|
||||
DB_SCHEMA_VERSION = "20260506043"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
|
||||
"profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests
|
||||
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
|
||||
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL)
|
||||
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
|
||||
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||
|
|
@ -15,8 +15,8 @@ MODULE_VERSIONS = {
|
|||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
|
||||
"training_units": "0.1.0",
|
||||
"exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
|
||||
"training_units": "0.2.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||
"import_wiki": "1.0.0",
|
||||
|
|
@ -27,6 +27,42 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.40",
|
||||
"date": "2026-05-06",
|
||||
"changes": [
|
||||
"Übungen Liste: Fokusfilter mit UND-+ (must_include) und UND-− (must_exclude), nur ohne Fokusbereich (focus_only_without); Frontend Dropdown + Mit / − Ohne",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.39",
|
||||
"date": "2026-05-06",
|
||||
"changes": [
|
||||
"Übungen DELETE: Nur eigene private / Vereinsadmin für Vereins-Übungen / Plattform für globale; keine harte Löschung bei Verwendung in Blöcken, Plan-Abschnitten oder Progressionskanten (409 → archivieren)",
|
||||
"GET /api/exercises: Negativfilter (visibility_exclude_any, status_exclude_any), exclude_without_focus, include_archived; archivierte standardmäßig ausgeblendet",
|
||||
"Profile exercise_list_prefs (JSONB, Migration 043): gespeicherte Standardfilter; Frontend Übungsliste Filterdialog + „Als Standard speichern“",
|
||||
"Übungspicker: gleiche Negativfilter; Planung lädt archivierte Übungen immer mit (bestehende Zuordnungen)",
|
||||
"pytest: tests/test_exercises_delete_policy.py",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.38",
|
||||
"date": "2026-05-06",
|
||||
"changes": [
|
||||
"Trainingsplanung: Vereinsadmins sehen alle Einheiten bei club_id-/Gruppenliste; GET/PUT Einheit & Löschen mit can_manage_club_org",
|
||||
"Planung UI: „Trainer zuweisen“ in Vereins-Ansicht (Liste + Kalender) + eigener Modal; Mitgliederverzeichnis für Vereinsorganisation",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.37",
|
||||
"date": "2026-05-05",
|
||||
"changes": [
|
||||
"DB 042: training_units.assistant_trainer_profile_ids (Co-Trainer-Zuweisung je Termin; NULL = Gruppen-Standard)",
|
||||
"Trainingseinheiten: POST/PUT lead_trainer_profile_id & assistant_trainer_profile_ids; Leitung für Vereinsmitglieder (Vertretung); GET-Listen inkl. Zuweisung für Sichtbarkeit/assigned_to_me",
|
||||
"Frontend Trainingsplanung: Leitung/Co-Trainer pro Einheit; Dashboard-Text",
|
||||
"pytest: tests/test_training_unit_assignments.py",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.36",
|
||||
"date": "2026-05-05",
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function Nav({ isAdmin }) {
|
|||
'nav-item' + (navItemActive(loc.pathname, item, isActive) ? ' active' : '')
|
||||
}
|
||||
>
|
||||
<item.Icon size={20} strokeWidth={2} />
|
||||
<item.Icon size={26} strokeWidth={2} />
|
||||
<span>{item.shortLabel || item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
|
|
|||
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'
|
||||
|
||||
/**
|
||||
|
|
@ -6,8 +6,6 @@ import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
|
|||
* Wechselt zwischen verschiedenen Admin-Seiten
|
||||
*/
|
||||
export default function AdminPageNav() {
|
||||
const location = useLocation()
|
||||
|
||||
const pages = [
|
||||
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
||||
|
|
@ -17,51 +15,18 @@ export default function AdminPageNav() {
|
|||
]
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
borderBottom: '2px solid var(--border)',
|
||||
marginBottom: '24px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{pages.map(page => {
|
||||
<nav className="admin-top-nav" aria-label="Administration">
|
||||
{pages.map((page) => {
|
||||
const Icon = page.icon
|
||||
const isActive = location.pathname === page.to
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={page.to}
|
||||
to={page.to}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: 500,
|
||||
color: isActive ? 'var(--accent)' : 'var(--text2)',
|
||||
borderBottomColor: isActive ? 'var(--accent)' : 'transparent',
|
||||
textDecoration: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.color = 'var(--text1)'
|
||||
e.currentTarget.style.background = 'var(--surface2)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.color = 'var(--text2)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}
|
||||
}}
|
||||
className={({ isActive }) =>
|
||||
'admin-top-nav__link' + (isActive ? ' admin-top-nav__link--active' : '')
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<Icon size={18} strokeWidth={2} aria-hidden />
|
||||
<span>{page.label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
|
|
|
|||
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 api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||
import {
|
||||
INITIAL_EXERCISE_LIST_FILTERS,
|
||||
mergeExerciseListPrefsFromApi,
|
||||
splitMnCatalogRules,
|
||||
splitScalarCatalogRules,
|
||||
} from '../constants/exerciseListFilters'
|
||||
import MultiSelectCombo from './MultiSelectCombo'
|
||||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||
import CatalogRulePicker from './CatalogRulePicker'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||
|
||||
const INITIAL_FILTERS = {
|
||||
focus_area_ids: [],
|
||||
style_direction_ids: [],
|
||||
training_type_ids: [],
|
||||
target_group_ids: [],
|
||||
skill_ids: [],
|
||||
skill_min_level: '',
|
||||
skill_max_level: '',
|
||||
visibility_any: [],
|
||||
status_any: [],
|
||||
}
|
||||
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||
|
||||
export default function ExercisePickerModal({
|
||||
open,
|
||||
|
|
@ -29,6 +28,7 @@ export default function ExercisePickerModal({
|
|||
multiSelect = false,
|
||||
onSelectExercises = null,
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const [catalogs, setCatalogs] = useState({
|
||||
focusAreas: [],
|
||||
styleDirections: [],
|
||||
|
|
@ -110,8 +110,10 @@ export default function ExercisePickerModal({
|
|||
setOffset(0)
|
||||
setHasMore(false)
|
||||
setMultiPicked([])
|
||||
return
|
||||
}
|
||||
}, [open])
|
||||
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
||||
}, [open, user?.exercise_list_prefs])
|
||||
|
||||
const focusOptions = useMemo(
|
||||
() => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim() })),
|
||||
|
|
@ -156,20 +158,46 @@ export default function ExercisePickerModal({
|
|||
const n = (v) => (v === '' || v == null ? undefined : Number(v))
|
||||
const ids = (arr) =>
|
||||
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
||||
const fMn = splitMnCatalogRules(filters.focus_rules)
|
||||
if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
|
||||
if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
|
||||
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
|
||||
|
||||
const fa = ids(filters.focus_area_ids)
|
||||
if (fa?.length) q.focus_area_ids = fa
|
||||
const sd = ids(filters.style_direction_ids)
|
||||
if (sd?.length) q.style_direction_ids = sd
|
||||
const tt = ids(filters.training_type_ids)
|
||||
if (tt?.length) q.training_type_ids = tt
|
||||
const tg = ids(filters.target_group_ids)
|
||||
if (tg?.length) q.target_group_ids = tg
|
||||
|
||||
const sdMn = splitMnCatalogRules(filters.style_direction_rules)
|
||||
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
|
||||
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
|
||||
const sdLegacy = ids(filters.style_direction_ids)
|
||||
if (sdLegacy?.length) q.style_direction_ids = sdLegacy
|
||||
|
||||
const ttMn = splitMnCatalogRules(filters.training_type_rules)
|
||||
if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
|
||||
if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
|
||||
const ttLegacy = ids(filters.training_type_ids)
|
||||
if (ttLegacy?.length) q.training_type_ids = ttLegacy
|
||||
|
||||
const tgMn = splitMnCatalogRules(filters.target_group_rules)
|
||||
if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
|
||||
if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
|
||||
const tgLegacy = ids(filters.target_group_ids)
|
||||
if (tgLegacy?.length) q.target_group_ids = tgLegacy
|
||||
|
||||
const visMn = splitScalarCatalogRules(filters.visibility_rules)
|
||||
if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
|
||||
if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
|
||||
|
||||
const stMn = splitScalarCatalogRules(filters.status_rules)
|
||||
if (stMn.includeVals.length) q.status_any = stMn.includeVals
|
||||
if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
|
||||
|
||||
const sk = ids(filters.skill_ids)
|
||||
if (sk?.length) q.skill_ids = sk
|
||||
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
|
||||
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
|
||||
if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
|
||||
if (filters.status_any?.length) q.status_any = [...filters.status_any]
|
||||
if (filters.exclude_without_focus) q.exclude_without_focus = true
|
||||
if (filters.include_archived) q.include_archived = true
|
||||
if (debouncedSearch) q.search = debouncedSearch
|
||||
if (debouncedAi) q.ai_search = debouncedAi
|
||||
return q
|
||||
|
|
@ -182,6 +210,7 @@ export default function ExercisePickerModal({
|
|||
try {
|
||||
const batch = await api.listExercises({
|
||||
...queryBase,
|
||||
include_archived: true,
|
||||
include_variants: true,
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0,
|
||||
|
|
@ -209,6 +238,7 @@ export default function ExercisePickerModal({
|
|||
try {
|
||||
const batch = await api.listExercises({
|
||||
...queryBase,
|
||||
include_archived: true,
|
||||
include_variants: true,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
|
|
@ -292,45 +322,44 @@ export default function ExercisePickerModal({
|
|||
{filterOpen && (
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
|
||||
<p style={{ margin: '0 0 12px 0' }}>
|
||||
Zwischen den Bereichen gilt <strong>UND</strong>, innerhalb ODER wie in der Übungsübersicht.
|
||||
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
|
||||
Sichtbarkeit/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
|
||||
</p>
|
||||
<div className="exercise-filters-modal-grid">
|
||||
<div>
|
||||
<label className="form-label">Fokus</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.focus_area_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, focus_area_ids: v }))}
|
||||
options={focusOptions}
|
||||
placeholder="Fokus …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Stilrichtung</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.style_direction_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
|
||||
options={styleOptions}
|
||||
placeholder="Stilrichtung …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Trainingsstil</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.training_type_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
|
||||
options={trainingTypeOptions}
|
||||
placeholder="Trainingsstil …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Zielgruppe</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.target_group_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
|
||||
options={targetGroupOptions}
|
||||
placeholder="Zielgruppe …"
|
||||
/>
|
||||
</div>
|
||||
<ExerciseFocusRulePicker
|
||||
focusOptions={focusOptions}
|
||||
focusRules={filters.focus_rules}
|
||||
focusOnlyWithout={filters.focus_only_without}
|
||||
legacyFocusAreaIds={filters.focus_area_ids}
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||||
<CatalogRulePicker
|
||||
label="Stilrichtung"
|
||||
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||
options={styleOptions}
|
||||
rules={filters.style_direction_rules}
|
||||
rulesFieldName="style_direction_rules"
|
||||
placeholder="Stil …"
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
<CatalogRulePicker
|
||||
label="Trainingsstil"
|
||||
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||
options={trainingTypeOptions}
|
||||
rules={filters.training_type_rules}
|
||||
rulesFieldName="training_type_rules"
|
||||
placeholder="Trainingsstil …"
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
<CatalogRulePicker
|
||||
label="Zielgruppe"
|
||||
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||
options={targetGroupOptions}
|
||||
rules={filters.target_group_rules}
|
||||
rulesFieldName="target_group_rules"
|
||||
placeholder="Gruppe …"
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label className="form-label">Fähigkeit</label>
|
||||
|
|
@ -369,25 +398,54 @@ export default function ExercisePickerModal({
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.visibility_any}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
|
||||
options={visibilityOptions}
|
||||
placeholder="Sichtbarkeit …"
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||||
<CatalogRulePicker
|
||||
label="Sichtbarkeit"
|
||||
options={visibilityOptions}
|
||||
rules={filters.visibility_rules}
|
||||
rulesFieldName="visibility_rules"
|
||||
idKind="string"
|
||||
placeholder="Sichtbarkeit …"
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
<CatalogRulePicker
|
||||
label="Status"
|
||||
options={statusOptions}
|
||||
rules={filters.status_rules}
|
||||
rulesFieldName="status_rules"
|
||||
idKind="string"
|
||||
placeholder="Status …"
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
|
||||
opacity: filters.focus_only_without ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!!filters.focus_only_without}
|
||||
checked={!!filters.exclude_without_focus}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
exclude_without_focus: e.target.checked,
|
||||
...(e.target.checked ? { focus_only_without: false } : {}),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Status</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.status_any}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
|
||||
options={statusOptions}
|
||||
placeholder="Status …"
|
||||
/>
|
||||
</div>
|
||||
<span>Ohne Fokus ausblenden</span>
|
||||
</label>
|
||||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>
|
||||
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende
|
||||
Zuordnungen).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
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>
|
||||
|
||||
{showExecutionExtras ? (
|
||||
<label className="tu-ex-run-block form-label">
|
||||
Ist-Dauer / Anpassungen
|
||||
<span className="tu-ex-run-block__controls">
|
||||
<div className="tu-ex-debrief">
|
||||
<div className="tu-ex-debrief__grow">
|
||||
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span>
|
||||
<textarea
|
||||
className="form-input tu-ex-debrief__textarea"
|
||||
rows={3}
|
||||
value={it.modifications || ''}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||||
}
|
||||
placeholder="Was lief anders? Anpassungen für spätere Planung…"
|
||||
/>
|
||||
</div>
|
||||
<div className="tu-ex-debrief__ist">
|
||||
<span className="tu-item-row__meta-label">Ist (Min)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
className="form-input tu-ex-duration"
|
||||
min={1}
|
||||
value={it.actual_duration_min}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||||
}
|
||||
placeholder="IST min"
|
||||
placeholder="IST"
|
||||
title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
|
||||
/>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={it.modifications || ''}
|
||||
onChange={(e) =>
|
||||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||||
}
|
||||
placeholder="Abweichungen beim Durchführen"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,22 +5,24 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
|
|||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: '2.5rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function toggleAssignment(styleDirectionId, targetGroupId, currentlyAssigned) {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (currentlyAssigned) {
|
||||
// Find and delete the assignment
|
||||
const assignment = assignments.find(
|
||||
a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
|
||||
(a) => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
|
||||
)
|
||||
if (assignment) {
|
||||
await api.deleteStyleDirectionTargetGroup(assignment.id)
|
||||
}
|
||||
} else {
|
||||
// Create new assignment
|
||||
await api.createStyleDirectionTargetGroup({
|
||||
style_direction_id: styleDirectionId,
|
||||
target_group_id: targetGroupId,
|
||||
|
|
@ -37,11 +39,10 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
|
|||
|
||||
function isAssigned(styleDirectionId, targetGroupId) {
|
||||
return assignments.some(
|
||||
a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
|
||||
(a) => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
|
||||
)
|
||||
}
|
||||
|
||||
// Group style directions by focus area
|
||||
const groupedStyles = styleDirections.reduce((acc, sd) => {
|
||||
const key = sd.focus_area_name || 'Ohne Fokusbereich'
|
||||
if (!acc[key]) acc[key] = []
|
||||
|
|
@ -50,30 +51,30 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
|
|||
}, {})
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Zuordnungen: Stilrichtungen ↔ Zielgruppen</h2>
|
||||
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '16px' }}>{error}</div>}
|
||||
<div className="admin-assignments-wrap">
|
||||
<h2 className="admin-assignments-wrap__title">Zuordnungen: Stilrichtungen ↔ Zielgruppen</h2>
|
||||
{error && <div className="admin-matrix-alert">{error}</div>}
|
||||
|
||||
{targetGroups.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
|
||||
Keine Zielgruppen vorhanden. Bitte erst im Tab "Kataloge" anlegen.
|
||||
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
|
||||
Keine Zielgruppen vorhanden. Bitte zuerst unter <strong>Kataloge</strong> anlegen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{styleDirections.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
|
||||
Keine Stilrichtungen vorhanden. Bitte erst im Tab "Hierarchie" anlegen.
|
||||
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
|
||||
Keine Stilrichtungen vorhanden. Bitte zuerst unter <strong>Hierarchie</strong> anlegen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetGroups.length > 0 && styleDirections.length > 0 && (
|
||||
<div className="assignment-matrix-container">
|
||||
<table className="assignment-matrix">
|
||||
<div className="admin-assignments-matrix-container">
|
||||
<table className="admin-assignments-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 2 }}>Stilrichtung</th>
|
||||
{targetGroups.map(tg => (
|
||||
<th key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
|
||||
<th className="admin-assignments-matrix__corner">Stilrichtung</th>
|
||||
{targetGroups.map((tg) => (
|
||||
<th key={tg.id} className="admin-assignments-matrix__th-narrow">
|
||||
{tg.name}
|
||||
</th>
|
||||
))}
|
||||
|
|
@ -82,17 +83,18 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
|
|||
<tbody>
|
||||
{Object.entries(groupedStyles).map(([focusAreaName, styles]) => (
|
||||
<React.Fragment key={focusAreaName}>
|
||||
<tr className="focus-area-header">
|
||||
<td colSpan={targetGroups.length + 1} style={{ background: 'var(--surface2)', padding: '8px 12px', fontWeight: 600, color: 'var(--text2)' }}>
|
||||
<tr>
|
||||
<td
|
||||
className="admin-assignments-matrix__focus-header"
|
||||
colSpan={targetGroups.length + 1}
|
||||
>
|
||||
{focusAreaName}
|
||||
</td>
|
||||
</tr>
|
||||
{styles.map(sd => (
|
||||
{styles.map((sd) => (
|
||||
<tr key={sd.id}>
|
||||
<td style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 1, padding: '12px', fontWeight: 500 }}>
|
||||
{sd.name}
|
||||
</td>
|
||||
{targetGroups.map(tg => {
|
||||
<td className="admin-assignments-matrix__row-label">{sd.name}</td>
|
||||
{targetGroups.map((tg) => {
|
||||
const assigned = isAssigned(sd.id, tg.id)
|
||||
return (
|
||||
<td key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
|
||||
|
|
@ -101,7 +103,8 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
|
|||
checked={assigned}
|
||||
onChange={() => toggleAssignment(sd.id, tg.id, assigned)}
|
||||
disabled={saving}
|
||||
style={{ width: '20px', height: '20px', cursor: 'pointer' }}
|
||||
aria-label={`${sd.name} — ${tg.name}`}
|
||||
style={{ width: '20px', height: '20px', cursor: 'pointer', accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
|
|
@ -114,45 +117,6 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
|
|||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.assignment-matrix-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.assignment-matrix {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.assignment-matrix th,
|
||||
.assignment-matrix td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.assignment-matrix th {
|
||||
background: var(--surface2);
|
||||
font-weight: 600;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.assignment-matrix tbody tr:hover {
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.assignment-matrix {
|
||||
font-size: 14px;
|
||||
}
|
||||
.assignment-matrix th,
|
||||
.assignment-matrix td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import React, { useState } from 'react'
|
||||
import { Target, Tags, Dumbbell } from 'lucide-react'
|
||||
import { api } from '../../utils/api'
|
||||
|
||||
function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loading, error, onUpdate }) {
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: '2.5rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '24px' }}>
|
||||
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface)', borderRadius: '8px' }}>{error}</div>}
|
||||
<div className="admin-catalog-stack">
|
||||
{error && <div className="admin-matrix-alert">{error}</div>}
|
||||
|
||||
<CatalogSection
|
||||
title="Zielgruppen"
|
||||
icon="🎯"
|
||||
Icon={Target}
|
||||
items={targetGroups}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createTargetGroup}
|
||||
|
|
@ -28,7 +33,7 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
|
|||
|
||||
<CatalogSection
|
||||
title="Fähigkeitskategorien"
|
||||
icon="⚡"
|
||||
Icon={Tags}
|
||||
items={skillCategories}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createSkillCategory}
|
||||
|
|
@ -42,7 +47,7 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
|
|||
|
||||
<CatalogSection
|
||||
title="Trainingscharakter"
|
||||
icon="💪"
|
||||
Icon={Dumbbell}
|
||||
items={trainingCharacters}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createTrainingCharacter}
|
||||
|
|
@ -57,27 +62,27 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
|
|||
)
|
||||
}
|
||||
|
||||
function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
|
||||
function CatalogSection({ title, Icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [form, setForm] = useState({})
|
||||
|
||||
function startCreate() {
|
||||
const emptyForm = {}
|
||||
fields.forEach(f => { emptyForm[f.key] = '' })
|
||||
fields.forEach((f) => { emptyForm[f.key] = '' })
|
||||
setForm(emptyForm)
|
||||
setCreating(true)
|
||||
}
|
||||
|
||||
function startEdit(item) {
|
||||
const editForm = {}
|
||||
fields.forEach(f => { editForm[f.key] = item[f.key] || '' })
|
||||
fields.forEach((f) => { editForm[f.key] = item[f.key] || '' })
|
||||
setEditing(item.id)
|
||||
setForm(editForm)
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const required = fields.filter(f => f.required)
|
||||
const required = fields.filter((f) => f.required)
|
||||
for (const field of required) {
|
||||
if (!form[field.key]) {
|
||||
alert(`${field.label} ist erforderlich`)
|
||||
|
|
@ -116,75 +121,116 @@ function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, dele
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: 0 }}>{icon} {title}</h3>
|
||||
<button className="btn btn-primary" onClick={startCreate}>+ Neu</button>
|
||||
<div className="admin-catalog-section">
|
||||
<div className="admin-catalog-section__head">
|
||||
<h3 className="admin-catalog-section__title">
|
||||
{Icon ? (
|
||||
<Icon className="admin-catalog-section__icon" size={20} strokeWidth={2} aria-hidden />
|
||||
) : null}
|
||||
{title}
|
||||
</h3>
|
||||
<button type="button" className="btn btn-primary btn-small" onClick={startCreate}>
|
||||
+ Neu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div style={{ marginBottom: '20px', padding: '16px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
||||
<h4 style={{ marginTop: 0 }}>Neu erstellen</h4>
|
||||
{fields.map(field => (
|
||||
<div className="admin-catalog-inline-form">
|
||||
<h4>Neu erstellen</h4>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="form-row">
|
||||
<label className="form-label">{field.label} {field.required && '*'}</label>
|
||||
<label className="form-label">
|
||||
{field.label} {field.required && '*'}
|
||||
</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={form[field.key] || ''}
|
||||
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
|
||||
<input
|
||||
className="form-input"
|
||||
type={field.type}
|
||||
value={form[field.key] || ''}
|
||||
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>Erstellen</button>
|
||||
<button className="btn" onClick={() => setCreating(false)}>Abbrechen</button>
|
||||
<div className="admin-catalog-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={handleCreate}>
|
||||
Erstellen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{items.map(item => (
|
||||
<div key={item.id} style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
||||
<div className="admin-catalog-list">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="admin-catalog-item">
|
||||
{editing === item.id ? (
|
||||
<div>
|
||||
{fields.map(field => (
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="form-row">
|
||||
<label className="form-label">{field.label}</label>
|
||||
{field.type === 'textarea' ? (
|
||||
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={form[field.key] || ''}
|
||||
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
|
||||
<input
|
||||
className="form-input"
|
||||
type={field.type}
|
||||
value={form[field.key] || ''}
|
||||
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||
<button className="btn btn-primary" onClick={() => handleUpdate(item.id)}>Speichern</button>
|
||||
<button className="btn" onClick={() => setEditing(null)}>Abbrechen</button>
|
||||
<div className="admin-catalog-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => handleUpdate(item.id)}>
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditing(null)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div className="admin-catalog-item__name-row">
|
||||
<strong>{item.name}</strong>
|
||||
{item.min_age !== null && item.max_age !== null && (
|
||||
<span style={{ marginLeft: '12px', color: 'var(--text3)', fontSize: '14px' }}>
|
||||
{item.min_age != null && item.max_age != null && (
|
||||
<span className="admin-catalog-meta">
|
||||
Alter: {item.min_age}-{item.max_age}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && <p style={{ color: 'var(--text2)', fontSize: '14px', margin: '8px 0' }}>{item.description}</p>}
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<button className="btn" onClick={() => startEdit(item)}>Bearbeiten</button>
|
||||
<button className="btn" onClick={() => handleDelete(item.id, item.name)}>Löschen</button>
|
||||
{item.description ? (
|
||||
<p className="admin-catalog-desc">{item.description}</p>
|
||||
) : null}
|
||||
<div className="admin-catalog-actions">
|
||||
<button type="button" className="btn btn-secondary btn-small" onClick={() => startEdit(item)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger btn-small" onClick={() => handleDelete(item.id, item.name)}>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && !creating && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '20px' }}>
|
||||
Noch keine Einträge vorhanden
|
||||
</div>
|
||||
<div className="admin-catalog-empty">Noch keine Einträge vorhanden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ function DetailPanel({ item, onUpdate, focusAreas }) {
|
|||
return <TrainingTypeDetail item={item} onUpdate={onUpdate} focusAreas={focusAreas} />
|
||||
}
|
||||
|
||||
return <div style={{ padding: '20px', color: 'var(--text3)' }}>Unbekannter Typ: {type}</div>
|
||||
return <div className="detail-panel__unknown">Unbekannter Typ: {type}</div>
|
||||
}
|
||||
|
||||
function FocusAreaDetail({ item, onUpdate }) {
|
||||
|
|
@ -57,7 +57,7 @@ function FocusAreaDetail({ item, onUpdate }) {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Fokusbereich bearbeiten</h2>
|
||||
<h2 className="detail-panel__title">Fokusbereich bearbeiten</h2>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
|
||||
|
|
@ -81,11 +81,11 @@ function FocusAreaDetail({ item, onUpdate }) {
|
|||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<div className="detail-panel__actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name}>
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
<button className="btn" onClick={handleDelete}>Löschen</button>
|
||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -130,7 +130,7 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Stilrichtung bearbeiten</h2>
|
||||
<h2 className="detail-panel__title">Stilrichtung bearbeiten</h2>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
|
||||
|
|
@ -163,11 +163,11 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
|
|||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<div className="detail-panel__actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
<button className="btn" onClick={handleDelete}>Löschen</button>
|
||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -212,7 +212,7 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Trainingstyp bearbeiten</h2>
|
||||
<h2 className="detail-panel__title">Trainingstyp bearbeiten</h2>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
|
||||
|
|
@ -245,11 +245,11 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
|
|||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<div className="detail-panel__actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
|
||||
{saving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
<button className="btn" onClick={handleDelete}>Löschen</button>
|
||||
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -284,8 +284,8 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Neue Stilrichtung erstellen</h2>
|
||||
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
|
||||
<h2 className="detail-panel__title">Neue Stilrichtung erstellen</h2>
|
||||
<div className="detail-panel__context">
|
||||
Fokusbereich: <strong>{item.focus_area_name}</strong>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
|
|
@ -304,7 +304,7 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
|
|||
<label className="form-label">Sortierung</label>
|
||||
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<div className="detail-panel__actions">
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={saving || !form.name}>
|
||||
{saving ? 'Erstellt...' : 'Erstellen'}
|
||||
</button>
|
||||
|
|
@ -342,8 +342,8 @@ function CreateTrainingTypeForm({ item, onUpdate }) {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0 }}>Neuen Trainingstyp erstellen</h2>
|
||||
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
|
||||
<h2 className="detail-panel__title">Neuen Trainingstyp erstellen</h2>
|
||||
<div className="detail-panel__context">
|
||||
Fokusbereich: <strong>{item.focus_area_name}</strong>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
|
|
@ -362,7 +362,7 @@ function CreateTrainingTypeForm({ item, onUpdate }) {
|
|||
<label className="form-label">Sortierung</label>
|
||||
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
|
||||
<div className="detail-panel__actions">
|
||||
<button className="btn btn-primary" onClick={handleCreate} disabled={saving || !form.name}>
|
||||
{saving ? 'Erstellt...' : 'Erstellen'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, selectedType }) {
|
||||
const nodeId = `fa-${focusArea.id}`
|
||||
|
|
@ -6,82 +7,94 @@ function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, se
|
|||
const isSelected = selectedType === 'focus_area' && selectedId === focusArea.id
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
{/* Focus Area Header */}
|
||||
<div
|
||||
onClick={() => onSelect(focusArea, 'focus_area')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'var(--accent)' : 'transparent',
|
||||
color: isSelected ? 'white' : 'var(--text1)',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(nodeId) }}
|
||||
style={{ marginRight: '8px', cursor: 'pointer', fontSize: '18px' }}
|
||||
<div className="focus-tree-root">
|
||||
<div className={'focus-tree-header' + (isSelected ? ' focus-tree-header--selected' : '')}>
|
||||
<button
|
||||
type="button"
|
||||
className="focus-tree-toggle"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? 'Bereich einklappen' : 'Bereich aufklappen'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(nodeId)
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
<span style={{ marginRight: '8px' }}>{focusArea.icon}</span>
|
||||
<span>{focusArea.name}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} strokeWidth={2} aria-hidden />
|
||||
) : (
|
||||
<ChevronRight size={18} strokeWidth={2} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="focus-tree-header__label"
|
||||
onClick={() => onSelect(focusArea, 'focus_area')}
|
||||
>
|
||||
{focusArea.icon ? (
|
||||
<span className="focus-tree-emoji" aria-hidden>
|
||||
{focusArea.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span>{focusArea.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Children: Style Directions + Training Types */}
|
||||
{isExpanded && (
|
||||
<div style={{ marginLeft: '28px', marginTop: '8px' }}>
|
||||
{/* Style Directions Section */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="focus-tree-children">
|
||||
<div className="focus-tree-group">
|
||||
<div className="focus-tree-group__head">
|
||||
<span>Stilrichtungen</span>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-tiny focus-tree-add-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect({ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_style_direction')
|
||||
onSelect(
|
||||
{ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name },
|
||||
'create_style_direction'
|
||||
)
|
||||
}}
|
||||
>
|
||||
+ Neu
|
||||
</button>
|
||||
</div>
|
||||
{focusArea.style_directions && focusArea.style_directions.map(sd => (
|
||||
<StyleDirectionNode
|
||||
key={sd.id}
|
||||
styleDirection={sd}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
|
||||
/>
|
||||
))}
|
||||
{focusArea.style_directions &&
|
||||
focusArea.style_directions.map((sd) => (
|
||||
<StyleDirectionNode
|
||||
key={sd.id}
|
||||
styleDirection={sd}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Training Types Section */}
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="focus-tree-group">
|
||||
<div className="focus-tree-group__head">
|
||||
<span>Trainingstypen</span>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-tiny focus-tree-add-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect({ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_training_type')
|
||||
onSelect(
|
||||
{ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name },
|
||||
'create_training_type'
|
||||
)
|
||||
}}
|
||||
>
|
||||
+ Neu
|
||||
</button>
|
||||
</div>
|
||||
{focusArea.training_types && focusArea.training_types.map(tt => (
|
||||
<TrainingTypeNode
|
||||
key={tt.id}
|
||||
trainingType={tt}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedType === 'training_type' && selectedId === tt.id}
|
||||
/>
|
||||
))}
|
||||
{focusArea.training_types &&
|
||||
focusArea.training_types.map((tt) => (
|
||||
<TrainingTypeNode
|
||||
key={tt.id}
|
||||
trainingType={tt}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedType === 'training_type' && selectedId === tt.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -92,28 +105,26 @@ function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, se
|
|||
function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={'focus-tree-item' + (isSelected ? ' focus-tree-item--selected' : '')}
|
||||
onClick={() => onSelect(styleDirection, 'style_direction')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
marginBottom: '4px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
|
||||
color: isSelected ? 'white' : 'var(--text1)',
|
||||
fontSize: '14px'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect(styleDirection, 'style_direction')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{styleDirection.name}
|
||||
{styleDirection.abbreviation && (
|
||||
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
|
||||
({styleDirection.abbreviation})
|
||||
</span>
|
||||
)}
|
||||
{styleDirection.target_groups && styleDirection.target_groups.length > 0 && (
|
||||
<div style={{ fontSize: '11px', opacity: 0.8, marginTop: '4px' }}>
|
||||
Zielgruppen: {styleDirection.target_groups.map(tg => tg.name).join(', ')}
|
||||
{styleDirection.abbreviation ? (
|
||||
<span className="focus-tree-item__abbr">({styleDirection.abbreviation})</span>
|
||||
) : null}
|
||||
{styleDirection.target_groups && styleDirection.target_groups.length > 0 ? (
|
||||
<div className="focus-tree-item__meta">
|
||||
Zielgruppen: {styleDirection.target_groups.map((tg) => tg.name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -121,25 +132,23 @@ function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
|
|||
function TrainingTypeNode({ trainingType, onSelect, isSelected }) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={'focus-tree-item' + (isSelected ? ' focus-tree-item--selected' : '')}
|
||||
onClick={() => onSelect(trainingType, 'training_type')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
marginBottom: '4px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
|
||||
color: isSelected ? 'white' : 'var(--text1)',
|
||||
fontSize: '14px'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect(trainingType, 'training_type')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trainingType.name}
|
||||
{trainingType.abbreviation && (
|
||||
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
|
||||
({trainingType.abbreviation})
|
||||
</span>
|
||||
)}
|
||||
{trainingType.abbreviation ? (
|
||||
<span className="focus-tree-item__abbr">({trainingType.abbreviation})</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FocusAreaNode
|
||||
export default FocusAreaNode
|
||||
|
|
@ -1,29 +1,24 @@
|
|||
import React from 'react'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import FocusAreaNode from './FocusAreaNode'
|
||||
import DetailPanel from './DetailPanel'
|
||||
|
||||
function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error, onToggleNode, onSelectItem, onUpdate }) {
|
||||
if (loading && hierarchy.length === 0) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: '2.5rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-hierarchy-container">
|
||||
{/* Tree View */}
|
||||
<div
|
||||
className="admin-tree-view"
|
||||
style={{
|
||||
display: selectedItem ? 'none' : 'block',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
background: 'var(--surface)'
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0 }}>Katalog-Hierarchie</h2>
|
||||
{error && <div style={{ color: 'var(--danger)', marginBottom: '16px' }}>{error}</div>}
|
||||
<div className="admin-hierarchy-container admin-hierarchy-layout">
|
||||
<div className="admin-hierarchy-pane admin-hierarchy-pane--tree" hidden={!!selectedItem}>
|
||||
<h2 className="admin-hierarchy-pane__title">Katalog-Hierarchie</h2>
|
||||
{error && <div className="admin-matrix-alert">{error}</div>}
|
||||
|
||||
{hierarchy.map(fa => (
|
||||
{hierarchy.map((fa) => (
|
||||
<FocusAreaNode
|
||||
key={fa.id}
|
||||
focusArea={fa}
|
||||
|
|
@ -36,22 +31,15 @@ function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error,
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedItem && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
background: 'var(--surface)'
|
||||
}}
|
||||
>
|
||||
<div className="admin-hierarchy-pane admin-hierarchy-pane--detail">
|
||||
<button
|
||||
className="btn admin-back-button"
|
||||
type="button"
|
||||
className="btn btn-secondary btn-small admin-hierarchy-back"
|
||||
onClick={() => onSelectItem(null)}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
← Zurück zur Übersicht
|
||||
<ArrowLeft size={16} strokeWidth={2} aria-hidden />
|
||||
Zurück zur Übersicht
|
||||
</button>
|
||||
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
|
||||
</div>
|
||||
|
|
|
|||
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 { api } from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
|
||||
const CATALOG_SUBTABS = [
|
||||
{ id: 'focus-areas', label: 'Fokusbereiche' },
|
||||
{ id: 'training-styles', label: 'Stilrichtungen' },
|
||||
{ id: 'training-types', label: 'Trainingsstil' },
|
||||
{ id: 'hierarchy', label: 'Hierarchie' },
|
||||
{ id: 'target-groups', label: 'Zielgruppen' },
|
||||
{ id: 'target-groups-matrix', label: 'Zuordnungen' },
|
||||
{ id: 'training-characters', label: 'Trainingscharakter' },
|
||||
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
|
||||
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' },
|
||||
]
|
||||
|
||||
export default function AdminCatalogsPage() {
|
||||
const [activeTab, setActiveTab] = useState('focus-areas')
|
||||
|
|
@ -316,44 +329,16 @@ export default function AdminCatalogsPage() {
|
|||
<div className="app-page">
|
||||
<AdminPageNav />
|
||||
|
||||
<h1 style={{ marginBottom: '24px' }}>Stammdaten-Kataloge</h1>
|
||||
<h1 className="page-title">Stammdaten-Kataloge</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '8px', borderBottom: '2px solid var(--border)', marginBottom: '24px', overflowX: 'auto' }}>
|
||||
{[
|
||||
{ id: 'focus-areas', label: 'Fokusbereiche' },
|
||||
{ id: 'training-styles', label: 'Stilrichtungen' },
|
||||
{ id: 'training-types', label: 'Trainingsstil' },
|
||||
{ id: 'hierarchy', label: 'Hierarchie' },
|
||||
{ id: 'target-groups', label: 'Zielgruppen' },
|
||||
{ id: 'target-groups-matrix', label: 'Zuordnungen' },
|
||||
{ id: 'training-characters', label: 'Trainingscharakter' },
|
||||
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
|
||||
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className="btn"
|
||||
style={{
|
||||
borderBottom: activeTab === tab.id ? '3px solid var(--accent)' : 'none',
|
||||
borderRadius: 0,
|
||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||
color: activeTab === tab.id ? 'var(--accent)' : 'var(--text2)',
|
||||
padding: '12px 16px',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PageSectionNav
|
||||
ariaLabel="Katalogbereiche"
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={CATALOG_SUBTABS}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '16px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="admin-matrix-alert">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="spinner" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { TreePine, FolderTree, Link2 } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
import AppSubnavShell from '../components/AppSubnavShell'
|
||||
import HierarchyTab from '../components/admin/HierarchyTab'
|
||||
import CatalogsTab from '../components/admin/CatalogsTab'
|
||||
import AssignmentsTab from '../components/admin/AssignmentsTab'
|
||||
|
|
@ -10,17 +12,14 @@ function AdminHierarchyPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Hierarchy Tab State
|
||||
const [hierarchy, setHierarchy] = useState([])
|
||||
const [expandedNodes, setExpandedNodes] = useState(new Set())
|
||||
const [selectedItem, setSelectedItem] = useState(null)
|
||||
|
||||
// Catalogs Tab State
|
||||
const [targetGroups, setTargetGroups] = useState([])
|
||||
const [skillCategories, setSkillCategories] = useState([])
|
||||
const [trainingCharacters, setTrainingCharacters] = useState([])
|
||||
|
||||
// Assignments Tab State
|
||||
const [styleDirections, setStyleDirections] = useState([])
|
||||
const [assignments, setAssignments] = useState([])
|
||||
|
||||
|
|
@ -62,7 +61,7 @@ function AdminHierarchyPage() {
|
|||
}
|
||||
|
||||
function handleToggleNode(nodeId) {
|
||||
setExpandedNodes(prev => {
|
||||
setExpandedNodes((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(nodeId)) {
|
||||
newSet.delete(nodeId)
|
||||
|
|
@ -86,33 +85,26 @@ function AdminHierarchyPage() {
|
|||
loadData()
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'hierarchy', label: '🌳 Hierarchie', icon: '🌳' },
|
||||
{ id: 'catalogs', label: '📋 Kataloge', icon: '📋' },
|
||||
{ id: 'assignments', label: '🔗 Zuordnungen', icon: '🔗' }
|
||||
const subnavItems = [
|
||||
{ id: 'hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||
{ id: 'catalogs', label: 'Kataloge', icon: FolderTree },
|
||||
{ id: 'assignments', label: 'Zuordnungen', icon: Link2 }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="app-page admin-hierarchy-page">
|
||||
<AdminPageNav />
|
||||
|
||||
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1>
|
||||
<h1 className="page-title" style={{ marginBottom: '12px' }}>
|
||||
Katalog & Hierarchie
|
||||
</h1>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="tab-navigation">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<AppSubnavShell
|
||||
ariaLabel="Bereich Katalogadministration"
|
||||
items={subnavItems}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
>
|
||||
{activeTab === 'hierarchy' && (
|
||||
<HierarchyTab
|
||||
hierarchy={hierarchy}
|
||||
|
|
@ -147,48 +139,7 @@ function AdminHierarchyPage() {
|
|||
onUpdate={handleUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.tab-navigation {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 12px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--text1);
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tab-button {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
font-size: 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</AppSubnavShell>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
|
|||
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
|
||||
import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin'
|
||||
import MaturityMatrixToolsAdmin from '../components/admin/MaturityMatrixToolsAdmin'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
|
||||
const MATURITY_SECTION_TABS = [
|
||||
{ id: 'catalog', label: 'Katalog und Hierarchie' },
|
||||
{ id: 'models', label: 'Reifegradmodelle' },
|
||||
{ id: 'bindings', label: 'Kontext-Zuordnung' },
|
||||
{ id: 'matrixviz', label: 'Matrix-Ansicht und Export' },
|
||||
]
|
||||
|
||||
export default function AdminMaturityModelsPage() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -27,44 +35,12 @@ export default function AdminMaturityModelsPage() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'catalog'}
|
||||
className={'admin-tabs__tab' + (tab === 'catalog' ? ' admin-tabs__tab--active' : '')}
|
||||
onClick={() => setTab('catalog')}
|
||||
>
|
||||
Katalog und Hierarchie
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'models'}
|
||||
className={'admin-tabs__tab' + (tab === 'models' ? ' admin-tabs__tab--active' : '')}
|
||||
onClick={() => setTab('models')}
|
||||
>
|
||||
Reifegradmodelle
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'bindings'}
|
||||
className={'admin-tabs__tab' + (tab === 'bindings' ? ' admin-tabs__tab--active' : '')}
|
||||
onClick={() => setTab('bindings')}
|
||||
>
|
||||
Kontext-Zuordnung
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'matrixviz'}
|
||||
className={'admin-tabs__tab' + (tab === 'matrixviz' ? ' admin-tabs__tab--active' : '')}
|
||||
onClick={() => setTab('matrixviz')}
|
||||
>
|
||||
Matrix-Ansicht und Export
|
||||
</button>
|
||||
</div>
|
||||
<PageSectionNav
|
||||
ariaLabel="Bereiche Fähigkeiten"
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
items={MATURITY_SECTION_TABS}
|
||||
/>
|
||||
|
||||
<div className="admin-tabs__panel" role="tabpanel">
|
||||
{tab === 'catalog' ? (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
|
||||
const CLUB_ROLE_OPTIONS = [
|
||||
{ code: 'club_admin', label: 'Vereinsadmin' },
|
||||
|
|
@ -285,9 +286,22 @@ function ClubsPage() {
|
|||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const clubTabItems = useMemo(() => {
|
||||
const ids = canManageOrgSomewhere
|
||||
? ['clubs', 'divisions', 'groups', 'members']
|
||||
: ['clubs', 'divisions', 'groups']
|
||||
const labels = {
|
||||
clubs: 'Vereine',
|
||||
divisions: 'Sparten',
|
||||
groups: 'Trainingsgruppen',
|
||||
members: 'Mitglieder',
|
||||
}
|
||||
return ids.map((id) => ({ id, label: labels[id] }))
|
||||
}, [canManageOrgSomewhere])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="skills-page__loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
|
@ -295,46 +309,21 @@ function ClubsPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
|
||||
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
|
||||
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
|
||||
Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
|
||||
</p>
|
||||
<div className="app-page clubs-page">
|
||||
<h1 className="page-title">Vereinsverwaltung</h1>
|
||||
<p className="clubs-page__intro muted">
|
||||
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
|
||||
Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
|
||||
</p>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
borderBottom: '2px solid var(--border)'
|
||||
}}>
|
||||
{(canManageOrgSomewhere
|
||||
? ['clubs', 'divisions', 'groups', 'members']
|
||||
: ['clubs', 'divisions', 'groups']
|
||||
).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: activeTab === tab ? 'var(--accent)' : 'transparent',
|
||||
color: activeTab === tab ? 'white' : 'var(--text1)',
|
||||
border: 'none',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeTab === tab ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
{tab === 'clubs' && 'Vereine'}
|
||||
{tab === 'divisions' && 'Sparten'}
|
||||
{tab === 'groups' && 'Trainingsgruppen'}
|
||||
{tab === 'members' && 'Mitglieder'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PageSectionNav
|
||||
ariaLabel="Vereinsverwaltung"
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={clubTabItems}
|
||||
/>
|
||||
|
||||
{/* Clubs Tab */}
|
||||
{/* Clubs Tab */}
|
||||
{activeTab === 'clubs' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
|
|
@ -512,11 +501,13 @@ function ClubsPage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div
|
||||
className="card-grid clubs-groups-card-grid"
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}
|
||||
>
|
||||
{groups.map(group => (
|
||||
<div key={group.id} className="card">
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{group.name}</h3>
|
||||
|
|
|
|||
|
|
@ -106,31 +106,32 @@ function Dashboard() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<h1>Dashboard</h1>
|
||||
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
|
||||
Willkommen, {user?.name || user?.email}!
|
||||
</p>
|
||||
{profile && <EmailVerificationBanner profile={profile} />}
|
||||
{/* Welcome Card */}
|
||||
<div className="card" style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
|
||||
<h2>Willkommen bei Shinkan Jinkendo</h2>
|
||||
<p style={{ color: 'var(--text2)' }}>
|
||||
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
|
||||
<div className="app-page dashboard-page">
|
||||
<div className="dashboard-greeting">
|
||||
<div>
|
||||
<h1 className="page-title" style={{ marginBottom: '6px' }}>
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Willkommen, {user?.name || user?.email}! Shinkan unterstützt dich bei Übungen, Planung und Vereinsstruktur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile && <EmailVerificationBanner profile={profile} />}
|
||||
|
||||
{user?.id && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
|
||||
gap: '1rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}
|
||||
>
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
|
||||
{user?.id && (
|
||||
<div
|
||||
className="dashboard-training-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 280px), 1fr))',
|
||||
gap: '1rem',
|
||||
alignItems: 'stretch',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: '1.05rem', marginBottom: '0.65rem' }}>Deine nächsten Trainings</h3>
|
||||
{trainingHomeErr ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>{trainingHomeErr}</p>
|
||||
) : trainingHome?.upcoming?.length ? (
|
||||
|
|
@ -153,11 +154,12 @@ function Dashboard() {
|
|||
</ul>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', margin: 0 }}>
|
||||
Keine anstehenden Termine mit dir als Leitung oder 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)' }}>
|
||||
Trainingsplanung
|
||||
</Link>{' '}
|
||||
kannst du den Vereins‑ oder Gruppen‑Zeitraum einblenden.
|
||||
kannst du Zeiträume und Zuordnungen bearbeiten.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -215,43 +217,6 @@ function Dashboard() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 260px), 1fr))',
|
||||
gap: '1rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
<div className="card">
|
||||
<h3>✅ Fertig</h3>
|
||||
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
|
||||
<li>Backend-Basis</li>
|
||||
<li>Datenbank-Schema</li>
|
||||
<li>Auth-System</li>
|
||||
<li>Login & Registrierung</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>🚧 In Arbeit</h3>
|
||||
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
|
||||
<li>Übungsverwaltung</li>
|
||||
<li>Trainingsplanung</li>
|
||||
<li>Kataloge (Skills, Methods)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>📋 Geplant</h3>
|
||||
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
|
||||
<li>MediaWiki-Import</li>
|
||||
<li>Trainingsprogramme</li>
|
||||
<li>Admin-Panel</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
{version && (
|
||||
<div className="card">
|
||||
<h3>System-Information</h3>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,14 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { Eye, Play, History } from 'lucide-react'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
|
||||
const WIKI_IMPORT_TABS = [
|
||||
{ id: 'preview', label: 'Vorschau', icon: Eye },
|
||||
{ id: 'execute', label: 'Ausführen', icon: Play },
|
||||
{ id: 'history', label: 'Historie', icon: History },
|
||||
]
|
||||
|
||||
export default function MediaWikiImportPage() {
|
||||
const [activeTab, setActiveTab] = useState('preview')
|
||||
|
|
@ -111,32 +119,12 @@ export default function MediaWikiImportPage() {
|
|||
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
|
||||
</p>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ borderBottom: '2px solid var(--border)', marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{['preview', 'execute', 'history'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
background: activeTab === tab ? 'var(--accent)' : 'transparent',
|
||||
color: activeTab === tab ? 'white' : 'var(--text1)',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
fontWeight: activeTab === tab ? 'bold' : 'normal',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
{tab === 'preview' && '👁️ Vorschau'}
|
||||
{tab === 'execute' && '▶️ Ausführen'}
|
||||
{tab === 'history' && '📜 Historie'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PageSectionNav
|
||||
ariaLabel="Import-Schritte"
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={WIKI_IMPORT_TABS}
|
||||
/>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
|
||||
const SKILLS_SECTION_TABS = [
|
||||
{ id: 'skills', label: 'Fähigkeiten' },
|
||||
{ id: 'methods', label: 'Trainingsmethoden' },
|
||||
]
|
||||
|
||||
function SkillsPage() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -132,7 +138,7 @@ function SkillsPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="skills-page__loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
|
@ -143,40 +149,22 @@ function SkillsPage() {
|
|||
const methodsByCategory = groupByCategory(methods)
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<h1 style={{ marginBottom: '1.5rem' }}>Fähigkeiten & Methoden</h1>
|
||||
<div className="app-page skills-page">
|
||||
<h1 className="page-title">Fähigkeiten & Methoden</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
borderBottom: '2px solid var(--border)'
|
||||
}}>
|
||||
{['skills', 'methods'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: activeTab === tab ? 'var(--accent)' : 'transparent',
|
||||
color: activeTab === tab ? 'white' : 'var(--text1)',
|
||||
border: 'none',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeTab === tab ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
{tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PageSectionNav
|
||||
ariaLabel="Bereich wählen"
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={SKILLS_SECTION_TABS}
|
||||
className="skills-page__tabs-scroll"
|
||||
/>
|
||||
|
||||
{/* Skills Tab */}
|
||||
{activeTab === 'skills' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<p style={{ color: 'var(--text2)' }}>
|
||||
<div className="skills-page__intro-row">
|
||||
<p>
|
||||
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
|
||||
</p>
|
||||
{isAdmin && (
|
||||
|
|
@ -188,60 +176,46 @@ function SkillsPage() {
|
|||
|
||||
{Object.keys(skillsByCategory).length === 0 ? (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||
<p className="skills-page__empty">
|
||||
Keine Fähigkeiten gefunden
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.keys(skillsByCategory).sort().map(category => (
|
||||
<div key={category} style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
|
||||
<div key={category} className="skills-page__category">
|
||||
<h2 className="skills-page__category-title">
|
||||
{category}
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div className="skills-page__card-grid">
|
||||
{skillsByCategory[category].map(skill => (
|
||||
<div key={skill.id} className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
|
||||
<h3 style={{ fontSize: '1rem' }}>{skill.name}</h3>
|
||||
<div key={skill.id} className="card skills-page-card">
|
||||
<div className="skills-page-card__head">
|
||||
<h3 className="skills-page-card__title">{skill.name}</h3>
|
||||
{skill.importance && (
|
||||
<span style={{
|
||||
fontSize: '0.875rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent)',
|
||||
color: 'white'
|
||||
}}>
|
||||
<span className="skills-page-card__badge">
|
||||
⭐ {skill.importance}/5
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skill.description && (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||
<p className="skills-page-card__desc">
|
||||
{skill.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||
<div className="skills-page-card__actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
type="button"
|
||||
className="btn btn-secondary skills-page-card__grow"
|
||||
onClick={() => handleEdit(skill, 'skill')}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'var(--danger)',
|
||||
color: 'white',
|
||||
border: 'none'
|
||||
}}
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => handleDelete(skill, 'skill')}
|
||||
>
|
||||
Löschen
|
||||
|
|
@ -260,8 +234,8 @@ function SkillsPage() {
|
|||
{/* Methods Tab */}
|
||||
{activeTab === 'methods' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<p style={{ color: 'var(--text2)' }}>
|
||||
<div className="skills-page__intro-row">
|
||||
<p>
|
||||
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
|
||||
</p>
|
||||
{isAdmin && (
|
||||
|
|
@ -273,52 +247,36 @@ function SkillsPage() {
|
|||
|
||||
{Object.keys(methodsByCategory).length === 0 ? (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||
<p className="skills-page__empty">
|
||||
Keine Trainingsmethoden gefunden
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
Object.keys(methodsByCategory).sort().map(category => (
|
||||
<div key={category} style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
|
||||
<div key={category} className="skills-page__category">
|
||||
<h2 className="skills-page__category-title">
|
||||
{category}
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div className="skills-page__card-grid skills-page__card-grid--methods">
|
||||
{methodsByCategory[category].map(method => (
|
||||
<div key={method.id} className="card">
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<h3 style={{ fontSize: '1rem', marginBottom: '0.25rem' }}>
|
||||
<div key={method.id} className="card skills-page-card">
|
||||
<div className="skills-page-card__meta-block">
|
||||
<h3 className="skills-page-card__title skills-page-card__title--method">
|
||||
{method.name}
|
||||
{method.abbreviation && (
|
||||
<span style={{ color: 'var(--text2)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
|
||||
<span className="skills-page-card__abbr">
|
||||
({method.abbreviation})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div className="skills-page-card__meta-row">
|
||||
{method.typical_duration && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--surface2)',
|
||||
color: 'var(--text2)'
|
||||
}}>
|
||||
<span className="skills-page-card__chip">
|
||||
⏱️ {method.typical_duration} min
|
||||
</span>
|
||||
)}
|
||||
{method.typical_group_size && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--surface2)',
|
||||
color: 'var(--text2)'
|
||||
}}>
|
||||
<span className="skills-page-card__chip">
|
||||
👥 {method.typical_group_size}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -326,27 +284,23 @@ function SkillsPage() {
|
|||
</div>
|
||||
|
||||
{method.description && (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||
<p className="skills-page-card__desc">
|
||||
{method.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||
<div className="skills-page-card__actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
type="button"
|
||||
className="btn btn-secondary skills-page-card__grow"
|
||||
onClick={() => handleEdit(method, 'method')}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'var(--danger)',
|
||||
color: 'white',
|
||||
border: 'none'
|
||||
}}
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => handleDelete(method, 'method')}
|
||||
>
|
||||
Löschen
|
||||
|
|
@ -364,36 +318,37 @@ function SkillsPage() {
|
|||
|
||||
{/* Modal */}
|
||||
{showModal && isAdmin && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<h2 style={{ marginBottom: '1.5rem' }}>
|
||||
{editing
|
||||
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
|
||||
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
|
||||
}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setShowModal(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-sheet skills-page-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="skills-page-modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h2 id="skills-page-modal-title" className="admin-modal-sheet__title">
|
||||
{editing
|
||||
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
|
||||
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
|
||||
}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary admin-modal-sheet__close"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-modal-sheet__body">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input
|
||||
|
|
@ -455,7 +410,7 @@ function SkillsPage() {
|
|||
|
||||
{modalType === 'method' && (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||||
<div className="form-row">
|
||||
<label className="form-label">Typische Dauer (min)</label>
|
||||
<input
|
||||
|
|
@ -492,8 +447,8 @@ function SkillsPage() {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
||||
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
||||
<div className="skills-page-modal__footer">
|
||||
<button type="submit" className="btn btn-primary skills-page-modal__submit">
|
||||
{editing ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -505,6 +460,7 @@ function SkillsPage() {
|
|||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import api from '../utils/api'
|
|||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import {
|
||||
defaultSection,
|
||||
normalizeUnitToForm,
|
||||
|
|
@ -663,52 +664,29 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
|
||||
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
|
||||
<details className="framework-edit-intro">
|
||||
<summary className="framework-edit-intro__summary">
|
||||
Kurz erklärt: Was ist ein Rahmenprogramm?
|
||||
</summary>
|
||||
<div className="framework-edit-intro__body">
|
||||
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
|
||||
Zielen und 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>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>Zwischen‑Anmerkungen</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div
|
||||
className="framework-edit__tabbar"
|
||||
role="tablist"
|
||||
aria-label="Bereiche"
|
||||
style={
|
||||
desktopLayout
|
||||
? { display: 'none' }
|
||||
: {
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
marginBottom: 14,
|
||||
padding: '6px 0 12px',
|
||||
borderBottom: '2px solid var(--accent)',
|
||||
flexWrap: 'nowrap',
|
||||
overflowX: 'auto',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 6,
|
||||
background: 'var(--bg)',
|
||||
}
|
||||
}
|
||||
>
|
||||
{[
|
||||
{ id: 'meta', label: 'Stammdaten' },
|
||||
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={frameworkTab === t.id}
|
||||
className={'framework-edit__tab' + (frameworkTab === t.id ? ' framework-edit__tab--active' : '')}
|
||||
onClick={() => setFrameworkTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="framework-edit__tabbar">
|
||||
<PageSectionNav
|
||||
ariaLabel="Bereiche"
|
||||
value={frameworkTab}
|
||||
onChange={setFrameworkTab}
|
||||
items={[
|
||||
{ id: 'meta', label: 'Stammdaten' },
|
||||
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
|
||||
]}
|
||||
className="page-section-nav--embedded framework-edit__section-nav"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -100,12 +100,23 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}>
|
||||
Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '}
|
||||
<strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme
|
||||
mit Bezug zum Rahmen).
|
||||
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
|
||||
Trainingsrahmenprogramme
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
|
||||
Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
|
||||
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
Trainingsplanung
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
|
||||
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
|
||||
<div className="planning-filter-help__body">
|
||||
Unter <strong>Planung</strong> wählst du eine Gruppe und übernimmst Slots aus einem Rahmenprogramm in
|
||||
echte Termine. So bleibt die Bibliothek wiederverwendbar, ohne dass Einzelgruppen fest verdrahtet sind.
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<Link
|
||||
to="/planning/framework-programs/new"
|
||||
|
|
@ -148,9 +159,9 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none' }}>
|
||||
<ul className="framework-programs-list">
|
||||
{rows.map((r) => (
|
||||
<li key={r.id} className="card" style={{ marginBottom: '12px' }}>
|
||||
<li key={r.id} className="card">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -375,7 +375,7 @@ export async function listExercises(filters = {}) {
|
|||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null) return
|
||||
if (typeof v === 'boolean') {
|
||||
if (v) q.set(k, 'true')
|
||||
q.set(k, v ? 'true' : 'false')
|
||||
return
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
|
|
@ -508,7 +508,7 @@ export async function updateExercise(id, data) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
|
||||
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
|
||||
export async function bulkPatchExercisesMetadata(data) {
|
||||
return request('/api/exercises/bulk-metadata', {
|
||||
method: 'PATCH',
|
||||
|
|
|
|||
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
|
||||
|
||||
export const APP_VERSION = "0.8.36"
|
||||
export const BUILD_DATE = "2026-05-05"
|
||||
export const APP_VERSION = "0.8.40"
|
||||
export const BUILD_DATE = "2026-05-06"
|
||||
|
||||
export const PAGE_VERSIONS = {
|
||||
LoginPage: "1.0.0",
|
||||
Dashboard: "1.0.0",
|
||||
AccountSettingsPage: "1.0.0",
|
||||
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
|
||||
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
|
||||
ClubsPage: "1.1.0",
|
||||
SkillsPage: "1.0.0",
|
||||
TrainingPlanningPage: "1.3.1",
|
||||
TrainingPlanningPage: "1.4.0",
|
||||
TrainingFrameworkProgramsListPage: "1.1.0",
|
||||
TrainingFrameworkProgramEditPage: "1.5.0",
|
||||
TrainingUnitRunPage: "1.1.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user