Compare commits

...

23 Commits

Author SHA1 Message Date
d04ebee1f6 Merge pull request 'UX - Filter' (#12) from develop into main
Some checks failed
Deploy Production / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
Reviewed-on: #12
2026-05-06 21:25:56 +02:00
b9d27b59b0 feat: enhance exercise filtering capabilities with new catalog rules
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
Test Suite / pytest-backend (pull_request) Successful in 5s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Failing after 28s
- Introduced new filtering options for style directions, training types, and target groups in the exercise list.
- Implemented catalog rule picker components to manage inclusion and exclusion of exercise attributes.
- Updated utility functions to handle new catalog rules for improved filtering logic.
- Enhanced the ExercisesListPage and ExercisePickerModal to support the new filtering features, improving user experience.
2026-05-06 21:20:19 +02:00
518918a6e5 feat: update version and enhance exercise filtering features
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 29s
- Bumped application version to 0.8.40 and updated module versions accordingly.
- Introduced new focus area filtering options in the ExercisesListPage, allowing users to include or exclude exercises based on specified focus areas.
- Added utility functions for deduplicating and merging focus area IDs to improve filtering logic.
- Enhanced the ExercisePickerModal and ExercisesListPage components to support new focus rules and improve user experience with focus area selections.
2026-05-06 17:15:44 +02:00
cfd40889ac feat: add utility functions for exercise data sanitization
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 29s
- Introduced `sanitizeExerciseRichText` and `coerceApiNameList` utility functions to enhance data handling in ExercisesListPage.
- Improved overall code organization by importing new utilities for better maintainability and readability.
2026-05-06 15:15:26 +02:00
585ee8c90d feat: enhance exercise management features and UI
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 27s
- Introduced new function `club_admin_shares_club_with_creator` to check club admin permissions for shared clubs.
- Updated `can_manage_club_org` to incorporate new role checks.
- Enhanced exercise deletion logic to include checks for club admin roles and shared club memberships.
- Added new filters for exercise visibility and status in the ExercisesListPage, allowing users to exclude specific criteria.
- Implemented functionality to save user-specific exercise list preferences, improving user experience.
- Updated API interactions to support new filtering options and preferences for exercise management.
2026-05-06 13:52:24 +02:00
8eec145393 style: update navigation styles for improved layout and consistency
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 27s
- Adjusted the height and padding of the navigation bar for better alignment with design standards.
- Enhanced the bottom navigation with a new box shadow for improved visual separation.
- Updated nav-item dimensions and styles for better responsiveness and user interaction.
- Increased icon size in navigation items for better visibility and accessibility.
2026-05-06 12:52:35 +02:00
db8af53652 refactor: update navigation components and styles for improved consistency
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
- Replaced legacy .capture-shell with .app-subnav-shell and integrated PageSectionNav for a unified navigation experience across multiple pages.
- Refactored AdminCatalogsPage, AdminMaturityModelsPage, ClubsPage, ExercisesListPage, MediaWikiImportPage, SkillsPage, and TrainingFrameworkProgramEditPage to utilize the new PageSectionNav component for tab navigation.
- Enhanced CSS styles for better responsiveness and visual clarity in navigation elements.
- Improved accessibility features with appropriate ARIA roles and attributes for better usability.
2026-05-06 12:49:35 +02:00
1e1fd80fb7 feat: enhance card layouts and UI components across multiple pages
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 10s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 29s
- Updated CSS styles to improve card spacing and layout consistency in grid formats.
- Introduced a new card-grid class for better handling of card arrangements in ClubsPage and TrainingFrameworkProgramsListPage.
- Added ExerciseCardScopeStatus component to display visibility and status icons in ExercisesListPage, enhancing user feedback.
- Refactored exercise card actions and footer for improved layout and accessibility.
- Enhanced overall responsiveness and visual clarity across various components.
2026-05-06 12:41:04 +02:00
b9ef0395c1 feat: enhance app.css and AppSubnavShell for improved navigation and layout
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 31s
- Updated CSS styles to refine card spacing in grid layouts, ensuring consistent margins.
- Enhanced the capture-shell component for better responsiveness and sticky navigation across all viewports.
- Improved sub-navigation structure for both mobile and desktop, promoting a unified user experience.
- Added detailed comments in CSS for better clarity on navigation layers and layout intentions.
2026-05-06 12:31:39 +02:00
5096eec16b feat: enhance Exercises and Clubs pages with improved UI and functionality
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 28s
- Added new utility functions for handling exercise focus areas, style directions, and training types, improving data presentation.
- Refactored ExercisesListPage to utilize new card layouts and improved visibility labels for exercises.
- Updated ClubsPage and SkillsPage to implement a consistent tab navigation style, enhancing user experience.
- Enhanced CSS styles for better responsiveness and visual consistency across various components.
- Improved loading states and accessibility features for better user feedback and interaction.
2026-05-06 12:20:22 +02:00
68923b0364 feat: enhance UI and functionality for Skills and Exercises pages
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 29s
- Added new CSS styles for Skills and Exercises pages, improving layout and responsiveness.
- Refactored components to utilize new styles, enhancing visual consistency and user experience.
- Implemented horizontal scrollable navigation for exercises and skills tabs, improving usability on smaller screens.
- Updated button styles and introduced new class names for better maintainability and accessibility.
- Enhanced loading states and empty messages for improved user feedback during data fetching.
2026-05-06 11:24:44 +02:00
657f73d2c5 feat: enhance admin catalog UI and functionality
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 27s
- Added new CSS styles for admin catalog sections, improving layout and responsiveness.
- Implemented icon support for catalog section titles, enhancing visual clarity.
- Refactored loading and error states for better user experience in the CatalogsTab and HierarchyTab components.
- Updated AdminCatalogsPage to utilize new styles and improve tab navigation.
- Enhanced accessibility with appropriate ARIA roles and attributes for better usability.
2026-05-06 11:12:59 +02:00
8b86021293 feat: update bulk metadata patch functionality for exercises
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 29s
- Bumped the version of exercises to 2.8.0, reflecting new features in the bulk metadata patch.
- Enhanced the ExerciseBulkMetadataPatch model to include focus area, style direction, training type, and target group IDs.
- Updated the bulk patch endpoint to support replacing catalog associations for exercises.
- Improved the ExercisesListPage to handle new relation fields and updated UI for bulk operations.
- Adjusted API documentation to reflect changes in the bulk patch functionality.
2026-05-06 11:02:46 +02:00
35a3f6e18d feat: enhance mobile responsiveness and UI components in app.css
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
- Added new CSS styles for mobile layout adjustments, improving spacing and readability for various UI elements.
- Implemented horizontal scrollable navigation for main and admin top navigation, enhancing usability on smaller screens.
- Updated button and card styles for better visual consistency and compactness in mobile view.
- Enhanced tab and segment button styles to improve user interaction and accessibility.
2026-05-06 10:55:36 +02:00
18a58cb5a5 feat: enhance UI and functionality in Training Framework pages
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 53s
- Added new CSS styles for segment buttons and admin assignment matrix, improving layout and responsiveness.
- Refactored AssignmentsTab component to utilize new styles and improve accessibility with aria-labels.
- Introduced collapsible details for framework edit introduction, enhancing user guidance.
- Updated TrainingPlanningPage to streamline button styling and improve visual consistency across components.
2026-05-06 10:46:40 +02:00
14884e6e55 UX. refactor: simplify AdminPageNav component by removing unused hooks and improving styling
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
- Removed the useLocation hook as it was unnecessary for the component's functionality.
- Updated the navigation styling to use CSS classes instead of inline styles, enhancing maintainability and readability.
- Improved accessibility by adding aria-labels to navigation elements.
2026-05-06 10:37:01 +02:00
2007f3f659 feat: enhance TrainingPlanningPage with new training unit creation UI
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 22s
- Introduced a new layout for creating training units within a card format, improving visual organization and user experience.
- Added CSS styles for various elements related to training unit creation, including titles, hints, and action buttons.
- Removed the quick template ID state and related functionality to streamline the creation process.
- Updated user prompts and hints to guide users more effectively in selecting training groups and creating new training units.
2026-05-06 08:44:15 +02:00
d4b9db9520 refactor: update sectionsEditMode logic and remove refine option in TrainingPlanningPage
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 48s
- Revised the sectionsEditMode state to clarify the editing modes available, focusing on 'planning' and 'debrief'.
- Removed the 'refine' option from the editing mode buttons to streamline the user interface.
- Updated related text descriptions to reflect the changes in editing modes, enhancing user guidance during the training planning process.
2026-05-06 08:00:27 +02:00
00b22a756f feat: refactor TrainingUnitSectionsEditor and enhance TrainingPlanningPage layout
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
- Replaced the tu-ex-run-block with a new tu-ex-debrief layout using CSS Grid for improved structure and responsiveness.
- Updated the TrainingUnitSectionsEditor component to utilize the new layout, enhancing the user experience for inputting modifications and actual durations.
- Introduced a sectionsEditMode state in TrainingPlanningPage to manage different editing modes (planning, refine, debrief) for better user guidance.
- Adjusted the visibility of execution extras based on the current editing mode, streamlining the interface for users.
2026-05-06 07:55:35 +02:00
56ea36ea25 feat: enhance TrainingPlanningPage with new utility functions and state management improvements
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
- Added utility functions to normalize co-trainer IDs and filter directory entries, improving data handling for training groups.
- Updated state management to remove reliance on the user profile for club admin checks, enhancing performance and clarity.
- Improved session assignment logic to ensure effective lead trainers are excluded from assistant trainer lists.
- Enhanced form field updates to manage session assistant IDs more effectively, streamlining the assignment process.
2026-05-06 07:42:39 +02:00
9dbd3cbd5f feat: enhance TrainingPlanningPage with template application functionality
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 30s
- Added a new function to apply selected training templates, improving user experience in the training planning process.
- Introduced modal display logic to enhance interaction when applying templates.
- Updated the state management to handle template ID selection more effectively.
2026-05-06 07:25:11 +02:00
9e759a28c6 feat: update application version to 0.8.38 and enhance training planning features
Some checks failed
Deploy Development / deploy (push) Failing after 14s
Test Suite / pytest-backend (push) Successful in 5s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 2s
Test Suite / playwright-tests (push) Successful in 29s
- Bumped application version to 0.8.38 in both backend and frontend files.
- Updated training planning API to improve permission checks for trainer assignments, allowing club admins to manage training units more effectively.
- Enhanced the TrainingPlanningPage with new modal functionality for assigning trainers and improved loading of club member directories.
- Updated changelog to reflect the new version and changes made in this release.
2026-05-06 07:18:30 +02:00
c778d21b26 feat: update application version to 0.8.37 and enhance training planning features
Some checks failed
Deploy Development / deploy (push) Failing after 14s
Test Suite / pytest-backend (push) Successful in 5s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 2s
Test Suite / playwright-tests (push) Successful in 23s
- Bumped application version to 0.8.37 in both backend and frontend files.
- Updated training planning API to include new session assignment features, allowing for lead trainer and assistant trainer assignments.
- Enhanced the TrainingPlanningPage to support dynamic loading of club member directories based on selected groups.
- Improved validation for trainer assignments, ensuring only active club members can be assigned as trainers.
- Updated changelog to reflect the new version and changes made in this release.
2026-05-05 23:35:41 +02:00
43 changed files with 5235 additions and 1621 deletions

View File

@ -1,7 +1,7 @@
# Einheitliche Zugriffsschicht & Governance Umsetzungsplan # Einheitliche Zugriffsschicht & Governance Umsetzungsplan
**Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`) **Status:** verbindliche Umsetzungsreihenfolge (nachgelagert zum Zielbild in `MULTI_TENANCY_RBAC_ARCHITECTURE.md`)
**Stand:** 2026-05-05 **Stand:** 2026-05-06
**Zweck:** Drift vermeiden eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen. **Zweck:** Drift vermeiden eine nachvollziehbare Schicht für Mandanten-Kontext, Sichtbarkeit und Berechtigungen, auf die alle inhaltsbezogenen Module konsistent aufbauen.
**Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) kommt nach stabiler Zugriffs- und Datenisolationsbasis. **Explizit zurückgestellt (wie vereinbart):** kostenpflichtiges Vereins-Membership / Tier-Limits pro Verein (`club_subscriptions` o. Ä.) kommt nach stabiler Zugriffs- und Datenisolationsbasis.
@ -101,7 +101,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
| **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? | | **PR-Checkliste** | Neuer/changed Endpoint: TenantContext verwendet? Governance für Listen + Detail? Tests für zweiten Verein? |
| **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. | | **Single Source of Truth** | Sichtbarkeitsregeln nur in Zugriffsmodul(en), nicht in Routers dupliziert. |
| **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). | | **Änderungen am Enum** | Nur zusammen mit Migration + Kurzbeschreibung in diesem Dokument (Datum/Changelog-Zeile). |
| **Beziehung zu MULTI_TENANCY-Doc** | Phasen 14 dort größtenteils umgesetzt; **Gap-Analyse §3** im alten Dokument historisch lesen fachlicher Zielabgleich bleibt dort, **operative Reihenfolge** hier. | | **Beziehung zu MULTI_TENANCY-Doc** | Zielbild und Gap-Analyse §3 dort pflegen (**§3.0** = aktueller Umsetzungsstand); **operative Reihenfolge** hier. |
--- ---
@ -109,7 +109,7 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin. 1. **TenantContext-Spezifikation** (ein Abschnitt in diesem Dokument oder Kurz-ADR): Request-Lebenszyklus, Fehlerbilder, Superadmin.
2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen). 2. **Endpoint-Audit-Tabelle** (Working-Dokument, bei jedem Merge pflegen bis Stufe C abgeschlossen).
3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — erste rein-funktionale Tests unter `backend/tests/test_access_layer.py` (ohne DB); Integration folgt. 3. **Testplan „Cross-Tenant“** (manuell oder pytest): Minimalsetup zweier Vereine — Unit-Tests `backend/tests/test_access_layer.py`; Integration `backend/tests/test_access_layer_integration.py` bei `ACCESS_LAYER_INTEGRATION=1` / CI im Backend-Container.
**Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` **Audit-Tabelle (fortlaufend):** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
@ -122,4 +122,4 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
--- ---
**Letzte Aktualisierung:** 2026-05-05 **Letzte Aktualisierung:** 2026-05-06

View File

@ -1,7 +1,7 @@
# Multi-Tenancy, Vereins-Membership und Rollenmodell Zielarchitektur & Umsetzungsplan # Multi-Tenancy, Vereins-Membership und Rollenmodell Zielarchitektur & Umsetzungsplan
**Status:** verbindliche Zielrichtung (Architekturpapier) **Status:** verbindliche Zielrichtung (Architekturpapier)
**Stand:** 2026-05-05 **Stand:** 2026-05-06
**Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §1718 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004008) **Bezug:** `functional/shinkan_anforderungsdokument_entwurf.md` §5, §1718 · `functional/DOMAIN_MODEL.md` (Sichtbarkeit §5.5) · `functional/TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` (CURR-004008)
**Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) dort sind Stufen AF, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**. **Operative Reihenfolge & einheitliche Zugriffsschicht:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) dort sind Stufen AF, Drift-Prävention und die Zurückstellung von Vereinsabo/Limits festgehalten; dieses Dokument bleibt das übergeordnete **Zielbild**.
@ -20,60 +20,68 @@ Dieses Dokument fasst den **Soll-Zustand** für Mandantenfähigkeit (Verein = Ma
|--------|-----------------------------------|-------------------------| |--------|-----------------------------------|-------------------------|
| `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** | | `shinkan_anforderungsdokument_entwurf.md` §5.4 | Rollen: Superadmin, Vereinsadmin, Trainer, Co-Trainer, Redakteur | Deckt sich; „Superadmin“ entspricht fachlich **Systemadmin** |
| §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** | | §17.1 | Erweiterung: Systemadmin, Spartenadmin | Entspricht den gewünschten **Spartenverantwortlichen** |
| §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; **technische Durchsetzung** ist noch lückenhaft | | §5.5 / §17 | Sichtbarkeit: privat, Verein, Sparte, global, offiziell | DOMAIN_MODEL listet ähnliche Stufen; Bibliothek **`private` \| `club` \| `official`** technisch über Zugriffsschicht durchgesetzt; **Sparte/community** folgt |
| §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung | | §18.5 | MVP: Datenmodell mandantenfähig, Rechte zunächst einfach | Bestätigt schrittweise Verschärfung |
| `DOMAIN_MODEL.md` §5.5 | Freigabeebenen inkl. Sparte | Zielbild; DB/API nutzen derzeit überwiegend `private` \| `club` \| `official` | | `DOMAIN_MODEL.md` §5.5 | Freigabeebenen inkl. Sparte | Zielbild; DB/API nutzen derzeit überwiegend `private` \| `club` \| `official` |
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt | | `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | CURR-004008: Governance-Kern, spätere Policy | Datenfelder vorbereitet; **Policy/Erzwingung** folgt |
| `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel UI-Komplexität kontrolliert einführen | | `working/SHINKAN_PROJECT_SETUP.md` §6 | „Multi-Tenant-Administration“ ausgeschlossen (MVP-Liste) | Historisch; **technische Mandanten** sind dennoch Ziel UI-Komplexität kontrolliert einführen |
**Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs bereits skizziert**. Es fehlt die **stringente technische Schicht**: Vereinszugehörigkeit, aktiver Vereinskontext, effektive Berechtigungen pro Anfrage und konsequente Filterung bei `club`-sichtbaren Objekten. **Fazit:** Die fachlichen Rollen und Sichtbarkeitsebenen sind **in den funktionalen Docs skizziert**. Für die **Kernteile Bibliothek und Vereinskontext** ist eine **technische Zugriffsschicht** (`TenantContext`, `club_members`, einheitliche Sichtbarkeits-SQL/-Prüfungen) umgesetzt — Details und Restarbeit (**Sparte**, Konsolidierung der Hilfen, Planungs-/Admin-Flows) siehe §3 und `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`.
--- ---
## 3. Ist-Stand im Code (Gap-Analyse) ## 3. Ist-Stand im Code (Gap-Analyse)
> **Hinweis:** Dieser Abschnitt beschreibt den Ausgangspunkt vor Ausbauschritten (**Mitgliedschaften, gefilterte Vereinsliste, Teilen von Governance für Übungen/Rahmen/Planung** sind bereits angegangen). Verbindliche **offene Arbeit und Reihenfolge** sind im Dokument [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md) festgehalten. > **Hinweis:** Die Unterabschnitte **3.13.6** enthalten weiterhin **historische Problemstellungen** (Ausgangsbild). Ergänzend beschreibt **3.0** den **aktualisierten Umsetzungsstand** nach Mitgliedschafts-, Tenant- und Bibliotheksarbeit. Verbindliche **offene Arbeit und Reihenfolge:** [`ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`](./ACCESS_LAYER_AND_GOVERNANCE_PLAN.md).
### 3.0 Aktualisierung des Umsetzungsstands (kurz)
- **Mitgliedschaft:** Tabellen `club_members` und `club_member_roles`; aktiver Verein über Profilfeld und Header `X-Active-Club-Id`; Auflösung in **`TenantContext`** (`tenant_context.py`).
- **Bibliothek** (Übungen, Trainingsplan-Vorlagen, Rahmenprogramme u.a.): gemeinsame Leselogik **`library_content_visibility_sql`** / **`library_content_visible_to_profile`** — Vereinsinhalte **`club`** nur bei passendem **`club_id`** und **aktiver Mitgliedschaft** im Objekt-Verein (normale Nutzer ohne gültigen Vereinskontext: kein „beliebiges club“).
- **`GET /api/clubs`:** Nicht-Admins sehen nur Vereine mit Mitgliedschaft; **`POST /api/clubs`:** nur **Plattform-Admin**, mit Pflicht **`primary_admin_profile_id`**.
- **Organisation** (Sparten/Gruppen): Schreibzugriff über **`can_manage_club_org`** / **`can_plan_in_club`** auf Basis von **`club_member_roles`** (nicht mehr nur globales `admin`).
- **Profil-API:** eingeschränktes **`GET /profiles/{id}`**, **`DELETE`**, **`POST /profiles`** (Plattform-Admin / Selbstzugriff) — Details `backend/routers/profiles.py`.
- **Tests:** pytest inkl. optionaler Mandanten-Integration (`ACCESS_LAYER_INTEGRATION`); CI-Anbindung siehe `.gitea/workflows/test.yml` (Ausführung im Backend-Container wie Schwesterprojekt).
### 3.1 Identität und Rollen ### 3.1 Identität und Rollen
- `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …). - `profiles.role` ist eine **globale** Kennzeichnung (`admin`, `superadmin`, `trainer`, `user`, …).
- **Keine** Tabelle für Vereinsmitgliedschaft mit **Mehrfachrollen pro Verein**. - *(Historisch)* Fehlende Abbildung von Vereinsrollen **ohne** eigene Tabellen.
- Sessions liefern nur `profile_id` + globale `role` (`auth.py` → `get_session`). - **Ist:** Zusätzlich **`club_member_roles`** pro Verein (z.B. `club_admin`, `trainer`, …); Sessions liefern weiter **`profile_id`** + globale **`role`** (`auth.py` → `get_session`), Vereinsrechte werden aus Mitgliedschaft abgeleitet.
**Konsequenz:** Mehrere Vereine mit unterschiedlichen Rollen pro User sind **nicht modelliert**; ein „Vereinsadmin“ kann nicht sauber von einem reinen Trainer unterschieden werden, sobald beides nur über `profiles.role` laufen soll. **Konsequenz:** Globale Rolle und Vereinsrollen **koexistieren**; Produkt und Code sollten langfristig klar trennen, was nur global vs. nur über Mitgliedschaft gilt (vgl. Zielarchitektur §4).
### 3.2 Organisation & APIs ### 3.2 Organisation & APIs
- `clubs`, `divisions`, `training_groups` existieren (`002_organization.sql`). - *(Historisch)* Zu offene Vereinsliste und Club-Anlage für jeden Trainer/User.
- `GET /api/clubs` listiert **alle** Vereine für jeden eingeloggten Nutzer. - **Ist:** siehe **3.0** — gefilterte Liste, eingeschränktes Anlegen, kontextbezogene Organisationsrechte.
- `POST /api/clubs` erlaubt Anlage für `trainer` und `user` **nicht** nur Systemadmin.
- Sparten/Gruppen: Schreibzugriff über globale `admin`/`superadmin`, nicht über **Vereinsadmin** im Kontext „sein Verein“.
**Konsequenz:** Weder **Datenisolation** noch **Produktdifferenzierung** „nur Systemadmin legt Verein an“ sind umgesetzt. **Konsequenz:** Offene Punkte verlagern sich in **feine Produktregeln** und **Sparten-/Community-Stufen** (ACCESS_LAYER Stufe D bzw. spätere Epics).
### 3.3 Trainingsplanung ### 3.3 Trainingsplanung
- Zugriff auf Einheiten gruppenbasiert: Trainer/Co-Trainer der `training_groups`, plus `lead_trainer_profile_id` (Migration/Pfad `training_planning`). - Zugriff auf Einheiten weiterhin stark **gruppenbezogen** (`training_groups`, optional **`lead_trainer_profile_id`** auf Einheiten).
- `_assert_club_visible_for_trainer` bindet Vereinssicht für Teile der Planung an „aktive Gruppe als Trainer/Co im Verein“ **kein** generelles Mitgliedschaftsmodell. - Mitgliedschaft/`TenantContext` unterstützen andere Endpoints; **`GET /training-units`** hat **keinen** impliziten Filter nur auf **`effective_club_id`** (Multi-Verein-Kalender; bei Bedarf Query **`club_id`**).
**Konsequenz:** Planung ist **gruppenzentriert**, nicht **mitgliedschaftszentriert**; Vereinsweite Aufgaben des Vereinsadmins fehlen als konsistentes Recht. **Konsequenz:** Vereinsweite oder „Administrations“-Planungsaufgaben können weiter ausgebaut werden (eigenes Produkt-Thema; nicht identisch mit Bibliotheks-Governance).
### 3.4 Governance / Sichtbarkeit (kritisch) ### 3.4 Governance / Sichtbarkeit (Bibliothek)
- Übungen (`list_exercises`): Bedingung sinngemäß „official OR club OR created_by = ich“ **`club` gilt für alle Mandanten**, ohne Prüfung `exercise.club_id` ∈ Vereine des Nutzers. - *(Historisch)* Risiko: **`club`**-Objekte ohne Bindung an **`club_id`** / Mitgliedschaft → mögliche Cross-Tenant-Sicht.
- Detailzugriff `private`: nur Owner **ok**. - **Ist:** Listen und Detail für die genannten Bibliotheksmodule nutzen die **einheitliche** Logik in **`club_tenancy`** / **`tenant_context`** (siehe **3.0**).
- Rahmenprogramme (`training_framework_programs`): Lesen fremder Rahmen über `visibility=club` ist in `_framework_access` **nicht** gelöst (faktisch stark creator-basiert für Nicht-Admins).
**Konsequenz:** **Cross-Tenant-Leaks** bei als `club` markierten Bibliotheksobjekten sind möglich bzw. Leselogik ist inkonsistent zwischen Modulen. **Konsequenz:** Die historische „Leak“-Diagnose für **Übungen und Rahmenprogramme** in dieser Form ist **überholt**. Verbleibend: **Konsolidierung auf wenige Hilfsfunktionen** (ACCESS_LAYER Stufe C), **Sparte** als eigene Stufe, ggf. **community**.
### 3.5 Frontend ### 3.5 Frontend
- **Stand 2026-05:** `GET /api/profiles/me` liefert `clubs[]`, `active_club_id`; Frontend setzt `X-Active-Club-Id`. Details und Pflicht zur serverseitigen **TenantContext**-Validierung siehe `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`. - `GET /api/profiles/me` liefert u.a. **`clubs[]`**, **`active_club_id`**; Client setzt **`X-Active-Club-Id`**. Geschützte Backend-Routen nutzen **`Depends(get_tenant_context)`** wo im Audit festgehalten.
### 3.6 Membership (kommerziell/limits) ### 3.6 Membership (kommerziell/limits)
- Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**. - Mitai-artige Tabellen (`tiers`, `subscriptions`, `tier_limits`, …) sind **nutzerbezogen**, nicht **vereinsbezogen**.
- Kein konzipiertes **`club_subscription` / `club_plan`** im Schema. - Kein konzipiertes **`club_subscription` / `club_plan`** im Schema — bewusst nach ACCESS_LAYER-Plan zurückgestellt.
**Letzte Überarbeitung dieses Abschnitts (3.x):** 2026-05-06.
--- ---

