From 16eaf839e7241cc29a68dd86e5e19de44e70edb1 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 May 2026 11:02:03 +0200 Subject: [PATCH] Enhance frontend testing setup and refactor TrainingPlanningPageRoot component - 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. --- backend/version.py | 10 +- docs/HANDOVER.md | 11 +- .../TRAINING_UNIT_EDIT_PAGE_MIGRATION.md | 126 +++ frontend/package.json | 7 +- frontend/src/App.jsx | 3 + .../DashboardTrainingVisibilityWidget.jsx | 2 +- .../planning/TrainingPlanningPageRoot.jsx | 884 +++--------------- .../planning/TrainingUnitFormShell.jsx | 507 ++++++++++ frontend/src/pages/Dashboard.jsx | 2 +- frontend/src/pages/MediaLibraryPage.jsx | 2 +- frontend/src/pages/TrainingUnitEditPage.jsx | 709 ++++++++++++++ frontend/src/utils/planningUnitRoutes.js | 96 ++ frontend/src/utils/planningUnitRoutes.test.js | 53 ++ frontend/src/utils/trainingUnitEditorCore.js | 133 +++ .../src/utils/trainingUnitEditorCore.test.js | 45 + frontend/vitest.config.js | 10 + tests/dev-smoke-test.spec.js | 40 + 17 files changed, 1854 insertions(+), 786 deletions(-) create mode 100644 docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md create mode 100644 frontend/src/components/planning/TrainingUnitFormShell.jsx create mode 100644 frontend/src/pages/TrainingUnitEditPage.jsx create mode 100644 frontend/src/utils/planningUnitRoutes.js create mode 100644 frontend/src/utils/planningUnitRoutes.test.js create mode 100644 frontend/src/utils/trainingUnitEditorCore.js create mode 100644 frontend/src/utils/trainingUnitEditorCore.test.js create mode 100644 frontend/vitest.config.js diff --git a/backend/version.py b/backend/version.py index e9bf313..6adcb35 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index f0e2f7d..7b05e7f 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -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. diff --git a/docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md b/docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md new file mode 100644 index 0000000..c08816a --- /dev/null +++ b/docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md @@ -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. diff --git a/frontend/package.json b/frontend/package.json index 876f479..9a4af8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fbabf06..3c4bb10 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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: }, { path: 'training-modules', element: }, { path: 'plan-templates', element: }, + { path: 'units/new', element: }, + { path: 'units/:id/edit', element: }, ], }, { path: 'planning/framework-programs/new', element: }, diff --git a/frontend/src/components/DashboardTrainingVisibilityWidget.jsx b/frontend/src/components/DashboardTrainingVisibilityWidget.jsx index 230e538..6b3f1d1 100644 --- a/frontend/src/components/DashboardTrainingVisibilityWidget.jsx +++ b/frontend/src/components/DashboardTrainingVisibilityWidget.jsx @@ -268,7 +268,7 @@ export default function DashboardTrainingVisibilityWidget({ user }) { }} > getTenantClubDependencyKey(user), [user]) - const [searchParams, setSearchParams] = useSearchParams() + const [searchParams] = useSearchParams() const unitDeepLinkHandledRef = useRef(null) + const hubQuerySyncedRef = useRef(false) const [groups, setGroups] = useState([]) const [selectedGroupId, setSelectedGroupId] = useState('') const [units, setUnits] = useState([]) - const [planTemplates, setPlanTemplates] = useState([]) const [loading, setLoading] = useState(true) - const [showModal, setShowModal] = useState(false) - const [editingUnit, setEditingUnit] = useState(null) const [publishFrameworkOpen, setPublishFrameworkOpen] = useState(false) /** Einheit für „Rahmen-Session“-Dialog (Liste oder geöffnetes Bearbeiten) */ const [publishFrameworkUnitId, setPublishFrameworkUnitId] = useState(null) const [saveModuleOpen, setSaveModuleOpen] = useState(false) const [saveModuleUnitId, setSaveModuleUnitId] = useState(null) - /** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */ - const [sectionsEditMode, setSectionsEditMode] = useState('planning') - const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('') - const [exercisePickerOpen, setExercisePickerOpen] = useState(false) - const [exercisePickerTarget, setExercisePickerTarget] = useState(null) - const [planningPeekCtx, setPlanningPeekCtx] = useState(null) const today = new Date().toISOString().split('T')[0] const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] @@ -79,23 +64,6 @@ function TrainingPlanningPageRoot() { const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7) const [fwImportSubmitting, setFwImportSubmitting] = useState(false) - 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 [startDate, setStartDate] = useState(today) const [endDate, setEndDate] = useState(thirtyDaysLater) const [planView, setPlanView] = useState('list') @@ -112,116 +80,40 @@ function TrainingPlanningPageRoot() { }) const [assignSaving, setAssignSaving] = useState(false) - const [formData, setFormData] = useState({ - group_id: '', - planned_date: '', - planned_time_start: '', - planned_time_end: '', - planned_focus: '', - actual_date: '', - actual_time_start: '', - actual_time_end: '', - attendance_count: '', - status: 'planned', - notes: '', - trainer_notes: '', - debrief_completed: false, - sections: [defaultSection()], - ...sessionAssignDefaults() - }) - const planningFormRef = useRef(formData) - planningFormRef.current = formData + const planningReturnState = useMemo( + () => + buildPlanningHubReturnState({ + selectedGroupId, + planView, + calendarMonthStr, + startDate, + endDate, + planScope, + assignedToMeOnly, + }), + [selectedGroupId, planView, calendarMonthStr, startDate, endDate, planScope, assignedToMeOnly] + ) - 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]) - - useEffect(() => { - if (!moduleApplyOpen || !moduleApplyFilteredList.length) return - if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return - setModuleApplyModuleId(String(moduleApplyFilteredList[0].id)) - }, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId]) - - const planningModalClubId = 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 || g.club_id == null || g.club_id === '') return null - const c = Number(g.club_id) - return Number.isFinite(c) ? c : null - }, [groups, formData.group_id]) - - 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]) - - const refreshPlanningSectionMeta = useCallback(async () => { - const next = await enrichSectionsWithVariants(planningFormRef.current.sections) - setFormData((prev) => ({ ...prev, sections: next })) - }, []) - - const loadPlanTemplates = useCallback(async () => { - try { - const tpl = await api.listTrainingPlanTemplates() - setPlanTemplates(tpl) - } catch (e) { - console.error('Vorlagen laden:', e) - } - }, []) + const navigateToEditUnit = useCallback( + (unit, opts = {}) => { + const id = typeof unit === 'object' ? unit?.id : unit + if (id == null) return + let path = buildPlanUnitEditPath(id) + const debrief = + opts.debrief === true || + opts.mode === 'debrief' || + searchParams.get('debrief') === '1' || + searchParams.get('debrief') === 'true' + if (debrief) path += '?mode=debrief' + navigate(path, { state: { planningReturn: planningReturnState } }) + }, + [navigate, planningReturnState, searchParams] + ) const loadData = useCallback(async () => { try { const groupsData = await api.listTrainingGroups({ status: 'active' }) setGroups(groupsData) - await loadPlanTemplates() if (groupsData.length > 0) { setSelectedGroupId((prev) => { @@ -242,7 +134,7 @@ function TrainingPlanningPageRoot() { } finally { setLoading(false) } - }, [user?.id, loadPlanTemplates]) + }, [user?.id, toast]) const loadUnits = useCallback(async () => { if (!selectedGroupId) return @@ -320,10 +212,6 @@ function TrainingPlanningPageRoot() { }, [user?.clubs]) useEffect(() => { - const gid = parseInt(formData.group_id || selectedGroupId || '0', 10) - const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null - const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null - let assignModalClubId = null if (assignModalOpen && assignDraft.unit?.group_id != null) { const ug = Number(assignDraft.unit.group_id) @@ -332,13 +220,11 @@ function TrainingPlanningPageRoot() { } const loadClubId = - showModal && clubForModal != null && Number.isFinite(clubForModal) - ? clubForModal - : assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId) - ? assignModalClubId - : canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo) - ? selectedGroupClubIdMemo - : null + assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId) + ? assignModalClubId + : canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo) + ? selectedGroupClubIdMemo + : null if (loadClubId == null || !Number.isFinite(loadClubId)) { setClubDirectory([]) @@ -359,16 +245,26 @@ function TrainingPlanningPageRoot() { return () => { cancelled = true } - }, [ - showModal, - assignModalOpen, - assignDraft.unit, - formData.group_id, - selectedGroupId, - groups, - canClubOrgTraining, - selectedGroupClubIdMemo, - ]) + }, [assignModalOpen, assignDraft.unit, groups, canClubOrgTraining, selectedGroupClubIdMemo]) + + const listActionClubId = useMemo(() => { + const uid = publishFrameworkUnitId ?? saveModuleUnitId + if (uid == null) return selectedGroupClubIdMemo + const u = units.find((x) => Number(x.id) === Number(uid)) + if (u?.group_club_id != null) { + const c = Number(u.group_club_id) + if (Number.isFinite(c)) return c + } + const gid = u?.group_id != null ? Number(u.group_id) : NaN + if (Number.isFinite(gid)) { + const g = groups.find((gr) => Number(gr.id) === gid) + if (g?.club_id != null) { + const c = Number(g.club_id) + if (Number.isFinite(c)) return c + } + } + return selectedGroupClubIdMemo + }, [publishFrameworkUnitId, saveModuleUnitId, units, groups, selectedGroupClubIdMemo]) useEffect(() => { if (!frameworkImportOpen) return @@ -497,28 +393,9 @@ function TrainingPlanningPageRoot() { toast.error('Bitte wähle zuerst eine Trainingsgruppe') return } - const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10)) - setEditingUnit(null) - setDraftPlanTemplateId('') - setFormData({ - group_id: selectedGroupId, - planned_date: today, - planned_time_start: group?.time_start?.slice(0, 5) || '', - planned_time_end: group?.time_end?.slice(0, 5) || '', - planned_focus: '', - actual_date: '', - actual_time_start: '', - actual_time_end: '', - attendance_count: '', - status: 'planned', - notes: '', - trainer_notes: '', - debrief_completed: false, - sections: [defaultSection('Hauptteil')], - ...sessionAssignDefaults() + navigate(buildPlanUnitNewPath({ groupId: selectedGroupId, plannedDate: today }), { + state: { planningReturn: planningReturnState }, }) - setSectionsEditMode('planning') - setShowModal(true) } const handleCreateForDate = (isoDay) => { @@ -526,348 +403,53 @@ function TrainingPlanningPageRoot() { toast.error('Bitte wähle zuerst eine Trainingsgruppe') return } - const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10)) - setEditingUnit(null) - setDraftPlanTemplateId('') - setFormData({ - group_id: selectedGroupId, - planned_date: isoDay, - planned_time_start: group?.time_start?.slice(0, 5) || '', - planned_time_end: group?.time_end?.slice(0, 5) || '', - planned_focus: '', - actual_date: '', - actual_time_start: '', - actual_time_end: '', - attendance_count: '', - status: 'planned', - notes: '', - trainer_notes: '', - debrief_completed: false, - sections: [defaultSection('Hauptteil')], - ...sessionAssignDefaults() + navigate(buildPlanUnitNewPath({ groupId: selectedGroupId, plannedDate: isoDay }), { + state: { planningReturn: planningReturnState }, }) - setSectionsEditMode('planning') - setShowModal(true) } - 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 handleEdit = useCallback(async (unit) => { - try { - const fullUnit = await api.getTrainingUnit(unit.id) - setEditingUnit(fullUnit) - setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '') - let sections = normalizeUnitToForm(fullUnit) - sections = await enrichSectionsWithVariants(sections) - setFormData({ - group_id: fullUnit.group_id, - planned_date: fullUnit.planned_date || '', - planned_time_start: fullUnit.planned_time_start?.slice(0, 5) || '', - planned_time_end: fullUnit.planned_time_end?.slice(0, 5) || '', - planned_focus: fullUnit.planned_focus || '', - actual_date: fullUnit.actual_date || '', - actual_time_start: fullUnit.actual_time_start?.slice(0, 5) || '', - actual_time_end: fullUnit.actual_time_end?.slice(0, 5) || '', - 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: (() => { - const efLead = - fullUnit.effective_lead_trainer_profile_id != null - ? Number(fullUnit.effective_lead_trainer_profile_id) - : null - let xs = toNumList(fullUnit.assistant_trainer_profile_ids) - if (efLead != null && Number.isFinite(efLead)) xs = xs.filter((id) => id !== efLead) - return xs - })(), - }) - setSectionsEditMode(fullUnit.status === 'completed' ? 'debrief' : 'planning') - setShowModal(true) - } catch (err) { - toast.error('Fehler beim Laden: ' + err.message) - throw err - } - }, []) + const handleEdit = useCallback( + (unit) => { + navigateToEditUnit(unit) + }, + [navigateToEditUnit] + ) useEffect(() => { if (!user?.id || loading) return - const uid = searchParams.get('unit') - if (!uid) { + const target = legacyPlanningUnitDeepLinkTarget(searchParams) + if (!target) { unitDeepLinkHandledRef.current = null return } + const uid = searchParams.get('unit') if (unitDeepLinkHandledRef.current === uid) return - const idNum = parseInt(uid, 10) - if (!Number.isFinite(idNum)) return unitDeepLinkHandledRef.current = uid - handleEdit({ id: idNum }) - .then(() => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev) - next.delete('unit') - next.delete('debrief') - return next - }, - { replace: true } - ) - }) - .catch(() => { - unitDeepLinkHandledRef.current = null - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev) - next.delete('unit') - next.delete('debrief') - return next - }, - { replace: true } - ) - }) - }, [user?.id, loading, searchParams, handleEdit, setSearchParams]) - - 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) { - const fb = planningModalClubId != null ? Number(planningModalClubId) : NaN - if (Number.isFinite(fb) && fb >= 1) club_id = fb - } - 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), - }) - await loadPlanTemplates() - 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 = planningFormRef.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 : [] - const cap = items.length - if (typeof placement.insertBeforeIndex === 'number' && Number.isFinite(placement.insertBeforeIndex)) { - before = Math.min(Math.max(0, placement.insertBeforeIndex), cap) - } else before = cap - } else { - const items = Array.isArray(secs[0]?.items) ? secs[0].items : [] - before = items.length - secIx = 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 = planningFormRef.current?.sections ?? [] - const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0 - setModuleApplyInsertSlot(`before:${len}`) - }, []) - - const handleApplyTrainingModuleConfirm = useCallback(async () => { - const mid = parseInt(moduleApplyModuleId, 10) - if (!Number.isFinite(mid)) { - toast.error('Bitte ein Trainingsmodul wählen.') - return - } - let secIx = parseInt(String(moduleApplySectionIx), 10) - if (!Number.isFinite(secIx)) secIx = 0 - - const baseSections = planningFormRef.current?.sections ?? formData.sections ?? [] - if (!baseSections.length) { - toast.error('Keine Abschnitte im Formular.') - return - } - if (secIx < 0 || secIx >= baseSections.length) secIx = 0 - - const secItems = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items : [] - const itemCap = secItems.length - 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) - setModuleApplyErr('') - 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]) + const returnState = buildPlanningHubReturnState(parsePlanningHubQuery(searchParams)) + navigate(target, { replace: true, state: { planningReturn: returnState } }) + }, [user?.id, loading, searchParams, navigate]) useEffect(() => { - if (!moduleApplyOpen) { - setModulePickPreview({ - loading: false, - moduleId: '', - exercises: [], - notes: 0, - err: '', - }) - return undefined + if (loading) return + if (searchParams.get('unit')) return + const hasHubQuery = ['group', 'view', 'month', 'start', 'end', 'scope', 'mine'].some((k) => + searchParams.has(k) + ) + if (!hasHubQuery) { + hubQuerySyncedRef.current = false + return } - 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]) + if (hubQuerySyncedRef.current) return + hubQuerySyncedRef.current = true + const q = parsePlanningHubQuery(searchParams) + if (q.selectedGroupId) setSelectedGroupId(q.selectedGroupId) + setPlanView(q.planView) + if (q.calendarMonthStr) setCalendarMonthStr(q.calendarMonthStr) + if (q.startDate) setStartDate(q.startDate) + if (q.endDate) setEndDate(q.endDate) + if (q.planScope) setPlanScope(q.planScope) + setAssignedToMeOnly(q.assignedToMeOnly) + }, [loading, searchParams]) const handleTakeLead = async (unit) => { if (!user?.id) return @@ -974,101 +556,6 @@ function TrainingPlanningPageRoot() { } } - const handleSubmit = async (e, { closeAfter = true } = {}) => { - e?.preventDefault?.() - if (!formData.group_id || !formData.planned_date) { - toast.error('Gruppe und Datum sind Pflichtfelder') - return false - } - try { - 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) - } - } - - let savedUnit - if (editingUnit) { - savedUnit = await api.updateTrainingUnit(editingUnit.id, payload) - } else { - savedUnit = await api.createTrainingUnit(payload) - } - await loadUnits() - if (closeAfter) { - setShowModal(false) - } else if (savedUnit?.id) { - await handleEdit({ id: savedUnit.id }) - } - return true - } catch (err) { - toast.error('Fehler beim Speichern: ' + err.message) - return false - } - } - - const updateFormField = (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 || selectedGroupId || '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) - } - } - const assistants = prev.session_assistant_profile_ids.filter((id) => !strip.has(id)) - return { ...prev, lead_trainer_profile_id: value, session_assistant_profile_ids: assistants } - }) - } - const calendarGridDays = useMemo(() => { const r = getCalendarGridRange(calendarMonthStr) return enumerateIsoDays(r.gridStart, r.gridEnd) @@ -1131,26 +618,6 @@ function TrainingPlanningPageRoot() { const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10)) - const gidTrainerForm = parseInt(formData.group_id || selectedGroupId || '0', 10) - const groupForTrainerForm = - Number.isFinite(gidTrainerForm) && gidTrainerForm >= 1 - ? groups.find((gr) => gr.id === gidTrainerForm) - : null - - let formTrainerAssignLeadExcludeId = null - if (groupForTrainerForm?.trainer_id != null) formTrainerAssignLeadExcludeId = Number(groupForTrainerForm.trainer_id) - const leadDraftTrim = String(formData.lead_trainer_profile_id || '').trim() - if (leadDraftTrim !== '') { - const nl = parseInt(leadDraftTrim, 10) - if (Number.isFinite(nl)) formTrainerAssignLeadExcludeId = nl - } - if (editingUnit?.effective_lead_trainer_profile_id != null && leadDraftTrim === '') { - const el = Number(editingUnit.effective_lead_trainer_profile_id) - if (Number.isFinite(el)) formTrainerAssignLeadExcludeId = el - } - - const clubDirectoryForCo = filterDirectoryExcludingLead(clubDirectory, formTrainerAssignLeadExcludeId) - let assignExcludeLeadPid = null if (assignModalOpen && assignDraft.unit) { const dl = String(assignDraft.lead_trainer_profile_id || '').trim() @@ -1905,32 +1372,6 @@ function TrainingPlanningPageRoot() { onSave={saveTrainerAssignModal} /> - { - setModuleApplyOpen(false) - setModuleApplyPlacementLocked(false) - }} - /> - { - setShowModal(false) + setPublishFrameworkOpen(false) setPublishFrameworkUnitId(null) }} - unitId={publishFrameworkUnitId ?? editingUnit?.id} - planningModalClubId={planningModalClubId} + unitId={publishFrameworkUnitId} + planningModalClubId={listActionClubId} /> { - setShowModal(false) + setSaveModuleOpen(false) setSaveModuleUnitId(null) }} - unitId={saveModuleUnitId ?? editingUnit?.id} - planningModalClubId={planningModalClubId} + unitId={saveModuleUnitId} + planningModalClubId={listActionClubId} /> - - setShowModal(false)} - draftPlanTemplateId={draftPlanTemplateId} - onDraftTemplateSelect={applyTemplateFromSelect} - planTemplates={planTemplates} - onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })} - onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })} - clubDirectory={clubDirectory} - clubDirectoryForCo={clubDirectoryForCo} - planningModalClubId={planningModalClubId} - user={user} - onMetaRefresh={refreshPlanningSectionMeta} - sectionsEditMode={sectionsEditMode} - setSectionsEditMode={setSectionsEditMode} - onSaveAsTemplate={handleSaveAsTemplate} - onRequestPublishToFramework={() => { - if (editingUnit?.id) { - setPublishFrameworkUnitId(editingUnit.id) - setPublishFrameworkOpen(true) - } - }} - onRequestSaveAsModule={() => { - if (editingUnit?.id) { - setSaveModuleUnitId(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, - }) - } - /> - { - 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 cur = items[iIdx] - if (!cur || cur.item_type !== 'exercise') return s - const [first, ...tail] = rows - items[iIdx] = { - ...cur, - exercise_id: first.exercise_id, - exercise_variant_id: first.exercise_variant_id, - exercise_title: first.exercise_title, - variants: first.variants, - } - if (tail.length) items.splice(iIdx + 1, 0, ...tail) - return { ...s, items } - } - const rawAt = - typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex) - ? insertBeforeIndex - : items.length - const at = Math.max(0, Math.min(rawAt, items.length)) - items.splice(at, 0, ...rows) - return { ...s, items } - }), - })) - setExercisePickerOpen(false) - setExercisePickerTarget(null) - }} - /> - setPlanningPeekCtx(null)} - /> ) } diff --git a/frontend/src/components/planning/TrainingUnitFormShell.jsx b/frontend/src/components/planning/TrainingUnitFormShell.jsx new file mode 100644 index 0000000..875d725 --- /dev/null +++ b/frontend/src/components/planning/TrainingUnitFormShell.jsx @@ -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 ( +
+

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

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

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

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

+ Übernimmt nur die Sektionsstruktur aus der Bibliothek; Übungen trägst du unten bei den + Abschnitten ein. Vorlagen verwaltest du unter{' '} + Planung → Vorlagen. +

+
+ )} + +

Planung

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

Trainerzuordnung (diese Einheit)

+
+ + +
+
+ +
+ {!formData.session_assistants_inherit ? ( +
+ {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 ( + + ) + })} +
+ ) : null} +
+ + + +
+ {editingUnit ? ( +
+
+ + Ablauf bearbeiten als + +
+ {[ + { id: 'planning', label: 'Planung' }, + { id: 'debrief', label: 'Nachbereitung' }, + ].map((opt, i) => ( + + ))} +
+
+
+ ) : null} + +
+ + +
+ {newTplVisibility === 'club' ? ( +
+ + +
+ ) : null} + + {editingUnit?.id && !editingUnit?.framework_slot_id ? ( + <> + + + + ) : null} +
+ } + sections={formData.sections} + wideExerciseGrid + onSectionsChange={(updater) => + setFormData((prev) => ({ ...prev, sections: updater(prev.sections) })) + } + onRequestTrainingModulePick={onRequestTrainingModulePick} + onRequestExercisePick={onRequestExercisePick} + onPeekExercise={onPeekExercise} + showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'} + enableParallelPhaseControls + /> +
+ + {editingUnit ? ( + <> +

Durchführung

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

Notizen

+
+ +