Enhance frontend testing setup and refactor TrainingPlanningPageRoot component
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 19s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m21s

- Added Vitest as a testing framework and included test scripts in package.json for improved testing capabilities.
- Refactored TrainingPlanningPageRoot component by removing unused state variables and imports, streamlining the code for better readability and performance.
- Introduced new utility functions for planning routes to enhance navigation within the training planning interface.
This commit is contained in:
Lars 2026-05-19 11:02:03 +02:00
parent 295c7e7efc
commit 16eaf839e7
17 changed files with 1854 additions and 786 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.148" APP_VERSION = "0.8.149"
BUILD_DATE = "2026-05-19" BUILD_DATE = "2026-05-19"
DB_SCHEMA_VERSION = "20260516065" DB_SCHEMA_VERSION = "20260516065"
@ -36,6 +36,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.149",
"date": "2026-05-19",
"changes": [
"Trainingsplanung: Einheiten-Editor als Vollseite (/planning/units/new|/:id/edit); Hub ohne Modal; Legacy ?unit= Redirect",
"Drift-Schutz: planningUnitRoutes.js, trainingUnitEditorCore.js, Vitest; Playwright 1415",
],
},
{ {
"version": "0.8.148", "version": "0.8.148",
"date": "2026-05-19", "date": "2026-05-19",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-14 **Stand:** 2026-05-19
**App-Version / DB-Schema:** App **`0.8.140`** (u. a. Planungs-Breakout-UI), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION` **App-Version / DB-Schema:** App **`0.8.149`** (Einheiten-Editor Vollseite), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -76,6 +76,13 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**. - **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`. - **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
### Trainingsplanung: Einheiten-Editor als Vollseite (Stand **0.8.149**)
- **Routen:** `/planning` (Hub), `/planning/units/new`, `/planning/units/:id/edit`; Legacy `/planning?unit={id}` → Redirect auf Edit-Route.
- **Code:** `TrainingUnitEditPage.jsx`, `TrainingUnitFormShell.jsx`, `planningUnitRoutes.js`, `trainingUnitEditorCore.js` (Payload-Drift-Schutz + Vitest).
- **Hub:** `TrainingPlanningPageRoot.jsx` ohne Einheiten-Modal; Modals nur noch Import, Trainer zuweisen, Rahmen-Session/Modul aus Liste.
- **Spec / DoD:** `docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md`; Playwright **1415** (Edit-Route, Legacy-Redirect).
### Trainingsplan: Phasen, parallele Streams und Coaching (Stand **0.8.1370.8.140**) ### Trainingsplan: Phasen, parallele Streams und Coaching (Stand **0.8.1370.8.140**)
- **Schema / API:** Migration **063**`training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` bzw. `parallel_stream_id`. **`GET /api/training-units/:id`** liefert **`phases`** (verschachtelt) und weiterhin flache **`sections`**. **`PUT/POST`** mit **`phases`** für Breakout-Einheiten (vgl. `CHANGELOG` **0.8.138**); Legacy: flache `sections` → implizite Ganzgruppen-Phase. - **Schema / API:** Migration **063**`training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` bzw. `parallel_stream_id`. **`GET /api/training-units/:id`** liefert **`phases`** (verschachtelt) und weiterhin flache **`sections`**. **`PUT/POST`** mit **`phases`** für Breakout-Einheiten (vgl. `CHANGELOG` **0.8.138**); Legacy: flache `sections` → implizite Ganzgruppen-Phase.

View File

@ -0,0 +1,126 @@
# Migration: Trainings-Einheit — Modal → Vollseiten-Editor
**Status:** Phase C abgeschlossen (Hub + Edit-Route produktiv)
**Stand:** 2026-05-19
**Bezug:** Architektur-Schuld A1 (`SCHULDEN_UND_REMEDIATION.md`), UX-Grundsatz Modals vs. Vollseiten
---
## 1. Ausgangslage
| Aspekt | Ist (Modal) | Soll (Vollseite) |
|--------|-------------|------------------|
| Bearbeiten / Neu | `TrainingPlanningUnitFormModal` über `TrainingPlanningPageRoot` | `TrainingUnitEditPage` unter eigener Route |
| Hub | Liste + Kalender auf `/planning` | unverändert — nur Übersicht & Kurzaktionen |
| Deep-Link | `/planning?unit={id}` (öffnet Modal, Query wird entfernt) | `/planning/units/{id}/edit` (bookmarkbar) |
| Vergleich | Rahmenprogramm, Modul, Vorlage, Run/Coach | gleiches Muster |
**Warum Modal historisch:** Frontend Phase 3 (0.8.131) — Extraktion aus God-Page, **kein** bewusstes UX-Zielbild.
---
## 2. Zielbild (verbindlich)
### 2.1 Routen
| Route | Zweck |
|-------|--------|
| `/planning` | Hub: Gruppe, Liste/Kalender, Import, Zuweisen, Löschen, Links „Bearbeiten“ |
| `/planning/units/new` | Neue Einheit (Query: `group`, `date`, optional `template`) |
| `/planning/units/:id/edit` | Bestehende Einheit bearbeiten (Query optional: `mode=debrief`) |
| `/planning/run/:unitId` | Durchführung (unverändert) |
| `/planning/run/:unitId/coach` | Coaching (unverändert) |
Implementierung: `frontend/src/utils/planningUnitRoutes.js`**einzige** Quelle für Pfade und Rückkehr-Kontext (Drift-Schutz).
### 2.2 Was Modal bleibt (Hub + Editor)
| Dialog | Ort | Begründung |
|--------|-----|------------|
| Rahmen-Import | Hub | Mehrfachauswahl + Datums-Vorschläge |
| Trainer zuweisen | Hub | Kurzaktion aus Liste/Kalender |
| Rahmen-Session / Modul aus Liste | Hub | Aktion auf gespeicherter Einheit ohne Editor |
| Übungspicker, Modul einfügen, Peek | **Editor-Seite** | Kontext des Ablaufs |
| Rahmen übernehmen / Modul aus Editor | **Editor-Seite** | Nach Speichern des Ablaufs |
| Kombi-Ablauf bearbeiten | SectionsEditor (Sheet) | Fokussierter Sub-Dialog |
### 2.3 UI-Shell Editor
- `TrainingUnitFormShell.jsx` — reine Formular-UI (ohne Overlay), `page-form-shell` + `FormActionBar` (`variant="page"`).
- Kein `FormModalOverlay` / kein Scroll-Lock auf der Editor-Seite nötig.
### 2.4 Rückkehr zum Hub
Beim Navigieren vom Hub zum Editor wird `location.state.planningReturn` gesetzt (Gruppe, Ansicht, Monat, Datumsfilter).
Abbrechen / Speichern & schließen → `/planning?…` mit wiederhergestellten Query-Parametern.
Legacy: `/planning?unit={id}` → Redirect auf `/planning/units/{id}/edit` (301/Replace via Router).
---
## 3. Code-Struktur (Ziel)
```
frontend/src/
├── utils/
│ ├── planningUnitRoutes.js # Pfade, Return-State, Legacy-Redirect
│ ├── planningUnitRoutes.test.js # Vitest
│ └── trainingUnitEditorCore.js # Reine Payload-/Form-Helfer
│ └── trainingUnitEditorCore.test.js
├── hooks/
│ └── useTrainingUnitEditor.js # Laden, Speichern, Form-State (Edit-Page)
├── components/planning/
│ ├── TrainingUnitFormShell.jsx # Formular-UI (ex Modal-Inhalt)
│ └── TrainingPlanningPageRoot.jsx # Hub only (~ weniger State)
└── pages/
└── TrainingUnitEditPage.jsx # Route-Container
```
**Soft-Limit (S1):** `TrainingUnitEditPage.jsx` delegiert an Hook + Shell; PageRoot verliert Modal-State.
---
## 4. Phasen & Abnahme
| Phase | Inhalt | Abnahme / Tests |
|-------|--------|-----------------|
| **A** | Doku, `planningUnitRoutes`, `trainingUnitEditorCore`, Vitest | `npm run test --prefix frontend` grün |
| **B** | `TrainingUnitFormShell`, `TrainingUnitEditPage`, Routen, Hub-Navigation, Modal entfernen | Playwright 1415; Build grün |
| **C** | Hub liest Return-Query; Dashboard-Links direkt auf Edit-Route; PageRoot weiter entschlacken | Manuell + E2E |
| **D** (optional) | `useTrainingUnitEditor` weiter modularisieren; ungespeichert-Blocker wie Modul-Edit | S8 Checkliste |
### Definition of Done (Phase B)
- [x] Kein `TrainingPlanningUnitFormModal` mehr in Produktionspfad
- [x] `data-testid="planning-unit-form"` auf Editor-Seite
- [x] Speichern sendet identisches Payload wie zuvor (`buildTrainingUnitSavePayload`)
- [x] Split-Sessions / `phases`-PUT unverändert (`buildPlanPayloadForSave`)
- [x] Playwright 1213 weiter grün; 1415 neu
- [x] `docs/HANDOVER.md` + Roadmap aktualisiert
---
## 5. Risiken & Mitigation
| Risiko | Mitigation |
|--------|------------|
| Payload-Drift beim Speichern | Logik in `trainingUnitEditorCore.js` + Unit-Tests |
| Kontextverlust Hub | `planningReturn` in `planningUnitRoutes.js` + Hub-Query-Restore |
| Modul-Einfügen / Picker | Nur auf Edit-Page; gleiche Handler wie zuvor |
| Doppelte Einträge Deep-Link | Legacy-Redirect; Dashboard später auf neue URL |
| Große Edit-Page | Hook + Shell; PageRoot schrumpft |
---
## 6. Nicht-Ziele (dieser Sprint)
- Backend-API-Änderungen
- Virtualisierung der Einheitenliste
- Refactor `TrainingUnitSectionsEditor`
- Coach/Run-Flows
---
## 7. Pflege
Nach Abschluss Phase B: Eintrag in `UMSETZUNGSPLAN_ROADMAP.md` (Phase 3 Nachzug), `SCHULDEN_UND_REMEDIATION.md` A1 Fortschritt, `backend/version.py` CHANGELOG.

View File

@ -5,7 +5,9 @@
"scripts": { "scripts": {
"dev": "vite --port 3098", "dev": "vite --port 3098",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
@ -20,6 +22,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"vite": "^5.1.4" "vite": "^5.1.4",
"vitest": "^3.0.5"
} }
} }