View File

@ -5,6 +5,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen | | Router / Bereich | Beispiel-Endpunkt | tenant-relevant | `Depends(get_tenant_context)` / Kontext | Governance geprüft (Liste+Detail) | Notizen |
|------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------| |------------------|-------------------|-----------------|----------------------------------------|-------------------------------------|---------|
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht | | profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft | | profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth | | clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | | | club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
@ -24,7 +25,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen. **Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
Letzte Änderung: 2026-05-05 — Cursor-Regel + Architektur-/Coding-Pflicht + Script `backend/scripts/check_access_layer_hints.py`; Katalog-Router im Audit als global dokumentiert. Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt.
--- ---

View File

@ -52,6 +52,33 @@ def has_club_role(cur, profile_id: int, club_id: int, *role_codes: str) -> bool:
return cur.fetchone() is not None return cur.fetchone() is not None
def club_admin_shares_club_with_creator(
cur, club_admin_profile_id: int, creator_profile_id: int
) -> bool:
"""
True, wenn club_admin_profile_id in mindestens einem Verein die Rolle club_admin hat und
creator_profile_id dort ebenfalls aktives Mitglied ist (z. B. Löschen fremder privater Übungen).
"""
if club_admin_profile_id == creator_profile_id:
return False
cur.execute(
"""
SELECT 1
FROM club_members cm_admin
INNER JOIN club_member_roles r
ON r.club_member_id = cm_admin.id AND r.role_code = 'club_admin'
INNER JOIN club_members cm_creator
ON cm_creator.club_id = cm_admin.club_id
AND cm_creator.profile_id = %s
AND cm_creator.status = 'active'
WHERE cm_admin.profile_id = %s AND cm_admin.status = 'active'
LIMIT 1
""",
(creator_profile_id, club_admin_profile_id),
)
return cur.fetchone() is not None
def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool: def can_manage_club_org(cur, profile_id: int, club_id: int, global_role: Optional[str]) -> bool:
"""Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin.""" """Sparten/Gruppen/Struktur: Vereinsadmin oder Plattform-Admin."""
if is_platform_admin(global_role): if is_platform_admin(global_role):

