develop #38

Merged
Lars merged 14 commits from develop into main 2026-05-19 14:56:42 +02:00
41 changed files with 4656 additions and 978 deletions

View File

@ -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

View File

@ -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 1415",
],
},
{
"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",

View File

@ -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 **1415** (Edit-Route, Legacy-Redirect).
### Trainingsplan: Phasen, parallele Streams und Coaching (Stand **0.8.1370.8.140**)
- **Schema / API:** Migration **063**`training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` bzw. `parallel_stream_id`. **`GET /api/training-units/:id`** liefert **`phases`** (verschachtelt) und weiterhin flache **`sections`**. **`PUT/POST`** mit **`phases`** für Breakout-Einheiten (vgl. `CHANGELOG` **0.8.138**); Legacy: flache `sections` → implizite Ganzgruppen-Phase.

View File

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

View File

@ -5,7 +5,9 @@
"scripts": {
"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"
}
}

View File

@ -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 (
<OrgInboxProvider user={user}>
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
<div className="app-shell">
<div className="app-shell__column">
<div className="app-header app-header--mobile app-header--mobile-stack">
<div className="app-header-mobile__top">
<div className="app-logo">🥋 Shinkan</div>
<FormEditorActionsProvider>
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
<div className="app-shell">
<div className="app-shell__column">
<div className="app-header app-header--mobile app-header--mobile-stack">
<div className="app-header-mobile__top">
<div className="app-logo">🥋 Shinkan</div>
</div>
<ActiveClubSwitcher variant="mobile" />
</div>
<ActiveClubSwitcher variant="mobile" />
<div className="app-main">
<InactiveMembershipBanner />
<Outlet />
</div>
<FormEditorBottomSlot>
<Nav showAdminNav={showAdminNav} />
</FormEditorBottomSlot>
</div>
<div className="app-main">
<InactiveMembershipBanner />
<Outlet />
</div>
<Nav showAdminNav={showAdminNav} />
</div>
</div>
</FormEditorActionsProvider>
</OrgInboxProvider>
)
}
@ -223,15 +232,25 @@ const appRouter = createBrowserRouter([
{ path: 'clubs', element: <ClubsPage /> },
{ path: 'inbox', element: <InboxPage /> },
{ path: 'skills', element: <SkillsPage /> },
{
path: 'planning',
element: <PlanningLayout />,
children: [
{ index: true, element: <TrainingPlanningPage /> },
{ path: 'framework-programs', element: <TrainingFrameworkProgramsListPage /> },
{ path: 'training-modules', element: <TrainingModulesListPage /> },
{ path: 'plan-templates', element: <TrainingPlanTemplatesListPage /> },
{ path: 'units/new', element: <TrainingUnitEditPage /> },
{ path: 'units/:id/edit', element: <TrainingUnitEditPage /> },
],
},
{ path: 'planning/framework-programs/new', element: <TrainingFrameworkProgramEditPage /> },
{ path: 'planning/framework-programs/:id', element: <TrainingFrameworkProgramEditPage /> },
{ path: 'planning/framework-programs', element: <TrainingFrameworkProgramsListPage /> },
{ path: 'planning/training-modules/new', element: <TrainingModuleEditPage /> },
{ path: 'planning/training-modules/:id', element: <TrainingModuleEditPage /> },
{ path: 'planning/training-modules', element: <TrainingModulesListPage /> },
{ path: 'planning/plan-templates/:id', element: <TrainingPlanTemplateEditPage /> },
{ path: 'planning/run/:unitId/coach', element: <TrainingCoachPage /> },
{ path: 'planning/run/:unitId', element: <TrainingUnitRunPage /> },
{ path: 'planning', element: <TrainingPlanningPage /> },
{ path: 'admin', element: <AdminHomeRedirect /> },
{
path: 'admin/users',

View File

@ -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')
}

View File

@ -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%;

View File

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

View File

@ -0,0 +1,114 @@
import { Check, Save, X } from 'lucide-react'
/**
* Feste Aktionsleiste für Formulare/Modale: Speichern, Speichern & schließen, Abbrechen.
* Bleibt sichtbar (sticky), während der Formularinhalt scrollt.
*/
function ActionLabel({ Icon, long, short, saving, savingShort = '…' }) {
if (saving) {
return <span className="form-action-bar__saving">{savingShort}</span>
}
return (
<>
{Icon ? <Icon size={17} strokeWidth={2.25} className="form-action-bar__icon" aria-hidden /> : null}
<span className="form-action-bar__text form-action-bar__text--long">{long}</span>
{short != null && short !== '' ? (
<span className="form-action-bar__text form-action-bar__text--short">{short}</span>
) : 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 (
<div
className={`form-action-bar form-action-bar--${placement} form-action-bar--${variant}${
primaryCount === 1 ? ' form-action-bar--single-primary' : ''
}`}
role="group"
aria-label="Formularaktionen"
>
<div className="form-action-bar__inner">
{showCancelBtn ? (
<button
type="button"
className="btn btn-secondary form-action-bar__btn form-action-bar__btn--cancel"
onClick={onCancel}
disabled={saving}
aria-label={cancelLabel}
title={cancelLabel}
>
<ActionLabel Icon={X} long={cancelLabel} short="" saving={saving} />
</button>
) : null}
<div className="form-action-bar__primary-group">
{showSaveBtn ? (
<button
type={formId && !onSave ? 'submit' : 'button'}
form={formId && !onSave ? formId : undefined}
className={saveBtnClass}
disabled={saving}
onClick={onSave || undefined}
title={labelSave}
>
<ActionLabel Icon={Save} long={labelSave} short={shortSave} saving={saving} />
</button>
) : null}
{showCloseBtn ? (
<button
type={formId && !onSaveAndClose ? 'submit' : 'button'}
form={formId && !onSaveAndClose ? formId : undefined}
className={closeBtnClass}
disabled={saving}
onClick={onSaveAndClose || undefined}
title={saveAndCloseLabel}
>
<ActionLabel
Icon={Check}
long={saveAndCloseLabel}
short={shortClose}
saving={saving}
/>
</button>
) : null}
</div>
</div>
</div>
)
}

View File

@ -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(
<div
ref={overlayRef}
tabIndex={-1}
data-testid={testId}
className={overlayClass}
onClick={
onBackdropClick
? (e) => {
if (e.target === e.currentTarget) onBackdropClick()
}
: undefined
}
>
{children}
</div>,
document.body,
)
}

View File

@ -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 (
<div className="page-form-editor" data-testid={testId}>
<header className="page-form-editor__header">
{backTo ? (
<Link to={backTo} className="page-form-editor__back">
{backLabel}
</Link>
) : null}
<h1 className="page-form-editor__title">{title}</h1>
</header>
<div className="page-form-editor__body">{children}</div>
</div>
)
}

View File

@ -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({
</select>
</div>
) : null}
{planMin > 0 && (
{!structureOnly && planMin > 0 && (
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
</p>
)}
{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({
</ul>
</div>
) : null}
</>
) : null}
</div>
{useStreamTagDropUx && pl?.phaseKind === 'parallel' && parallelPhaseOrder != null
? (() => {

View File

@ -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 (
<p style={{ margin: 0, fontSize: compact ? '0.82rem' : '0.88rem', color: 'var(--text3)' }}>
Noch keine Abschnitte definiert.
</p>
)
}
return (
<ul
className="plan-template-structure-preview"
style={{
listStyle: 'none',
margin: compact ? '0.35rem 0 0' : '0.5rem 0 0',
padding: 0,
display: 'grid',
gap: compact ? '0.35rem' : '0.45rem',
}}
>
{preview.lines.map((line, idx) => (
<li
key={`${line.kind}-${line.label}-${idx}`}
style={{
fontSize: compact ? '0.82rem' : '0.88rem',
lineHeight: 1.45,
color: 'var(--text2)',
}}
>
<span
style={{
display: 'inline-block',
fontSize: '0.72rem',
fontWeight: 700,
letterSpacing: '0.02em',
textTransform: 'uppercase',
color: line.kind === 'parallel' ? 'var(--accent-dark)' : 'var(--text3)',
marginRight: '0.45rem',
}}
>
{line.label}
</span>
<span style={{ color: 'var(--text1)' }}>{line.detail}</span>
</li>
))}
</ul>
)
}

View File

@ -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 (
<nav
className="admin-page-subtabs page-section-nav page-section-nav--wrap planning-route-nav"
aria-label="Planung"
>
{ITEMS.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
'admin-page-subtabs__btn' + (isActive ? ' admin-page-subtabs__btn--active' : '')
}
>
<span>{item.label}</span>
</NavLink>
))}
</nav>
)
}

