From 1631bd2e0246223e6a43aac948b7f19d888f710d Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 12:30:04 +0200 Subject: [PATCH 01/34] chore(version): update version and changelog for release 0.8.124 - Bumped APP_VERSION to 0.8.124 and updated the changelog to reflect recent changes. - Introduced the ExerciseListBulkToolbar component in ExercisesListPage for improved bulk action handling. - Enhanced the user interface for selecting and managing exercises in bulk. --- backend/version.py | 9 ++++++- .../exercises/ExerciseListBulkToolbar.jsx | 27 +++++++++++++++++++ frontend/src/pages/ExercisesListPage.jsx | 23 +++++----------- 3 files changed, 42 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/exercises/ExerciseListBulkToolbar.jsx diff --git a/backend/version.py b/backend/version.py index 7364ece..35133a5 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.123" +APP_VERSION = "0.8.124" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.124", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3 (Teil): ExerciseListBulkToolbar-Komponente; Übungsliste nur Verdrahtung.", + ], + }, { "version": "0.8.123", "date": "2026-05-13", diff --git a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx new file mode 100644 index 0000000..1e1ea18 --- /dev/null +++ b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx @@ -0,0 +1,27 @@ +import React from 'react' + +export default function ExerciseListBulkToolbar({ + selectedCount, + bulkMaxIds, + onClearSelection, + onOpenBulkModal, +}) { + if (selectedCount < 1) return null + + return ( +
+ {selectedCount} ausgewählt + + + + Bis zu {bulkMaxIds} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext ( + X-Active-Club-Id + ). + +
+ ) +} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 5e2473b..b6a8e88 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -8,6 +8,7 @@ import ExerciseListCard from '../components/exercises/ExerciseListCard' import ExerciseListFilterModal from '../components/exercises/ExerciseListFilterModal' import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal' import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar' +import ExerciseListBulkToolbar from '../components/exercises/ExerciseListBulkToolbar' import { buildExerciseListFilterChips } from '../utils/exerciseListFilterChips' import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../utils/exerciseListQuery' import { useExerciseListCatalogsAndQuery } from '../hooks/useExerciseListCatalogsAndQuery' @@ -474,22 +475,12 @@ function ExercisesListPage() { onToggleSelectAllPage={toggleSelectAllPage} /> - {selectedIds.size > 0 ? ( -
- {selectedIds.size} ausgewählt - - - - Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext ( - X-Active-Club-Id - ). - -
- ) : null} + Date: Thu, 14 May 2026 12:48:33 +0200 Subject: [PATCH 02/34] chore(version): update version and changelog for release 0.8.125 - Bumped APP_VERSION to 0.8.125 and updated the changelog to reflect recent changes. - Added new tests for the dashboard API to ensure proper HTTP 200 responses when inner lists are mocked. - Enhanced the ExerciseListBulkToolbar component with a data-testid for improved testing capabilities. - Refactored the TrainingPlanningPage by extracting utility functions to trainingPlanningPageHelpers for better code organization. --- backend/tests/test_dashboard_kpis.py | 40 +++++ backend/version.py | 10 +- .../exercises/ExerciseListBulkToolbar.jsx | 2 +- frontend/src/pages/TrainingPlanningPage.jsx | 120 ++------------- .../src/utils/trainingPlanningPageHelpers.js | 106 ++++++++++++++ test-results/.last-run.json | 8 +- .../error-context.md | 137 ++++++++++++++++++ .../test-failed-1.png | Bin 0 -> 2740 bytes .../error-context.md | 137 ++++++++++++++++++ .../test-failed-1.png | Bin 0 -> 2740 bytes .../error-context.md | 137 ++++++++++++++++++ .../test-failed-1.png | Bin 0 -> 2740 bytes tests/dev-smoke-test.spec.js | 48 +++++- 13 files changed, 635 insertions(+), 110 deletions(-) create mode 100644 frontend/src/utils/trainingPlanningPageHelpers.js create mode 100644 test-results/dev-smoke-test-11-Übungsli-01330-nauswahl-zeigt-Bulk-Toolbar/error-context.md create mode 100644 test-results/dev-smoke-test-11-Übungsli-01330-nauswahl-zeigt-Bulk-Toolbar/test-failed-1.png create mode 100644 test-results/dev-smoke-test-12-Training-ae8bb--Seite-lädt-mit-Überschrift/error-context.md create mode 100644 test-results/dev-smoke-test-12-Training-ae8bb--Seite-lädt-mit-Überschrift/test-failed-1.png create mode 100644 test-results/dev-smoke-test-8-Dashboard-8c7cc-profiles-me-dashboard-kpis-/error-context.md create mode 100644 test-results/dev-smoke-test-8-Dashboard-8c7cc-profiles-me-dashboard-kpis-/test-failed-1.png diff --git a/backend/tests/test_dashboard_kpis.py b/backend/tests/test_dashboard_kpis.py index c9ea3dc..313b45b 100644 --- a/backend/tests/test_dashboard_kpis.py +++ b/backend/tests/test_dashboard_kpis.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +from unittest.mock import patch import pytest from fastapi import Query @@ -11,6 +12,7 @@ os.environ.setdefault("SKIP_DB_MIGRATE", "1") from fastapi_param_unwrap import unwrap_query_default from main import app +from tenant_context import TenantContext, get_tenant_context @pytest.fixture @@ -27,3 +29,41 @@ def test_unwrap_query_default_for_direct_route_calls() -> None: def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None: r = client.get("/api/dashboard/kpis") assert r.status_code == 401 + + +def _fake_tenant_for_kpis() -> TenantContext: + return TenantContext( + profile_id=42, + global_role="trainer", + effective_club_id=7, + club_ids=frozenset({7}), + memberships=[], + ) + + +@patch("routers.dashboard.list_training_units") +@patch("routers.dashboard.list_exercises_like_get") +def test_dashboard_kpis_200_when_inner_lists_mocked( + mock_list_ex: object, + mock_list_tu: object, + client: TestClient, +) -> None: + mock_list_ex.return_value = [] + mock_list_tu.return_value = [] + app.dependency_overrides[get_tenant_context] = _fake_tenant_for_kpis + try: + r = client.get("/api/dashboard/kpis") + assert r.status_code == 200, r.text + data = r.json() + assert "year" in data + assert data["draft_count"] == 0 + assert data["mine_count"] == 0 + assert data["ytd_completed_count"] == 0 + th = data["training_home"] + assert th["upcoming"] == [] + assert th["planned_with_notes"] == [] + assert th["review_pending"] == [] + assert mock_list_ex.call_count == 2 + assert mock_list_tu.call_count == 3 + finally: + app.dependency_overrides.clear() diff --git a/backend/version.py b/backend/version.py index 35133a5..6fdba72 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.124" +APP_VERSION = "0.8.125" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.125", + "date": "2026-05-13", + "changes": [ + "Tests: Playwright 11 (Übungsliste Bulk-Toolbar), 12 (Trainingsplanung); Dashboard-Test 8 prüft HTTP 200 auf /api/dashboard/kpis; pytest test_dashboard_kpis_200_when_inner_lists_mocked.", + "Frontend Phase 3: trainingPlanningPageHelpers.js aus TrainingPlanningPage; ExerciseListBulkToolbar data-testid.", + ], + }, { "version": "0.8.124", "date": "2026-05-13", diff --git a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx index 1e1ea18..084dcd3 100644 --- a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx +++ b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx @@ -9,7 +9,7 @@ export default function ExerciseListBulkToolbar({ if (selectedCount < 1) return null return ( -
+
{selectedCount} ausgewählt +
+
+ + ) : fwImportProgramId ? ( +

Keine Sessions in diesem Programm.

+ ) : null} + +
+ + +
+ + + ) +} diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index 240fb09..5cffedf 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -9,6 +9,7 @@ import ExercisePeekModal from '../components/ExercisePeekModal' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel' import PageSectionNav from '../components/PageSectionNav' +import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal' import { defaultSection, normalizeUnitToForm, @@ -2287,198 +2288,28 @@ function TrainingPlanningPage() { )} - {frameworkImportOpen && ( -
-
-

Sessions aus Rahmen übernehmen

-

- Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '} - eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '} - Verknüpfung zum Rahmen-Slot wird gespeichert, damit die Herkunft sichtbar bleibt. -

- -
- - -
- - {fwImportLoading ? ( -

Laden der Sessions…

- ) : fwImportDetail?.slots?.length ? ( - <> -
- - Sessions (mit Ablauf) - -
    - {[...fwImportDetail.slots] - .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) - .map((slot) => { - const hasBp = !!slot.blueprint_training_unit_id - const checked = fwImportSelectedSlots.has(slot.id) - const label = - (slot.title || '').trim() || - `Session ${(slot.sort_order ?? 0) + 1}` - return ( -
  • - -
  • - ) - })} -
-
- -
-
- - setFwImportStartDate(e.target.value)} - disabled={fwImportSubmitting} - /> -
-
- - setFwImportIntervalDays(parseInt(e.target.value, 10) || 0)} - disabled={fwImportSubmitting} - /> -
-
- -
-
- - ) : fwImportProgramId ? ( -

Keine Sessions in diesem Programm.

- ) : null} - -
- - -
-
-
- )} + + setFwImportSlotDates((prev) => ({ ...prev, [slotId]: value })) + } + fwImportStartDate={fwImportStartDate} + onFwImportStartDateChange={setFwImportStartDate} + fwImportIntervalDays={fwImportIntervalDays} + onFwImportIntervalDaysChange={setFwImportIntervalDays} + fwImportSubmitting={fwImportSubmitting} + onApplyDateSuggestions={applyFwImportDateSuggestions} + onSubmit={submitFrameworkImport} + onClose={() => setFrameworkImportOpen(false)} + /> {showModal && (
{ console.log('✓ Trainingsplanung: Grundansicht'); }); +test('13. Trainingsplanung: Rahmen-Import-Dialog öffnet und schließt', async ({ page }, testInfo) => { + await login(page); + await page.goto('/planning', { waitUntil: 'networkidle' }); + const main = page.locator('.app-main'); + await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 }); + await expect(main.getByRole('heading', { level: 1, name: 'Trainingsplanung' })).toBeVisible({ + timeout: 20000, + }); + const openBtn = main.getByRole('button', { name: /Aus Rahmen übernehmen/i }); + if (await openBtn.isDisabled()) { + testInfo.skip(true, 'Keine Trainingsgruppe — Button bleibt deaktiviert'); + return; + } + await openBtn.click(); + const dlg = page.getByTestId('planning-framework-import-modal'); + await expect(dlg).toBeVisible({ timeout: 10000 }); + await expect(dlg.getByRole('heading', { name: /Sessions aus Rahmen übernehmen/i })).toBeVisible(); + await dlg.getByRole('button', { name: 'Abbrechen' }).click(); + await expect(dlg).toHaveCount(0); + console.log('✓ Trainingsplanung: Rahmen-Import-Dialog Smoke'); +}); + test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await login(page); From 45bc049c0daa450e94d9bb2ce26e1ca1c90cdae3 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 13:44:37 +0200 Subject: [PATCH 04/34] chore(version): update version and changelog for release 0.8.128 - Bumped APP_VERSION to 0.8.128 and updated the changelog to reflect recent changes. - Added the TrainingPlanningModuleApplyModal component to the TrainingPlanningPage for enhanced training module application functionality. - Implemented a new callback function onModuleApplySectionIndexChange to manage module application section index changes. --- backend/version.py | 9 +- .../TrainingPlanningModuleApplyModal.jsx | 319 +++++++++++++++++ frontend/src/pages/TrainingPlanningPage.jsx | 334 ++---------------- 3 files changed, 360 insertions(+), 302 deletions(-) create mode 100644 frontend/src/components/planning/TrainingPlanningModuleApplyModal.jsx diff --git a/backend/version.py b/backend/version.py index 090590e..b3e05cf 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.126" +APP_VERSION = "0.8.128" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.128", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3: TrainingPlanningModuleApplyModal (Trainingsmodul einfügen) aus Trainingsplanungsseite; gemeinsamer Callback onModuleApplySectionIndexChange.", + ], + }, { "version": "0.8.126", "date": "2026-05-13", diff --git a/frontend/src/components/planning/TrainingPlanningModuleApplyModal.jsx b/frontend/src/components/planning/TrainingPlanningModuleApplyModal.jsx new file mode 100644 index 0000000..8d9ae91 --- /dev/null +++ b/frontend/src/components/planning/TrainingPlanningModuleApplyModal.jsx @@ -0,0 +1,319 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { trainingVisibilityShortDE } from '../../utils/trainingPlanningPageHelpers' + +/** + * Dialog: Trainingsmodul in die Abschnitte einer Einheit einfügen (Bibliothekskopie). + */ +export default function TrainingPlanningModuleApplyModal({ + open, + busy, + err, + placementLocked, + placementSummary, + sections, + sectionIx, + onSectionIndexChange, + insertSlot, + onInsertSlotChange, + targetItems, + searchQuery, + onSearchQueryChange, + filteredList, + fullList, + selectedModuleId, + onSelectModuleId, + modulePickPreview, + onConfirm, + onCancel, +}) { + if (!open) return null + + const handleBackdropMouseDown = (ev) => { + if (ev.target !== ev.currentTarget || busy) return + onCancel() + } + + return ( +
+
+