View File

@ -0,0 +1,9 @@
-- Session-spezifische Co-Trainer: NULL = wie training_groups.co_trainer_ids; [] = explizit keine Co-Trainer
ALTER TABLE training_units
ADD COLUMN IF NOT EXISTS assistant_trainer_profile_ids JSONB;
COMMENT ON COLUMN training_units.assistant_trainer_profile_ids IS
'Co-Trainer nur für diese Einheit; NULL vererbt training_groups.co_trainer_ids; leeres Array = keine Co-Trainer';
CREATE INDEX IF NOT EXISTS idx_training_units_assistant_trainers
ON training_units USING GIN (assistant_trainer_profile_ids);

View File

@ -0,0 +1,3 @@
-- Gespeicherte Standard-Filter für die Übungsliste (pro Nutzer)
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS exercise_list_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

@ -4,7 +4,7 @@ Pydantic Models for Shinkan Jinkendo API
Request/Response schemas for all endpoints Request/Response schemas for all endpoints
""" """
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List from typing import Optional, List, Dict, Any
from datetime import date, time, datetime from datetime import date, time, datetime
# ============================================================================ # ============================================================================
@ -43,6 +43,10 @@ class ProfileUpdate(BaseModel):
description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)", description="Portal-Rolle: user, trainer, admin, superadmin (nur Plattform-Admin)",
) )
tier: Optional[str] = Field(default=None, max_length=50) tier: Optional[str] = Field(default=None, max_length=50)
exercise_list_prefs: Optional[Dict[str, Any]] = Field(
default=None,
description="JSON: gespeicherte Standardfilter für die Übungsliste",
)
class ProfileResponse(BaseModel): class ProfileResponse(BaseModel):
id: int id: int

View File