View File

@ -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 (
<FormModalOverlay open={open} raised onBackdropClick={onClose}>
<div className="card modal-panel--form modal-panel--narrow">
<h2 className="modal-panel__title">Übungen als Trainingsmodul</h2>
<p className="modal-panel__intro">
Es werden die <strong>gespeicherten</strong> Übungspositionen der Einheit vom{' '}
<strong>{unitLabel || '…'}</strong> verwendet. Speichere die Planung vorher, wenn du den aktuellen Stand
brauchst.
</p>
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : loadErr ? (
<p style={{ color: 'var(--danger)' }}>{loadErr}</p>
) : candidates.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>In dieser Einheit sind keine Übungen im Ablauf hinterlegt.</p>
) : (
<form id="save-module-form" className="modal-form-shell" onSubmit={handleSubmit}>
<div className="modal-form-shell__body">
<div style={{ marginBottom: '1rem' }}>
<label className="form-label">Modultitel</label>
<input
className="form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
placeholder="z. B. Aufwärmsequenz"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginBottom: '0.75rem' }}>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => setAll(true)}>
Alle
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => setAll(false)}>
Keine
</button>
<span style={{ fontSize: '0.82rem', color: 'var(--text3)', alignSelf: 'center' }}>
{selectedCount} von {candidates.length} gewählt (Reihenfolge wie im Plan)
</span>
</div>
<div
className="card"
style={{
marginBottom: '1rem',
padding: '10px 12px',
maxHeight: 'min(240px, 40vh)',
overflowY: 'auto',
background: 'var(--surface2)',
}}
>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{candidates.map((c, idx) => (
<li
key={`${c.exercise_id}-${idx}`}
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
padding: '8px 0',
borderTop: idx === 0 ? 'none' : '1px solid var(--border)',
}}
>
<input
type="checkbox"
checked={!!selected[idx]}
onChange={() => toggleOne(idx)}
style={{ marginTop: 4 }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 600, color: 'var(--text1)', fontSize: '0.92rem' }}>
{c.exercise_title}
</div>
{c.contextLabel ? (
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: 2 }}>{c.contextLabel}</div>
) : null}
<div style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: 2 }}>
{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)`
: ''}
</div>
</div>
</li>
))}
</ul>
</div>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={visibility}
onChange={(e) => {
const v = e.target.value
setVisibility(v)
if (v === 'club' && !clubId && planningModalClubId != null) {
setClubId(String(planningModalClubId))
}
}}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
{visibility === 'club' ? (
<div className="form-row" style={{ marginBottom: '1rem' }}>
<label className="form-label">Verein</label>
<select className="form-input" value={clubId} onChange={(e) => setClubId(e.target.value)}>
<option value=""> Verein wählen </option>
{memberClubs.map((cl) => (
<option key={cl.id} value={String(cl.id)}>
{cl.name || `Verein #${cl.id}`}
</option>
))}
</select>
</div>
) : null}
</div>
<FormActionBar
placement="bottom"
variant="modal"
formId="save-module-form"
saving={submitting}
showSave={false}
saveAndCloseLabel="Modul anlegen"
saveAndCloseShortLabel="Anlegen"
onCancel={onClose}
/>
</form>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Abbrechen
</button>
</div>
) : null}
{!loading && loadErr ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
) : null}
{!loading && !loadErr && candidates.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
) : null}
</div>
</FormModalOverlay>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<div
data-testid="planning-unit-form-modal"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(12px, 3vw, 2rem)',
maxWidth: 'min(1100px, 100%)',
width: '100%',
maxHeight: '92vh',
overflowY: 'auto',
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '1rem' }}>
<FormModalOverlay open={open} data-testid="planning-unit-form-modal" onBackdropClick={onCancel}>
<div className="modal-panel--form">
<h2 className="modal-panel__title">
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h2>
<form
id={formId}
className="modal-form-shell"
onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSubmit?.(e))}
>
<div className="modal-form-shell__body">
{editingUnit?.origin_framework_slot_id
? (() => {
const L = frameworkLineageText(editingUnit)
@ -148,80 +131,11 @@ export default function TrainingPlanningUnitFormModal({
</select>
<p className="training-planning-template-panel__help">
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
Abschnitten ein. Vorlagen verwaltest du unter <Link to="/planning/plan-templates">Planung Vorlagen</Link>.
</p>
</div>
)}
{planTemplates.length > 0 && typeof onDeletePlanTemplate === 'function' ? (
<details
className="card"
style={{
marginBottom: '1.35rem',
padding: '12px 14px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<summary style={{ cursor: 'pointer', fontWeight: 600, color: 'var(--text1)' }}>
Gespeicherte Vorlagen löschen
</summary>
<p style={{ margin: '0.65rem 0 0.75rem', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.45 }}>
Entfernen nach Rolle: eigene private Vorlagen; Vereins­inhalte als Vereins­admin; offizielle nur als
PlattformAdmin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt.
</p>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{planTemplates.map((t, ti) => {
const canDel = user && canDeleteLibraryContent(user, t)
return (
<li
key={t.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '10px',
padding: '8px 0',
borderTop: ti === 0 ? 'none' : '1px solid var(--border)',
}}
>
<span style={{ minWidth: 0, flex: 1, fontSize: '0.9rem' }}>
<strong style={{ color: 'var(--text1)' }}>{t.name}</strong>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', marginLeft: '6px' }}>
(
{String(t.visibility || 'club').toLowerCase() === 'private'
? 'Privat'
: String(t.visibility || 'club').toLowerCase() === 'official'
? 'Offiziell'
: 'Verein'}
)
</span>
{typeof t.sections_count === 'number' ? (
<span style={{ fontSize: '0.82rem', color: 'var(--text2)', marginLeft: '6px' }}>
· {t.sections_count} Abschn.
</span>
) : null}
</span>
{canDel ? (
<button
type="button"
className="btn btn-danger"
style={{ flexShrink: 0, padding: '6px 12px', fontSize: '0.82rem' }}
onClick={() => onDeletePlanTemplate(t)}
>
Löschen
</button>
) : (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', flexShrink: 0 }}>nur Lesen</span>
)}
</li>
)
})}
</ul>
</details>
) : null}
<form onSubmit={onSubmit}>
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
@ -492,6 +406,28 @@ export default function TrainingPlanningUnitFormModal({
>
Vorlage aus Aufbau speichern
</button>
{editingUnit?.id && !editingUnit?.framework_slot_id ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginBottom: '2px' }}
onClick={() => onRequestPublishToFramework?.()}
title="Letzten gespeicherten Ablauf ins Rahmenprogramm übernehmen"
>
Als Rahmen-Session speichern
</button>
) : null}
{editingUnit?.id && !editingUnit?.framework_slot_id ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginBottom: '2px' }}
onClick={() => onRequestSaveAsModule?.()}
title="Gespeicherte Übungen als Trainingsmodul sichern"
>
Übungen als Modul
</button>
) : null}
</div>
</>
}
@ -625,16 +561,21 @@ export default function TrainingPlanningUnitFormModal({
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
{editingUnit ? 'Speichern' : 'Erstellen'}
</button>
<button type="button" className="btn btn-secondary" onClick={onCancel}>
Abbrechen
</button>
</div>
<FormActionBar
placement="bottom"
variant="modal"
formId={formId}
isNew={!editingUnit}
onSave={onSaveOnly ? () => onSaveOnly() : undefined}
onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined}
onCancel={onCancel}
showSave={Boolean(onSaveOnly)}
showSaveAndClose
/>
</form>
</div>
</div>
</FormModalOverlay>
)
}