View File

@ -42,6 +42,7 @@ const TrainingFrameworkProgramEditPage = lazy(() =>
const TrainingModulesListPage = lazy(() => import('./pages/TrainingModulesListPage')) const TrainingModulesListPage = lazy(() => import('./pages/TrainingModulesListPage'))
const TrainingModuleEditPage = lazy(() => import('./pages/TrainingModuleEditPage')) const TrainingModuleEditPage = lazy(() => import('./pages/TrainingModuleEditPage'))
const TrainingUnitRunPage = lazy(() => import('./pages/TrainingUnitRunPage')) const TrainingUnitRunPage = lazy(() => import('./pages/TrainingUnitRunPage'))
const TrainingUnitEditPage = lazy(() => import('./pages/TrainingUnitEditPage'))
const TrainingCoachPage = lazy(() => import('./pages/TrainingCoachPage')) const TrainingCoachPage = lazy(() => import('./pages/TrainingCoachPage'))
const AdminCatalogsPage = lazy(() => import('./pages/AdminCatalogsPage')) const AdminCatalogsPage = lazy(() => import('./pages/AdminCatalogsPage'))
const AdminHierarchyPage = lazy(() => import('./pages/AdminHierarchyPage')) const AdminHierarchyPage = lazy(() => import('./pages/AdminHierarchyPage'))
@ -234,6 +235,8 @@ const appRouter = createBrowserRouter([
{ path: 'framework-programs', element: <TrainingFrameworkProgramsListPage /> }, { path: 'framework-programs', element: <TrainingFrameworkProgramsListPage /> },
{ path: 'training-modules', element: <TrainingModulesListPage /> }, { path: 'training-modules', element: <TrainingModulesListPage /> },
{ path: 'plan-templates', element: <TrainingPlanTemplatesListPage /> }, { path: 'plan-templates', element: <TrainingPlanTemplatesListPage /> },
{ path: 'units/new', element: <TrainingUnitEditPage /> },
{ path: 'units/:id/edit', element: <TrainingUnitEditPage /> },
], ],
}, },
{ path: 'planning/framework-programs/new', element: <TrainingFrameworkProgramEditPage /> }, { path: 'planning/framework-programs/new', element: <TrainingFrameworkProgramEditPage /> },

View File