+ Trainingsmodul einfügen +

+

+ Alle Positionen des gewählten Moduls werden als neue Zeilen eingefügt (Kopie, mit klarer + Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben — Speichern am Ende + wie gewohnt. Vollständige Textsuche oder Modulkategorien planen wir serverseitig für + eine spätere Iteration; vorerst steht hier eine{' '} + Schnellsuche über Titel und Freitext-Felder zur Verfügung. +

+ + {err ? ( +

{err}

+ ) : null} + + {placementLocked ? ( + <> +

+ Aktuelle Einfügeposition: Abschnitt {placementSummary.secTitle}{' '} + / {placementSummary.positionDescription} +

+
+ Abschnitt oder Position ändern +
+
+ + +
+
+ + +
+
+
+ + ) : ( + <> +
+ + +
+ +
+ + +
+ + )} + +
+ + onSearchQueryChange(e.target.value)} + disabled={busy} + aria-label="Module durch Freitext filtern" + /> +
+ +
+ +
+
+ {!filteredList.length ? ( +

+ {!fullList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'} +

+ ) : ( + filteredList.map((m) => { + const title = ((m.title || '').trim() || `Modul #${m.id}`).trim() + const visLbl = trainingVisibilityShortDE(m.visibility) + const nPos = typeof m.items_count === 'number' ? m.items_count : '—' + const selected = String(m.id) === String(selectedModuleId) + return ( + + ) + }) + )} +
+ + {selectedModuleId ? ( +
+
Ablauf-Vorschau (Bibliotheksmodul)
+ {modulePickPreview.loading ? ( +

+ Übungen und Hinweise laden … +

+ ) : modulePickPreview.err ? ( +

+ {modulePickPreview.err} +

+ ) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? ( +

+ Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben). +

+ ) : ( + <> +
    + {(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => ( +
  1. {t}
  2. + ))} +
+ {modulePickPreview.exercises.length > 12 ? ( +

+ … und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge. +

+ ) : null} + {modulePickPreview.notes > 0 ? ( +

+ zusätzlich {modulePickPreview.notes}{' '} + {modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '} + (ohne Aufzählung) +

+ ) : null} + + )} +
+ ) : null} + +
+ + +
+ +

+ Neue Module kannst du unter{' '} + + Trainingsmodule + {' '} + anlegen. +

+
+
+ ) +} diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index 5cffedf..f491942 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -10,6 +10,7 @@ import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel' import PageSectionNav from '../components/PageSectionNav' import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal' +import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal' import { defaultSection, normalizeUnitToForm, @@ -19,7 +20,6 @@ import { insertTrainingModuleIntoPlanningSections, } from '../utils/trainingUnitSectionsForm' import { - trainingVisibilityShortDE, addDaysIsoDate, pad2, toIsoLocal, @@ -709,6 +709,13 @@ function TrainingPlanningPage() { } }, []) + const onModuleApplySectionIndexChange = useCallback((newIx) => { + setModuleApplySectionIx(newIx) + const secsNow = planningFormRef.current?.sections ?? [] + const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0 + setModuleApplyInsertSlot(`before:${len}`) + }, []) + const handleApplyTrainingModuleConfirm = useCallback(async () => { const mid = parseInt(moduleApplyModuleId, 10) if (!Number.isFinite(mid)) { @@ -1987,306 +1994,31 @@ function TrainingPlanningPage() {
) : null} - {moduleApplyOpen && ( -
{ - if (ev.target !== ev.currentTarget || moduleApplyBusy) return - setModuleApplyOpen(false) - setModuleApplyPlacementLocked(false) - }} - > -
-

- Trainingsmodul einfügen -

-

- Alle Positionen des gewählten Moduls werden als neue Zeilen eingefügt (Kopie, mit klarer - Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben — Speichern am Ende - wie gewohnt. Vollständige Textsuche oder Modulkategorien planen wir serverseitig für - eine spätere Iteration; vorerst steht hier eine{' '} - Schnellsuche über Titel und Freitext-Felder zur Verfügung. -

- - {moduleApplyErr ? ( -

{moduleApplyErr}

- ) : null} - - {moduleApplyPlacementLocked ? ( - <> -

- Aktuelle Einfügeposition: Abschnitt {modulePlacementSummary.secTitle}{' '} - / {modulePlacementSummary.positionDescription} -

-
- Abschnitt oder Position ändern -
-
- - -
-
- - -
-
-
- - ) : ( - <> -
- - -
- -
- - -
- - )} - -
- - setModuleApplySearchQuery(e.target.value)} - disabled={moduleApplyBusy} - aria-label="Module durch Freitext filtern" - /> -
- -
- -
-
- {!moduleApplyFilteredList.length ? ( -

- {!moduleApplyList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'} -

- ) : ( - moduleApplyFilteredList.map((m) => { - const title = ((m.title || '').trim() || `Modul #${m.id}`).trim() - const visLbl = trainingVisibilityShortDE(m.visibility) - const nPos = typeof m.items_count === 'number' ? m.items_count : '—' - const selected = String(m.id) === String(moduleApplyModuleId) - return ( - - ) - }) - )} -
- - {moduleApplyModuleId ? ( -
-
Ablauf-Vorschau (Bibliotheksmodul)
- {modulePickPreview.loading ? ( -

- Übungen und Hinweise laden … -

- ) : modulePickPreview.err ? ( -

- {modulePickPreview.err} -

- ) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? ( -

- Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben). -

- ) : ( - <> -
    - {(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => ( -
  1. {t}
  2. - ))} -
- {modulePickPreview.exercises.length > 12 ? ( -

- … und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge. -

- ) : null} - {modulePickPreview.notes > 0 ? ( -

- zusätzlich {modulePickPreview.notes}{' '} - {modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '} - (ohne Aufzählung) -

- ) : null} - - )} -
- ) : null} - -
- - -
- -

- Neue Module kannst du unter{' '} - - Trainingsmodule - {' '} - anlegen. -

-
-
- )} + { + setModuleApplyOpen(false) + setModuleApplyPlacementLocked(false) + }} + /> Date: Thu, 14 May 2026 15:32:21 +0200 Subject: [PATCH 05/34] chore(version): update version and changelog for release 0.8.129 - Bumped APP_VERSION to 0.8.129 and updated the changelog to reflect recent changes. - Added the TrainingPlanningTrainerAssignModal component to the TrainingPlanningPage for enhanced trainer assignment functionality. - Implemented new callback functions for managing lead trainer and assistant assignments in the training planning process. --- backend/version.py | 9 +- .../TrainingPlanningTrainerAssignModal.jsx | 155 ++++++++++++ frontend/src/pages/TrainingPlanningPage.jsx | 227 +++++------------- 3 files changed, 218 insertions(+), 173 deletions(-) create mode 100644 frontend/src/components/planning/TrainingPlanningTrainerAssignModal.jsx diff --git a/backend/version.py b/backend/version.py index b3e05cf..9e7a6d1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.128" +APP_VERSION = "0.8.129" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.129", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3: TrainingPlanningTrainerAssignModal (Trainer zuweisen) aus Trainingsplanungsseite; Handler per useCallback.", + ], + }, { "version": "0.8.128", "date": "2026-05-13", diff --git a/frontend/src/components/planning/TrainingPlanningTrainerAssignModal.jsx b/frontend/src/components/planning/TrainingPlanningTrainerAssignModal.jsx new file mode 100644 index 0000000..ac861fc --- /dev/null +++ b/frontend/src/components/planning/TrainingPlanningTrainerAssignModal.jsx @@ -0,0 +1,155 @@ +import React from 'react' + +/** + * Modal: organisatorische Trainer-Zuweisung (Leitung + Co) für eine bestehende Einheit. + */ +export default function TrainingPlanningTrainerAssignModal({ + open, + unit, + leadTrainerProfileId, + onLeadChange, + sessionAssistantsInherit, + onSessionAssistantsInheritChange, + sessionAssistantProfileIds, + onCoTrainerToggle, + clubDirectory, + coTrainerOptions, + saving, + onBackdropRequestClose, + onCancel, + onSave, +}) { + if (!open || !unit) return null + + return ( +
{ + if (!saving) onBackdropRequestClose() + }} + > +
e.stopPropagation()} + style={{ + background: 'var(--surface)', + borderRadius: '12px', + padding: 'clamp(14px, 3vw, 1.75rem)', + maxWidth: 'min(460px, 100%)', + width: '100%', + maxHeight: '90vh', + overflowY: 'auto', + boxSizing: 'border-box', + }} + > +