View File

@ -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 (
<FormModalOverlay open={open} raised onBackdropClick={resetAndClose}>
<div className="card modal-panel--form modal-panel--narrow">
<h2 className="modal-panel__title">Ablauf ins Rahmenprogramm übernehmen</h2>
<p className="modal-panel__intro">
Es wird der <strong>zuletzt gespeicherte</strong> Ablauf dieser Einheit aus der Datenbank übernommen.
Nicht gespeicherte Änderungen im Formular sind nicht enthalten bitte vorher die Einheit speichern.
</p>
<form id="publish-framework-form" className="modal-form-shell" onSubmit={handleSubmit}>
<div className="modal-form-shell__body">
<div style={{ marginBottom: '1rem' }}>
<span className="form-label">Ziel</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px', marginTop: '0.35rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="pf_scope"
checked={scope === 'existing'}
onChange={() => setScope('existing')}
/>
Bestehendes Rahmenprogramm
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="pf_scope"
checked={scope === 'new'}
onChange={() => setScope('new')}
/>
Neues Rahmenprogramm
</label>
</div>
</div>
{scope === 'existing' ? (
<>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Rahmenprogramm</label>
<select
className="form-input"
value={fwProgramId}
onChange={(e) => {
setFwProgramId(e.target.value)
setExistingSlotId('')
}}
required
>
<option value="">{programsLoading ? 'Laden…' : '— Wählen —'}</option>
{programs.map((p) => (
<option key={p.id} value={String(p.id)}>
{(p.title || '').trim() || `Rahmen #${p.id}`}
</option>
))}
</select>
</div>
<div style={{ marginBottom: '0.85rem' }}>
<span className="form-label">Session-Platz</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: '0.35rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="pf_slot"
checked={slotMode === 'new_slot'}
onChange={() => setSlotMode('new_slot')}
/>
Neuen Session-Slot anlegen
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="radio"
name="pf_slot"
checked={slotMode === 'existing_slot'}
onChange={() => setSlotMode('existing_slot')}
/>
Bestehenden Slot überschreiben (Ablauf ersetzen)
</label>
</div>
</div>
{slotMode === 'new_slot' ? (
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Position (0 = erste Stelle)</label>
<input
className="form-input"
type="text"
inputMode="numeric"
placeholder="Leer = ans Ende anhängen"
value={insertAt}
onChange={(e) => setInsertAt(e.target.value)}
/>
<p style={{ fontSize: '0.78rem', color: 'var(--text3)', margin: '0.35rem 0 0' }}>
Die Reihenfolge der Slots kannst du in der Rahmen-Bearbeitung jederzeit ändern (Ziehen oder
Pfeile).
</p>
</div>
) : (
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Slot</label>
<select
className="form-input"
value={existingSlotId}
onChange={(e) => setExistingSlotId(e.target.value)}
required={slotMode === 'existing_slot'}
>
<option value="">
{fwDetailLoading ? 'Laden…' : '— Session wählen —'}
</option>
{sortedSlots.map((s, i) => (
<option key={s.id} value={String(s.id)}>
{(s.title || '').trim() || `Session ${i + 1}`}
</option>
))}
</select>
</div>
)}
</>
) : (
<>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Titel Rahmenprogramm</label>
<input
className="form-input"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z. B. Saisonvorbereitung"
required
/>
</div>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Erstes Entwicklungsziel (Pflichtfeld Rahmen)</label>
<input
className="form-input"
value={newGoalTitle}
onChange={(e) => setNewGoalTitle(e.target.value)}
/>
</div>
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={newVisibility}
onChange={(e) => {
const v = e.target.value
setNewVisibility(v)
if (v === 'club' && !newClubId && planningModalClubId != null) {
setNewClubId(String(planningModalClubId))
}
}}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
{newVisibility === 'club' ? (
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Verein</label>
<select
className="form-input"
value={newClubId}
onChange={(e) => setNewClubId(e.target.value)}
>
<option value=""> Verein wählen </option>
{memberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</div>
) : null}
</>
)}
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Session-Titel (optional)</label>
<input
className="form-input"
value={slotTitle}
onChange={(e) => setSlotTitle(e.target.value)}
placeholder="z. B. Woche 3 — Technik"
/>
</div>
<div className="form-row" style={{ marginBottom: '1.1rem' }}>
<label className="form-label">Notizen zur Session (optional)</label>
<textarea
className="form-input"
rows={2}
value={slotNotes}
onChange={(e) => setSlotNotes(e.target.value)}
/>
</div>
</div>
<FormActionBar
placement="bottom"
variant="modal"
formId="publish-framework-form"
saving={submitting}
showSave={false}
saveAndCloseLabel="In Rahmen übernehmen"
saveAndCloseShortLabel="Übernehmen"
onCancel={resetAndClose}
/>
</form>
</div>
</FormModalOverlay>
)
}