@ -268,7 +268,7 @@ export default function DashboardTrainingVisibilityWidget({ user }) {
}} }}
> >
<Link <Link
to={`/planning?unit=${first.id}`} to={`/planning/units/${first.id}/edit`}
className="btn-ghost" className="btn-ghost"
style={{ style={{
padding: '2px 4px', padding: '2px 4px',

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,507 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import FormActionBar from '../FormActionBar'
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
import { activeClubMemberships } from '../../utils/activeClub'
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
/**
* Vollseiten-Formular: Trainingseinheit planen / nachbereiten (ohne Modal-Overlay).
*/
export default function TrainingUnitFormShell({
editingUnit,
formData,
updateFormField,
setFormData,
onSaveOnly,
onSaveAndClose,
onCancel,
draftPlanTemplateId,
onDraftTemplateSelect,
planTemplates,
clubDirectory,
clubDirectoryForCo,
planningClubId,
user,
onMetaRefresh,
sectionsEditMode,
setSectionsEditMode,
onSaveAsTemplate,
onRequestPublishToFramework,
onRequestSaveAsModule,
onRequestTrainingModulePick,
onRequestExercisePick,
onPeekExercise,
saving = false,
formId = 'planning-unit-form',
}) {
const [newTplVisibility, setNewTplVisibility] = useState('private')
const [newTplClubId, setNewTplClubId] = useState('')
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const roleLc = String(user?.role || '').toLowerCase()
const isSuperadmin = roleLc === 'superadmin'
useEffect(() => {
if (planningClubId != null && planningClubId !== '') {
setNewTplClubId(String(planningClubId))
} else if (memberClubs.length === 1) {
setNewTplClubId(String(memberClubs[0].id))
}
}, [planningClubId, memberClubs])
return (
<div data-testid="planning-unit-form">
<h1 className="page-title" style={{ marginBottom: '1rem' }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h1>
<form
id={formId}
className="card page-form-shell"
style={{ padding: 'clamp(14px, 3vw, 1.75rem)' }}
onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSaveOnly?.(e))}
>
<div className="page-form-shell__scroll">
{editingUnit?.origin_framework_slot_id
? (() => {
const L = frameworkLineageText(editingUnit)
return (
<div
className="card"
style={{
marginBottom: '1.1rem',
padding: '12px 14px',
background: 'var(--surface2)',
fontSize: '0.9rem',
lineHeight: 1.5,
}}
>
<strong style={{ color: 'var(--text1)' }}>Herkunft:</strong>{' '}
{editingUnit.origin_framework_program_id ? (
<Link
to={`/planning/framework-programs/${editingUnit.origin_framework_program_id}`}
style={{ color: 'var(--accent-dark)' }}
>
{L.fpTitle}
</Link>
) : (
L.fpTitle
)}
<span style={{ color: 'var(--text2)' }}> · {L.slotBit}</span>
<p style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
</p>
</div>
)
})()
: null}
{!editingUnit && (
<div className="training-planning-template-panel" style={{ marginBottom: '1.35rem' }}>
<label className="form-label training-planning-template-panel__label" htmlFor="planning-draft-template">
Vorlage für den Ablauf
</label>
<select
id="planning-draft-template"
className="form-input training-planning-template-panel__select"
value={draftPlanTemplateId}
onChange={(e) => onDraftTemplateSelect(e.target.value)}
>
<option value="">Ohne Vorlage leere Gliederung (ein Abschnitt)</option>
{planTemplates.map((t) => {
const v = String(t.visibility || 'club').toLowerCase()
const vLabel = v === 'private' ? 'Privat' : v === 'official' ? 'Offiziell' : 'Verein'
return (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''} · {vLabel}
</option>
)
})}
</select>
<p className="training-planning-template-panel__help">
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
Abschnitten ein. Vorlagen verwaltest du unter{' '}
<Link to="/planning/plan-templates">Planung Vorlagen</Link>.
</p>
</div>
)}
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Datum *</label>
<input
type="date"
className="form-input"
value={formData.planned_date}
onChange={(e) => updateFormField('planned_date', e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.planned_time_start}
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.planned_time_end}
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Trainingsfokus</label>
<input
type="text"
className="form-input"
value={formData.planned_focus}
onChange={(e) => updateFormField('planned_focus', e.target.value)}
placeholder="z.B. Grundlagen, Kinder altersgerecht"
/>
</div>
<div
className="card"
style={{
marginTop: '1.25rem',
marginBottom: '0.25rem',
padding: '12px 14px',
background: 'var(--surface2)',
}}
>
<h3 style={{ margin: '0 0 10px', fontSize: '1rem' }}>Trainerzuordnung (diese Einheit)</h3>
<div className="form-row">
<label className="form-label">Leitung</label>
<select
className="form-input"
value={formData.lead_trainer_profile_id}
onChange={(e) => updateFormField('lead_trainer_profile_id', e.target.value)}
disabled={!editingUnit && !formData.group_id}
>
<option value="">Standard (Haupttrainer der Gruppe)</option>
{clubDirectory.map((m) => (
<option key={String(m.id)} value={String(m.id)}>
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.session_assistants_inherit}
onChange={(e) => updateFormField('session_assistants_inherit', e.target.checked)}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
Co-Trainer wie in der Trainingsgruppe (Standard)
</span>
</label>
</div>
{!formData.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
{clubDirectoryForCo.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const isOn =
Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
return (
<label
key={`co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={isOn}
onChange={() => {
setFormData((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 }
})
}}
/>
<span>{(m.name || '').trim() || m.email || `Profil ${mid}`}</span>
</label>
)
})}
</div>
) : null}
</div>
<TrainingPlanExerciseVisibilityPanel
sections={formData.sections}
targetClubId={planningClubId}
user={user}
onMetaRefresh={onMetaRefresh}
/>
<div style={{ marginTop: '2rem' }}>
{editingUnit ? (
<div style={{ marginBottom: '1rem' }}>
<div
role="radiogroup"
aria-label="Modus für Abschnitte und Übungen"
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '10px' }}
>
<span className="form-label" style={{ marginBottom: 0, fontSize: '0.82rem' }}>
Ablauf bearbeiten als
</span>
<div
style={{
display: 'inline-flex',
borderRadius: '10px',
border: '1.5px solid var(--border2)',
overflow: 'hidden',
background: 'var(--surface2)',
}}
>
{[
{ id: 'planning', label: 'Planung' },
{ id: 'debrief', label: 'Nachbereitung' },
].map((opt, i) => (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={sectionsEditMode === opt.id}
onClick={() => setSectionsEditMode(opt.id)}
style={{
border: 'none',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: 'pointer',
background: sectionsEditMode === opt.id ? 'var(--accent-dark)' : 'transparent',
color: sectionsEditMode === opt.id ? '#fff' : 'var(--text1)',
...(i > 0 ? { borderLeft: '1.5px solid var(--border2)' } : {}),
}}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
) : null}
<TrainingUnitSectionsEditor
heading="Abschnitte & Übungen"
headingAccessory={
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-end',
gap: '10px',
marginBottom: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0, minWidth: 'min(160px, 100%)' }}>
<label className="form-label" style={{ fontSize: '0.82rem' }}>
Neue Vorlage: Sichtbarkeit
</label>
<select
className="form-input"
value={newTplVisibility}
onChange={(e) => {
const v = e.target.value
setNewTplVisibility(v)
if (v === 'club' && !newTplClubId && planningClubId != null) {
setNewTplClubId(String(planningClubId))
}
}}
>
<option value="private">Privat (nur du)</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell (global)</option> : null}
</select>
</div>
{newTplVisibility === 'club' ? (
<div className="form-row" style={{ marginBottom: 0, flex: '1 1 200px' }}>
<label className="form-label" style={{ fontSize: '0.82rem' }}>
Verein
</label>
<select
className="form-input"
value={newTplClubId}
onChange={(e) => setNewTplClubId(e.target.value)}
>
<option value=""> Verein wählen </option>
{memberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</div>
) : null}
<button
type="button"
className="btn btn-secondary"
onClick={() =>
onSaveAsTemplate?.({
visibility: newTplVisibility,
club_id:
newTplVisibility === 'club' && newTplClubId
? parseInt(newTplClubId, 10)
: null,
})
}
>
Vorlage aus Aufbau speichern
</button>
{editingUnit?.id && !editingUnit?.framework_slot_id ? (
<>
<button type="button" className="btn btn-secondary" onClick={() => onRequestPublishToFramework?.()}>
Als Rahmen-Session speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => onRequestSaveAsModule?.()}>
Übungen als Modul
</button>
</>
) : null}
</div>
}
sections={formData.sections}
wideExerciseGrid
onSectionsChange={(updater) =>
setFormData((prev) => ({ ...prev, sections: updater(prev.sections) }))
}
onRequestTrainingModulePick={onRequestTrainingModulePick}
onRequestExercisePick={onRequestExercisePick}
onPeekExercise={onPeekExercise}
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
enableParallelPhaseControls
/>
</div>
{editingUnit ? (
<>
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Durchführung</h3>
<div className="responsive-grid-4" style={{ marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Tatsächliches Datum</label>
<input
type="date"
className="form-input"
value={formData.actual_date}
onChange={(e) => updateFormField('actual_date', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.actual_time_start}
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.actual_time_end}
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Teilnehmer</label>
<input
type="number"
className="form-input"
value={formData.attendance_count}
onChange={(e) => updateFormField('attendance_count', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="planned">Geplant</option>
<option value="completed">Durchgeführt</option>
<option value="cancelled">Abgesagt</option>
</select>
</div>
{formData.status === 'completed' ? (
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!formData.debrief_completed}
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
style={{ marginTop: '3px' }}
/>
<span>
<strong>Rückschau erledigt</strong>
</span>
</label>
</div>
) : null}
</>
) : null}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
<div className="form-row">
<label className="form-label">Öffentliche Notizen</label>
<textarea
className="form-input"
rows={3}
value={formData.notes}
onChange={(e) => updateFormField('notes', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Trainernotizen</label>
<textarea
className="form-input"
rows={3}
value={formData.trainer_notes}
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
/>
</div>
</div>
<FormActionBar
placement="bottom"
variant="page"
formId={formId}
saving={saving}
isNew={!editingUnit}
onSave={onSaveOnly ? () => onSaveOnly() : undefined}
onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined}
onCancel={onCancel}
showSave={Boolean(onSaveOnly)}
showSaveAndClose
/>
</form>
</div>
)
}

View File

@ -266,7 +266,7 @@ function Dashboard() {
<ul className="dashboard-preview-card__list"> <ul className="dashboard-preview-card__list">
{trainingHome.reviewPending.map((u) => ( {trainingHome.reviewPending.map((u) => (
<li key={`r-${u.id}`}> <li key={`r-${u.id}`}>
<Link to={`/planning?unit=${u.id}`} className="dashboard-preview-card__link"> <Link to={`/planning/units/${u.id}/edit`} className="dashboard-preview-card__link">
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'} {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
</Link> </Link>
{u.group_name ? ( {u.group_name ? (

View File

@ -134,7 +134,7 @@ function MediaUsageBlock({ usage, compact }) {
[t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}` [t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}`
const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}` : label const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}` : label
return ( return (
<Link key={t.id} to={`/planning?unit=${t.id}`} title={label}> <Link key={t.id} to={`/planning/units/${t.id}/edit`} title={label}>
{short} {short}
</Link> </Link>
) )

View File

@ -0,0 +1,709 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitFormShell from '../components/planning/TrainingUnitFormShell'
import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
import TrainingPublishToFrameworkModal from '../components/planning/TrainingPublishToFrameworkModal'
import SaveExercisesAsModuleModal from '../components/planning/SaveExercisesAsModuleModal'
import {
defaultSection,
enrichSectionsWithVariants,
formSectionsFromPlanTemplateRows,
hydrateExercisePlanningRow,
insertTrainingModuleIntoPlanningSections,
normalizeUnitToForm,
templateSectionsPayloadFromFormSections,
} from '../utils/trainingUnitSectionsForm'
import {
filterDirectoryExcludingLead,
} from '../utils/trainingPlanningPageHelpers'
import {
createEmptyTrainingUnitFormData,
buildTrainingUnitSavePayload,
trainingUnitToFormFields,
validateTrainingUnitFormForSave,
} from '../utils/trainingUnitEditorCore'
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
export default function TrainingUnitEditPage() {
const { id: routeId } = useParams()
const isNew = !routeId || routeId === 'new'
const unitId = !isNew ? parseInt(routeId, 10) : NaN
const navigate = useNavigate()
const location = useLocation()
const [searchParams] = useSearchParams()
const { user } = useAuth()
const toast = useToast()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [groups, setGroups] = useState([])
const [planTemplates, setPlanTemplates] = useState([])
const [editingUnit, setEditingUnit] = useState(null)
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
const [clubDirectory, setClubDirectory] = useState([])
const [formData, setFormData] = useState(() => createEmptyTrainingUnitFormData())
const formRef = useRef(formData)
formRef.current = formData
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
const [moduleApplyOpen, setModuleApplyOpen] = useState(false)
const [moduleApplyBusy, setModuleApplyBusy] = useState(false)
const [moduleApplyList, setModuleApplyList] = useState([])
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0')
const [moduleApplyErr, setModuleApplyErr] = useState('')
const [moduleApplyPlacementLocked, setModuleApplyPlacementLocked] = useState(false)
const [moduleApplySearchQuery, setModuleApplySearchQuery] = useState('')
const [modulePickPreview, setModulePickPreview] = useState({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false)
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
const goBack = useCallback(() => {
navigate(planningHubPathFromReturnState(location.state?.planningReturn))
}, [location.state, navigate])
const planningClubId = useMemo(() => {
const gid = Number(formData.group_id)
if (!Number.isFinite(gid) || gid < 1) return null
const g = groups.find((x) => Number(x.id) === gid)
if (!g?.club_id) return null
const c = Number(g.club_id)
return Number.isFinite(c) ? c : null
}, [groups, formData.group_id])
const moduleApplyFilteredList = useMemo(() => {
const q = moduleApplySearchQuery.trim().toLowerCase().replace(/\s+/g, ' ')
const words = q ? q.split(' ').filter(Boolean) : []
const list = Array.isArray(moduleApplyList) ? moduleApplyList : []
if (!words.length) return list
return list.filter((m) => {
const blob = [m.title, m.summary, m.goal, m.target_group_notes, m.deployment_context_notes]
.map((x) => String(x ?? '').toLowerCase())
.join('\n')
return words.every((w) => blob.includes(w))
})
}, [moduleApplySearchQuery, moduleApplyList])
const modulePlacementSummary = useMemo(() => {
const secs = Array.isArray(formData.sections) ? formData.sections : []
let si =
typeof moduleApplySectionIx === 'number'
? moduleApplySectionIx
: parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(si)) si = 0
si = Math.max(0, Math.min(si, secs.length ? secs.length - 1 : 0))
const cap = secs[si]?.items?.length ?? 0
let beforeIx = cap
if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
if (Number.isFinite(zi)) beforeIx = Math.min(Math.max(0, zi), cap)
}
const rawTitle = (secs[si]?.title || '').trim()
const secTitle = rawTitle || `Abschnitt ${si + 1}`
let positionDescription
if (cap <= 0) positionDescription = 'als erste Einträge dieses Abschnitts'
else if (beforeIx <= 0) positionDescription = 'vor dem ersten Eintrag dieses Abschnitts'
else if (beforeIx >= cap) positionDescription = 'nach dem letzten Eintrag dieses Abschnitts'
else positionDescription = `unmittelbar vor Eintrag ${beforeIx + 1} (${cap} Einträge im Abschnitt)`
return { secTitle, positionDescription }
}, [formData.sections, moduleApplySectionIx, moduleApplyInsertSlot])
const moduleApplyTargetItems = useMemo(() => {
const secs = formData.sections || []
if (!secs.length) return []
let ix =
typeof moduleApplySectionIx === 'number'
? moduleApplySectionIx
: parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(ix)) ix = 0
if (ix < 0 || ix >= secs.length) return []
const sec = secs[ix]
return Array.isArray(sec?.items) ? sec.items : []
}, [formData.sections, moduleApplySectionIx])
useEffect(() => {
if (!moduleApplyOpen || !moduleApplyFilteredList.length) return
if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return
setModuleApplyModuleId(String(moduleApplyFilteredList[0].id))
}, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId])
useEffect(() => {
if (!moduleApplyOpen) {
setModulePickPreview({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
return undefined
}
const mid = parseInt(String(moduleApplyModuleId), 10)
if (!Number.isFinite(mid) || mid < 1) {
setModulePickPreview({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
return undefined
}
let cancelled = false
setModulePickPreview({
loading: true,
moduleId: String(mid),
exercises: [],
notes: 0,
err: '',
})
;(async () => {
try {
const detail = await api.getTrainingModule(mid)
if (cancelled) return
const itemsSorted = [...(detail.items ?? [])].sort(
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
)
const uniqueEx = new Set()
let notes = 0
for (const row of itemsSorted) {
if ((row.item_type || '') !== 'note') {
const eid = row.exercise_id
if (eid) uniqueEx.add(Number(eid))
continue
}
const b = String(row.note_body ?? '').trim()
if (b === '---') continue
notes += 1
}
const titleById = new Map()
await Promise.all(
[...uniqueEx].map(async (eid) => {
try {
const ex = await api.getExercise(eid)
titleById.set(eid, (ex?.title || '').trim() || `Übung #${eid}`)
} catch {
titleById.set(eid, `Übung #${eid}`)
}
})
)
if (cancelled) return
const exTitlesInOrder = []
for (const row of itemsSorted) {
if ((row.item_type || '') !== 'exercise') continue
const eid = Number(row.exercise_id)
if (!Number.isFinite(eid)) continue
exTitlesInOrder.push(titleById.get(eid) || `Übung #${eid}`)
}
setModulePickPreview({
loading: false,
moduleId: String(mid),
exercises: exTitlesInOrder,
notes,
err: '',
})
} catch (e) {
if (!cancelled) {
setModulePickPreview({
loading: false,
moduleId: String(mid),
exercises: [],
notes: 0,
err: e?.message || 'Vorschau fehlgeschlagen',
})
}
}
})()
return () => {
cancelled = true
}
}, [moduleApplyOpen, moduleApplyModuleId])
useEffect(() => {
if (planningClubId == null) {
setClubDirectory([])
return undefined
}
let cancelled = false
;(async () => {
try {
const d = await api.clubMembersDirectory(planningClubId)
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
} catch {
if (!cancelled) setClubDirectory([])
}
})()
return () => {
cancelled = true
}
}, [planningClubId])
const clubDirectoryForCo = useMemo(() => {
let exclude = null
const leadTrim = String(formData.lead_trainer_profile_id || '').trim()
if (leadTrim) {
const n = parseInt(leadTrim, 10)
if (Number.isFinite(n)) exclude = n
} else if (editingUnit?.effective_lead_trainer_profile_id != null) {
exclude = Number(editingUnit.effective_lead_trainer_profile_id)
} else {
const gid = parseInt(formData.group_id || '0', 10)
const g = groups.find((gr) => gr.id === gid)
if (g?.trainer_id != null) exclude = Number(g.trainer_id)
}
return filterDirectoryExcludingLead(clubDirectory, exclude)
}, [clubDirectory, formData.lead_trainer_profile_id, formData.group_id, editingUnit, groups])
const loadCatalogs = useCallback(async () => {
const [groupsData, tpl] = await Promise.all([
api.listTrainingGroups({ status: 'active' }),
api.listTrainingPlanTemplates(),
])
setGroups(Array.isArray(groupsData) ? groupsData : [])
setPlanTemplates(Array.isArray(tpl) ? tpl : [])
return Array.isArray(groupsData) ? groupsData : []
}, [])
useEffect(() => {
let cancelled = false
async function init() {
setLoading(true)
try {
const groupsData = await loadCatalogs()
if (cancelled) return
if (isNew) {
const qGroup = searchParams.get('group') || ''
const qDate = searchParams.get('date') || new Date().toISOString().slice(0, 10)
const qTemplate = searchParams.get('template') || ''
const gid =
qGroup ||
(groupsData.length === 1 ? String(groupsData[0].id) : '') ||
(groupsData.find((g) => g.trainer_id === user?.id)
? String(groupsData.find((g) => g.trainer_id === user?.id).id)
: '')
const group = groupsData.find((g) => String(g.id) === String(gid))
setEditingUnit(null)
setDraftPlanTemplateId(qTemplate)
setSectionsEditMode(searchParams.get('mode') === 'debrief' ? 'debrief' : 'planning')
setFormData(
createEmptyTrainingUnitFormData({
groupId: gid,
plannedDate: qDate,
timeStart: group?.time_start?.slice(0, 5) || '',
timeEnd: group?.time_end?.slice(0, 5) || '',
})
)
if (qTemplate) {
const tpl = await api.getTrainingPlanTemplate(parseInt(qTemplate, 10))
if (!cancelled && tpl?.sections?.length) {
setFormData((fd) => ({
...fd,
sections: formSectionsFromPlanTemplateRows(tpl.sections),
}))
}
}
} else if (Number.isFinite(unitId)) {
const fullUnit = await api.getTrainingUnit(unitId)
if (cancelled) return
setEditingUnit(fullUnit)
setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '')
let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections)
if (cancelled) return
setFormData(trainingUnitToFormFields(fullUnit, sections))
const modeParam = searchParams.get('mode')
setSectionsEditMode(
modeParam === 'debrief' || fullUnit.status === 'completed' ? 'debrief' : 'planning'
)
}
} catch (e) {
if (!cancelled) toast.error(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
}
init()
return () => {
cancelled = true
}
}, [isNew, unitId, searchParams, loadCatalogs, user?.id, tenantClubDepKey, toast])
const updateFormField = useCallback(
(field, value) => {
setFormData((prev) => {
if (field !== 'lead_trainer_profile_id') {
const patch = { ...prev, [field]: value }
if (field === 'status' && value !== 'completed') patch.debrief_completed = false
return patch
}
const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim()
const strip = new Set()
if (ts !== '') {
const nid = parseInt(ts, 10)
if (Number.isFinite(nid)) strip.add(nid)
} else {
const gidParsed = parseInt(prev.group_id || '0', 10)
const gr =
Number.isFinite(gidParsed) && gidParsed >= 1
? groups.find((xg) => xg.id === gidParsed)
: null
if (gr?.trainer_id != null) {
const ht = Number(gr.trainer_id)
if (Number.isFinite(ht)) strip.add(ht)
}
}
return {
...prev,
lead_trainer_profile_id: value,
session_assistant_profile_ids: prev.session_assistant_profile_ids.filter((id) => !strip.has(id)),
}
})
},
[groups]
)
const applyTemplateFromSelect = async (templateId) => {
setDraftPlanTemplateId(templateId)
if (!templateId) return
try {
const tpl = await api.getTrainingPlanTemplate(parseInt(templateId, 10))
setFormData((fd) => ({
...fd,
sections: (tpl.sections || []).length
? formSectionsFromPlanTemplateRows(tpl.sections)
: [defaultSection()],
}))
} catch (err) {
toast.error('Vorlage laden: ' + err.message)
}
}
const reloadUnitAfterSave = async (savedId) => {
const fullUnit = await api.getTrainingUnit(savedId)
setEditingUnit(fullUnit)
let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections)
setFormData(trainingUnitToFormFields(fullUnit, sections))
if (!isNew && savedId !== unitId) {
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
}
}
const handleSubmit = async (e, { closeAfter = true } = {}) => {
e?.preventDefault?.()
const v = validateTrainingUnitFormForSave(formData)
if (!v.ok) {
toast.error(v.message)
return false
}
setSaving(true)
try {
const payload = buildTrainingUnitSavePayload(formData, {
editingUnit,
draftPlanTemplateId,
})
let savedUnit
if (editingUnit) {
savedUnit = await api.updateTrainingUnit(editingUnit.id, payload)
} else {
savedUnit = await api.createTrainingUnit(payload)
}
toast.success('Gespeichert.')
if (closeAfter) {
goBack()
} else if (savedUnit?.id) {
await reloadUnitAfterSave(savedUnit.id)
}
return true
} catch (err) {
toast.error('Fehler beim Speichern: ' + err.message)
return false
} finally {
setSaving(false)
}
}
const handleSaveAsTemplate = async (opts = {}) => {
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
if (!name?.trim()) return
const descRaw = window.prompt('Kurzbeschreibung (optional, leer lassen zum Überspringen):')
const visibility =
typeof opts.visibility === 'string' && opts.visibility.trim()
? String(opts.visibility).trim().toLowerCase()
: 'private'
let club_id = opts.club_id != null && opts.club_id !== '' ? Number(opts.club_id) : null
if (visibility === 'club') {
if (!Number.isFinite(club_id) || club_id < 1) club_id = planningClubId
if (!Number.isFinite(club_id) || club_id < 1) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
return
}
} else {
club_id = null
}
try {
await api.createTrainingPlanTemplate({
name: name.trim(),
description: descRaw?.trim() ? descRaw.trim() : null,
visibility,
club_id: visibility === 'club' ? club_id : null,
sections: templateSectionsPayloadFromFormSections(formData.sections),
})
toast.success('Vorlage gespeichert.')
} catch (err) {
toast.error('Speichern: ' + err.message)
}
}
const openModuleApplyModal = useCallback(async (placement) => {
setModuleApplyErr('')
setModuleApplySearchQuery('')
const placementLocked =
placement != null &&
typeof placement.sectionIndex === 'number' &&
typeof placement.insertBeforeIndex === 'number'
setModuleApplyPlacementLocked(placementLocked)
const secs = formRef.current?.sections ?? []
let secIx = 0
let before = 0
if (secs.length) {
if (placement && typeof placement.sectionIndex === 'number') {
secIx = Math.min(Math.max(0, placement.sectionIndex), secs.length - 1)
const items = Array.isArray(secs[secIx]?.items) ? secs[secIx].items : []
before =
typeof placement.insertBeforeIndex === 'number'
? Math.min(Math.max(0, placement.insertBeforeIndex), items.length)
: items.length
} else {
before = Array.isArray(secs[0]?.items) ? secs[0].items.length : 0
}
}
setModuleApplySectionIx(secIx)
setModuleApplyInsertSlot(`before:${before}`)
setModuleApplyOpen(true)
try {
const list = await api.listTrainingModules()
const arr = Array.isArray(list) ? list : []
setModuleApplyList(arr)
setModuleApplyModuleId(arr.length ? String(arr[0].id) : '')
} catch (e) {
setModuleApplyErr(e.message || 'Module konnten nicht geladen werden')
setModuleApplyList([])
}
}, [])
const onModuleApplySectionIndexChange = useCallback((newIx) => {
setModuleApplySectionIx(newIx)
const secsNow = formRef.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)) {
toast.error('Bitte ein Trainingsmodul wählen.')
return
}
let secIx = parseInt(String(moduleApplySectionIx), 10)
const baseSections = formRef.current?.sections ?? []
if (!baseSections.length) return
if (!Number.isFinite(secIx) || secIx < 0 || secIx >= baseSections.length) secIx = 0
const itemCap = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items.length : 0
let insertBefore = itemCap
if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
if (Number.isFinite(zi)) insertBefore = Math.min(Math.max(0, zi), itemCap)
}
setModuleApplyBusy(true)
try {
const detail = await api.getTrainingModule(mid)
let nextSections = await insertTrainingModuleIntoPlanningSections({
sections: baseSections,
moduleDetail: detail,
sectionIndex: secIx,
insertBeforeItemIndex: insertBefore,
})
nextSections = await enrichSectionsWithVariants(nextSections)
setFormData((fd) => ({ ...fd, sections: nextSections }))
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
} catch (e) {
setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
} finally {
setModuleApplyBusy(false)
}
}, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot, toast])
const refreshPlanningSectionMeta = useCallback(async () => {
const next = await enrichSectionsWithVariants(formRef.current.sections)
setFormData((prev) => ({ ...prev, sections: next }))
}, [])
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner" />
<p>Laden </p>
</div>
)
}
return (
<div className="app-page">
<p style={{ marginBottom: '0.75rem' }}>
<Link to={planningHubPathFromReturnState(location.state?.planningReturn)} style={{ color: 'var(--accent-dark)' }}>
Zurück zur Trainingsplanung
</Link>
</p>
<TrainingUnitFormShell
editingUnit={editingUnit}
formData={formData}
updateFormField={updateFormField}
setFormData={setFormData}
onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })}
onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })}
onCancel={goBack}
draftPlanTemplateId={draftPlanTemplateId}
onDraftTemplateSelect={applyTemplateFromSelect}
planTemplates={planTemplates}
clubDirectory={clubDirectory}
clubDirectoryForCo={clubDirectoryForCo}
planningClubId={planningClubId}
user={user}
onMetaRefresh={refreshPlanningSectionMeta}
sectionsEditMode={sectionsEditMode}
setSectionsEditMode={setSectionsEditMode}
onSaveAsTemplate={handleSaveAsTemplate}
onRequestPublishToFramework={() => editingUnit?.id && setPublishFrameworkOpen(true)}
onRequestSaveAsModule={() => editingUnit?.id && setSaveModuleOpen(true)}
onRequestTrainingModulePick={(ctx) => void openModuleApplyModal(ctx)}
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
setExercisePickerTarget({
sIdx: sectionIndex,
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
insertBeforeIndex:
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
? insertBeforeIndex
: undefined,
})
setExercisePickerOpen(true)
}}
onPeekExercise={(id, variantId, peekExtras) =>
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
}
saving={saving}
/>
<TrainingPlanningModuleApplyModal
open={moduleApplyOpen}
busy={moduleApplyBusy}
err={moduleApplyErr}
placementLocked={moduleApplyPlacementLocked}
placementSummary={modulePlacementSummary}
sections={formData.sections}
sectionIx={moduleApplySectionIx}
onSectionIndexChange={onModuleApplySectionIndexChange}
insertSlot={moduleApplyInsertSlot}
onInsertSlotChange={setModuleApplyInsertSlot}
targetItems={moduleApplyTargetItems}
searchQuery={moduleApplySearchQuery}
onSearchQueryChange={setModuleApplySearchQuery}
filteredList={moduleApplyFilteredList}
fullList={moduleApplyList}
selectedModuleId={moduleApplyModuleId}
onSelectModuleId={setModuleApplyModuleId}
modulePickPreview={modulePickPreview}
onConfirm={handleApplyTrainingModuleConfirm}
onCancel={() => {
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
/>
<TrainingPublishToFrameworkModal
open={publishFrameworkOpen}
onClose={() => setPublishFrameworkOpen(false)}
onSuccess={() => setPublishFrameworkOpen(false)}
unitId={editingUnit?.id}
planningModalClubId={planningClubId}
/>
<SaveExercisesAsModuleModal
open={saveModuleOpen}
onClose={() => setSaveModuleOpen(false)}
onSuccess={() => setSaveModuleOpen(false)}
unitId={editingUnit?.id}
planningModalClubId={planningClubId}
/>
<ExercisePickerModal
open={exercisePickerOpen}
multiSelect
enableQuickCreateDraft
onClose={() => {
setExercisePickerOpen(false)
setExercisePickerTarget(null)
}}
onSelectExercises={async (picked) => {
if (!exercisePickerTarget || !picked?.length) return
const rows = []
for (const ex of picked) {
const row = await hydrateExercisePlanningRow(ex)
if (row) rows.push(row)
}
if (!rows.length) return
const { sIdx, iIdx, insertBeforeIndex } = exercisePickerTarget
setFormData((prev) => ({
...prev,
sections: prev.sections.map((s, si) => {
if (si !== sIdx) return s
const items = [...(s.items || [])]
if (typeof iIdx === 'number') {
const [first, ...tail] = rows
items[iIdx] = { ...items[iIdx], ...first, item_type: 'exercise' }
if (tail.length) items.splice(iIdx + 1, 0, ...tail)
return { ...s, items }
}
const at = Math.min(
typeof insertBeforeIndex === 'number' ? insertBeforeIndex : items.length,
items.length
)
items.splice(at, 0, ...rows)
return { ...s, items }
}),
}))
setExercisePickerOpen(false)
setExercisePickerTarget(null)
}}
/>
<ExercisePeekModal
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
</div>
)
}