@ -17,6 +17,8 @@ from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
club_admin_shares_club_with_creator,
has_club_role,
is_platform_admin, is_platform_admin,
library_content_visible_to_profile, library_content_visible_to_profile,
) )
@ -26,6 +28,24 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"]) router = APIRouter(prefix="/api", tags=["exercises"])
def _coerce_json_str_list(val: Any) -> List[str]:
"""JSON-Aggregat oder JSON-String aus PG in eine saubere str-Liste für die Listen-API."""
if val is None:
return []
if isinstance(val, list):
return [str(x) for x in val if x is not None and str(x).strip()]
if isinstance(val, str):
try:
parsed = json.loads(val)
if isinstance(parsed, list):
return [str(x) for x in parsed if x is not None and str(x).strip()]
except Exception:
return []
return []
return []
# Kanonische Fähigkeitsstufen 15 (Übung ↔ Skill-Zeile), siehe Migration 029 # Kanonische Fähigkeitsstufen 15 (Übung ↔ Skill-Zeile), siehe Migration 029
_CANONICAL_SKILL_LEVELS = frozenset( _CANONICAL_SKILL_LEVELS = frozenset(
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"} {"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
@ -214,21 +234,38 @@ class ExerciseVariantsReorder(BaseModel):
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"}) _VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
_LIST_FILTER_VISIBILITY = frozenset({"private", "club", "official"})
_LIST_FILTER_STATUS = frozenset({"draft", "in_review", "approved", "archived"})
_MAX_BULK_METADATA_IDS = 500 _MAX_BULK_METADATA_IDS = 500
_MAX_BULK_RELATION_IDS_PER_KIND = 80
class ExerciseBulkMetadataPatch(BaseModel): class ExerciseBulkMetadataPatch(BaseModel):
"""Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein).""" """Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge)."""
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS) exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
status: Optional[str] = None status: Optional[str] = None
club_id: Optional[int] = Field(default=None, ge=1) club_id: Optional[int] = Field(default=None, ge=1)
focus_area_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
style_direction_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
training_type_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
target_group_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
@model_validator(mode="after") @model_validator(mode="after")
def at_least_visibility_or_status(self): def at_least_one_patch_field(self):
if self.visibility is None and self.status is None: if (
raise ValueError("Mindestens eines der Felder visibility oder status angeben") self.visibility is None
and self.status is None
and self.focus_area_ids is None
and self.style_direction_ids is None
and self.training_type_ids is None
and self.target_group_ids is None
):
raise ValueError(
"Mindestens eines der Felder visibility, status, focus_area_ids, style_direction_ids, "
"training_type_ids oder target_group_ids angeben"
)
return self return self
@ -456,7 +493,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
return exercise return exercise
def assign_exercise_relations(cur, conn, exercise_id: int, data: dict): def assign_exercise_relations(
cur,
conn,
exercise_id: int,
data: dict,
*,
do_commit: bool = True,
):
""" """
Weist M:N Relations für eine Übung zu. Weist M:N Relations für eine Übung zu.
Löscht alte Zuordnungen und legt neue an (REPLACE-Logik). Löscht alte Zuordnungen und legt neue an (REPLACE-Logik).
@ -532,13 +576,59 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
) )
) )
conn.commit() if do_commit:
conn.commit()
# ============================================================================ # ============================================================================
# Endpoints # Endpoints
# ============================================================================ # ============================================================================
def _normalize_bulk_id_list(raw: Optional[list]) -> list[int]:
"""Positive IDs, Reihenfolge beibehalten, Duplikate entfernen."""
if not raw:
return []
seen: set[int] = set()
out: list[int] = []
for x in raw:
try:
xi = int(x)
except (TypeError, ValueError):
continue
if xi < 1 or xi in seen:
continue
seen.add(xi)
out.append(xi)
return out
def _assert_catalog_ids_exist(cur, kind: str, ids: list[int]) -> None:
if not ids:
return
table_by_kind = {
"focus_areas": "focus_areas",
"style_directions": "style_directions",
"training_types": "training_types",
"target_groups": "target_groups",
}
table = table_by_kind.get(kind)
if not table:
raise HTTPException(status_code=500, detail="Interner Fehler: unbekannter Katalog")
ph = ",".join(["%s"] * len(ids))
cur.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", tuple(ids))
found = {
int(r["id"]) if isinstance(r, dict) else int(r[0])
for r in cur.fetchall()
}
missing = [i for i in ids if i not in found]
if missing:
raise HTTPException(
status_code=400,
detail=f"Unbekannte {kind}-IDs (Beispiele): {missing[:12]}",
)
def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]: def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
"""Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate).""" """Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate)."""
seen: set[int] = set() seen: set[int] = set()
@ -555,6 +645,21 @@ def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
return out return out
def _dedupe_positive_ids(ids: list[int]) -> list[int]:
seen: set[int] = set()
out: list[int] = []
for raw in ids or []:
try:
xi = int(raw)
except (TypeError, ValueError):
continue
if xi < 1 or xi in seen:
continue
seen.add(xi)
out.append(xi)
return out
def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]: def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
seen = set() seen = set()
out = [] out = []
@ -571,13 +676,107 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
return out return out
def _normalize_choice_list(raw: list[str], allowed: frozenset, label: str) -> list[str]:
out = []
seen = set()
for x in raw or []:
s = str(x).strip().lower()
if not s or s in seen:
continue
if s not in allowed:
raise HTTPException(status_code=400, detail=f"Ungültiger Wert in {label}")
seen.add(s)
out.append(s)
return out
def _exercise_delete_usage_counts(cur, exercise_id: int) -> dict:
cur.execute(
"""
SELECT
(SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items,
(SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items,
(SELECT COUNT(*)::int FROM exercise_progression_edges
WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges
""",
(exercise_id, exercise_id, exercise_id, exercise_id),
)
row = cur.fetchone()
return dict(row) if row else {"block_items": 0, "section_items": 0, "prog_edges": 0}
def _exercise_delete_usage_message(counts: dict) -> str:
bi = int(counts.get("block_items") or 0)
si = int(counts.get("section_items") or 0)
pe = int(counts.get("prog_edges") or 0)
parts = []
if bi:
parts.append(f"{bi}× in Übungsblöcken")
if si:
parts.append(f"{si}× in Trainingsplänen oder Rahmenabläufen")
if pe:
parts.append(f"{pe}× in Progressionsgraphen (Kanten)")
if not parts:
return ""
return (
"Die Übung wird noch verwendet und kann nicht gelöscht werden. Bitte auf „archiviert“ setzen. "
"Verwendung: " + ", ".join(parts) + "."
)
def _assert_can_delete_exercise(cur, tenant: TenantContext, row: dict) -> None:
pid = tenant.profile_id
role = tenant.global_role
if is_platform_admin(role):
return
vis = str(row.get("visibility") or "private").strip().lower()
cid = row.get("club_id")
creator = row.get("created_by")
try:
creator_int = int(creator) if creator is not None else None
except (TypeError, ValueError):
creator_int = None
if vis == "official":
raise HTTPException(
status_code=403,
detail="Globale Übungen dürfen nur von Plattform-Admins gelöscht werden.",
)
if vis == "club":
try:
ex_club = int(cid) if cid is not None else None
except (TypeError, ValueError):
ex_club = None
if ex_club is None:
raise HTTPException(status_code=400, detail="Vereins-Übung ohne gültige Vereinszuordnung")
if not has_club_role(cur, pid, ex_club, "club_admin"):
raise HTTPException(
status_code=403,
detail="Nur Vereins-Admins dürfen Vereins-Übungen löschen.",
)
return
if creator_int is not None and creator_int == pid:
return
if creator_int is not None and club_admin_shares_club_with_creator(cur, pid, creator_int):
return
raise HTTPException(
status_code=403,
detail="Keine Berechtigung zum Löschen dieser Übung.",
)
@router.patch("/exercises/bulk-metadata") @router.patch("/exercises/bulk-metadata")
def bulk_patch_exercises_metadata( def bulk_patch_exercises_metadata(
body: ExerciseBulkMetadataPatch, body: ExerciseBulkMetadataPatch,
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal. Ändert Sichtbarkeit, Status und/oder Katalog-Zuordnungen für viele Übungen auf einmal (REPLACE je Kategorie).
Zuordnung: Sind z. B. focus_area_ids im Body gesetzt, werden die Fokusbereiche bei den bearbeiteten
Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin). Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin). Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
""" """
@ -603,6 +802,33 @@ def bulk_patch_exercises_metadata(
patch_visibility = body.visibility is not None patch_visibility = body.visibility is not None
patch_status = status_val is not None patch_status = status_val is not None
patch_focus_areas = body.focus_area_ids is not None
fa_ids = _normalize_bulk_id_list(body.focus_area_ids or []) if patch_focus_areas else []
patch_style_dirs = body.style_direction_ids is not None
sd_ids = _normalize_bulk_id_list(body.style_direction_ids or []) if patch_style_dirs else []
patch_training_types = body.training_type_ids is not None
tt_ids = _normalize_bulk_id_list(body.training_type_ids or []) if patch_training_types else []
patch_target_groups = body.target_group_ids is not None
tg_ids = _normalize_bulk_id_list(body.target_group_ids or []) if patch_target_groups else []
relation_data: Dict[str, Any] = {}
if patch_focus_areas:
relation_data["focus_areas_multi"] = [
{"focus_area_id": i, "is_primary": idx == 0} for idx, i in enumerate(fa_ids)
]
if patch_style_dirs:
relation_data["training_styles_multi"] = [
{"training_style_id": i, "is_primary": idx == 0} for idx, i in enumerate(sd_ids)
]
if patch_training_types:
relation_data["training_types_multi"] = [
{"training_type_id": i, "is_primary": idx == 0} for idx, i in enumerate(tt_ids)
]
if patch_target_groups:
relation_data["target_groups_multi"] = [
{"target_group_id": i, "is_primary": idx == 0} for idx, i in enumerate(tg_ids)
]
updated: List[int] = [] updated: List[int] = []
failed: List[Dict[str, Any]] = [] failed: List[Dict[str, Any]] = []
@ -612,6 +838,16 @@ def bulk_patch_exercises_metadata(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if patch_focus_areas:
_assert_catalog_ids_exist(cur, "focus_areas", fa_ids)
if patch_style_dirs:
_assert_catalog_ids_exist(cur, "style_directions", sd_ids)
if patch_training_types:
_assert_catalog_ids_exist(cur, "training_types", tt_ids)
if patch_target_groups:
_assert_catalog_ids_exist(cur, "target_groups", tg_ids)
for ex_id in unique_ids: for ex_id in unique_ids:
cur.execute( cur.execute(
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s", "SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
@ -681,6 +917,8 @@ def bulk_patch_exercises_metadata(
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s", f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
tuple(vals), tuple(vals),
) )
if relation_data:
assign_exercise_relations(cur, conn, ex_id, relation_data, do_commit=False)
updated.append(ex_id) updated.append(ex_id)
conn.commit() conn.commit()
@ -721,6 +959,56 @@ def list_exercises(
default=False, default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
), ),
visibility_exclude_any: list[str] = Query(
default=[], description="Keine dieser Sichtbarkeiten (Negativliste)"
),
status_exclude_any: list[str] = Query(
default=[], description="Keiner dieser Statuswerte (Negativliste)"
),
exclude_without_focus: bool = Query(
default=False,
description="Wenn true: nur Übungen mit mindestens einem Fokusbereich",
),
focus_only_without_focus_areas: bool = Query(
default=False,
description="Nur Übungen ohne einen einzigen Fokusbereich (M:N exercise_focus_areas leer)",
),
focus_area_must_include_ids: list[int] = Query(
default=[],
description="Alle genannten Fokusbereiche müssen gesetzt sein (UND / „+“)",
),
focus_area_must_exclude_ids: list[int] = Query(
default=[],
description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
),
style_direction_must_include_ids: list[int] = Query(
default=[],
description="Alle genannten Stilrichtungen müssen der Übung zugeordnet sein (UND)",
),
style_direction_must_exclude_ids: list[int] = Query(
default=[],
description="Keine dieser Stilrichtungen darf zugeordnet sein",
),
training_type_must_include_ids: list[int] = Query(
default=[],
description="Alle genannten Trainingsstile müssen zugeordnet sein (UND)",
),
training_type_must_exclude_ids: list[int] = Query(
default=[],
description="Keiner dieser Trainingsstile darf zugeordnet sein",
),
target_group_must_include_ids: list[int] = Query(
default=[],
description="Alle genannten Zielgruppen müssen zugeordnet sein (UND)",
),
target_group_must_exclude_ids: list[int] = Query(
default=[],
description="Keine dieser Zielgruppen darf zugeordnet sein",
),
include_archived: bool = Query(
default=False,
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
),
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
@ -760,13 +1048,83 @@ def list_exercises(
where.append(f"e.status IN ({ph})") where.append(f"e.status IN ({ph})")
params.extend(st_list) params.extend(st_list)
fa_ids = _merge_ids(focus_area_ids, focus_area) includes_archived = any(str(x).strip().lower() == "archived" for x in st_list)
if fa_ids: if not include_archived and not includes_archived:
ph = ",".join(["%s"] * len(fa_ids)) where.append("COALESCE(e.status, '') <> %s")
params.append("archived")
vis_excl = _normalize_choice_list(
list(visibility_exclude_any),
_LIST_FILTER_VISIBILITY,
"visibility_exclude_any",
)
if vis_excl:
ph = ",".join(["%s"] * len(vis_excl))
where.append(f"(e.visibility IS NULL OR LOWER(TRIM(e.visibility::text)) NOT IN ({ph}))")
params.extend(vis_excl)
st_excl = _normalize_choice_list(
list(status_exclude_any),
_LIST_FILTER_STATUS,
"status_exclude_any",
)
if st_excl:
ph = ",".join(["%s"] * len(st_excl))
where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
params.extend(st_excl)
focus_only = focus_only_without_focus_areas
must_inc = _dedupe_positive_ids(list(focus_area_must_include_ids))
must_exc = _dedupe_positive_ids(list(focus_area_must_exclude_ids))
fa_or = _merge_ids(focus_area_ids, focus_area)
if focus_only:
if exclude_without_focus:
raise HTTPException(
status_code=400,
detail="focus_only_without_focus_areas schließt exclude_without_focus aus.",
)
if fa_or:
raise HTTPException(
status_code=400,
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_ids (ODER-Liste) verwendet werden.",
)
if must_inc:
raise HTTPException(
status_code=400,
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_include_ids verwendet werden.",
)
if must_exc:
raise HTTPException(
status_code=400,
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_exclude_ids verwendet werden.",
)
where.append( where.append(
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))" "NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
) )
params.extend(fa_ids) else:
if exclude_without_focus:
where.append(
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
)
if fa_or:
ph = ",".join(["%s"] * len(fa_or))
where.append(
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
)
params.extend(fa_or)
for fid in must_inc:
where.append(
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
)
params.append(fid)
if must_exc:
ph = ",".join(["%s"] * len(must_exc))
where.append(
f"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa "
f"WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
)
params.extend(must_exc)
sk_ids = _merge_ids(skill_ids, skill_id) sk_ids = _merge_ids(skill_ids, skill_id)
if sk_ids: if sk_ids:
@ -776,32 +1134,77 @@ def list_exercises(
) )
params.extend(sk_ids) params.extend(sk_ids)
sd_ids = _merge_ids(style_direction_ids, style_direction_id) sd_or = _merge_ids(style_direction_ids, style_direction_id)
if sd_ids: sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids))
ph = ",".join(["%s"] * len(sd_ids)) sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids))
if sd_or:
ph = ",".join(["%s"] * len(sd_or))
where.append( where.append(
"EXISTS (SELECT 1 FROM exercise_style_directions esd " "EXISTS (SELECT 1 FROM exercise_style_directions esd "
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))" f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
) )
params.extend(sd_ids) params.extend(sd_or)
for sid in sd_inc:
where.append(
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
"WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
)
params.append(sid)
if sd_exc:
ph = ",".join(["%s"] * len(sd_exc))
where.append(
"NOT EXISTS (SELECT 1 FROM exercise_style_directions esd "
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
)
params.extend(sd_exc)
tt_ids = _merge_ids(training_type_ids, training_type_id) tt_or = _merge_ids(training_type_ids, training_type_id)
if tt_ids: tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids))
ph = ",".join(["%s"] * len(tt_ids)) tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids))
if tt_or:
ph = ",".join(["%s"] * len(tt_or))
where.append( where.append(
"EXISTS (SELECT 1 FROM exercise_training_types ett " "EXISTS (SELECT 1 FROM exercise_training_types ett "
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))" f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
) )
params.extend(tt_ids) params.extend(tt_or)
for tid in tt_inc:
where.append(
"EXISTS (SELECT 1 FROM exercise_training_types ett "
"WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
)
params.append(tid)
if tt_exc:
ph = ",".join(["%s"] * len(tt_exc))
where.append(
"NOT EXISTS (SELECT 1 FROM exercise_training_types ett "
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
)
params.extend(tt_exc)
tg_ids = _merge_ids(target_group_ids, target_group_id) tg_or = _merge_ids(target_group_ids, target_group_id)
if tg_ids: tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids))
ph = ",".join(["%s"] * len(tg_ids)) tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids))
if tg_or:
ph = ",".join(["%s"] * len(tg_or))
where.append( where.append(
"EXISTS (SELECT 1 FROM exercise_target_groups etg " "EXISTS (SELECT 1 FROM exercise_target_groups etg "
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))" f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
) )
params.extend(tg_ids) params.extend(tg_or)
for gid in tg_inc:
where.append(
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
"WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
)
params.append(gid)
if tg_exc:
ph = ",".join(["%s"] * len(tg_exc))
where.append(
"NOT EXISTS (SELECT 1 FROM exercise_target_groups etg "
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
)
params.extend(tg_exc)
if skill_min_level is not None or skill_max_level is not None: if skill_min_level is not None or skill_max_level is not None:
lo = skill_min_level if skill_min_level is not None else 1 lo = skill_min_level if skill_min_level is not None else 1
@ -860,7 +1263,34 @@ def list_exercises(
WHERE efa.exercise_id = e.id WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1 LIMIT 1
) AS primary_focus_name ) AS primary_focus_name,
(
SELECT COALESCE(
json_agg(fa.name ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC),
'[]'::json
)
FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
) AS focus_area_names,
(
SELECT COALESCE(
json_agg(sd.name ORDER BY esd.is_primary DESC NULLS LAST, sd.name ASC),
'[]'::json
)
FROM exercise_style_directions esd
JOIN style_directions sd ON sd.id = esd.style_direction_id
WHERE esd.exercise_id = e.id
) AS style_direction_names,
(
SELECT COALESCE(
json_agg(tt.name ORDER BY ett.is_primary DESC NULLS LAST, tt.sort_order NULLS LAST, tt.name ASC),
'[]'::json
)
FROM exercise_training_types ett
JOIN training_types tt ON tt.id = ett.training_type_id
WHERE ett.exercise_id = e.id
) AS training_type_names
{variants_sql} {variants_sql}
FROM exercises e FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id LEFT JOIN profiles p ON e.created_by = p.id
@ -879,6 +1309,9 @@ def list_exercises(
d = r2d(r) d = r2d(r)
pfn = d.get("primary_focus_name") pfn = d.get("primary_focus_name")
d["focus_area"] = pfn d["focus_area"] = pfn
d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names"))
d["style_direction_names"] = _coerce_json_str_list(d.get("style_direction_names"))
d["training_type_names"] = _coerce_json_str_list(d.get("training_type_names"))
if include_variants: if include_variants:
v = d.get("variants") v = d.get("variants")
if isinstance(v, str): if isinstance(v, str):
@ -1082,38 +1515,32 @@ def delete_exercise(
): ):
""" """
Löscht eine Übung. Löscht eine Übung.
Nur Owner oder Admin darf löschen.
"""
profile_id = tenant.profile_id
role = tenant.global_role
Berechtigung: Plattform-Admin (alle); Vereins-Admin Vereins-Übungen seines Vereins;
Ersteller nur eigene private Übungen; Vereins-Admin zusätzlich private Übungen von Mitgliedern,
mit denen er einen Verein teilt.
Bei Verwendung in Blöcken, Trainingsplänen oder Progressionsgraphen: 409 bitte archivieren.
"""
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
# Existiert die Übung? cur.execute(
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,)) "SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
(exercise_id,),
)
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden") raise HTTPException(status_code=404, detail="Übung nicht gefunden")
ex = r2d(row)
# Permission Check _assert_can_delete_exercise(cur, tenant, ex)
if _row_created_by(row) != profile_id and not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
# Prüfen ob Übung in Block-Items verwendet wird counts = _exercise_delete_usage_counts(cur, exercise_id)
cur.execute( usage_msg = _exercise_delete_usage_message(counts)
"SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s", if usage_msg:
(exercise_id,) raise HTTPException(status_code=409, detail=usage_msg)
)
crow = cur.fetchone()
count = crow["cnt"] if isinstance(crow, dict) else crow[0]
if count > 0:
raise HTTPException(
status_code=409,
detail=f"Übung wird in {count} Block-Item(s) verwendet und kann nicht gelöscht werden"
)
# DELETE (Cascade löscht M:N Zuordnungen automatisch)
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,)) cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
conn.commit() conn.commit()