View File

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

View File

@ -0,0 +1,38 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'
import FormActionBar from '../components/FormActionBar'
const FormEditorActionsContext = createContext(null)
export function FormEditorActionsProvider({ children }) {
const [config, setConfig] = useState(null)
const value = useMemo(() => ({ config, setConfig }), [config])
return <FormEditorActionsContext.Provider value={value}>{children}</FormEditorActionsContext.Provider>
}
/** Mobile: FormActionBar statt Bottom-Nav; Desktop: Fallback (Nav ist ohnehin ausgeblendet). */
export function FormEditorBottomSlot({ children }) {
const ctx = useContext(FormEditorActionsContext)
const config = ctx?.config
if (config) {
return (
<div className="form-editor-mobile-dock" data-testid="form-editor-mobile-dock">
<FormActionBar {...config} placement="bottom" variant="page" />
</div>
)
}
return children
}
/** @param {Record<string, unknown> | null} config FormActionBar-Props */
export function useFormEditorActions(config) {
const setConfig = useContext(FormEditorActionsContext)?.setConfig
useEffect(() => {
if (!setConfig) return undefined
setConfig(config ?? null)
return () => setConfig(null)
}, [setConfig, config])
}

View File

@ -0,0 +1,60 @@
import { useEffect } from 'react'
let lockDepth = 0
const saved = {
appMainScrollTop: 0,
bodyScrollTop: 0,
appMainOverflow: '',
appMainTouchAction: '',
}
function queryAppMain() {
return document.querySelector('.app-main')
}
function applyLock() {
document.documentElement.classList.add('modal-scroll-locked')
const appMain = queryAppMain()
if (appMain) {
saved.appMainScrollTop = appMain.scrollTop
saved.appMainOverflow = appMain.style.overflow
saved.appMainTouchAction = appMain.style.touchAction
appMain.style.overflow = 'hidden'
appMain.style.touchAction = 'none'
}
saved.bodyScrollTop = window.scrollY || document.documentElement.scrollTop || 0
document.body.style.overflow = 'hidden'
document.body.style.overscrollBehavior = 'none'
}
function releaseLock() {
document.documentElement.classList.remove('modal-scroll-locked')
const appMain = queryAppMain()
if (appMain) {
appMain.style.overflow = saved.appMainOverflow
appMain.style.touchAction = saved.appMainTouchAction
appMain.scrollTop = saved.appMainScrollTop
}
document.body.style.overflow = ''
document.body.style.overscrollBehavior = ''
if (saved.bodyScrollTop) {
window.scrollTo(0, saved.bodyScrollTop)
}
}
/** Hintergrund-Scroll sperren (.app-main + body), verschachtelte Modals via Zähler. */
export function useModalScrollLock(active) {
useEffect(() => {
if (!active) return undefined
lockDepth += 1
if (lockDepth === 1) applyLock()
return () => {
lockDepth = Math.max(0, lockDepth - 1)
if (lockDepth === 0) releaseLock()
}
}, [active])
}

View File