View File

@ -0,0 +1,96 @@
/**
* Zentrale Routen & Rückkehr-Kontext Trainingsplanung Einheiten-Editor.
* Alle Navigations-URLs für Kalender-Einheiten hier pflegen (Drift-Schutz).
*/
export const PLANNING_HUB_PATH = '/planning'
export function buildPlanUnitEditPath(unitId) {
const id = Number(unitId)
if (!Number.isFinite(id) || id < 1) return PLANNING_HUB_PATH
return `/planning/units/${id}/edit`
}
/**
* @param {{ groupId?: string|number, plannedDate?: string, templateId?: string|number, mode?: string }} opts
*/
export function buildPlanUnitNewPath(opts = {}) {
const params = new URLSearchParams()
if (opts.groupId != null && opts.groupId !== '') params.set('group', String(opts.groupId))
if (opts.plannedDate) params.set('date', String(opts.plannedDate).slice(0, 10))
if (opts.templateId != null && opts.templateId !== '') params.set('template', String(opts.templateId))
if (opts.mode === 'debrief') params.set('mode', 'debrief')
const q = params.toString()
return q ? `/planning/units/new?${q}` : '/planning/units/new'
}
/** @param {import('react-router-dom').URLSearchParams|string|null|undefined} raw */
export function legacyPlanningUnitDeepLinkTarget(raw) {
const params =
raw instanceof URLSearchParams ? raw : new URLSearchParams(typeof raw === 'string' ? raw : '')
const uid = params.get('unit')
if (!uid) return null
const idNum = parseInt(uid, 10)
if (!Number.isFinite(idNum) || idNum < 1) return null
const debrief = params.get('debrief')
const base = buildPlanUnitEditPath(idNum)
if (debrief === '1' || debrief === 'true') {
return `${base}?mode=debrief`
}
return base
}
/**
* @param {{
* selectedGroupId?: string,
* planView?: string,
* calendarMonthStr?: string,
* startDate?: string,
* endDate?: string,
* planScope?: string,
* assignedToMeOnly?: boolean,
* }} hubState
*/
export function buildPlanningHubReturnState(hubState = {}) {
return {
v: 1,
selectedGroupId: hubState.selectedGroupId != null ? String(hubState.selectedGroupId) : '',
planView: hubState.planView || 'list',
calendarMonthStr: hubState.calendarMonthStr || '',
startDate: hubState.startDate || '',
endDate: hubState.endDate || '',
planScope: hubState.planScope || 'group',
assignedToMeOnly: Boolean(hubState.assignedToMeOnly),
}
}
/** @param {ReturnType<typeof buildPlanningHubReturnState>|null|undefined} state */
export function planningHubPathFromReturnState(state) {
if (!state || state.v !== 1) return PLANNING_HUB_PATH
const params = new URLSearchParams()
if (state.selectedGroupId) params.set('group', state.selectedGroupId)
if (state.planView && state.planView !== 'list') params.set('view', state.planView)
if (state.planView === 'calendar' && state.calendarMonthStr) {
params.set('month', state.calendarMonthStr)
}
if (state.startDate) params.set('start', state.startDate)
if (state.endDate) params.set('end', state.endDate)
if (state.planScope && state.planScope !== 'group') params.set('scope', state.planScope)
if (state.assignedToMeOnly) params.set('mine', '1')
const q = params.toString()
return q ? `${PLANNING_HUB_PATH}?${q}` : PLANNING_HUB_PATH
}
/** Hub-Query beim Mount auf State-Felder mappen. */
export function parsePlanningHubQuery(searchParams) {
const p = searchParams instanceof URLSearchParams ? searchParams : new URLSearchParams(searchParams || '')
return {
selectedGroupId: p.get('group') || '',
planView: p.get('view') === 'calendar' ? 'calendar' : 'list',
calendarMonthStr: p.get('month') || '',
startDate: p.get('start') || '',
endDate: p.get('end') || '',
planScope: p.get('scope') === 'club' ? 'club' : 'group',
assignedToMeOnly: p.get('mine') === '1',
}
}

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
buildPlanUnitEditPath,
buildPlanUnitNewPath,
buildPlanningHubReturnState,
legacyPlanningUnitDeepLinkTarget,
parsePlanningHubQuery,
planningHubPathFromReturnState,
} from './planningUnitRoutes.js'
describe('planningUnitRoutes', () => {
it('buildPlanUnitEditPath', () => {
expect(buildPlanUnitEditPath(42)).toBe('/planning/units/42/edit')
expect(buildPlanUnitEditPath('7')).toBe('/planning/units/7/edit')
expect(buildPlanUnitEditPath(0)).toBe('/planning')
})
it('buildPlanUnitNewPath with query', () => {
expect(buildPlanUnitNewPath({ groupId: 3, plannedDate: '2026-05-20' })).toBe(
'/planning/units/new?group=3&date=2026-05-20'
)
expect(buildPlanUnitNewPath()).toBe('/planning/units/new')
})
it('legacyPlanningUnitDeepLinkTarget', () => {
expect(legacyPlanningUnitDeepLinkTarget('unit=5')).toBe('/planning/units/5/edit')
expect(legacyPlanningUnitDeepLinkTarget('unit=5&debrief=1')).toBe(
'/planning/units/5/edit?mode=debrief'
)
expect(legacyPlanningUnitDeepLinkTarget('')).toBeNull()
})
it('planning hub return roundtrip', () => {
const state = buildPlanningHubReturnState({
selectedGroupId: '12',
planView: 'calendar',
calendarMonthStr: '2026-06',
assignedToMeOnly: true,
})
const path = planningHubPathFromReturnState(state)
expect(path).toContain('/planning?')
expect(path).toContain('group=12')
expect(path).toContain('view=calendar')
expect(path).toContain('month=2026-06')
expect(path).toContain('mine=1')
const parsed = parsePlanningHubQuery(path.split('?')[1] || '')
expect(parsed.selectedGroupId).toBe('12')
expect(parsed.planView).toBe('calendar')
expect(parsed.calendarMonthStr).toBe('2026-06')
expect(parsed.assignedToMeOnly).toBe(true)
})
})

