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
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:
parent
295c7e7efc
commit
16eaf839e7
|
|
@ -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 14–15",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.148",
|
"version": "0.8.148",
|
||||||
"date": "2026-05-19",
|
"date": "2026-05-19",
|
||||||
|
|
|
||||||
|
|
@ -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 **14–15** (Edit-Route, Legacy-Redirect).
|
||||||
|
|
||||||
### Trainingsplan: Phasen, parallele Streams und Coaching (Stand **0.8.137–0.8.140**)
|
### Trainingsplan: Phasen, parallele Streams und Coaching (Stand **0.8.137–0.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.
|
||||||
|
|
|
||||||
126
docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md
Normal file
126
docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md
Normal 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 14–15; 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 12–13 weiter grün; 14–15 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.
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 /> },
|
||||||
|
|
|
||||||
|
|
@ -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
507
frontend/src/components/planning/TrainingUnitFormShell.jsx
Normal file
507
frontend/src/components/planning/TrainingUnitFormShell.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
709
frontend/src/pages/TrainingUnitEditPage.jsx
Normal file
709
frontend/src/pages/TrainingUnitEditPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
frontend/src/utils/planningUnitRoutes.js
Normal file
96
frontend/src/utils/planningUnitRoutes.js
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
53
frontend/src/utils/planningUnitRoutes.test.js
Normal file
53
frontend/src/utils/planningUnitRoutes.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
133
frontend/src/utils/trainingUnitEditorCore.js
Normal file
133
frontend/src/utils/trainingUnitEditorCore.js
Normal 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 }
|
||||||
|
}
|
||||||
45
frontend/src/utils/trainingUnitEditorCore.test.js
Normal file
45
frontend/src/utils/trainingUnitEditorCore.test.js
Normal 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
10
frontend/vitest.config.js
Normal 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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user