@ -0,0 +1,18 @@
import { Outlet, useLocation } from 'react-router-dom'
import PlanningRouteNav from '../components/planning/PlanningRouteNav'
import { isPlanningUnitEditorPath } from '../utils/planningUnitRoutes'
/** Gemeinsame Hülle für Planung, Rahmenprogramme, Module und Vorlagen. */
export default function PlanningLayout() {
const { pathname } = useLocation()
const hideRouteNav = isPlanningUnitEditorPath(pathname)
return (
<div className="app-page planning-layout">
{!hideRouteNav ? <PlanningRouteNav /> : null}
<div className="planning-layout__main">
<Outlet />
</div>
</div>
)
}

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import PageSectionNav from '../components/PageSectionNav'
import FormActionBar from '../components/FormActionBar'
import { useToast } from '../context/ToastContext'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
@ -427,7 +428,7 @@ export default function TrainingFrameworkProgramEditPage() {
}))
}
const performFrameworkSave = async ({ fromUnsavedDialog = false } = {}) => {
const performFrameworkSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
if (!(form.title || '').trim()) {
toast.error('Titel ist Pflichtfeld.')
return false
@ -448,7 +449,9 @@ export default function TrainingFrameworkProgramEditPage() {
if (isNew) {
const created = await api.createTrainingFrameworkProgram(payload)
toast.success('Rahmenprogramm angelegt.')
if (!fromUnsavedDialog) {
if (closeAfter) {
navigate('/planning/framework-programs')
} else if (!fromUnsavedDialog) {
navigate(`/planning/framework-programs/${created.id}`, { replace: true })
}
return true
@ -463,6 +466,7 @@ export default function TrainingFrameworkProgramEditPage() {
setBypassDirty(false)
setBaselineReady(true)
toast.success('Gespeichert.')
if (closeAfter) navigate('/planning/framework-programs')
return true
} catch (e) {
toast.error(e.message || 'Speichern fehlgeschlagen')
@ -473,7 +477,11 @@ export default function TrainingFrameworkProgramEditPage() {
}
const handleSave = async () => {
await performFrameworkSave({ fromUnsavedDialog: false })
await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: false })
}
const handleSaveAndClose = async () => {
await performFrameworkSave({ fromUnsavedDialog: false, closeAfter: true })
}
const handleUnsavedDialogSave = async () => {
@ -1250,19 +1258,19 @@ export default function TrainingFrameworkProgramEditPage() {
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'center' }}>
<button type="button" className="btn btn-primary" disabled={saving} onClick={handleSave}>
{saving ? 'Speichern…' : isNew ? 'Anlegen' : 'Speichern'}
<FormActionBar
isNew={isNew}
saving={saving}
onSave={handleSave}
onSaveAndClose={handleSaveAndClose}
onCancel={() => navigate('/planning/framework-programs')}
cancelLabel="Abbrechen"
/>
{!isNew ? (
<button type="button" className="btn btn-secondary" onClick={handleDelete} style={{ marginTop: '10px' }}>
Löschen
</button>
<Link to="/planning/framework-programs" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Abbrechen
</Link>
{!isNew ? (
<button type="button" className="btn btn-secondary" onClick={handleDelete}>
Löschen
</button>
) : null}
</div>
) : null}
</div>
<ExercisePickerModal

View File

@ -92,7 +92,7 @@ export default function TrainingFrameworkProgramsListPage() {
}
return (
<div className="app-page">
<>
<div
style={{
display: 'flex',
@ -108,11 +108,8 @@ export default function TrainingFrameworkProgramsListPage() {
Trainingsrahmenprogramme
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
Vorlagen für Ziele und Sessions die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
.
Vorlagen für Ziele und Sessions die Verknüpfung mit Gruppenterminen erfolgt in der
Trainingsplanung (Registerkarte oben).
</p>
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
@ -131,12 +128,6 @@ export default function TrainingFrameworkProgramsListPage() {
</Link>
</div>
<p style={{ marginBottom: '1rem' }}>
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Zurück zur Trainingsplanung
</Link>
</p>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
{error}
@ -207,6 +198,6 @@ export default function TrainingFrameworkProgramsListPage() {
))}
</ul>
)}
</div>
</>
)
}

View File

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import FormActionBar from '../components/FormActionBar'
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
@ -301,7 +302,7 @@ export default function TrainingModuleEditPage() {
}
}
const performModuleSave = async ({ fromUnsavedDialog = false } = {}) => {
const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
if (!title.trim()) {
toast.error('Titel ist Pflicht.')
return false
@ -313,7 +314,9 @@ export default function TrainingModuleEditPage() {
if (isNew) {
const created = await api.createTrainingModule(body)
toast.success('Trainingsmodul angelegt.')
if (!fromUnsavedDialog) {
if (closeAfter) {
navigate('/planning/training-modules')
} else if (!fromUnsavedDialog) {
navigate(`/planning/training-modules/${created.id}`, { replace: true })
}
return true
@ -322,6 +325,7 @@ export default function TrainingModuleEditPage() {
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
setBypassDirty(false)
toast.success('Gespeichert.')
if (closeAfter) navigate('/planning/training-modules')
return true
} catch (err) {
const msg = err.message || 'Speichern fehlgeschlagen'
@ -334,8 +338,12 @@ export default function TrainingModuleEditPage() {
}
const handleSave = async (e) => {
e.preventDefault()
await performModuleSave({ fromUnsavedDialog: false })
e?.preventDefault?.()
await performModuleSave({ fromUnsavedDialog: false, closeAfter: false })
}
const handleSaveAndClose = async () => {
await performModuleSave({ fromUnsavedDialog: false, closeAfter: true })
}
const handleUnsavedDialogSave = async () => {
@ -367,7 +375,13 @@ export default function TrainingModuleEditPage() {
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : (
<form className="card" style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }} onSubmit={handleSave}>
<form
id="training-module-form"
className="card page-form-shell"
style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '720px' }}
onSubmit={handleSave}
>
<div className="page-form-shell__scroll">
<div className="form-row">
<label className="form-label">Titel *</label>
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
@ -648,14 +662,17 @@ export default function TrainingModuleEditPage() {
))}
</ul>
<div style={{ display: 'flex', gap: '10px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern …' : isNew ? 'Anlegen' : 'Speichern'}
</button>
<Link to="/planning/training-modules" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Abbrechen
</Link>
</div>
<FormActionBar
formId="training-module-form"
isNew={isNew}
saving={saving}
onSave={() => handleSave()}
onSaveAndClose={handleSaveAndClose}
onCancel={() => navigate('/planning/training-modules')}
cancelLabel="Abbrechen"
/>
</form>
)}

View File

@ -40,7 +40,7 @@ export default function TrainingModulesListPage() {
}
return (
<div className="app-page">
<>
<div
style={{
display: 'flex',
@ -56,11 +56,8 @@ export default function TrainingModulesListPage() {
Trainingsmodule
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
Wiederverwendbare Übungsfolgen für die{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
. Übernahme in eine Einheit erfolgt dort als lokale Kopie (mit Herkunftsmarkierung).
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Übernahme in eine Einheit erfolgt dort als
lokale Kopie (mit Herkunftsmarkierung).
</p>
</div>
<Link to="/planning/training-modules/new" className="btn btn-primary" style={{ textDecoration: 'none' }}>
@ -130,6 +127,6 @@ export default function TrainingModulesListPage() {
))}
</ul>
)}
</div>
</>
)
}

View File

@ -0,0 +1,337 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import FormActionBar from '../components/FormActionBar'
import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
import {
defaultSection,
formSectionsFromPlanTemplateRows,
templateSectionsPayloadFromFormSections,
} from '../utils/trainingUnitSectionsForm'
import {
activeClubMemberships,
getDefaultClubIdForGovernanceForms,
getTenantClubDependencyKey,
} from '../utils/activeClub'
function templateFormSnapshot({ name, description, visibility, clubIdField, sections }) {
return JSON.stringify({
name: (name || '').trim(),
description: (description || '').trim(),
visibility: visibility || '',
clubIdField: (clubIdField || '').trim(),
sections: templateSectionsPayloadFromFormSections(sections),
})
}
export default function TrainingPlanTemplateEditPage() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const templateId = parseInt(routeId, 10)
const toast = useToast()
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [visibility, setVisibility] = useState('club')
const [clubIdField, setClubIdField] = useState('')
const [sections, setSections] = useState([defaultSection()])
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
const baselineRef = useRef(null)
const latestFormRef = useRef({})
const [baselineReady, setBaselineReady] = useState(false)
const [bypassDirty, setBypassDirty] = useState(false)
latestFormRef.current = { name, description, visibility, clubIdField, sections }
const dirtySignature = templateFormSnapshot(latestFormRef.current)
const formDirtyEffective =
baselineReady && baselineRef.current != null && !bypassDirty && !loading && dirtySignature !== baselineRef.current
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
const visibilityClubChoices = useMemo(() => {
if (isPlatformAdmin && clubsForGovernanceForms.length > 0) {
return [...clubsForGovernanceForms].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de')
)
}
return [...membershipClubRows].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de')
)
}, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows])
useEffect(() => {
if (!isPlatformAdmin) {
setClubsForGovernanceForms([])
return undefined
}
let cancelled = false
;(async () => {
try {
const list = await api.listClubs()
if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
} catch {
if (!cancelled) setClubsForGovernanceForms([])
}
})()
return () => {
cancelled = true
}
}, [isPlatformAdmin, tenantClubDepKey])
useEffect(() => {
baselineRef.current = null
setBaselineReady(false)
setBypassDirty(false)
}, [templateId])
useEffect(() => {
if (loading) return
const handle = window.setTimeout(() => {
baselineRef.current = templateFormSnapshot(latestFormRef.current)
setBaselineReady(true)
}, 120)
return () => clearTimeout(handle)
}, [loading, templateId])
useEffect(() => {
if (!Number.isFinite(templateId) || templateId < 1) {
setError('Ungültige Vorlagen-ID')
setLoading(false)
return undefined
}
let cancelled = false
async function load() {
setLoading(true)
setError('')
try {
const tpl = await api.getTrainingPlanTemplate(templateId)
if (cancelled) return
setName((tpl.name || '').trim())
setDescription((tpl.description || '').trim())
setVisibility((tpl.visibility || 'club').trim())
setClubIdField(tpl.club_id != null ? String(tpl.club_id) : '')
const nextSections = formSectionsFromPlanTemplateRows(tpl.sections)
setSections(nextSections.length ? nextSections : [defaultSection()])
} catch (e) {
if (!cancelled) setError(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [templateId])
const buildBody = useCallback(() => {
let cid = null
if (visibility === 'club') {
const raw = (clubIdField || '').trim()
if (raw !== '') {
const p = parseInt(raw, 10)
if (Number.isFinite(p) && p >= 1) cid = p
} else if (visibilityClubChoices.length === 1) {
cid = visibilityClubChoices[0].id
}
}
return {
name: name.trim(),
description: description.trim() || null,
visibility,
club_id:
cid != null && Number.isFinite(cid) && cid >= 1
? cid
: visibility === 'club'
? undefined
: null,
sections: templateSectionsPayloadFromFormSections(sections),
}
}, [name, description, visibility, clubIdField, visibilityClubChoices, sections])
const performSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
if (!name.trim()) {
toast.error('Name ist Pflicht.')
return false
}
if (visibility === 'club') {
const bodyPreview = buildBody()
if (bodyPreview.club_id === undefined) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
return false
}
}
setSaving(true)
setError('')
try {
await api.updateTrainingPlanTemplate(templateId, buildBody())
baselineRef.current = templateFormSnapshot(latestFormRef.current)
setBypassDirty(false)
toast.success('Vorlage gespeichert.')
if (closeAfter) navigate('/planning/plan-templates')
return true
} catch (err) {
const msg = err.message || 'Speichern fehlgeschlagen'
setError(msg)
toast.error(msg)
return false
} finally {
setSaving(false)
}
}
const handleSave = async (e) => {
e?.preventDefault?.()
await performSave({ closeAfter: false })
}
const handleSaveAndClose = async () => {
await performSave({ closeAfter: true })
}
const handleUnsavedDialogSave = async () => {
const ok = await performSave({ fromUnsavedDialog: true })
if (ok) blocker.proceed()
}
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner" />
<p>Laden </p>
</div>
)
}
return (
<div className="app-page">
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/planning/plan-templates" style={{ color: 'var(--accent-dark)', fontWeight: 600 }}>
Zurück zu Vorlagen
</Link>
</p>
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsvorlage bearbeiten
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem', maxWidth: '42rem', lineHeight: 1.5 }}>
Nur die <strong>Abschnitts-Gliederung</strong> (inkl. Split-Sessions / parallele Gruppen) ohne Übungen.
Beim Anwenden auf eine Einheit wird der Ablauf als Struktur übernommen.
</p>
{error ? <p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p> : null}
<form
id="plan-template-form"
className="card page-form-shell"
style={{ padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: '920px' }}
onSubmit={handleSave}
>
<div className="page-form-shell__scroll">
<div className="form-row">
<label className="form-label">Name *</label>
<input className="form-input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<textarea
className="form-input"
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Wofür eignet sich diese Gliederung? (optional)"
/>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 200px), 1fr))',
gap: '12px',
marginBottom: '1rem',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={visibility}
onChange={(e) => {
const v = e.target.value
setVisibility(v)
if (v === 'club' && !(clubIdField || '').trim()) {
const fb = getDefaultClubIdForGovernanceForms(user)
if (fb != null) setClubIdField(String(fb))
}
}}
>
<option value="private">Privat (nur du)</option>
<option value="club">Verein</option>
{isPlatformAdmin ? <option value="official">Offiziell (global)</option> : null}
</select>
</div>
{visibility === 'club' ? (
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Verein</label>
<select
className="form-input"
value={clubIdField}
onChange={(e) => setClubIdField(e.target.value)}
>
<option value=""> Verein wählen </option>
{visibilityClubChoices.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</div>
) : null}
</div>
<TrainingUnitSectionsEditor
heading="Abschnitts-Gliederung"
structureOnly
enableParallelPhaseControls
betweenInsertMenus={false}
enableItemDragReorder={false}
sections={sections}
onSectionsChange={(updater) => setSections((prev) => updater(prev))}
/>
</div>
<FormActionBar
placement="bottom"
variant="page"
formId="plan-template-form"
saving={saving}
onCancel={() => navigate('/planning/plan-templates')}
onSaveAndClose={handleSaveAndClose}
/>
</form>
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setBypassDirty(true)}
/>
</div>
)
}