View File

@ -0,0 +1,133 @@
import { buildPlanPayloadForSave, defaultSection } from './trainingUnitSectionsForm'
import { sessionAssignDefaults } from './trainingPlanningPageHelpers'
/** Leeres Formular für neue Einheit (ohne async Varianten-Anreicherung). */
export function createEmptyTrainingUnitFormData({
groupId = '',
plannedDate = '',
timeStart = '',
timeEnd = '',
} = {}) {
return {
group_id: groupId != null ? String(groupId) : '',
planned_date: plannedDate || '',
planned_time_start: timeStart || '',
planned_time_end: timeEnd || '',
planned_focus: '',
actual_date: '',
actual_time_start: '',
actual_time_end: '',
attendance_count: '',
status: 'planned',
notes: '',
trainer_notes: '',
debrief_completed: false,
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults(),
}
}
/**
* API-Zeile Formular (sync). `sections` müssen vorher via normalizeUnitToForm + enrich kommen.
* @param {object} fullUnit
* @param {object} sections bereits angereicherte Editor-Sections
*/
export function trainingUnitToFormFields(fullUnit, sections) {
const efLead =
fullUnit.effective_lead_trainer_profile_id != null
? Number(fullUnit.effective_lead_trainer_profile_id)
: null
let assistantIds = []
if (Array.isArray(fullUnit.assistant_trainer_profile_ids)) {
assistantIds = fullUnit.assistant_trainer_profile_ids
.map((x) => Number(x))
.filter((n) => Number.isFinite(n) && n >= 1)
}
if (efLead != null && Number.isFinite(efLead)) {
assistantIds = assistantIds.filter((id) => id !== efLead)
}
return {
group_id: fullUnit.group_id,
planned_date: fullUnit.planned_date || '',
planned_time_start: fullUnit.planned_time_start?.slice?.(0, 5) || fullUnit.planned_time_start || '',
planned_time_end: fullUnit.planned_time_end?.slice?.(0, 5) || fullUnit.planned_time_end || '',
planned_focus: fullUnit.planned_focus || '',
actual_date: fullUnit.actual_date || '',
actual_time_start: fullUnit.actual_time_start?.slice?.(0, 5) || fullUnit.actual_time_start || '',
actual_time_end: fullUnit.actual_time_end?.slice?.(0, 5) || fullUnit.actual_time_end || '',
attendance_count: fullUnit.attendance_count ?? '',
status: fullUnit.status || 'planned',
notes: fullUnit.notes || '',
trainer_notes: fullUnit.trainer_notes || '',
debrief_completed: Boolean(fullUnit.debrief_completed_at),
sections,
lead_trainer_profile_id:
fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
? String(fullUnit.lead_trainer_profile_id)
: '',
session_assistants_inherit:
fullUnit.assistant_trainer_profile_ids == null ||
fullUnit.assistant_trainer_profile_ids === undefined,
session_assistant_profile_ids: assistantIds,
}
}
/**
* Formular API-Payload (identisch zur bisherigen Modal-Logik).
* @param {object} formData
* @param {{ editingUnit?: object|null, draftPlanTemplateId?: string }} opts
*/
export function buildTrainingUnitSavePayload(formData, { editingUnit = null, draftPlanTemplateId = '' } = {}) {
const planPart = buildPlanPayloadForSave(formData.sections)
const payload = {
planned_date: formData.planned_date,
planned_time_start: formData.planned_time_start || null,
planned_time_end: formData.planned_time_end || null,
planned_focus: formData.planned_focus || null,
actual_date: formData.actual_date || null,
actual_time_start: formData.actual_time_start || null,
actual_time_end: formData.actual_time_end || null,
attendance_count: formData.attendance_count ? parseInt(formData.attendance_count, 10) : null,
status: formData.status || 'planned',
notes: formData.notes || null,
trainer_notes: formData.trainer_notes || null,
...planPart,
}
if (editingUnit) {
payload.debrief_completed =
(formData.status || '') === 'completed' ? !!formData.debrief_completed : false
}
const leadStr = String(formData.lead_trainer_profile_id || '').trim()
if (leadStr) {
payload.lead_trainer_profile_id = parseInt(leadStr, 10)
} else if (editingUnit) {
payload.lead_trainer_profile_id = null
}
if (formData.session_assistants_inherit) {
if (editingUnit) payload.assistant_trainer_profile_ids = null
} else {
payload.assistant_trainer_profile_ids = [...(formData.session_assistant_profile_ids || [])].sort(
(a, b) => a - b
)
}
if (!editingUnit) {
payload.group_id = parseInt(formData.group_id, 10)
if (draftPlanTemplateId) {
payload.plan_template_id = parseInt(draftPlanTemplateId, 10)
}
}
return payload
}
export function validateTrainingUnitFormForSave(formData) {
if (!formData?.group_id || !formData?.planned_date) {
return { ok: false, message: 'Gruppe und Datum sind Pflichtfelder' }
}
return { ok: true }
}

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
buildTrainingUnitSavePayload,
createEmptyTrainingUnitFormData,
validateTrainingUnitFormForSave,
} from './trainingUnitEditorCore.js'
describe('trainingUnitEditorCore', () => {
it('createEmptyTrainingUnitFormData defaults', () => {
const fd = createEmptyTrainingUnitFormData({ groupId: '2', plannedDate: '2026-05-01' })
expect(fd.group_id).toBe('2')
expect(fd.planned_date).toBe('2026-05-01')
expect(fd.sections).toHaveLength(1)
expect(fd.sections[0].title).toBe('Hauptteil')
})
it('validateTrainingUnitFormForSave', () => {
expect(validateTrainingUnitFormForSave({ group_id: '', planned_date: '' }).ok).toBe(false)
expect(validateTrainingUnitFormForSave({ group_id: '1', planned_date: '2026-01-01' }).ok).toBe(true)
})
it('buildTrainingUnitSavePayload create includes group and template', () => {
const formData = createEmptyTrainingUnitFormData({ groupId: '5', plannedDate: '2026-05-10' })
const payload = buildTrainingUnitSavePayload(formData, { draftPlanTemplateId: '9' })
expect(payload.group_id).toBe(5)
expect(payload.plan_template_id).toBe(9)
expect(payload.planned_date).toBe('2026-05-10')
expect(payload.sections).toBeDefined()
})
it('buildTrainingUnitSavePayload update sets debrief and clears lead when empty', () => {
const formData = {
...createEmptyTrainingUnitFormData({ groupId: '5', plannedDate: '2026-05-10' }),
status: 'completed',
debrief_completed: true,
lead_trainer_profile_id: '',
session_assistants_inherit: true,
}
const payload = buildTrainingUnitSavePayload(formData, { editingUnit: { id: 1 } })
expect(payload.debrief_completed).toBe(true)
expect(payload.lead_trainer_profile_id).toBeNull()
expect(payload.assistant_trainer_profile_ids).toBeNull()
expect(payload.group_id).toBeUndefined()
})
})

