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
|
||||
|
||||
APP_VERSION = "0.8.148"
|
||||
APP_VERSION = "0.8.149"
|
||||
BUILD_DATE = "2026-05-19"
|
||||
DB_SCHEMA_VERSION = "20260516065"
|
||||
|
||||
|
|
@ -36,6 +36,14 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
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",
|
||||
"date": "2026-05-19",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-14
|
||||
**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`
|
||||
**Stand:** 2026-05-19
|
||||
**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**.
|
||||
|
||||
|
|
@ -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`**.
|
||||
- **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**)
|
||||
|
||||
- **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": {
|
||||
"dev": "vite --port 3098",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
|
@ -20,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@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 TrainingModuleEditPage = lazy(() => import('./pages/TrainingModuleEditPage'))
|
||||
const TrainingUnitRunPage = lazy(() => import('./pages/TrainingUnitRunPage'))
|
||||
const TrainingUnitEditPage = lazy(() => import('./pages/TrainingUnitEditPage'))
|
||||
const TrainingCoachPage = lazy(() => import('./pages/TrainingCoachPage'))
|
||||
const AdminCatalogsPage = lazy(() => import('./pages/AdminCatalogsPage'))
|
||||
const AdminHierarchyPage = lazy(() => import('./pages/AdminHierarchyPage'))
|
||||
|
|
@ -234,6 +235,8 @@ const appRouter = createBrowserRouter([
|
|||
{ path: 'framework-programs', element: <TrainingFrameworkProgramsListPage /> },
|
||||
{ path: 'training-modules', element: <TrainingModulesListPage /> },
|
||||
{ path: 'plan-templates', element: <TrainingPlanTemplatesListPage /> },
|
||||
{ path: 'units/new', element: <TrainingUnitEditPage /> },
|
||||
{ path: 'units/:id/edit', element: <TrainingUnitEditPage /> },
|
||||
],
|
||||
},
|
||||
{ path: 'planning/framework-programs/new', element: <TrainingFrameworkProgramEditPage /> },
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ export default function DashboardTrainingVisibilityWidget({ user }) {
|
|||
}}
|
||||
>
|
||||
<Link
|
||||
to={`/planning?unit=${first.id}`}
|
||||
to={`/planning/units/${first.id}/edit`}
|
||||
className="btn-ghost"
|
||||
style={{
|
||||
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">
|
||||
{trainingHome.reviewPending.map((u) => (
|
||||
<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'}
|
||||
</Link>
|
||||
{u.group_name ? (
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ function MediaUsageBlock({ usage, compact }) {
|
|||
[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
|
||||
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}
|
||||
</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');
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await login(page);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user