View File

@ -0,0 +1,196 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { canDeleteLibraryContent, canEditLibraryContent } from '../utils/libraryContentPermissions'
import PlanTemplateStructurePreview from '../components/planning/PlanTemplateStructurePreview'
function visibilityLabel(v) {
const x = String(v || 'club').toLowerCase()
if (x === 'private') return 'Privat'
if (x === 'official') return 'Offiziell'
return 'Verein'
}
export default function TrainingPlanTemplatesListPage() {
const { user } = useAuth()
const toast = useToast()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const list = await api.listTrainingPlanTemplates()
setRows(Array.isArray(list) ? list : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load, tenantClubDepKey])
async function handleDelete(tpl) {
if (!tpl?.id) return
const label = (tpl.name || '').trim() || `Vorlage #${tpl.id}`
if (
!window.confirm(
`Trainingsvorlage „${label}“ wirklich löschen? Einheiten mit Verweis behalten ihren Ablauf; die Vorlage wird entkoppelt.`
)
) {
return
}
try {
await api.deleteTrainingPlanTemplate(tpl.id)
await load()
toast.success('Vorlage gelöscht.')
} catch (e) {
toast.error(e.message || 'Löschen fehlgeschlagen')
}
}
return (
<>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '1rem',
marginBottom: '1.25rem',
}}
>
<div>
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsvorlagen
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '42rem', margin: 0, lineHeight: 1.5 }}>
Mikrovorlagen für die <strong>Sektions-Gliederung</strong> einer Einheit (ohne Übungen), inklusive{' '}
<strong>Split-Sessions</strong>. Neue Vorlagen legst du beim Bearbeiten einer Trainingseinheit über Vorlage
aus Aufbau speichern an; hier kannst du sie prüfen und anpassen.
</p>
</div>
</div>
{error ? <p style={{ color: 'var(--danger)', marginBottom: '1rem' }}>{error}</p> : null}
{loading ? (
<p style={{ color: 'var(--text2)' }}>Laden </p>
) : rows.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0, color: 'var(--text2)', lineHeight: 1.5 }}>
Noch keine Vorlagen gespeichert. Öffne unter <strong>Trainingsplanung</strong> eine Einheit, strukturiere
die Abschnitte (auch parallele Gruppen) und nutze dort Vorlage aus Aufbau speichern.
</p>
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: '0.75rem' }}>
{rows.map((t) => {
const canEdit = user && canEditLibraryContent(user, t)
const canDel = user && canDeleteLibraryContent(user, t)
const desc = (t.description || '').trim()
return (
<li key={t.id} className="card" style={{ padding: '14px 16px' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '12px',
}}
>
<div style={{ minWidth: 0, flex: '1 1 240px' }}>
{canEdit ? (
<Link
to={`/planning/plan-templates/${t.id}`}
style={{
fontWeight: 700,
fontSize: '1rem',
color: 'var(--accent-dark)',
textDecoration: 'none',
wordBreak: 'break-word',
}}
>
{(t.name || '').trim() || `Vorlage #${t.id}`}
</Link>
) : (
<strong style={{ color: 'var(--text1)', fontSize: '1rem', wordBreak: 'break-word' }}>
{(t.name || '').trim() || `Vorlage #${t.id}`}
</strong>
)}
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '4px' }}>
{visibilityLabel(t.visibility)}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
{t.updated_at ? (
<span style={{ color: 'var(--text3)' }}>
{' '}
· aktualisiert {String(t.updated_at).slice(0, 10)}
</span>
) : null}
</div>
{desc ? (
<p
style={{
margin: '0.45rem 0 0',
fontSize: '0.88rem',
color: 'var(--text2)',
lineHeight: 1.45,
maxWidth: '42rem',
}}
>
{desc}
</p>
) : null}
<PlanTemplateStructurePreview sections={t.sections} compact />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', flexShrink: 0 }}>
{canEdit ? (
<Link
className="btn btn-secondary"
style={{ textDecoration: 'none' }}
to={`/planning/plan-templates/${t.id}`}
>
Bearbeiten
</Link>
) : (
<span style={{ fontSize: '0.82rem', color: 'var(--text3)', alignSelf: 'center' }}>
nur Lesen
</span>
)}
{canDel ? (
<button
type="button"
className="btn btn-secondary"
style={{ color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDelete(t)}
>
Löschen
</button>
) : null}
</div>
</div>
</li>
)
})}
</ul>
)}
<p style={{ marginTop: '1.25rem', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45, maxWidth: '40rem' }}>
Bearbeiten: eigene private Vorlagen, Vereinsinhalte für Trainer im Verein, offizielle nur als Plattform-Admin.
Löschen nach Rolle: eigene private Vorlagen; Vereinsinhalte als Vereinsadmin; offizielle nur als Plattform-Admin.
</p>
</>
)
}