10
frontend/vitest.config.js Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'node',
include: ['src/**/*.test.js'],
},
})

View File

@ -305,6 +305,46 @@ test('13. Trainingsplanung: Rahmen-Import-Dialog öffnet und schließt', async (
console.log('✓ Trainingsplanung: Rahmen-Import-Dialog Smoke'); console.log('✓ Trainingsplanung: Rahmen-Import-Dialog Smoke');
}); });
test('14. Trainingsplanung: Bearbeiten navigiert zur Edit-Route', 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 });
const editBtn = main.getByRole('button', { name: 'Bearbeiten' }).first();
if (!(await editBtn.count())) {
testInfo.skip(true, 'Keine Trainingseinheit in der Liste');
return;
}
await editBtn.click();
await page.waitForURL(/\/planning\/units\/\d+\/edit/, { timeout: 15000 });
await expect(page.getByTestId('planning-unit-form')).toBeVisible({ timeout: 15000 });
console.log('✓ Trainingsplanung: Edit-Route aus Hub');
});
test('15. Trainingsplanung: Legacy-Deep-Link ?unit= leitet auf Edit-Route um', 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 });
const editBtn = main.getByRole('button', { name: 'Bearbeiten' }).first();
if (!(await editBtn.count())) {
testInfo.skip(true, 'Keine Trainingseinheit in der Liste');
return;
}
await editBtn.click();
await page.waitForURL(/\/planning\/units\/(\d+)\/edit/, { timeout: 15000 });
const m = page.url().match(/\/planning\/units\/(\d+)\/edit/);
const unitId = m?.[1];
if (!unitId) {
testInfo.skip(true, 'Unit-ID aus Edit-URL nicht ermittelbar');
return;
}
await page.goto(`/planning?unit=${unitId}`, { waitUntil: 'networkidle' });
await page.waitForURL(new RegExp(`/planning/units/${unitId}/edit`), { timeout: 15000 });
await expect(page.getByTestId('planning-unit-form')).toBeVisible({ timeout: 15000 });
console.log('✓ Trainingsplanung: Legacy ?unit= Redirect');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => { test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 }); await page.setViewportSize({ width: 1280, height: 800 });
await login(page); await login(page);