Grafische Änderungen (kein Trainingsmodal). Diverse Verwaltungsdialoge
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 16s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
All checks were successful
Deploy Production / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 16s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s
Reviewed-on: #38
This commit is contained in:
commit
99a5fccaa5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.145"
|
||||
BUILD_DATE = "2026-05-16"
|
||||
APP_VERSION = "0.8.149"
|
||||
BUILD_DATE = "2026-05-19"
|
||||
DB_SCHEMA_VERSION = "20260516065"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
|
|
@ -22,9 +22,9 @@ MODULE_VERSIONS = {
|
|||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
||||
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.13.0", # Vorlagen/Framework/Module/Graphs: RBAC wie Übungen (edit/delete/governance transition); Planungs-UI Sichtbarkeit neue Vorlage
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
|
||||
"training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen)
|
||||
"import_wiki": "1.0.3", # Default-Kategorie Fähigkeiten: Fähigkeitsbeschreibung; cmtitle-Normalisierung; UI Preview/Execute Defaults je Typ
|
||||
|
|
@ -36,6 +36,35 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.149",
|
||||
"date": "2026-05-19",
|
||||
"changes": [
|
||||
"Trainingsplanung: Einheiten-Editor als Vollseite (/planning/units/new|/:id/edit); Hub ohne Modal; Legacy ?unit= Redirect",
|
||||
"Drift-Schutz: planningUnitRoutes.js, trainingUnitEditorCore.js, Vitest; Playwright 14–15",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.148",
|
||||
"date": "2026-05-19",
|
||||
"changes": [
|
||||
"Planung Vorlagen: Strukturvorschau (Split-Sessions), Kurzbeschreibung, Bearbeitungsseite; Liste liefert sections[] mit",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.147",
|
||||
"date": "2026-05-19",
|
||||
"changes": [
|
||||
"Planung: Liste – Rahmen-Session & Übungen→Modul; Dialog Modul aus Einheit; klarere Rahmen-Unit-ID aus Liste",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.146",
|
||||
"date": "2026-05-19",
|
||||
"changes": [
|
||||
"Planung: Trainingseinheit → Rahmenprogramm (Session-Slot) speichern; API POST /api/training-units/{id}/publish-to-framework",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.145",
|
||||
"date": "2026-05-16",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-14
|
||||
**App-Version / DB-Schema:** App **`0.8.140`** (u. a. Planungs-Breakout-UI), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
|
||||
**Stand:** 2026-05-19
|
||||
**App-Version / DB-Schema:** App **`0.8.149`** (Einheiten-Editor Vollseite), DB-Schema **`20260515063`** — maßgeblich **`backend/version.py`**: `APP_VERSION`, `DB_SCHEMA_VERSION`
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -76,6 +76,13 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
|
||||
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
|
||||
|
||||
### Trainingsplanung: Einheiten-Editor als Vollseite (Stand **0.8.149**)
|
||||
|
||||
- **Routen:** `/planning` (Hub), `/planning/units/new`, `/planning/units/:id/edit`; Legacy `/planning?unit={id}` → Redirect auf Edit-Route.
|
||||
- **Code:** `TrainingUnitEditPage.jsx`, `TrainingUnitFormShell.jsx`, `planningUnitRoutes.js`, `trainingUnitEditorCore.js` (Payload-Drift-Schutz + Vitest).
|
||||
- **Hub:** `TrainingPlanningPageRoot.jsx` ohne Einheiten-Modal; Modals nur noch Import, Trainer zuweisen, Rahmen-Session/Modul aus Liste.
|
||||
- **Spec / DoD:** `docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md`; Playwright **14–15** (Edit-Route, Legacy-Redirect).
|
||||
|
||||
### Trainingsplan: Phasen, parallele Streams und Coaching (Stand **0.8.137–0.8.140**)
|
||||
|
||||
- **Schema / API:** Migration **063** — `training_unit_phases`, `training_unit_parallel_streams`; Sektionen mit `phase_id` bzw. `parallel_stream_id`. **`GET /api/training-units/:id`** liefert **`phases`** (verschachtelt) und weiterhin flache **`sections`**. **`PUT/POST`** mit **`phases`** für Breakout-Einheiten (vgl. `CHANGELOG` **0.8.138**); Legacy: flache `sections` → implizite Ganzgruppen-Phase.
|
||||
|
|
|
|||
126
docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md
Normal file
126
docs/architecture/TRAINING_UNIT_EDIT_PAGE_MIGRATION.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Migration: Trainings-Einheit — Modal → Vollseiten-Editor
|
||||
|
||||
**Status:** Phase C abgeschlossen (Hub + Edit-Route produktiv)
|
||||
**Stand:** 2026-05-19
|
||||
**Bezug:** Architektur-Schuld A1 (`SCHULDEN_UND_REMEDIATION.md`), UX-Grundsatz Modals vs. Vollseiten
|
||||
|
||||
---
|
||||
|
||||
## 1. Ausgangslage
|
||||
|
||||
| Aspekt | Ist (Modal) | Soll (Vollseite) |
|
||||
|--------|-------------|------------------|
|
||||
| Bearbeiten / Neu | `TrainingPlanningUnitFormModal` über `TrainingPlanningPageRoot` | `TrainingUnitEditPage` unter eigener Route |
|
||||
| Hub | Liste + Kalender auf `/planning` | unverändert — nur Übersicht & Kurzaktionen |
|
||||
| Deep-Link | `/planning?unit={id}` (öffnet Modal, Query wird entfernt) | `/planning/units/{id}/edit` (bookmarkbar) |
|
||||
| Vergleich | Rahmenprogramm, Modul, Vorlage, Run/Coach | gleiches Muster |
|
||||
|
||||
**Warum Modal historisch:** Frontend Phase 3 (0.8.131) — Extraktion aus God-Page, **kein** bewusstes UX-Zielbild.
|
||||
|
||||
---
|
||||
|
||||
## 2. Zielbild (verbindlich)
|
||||
|
||||
### 2.1 Routen
|
||||
|
||||
| Route | Zweck |
|
||||
|-------|--------|
|
||||
| `/planning` | Hub: Gruppe, Liste/Kalender, Import, Zuweisen, Löschen, Links „Bearbeiten“ |
|
||||
| `/planning/units/new` | Neue Einheit (Query: `group`, `date`, optional `template`) |
|
||||
| `/planning/units/:id/edit` | Bestehende Einheit bearbeiten (Query optional: `mode=debrief`) |
|
||||
| `/planning/run/:unitId` | Durchführung (unverändert) |
|
||||
| `/planning/run/:unitId/coach` | Coaching (unverändert) |
|
||||
|
||||
Implementierung: `frontend/src/utils/planningUnitRoutes.js` — **einzige** Quelle für Pfade und Rückkehr-Kontext (Drift-Schutz).
|
||||
|
||||
### 2.2 Was Modal bleibt (Hub + Editor)
|
||||
|
||||
| Dialog | Ort | Begründung |
|
||||
|--------|-----|------------|
|
||||
| Rahmen-Import | Hub | Mehrfachauswahl + Datums-Vorschläge |
|
||||
| Trainer zuweisen | Hub | Kurzaktion aus Liste/Kalender |
|
||||
| Rahmen-Session / Modul aus Liste | Hub | Aktion auf gespeicherter Einheit ohne Editor |
|
||||
| Übungspicker, Modul einfügen, Peek | **Editor-Seite** | Kontext des Ablaufs |
|
||||
| Rahmen übernehmen / Modul aus Editor | **Editor-Seite** | Nach Speichern des Ablaufs |
|
||||
| Kombi-Ablauf bearbeiten | SectionsEditor (Sheet) | Fokussierter Sub-Dialog |
|
||||
|
||||
### 2.3 UI-Shell Editor
|
||||
|
||||
- `TrainingUnitFormShell.jsx` — reine Formular-UI (ohne Overlay), `page-form-shell` + `FormActionBar` (`variant="page"`).
|
||||
- Kein `FormModalOverlay` / kein Scroll-Lock auf der Editor-Seite nötig.
|
||||
|
||||
### 2.4 Rückkehr zum Hub
|
||||
|
||||
Beim Navigieren vom Hub zum Editor wird `location.state.planningReturn` gesetzt (Gruppe, Ansicht, Monat, Datumsfilter).
|
||||
Abbrechen / Speichern & schließen → `/planning?…` mit wiederhergestellten Query-Parametern.
|
||||
|
||||
Legacy: `/planning?unit={id}` → Redirect auf `/planning/units/{id}/edit` (301/Replace via Router).
|
||||
|
||||
---
|
||||
|
||||
## 3. Code-Struktur (Ziel)
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── utils/
|
||||
│ ├── planningUnitRoutes.js # Pfade, Return-State, Legacy-Redirect
|
||||
│ ├── planningUnitRoutes.test.js # Vitest
|
||||
│ └── trainingUnitEditorCore.js # Reine Payload-/Form-Helfer
|
||||
│ └── trainingUnitEditorCore.test.js
|
||||
├── hooks/
|
||||
│ └── useTrainingUnitEditor.js # Laden, Speichern, Form-State (Edit-Page)
|
||||
├── components/planning/
|
||||
│ ├── TrainingUnitFormShell.jsx # Formular-UI (ex Modal-Inhalt)
|
||||
│ └── TrainingPlanningPageRoot.jsx # Hub only (~ weniger State)
|
||||
└── pages/
|
||||
└── TrainingUnitEditPage.jsx # Route-Container
|
||||
```
|
||||
|
||||
**Soft-Limit (S1):** `TrainingUnitEditPage.jsx` delegiert an Hook + Shell; PageRoot verliert Modal-State.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phasen & Abnahme
|
||||
|
||||
| Phase | Inhalt | Abnahme / Tests |
|
||||
|-------|--------|-----------------|
|
||||
| **A** | Doku, `planningUnitRoutes`, `trainingUnitEditorCore`, Vitest | `npm run test --prefix frontend` grün |
|
||||
| **B** | `TrainingUnitFormShell`, `TrainingUnitEditPage`, Routen, Hub-Navigation, Modal entfernen | Playwright 14–15; Build grün |
|
||||
| **C** | Hub liest Return-Query; Dashboard-Links direkt auf Edit-Route; PageRoot weiter entschlacken | Manuell + E2E |
|
||||
| **D** (optional) | `useTrainingUnitEditor` weiter modularisieren; ungespeichert-Blocker wie Modul-Edit | S8 Checkliste |
|
||||
|
||||
### Definition of Done (Phase B)
|
||||
|
||||
- [x] Kein `TrainingPlanningUnitFormModal` mehr in Produktionspfad
|
||||
- [x] `data-testid="planning-unit-form"` auf Editor-Seite
|
||||
- [x] Speichern sendet identisches Payload wie zuvor (`buildTrainingUnitSavePayload`)
|
||||
- [x] Split-Sessions / `phases`-PUT unverändert (`buildPlanPayloadForSave`)
|
||||
- [x] Playwright 12–13 weiter grün; 14–15 neu
|
||||
- [x] `docs/HANDOVER.md` + Roadmap aktualisiert
|
||||
|
||||
---
|
||||
|
||||
## 5. Risiken & Mitigation
|
||||
|
||||
| Risiko | Mitigation |
|
||||
|--------|------------|
|
||||
| Payload-Drift beim Speichern | Logik in `trainingUnitEditorCore.js` + Unit-Tests |
|
||||
| Kontextverlust Hub | `planningReturn` in `planningUnitRoutes.js` + Hub-Query-Restore |
|
||||
| Modul-Einfügen / Picker | Nur auf Edit-Page; gleiche Handler wie zuvor |
|
||||
| Doppelte Einträge Deep-Link | Legacy-Redirect; Dashboard später auf neue URL |
|
||||
| Große Edit-Page | Hook + Shell; PageRoot schrumpft |
|
||||
|
||||
---
|
||||
|
||||
## 6. Nicht-Ziele (dieser Sprint)
|
||||
|
||||
- Backend-API-Änderungen
|
||||
- Virtualisierung der Einheitenliste
|
||||
- Refactor `TrainingUnitSectionsEditor`
|
||||
- Coach/Run-Flows
|
||||
|
||||
---
|
||||
|
||||
## 7. Pflege
|
||||
|
||||
Nach Abschluss Phase B: Eintrag in `UMSETZUNGSPLAN_ROADMAP.md` (Phase 3 Nachzug), `SCHULDEN_UND_REMEDIATION.md` A1 Fortschritt, `backend/version.py` CHANGELOG.
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
"scripts": {
|
||||
"dev": "vite --port 3098",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
|
@ -20,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.1.4"
|
||||
"vite": "^5.1.4",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
114
frontend/src/components/FormActionBar.jsx
Normal file
114
frontend/src/components/FormActionBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/components/FormModalOverlay.jsx
Normal file
56
frontend/src/components/FormModalOverlay.jsx
Normal 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,
|
||||
)
|
||||
}
|
||||
31
frontend/src/components/PageFormEditorChrome.jsx
Normal file
31
frontend/src/components/PageFormEditorChrome.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
? (() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
31
frontend/src/components/planning/PlanningRouteNav.jsx
Normal file
31
frontend/src/components/planning/PlanningRouteNav.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
312
frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
Normal file
312
frontend/src/components/planning/SaveExercisesAsModuleModal.jsx
Normal 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
|
|
@ -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; Vereinsinhalte als Vereinsadmin; offizielle nur als
|
||||
Plattform‑Admin. 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
485
frontend/src/components/planning/TrainingUnitFormShell.jsx
Normal file
485
frontend/src/components/planning/TrainingUnitFormShell.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
frontend/src/context/FormEditorActionsContext.jsx
Normal file
38
frontend/src/context/FormEditorActionsContext.jsx
Normal 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])
|
||||
}
|
||||
60
frontend/src/hooks/useModalScrollLock.js
Normal file
60
frontend/src/hooks/useModalScrollLock.js
Normal 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])
|
||||
}
|
||||
18
frontend/src/layouts/PlanningLayout.jsx
Normal file
18
frontend/src/layouts/PlanningLayout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -266,7 +266,7 @@ function Dashboard() {
|
|||
<ul className="dashboard-preview-card__list">
|
||||
{trainingHome.reviewPending.map((u) => (
|
||||
<li key={`r-${u.id}`}>
|
||||
<Link to={`/planning?unit=${u.id}`} className="dashboard-preview-card__link">
|
||||
<Link to={`/planning/units/${u.id}/edit`} className="dashboard-preview-card__link">
|
||||
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
|
||||
</Link>
|
||||
{u.group_name ? (
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ function MediaUsageBlock({ usage, compact }) {
|
|||
[t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}`
|
||||
const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}…` : label
|
||||
return (
|
||||
<Link key={t.id} to={`/planning?unit=${t.id}`} title={label}>
|
||||
<Link key={t.id} to={`/planning/units/${t.id}/edit`} title={label}>
|
||||
{short}
|
||||
</Link>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
337
frontend/src/pages/TrainingPlanTemplateEditPage.jsx
Normal file
337
frontend/src/pages/TrainingPlanTemplateEditPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
196
frontend/src/pages/TrainingPlanTemplatesListPage.jsx
Normal file
196
frontend/src/pages/TrainingPlanTemplatesListPage.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
794
frontend/src/pages/TrainingUnitEditPage.jsx
Normal file
794
frontend/src/pages/TrainingUnitEditPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
103
frontend/src/utils/planningUnitRoutes.js
Normal file
103
frontend/src/utils/planningUnitRoutes.js
Normal 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',
|
||||
}
|
||||
}
|
||||
61
frontend/src/utils/planningUnitRoutes.test.js
Normal file
61
frontend/src/utils/planningUnitRoutes.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
60
frontend/src/utils/trainingPlanModuleFromUnit.js
Normal file
60
frontend/src/utils/trainingPlanModuleFromUnit.js
Normal 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
|
||||
}
|
||||
141
frontend/src/utils/trainingUnitEditorCore.js
Normal file
141
frontend/src/utils/trainingUnitEditorCore.js
Normal 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 }))
|
||||
}
|
||||
53
frontend/src/utils/trainingUnitEditorCore.test.js
Normal file
53
frontend/src/utils/trainingUnitEditorCore.test.js
Normal 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))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
10
frontend/vitest.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.js'],
|
||||
},
|
||||
})
|
||||
|
|
@ -305,6 +305,46 @@ test('13. Trainingsplanung: Rahmen-Import-Dialog öffnet und schließt', async (
|
|||
console.log('✓ Trainingsplanung: Rahmen-Import-Dialog Smoke');
|
||||
});
|
||||
|
||||
test('14. Trainingsplanung: Bearbeiten navigiert zur Edit-Route', async ({ page }, testInfo) => {
|
||||
await login(page);
|
||||
await page.goto('/planning', { waitUntil: 'networkidle' });
|
||||
const main = page.locator('.app-main');
|
||||
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 });
|
||||
const editBtn = main.getByRole('button', { name: 'Bearbeiten' }).first();
|
||||
if (!(await editBtn.count())) {
|
||||
testInfo.skip(true, 'Keine Trainingseinheit in der Liste');
|
||||
return;
|
||||
}
|
||||
await editBtn.click();
|
||||
await page.waitForURL(/\/planning\/units\/\d+\/edit/, { timeout: 15000 });
|
||||
await expect(page.getByTestId('planning-unit-form')).toBeVisible({ timeout: 15000 });
|
||||
console.log('✓ Trainingsplanung: Edit-Route aus Hub');
|
||||
});
|
||||
|
||||
test('15. Trainingsplanung: Legacy-Deep-Link ?unit= leitet auf Edit-Route um', async ({ page }, testInfo) => {
|
||||
await login(page);
|
||||
await page.goto('/planning', { waitUntil: 'networkidle' });
|
||||
const main = page.locator('.app-main');
|
||||
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 });
|
||||
const editBtn = main.getByRole('button', { name: 'Bearbeiten' }).first();
|
||||
if (!(await editBtn.count())) {
|
||||
testInfo.skip(true, 'Keine Trainingseinheit in der Liste');
|
||||
return;
|
||||
}
|
||||
await editBtn.click();
|
||||
await page.waitForURL(/\/planning\/units\/(\d+)\/edit/, { timeout: 15000 });
|
||||
const m = page.url().match(/\/planning\/units\/(\d+)\/edit/);
|
||||
const unitId = m?.[1];
|
||||
if (!unitId) {
|
||||
testInfo.skip(true, 'Unit-ID aus Edit-URL nicht ermittelbar');
|
||||
return;
|
||||
}
|
||||
await page.goto(`/planning?unit=${unitId}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForURL(new RegExp(`/planning/units/${unitId}/edit`), { timeout: 15000 });
|
||||
await expect(page.getByTestId('planning-unit-form')).toBeVisible({ timeout: 15000 });
|
||||
console.log('✓ Trainingsplanung: Legacy ?unit= Redirect');
|
||||
});
|
||||
|
||||
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await login(page);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user