View File

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

View File

@ -11,6 +11,25 @@ export function clubAdminInClub(user, clubId) {
* Löschen von Bibliotheks-/Planungsinhalten (Vorlage, Modul, Rahmen, Graph) grob wie Backend club_tenancy.
* Vereins-Admins können fremde private Einträge im API löschen (gemeinsamer Verein); das blenden wir hier nicht ein.
*/
/** Bearbeiten — grob wie Backend assert_library_content_editable (Ersteller, Plattform, Planung im Verein). */
export function canEditLibraryContent(user, row) {
const grole = String(user?.role || '').toLowerCase()
if (grole === 'admin' || grole === 'superadmin') return true
const uid = Number(user?.id)
if (!Number.isFinite(uid)) return false
const vis = String(row?.visibility ?? 'club').toLowerCase()
const createdBy = row?.created_by != null ? Number(row.created_by) : null
const clubId = row?.club_id != null ? Number(row.club_id) : null
if (vis === 'official') return false
if (Number.isFinite(createdBy) && createdBy === uid) return true
if (vis === 'club' && Number.isFinite(clubId)) {
return activeClubMemberships(user?.clubs).some((c) => Number(c.id) === clubId)
}
return false
}
export function canDeleteLibraryContent(user, row) {
const grole = String(user?.role || '').toLowerCase()
if (grole === 'admin' || grole === 'superadmin') return true

View File

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

View File

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

View File

@ -0,0 +1,60 @@
/**
* Übungspositionen aus GET /api/training-units/:id für die Modul-Erstellung (Reihenfolge = Timeline).
* @param {object} unit hydratisierte Trainingseinheit mit `phases` und/oder `sections`
* @returns {Array<{ exercise_id: number, exercise_variant_id: number|null, planned_duration_min: number|null, notes: string|null, exercise_title?: string, contextLabel: string }>}
*/
export function collectExercisePlacementsForModule(unit) {
if (!unit || typeof unit !== 'object') return []
const rows = []
const pushFromSection = (sec, ctx) => {
const st = (sec?.title || '').trim()
for (const it of sec?.items || []) {
if ((it?.item_type || 'exercise') !== 'exercise') continue
const eid = it.exercise_id
if (!eid) continue
const labelParts = [ctx, st].filter(Boolean)
rows.push({
exercise_id: Number(eid),
exercise_variant_id:
it.exercise_variant_id != null && it.exercise_variant_id !== ''
? Number(it.exercise_variant_id)
: null,
planned_duration_min:
it.planned_duration_min != null && it.planned_duration_min !== ''
? Number(it.planned_duration_min)
: null,
notes: it.notes != null && String(it.notes).trim() ? String(it.notes).trim() : null,
exercise_title: (it.exercise_title || '').trim() || `Übung #${eid}`,
contextLabel: labelParts.length ? labelParts.join(' · ') : '',
})
}
}
const phases = Array.isArray(unit.phases) ? unit.phases : []
for (const ph of phases) {
const pk = String(ph?.phase_kind || '').toLowerCase()
const phaseCtx = (ph?.title || '').trim()
if (pk === 'parallel') {
for (const st of ph.streams || []) {
const streamCtx = [phaseCtx, (st?.title || '').trim()].filter(Boolean).join(' · ')
for (const sec of st?.sections || []) {
pushFromSection(sec, streamCtx)
}
}
} else {
for (const sec of ph?.sections || []) {
pushFromSection(sec, phaseCtx)
}
}
}
if (!rows.length && Array.isArray(unit.sections)) {
for (const sec of unit.sections) {
pushFromSection(sec, '')
}
}
return rows
}

View File

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

View File

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

View File

@ -1180,6 +1180,66 @@ export function templateSectionsPayloadFromFormSections(sections) {
})
}
/** Kurzdarstellung der Vorlagen-Gliederung (Ganzgruppe + Split-Streams) für Listen/Übersicht. */
export function formatPlanTemplateStructurePreview(templateSections) {
const rows = Array.isArray(templateSections) ? [...templateSections] : []
if (!rows.length) {
return { lines: [], hasSplit: false, isEmpty: true }
}
rows.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
const lines = []
let hasSplit = false
let i = 0
while (i < rows.length) {
const r0 = rows[i]
const pk0 = String(r0.phase_kind || 'whole_group').toLowerCase().trim()
const poi0 = Number(r0.phase_order_index)
const phaseOrder = Number.isFinite(poi0) ? poi0 : 0
const run = []
while (i < rows.length) {
const r = rows[i]
const pk = String(r.phase_kind || 'whole_group').toLowerCase().trim()
const poi = Number(r.phase_order_index)
const phaseOi = Number.isFinite(poi) ? poi : 0
if (pk !== pk0 || phaseOi !== phaseOrder) break
run.push(r)
i += 1
}
if (pk0 === 'parallel') {
hasSplit = true
const byStream = new Map()
for (const r of run) {
const soRaw = r.parallel_stream_order_index
const so = soRaw == null || soRaw === '' ? 0 : Number(soRaw)
const streamKey = Number.isFinite(so) ? so : 0
if (!byStream.has(streamKey)) byStream.set(streamKey, [])
byStream.get(streamKey).push(r)
}
const streamParts = [...byStream.keys()]
.sort((a, b) => a - b)
.map((so) => {
const titles = byStream
.get(so)
.map((r) => (r.title || '').trim() || 'Abschnitt')
return `Gruppe ${so + 1}: ${titles.join(' · ')}`
})
lines.push({
kind: 'parallel',
label: phaseOrder > 0 ? `Split · Phase ${phaseOrder}` : 'Split-Session',
detail: streamParts.join(' │ '),
})
} else {
const titles = run.map((r) => (r.title || '').trim() || 'Abschnitt')
lines.push({
kind: 'whole_group',
label: phaseOrder > 0 ? `Ganzgruppe · Phase ${phaseOrder}` : 'Ganzgruppe',
detail: titles.join(' → '),
})
}
}
return { lines, hasSplit, isEmpty: false }
}
/** GET-Vorlage → Editor-Abschnitte mit planLoc (Split-Sessions). */
export function formSectionsFromPlanTemplateRows(templateSections) {
const rows = Array.isArray(templateSections) ? [...templateSections] : []

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

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

View File

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