diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index e0f215a..d08757d 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -878,6 +878,86 @@ def _phases_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: return out +def _copy_scheduled_unit_plan_to_blueprint( + cur, + source_unit_id: int, + blueprint_unit_id: int, + profile_id: int, + role: str, +) -> None: + """Übernimmt Phasen/Sektionen einer geplanten Einheit in eine Rahmen-Blueprint-Einheit.""" + cloned_phases = _phases_clone_payload(cur, source_unit_id) + if cloned_phases: + _replace_unit_phases(cur, blueprint_unit_id, cloned_phases, profile_id, role, profile_id) + return + secs = _fetch_sections(cur, source_unit_id) + sections_payload = [_clone_section_payload_dict(s) for s in secs] + if not sections_payload: + _replace_unit_sections( + cur, + blueprint_unit_id, + [{"title": "Ablauf", "order_index": 0, "guidance_notes": None, "items": []}], + ) + return + _replace_unit_sections(cur, blueprint_unit_id, sections_payload) + + +def _shift_framework_slots_sort_orders_from(cur, framework_program_id: int, from_sort_order: int) -> None: + cur.execute( + """ + UPDATE training_framework_slots + SET sort_order = sort_order + 1 + WHERE framework_program_id = %s AND sort_order >= %s + """, + (framework_program_id, int(from_sort_order)), + ) + + +def _insert_framework_slot_and_blueprint_unit( + cur, + framework_program_id: int, + sort_order: int, + title: Optional[str], + notes: Optional[Any], + profile_id: int, +) -> Tuple[int, int]: + """Legt Slot-Zeile + Blueprint-`training_units`-Zeile an; gibt (slot_id, blueprint_unit_id) zurück.""" + cur.execute( + """ + INSERT INTO training_framework_slots ( + framework_program_id, sort_order, title, notes, training_unit_id + ) VALUES (%s, %s, %s, %s, NULL) + RETURNING id + """, + ( + framework_program_id, + int(sort_order), + title, + notes, + ), + ) + sid = int(cur.fetchone()["id"]) + cur.execute( + """ + INSERT INTO training_units ( + group_id, planned_date, + planned_time_start, planned_time_end, planned_focus, + status, notes, trainer_notes, + created_by, plan_template_id, framework_slot_id + ) VALUES ( + NULL, NULL, + NULL, NULL, NULL, + 'planned', NULL, NULL, + %s, NULL, %s + ) + RETURNING id + """, + (profile_id, sid), + ) + bid = int(cur.fetchone()["id"]) + return sid, bid + + def _copy_blueprint_into_scheduled_unit( cur, blueprint_unit_id: int, @@ -1777,7 +1857,26 @@ def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_cont f""" SELECT t.*, (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) - AS sections_count + AS sections_count, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'id', s.id, + 'order_index', s.order_index, + 'title', s.title, + 'guidance_text', s.guidance_text, + 'phase_kind', s.phase_kind, + 'phase_order_index', s.phase_order_index, + 'parallel_stream_order_index', s.parallel_stream_order_index + ) + ORDER BY s.order_index + ) + FROM training_plan_template_sections s + WHERE s.template_id = t.id + ), + '[]'::json + ) AS sections FROM training_plan_templates t WHERE ({vis_clause}) ORDER BY t.updated_at DESC NULLS LAST, t.name @@ -2877,6 +2976,234 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = return get_training_unit(new_id, tenant) +@router.post("/training-units/{unit_id}/publish-to-framework") +def publish_training_unit_to_framework( + unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context) +): + """Geplanten Ablauf einer Einheit als Session-Blueprint in ein Rahmenprogramm übernehmen (neu / bestehend, Slot wählbar).""" + from routers.training_framework_programs import ( # noqa: WPS433 — zyklischer Import + _assert_visibility, + _fetch_framework_row, + _insert_goal_rows, + _parse_positive_int_ids, + _replace_target_groups, + _replace_training_types, + _response_framework_detail, + ) + + profile_id = tenant.profile_id + role = tenant.global_role + + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Rahmenprogramme bearbeiten") + + mode = (data.get("mode") or "").strip().lower() + if mode not in ("new_slot", "existing_slot"): + raise HTTPException(status_code=400, detail="mode muss new_slot oder existing_slot sein") + + fw_new = data.get("new_framework") + fw_id_raw = data.get("framework_program_id") + has_new = isinstance(fw_new, dict) and len(fw_new) > 0 + has_id = fw_id_raw not in (None, "") + + if has_new == has_id: + raise HTTPException( + status_code=400, + detail="Entweder new_framework ODER framework_program_id angeben (nicht beides, nicht keines)", + ) + + framework_id: int = 0 + slot_title_o: Optional[str] = None + notes_o = data.get("slot_notes") + st = data.get("slot_title") + if st is not None: + st = str(st).strip() + slot_title_o = st or None + + with get_db() as conn: + cur = get_cursor(conn) + + unit_row = _training_unit_guard_row(cur, unit_id) + if unit_row.get("framework_slot_id"): + raise HTTPException( + status_code=400, + detail="Nur geplante Einheiten (Kalender) können in einen Rahmen übernommen werden, keine Blueprint-Einheit", + ) + _assert_training_unit_permission(cur, unit_row, profile_id, role) + + if has_new: + title = (fw_new.get("title") or "").strip() + if not title: + raise HTTPException(status_code=400, detail="new_framework.title ist Pflicht") + + vis = _assert_visibility(fw_new.get("visibility") or "private") + club_nf = fw_new.get("club_id") + if club_nf in ("", []): + club_nf = None + if vis == "club" and club_nf is None: + club_nf = tenant.effective_club_id + + goals_in = fw_new.get("goals") + if not isinstance(goals_in, list) or not goals_in: + raise HTTPException( + status_code=400, + detail="new_framework.goals als Liste mit mindestens einem Eintrag ist Pflicht", + ) + + fa_id = _optional_positive_int(fw_new.get("focus_area_id"), "focus_area_id") + sd_id = _optional_positive_int(fw_new.get("style_direction_id"), "style_direction_id") + tt_ids = _parse_positive_int_ids(fw_new.get("training_type_ids"), "training_type_ids") + tg_ids = _parse_positive_int_ids(fw_new.get("target_group_ids"), "target_group_ids") + + assert_valid_governance_visibility(cur, profile_id, role, vis, club_nf) + cur.execute( + """ + INSERT INTO training_framework_programs ( + title, description, + planned_period_start, planned_period_end, + visibility, club_id, created_by, + focus_area_id, style_direction_id + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + title[:200], + fw_new.get("description"), + fw_new.get("planned_period_start"), + fw_new.get("planned_period_end"), + vis, + club_nf, + profile_id, + fa_id, + sd_id, + ), + ) + framework_id = int(cur.fetchone()["id"]) + _insert_goal_rows(cur, framework_id, goals_in) + _replace_training_types(cur, framework_id, tt_ids) + _replace_target_groups(cur, framework_id, tg_ids) + else: + try: + framework_id = int(fw_id_raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="framework_program_id ungültig") from None + if framework_id < 1: + raise HTTPException(status_code=400, detail="framework_program_id ungültig") + + row_fw = _fetch_framework_row(cur, framework_id) + assert_library_content_editable(cur, profile_id, role, row_fw) + + ins_raw = data.get("insert_at_index") + slot_id_out: int = 0 + + if mode == "new_slot": + pos: Optional[int] = None + if ins_raw is not None and ins_raw != "": + try: + pos = int(ins_raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="insert_at_index ungültig") from None + if pos < 0: + raise HTTPException(status_code=400, detail="insert_at_index ungültig") + + if pos is None: + cur.execute( + """ + SELECT COALESCE(MAX(sort_order), -1) + 1 AS n + FROM training_framework_slots + WHERE framework_program_id = %s + """, + (framework_id,), + ) + pos = int(cur.fetchone()["n"]) + else: + cur.execute( + "SELECT COUNT(*)::int AS c FROM training_framework_slots WHERE framework_program_id = %s", + (framework_id,), + ) + cmax = int(cur.fetchone()["c"]) + if pos > cmax: + pos = cmax + _shift_framework_slots_sort_orders_from(cur, framework_id, pos) + + sid, bid = _insert_framework_slot_and_blueprint_unit( + cur, + framework_id, + pos, + slot_title_o, + notes_o, + profile_id, + ) + slot_id_out = sid + _copy_scheduled_unit_plan_to_blueprint(cur, unit_id, bid, profile_id, role) + _promote_private_exercises_used_in_unit(cur, bid, profile_id, role) + + else: + raw_existing = data.get("framework_slot_id") + try: + slot_id_out = int(raw_existing) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="framework_slot_id ist Pflicht und muss eine Zahl sein") from None + if slot_id_out < 1: + raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig") + + cur.execute( + """ + SELECT id, framework_program_id, sort_order, title, notes + FROM training_framework_slots + WHERE id = %s + """, + (slot_id_out,), + ) + slot_row = cur.fetchone() + if not slot_row: + raise HTTPException(status_code=404, detail="Rahmen-Slot nicht gefunden") + if int(slot_row["framework_program_id"]) != framework_id: + raise HTTPException(status_code=400, detail="Slot gehört nicht zu diesem Rahmenprogramm") + + cur.execute( + "SELECT id FROM training_units WHERE framework_slot_id = %s", + (slot_id_out,), + ) + bp = cur.fetchone() + if not bp: + raise HTTPException(status_code=404, detail="Keine Blueprint-Einheit für diesen Slot") + + meta_fields: List[str] = [] + meta_params: List[Any] = [] + if "slot_title" in data: + stn = data.get("slot_title") + title_v = (str(stn).strip() or None) if stn is not None else None + meta_fields.append("title = %s") + meta_params.append(title_v) + if "slot_notes" in data: + meta_fields.append("notes = %s") + meta_params.append(data.get("slot_notes")) + if meta_fields: + meta_params.append(slot_id_out) + cur.execute( + f""" + UPDATE training_framework_slots + SET {", ".join(meta_fields)} + WHERE id = %s + """, + tuple(meta_params), + ) + + bid = int(bp["id"]) + _copy_scheduled_unit_plan_to_blueprint(cur, unit_id, bid, profile_id, role) + _promote_private_exercises_used_in_unit(cur, bid, profile_id, role) + + cur.execute( + "UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s", + (framework_id,), + ) + + conn.commit() + + return _response_framework_detail(framework_id, profile_id, role) + + @router.post("/training-units/quick-create") def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id diff --git a/backend/version.py b/backend/version.py index a3c7b7d..6adcb35 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,7 +1,7 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.145" -BUILD_DATE = "2026-05-16" +APP_VERSION = "0.8.149" +BUILD_DATE = "2026-05-19" DB_SCHEMA_VERSION = "20260516065" MODULE_VERSIONS = { @@ -22,9 +22,9 @@ MODULE_VERSIONS = { "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "methods": "0.1.0", "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break - "training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak + "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", - "planning": "0.13.0", # Vorlagen/Framework/Module/Graphs: RBAC wie Übungen (edit/delete/governance transition); Planungs-UI Sichtbarkeit neue Vorlage + "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine) "training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen) "import_wiki": "1.0.3", # Default-Kategorie Fähigkeiten: Fähigkeitsbeschreibung; cmtitle-Normalisierung; UI Preview/Execute Defaults je Typ @@ -36,6 +36,35 @@ 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", + "changes": [ + "Planung Vorlagen: Strukturvorschau (Split-Sessions), Kurzbeschreibung, Bearbeitungsseite; Liste liefert sections[] mit", + ], + }, + { + "version": "0.8.147", + "date": "2026-05-19", + "changes": [ + "Planung: Liste – Rahmen-Session & Übungen→Modul; Dialog Modul aus Einheit; klarere Rahmen-Unit-ID aus Liste", + ], + }, + { + "version": "0.8.146", + "date": "2026-05-19", + "changes": [ + "Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework", + ], + }, { "version": "0.8.145", "date": "2026-05-16", 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 65c338e..ee2da58 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import { Outlet, } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' +import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext' import { ToastProvider } from './context/ToastContext' import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import DesktopSidebar from './components/DesktopSidebar' @@ -30,6 +31,9 @@ const ClubsPage = lazy(() => import('./pages/ClubsPage')) const InboxPage = lazy(() => import('./pages/InboxPage')) const SkillsPage = lazy(() => import('./pages/SkillsPage')) const TrainingPlanningPage = lazy(() => import('./pages/TrainingPlanningPage')) +const TrainingPlanTemplatesListPage = lazy(() => import('./pages/TrainingPlanTemplatesListPage')) +const TrainingPlanTemplateEditPage = lazy(() => import('./pages/TrainingPlanTemplateEditPage')) +const PlanningLayout = lazy(() => import('./layouts/PlanningLayout')) const TrainingFrameworkProgramsListPage = lazy(() => import('./pages/TrainingFrameworkProgramsListPage'), ) @@ -39,6 +43,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')) @@ -141,22 +146,26 @@ function ProtectedLayout() { return ( - -
-
-
-
-
🥋 Shinkan
+ + +
+
+
+
+
🥋 Shinkan
+
+
- +
+ + +
+ +
-
- - -
-
-
+ ) } @@ -223,15 +232,25 @@ const appRouter = createBrowserRouter([ { path: 'clubs', element: }, { path: 'inbox', element: }, { path: 'skills', element: }, + { + path: 'planning', + element: , + children: [ + { index: true, element: }, + { 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: }, { path: 'planning/framework-programs/:id', element: }, - { path: 'planning/framework-programs', element: }, { path: 'planning/training-modules/new', element: }, { path: 'planning/training-modules/:id', element: }, - { path: 'planning/training-modules', element: }, + { path: 'planning/plan-templates/:id', element: }, { path: 'planning/run/:unitId/coach', element: }, { path: 'planning/run/:unitId', element: }, - { path: 'planning', element: }, { path: 'admin', element: }, { path: 'admin/users', diff --git a/frontend/src/api/planning.js b/frontend/src/api/planning.js index eb02a91..8eb6ee1 100644 --- a/frontend/src/api/planning.js +++ b/frontend/src/api/planning.js @@ -144,6 +144,14 @@ export async function applyTrainingModuleToTrainingUnit(unitId, data) { }) } +/** Geplanten Ablauf als Session-Blueprint in ein Rahmenprogramm schreiben (neu oder bestehend). */ +export async function publishTrainingUnitToFramework(unitId, data) { + return request(`/api/training-units/${unitId}/publish-to-framework`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + export async function listTrainingFrameworkPrograms() { return request('/api/training-framework-programs') } diff --git a/frontend/src/app.css b/frontend/src/app.css index a3d4378..0c500ce 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1155,6 +1155,359 @@ a.analysis-split__nav-item { min-width: 0; } +/* Planung: gemeinsame Chip-Navigation + Inhalt */ +.planning-layout__main { + min-width: 0; +} +.planning-route-nav { + margin-bottom: 1.25rem; +} + +/* Formular-Aktionsleiste (sticky, schmal, touch-freundlich) */ +.form-action-bar { + flex-shrink: 0; + position: sticky; + bottom: 0; + z-index: 12; + background: var(--surface); + border-top: 1px solid var(--border); + padding: 8px clamp(10px, 2.5vw, 14px); + padding-bottom: max(8px, env(safe-area-inset-bottom, 0px)); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06); +} +.form-action-bar--top { + border-top: none; + border-bottom: 1px solid var(--border); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); +} +.form-action-bar--modal { + margin-top: auto; +} +.form-action-bar__inner { + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: flex-start; + gap: 8px; + max-width: min(1100px, 100%); + margin: 0 auto; + min-width: 0; +} +.form-action-bar__primary-group { + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: flex-end; + gap: 8px; + margin-left: auto; + min-width: 0; + flex: 1 1 auto; +} +.form-action-bar__btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 44px; + padding: 8px 14px; + font-size: 0.88rem; + font-weight: 600; + white-space: nowrap; + min-width: 0; +} +.form-action-bar__btn--cancel { + min-width: 5.5rem; +} +.form-action-bar__icon { + flex-shrink: 0; + display: none; +} +.form-action-bar__text--short { + display: none; +} +.form-action-bar__saving { + font-size: 0.82rem; + letter-spacing: 0.02em; +} + +/* Form-Modale: Overlay + Panel (Desktop zentriert, Mobile Vollbild) */ +html.modal-scroll-locked { + overflow: hidden; + overscroll-behavior: none; + height: 100%; +} +html.modal-scroll-locked body { + overflow: hidden !important; + overscroll-behavior: none; +} +html.modal-scroll-locked .app-shell, +html.modal-scroll-locked .app-shell__column { + overflow: hidden; +} +html.modal-scroll-locked .app-main { + overflow: hidden !important; + overscroll-behavior: none; +} + +.modal-overlay--form { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; + overflow: hidden; + overscroll-behavior: none; + touch-action: none; + box-sizing: border-box; + width: 100%; + max-width: 100vw; +} +.modal-overlay--form.modal-overlay--raised { + z-index: 1100; +} +.modal-overlay__focus-catcher { + position: absolute; + width: 0; + height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; +} +.modal-panel--form { + display: flex; + flex-direction: column; + background: var(--surface); + border-radius: 12px; + padding: clamp(12px, 3vw, 2rem); + width: 100%; + max-width: min(1100px, 100%); + max-height: min(92vh, 100dvh); + overflow: hidden; + min-height: 0; + min-width: 0; + box-sizing: border-box; + touch-action: manipulation; + flex: 0 1 auto; + margin: auto; + align-self: center; +} +.modal-panel--form.modal-panel--narrow { + max-width: min(560px, 100%); +} +.modal-panel--form.card { + /* card-Klasse nur für Desktop-Rahmen; Mobile überschreibt unten */ +} +.modal-panel__title { + margin: 0 0 1rem; + flex-shrink: 0; + font-size: clamp(1.05rem, 4vw, 1.25rem); + line-height: 1.25; + min-width: 0; +} +.modal-panel__intro { + font-size: 0.88rem; + color: var(--text2); + line-height: 1.45; + margin: 0 0 1rem; + flex-shrink: 0; + min-width: 0; +} +.modal-form-shell { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; +} +.modal-form-shell__body { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + overscroll-behavior-x: none; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; + padding-bottom: 4px; +} +.modal-panel--form > *, +.modal-form-shell__body > * { + max-width: 100%; + min-width: 0; +} + +@media (max-width: 639px) { + .modal-overlay--form { + padding: 0; + align-items: stretch; + justify-content: stretch; + width: 100vw; + max-width: 100vw; + left: 0; + right: 0; + } + .modal-panel--form { + max-width: 100%; + width: 100%; + max-height: 100dvh; + height: 100dvh; + border-radius: 0; + margin: 0; + flex: 1 1 auto; + align-self: stretch; + padding: 12px; + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + padding-top: max(12px, env(safe-area-inset-top, 0px)); + padding-bottom: max(0px, env(safe-area-inset-bottom, 0px)); + } + .modal-panel--form.card { + border: none; + border-radius: 0; + padding: 12px; + padding-top: max(12px, env(safe-area-inset-top, 0px)); + padding-bottom: max(0px, env(safe-area-inset-bottom, 0px)); + } + .modal-panel__title { + margin-bottom: 0.75rem; + font-size: 1.05rem; + } + .modal-panel__intro { + margin-bottom: 0.75rem; + font-size: 0.85rem; + } + + .form-action-bar { + padding: 5px 6px; + padding-bottom: max(5px, env(safe-area-inset-bottom, 0px)); + } + .form-action-bar__inner { + flex-wrap: nowrap; + gap: 6px; + max-width: none; + } + .form-action-bar__primary-group { + flex: 1 1 0; + flex-wrap: nowrap; + gap: 6px; + margin-left: 0; + min-width: 0; + } + .form-action-bar__btn { + min-height: 38px; + padding: 6px 8px; + font-size: 0.75rem; + gap: 4px; + flex: 1 1 0; + } + .form-action-bar__btn--cancel { + flex: 0 0 38px; + width: 38px; + min-width: 38px; + max-width: 38px; + padding: 0; + } + .form-action-bar__btn--cancel .form-action-bar__text--long, + .form-action-bar__btn--cancel .form-action-bar__text--short { + display: none !important; + } + .form-action-bar__icon { + display: inline; + } + .form-action-bar__text--long { + display: none; + } + .form-action-bar__text--short { + display: inline; + } + .form-action-bar--single-primary .form-action-bar__primary-group .form-action-bar__btn { + flex: 1 1 auto; + } +} + +.page-form-shell { + display: flex; + flex-direction: column; + min-height: 0; +} +.page-form-shell__scroll { + flex: 1 1 auto; + min-height: 0; +} +.page-form-shell .form-action-bar { + position: sticky; + bottom: 0; + margin-top: 1rem; + margin-left: calc(-1 * var(--page-pad, 16px)); + margin-right: calc(-1 * var(--page-pad, 16px)); + padding-left: var(--page-pad, 16px); + padding-right: var(--page-pad, 16px); +} + +/* Vollseiten-Editor: Kopfzeile (Zurück/Titel) + fixiertes Action-Dock unten (alle Viewports) */ +.page-form-editor__header { + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border); +} +.page-form-editor__intro { + min-width: 0; +} +.page-form-editor__back { + display: inline-block; + margin-bottom: 0.35rem; + color: var(--accent-dark); + font-weight: 600; + font-size: 0.9rem; + text-decoration: none; +} +.page-form-editor__title { + margin: 0; + font-size: clamp(1.15rem, 4vw, 1.45rem); + line-height: 1.25; +} + +/* Ersetzt Bottom-Nav (Mobile) bzw. schwebt unten fix (Desktop) — nur Breite per Breakpoint */ +.form-editor-mobile-dock { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 20; + width: auto; + max-width: none; + background: var(--surface); + border-top: 1px solid var(--border); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06); + padding: var(--nav-pad-top, 8px) max(10px, env(safe-area-inset-right, 0px)) + max(8px, env(safe-area-inset-bottom, 0px)) max(10px, env(safe-area-inset-left, 0px)); + box-sizing: border-box; +} +.form-editor-mobile-dock .form-action-bar { + position: static; + border: none; + box-shadow: none; + margin: 0; + padding-bottom: 0; +} + +.app-shell__column:has(.form-editor-mobile-dock) .app-main { + padding-bottom: calc(56px + var(--nav-pad-top, 8px) + env(safe-area-inset-bottom, 0px) + 16px); +} + +@media (min-width: 1024px) { + .form-editor-mobile-dock { + left: var(--desktop-sidebar-width, 220px); + width: calc(100% - var(--desktop-sidebar-width, 220px)); + } +} + /* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */ .settings-shell { width: 100%; 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 }) { }} > {savingShort} + } + return ( + <> + {Icon ? : null} + {long} + {short != null && short !== '' ? ( + {short} + ) : null} + + ) +} + +export default function FormActionBar({ + placement = 'bottom', + variant = 'default', + saving = false, + saveLabel, + saveShortLabel, + saveAndCloseLabel = 'Speichern & schließen', + saveAndCloseShortLabel, + cancelLabel = 'Abbrechen', + onSave, + onSaveAndClose, + onCancel, + showSave = true, + showSaveAndClose = true, + showCancel = true, + formId, + isNew = false, + primaryIsSaveOnly = false, +}) { + const labelSave = saveLabel ?? (isNew ? 'Anlegen' : 'Speichern') + const shortSave = saveShortLabel ?? (isNew ? 'Neu' : 'Sichern') + const shortClose = saveAndCloseShortLabel ?? 'Fertig' + const showSaveBtn = showSave && (Boolean(onSave) || Boolean(formId)) + const showCloseBtn = showSaveAndClose && (Boolean(onSaveAndClose) || Boolean(formId)) + const showCancelBtn = showCancel && Boolean(onCancel) + + if (!showSaveBtn && !showCloseBtn && !showCancelBtn) return null + + const saveBtnClass = `btn form-action-bar__btn${ + primaryIsSaveOnly ? ' btn-primary' : ' btn-secondary' + }` + const closeBtnClass = `btn form-action-bar__btn${ + primaryIsSaveOnly ? ' btn-secondary' : ' btn-primary' + }` + + const primaryCount = (showSaveBtn ? 1 : 0) + (showCloseBtn ? 1 : 0) + + return ( +
+
+ {showCancelBtn ? ( + + ) : null} +
+ {showSaveBtn ? ( + + ) : null} + {showCloseBtn ? ( + + ) : null} +
+
+
+ ) +} diff --git a/frontend/src/components/FormModalOverlay.jsx b/frontend/src/components/FormModalOverlay.jsx new file mode 100644 index 0000000..6365ae6 --- /dev/null +++ b/frontend/src/components/FormModalOverlay.jsx @@ -0,0 +1,56 @@ +import React, { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useModalScrollLock } from '../hooks/useModalScrollLock' + +/** + * Formular-Modal am document.body (Portal) — volle Viewport-Breite, kein Hintergrund-Scroll. + */ +export default function FormModalOverlay({ + open, + children, + raised = false, + className = '', + onBackdropClick, + 'data-testid': testId, +}) { + const overlayRef = useRef(null) + useModalScrollLock(open) + + useEffect(() => { + if (!open) return undefined + const t = window.setTimeout(() => { + overlayRef.current?.focus({ preventScroll: true }) + }, 0) + return () => clearTimeout(t) + }, [open]) + + if (!open) return null + + const overlayClass = [ + 'modal-overlay', + 'modal-overlay--form', + raised ? 'modal-overlay--raised' : '', + className, + ] + .filter(Boolean) + .join(' ') + + return createPortal( +
{ + if (e.target === e.currentTarget) onBackdropClick() + } + : undefined + } + > + {children} +
, + document.body, + ) +} diff --git a/frontend/src/components/PageFormEditorChrome.jsx b/frontend/src/components/PageFormEditorChrome.jsx new file mode 100644 index 0000000..57151be --- /dev/null +++ b/frontend/src/components/PageFormEditorChrome.jsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { useFormEditorActions } from '../context/FormEditorActionsContext' + +/** + * Vollseiten-Editor: Zurück/Titel oben; FormActionBar fix unten (alle Viewports via FormEditorBottomSlot). + */ +export default function PageFormEditorChrome({ + title, + backTo, + backLabel = 'Zurück', + actionConfig, + children, + testId, +}) { + useFormEditorActions(actionConfig) + + return ( +
+
+ {backTo ? ( + + ← {backLabel} + + ) : null} +

{title}

+
+
{children}
+
+ ) +} diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index a58fe7f..f2f148a 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -266,6 +266,8 @@ export default function TrainingUnitSectionsEditor({ betweenInsertMenus = true, /** Trainingsplanung: Phasen/Streams anlegen und Abschnitte zuordnen */ enableParallelPhaseControls = false, + /** Nur Abschnitts-Gliederung (Vorlagen): keine Übungen/Anmerkungen */ + structureOnly = false, }) { const { user } = useAuth() const planningCompactLegend = isCompactTagLegendMode( @@ -2021,15 +2023,17 @@ export default function TrainingUnitSectionsEditor({
) : null} - {planMin > 0 && ( + {!structureOnly && planMin > 0 && (

Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)

)} - {betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null} + {!structureOnly ? ( + <> + {betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null} - {(sec.items || []).map((it, iIdx) => { + {(sec.items || []).map((it, iIdx) => { const dropHere = enableItemDragReorder && dropTargetPos?.sIdx === sIdx && @@ -2541,6 +2545,8 @@ export default function TrainingUnitSectionsEditor({
) : null} + + ) : null}
{useStreamTagDropUx && pl?.phaseKind === 'parallel' && parallelPhaseOrder != null ? (() => { diff --git a/frontend/src/components/planning/PlanTemplateStructurePreview.jsx b/frontend/src/components/planning/PlanTemplateStructurePreview.jsx new file mode 100644 index 0000000..d8ad88c --- /dev/null +++ b/frontend/src/components/planning/PlanTemplateStructurePreview.jsx @@ -0,0 +1,53 @@ +import React, { useMemo } from 'react' +import { formatPlanTemplateStructurePreview } from '../../utils/trainingUnitSectionsForm' + +export default function PlanTemplateStructurePreview({ sections, compact = false }) { + const preview = useMemo(() => formatPlanTemplateStructurePreview(sections), [sections]) + + if (preview.isEmpty) { + return ( +

+ Noch keine Abschnitte definiert. +

+ ) + } + + return ( + + ) +} diff --git a/frontend/src/components/planning/PlanningRouteNav.jsx b/frontend/src/components/planning/PlanningRouteNav.jsx new file mode 100644 index 0000000..bf3c1da --- /dev/null +++ b/frontend/src/components/planning/PlanningRouteNav.jsx @@ -0,0 +1,31 @@ +import { NavLink } from 'react-router-dom' + +const ITEMS = [ + { to: '/planning', label: 'Trainingsplanung', end: true }, + { to: '/planning/framework-programs', label: 'Rahmenprogramme' }, + { to: '/planning/training-modules', label: 'Trainingsmodule' }, + { to: '/planning/plan-templates', label: 'Vorlagen' }, +] + +/** Oberste Planungs-Navigation (Chip-Register). */ +export default function PlanningRouteNav() { + return ( + + ) +} diff --git a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx new file mode 100644 index 0000000..ee2c119 --- /dev/null +++ b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx @@ -0,0 +1,312 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import FormActionBar from '../FormActionBar' +import FormModalOverlay from '../FormModalOverlay' +import api from '../../utils/api' +import { useToast } from '../../context/ToastContext' +import { useAuth } from '../../context/AuthContext' +import { activeClubMemberships } from '../../utils/activeClub' +import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit' + +/** + * Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl). + */ +export default function SaveExercisesAsModuleModal({ + open, + onClose, + unitId, + planningModalClubId, + onSuccess, +}) { + const navigate = useNavigate() + const toast = useToast() + const { user } = useAuth() + const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs]) + const roleLc = String(user?.role || '').toLowerCase() + const isSuperadmin = roleLc === 'superadmin' + + const [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [loadErr, setLoadErr] = useState('') + const [unitLabel, setUnitLabel] = useState('') + const [candidates, setCandidates] = useState([]) + const [selected, setSelected] = useState(() => []) + + const [title, setTitle] = useState('') + const [visibility, setVisibility] = useState('club') + const [clubId, setClubId] = useState('') + + const resetLocal = useCallback(() => { + setLoadErr('') + setUnitLabel('') + setCandidates([]) + setSelected([]) + setTitle('') + setVisibility('club') + setClubId('') + }, []) + + useEffect(() => { + if (!open || !unitId) { + resetLocal() + return + } + if (planningModalClubId != null && planningModalClubId !== '') { + setClubId(String(planningModalClubId)) + } else if (memberClubs.length === 1) { + setClubId(String(memberClubs[0].id)) + } + setLoading(true) + setLoadErr('') + api + .getTrainingUnit(unitId) + .then((u) => { + const dateStr = (u.planned_date || '').trim() || 'Training' + setUnitLabel(dateStr) + setTitle(`Modul · ${dateStr}`) + const c = collectExercisePlacementsForModule(u) + setCandidates(c) + setSelected(c.map(() => true)) + }) + .catch((e) => { + setLoadErr(e.message || 'Einheit konnte nicht geladen werden') + setCandidates([]) + setSelected([]) + }) + .finally(() => setLoading(false)) + }, [open, unitId, planningModalClubId, memberClubs.length, resetLocal]) + + const toggleOne = (idx) => { + setSelected((prev) => { + const next = [...prev] + next[idx] = !next[idx] + return next + }) + } + + const setAll = (on) => { + setSelected(candidates.map(() => on)) + } + + const selectedCount = selected.filter(Boolean).length + + const handleSubmit = async (e) => { + e.preventDefault() + if (!unitId || submitting) return + const itemsPayload = [] + let oi = 0 + for (let i = 0; i < candidates.length; i += 1) { + if (!selected[i]) continue + const c = candidates[i] + itemsPayload.push({ + item_type: 'exercise', + order_index: oi, + exercise_id: c.exercise_id, + exercise_variant_id: c.exercise_variant_id, + planned_duration_min: c.planned_duration_min, + notes: c.notes, + }) + oi += 1 + } + if (!itemsPayload.length) { + toast.error('Mindestens eine Übung auswählen.') + return + } + const tit = (title || '').trim() + if (!tit) { + toast.error('Bitte einen Modultitel angeben.') + return + } + let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null + if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { + toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).') + return + } + if (visibility !== 'club') cid = null + + setSubmitting(true) + try { + const created = await api.createTrainingModule({ + title: tit, + visibility, + club_id: cid, + items: itemsPayload, + }) + toast.success('Trainingsmodul gespeichert.') + if (created?.id) { + navigate(`/planning/training-modules/${created.id}`) + } + onSuccess?.() + onClose() + } catch (err) { + toast.error(err.message || 'Speichern fehlgeschlagen') + } finally { + setSubmitting(false) + } + } + + if (!open) return null + + return ( + +
+

Übungen als Trainingsmodul

+

+ Es werden die gespeicherten Übungspositionen der Einheit vom{' '} + {unitLabel || '…'} verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand + brauchst. +

+ + {loading ? ( +

Laden …

+ ) : loadErr ? ( +

{loadErr}

+ ) : candidates.length === 0 ? ( +

In dieser Einheit sind keine Übungen im Ablauf hinterlegt.

+ ) : ( +
+
+
+ + setTitle(e.target.value)} + required + placeholder="z. B. Aufwärmsequenz" + /> +
+ +
+ + + + {selectedCount} von {candidates.length} gewählt (Reihenfolge wie im Plan) + +
+ +
+
    + {candidates.map((c, idx) => ( +
  • + toggleOne(idx)} + style={{ marginTop: 4 }} + /> +
    +
    + {c.exercise_title} +
    + {c.contextLabel ? ( +
    {c.contextLabel}
    + ) : null} +
    + {c.exercise_variant_id ? `Variante #${c.exercise_variant_id}` : 'Standard-Variante'} + {c.planned_duration_min != null && Number.isFinite(Number(c.planned_duration_min)) + ? ` · ${c.planned_duration_min} Min (Plan)` + : ''} +
    +
    +
  • + ))} +
+
+ +
+ + +
+ {visibility === 'club' ? ( +
+ + +
+ ) : null} + +
+ + + + )} + + {loading ? ( +
+ +
+ ) : null} + + {!loading && loadErr ? ( +
+ +
+ ) : null} + + {!loading && !loadErr && candidates.length === 0 ? ( +
+ +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx index e72c214..7e42b8a 100644 --- a/frontend/src/components/planning/TrainingPlanningPageRoot.jsx +++ b/frontend/src/components/planning/TrainingPlanningPageRoot.jsx @@ -1,27 +1,14 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { Link, useSearchParams } from 'react-router-dom' +import { Link, useNavigate, useSearchParams } from 'react-router-dom' import api from '../../utils/api' import { useAuth } from '../../context/AuthContext' import { useToast } from '../../context/ToastContext' import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub' -import ExercisePickerModal from '../ExercisePickerModal' -import ExercisePeekModal from '../ExercisePeekModal' import PageSectionNav from '../PageSectionNav' import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImportModal' -import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal' import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal' -import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal' -/* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */ -import { - defaultSection, - normalizeUnitToForm, - enrichSectionsWithVariants, - buildPlanPayloadForSave, - hydrateExercisePlanningRow, - insertTrainingModuleIntoPlanningSections, - templateSectionsPayloadFromFormSections, - formSectionsFromPlanTemplateRows, -} from '../../utils/trainingUnitSectionsForm' +import TrainingPublishToFrameworkModal from './TrainingPublishToFrameworkModal' +import SaveExercisesAsModuleModal from './SaveExercisesAsModuleModal' import { addDaysIsoDate, pad2, @@ -37,26 +24,31 @@ import { filterDirectoryExcludingLead, frameworkLineageText, } from '../../utils/trainingPlanningPageHelpers' +import { + buildPlanUnitEditPath, + buildPlanUnitNewPath, + buildPlanningHubReturnState, + legacyPlanningUnitDeepLinkTarget, + parsePlanningHubQuery, +} from '../../utils/planningUnitRoutes' function TrainingPlanningPageRoot() { const { user } = useAuth() const toast = useToast() + const navigate = useNavigate() const tenantClubDepKey = useMemo(() => 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) - /** 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 [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) const today = new Date().toISOString().split('T')[0] const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] @@ -72,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') @@ -105,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) => { @@ -235,7 +134,7 @@ function TrainingPlanningPageRoot() { } finally { setLoading(false) } - }, [user?.id, loadPlanTemplates]) + }, [user?.id, toast]) const loadUnits = useCallback(async () => { if (!selectedGroupId) return @@ -313,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) @@ -325,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([]) @@ -352,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 @@ -490,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) => { @@ -519,369 +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 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(), - 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 handleDeletePlanTemplate = useCallback( - async (tpl) => { - if (!tpl?.id) return - const label = (tpl.name || '').trim() || `Vorlage #${tpl.id}` - if ( - !window.confirm( - `Trainingsvorlage „${label}“ wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden.` - ) - ) { - return - } - try { - await api.deleteTrainingPlanTemplate(tpl.id) - setDraftPlanTemplateId((prev) => (String(prev) === String(tpl.id) ? '' : prev)) - await loadPlanTemplates() - toast.success('Vorlage gelöscht.') - } catch (err) { - toast.error(err.message || 'Löschen fehlgeschlagen') - } - }, - [loadPlanTemplates, toast] - ) - - 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 @@ -988,94 +556,6 @@ function TrainingPlanningPageRoot() { } } - const handleSubmit = async (e) => { - e.preventDefault() - if (!formData.group_id || !formData.planned_date) { - toast.error('Gruppe und Datum sind Pflichtfelder') - return - } - 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) - } - } - - if (editingUnit) { - await api.updateTrainingUnit(editingUnit.id, payload) - } else { - await api.createTrainingUnit(payload) - } - setShowModal(false) - await loadUnits() - } catch (err) { - toast.error('Fehler beim Speichern: ' + err.message) - } - } - - 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) @@ -1138,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() @@ -1172,7 +632,7 @@ function TrainingPlanningPageRoot() { const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid) return ( -
+ <>

Trainingsplanung

Wähle eine Trainingsgruppe und lege Trainingseinheiten für den Zeitraum an (Inhalt: Abschnitte - und Übungen). + und Übungen). Rahmenprogramme, Module und Vorlagen erreichst du über die Registerkarten oben.

-
-

- Mehrere Einheiten strukturieren auf einmal:{' '} - - Trainingsrahmenprogramme - {' '} - (Ziele, Sessions, Vorlagen‑Ablauf). -

-

- Wiederverwendbare Blöcke innerhalb einer Einheit:{' '} - - Trainingsmodule - {' '} - (übernahme als Kopie beim Bearbeiten einer Einheit). -

-
{!loading && groups.length === 0 && (
handleEdit(unit)}> Bearbeiten + + {mayConfigureSessionAssignments(unit) ? (
+ + { + setSaveModuleOpen(false) + setSaveModuleUnitId(null) + }} + onSuccess={() => { + setSaveModuleOpen(false) + setSaveModuleUnitId(null) + }} + unitId={saveModuleUnitId} + planningModalClubId={listActionClubId} + /> + ) } diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx index 7136320..7622624 100644 --- a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx @@ -1,9 +1,10 @@ import React, { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' +import FormActionBar from '../FormActionBar' +import FormModalOverlay from '../FormModalOverlay' import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel' import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor' import { activeClubMemberships } from '../../utils/activeClub' -import { canDeleteLibraryContent } from '../../utils/libraryContentPermissions' import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers' /** @@ -16,11 +17,12 @@ export default function TrainingPlanningUnitFormModal({ updateFormField, setFormData, onSubmit, + onSaveOnly, + onSaveAndClose, onCancel, draftPlanTemplateId, onDraftTemplateSelect, planTemplates, - onDeletePlanTemplate, clubDirectory, clubDirectoryForCo, planningModalClubId, @@ -29,6 +31,8 @@ export default function TrainingPlanningUnitFormModal({ sectionsEditMode, setSectionsEditMode, onSaveAsTemplate, + onRequestPublishToFramework, + onRequestSaveAsModule, onRequestTrainingModulePick, onRequestExercisePick, onPeekExercise, @@ -51,42 +55,21 @@ export default function TrainingPlanningUnitFormModal({ if (!open) return null + const formId = 'planning-unit-form' + return ( -
-
-

+ +
+

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

+
(onSaveAndClose ? onSaveAndClose(e) : onSubmit?.(e))} + > +
{editingUnit?.origin_framework_slot_id ? (() => { const L = frameworkLineageText(editingUnit) @@ -148,80 +131,11 @@ export default function TrainingPlanningUnitFormModal({

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

)} - {planTemplates.length > 0 && typeof onDeletePlanTemplate === 'function' ? ( -
- - Gespeicherte Vorlagen löschen - -

- Entfernen nach Rolle: eigene private Vorlagen; Vereins­inhalte als Vereins­admin; offizielle nur als - Plattform‑Admin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt. -

-
    - {planTemplates.map((t, ti) => { - const canDel = user && canDeleteLibraryContent(user, t) - return ( -
  • - - {t.name} - - ( - {String(t.visibility || 'club').toLowerCase() === 'private' - ? 'Privat' - : String(t.visibility || 'club').toLowerCase() === 'official' - ? 'Offiziell' - : 'Verein'} - ) - - {typeof t.sections_count === 'number' ? ( - - · {t.sections_count} Abschn. - - ) : null} - - {canDel ? ( - - ) : ( - nur Lesen - )} -
  • - ) - })} -
-
- ) : null} - -

Planung

@@ -492,6 +406,28 @@ export default function TrainingPlanningUnitFormModal({ > Vorlage aus Aufbau speichern + {editingUnit?.id && !editingUnit?.framework_slot_id ? ( + + ) : null} + {editingUnit?.id && !editingUnit?.framework_slot_id ? ( + + ) : null}
} @@ -625,16 +561,21 @@ export default function TrainingPlanningUnitFormModal({ />
-
- -
+ + onSaveOnly() : undefined} + onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined} + onCancel={onCancel} + showSave={Boolean(onSaveOnly)} + showSaveAndClose + />

-
+ ) } diff --git a/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx new file mode 100644 index 0000000..c7714d6 --- /dev/null +++ b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx @@ -0,0 +1,406 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import FormActionBar from '../FormActionBar' +import FormModalOverlay from '../FormModalOverlay' +import api from '../../utils/api' +import { useToast } from '../../context/ToastContext' +import { useAuth } from '../../context/AuthContext' +import { activeClubMemberships } from '../../utils/activeClub' + +/** + * Übernimmt den gespeicherten Ablauf einer geplanten Trainingseinheit in ein Rahmenprogramm (neu oder bestehend, Slot wählbar). + */ +export default function TrainingPublishToFrameworkModal({ + open, + onClose, + unitId, + planningModalClubId, + onSuccess, +}) { + const navigate = useNavigate() + const toast = useToast() + const { user } = useAuth() + const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs]) + const roleLc = String(user?.role || '').toLowerCase() + const isSuperadmin = roleLc === 'superadmin' + + const [scope, setScope] = useState('existing') + const [programs, setPrograms] = useState([]) + const [programsLoading, setProgramsLoading] = useState(false) + const [fwProgramId, setFwProgramId] = useState('') + const [fwDetail, setFwDetail] = useState(null) + const [fwDetailLoading, setFwDetailLoading] = useState(false) + + const [slotMode, setSlotMode] = useState('new_slot') + const [insertAt, setInsertAt] = useState('') + const [existingSlotId, setExistingSlotId] = useState('') + + const [newTitle, setNewTitle] = useState('') + const [newVisibility, setNewVisibility] = useState('private') + const [newClubId, setNewClubId] = useState('') + const [newGoalTitle, setNewGoalTitle] = useState('Aus geplanter Einheit') + + const [slotTitle, setSlotTitle] = useState('') + const [slotNotes, setSlotNotes] = useState('') + + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!open) { + return + } + if (planningModalClubId != null && planningModalClubId !== '') { + setNewClubId(String(planningModalClubId)) + } else if (memberClubs.length === 1) { + setNewClubId(String(memberClubs[0].id)) + } + setProgramsLoading(true) + api + .listTrainingFrameworkPrograms() + .then((list) => { + setPrograms(Array.isArray(list) ? list : []) + }) + .catch(() => setPrograms([])) + .finally(() => setProgramsLoading(false)) + }, [open, planningModalClubId, memberClubs]) + + useEffect(() => { + if (!open || scope !== 'existing' || !fwProgramId) { + setFwDetail(null) + return + } + const id = parseInt(fwProgramId, 10) + if (!Number.isFinite(id) || id < 1) { + setFwDetail(null) + return + } + setFwDetailLoading(true) + api + .getTrainingFrameworkProgram(id) + .then(setFwDetail) + .catch(() => setFwDetail(null)) + .finally(() => setFwDetailLoading(false)) + }, [open, scope, fwProgramId]) + + const sortedSlots = useMemo(() => { + const sl = fwDetail?.slots + if (!Array.isArray(sl)) return [] + return [...sl].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) + }, [fwDetail]) + + const resetAndClose = () => { + onClose() + } + + const handleSubmit = async (e) => { + e.preventDefault() + if (!unitId || submitting) return + + setSubmitting(true) + try { + if (scope === 'new') { + const tit = (newTitle || '').trim() + if (!tit) { + toast.error('Bitte einen Titel für das neue Rahmenprogramm angeben.') + setSubmitting(false) + return + } + const gt = (newGoalTitle || '').trim() || 'Entwicklungsziel' + let club_id = + newVisibility === 'club' && newClubId ? parseInt(newClubId, 10) : null + if (newVisibility === 'club' && (!Number.isFinite(club_id) || club_id < 1)) { + toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).') + setSubmitting(false) + return + } + if (newVisibility !== 'club') club_id = null + + const st = (slotTitle || '').trim() + const sn = (slotNotes || '').trim() + const created = await api.publishTrainingUnitToFramework(unitId, { + new_framework: { + title: tit, + visibility: newVisibility, + club_id, + goals: [{ sort_order: 0, title: gt, notes: null }], + }, + mode: 'new_slot', + insert_at_index: null, + ...(st ? { slot_title: st } : {}), + ...(sn ? { slot_notes: slotNotes } : {}), + }) + toast.success('Ablauf wurde im Rahmenprogramm gespeichert.') + if (created?.id) { + navigate(`/planning/framework-programs/${created.id}`) + } + onSuccess?.() + resetAndClose() + return + } else { + const fid = parseInt(fwProgramId, 10) + if (!Number.isFinite(fid) || fid < 1) { + toast.error('Bitte ein Rahmenprogramm auswählen.') + setSubmitting(false) + return + } + + const payload = { + framework_program_id: fid, + mode: slotMode, + } + const st = (slotTitle || '').trim() + const sn = (slotNotes || '').trim() + if (st) payload.slot_title = st + if (sn) payload.slot_notes = slotNotes + if (slotMode === 'new_slot') { + if (insertAt.trim() === '') { + payload.insert_at_index = null + } else { + const n = parseInt(insertAt, 10) + if (!Number.isFinite(n) || n < 0) { + toast.error('Position: nicht negative Ganzzahl oder leer (anhängen).') + setSubmitting(false) + return + } + payload.insert_at_index = n + } + } else { + const sid = parseInt(existingSlotId, 10) + if (!Number.isFinite(sid) || sid < 1) { + toast.error('Bitte einen Session-Slot zum Überschreiben wählen.') + setSubmitting(false) + return + } + payload.framework_slot_id = sid + } + + const updated = await api.publishTrainingUnitToFramework(unitId, payload) + toast.success('Ablauf wurde im Rahmenprogramm gespeichert.') + if (updated?.id) { + navigate(`/planning/framework-programs/${updated.id}`) + } + onSuccess?.() + resetAndClose() + return + } + } catch (err) { + toast.error(err.message || 'Speichern fehlgeschlagen') + } finally { + setSubmitting(false) + } + } + + if (!open) return null + + return ( + +
+

Ablauf ins Rahmenprogramm übernehmen

+

+ Es wird der zuletzt gespeicherte Ablauf dieser Einheit aus der Datenbank übernommen. + Nicht gespeicherte Änderungen im Formular sind nicht enthalten — bitte vorher die Einheit speichern. +

+ +
+
+
+ Ziel +
+ + +
+
+ + {scope === 'existing' ? ( + <> +
+ + +
+ +
+ Session-Platz +
+ + +
+
+ + {slotMode === 'new_slot' ? ( +
+ + setInsertAt(e.target.value)} + /> +

+ Die Reihenfolge der Slots kannst du in der Rahmen-Bearbeitung jederzeit ändern (Ziehen oder + Pfeile). +

+
+ ) : ( +
+ + +
+ )} + + ) : ( + <> +
+ + setNewTitle(e.target.value)} + placeholder="z. B. Saisonvorbereitung" + required + /> +
+
+ + setNewGoalTitle(e.target.value)} + /> +
+
+ + +
+ {newVisibility === 'club' ? ( +
+ + +
+ ) : null} + + )} + +
+ + setSlotTitle(e.target.value)} + placeholder="z. B. Woche 3 — Technik" + /> +
+
+ +