+ Trainer zuweisen (organisatorisch) +

+

+ {(unit.planned_date || '').toString().slice(0, 10)} + {unit.planned_time_start ? ` · ${String(unit.planned_time_start).slice(0, 5)}` : ''} + {(unit.group_name || '').trim() ? ` · ${(unit.group_name || '').trim()}` : null} +

+
+ + +
+
+ +
+ {!sessionAssistantsInherit ? ( +
+ {coTrainerOptions.map((m) => { + const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10) + const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}` + const isOn = Number.isFinite(mid) && sessionAssistantProfileIds.includes(mid) + return ( + + ) + })} +
+ ) : null} + {!clubDirectory.length ? ( +

+ Mitgliederverzeichnis konnte nicht geladen werden. +

+ ) : null} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index f491942..2de2a15 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -11,6 +11,7 @@ import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerc import PageSectionNav from '../components/PageSectionNav' import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal' import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal' +import TrainingPlanningTrainerAssignModal from '../components/planning/TrainingPlanningTrainerAssignModal' import { defaultSection, normalizeUnitToForm, @@ -912,6 +913,42 @@ function TrainingPlanningPage() { } } + const handleAssignLeadSelectChange = useCallback((v) => { + setAssignDraft((prev) => { + const exclude = [] + const tr = String(v || '').trim() + if (tr !== '') { + const n = parseInt(tr, 10) + if (Number.isFinite(n)) exclude.push(n) + } else if (prev.unit?.effective_lead_trainer_profile_id != null) { + const ef = Number(prev.unit.effective_lead_trainer_profile_id) + if (Number.isFinite(ef)) exclude.push(ef) + } + const exSet = new Set(exclude) + const co = exclude.length + ? prev.session_assistant_profile_ids.filter((x) => !exSet.has(x)) + : prev.session_assistant_profile_ids + return { ...prev, lead_trainer_profile_id: v, session_assistant_profile_ids: co } + }) + }, []) + + const handleAssignAssistantsInheritChange = useCallback((checked) => { + setAssignDraft((prev) => ({ + ...prev, + session_assistants_inherit: checked, + })) + }, []) + + const handleAssignCoTrainerToggle = useCallback((mid) => { + setAssignDraft((prev) => { + const was = prev.session_assistant_profile_ids.includes(mid) + const nextIds = was + ? prev.session_assistant_profile_ids.filter((x) => x !== mid) + : [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b) + return { ...prev, session_assistant_profile_ids: nextIds } + }) + }, []) + const handleDelete = async (unit) => { if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return try { @@ -1821,178 +1858,24 @@ function TrainingPlanningPage() { )} - {assignModalOpen && assignDraft.unit ? ( -
{ - if (!assignSaving) setAssignModalOpen(false) - }} - > -
e.stopPropagation()} - style={{ - background: 'var(--surface)', - borderRadius: '12px', - padding: 'clamp(14px, 3vw, 1.75rem)', - maxWidth: 'min(460px, 100%)', - width: '100%', - maxHeight: '90vh', - overflowY: 'auto', - boxSizing: 'border-box', - }} - > -

- Trainer zuweisen (organisatorisch) -

-

- {(assignDraft.unit.planned_date || '').toString().slice(0, 10)} - {assignDraft.unit.planned_time_start - ? ` · ${String(assignDraft.unit.planned_time_start).slice(0, 5)}` - : ''} - {(assignDraft.unit.group_name || '').trim() - ? ` · ${(assignDraft.unit.group_name || '').trim()}` - : null} -

-
- - -
-
- -
- {!assignDraft.session_assistants_inherit ? ( -
- {clubDirectoryForAssignCo.map((m) => { - const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10) - const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}` - const isOn = Number.isFinite(mid) && assignDraft.session_assistant_profile_ids.includes(mid) - return ( - - ) - })} -
- ) : null} - {!clubDirectory.length ? ( -

- Mitgliederverzeichnis konnte nicht geladen werden. -

- ) : null} -
- - -
-
-
- ) : null} + { + if (!assignSaving) setAssignModalOpen(false) + }} + onCancel={() => setAssignModalOpen(false)} + onSave={saveTrainerAssignModal} + /> Date: Thu, 14 May 2026 15:40:45 +0200 Subject: [PATCH 06/34] chore(version): update version and changelog for release 0.8.130 - Bumped APP_VERSION to 0.8.130 and updated the changelog to reflect recent changes. - Fixed PUT/POST for training_units to handle assistant_trainer_profile_ids as JSONB using psycopg2.extras.Json, resolving a ProgrammingError during co-assignment. --- backend/routers/training_planning.py | 5 +++-- backend/version.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 461999e..dfa8fa8 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -1863,6 +1863,7 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ lead_ins, ) if assistant_set: + av_db = None if assistant_val is None else PsycopgJson(assistant_val) cur.execute( """ INSERT INTO training_units ( @@ -1874,7 +1875,7 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, - base_params + (assistant_val,), + base_params + (av_db,), ) else: cur.execute( @@ -2013,7 +2014,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen eff_lead_for_co, ) assist_sql = ", assistant_trainer_profile_ids = %s" - assist_params.append(na) + assist_params.append(None if na is None else PsycopgJson(na)) debrief_frag = "" if "debrief_completed" in data and not is_blueprint: diff --git a/backend/version.py b/backend/version.py index 9e7a6d1..013f24b 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.129" +APP_VERSION = "0.8.130" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -24,7 +24,7 @@ MODULE_VERSIONS = { "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak "training_programs": "0.1.0", - "planning": "0.9.4", # list_training_units: Keyset-Pagination + stabile Sortierung (NULLS LAST + id) + "planning": "0.9.5", # assistant_trainer_profile_ids: JSONB-Write mit PsycopgJson (Fix 500 bei Co-Liste) "dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine) "training_modules": "1.0.0", "import_wiki": "1.0.0", @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.130", + "date": "2026-05-13", + "changes": [ + "Fix: PUT/POST training_units — assistant_trainer_profile_ids als JSONB mit psycopg2.extras.Json schreiben (rohe Python-Liste → ProgrammingError/500 bei Co-Zuweisung).", + ], + }, { "version": "0.8.129", "date": "2026-05-13", From e09a2284e9fb1a8598a6408d5326ac945897fd29 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 16:02:54 +0200 Subject: [PATCH 07/34] chore(version): update version and changelog for release 0.8.131 - Bumped APP_VERSION to 0.8.131 and updated the changelog to reflect recent changes. - Added the TrainingPlanningUnitFormModal component to the TrainingPlanningPage for enhanced training unit management. - Refactored frameworkLineageText utility function for better code organization and reusability in the training planning context. - Updated BASELINE_SNAPSHOT documentation to include new metrics and logging details for k6 health checks. --- backend/version.py | 9 +- docs/architecture/BASELINE_SNAPSHOT.md | 16 + .../TrainingPlanningUnitFormModal.jsx | 485 ++++++++++++++++ frontend/src/pages/TrainingPlanningPage.jsx | 521 ++---------------- .../src/utils/trainingPlanningPageHelpers.js | 9 + 5 files changed, 561 insertions(+), 479 deletions(-) create mode 100644 frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx diff --git a/backend/version.py b/backend/version.py index 013f24b..7dc65a0 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.130" +APP_VERSION = "0.8.131" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.131", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3: TrainingPlanningUnitFormModal (Neu/Bearbeiten-Einheit); frameworkLineageText in trainingPlanningPageHelpers; BASELINE_SNAPSHOT §3.4 k6-Log-Mapping.", + ], + }, { "version": "0.8.130", "date": "2026-05-13", diff --git a/docs/architecture/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md index 0018eca..0b77b98 100644 --- a/docs/architecture/BASELINE_SNAPSHOT.md +++ b/docs/architecture/BASELINE_SNAPSHOT.md @@ -90,6 +90,22 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production). |----------|-------------------|------------------| | 10 VUs, 30 s `/health` | *—* | *nach Messung* | +### 3.4 Aus dem Deployment-/CI-Log übernehmen (k6 `k6-health-baseline`) + +Das Skript `scripts/load/k6-health-baseline.js` nutzt **10 VUs**, **30 s**, Ziel **`GET {BASE_URL}/health`** (siehe Workflow-Env für `BASE_URL`). + +**In die Tabelle oben (Abschnitt 3.3) eintragen — aus der k6-Zusammenfassung am Ende des Jobs:** + +| Feld in BASELINE_SNAPSHOT | Wo im k6-Log (typisch) | +|---------------------------|-------------------------| +| **p95** (Latenz ms) | Zeile **`http_req_duration`** → Wert **`p(95)=…`** (ganze Zahl oder ms mit Einheit wie `12.34ms`) | +| **Fehlerquote** | Zeile **`http_req_failed`** → z. B. `0.00%` bzw. `✓ 0%` — oder kurz „0 %“ notieren | +| **Checks** (optional) | Zeile **`checks`** → Anteil **`✓`** (soll **100 %** sein, sonst Hinweis) | +| **Datum / BASE_URL** | Deploy-Datum + die **öffentliche** Basis-URL des Laufs (wie im Workflow gesetzt, z. B. `https://dev.shinkan.jinkendo.de`) | +| **App-Version** (optional) | dieselbe wie im Deploy (`backend/version.py` / Release), damit M2-Vergleich ressortfähig bleibt | + +**Zusätzlich (Abschnitt 2.2):** nur die Zeile **`/health` GET`** mit dem **gleichen** p95 befüllen, wenn ihr dort noch Platzhalter habt — echte API-Routen (`/api/...`) kommen weiter aus Monitoring/k6 mit Auth, nicht aus diesem Job. + --- ## 4. Nächster Schritt (Roadmap) diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx new file mode 100644 index 0000000..fb61f7e --- /dev/null +++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx @@ -0,0 +1,485 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel' +import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor' +import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers' + +/** + * Großes Modal: Neue Trainingseinheit / Einheit bearbeiten (Planung, Trainer, Abschnitte, Durchführung, Notizen). + */ +export default function TrainingPlanningUnitFormModal({ + open, + editingUnit, + formData, + updateFormField, + setFormData, + onSubmit, + onCancel, + draftPlanTemplateId, + onDraftTemplateSelect, + planTemplates, + clubDirectory, + clubDirectoryForCo, + planningModalClubId, + user, + onMetaRefresh, + sectionsEditMode, + setSectionsEditMode, + onSaveAsTemplate, + onRequestTrainingModulePick, + onRequestExercisePick, + onPeekExercise, +}) { + if (!open) return null + + return ( +
+
+

+ {editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'} +

+ + {editingUnit?.origin_framework_slot_id + ? (() => { + const L = frameworkLineageText(editingUnit) + return ( +
+ Herkunft:{' '} + {editingUnit.origin_framework_program_id ? ( + + {L.fpTitle} + + ) : ( + L.fpTitle + )} + · {L.slotBit} +

+ Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese + geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten. +

+
+ ) + })() + : null} + + {!editingUnit && ( +
+ + +

+ Übernimmt nur die Sektionsstruktur aus der Bibliothek; Übungen trägst du unten bei den + Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern. +

+
+ )} + +
+

Planung

+ +
+
+ + updateFormField('planned_date', e.target.value)} + required + /> +
+ +
+ + updateFormField('planned_time_start', e.target.value)} + /> +
+ +
+ + updateFormField('planned_time_end', e.target.value)} + /> +
+
+ +
+ + updateFormField('planned_focus', e.target.value)} + placeholder="z.B. Grundlagen, Kinder altersgerecht" + /> +
+ +
+

Trainerzuordnung (diese Einheit)

+
+ + +

+ Für Vertretungen genügt in der Regel die Vereinsmitgliedschaft; Zuweisen dürfen u. a. Haupt-/Co‑Trainer + dieser Gruppe, der/die Ersteller:in der Einheit oder Vereinsadmins. +

+
+
+ +
+ {!formData.session_assistants_inherit ? ( +
+ {clubDirectoryForCo.map((m) => { + const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10) + const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}` + const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid) + return ( + + ) + })} +
+ ) : null} + {!clubDirectory.length ? ( +

+ Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne). +

+ ) : null} +
+ + + +
+ {editingUnit ? ( +
+
+ + Ablauf bearbeiten als + +
+ {[ + { id: 'planning', label: 'Planung' }, + { id: 'debrief', label: 'Nachbereitung' }, + ].map((opt, i) => ( + + ))} +
+
+

+ {sectionsEditMode === 'debrief' + ? 'Ist‑Minuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.' + : 'Ablauf, Übungen und geplante Minuten. Ist‑Werte und Abweichungen unter „Nachbereitung“.'} +

+
+ ) : null} + + + + } + sections={formData.sections} + wideExerciseGrid + onSectionsChange={(updater) => + setFormData((prev) => ({ + ...prev, + sections: updater(prev.sections), + })) + } + onRequestTrainingModulePick={onRequestTrainingModulePick} + onRequestExercisePick={onRequestExercisePick} + onPeekExercise={onPeekExercise} + showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'} + /> +
+ +
+ + {editingUnit && ( + <> +

Durchführung

+ +
+
+ + updateFormField('actual_date', e.target.value)} + /> +
+ +
+ + updateFormField('actual_time_start', e.target.value)} + /> +
+ +
+ + updateFormField('actual_time_end', e.target.value)} + /> +
+ +
+ + updateFormField('attendance_count', e.target.value)} + /> +
+
+ +
+ + +
+ + {formData.status === 'completed' ? ( +
+ +
+ ) : null} + + )} + +

Notizen

+ +
+ +