View File

@ -9,6 +9,8 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends from fastapi import APIRouter, HTTPException, Header, Depends
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, hash_pin from auth import require_auth, hash_pin
from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
@ -258,6 +260,15 @@ def _run_profile_update(pid: str, p: ProfileUpdate, tenant: TenantContext) -> di
assert_club_member(cur, int(pid), cid) assert_club_member(cur, int(pid), cid)
data["active_club_id"] = cid data["active_club_id"] = cid
if "exercise_list_prefs" in patch:
ep = patch.pop("exercise_list_prefs")
if ep is None:
data["exercise_list_prefs"] = Json({})
elif isinstance(ep, dict):
data["exercise_list_prefs"] = Json(ep)
else:
raise HTTPException(400, "exercise_list_prefs muss ein JSON-Objekt sein")
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"} nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
for k, v in patch.items(): for k, v in patch.items():
if k == "email": if k == "email":

View File

@ -12,6 +12,7 @@ from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import ( from club_tenancy import (
assert_valid_governance_visibility, assert_valid_governance_visibility,
can_manage_club_org,
is_platform_admin, is_platform_admin,
library_content_visible_to_profile, library_content_visible_to_profile,
) )
@ -53,7 +54,7 @@ def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id:
def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None: def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None:
cur.execute( cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s", "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,), (group_id,),
) )
group = cur.fetchone() group = cur.fetchone()
@ -64,9 +65,83 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen") raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
if role not in ["admin", "superadmin"]: if role not in ["admin", "superadmin"]:
if group["trainer_id"] != profile_id and profile_id not in co_trainers: if group["trainer_id"] != profile_id and profile_id not in co_trainers:
raise HTTPException( if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role):
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen" raise HTTPException(
) status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
)
def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool:
cur.execute(
"""
SELECT 1 FROM club_members
WHERE club_id = %s AND profile_id = %s AND status = 'active'
LIMIT 1
""",
(club_id, profile_id),
)
return cur.fetchone() is not None
def _caller_may_assign_session_trainers(
cur,
group_row: Dict[str, Any],
profile_id: int,
role: str,
unit_created_by: Optional[int],
) -> bool:
if is_platform_admin(role):
return True
cid = group_row.get("club_id")
if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role):
return True
if unit_created_by is not None and unit_created_by == profile_id:
return True
if group_row.get("trainer_id") == profile_id:
return True
co = group_row.get("co_trainer_ids") or []
return profile_id in co
def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]:
"""Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard."""
unit_asst = unit_row.get("assistant_trainer_profile_ids")
if unit_asst is not None:
src = unit_asst
else:
src = unit_row.get("co_trainer_ids") or []
seen: set = set()
out: List[int] = []
for x in src:
try:
i = int(x)
except (TypeError, ValueError):
continue
if i not in seen:
seen.add(i)
out.append(i)
return sorted(out)
def effective_co_trainer_profile_ids_for_merge(
unit_assistant: Any, group_co: Any
) -> List[int]:
"""Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row."""
if unit_assistant is not None:
src = unit_assistant
else:
src = group_co or []
seen: set = set()
out: List[int] = []
for x in src:
try:
i = int(x)
except (TypeError, ValueError):
continue
if i not in seen:
seen.add(i)
out.append(i)
return sorted(out)
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]: def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
@ -74,7 +149,8 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
""" """
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id, SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
tu.lead_trainer_profile_id, tu.lead_trainer_profile_id,
tg.trainer_id, tg.co_trainer_ids, tu.assistant_trainer_profile_ids,
tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id,
fwp.created_by AS framework_created_by fwp.created_by AS framework_created_by
FROM training_units tu FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN training_groups tg ON tu.group_id = tg.id
@ -103,26 +179,53 @@ def _assert_training_unit_permission(
return return
raise HTTPException(status_code=403, detail="Keine Berechtigung") raise HTTPException(status_code=403, detail="Keine Berechtigung")
co_trainers = unit_row["co_trainer_ids"] or [] co_eff = _effective_co_trainer_ids_for_row(unit_row)
if role not in ["admin", "superadmin"]: if role in ["admin", "superadmin"]:
if ( return
unit_row["created_by"] != profile_id gcid = unit_row.get("group_club_id")
and unit_row["trainer_id"] != profile_id if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role):
and profile_id not in co_trainers return
and unit_row.get("lead_trainer_profile_id") != profile_id if (
): unit_row["created_by"] != profile_id
raise HTTPException(status_code=403, detail="Keine Berechtigung") and unit_row["trainer_id"] != profile_id
and profile_id not in co_eff
and unit_row.get("lead_trainer_profile_id") != profile_id
def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None: ):
if role not in ["admin", "superadmin"] and created_by != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung") raise HTTPException(status_code=403, detail="Keine Berechtigung")
def _assert_delete_training_unit(
cur,
role: str,
created_by: int,
profile_id: int,
group_club_id: Optional[int],
) -> None:
if role in ["admin", "superadmin"]:
return
if created_by == profile_id:
return
if group_club_id is not None and can_manage_club_org(cur, profile_id, int(group_club_id), role):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung")
def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None: def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
"""Nicht-Admin: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer.""" """Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
if role in ("admin", "superadmin"): if role in ("admin", "superadmin"):
return return
if can_manage_club_org(cur, profile_id, club_id, role):
return
cur.execute(
"""
SELECT 1 FROM club_members
WHERE club_id = %s AND profile_id = %s AND status = 'active'
LIMIT 1
""",
(club_id, profile_id),
)
if cur.fetchone():
return
cur.execute( cur.execute(
""" """
SELECT 1 FROM training_groups g SELECT 1 FROM training_groups g
@ -145,8 +248,9 @@ def _normalize_lead_trainer_profile_id(
raw_lead: Any, raw_lead: Any,
profile_id: int, profile_id: int,
role: str, role: str,
unit_created_by: Optional[int],
) -> Optional[int]: ) -> Optional[int]:
"""NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext.""" """NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i.d.R. mit Vereinsbezug."""
if raw_lead is None: if raw_lead is None:
return None return None
if raw_lead in ("", []): if raw_lead in ("", []):
@ -160,27 +264,130 @@ def _normalize_lead_trainer_profile_id(
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,)) cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
if not cur.fetchone(): if not cur.fetchone():
raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden") raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")
if role in ("admin", "superadmin"):
return nid
if nid == profile_id:
return nid
cur.execute( cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s", "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,), (group_id,),
) )
gr = cur.fetchone() gr = cur.fetchone()
if not gr: if not gr:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set() grd = dict(gr)
for x in gr.get("co_trainer_ids") or []: cid = grd.get("club_id")
eligible.add(x) if cid is None:
if nid in eligible: raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
return nid club_i = int(cid)
raise HTTPException(
status_code=403,
detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
)
if is_platform_admin(role):
return nid
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
for x in grd.get("co_trainer_ids") or []:
try:
eligible.add(int(x))
except (TypeError, ValueError):
continue
if nid == profile_id:
if not _profile_active_in_club(cur, club_i, profile_id):
raise HTTPException(
status_code=403,
detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen",
)
return nid
if nid not in eligible:
if not _profile_active_in_club(cur, club_i, nid):
raise HTTPException(
status_code=400,
detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe",
)
if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by):
raise HTTPException(
status_code=403,
detail="Keine Berechtigung, die Leitung zuzuweisen",
)
return nid
if nid != profile_id and not _caller_may_assign_session_trainers(
cur, grd, profile_id, role, unit_created_by
):
raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen")
return nid
def _normalize_assistant_trainer_profile_ids(
cur,
group_id: int,
raw_val: Any,
profile_id: int,
role: str,
unit_created_by: Optional[int],
lead_nid: Optional[int],
) -> Any:
"""
None = Vererbung aus training_groups.co_trainer_ids (SQL NULL);
Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.)
"""
if raw_val is None:
return None
if not isinstance(raw_val, list):
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids muss Liste oder null sein")
ids_in: List[int] = []
for x in raw_val:
try:
i = int(x)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
if i < 1:
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
ids_in.append(i)
uniq = sorted(set(ids_in))
cur.execute(
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
(group_id,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
grd = dict(gr)
cid = grd.get("club_id")
if cid is None:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
club_i = int(cid)
if not is_platform_admin(role) and not _caller_may_assign_session_trainers(
cur, grd, profile_id, role, unit_created_by
):
raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer zuzuweisen")
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
for x in grd.get("co_trainer_ids") or []:
try:
eligible.add(int(x))
except (TypeError, ValueError):
continue
eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None)
for nid in uniq:
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
if not cur.fetchone():
raise HTTPException(status_code=400, detail="Profil für Co-Trainer nicht gefunden")
if eff_lead is not None and nid == eff_lead:
raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden")
if is_platform_admin(role):
continue
if nid in eligible:
continue
if not _profile_active_in_club(cur, club_i, nid):
raise HTTPException(
status_code=400,
detail="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
)
return uniq
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id # Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
_ORIGIN_LINEAGE_JOIN = """ _ORIGIN_LINEAGE_JOIN = """
@ -775,14 +982,18 @@ def list_training_units(
if gid and role not in ["admin", "superadmin"]: if gid and role not in ["admin", "superadmin"]:
cur.execute( cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s AND status = 'active'", "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'",
(gid,), (gid,),
) )
gr = cur.fetchone() gr = cur.fetchone()
if not gr: if not gr:
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
cob = gr["co_trainer_ids"] or [] gd = dict(gr)
if gr["trainer_id"] != profile_id and profile_id not in cob: cob = gd.get("co_trainer_ids") or []
ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob
ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role)
ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id)
if not (ok_staff or ok_org or ok_member):
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC" order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
@ -805,6 +1016,8 @@ def list_training_units(
p.name as trainer_name, p.name as trainer_name,
p.name as creator_name, p.name as creator_name,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
AS effective_assistant_trainer_profile_ids,
leadp.name AS lead_trainer_name leadp.name AS lead_trainer_name
""" """
query += "," + _ORIGIN_LINEAGE_FIELDS query += "," + _ORIGIN_LINEAGE_FIELDS
@ -820,12 +1033,27 @@ def list_training_units(
where = [] where = []
params = [] params = []
if role not in ["admin", "superadmin"]: skip_involvement_filter = role in ("admin", "superadmin")
where.append( if not skip_involvement_filter and cid is not None:
"(tu.created_by = %s OR tg.trainer_id = %s OR " if can_manage_club_org(cur, profile_id, cid, role):
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))" skip_involvement_filter = True
if not skip_involvement_filter and gid is not None:
cur.execute(
"SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'",
(gid,),
) )
params.extend([profile_id, profile_id, profile_id]) gcx = cur.fetchone()
if gcx and gcx.get("club_id") is not None:
if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role):
skip_involvement_filter = True
if not skip_involvement_filter:
where.append(
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
"@> jsonb_build_array(%s::int))"
)
params.extend([profile_id, profile_id, profile_id, profile_id])
where.append("tu.framework_slot_id IS NULL") where.append("tu.framework_slot_id IS NULL")
@ -840,7 +1068,8 @@ def list_training_units(
if assigned_to_me: if assigned_to_me:
where.append( where.append(
"(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR " "(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))" "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
"@> jsonb_build_array(%s::int))"
) )
params.extend([profile_id, profile_id]) params.extend([profile_id, profile_id])
@ -890,7 +1119,10 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c
p.name as creator_name, p.name as creator_name,
tg.trainer_id AS trainer_id, tg.trainer_id AS trainer_id,
tg.co_trainer_ids AS co_trainer_ids, tg.co_trainer_ids AS co_trainer_ids,
tg.club_id AS group_club_id,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
AS effective_assistant_trainer_profile_ids,
leadp.name AS lead_trainer_name, leadp.name AS lead_trainer_name,
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """ """ + _ORIGIN_LINEAGE_FIELDS.strip() + """
FROM training_units tu FROM training_units tu
@ -957,27 +1189,77 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
tpl_id_safe = plan_template_id tpl_id_safe = plan_template_id
cur.execute( cur.execute(
""" "SELECT trainer_id FROM training_groups WHERE id = %s",
INSERT INTO training_units ( (int(group_id),),
group_id, planned_date, planned_time_start, planned_time_end,
planned_focus, status, notes, trainer_notes, created_by,
plan_template_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
group_id,
planned_date,
data.get("planned_time_start"),
data.get("planned_time_end"),
data.get("planned_focus"),
data.get("status", "planned"),
data.get("notes"),
data.get("trainer_notes"),
profile_id,
tpl_id_safe,
),
) )
g0 = cur.fetchone()
default_group_trainer = g0["trainer_id"] if g0 else None
lead_ins: Optional[int] = None
if "lead_trainer_profile_id" in data:
lead_ins = _normalize_lead_trainer_profile_id(
cur,
int(group_id),
data.get("lead_trainer_profile_id"),
profile_id,
role,
profile_id,
)
assistant_val: Any = None
assistant_set = False
if "assistant_trainer_profile_ids" in data:
assistant_set = True
eff_lead_for_co = lead_ins if lead_ins is not None else default_group_trainer
assistant_val = _normalize_assistant_trainer_profile_ids(
cur,
int(group_id),
data.get("assistant_trainer_profile_ids"),
profile_id,
role,
profile_id,
eff_lead_for_co,
)
base_params = (
group_id,
planned_date,
data.get("planned_time_start"),
data.get("planned_time_end"),
data.get("planned_focus"),
data.get("status", "planned"),
data.get("notes"),
data.get("trainer_notes"),
profile_id,
tpl_id_safe,
lead_ins,
)
if assistant_set:
cur.execute(
"""
INSERT INTO training_units (
group_id, planned_date, planned_time_start, planned_time_end,
planned_focus, status, notes, trainer_notes, created_by,
plan_template_id,
lead_trainer_profile_id,
assistant_trainer_profile_ids
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
base_params + (assistant_val,),
)
else:
cur.execute(
"""
INSERT INTO training_units (
group_id, planned_date, planned_time_start, planned_time_end,
planned_focus, status, notes, trainer_notes, created_by,
plan_template_id,
lead_trainer_profile_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
base_params,
)
unit_id = cur.fetchone()["id"] unit_id = cur.fetchone()["id"]
@ -1066,8 +1348,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tuple(blueprint_params), tuple(blueprint_params),
) )
else: else:
cur_lead = unit_row.get("lead_trainer_profile_id")
base_tr = unit_row.get("trainer_id")
lead_sql = "" lead_sql = ""
lead_params: List[Any] = [] lead_params: List[Any] = []
assist_sql = ""
assist_params: List[Any] = []
nl: Optional[int]
if "lead_trainer_profile_id" in data: if "lead_trainer_profile_id" in data:
nl = _normalize_lead_trainer_profile_id( nl = _normalize_lead_trainer_profile_id(
cur, cur,
@ -1075,9 +1362,27 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
data.get("lead_trainer_profile_id"), data.get("lead_trainer_profile_id"),
profile_id, profile_id,
role, role,
unit_row.get("created_by"),
) )
lead_sql = ", lead_trainer_profile_id = %s" lead_sql = ", lead_trainer_profile_id = %s"
lead_params.append(nl) lead_params.append(nl)
eff_lead_for_co = nl if nl is not None else base_tr
else:
nl = cur_lead if cur_lead is not None else base_tr
eff_lead_for_co = nl
if "assistant_trainer_profile_ids" in data:
na = _normalize_assistant_trainer_profile_ids(
cur,
unit_row["group_id"],
data.get("assistant_trainer_profile_ids"),
profile_id,
role,
unit_row.get("created_by"),
eff_lead_for_co,
)
assist_sql = ", assistant_trainer_profile_ids = %s"
assist_params.append(na)
cur.execute( cur.execute(
f""" f"""
@ -1096,6 +1401,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
plan_template_id = COALESCE(%s, plan_template_id), plan_template_id = COALESCE(%s, plan_template_id),
updated_at = NOW() updated_at = NOW()
{lead_sql} {lead_sql}
{assist_sql}
WHERE id = %s WHERE id = %s
""", """,
( (
@ -1113,6 +1419,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
tpl_id_val, tpl_id_val,
) )
+ tuple(lead_params) + tuple(lead_params)
+ tuple(assist_params)
+ (unit_id,), + (unit_id,),
) )
@ -1152,7 +1459,12 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
"SELECT created_by, framework_slot_id FROM training_units WHERE id = %s", """
SELECT tu.created_by, tu.framework_slot_id, tg.club_id AS group_club_id
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
WHERE tu.id = %s
""",
(unit_id,), (unit_id,),
) )
@ -1167,7 +1479,13 @@ def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenan
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.", detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
) )
_assert_delete_training_unit(role, unit["created_by"], profile_id) _assert_delete_training_unit(
cur,
role,
unit["created_by"],
profile_id,
unit.get("group_club_id"),
)
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
conn.commit() conn.commit()

View File

@ -0,0 +1,131 @@
"""
DELETE /api/exercises/{id}: Mandanten-/Rollenlogik und Verwendungsblock (409).
TestClient mit Overrides für Auth und TenantContext; DB via get_db/get_cursor gemockt.
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
from tenant_context import TenantContext, get_tenant_context
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides() -> None:
yield
app.dependency_overrides.pop(require_auth, None)
app.dependency_overrides.pop(get_tenant_context, None)
def _mock_db_cm(mock_cur: MagicMock):
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm
def test_delete_trainer_private_own_ok(client: TestClient) -> None:
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"id": 7, "created_by": 42, "visibility": "private", "club_id": None},
{"block_items": 0, "section_items": 0, "prog_edges": 0},
]
mock_cm = _mock_db_cm(mock_cur)
app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=42,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 200
assert r.json().get("ok") is True
def test_delete_trainer_club_exercise_forbidden_without_club_admin(client: TestClient) -> None:
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"id": 7, "created_by": 42, "visibility": "club", "club_id": 5},
]
mock_cm = _mock_db_cm(mock_cur)
app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=42,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
), patch("routers.exercises.has_club_role", return_value=False):
r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 403
def test_delete_usage_returns_409(client: TestClient) -> None:
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"id": 7, "created_by": 42, "visibility": "private", "club_id": None},
{"block_items": 1, "section_items": 2, "prog_edges": 3},
]
mock_cm = _mock_db_cm(mock_cur)
app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=42,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/7", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 409
detail = r.json().get("detail", "")
assert "Übungsblöcken" in detail or "Trainingsplänen" in detail
def test_delete_official_forbidden_non_platform_admin(client: TestClient) -> None:
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"id": 99, "created_by": 1, "visibility": "official", "club_id": None},
]
mock_cm = _mock_db_cm(mock_cur)
app.dependency_overrides[require_auth] = lambda: {"profile_id": 42, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=42,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/99", headers={"X-Auth-Token": "dummy"})
assert r.status_code == 403

View File

@ -0,0 +1,17 @@
"""Unit-Tests ohne DB: Zusammenführung Session-Co vs. Gruppe."""
import pytest
from routers.training_planning import effective_co_trainer_profile_ids_for_merge
@pytest.mark.parametrize(
"unit_side,group_side,expected",
[
(None, [10, 22], [10, 22]),
(None, None, []),
([], [10, 22], []),
([7, "8", 7], None, [7, 8]),
],
)
def test_effective_co_trainer_profile_ids_for_merge(unit_side, group_side, expected):
assert effective_co_trainer_profile_ids_for_merge(unit_side, group_side) == expected

View File

@ -1,12 +1,12 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.36" APP_VERSION = "0.8.40"
BUILD_DATE = "2026-05-05" BUILD_DATE = "2026-05-06"
DB_SCHEMA_VERSION = "20260505041" DB_SCHEMA_VERSION = "20260506043"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.6.0", # POST /profiles nur Plattform-Admin; Insert SERIAL + E-Mail wie Auth; Tests "profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL) "tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL)
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"club_memberships": "1.0.1", # Depends(get_tenant_context) "club_memberships": "1.0.1", # Depends(get_tenant_context)
@ -15,8 +15,8 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status "exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
"training_units": "0.1.0", "training_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
"import_wiki": "1.0.0", "import_wiki": "1.0.0",
@ -27,6 +27,42 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.40",
"date": "2026-05-06",
"changes": [
"Übungen Liste: Fokusfilter mit UND-+ (must_include) und UND- (must_exclude), nur ohne Fokusbereich (focus_only_without); Frontend Dropdown + Mit / Ohne",
],
},
{
"version": "0.8.39",
"date": "2026-05-06",
"changes": [
"Übungen DELETE: Nur eigene private / Vereinsadmin für Vereins-Übungen / Plattform für globale; keine harte Löschung bei Verwendung in Blöcken, Plan-Abschnitten oder Progressionskanten (409 → archivieren)",
"GET /api/exercises: Negativfilter (visibility_exclude_any, status_exclude_any), exclude_without_focus, include_archived; archivierte standardmäßig ausgeblendet",
"Profile exercise_list_prefs (JSONB, Migration 043): gespeicherte Standardfilter; Frontend Übungsliste Filterdialog + „Als Standard speichern“",
"Übungspicker: gleiche Negativfilter; Planung lädt archivierte Übungen immer mit (bestehende Zuordnungen)",
"pytest: tests/test_exercises_delete_policy.py",
],
},
{
"version": "0.8.38",
"date": "2026-05-06",
"changes": [
"Trainingsplanung: Vereinsadmins sehen alle Einheiten bei club_id-/Gruppenliste; GET/PUT Einheit & Löschen mit can_manage_club_org",
"Planung UI: „Trainer zuweisen“ in Vereins-Ansicht (Liste + Kalender) + eigener Modal; Mitgliederverzeichnis für Vereinsorganisation",
],
},
{
"version": "0.8.37",
"date": "2026-05-05",
"changes": [
"DB 042: training_units.assistant_trainer_profile_ids (Co-Trainer-Zuweisung je Termin; NULL = Gruppen-Standard)",
"Trainingseinheiten: POST/PUT lead_trainer_profile_id & assistant_trainer_profile_ids; Leitung für Vereinsmitglieder (Vertretung); GET-Listen inkl. Zuweisung für Sichtbarkeit/assigned_to_me",
"Frontend Trainingsplanung: Leitung/Co-Trainer pro Einheit; Dashboard-Text",
"pytest: tests/test_training_unit_assignments.py",
],
},
{ {
"version": "0.8.36", "version": "0.8.36",
"date": "2026-05-05", "date": "2026-05-05",

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { NavLink, useLocation } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
/** /**
@ -6,8 +6,6 @@ import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
* Wechselt zwischen verschiedenen Admin-Seiten * Wechselt zwischen verschiedenen Admin-Seiten
*/ */
export default function AdminPageNav() { export default function AdminPageNav() {
const location = useLocation()
const pages = [ const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users }, { to: '/admin/users', label: 'Nutzer', icon: Users },
@ -17,51 +15,18 @@ export default function AdminPageNav() {
] ]
return ( return (
<nav style={{ <nav className="admin-top-nav" aria-label="Administration">
display: 'flex', {pages.map((page) => {
gap: '8px',
borderBottom: '2px solid var(--border)',
marginBottom: '24px',
flexWrap: 'wrap'
}}>
{pages.map(page => {
const Icon = page.icon const Icon = page.icon
const isActive = location.pathname === page.to
return ( return (
<NavLink <NavLink
key={page.to} key={page.to}
to={page.to} to={page.to}
style={{ className={({ isActive }) =>
padding: '12px 20px', 'admin-top-nav__link' + (isActive ? ' admin-top-nav__link--active' : '')
background: 'transparent', }
border: 'none',
borderBottom: '3px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: 500,
color: isActive ? 'var(--accent)' : 'var(--text2)',
borderBottomColor: isActive ? 'var(--accent)' : 'transparent',
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.color = 'var(--text1)'
e.currentTarget.style.background = 'var(--surface2)'
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.color = 'var(--text2)'
e.currentTarget.style.background = 'transparent'
}
}}
> >
<Icon size={18} /> <Icon size={18} strokeWidth={2} aria-hidden />
<span>{page.label}</span> <span>{page.label}</span>
</NavLink> </NavLink>
) )

View File

@ -0,0 +1,28 @@
import PageSectionNav from './PageSectionNav'
/**
* Sub-Navigation mit Icon-Chips: gleiche Darstellung wie Stammdaten / Vereine (PageSectionNav).
* Sub-Sub (z. B. Editor) bleibt in den jeweiligen Feature-Layouts.
*/
export default function AppSubnavShell({
ariaLabel,
items,
value,
onChange,
children,
iconSize = 18,
}) {
return (
<div className="app-subnav-shell">
<PageSectionNav
ariaLabel={ariaLabel}
value={value}
onChange={onChange}
items={items}
iconSize={iconSize}
className="page-section-nav--wrap"
/>
<div className="app-subnav-shell__main">{children}</div>
</div>
)
}

View File

@ -0,0 +1,109 @@
import React, { useState } from 'react'
import { newCatalogRuleKey } from '../constants/exerciseListFilters'
/**
* Kompakte +/- Regeln für Katalogwerte (numerische IDs oder Slugs).
* Chips oben, schmales Dropdown, Schalter nur + und .
*/
export default function CatalogRulePicker({
label,
hint,
options = [],
rules = [],
rulesFieldName,
disabled = false,
placeholder = 'Auswählen …',
idKind = 'numeric',
onPatch,
}) {
const [pendingId, setPendingId] = useState('')
const labelFor = (id) => options.find((o) => String(o.id) === String(id))?.label ?? id
const addRule = (mode) => {
const raw = String(pendingId || '').trim()
if (!raw || disabled) return
if (idKind === 'numeric') {
const n = Number(raw)
if (!Number.isFinite(n) || n < 1) return
}
const dup = (rules || []).some((r) => String(r.id) === raw && r.mode === mode)
if (dup) return
onPatch({
[rulesFieldName]: [
...(rules || []),
{ key: newCatalogRuleKey(rulesFieldName), id: raw, mode },
],
})
setPendingId('')
}
const removeRule = (key) => {
onPatch({
[rulesFieldName]: (rules || []).filter((r) => r.key !== key),
})
}
return (
<div className={`catalog-rule-picker${disabled ? ' catalog-rule-picker--disabled' : ''}`}>
<label className="form-label catalog-rule-picker__label">{label}</label>
{hint ? (
<p className="muted catalog-rule-picker__hint" style={{ fontSize: '11px', marginTop: '2px', marginBottom: '6px' }}>
{hint}
</p>
) : null}
<div className="catalog-rule-picker__chips" aria-live="polite">
{(rules || []).map((r) => (
<span key={r.key} className="catalog-rule-chip">
<span className={`catalog-rule-chip__sign catalog-rule-chip__sign--${r.mode}`}>
{r.mode === 'forbid' ? '' : '+'}
</span>
<span className="catalog-rule-chip__text">{labelFor(r.id)}</span>
<button
type="button"
className="catalog-rule-chip__x"
aria-label={`${label}: Regel entfernen`}
onClick={() => removeRule(r.key)}
>
×
</button>
</span>
))}
</div>
<div className="catalog-rule-picker__row">
<select
className="form-input catalog-rule-picker__select"
value={pendingId}
disabled={disabled}
onChange={(e) => setPendingId(e.target.value)}
aria-label={label}
>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.id} value={String(o.id)}>
{o.label || o.id}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
disabled={disabled || !pendingId}
title="Muss zutreffen"
onClick={() => addRule('require')}
>
+
</button>
<button
type="button"
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
disabled={disabled || !pendingId}
title="Darf nicht zutreffen"
onClick={() => addRule('forbid')}
>
</button>
</div>
</div>
)
}

View File

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

View File

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

View File

@ -0,0 +1,50 @@
/**
* Einheitliche Sektions-Navigation: Chip-Zeile wie Admin-Stammdaten (.admin-page-subtabs).
* Für Tabs (role=tablist) oder kompakte Umschalter (aria-pressed, role=group).
*/
export default function PageSectionNav({
ariaLabel,
value,
onChange,
items,
className = '',
iconSize = 16,
semantics = 'tabs',
}) {
const isToggle = semantics === 'toggle'
return (
<div
className={`admin-page-subtabs page-section-nav ${className}`.trim()}
role={isToggle ? 'group' : 'tablist'}
aria-label={ariaLabel}
>
{items.map((item) => {
const Icon = item.icon
const active = value === item.id
const disabled = Boolean(item.disabled)
return (
<button
key={item.id}
type="button"
role={isToggle ? undefined : 'tab'}
aria-selected={isToggle ? undefined : active}
aria-pressed={isToggle ? active : undefined}
disabled={disabled}
className={
'admin-page-subtabs__btn' +
(active ? ' admin-page-subtabs__btn--active' : '')
}
onClick={() => {
if (!disabled) onChange(item.id)
}}
>
{Icon ? (
<Icon size={iconSize} strokeWidth={2} className="page-section-nav__icon" aria-hidden />
) : null}
<span>{item.label}</span>
</button>
)
})}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,163 @@
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
export const INITIAL_EXERCISE_LIST_FILTERS = {
focus_area_ids: [],
focus_rules: [],
focus_only_without: false,
style_direction_ids: [],
style_direction_rules: [],
training_type_ids: [],
training_type_rules: [],
target_group_ids: [],
target_group_rules: [],
skill_ids: [],
skill_min_level: '',
skill_max_level: '',
visibility_any: [],
visibility_exclude_any: [],
visibility_rules: [],
status_any: [],
status_exclude_any: [],
status_rules: [],
exclude_without_focus: false,
include_archived: false,
}
export const CATALOG_RULE_FIELD_KEYS = [
'focus_rules',
'style_direction_rules',
'training_type_rules',
'target_group_rules',
'visibility_rules',
'status_rules',
]
const PREFS_KEYS = Object.keys(INITIAL_EXERCISE_LIST_FILTERS)
export function newCatalogRuleKey(prefix = 'r') {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
/** Einheitliche Regel-Zeile: { key, id, mode }. Legacy: focus_area_id. */
export function normalizeCatalogRule(r, i, prefix = 'r') {
if (!r || typeof r !== 'object') return null
const id = String(r.id ?? r.focus_area_id ?? '').trim()
if (!id) return null
const mode = r.mode === 'forbid' ? 'forbid' : 'require'
return {
key: r.key || newCatalogRuleKey(prefix),
id,
mode,
}
}
export function splitMnCatalogRules(rules) {
const inc = []
const exc = []
for (const r of rules || []) {
const id = Number(r.id ?? r.focus_area_id)
if (!Number.isFinite(id) || id < 1) continue
if (r.mode === 'forbid') exc.push(id)
else inc.push(id)
}
return {
includeIds: [...new Set(inc)],
excludeIds: [...new Set(exc)],
}
}
/** Für visibility/status (einfaches Feld mit einem Wert pro Übung): + → OR (Liste), → Ausschlussliste. */
export function splitScalarCatalogRules(rules) {
const inc = []
const exc = []
for (const r of rules || []) {
let id = String(r.id ?? '').trim().toLowerCase()
if (!id) continue
if (r.mode === 'forbid') exc.push(id)
else inc.push(id)
}
return {
includeVals: [...new Set(inc)],
excludeVals: [...new Set(exc)],
}
}
/**
* Ruft aus dem Profilfeld exercise_list_prefs einen gültigen Filter-State ab.
*/
export function mergeExerciseListPrefsFromApi(raw) {
const out = { ...INITIAL_EXERCISE_LIST_FILTERS }
if (!raw || typeof raw !== 'object') return out
for (const key of CATALOG_RULE_FIELD_KEYS) {
if (!Array.isArray(raw[key])) continue
out[key] = raw[key].map((r, i) => normalizeCatalogRule(r, i, key)).filter(Boolean)
}
if (raw.focus_only_without !== undefined) out.focus_only_without = !!raw.focus_only_without
if (!out.visibility_rules.length) {
const vr = []
;(raw.visibility_any || []).forEach((id, i) => {
const n = normalizeCatalogRule({ id, mode: 'require', key: `lv-${i}` }, i, 'visibility_rules')
if (n) vr.push(n)
})
;(raw.visibility_exclude_any || []).forEach((id, i) => {
const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lve-${i}` }, i, 'visibility_rules')
if (n) vr.push(n)
})
if (vr.length) out.visibility_rules = vr
}
if (!out.status_rules.length) {
const sr = []
;(raw.status_any || []).forEach((id, i) => {
const n = normalizeCatalogRule({ id, mode: 'require', key: `ls-${i}` }, i, 'status_rules')
if (n) sr.push(n)
})
;(raw.status_exclude_any || []).forEach((id, i) => {
const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lse-${i}` }, i, 'status_rules')
if (n) sr.push(n)
})
if (sr.length) out.status_rules = sr
}
for (const k of PREFS_KEYS) {
if (CATALOG_RULE_FIELD_KEYS.includes(k)) continue
if (k === 'focus_only_without') continue
if (raw[k] === undefined) continue
if (
k === 'visibility_any' ||
k === 'visibility_exclude_any' ||
k === 'status_any' ||
k === 'status_exclude_any'
) {
continue
}
if (k.endsWith('_ids') || k.endsWith('_any')) {
if (Array.isArray(raw[k])) out[k] = raw[k].map(String)
continue
}
if (k === 'exclude_without_focus' || k === 'include_archived') {
out[k] = !!raw[k]
continue
}
if (k === 'skill_min_level' || k === 'skill_max_level') {
out[k] = raw[k] === '' || raw[k] == null ? '' : String(raw[k])
}
}
return out
}
/** Nur von den Defaults abweichende Werte — kompaktes Profil-JSON. */
export function compactExerciseListPrefsPayload(filters) {
const full = { ...INITIAL_EXERCISE_LIST_FILTERS, ...filters }
const o = {}
for (const k of PREFS_KEYS) {
const v = full[k]
const ini = INITIAL_EXERCISE_LIST_FILTERS[k]
if (JSON.stringify(v) === JSON.stringify(ini)) continue
o[k] = v
}
return o
}

View File

@ -1,6 +1,19 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { api } from '../utils/api' import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav' import AdminPageNav from '../components/AdminPageNav'
import PageSectionNav from '../components/PageSectionNav'
const CATALOG_SUBTABS = [
{ id: 'focus-areas', label: 'Fokusbereiche' },
{ id: 'training-styles', label: 'Stilrichtungen' },
{ id: 'training-types', label: 'Trainingsstil' },
{ id: 'hierarchy', label: 'Hierarchie' },
{ id: 'target-groups', label: 'Zielgruppen' },
{ id: 'target-groups-matrix', label: 'Zuordnungen' },
{ id: 'training-characters', label: 'Trainingscharakter' },
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' },
]
export default function AdminCatalogsPage() { export default function AdminCatalogsPage() {
const [activeTab, setActiveTab] = useState('focus-areas') const [activeTab, setActiveTab] = useState('focus-areas')
@ -316,44 +329,16 @@ export default function AdminCatalogsPage() {
<div className="app-page"> <div className="app-page">
<AdminPageNav /> <AdminPageNav />
<h1 style={{ marginBottom: '24px' }}>Stammdaten-Kataloge</h1> <h1 className="page-title">Stammdaten-Kataloge</h1>
{/* Tabs */} <PageSectionNav
<div style={{ display: 'flex', gap: '8px', borderBottom: '2px solid var(--border)', marginBottom: '24px', overflowX: 'auto' }}> ariaLabel="Katalogbereiche"
{[ value={activeTab}
{ id: 'focus-areas', label: 'Fokusbereiche' }, onChange={setActiveTab}
{ id: 'training-styles', label: 'Stilrichtungen' }, items={CATALOG_SUBTABS}
{ id: 'training-types', label: 'Trainingsstil' }, />
{ id: 'hierarchy', label: 'Hierarchie' },
{ id: 'target-groups', label: 'Zielgruppen' },
{ id: 'target-groups-matrix', label: 'Zuordnungen' },
{ id: 'training-characters', label: 'Trainingscharakter' },
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="btn"
style={{
borderBottom: activeTab === tab.id ? '3px solid var(--accent)' : 'none',
borderRadius: 0,
fontWeight: activeTab === tab.id ? 600 : 400,
color: activeTab === tab.id ? 'var(--accent)' : 'var(--text2)',
padding: '12px 16px',
whiteSpace: 'nowrap'
}}
>
{tab.label}
</button>
))}
</div>
{error && ( {error && <div className="admin-matrix-alert">{error}</div>}
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '16px' }}>
{error}
</div>
)}
{loading ? ( {loading ? (
<div className="spinner" /> <div className="spinner" />

View File

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { TreePine, FolderTree, Link2 } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import AdminPageNav from '../components/AdminPageNav' import AdminPageNav from '../components/AdminPageNav'
import AppSubnavShell from '../components/AppSubnavShell'
import HierarchyTab from '../components/admin/HierarchyTab' import HierarchyTab from '../components/admin/HierarchyTab'
import CatalogsTab from '../components/admin/CatalogsTab' import CatalogsTab from '../components/admin/CatalogsTab'
import AssignmentsTab from '../components/admin/AssignmentsTab' import AssignmentsTab from '../components/admin/AssignmentsTab'
@ -10,17 +12,14 @@ function AdminHierarchyPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
// Hierarchy Tab State
const [hierarchy, setHierarchy] = useState([]) const [hierarchy, setHierarchy] = useState([])
const [expandedNodes, setExpandedNodes] = useState(new Set()) const [expandedNodes, setExpandedNodes] = useState(new Set())
const [selectedItem, setSelectedItem] = useState(null) const [selectedItem, setSelectedItem] = useState(null)
// Catalogs Tab State
const [targetGroups, setTargetGroups] = useState([]) const [targetGroups, setTargetGroups] = useState([])
const [skillCategories, setSkillCategories] = useState([]) const [skillCategories, setSkillCategories] = useState([])
const [trainingCharacters, setTrainingCharacters] = useState([]) const [trainingCharacters, setTrainingCharacters] = useState([])
// Assignments Tab State
const [styleDirections, setStyleDirections] = useState([]) const [styleDirections, setStyleDirections] = useState([])
const [assignments, setAssignments] = useState([]) const [assignments, setAssignments] = useState([])
@ -62,7 +61,7 @@ function AdminHierarchyPage() {
} }
function handleToggleNode(nodeId) { function handleToggleNode(nodeId) {
setExpandedNodes(prev => { setExpandedNodes((prev) => {
const newSet = new Set(prev) const newSet = new Set(prev)
if (newSet.has(nodeId)) { if (newSet.has(nodeId)) {
newSet.delete(nodeId) newSet.delete(nodeId)
@ -86,33 +85,26 @@ function AdminHierarchyPage() {
loadData() loadData()
} }
const tabs = [ const subnavItems = [
{ id: 'hierarchy', label: '🌳 Hierarchie', icon: '🌳' }, { id: 'hierarchy', label: 'Hierarchie', icon: TreePine },
{ id: 'catalogs', label: '📋 Kataloge', icon: '📋' }, { id: 'catalogs', label: 'Kataloge', icon: FolderTree },
{ id: 'assignments', label: '🔗 Zuordnungen', icon: '🔗' } { id: 'assignments', label: 'Zuordnungen', icon: Link2 }
] ]
return ( return (
<div className="app-page"> <div className="app-page admin-hierarchy-page">
<AdminPageNav /> <AdminPageNav />
<h1 style={{ marginTop: 0 }}>Admin: Katalog-Hierarchie</h1> <h1 className="page-title" style={{ marginBottom: '12px' }}>
Katalog &amp; Hierarchie
</h1>
{/* Tab Navigation */} <AppSubnavShell
<div className="tab-navigation"> ariaLabel="Bereich Katalogadministration"
{tabs.map(tab => ( items={subnavItems}
<button value={activeTab}
key={tab.id} onChange={setActiveTab}
className={activeTab === tab.id ? 'tab-button active' : 'tab-button'} >
onClick={() => setActiveTab(tab.id)}
>
{tab.icon} {tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div style={{ marginTop: '20px' }}>
{activeTab === 'hierarchy' && ( {activeTab === 'hierarchy' && (
<HierarchyTab <HierarchyTab
hierarchy={hierarchy} hierarchy={hierarchy}
@ -147,48 +139,7 @@ function AdminHierarchyPage() {
onUpdate={handleUpdate} onUpdate={handleUpdate}
/> />
)} )}
</div> </AppSubnavShell>
<style>{`
.tab-navigation {
display: flex;
gap: 8px;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
flex-wrap: wrap;
}
.tab-button {
padding: 12px 20px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: var(--text2);
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text1);
background: var(--surface2);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
@media (max-width: 768px) {
.tab-button {
flex: 1 1 auto;
min-width: 120px;
font-size: 14px;
padding: 10px 12px;
}
}
`}</style>
</div> </div>
) )
} }

View File

@ -6,6 +6,14 @@ import SkillsCatalogAdmin from '../components/admin/SkillsCatalogAdmin'
import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel' import MaturityModelsAdminPanel from '../components/admin/MaturityModelsAdminPanel'
import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin' import MaturityModelBindingsAdmin from '../components/admin/MaturityModelBindingsAdmin'
import MaturityMatrixToolsAdmin from '../components/admin/MaturityMatrixToolsAdmin' import MaturityMatrixToolsAdmin from '../components/admin/MaturityMatrixToolsAdmin'
import PageSectionNav from '../components/PageSectionNav'
const MATURITY_SECTION_TABS = [
{ id: 'catalog', label: 'Katalog und Hierarchie' },
{ id: 'models', label: 'Reifegradmodelle' },
{ id: 'bindings', label: 'Kontext-Zuordnung' },
{ id: 'matrixviz', label: 'Matrix-Ansicht und Export' },
]
export default function AdminMaturityModelsPage() { export default function AdminMaturityModelsPage() {
const { user } = useAuth() const { user } = useAuth()
@ -27,44 +35,12 @@ export default function AdminMaturityModelsPage() {
</p> </p>
</header> </header>
<div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten"> <PageSectionNav
<button ariaLabel="Bereiche Fähigkeiten"
type="button" value={tab}
role="tab" onChange={setTab}
aria-selected={tab === 'catalog'} items={MATURITY_SECTION_TABS}
className={'admin-tabs__tab' + (tab === 'catalog' ? ' admin-tabs__tab--active' : '')} />
onClick={() => setTab('catalog')}
>
Katalog und Hierarchie
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'models'}
className={'admin-tabs__tab' + (tab === 'models' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('models')}
>
Reifegradmodelle
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'bindings'}
className={'admin-tabs__tab' + (tab === 'bindings' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('bindings')}
>
Kontext-Zuordnung
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'matrixviz'}
className={'admin-tabs__tab' + (tab === 'matrixviz' ? ' admin-tabs__tab--active' : '')}
onClick={() => setTab('matrixviz')}
>
Matrix-Ansicht und Export
</button>
</div>
<div className="admin-tabs__panel" role="tabpanel"> <div className="admin-tabs__panel" role="tabpanel">
{tab === 'catalog' ? ( {tab === 'catalog' ? (

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import PageSectionNav from '../components/PageSectionNav'
const CLUB_ROLE_OPTIONS = [ const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' }, { code: 'club_admin', label: 'Vereinsadmin' },
@ -285,9 +286,22 @@ function ClubsPage() {
setFormData(prev => ({ ...prev, [field]: value })) setFormData(prev => ({ ...prev, [field]: value }))
} }
const clubTabItems = useMemo(() => {
const ids = canManageOrgSomewhere
? ['clubs', 'divisions', 'groups', 'members']
: ['clubs', 'divisions', 'groups']
const labels = {
clubs: 'Vereine',
divisions: 'Sparten',
groups: 'Trainingsgruppen',
members: 'Mitglieder',
}
return ids.map((id) => ({ id, label: labels[id] }))
}, [canManageOrgSomewhere])
if (loading) { if (loading) {
return ( return (
<div style={{ padding: '2rem', textAlign: 'center' }}> <div className="skills-page__loading">
<div className="spinner"></div> <div className="spinner"></div>
<p>Laden...</p> <p>Laden...</p>
</div> </div>
@ -295,46 +309,21 @@ function ClubsPage() {
} }
return ( return (
<div className="app-page"> <div className="app-page clubs-page">
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1> <h1 className="page-title">Vereinsverwaltung</h1>
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}> <p className="clubs-page__intro muted">
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht. Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
Sparten sind optional typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen. Sparten sind optional typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
</p> </p>
{/* Tabs */} <PageSectionNav
<div style={{ ariaLabel="Vereinsverwaltung"
display: 'flex', value={activeTab}
gap: '0.5rem', onChange={setActiveTab}
marginBottom: '1.5rem', items={clubTabItems}
borderBottom: '2px solid var(--border)' />
}}>
{(canManageOrgSomewhere
? ['clubs', 'divisions', 'groups', 'members']
: ['clubs', 'divisions', 'groups']
).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '0.75rem 1.5rem',
background: activeTab === tab ? 'var(--accent)' : 'transparent',
color: activeTab === tab ? 'white' : 'var(--text1)',
border: 'none',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: activeTab === tab ? 'bold' : 'normal'
}}
>
{tab === 'clubs' && 'Vereine'}
{tab === 'divisions' && 'Sparten'}
{tab === 'groups' && 'Trainingsgruppen'}
{tab === 'members' && 'Mitglieder'}
</button>
))}
</div>
{/* Clubs Tab */} {/* Clubs Tab */}
{activeTab === 'clubs' && ( {activeTab === 'clubs' && (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
@ -512,11 +501,13 @@ function ClubsPage() {
</p> </p>
</div> </div>
) : ( ) : (
<div style={{ <div
display: 'grid', className="card-grid clubs-groups-card-grid"
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', style={{
gap: '1rem' gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
}}> gap: '1rem'
}}
>
{groups.map(group => ( {groups.map(group => (
<div key={group.id} className="card"> <div key={group.id} className="card">
<h3 style={{ marginBottom: '0.5rem' }}>{group.name}</h3> <h3 style={{ marginBottom: '0.5rem' }}>{group.name}</h3>

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,14 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Eye, Play, History } from 'lucide-react'
import api from '../utils/api' import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav' import AdminPageNav from '../components/AdminPageNav'
import PageSectionNav from '../components/PageSectionNav'
const WIKI_IMPORT_TABS = [
{ id: 'preview', label: 'Vorschau', icon: Eye },
{ id: 'execute', label: 'Ausführen', icon: Play },
{ id: 'history', label: 'Historie', icon: History },
]
export default function MediaWikiImportPage() { export default function MediaWikiImportPage() {
const [activeTab, setActiveTab] = useState('preview') const [activeTab, setActiveTab] = useState('preview')
@ -111,32 +119,12 @@ export default function MediaWikiImportPage() {
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
</p> </p>
{/* Tabs */} <PageSectionNav
<div style={{ borderBottom: '2px solid var(--border)', marginBottom: '24px' }}> ariaLabel="Import-Schritte"
<div style={{ display: 'flex', gap: '8px' }}> value={activeTab}
{['preview', 'execute', 'history'].map(tab => ( onChange={setActiveTab}
<button items={WIKI_IMPORT_TABS}
key={tab} />
onClick={() => setActiveTab(tab)}
style={{
padding: '12px 24px',
background: activeTab === tab ? 'var(--accent)' : 'transparent',
color: activeTab === tab ? 'white' : 'var(--text1)',
border: 'none',
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
cursor: 'pointer',
fontSize: '16px',
fontWeight: activeTab === tab ? 'bold' : 'normal',
transition: 'all 0.2s'
}}
>
{tab === 'preview' && '👁️ Vorschau'}
{tab === 'execute' && '▶️ Ausführen'}
{tab === 'history' && '📜 Historie'}
</button>
))}
</div>
</div>
{/* Error Display */} {/* Error Display */}
{error && ( {error && (

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -375,7 +375,7 @@ export async function listExercises(filters = {}) {
Object.entries(filters).forEach(([k, v]) => { Object.entries(filters).forEach(([k, v]) => {
if (v === undefined || v === null) return if (v === undefined || v === null) return
if (typeof v === 'boolean') { if (typeof v === 'boolean') {
if (v) q.set(k, 'true') q.set(k, v ? 'true' : 'false')
return return
} }
if (Array.isArray(v)) { if (Array.isArray(v)) {
@ -508,7 +508,7 @@ export async function updateExercise(id, data) {
}) })
} }
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */ /** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) { export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', { return request('/api/exercises/bulk-metadata', {
method: 'PATCH', method: 'PATCH',

View File

@ -0,0 +1,27 @@
function userIsClubAdminForClub(user, clubId) {
if (clubId == null || user == null) return false
const cid = Number(clubId)
const row = (user.clubs || []).find((c) => Number(c.id) === cid)
return Array.isArray(row?.roles) && row.roles.includes('club_admin')
}
function userHasAnyClubAdminRole(user) {
return (user?.clubs || []).some((c) => Array.isArray(c.roles) && c.roles.includes('club_admin'))
}
/**
* Ob die Löschen-Aktion in der Liste sinnvoll angeboten werden kann (Server hat letzte Instanz).
*/
export function canUserRequestExerciseDelete(user, exercise) {
if (!user || !exercise) return false
const role = String(user.role || '').toLowerCase()
if (role === 'admin' || role === 'superadmin') return true
const vis = exercise.visibility || 'private'
const mine = Number(exercise.created_by) === Number(user.id)
if (vis === 'official') return false
if (vis === 'club') {
return userIsClubAdminForClub(user, exercise.club_id)
}
if (mine) return true
return userHasAnyClubAdminRole(user)
}

View File

@ -0,0 +1,52 @@
/**
* Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute).
* Für Anzeige mit dangerouslySetInnerHTML.
*/
const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li'])
function cleanTree(parent) {
const nodes = Array.from(parent.childNodes)
for (const node of nodes) {
if (node.nodeType === Node.TEXT_NODE) continue
if (node.nodeType !== Node.ELEMENT_NODE) {
parent.removeChild(node)
continue
}
const tag = node.tagName.toLowerCase()
if (!ALLOWED_TAGS.has(tag)) {
while (node.firstChild) {
parent.insertBefore(node.firstChild, node)
}
parent.removeChild(node)
continue
}
while (node.attributes.length > 0) {
node.removeAttribute(node.attributes[0].name)
}
cleanTree(node)
}
}
export function sanitizeExerciseRichText(html) {
if (html == null || typeof html !== 'string') return ''
const trimmed = html.trim()
if (!trimmed) return ''
const tpl = document.createElement('template')
tpl.innerHTML = trimmed
cleanTree(tpl.content)
return tpl.innerHTML
}
export function coerceApiNameList(value) {
if (Array.isArray(value)) return value.map(String).filter((s) => s.trim())
if (typeof value === 'string') {
try {
const p = JSON.parse(value)
if (Array.isArray(p)) return p.map(String).filter((s) => s.trim())
} catch {
return []
}
}
return []
}

View File

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