Verbesserung UX für Übungen #44
|
|
@ -104,6 +104,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
**Skills-System:**
|
||||
|
||||
- [x] Hierarchisches Schema, Fokusbereich-Zuordnung, Exercise-Skill mit Levels
|
||||
- [x] **Gewichtetes Fähigkeiten-Profil (Phase 3):** Module, Rahmenprogramme, Regressionspfade; Peer-Kontext getrennt; Listen-Filter + Discovery — **`technical/SKILL_SCORING_SPEC.md`**
|
||||
|
||||
**Admin-UI:**
|
||||
|
||||
|
|
|
|||
|
|
@ -407,10 +407,9 @@ skill_level_definitions (
|
|||
- Reaktion (Koordination, target_level: 2, intensity: mittel)
|
||||
|
||||
**Attribute pro Fähigkeitsbezug:**
|
||||
- is_primary (Haupt- oder Nebenfähigkeit)
|
||||
- intensity (niedrig/mittel/hoch)
|
||||
- required_level (Voraussetzung, 1-5)
|
||||
- target_level (Ziel-Level, 1-5)
|
||||
- `intensity` — Nutzeneinschätzung: **niedrig | mittel | hoch** (Standard **mittel**)
|
||||
- `required_level` / `target_level` — Stufen-Spanne (kanonische Slugs basis … optimierung)
|
||||
- `is_primary` — Legacy-Feld; **nicht mehr in der UI**, beim Speichern immer false; Scoring ignoriert es
|
||||
|
||||
**🆕 Fokusbereich-Filterung:**
|
||||
- Bei Übungen mit Fokusbereich "Karate" sollten primär KARATE-Fähigkeiten zugeordnet werden
|
||||
|
|
@ -474,6 +473,34 @@ skill_level_definitions (
|
|||
|
||||
**Konkretisierung (037/API):** `POST /api/training-units/from-framework-slot` legt eine geplante Einheit aus dem Slot‑Blueprint an; **`origin_framework_slot_id`** dient als Herkunftsreferenz (**Lineage light**; weiteres Feedback/Lineage‑Konzept: Konzeptpapier Schritt **E**).
|
||||
|
||||
### Trainingsmodul (Bibliothek)
|
||||
|
||||
**Abgrenzung:** Wiederverwendbare **Übungsfolge** (`training_modules` + `training_module_items`) — kein Kalendertermin, kein Rahmen-Slot. Übernahme in geplante Einheiten über Planung (`apply-training-module`).
|
||||
|
||||
**Governance:** wie andere Bibliotheksartefakte (`visibility`, `club_id`, `library_content_visibility_sql`).
|
||||
|
||||
### Gewichtetes Fähigkeiten-Profil (Planungs-Bausteine, Phase 3)
|
||||
|
||||
**Zweck:** Aus den verknüpften Übungen eines Planungsartefakts wird ein **Fähigkeiten-Profil** berechnet (Trainingsgewicht je Fähigkeit). Trainer vergleichen Bausteine **innerhalb desselben Typs**, um z. B. das passendste Modul für eine Ziel-Fähigkeit zu finden.
|
||||
|
||||
**Artefakttypen (getrennte Peer-Kontexte):**
|
||||
|
||||
| Typ | Vergleich |
|
||||
|-----|-----------|
|
||||
| `training_module` | nur sichtbare **Module** |
|
||||
| `framework_program` | nur sichtbare **Rahmenprogramme** |
|
||||
| `progression_graph` | nur sichtbare **Regressionspfade** |
|
||||
|
||||
**Metriken (Nutzer):**
|
||||
|
||||
- **Score / Gewicht** — absolut (Dauer × Häufigkeit × Intensität × Stufen-Spanne)
|
||||
- **Prozent** — Anteil am stärksten sichtbaren Peer **desselben Typs** für diese Fähigkeit (max. 100 %)
|
||||
- **★** — stärkster Peer in diesem Kontext
|
||||
|
||||
**UI:** Profile in Editoren; KPI-Kacheln und Filter in Listen (`/planning/framework-programs`, `/planning/training-modules`); Discovery auf der Fähigkeiten-Seite.
|
||||
|
||||
**Technik:** `backend/skill_scoring.py`, `routers/skill_profiles.py` — Spec **`technical/SKILL_SCORING_SPEC.md`**.
|
||||
|
||||
### Parallele Trainingsstreams (Breakout)
|
||||
|
||||
**Fachlich:** Eine Kalender‑**Einheit** kann aus **Phasen** bestehen — z. B. gemeinsamer Block, dann **beliebig viele parallele** „Teilstrecken“ (**Streams**) mit je eigenem Miniplan (Abschnitte/Übungen), erneut gemeinsamer Block. Das ist **nicht** dasselbe wie ein **Rahmenprogramm‑Slot** (Serien‑Session über Wochen): Slots strukturieren **mehrere Einheiten** in einem Programm; **Streams** strukturieren **gleichzeitige** Abläufe **innerhalb einer** Einheit.
|
||||
|
|
@ -648,12 +675,13 @@ skill_level_definitions (
|
|||
- [ ] Level-Definitionen aus Fähigkeitsmatrix extrahieren (optional)
|
||||
- [ ] Skills-Beschreibungen aus Wiki importieren (Migration 024)
|
||||
- [ ] Admin-UI für Fähigkeiten-Kategorien (CRUD)
|
||||
- [ ] Skill-Filter in Übungssuche integrieren
|
||||
- [x] Skill-Filter in Übungssuche (SkillTreeMultiSelect + Stufen)
|
||||
- [x] Gewichtetes Fähigkeiten-Profil für Planungs-Bausteine (Module, Rahmen, Pfade) — siehe `technical/SKILL_SCORING_SPEC.md`
|
||||
- [ ] Reifegradmodelle definieren (Kombination Fokusbereich + Stil + Zielgruppe)
|
||||
- [ ] KI-Unterstützung für Trainingsplanung (basierend auf Fähigkeiten-Level)
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-04-27
|
||||
**Letzte Aktualisierung:** 2026-05-20
|
||||
**Verantwortlich:** Claude Code
|
||||
**Review:** Pending
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Gelieferte Features & technische Basis (Q2 2026)
|
||||
|
||||
**Stand:** 2026-05-12
|
||||
**Stand:** 2026-05-20
|
||||
**Referenz:** `backend/version.py` — aktuelle **APP_VERSION** / **DB_SCHEMA_VERSION** (Stand Code u. a. **0.8.96**)
|
||||
|
||||
Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. Trainingsrahmen‑Bibliothek + Slot‑Blueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§3–4**. **Medien-Archiv & Bibliothek:** Abschnitt **12** unten + **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
|
||||
|
|
@ -169,11 +169,35 @@ Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick gel
|
|||
|
||||
---
|
||||
|
||||
## 15. Verweise
|
||||
## 15. Gewichtetes Fähigkeiten-Scoring (Phase 3, Stand 2026-05-20)
|
||||
|
||||
Norm: **`technical/SKILL_SCORING_SPEC.md`**.
|
||||
|
||||
### 15.1 Backend
|
||||
|
||||
- **`skill_scoring.py`:** Gewichtung (Dauer × Vorkommen × Intensität × Stufen); `compute_planning_corpus_by_type()` mit getrennten Corpora; `universal_percent` capped auf 100 %
|
||||
- **`routers/skill_profiles.py`:** Profile-GET pro Artefakt; `POST /api/skill-profiles/batch-summaries`; `GET /api/skill-discovery/suggestions`
|
||||
- Sichtbarkeit: **`library_content_visibility_sql`** (Planungs-Bibliothek, nicht „nur Verein club“)
|
||||
|
||||
### 15.2 Frontend
|
||||
|
||||
- **Listen:** Rahmenprogramme + Trainingsmodule — Filter-Modal (wie Übungen), Chips, `SkillTreeMultiSelect` (Portal-Dropdown)
|
||||
- **KPI:** `SkillProfileCompact` — Top je Unterkategorie, Score + Peer-%
|
||||
- **Editoren + Modal:** `SkillProfilePanel`, `SkillProfileFullModal`
|
||||
- **Discovery:** `SkillDiscoveryPanel` auf Fähigkeiten-Seite
|
||||
|
||||
### 15.3 Offen
|
||||
|
||||
- Corpus-Caching; pytest für Typ-Trennung; Filter-Persistenz; Skill-Filter Import-Dialog „Rahmen übernehmen“
|
||||
|
||||
---
|
||||
|
||||
## 16. Verweise
|
||||
|
||||
| Thema | Dokument |
|
||||
|--------|----------|
|
||||
| Rahmenprogramm / Progressionsgraph | `technical/TRAINING_FRAMEWORK_SPEC.md` |
|
||||
| Fähigkeiten-Scoring Planung | `technical/SKILL_SCORING_SPEC.md` |
|
||||
| API Übungen | `technical/EXERCISES_API_SPEC.md` |
|
||||
| Domänenmodell | `functional/DOMAIN_MODEL.md` |
|
||||
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
|
||||
|
|
|
|||
|
|
@ -1,12 +1,33 @@
|
|||
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
|
||||
|
||||
**Stand:** 2026-05-20
|
||||
**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala)
|
||||
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
|
||||
**Status:** Variante A (regelbasiert) umgesetzt — **v1.3** (Peer-Kontext getrennt + Listen-Filter)
|
||||
**Modul:** `backend/skill_scoring.py`, Router `backend/routers/skill_profiles.py`
|
||||
|
||||
## Ziel
|
||||
|
||||
Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** (Progressionsgraphen), deren Übungen diese Fähigkeiten stark abdecken.
|
||||
Trainer wählen **Schwerpunkt-Fähigkeiten** und finden passende **Bausteine** für die Trainingsplanung:
|
||||
|
||||
- **Trainingsmodule** — wiederverwendbare Übungsfolgen
|
||||
- **Rahmenprogramme** — Programme mit Zielen und Session-Slots
|
||||
- **Regressionspfade** (Progressionsgraphen) — Übungsketten
|
||||
|
||||
Das Scoring beantwortet: *Wie stark trainiert dieser Baustein eine Fähigkeit?* und *Wie stark ist er im Vergleich zu anderen **sichtbaren** Bausteinen **desselben Typs**?*
|
||||
|
||||
## Fachliche Kernregel: Peer-Kontext (nicht vermischen)
|
||||
|
||||
| Planungs-Artefakt | Vergleichsgruppe (`universal_percent`) |
|
||||
|-------------------|----------------------------------------|
|
||||
| Trainingsmodul | nur andere **sichtbare Module** |
|
||||
| Rahmenprogramm | nur andere **sichtbare Rahmenprogramme** |
|
||||
| Regressionspfad | nur andere **sichtbare Pfade** |
|
||||
|
||||
**Nicht** verglichen werden:
|
||||
|
||||
- Module vs. Rahmenprogramme vs. Pfade (kein Mix)
|
||||
- Artefakte anderer Vereine, auf die der Nutzer keinen Planungszugriff hat
|
||||
|
||||
**Sichtbarkeit:** `library_content_visibility_sql` — private, vereinsinterne und offizielle Inhalte gemäß Mandant/Rolle, analog zu anderen Bibliothekslisten.
|
||||
|
||||
## Datenquellen
|
||||
|
||||
|
|
@ -19,7 +40,7 @@ Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rah
|
|||
|
||||
Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`).
|
||||
|
||||
## Gewichtungsformel (v1.1)
|
||||
## Gewichtungsformel (v1.1 / v1.2)
|
||||
|
||||
Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
|
||||
|
||||
|
|
@ -44,8 +65,6 @@ Kanonische Slugs: basis … optimierung (1–5). Fehlen beide: Faktor 1,0.
|
|||
- **Mittelpunkt** = durchschnittliche Stufe
|
||||
- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,96–1,20
|
||||
|
||||
Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
|
||||
|
||||
### Bewusst nicht im Scoring
|
||||
|
||||
| Feld | Grund |
|
||||
|
|
@ -53,44 +72,113 @@ Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
|
|||
| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein |
|
||||
| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt |
|
||||
|
||||
Aggregation:
|
||||
## Aggregierte Metriken
|
||||
|
||||
- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
|
||||
- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
|
||||
- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
|
||||
- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade
|
||||
- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht)
|
||||
- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs
|
||||
| Feld | Bedeutung |
|
||||
|------|-----------|
|
||||
| `weight` / `score` | Absolutes **Trainingsgewicht** (gewichtete Minuten) — über alle Fähigkeiten eines Artefakts vergleichbar |
|
||||
| `share_percent` | Anteil am `total_weight` **innerhalb dieses Artefakts** (summiert 100 %) — sekundär |
|
||||
| `by_main_category[]` | Je Unterkategorie `top_skill` (stärkste Fähigkeit nach Gewicht) |
|
||||
| `universal_percent` | Anteil am **Maximum derselben Fähigkeit im Peer-Kontext** (max. 100 %) |
|
||||
| `is_club_best_for_skill` | Stärkster sichtbarer Peer für diese Fähigkeit (★ in UI) |
|
||||
| `club_best` | Referenz-Peer (Titel, Typ, Gewicht) — **Legacy-Name**, fachlich Peer-Best |
|
||||
|
||||
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
|
||||
### Berechnung `universal_percent`
|
||||
|
||||
```
|
||||
effective_ref = max(max_weight_in_peer_corpus(skill_id), eigenes_gewicht)
|
||||
universal_percent = min(100, weight / effective_ref × 100)
|
||||
```
|
||||
|
||||
Corpus je Typ: `compute_planning_corpus_by_type()` scannt sichtbare Artefakte getrennt nach `framework_program`, `training_module`, `progression_graph`.
|
||||
|
||||
Discovery-Sortierung nutzt **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil. Discovery verwendet typ-getrennte Referenz (`fw_ref`, `mod_ref`, `graph_ref`).
|
||||
|
||||
## API
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` |
|
||||
| GET | `/api/training-modules/{id}/skill-profile` | `overall` |
|
||||
| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` |
|
||||
| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile`; `reference_scale` (Peer-Kontext Rahmenprogramme) |
|
||||
| GET | `/api/training-modules/{id}/skill-profile` | `overall`; `reference_scale` (Peer-Kontext Module) |
|
||||
| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall`; `reference_scale` (Peer-Kontext Pfade) |
|
||||
| POST | `/api/skill-profiles/batch-summaries` | Kompakte Profile für Listen; Body: `frameworkProgramIds`, `trainingModuleIds`, …; Response: `summaries`, `reference_scale_by_type`, `club_best_by_skill` |
|
||||
| GET | `/api/skill-discovery/suggestions?skill_ids=1,2,3` | Ranking sichtbarer Artefakte; Query `types`, `limit` |
|
||||
|
||||
Zugriff: `get_tenant_context` + gleiche Sichtbarkeit wie Parent-Artefakt (`library_content_visibility_sql`).
|
||||
Zugriff: `get_tenant_context` + `library_content_visibility_sql` wie Parent-Artefakt.
|
||||
|
||||
### `reference_scale` / `reference_scale_by_type`
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": "planning_peer",
|
||||
"artifact_type": "training_module",
|
||||
"artifacts_scanned": 12,
|
||||
"skills_in_corpus": 34,
|
||||
"description": "Prozent = Anteil am stärksten sichtbaren Eintrag unter Trainingsmodulen …"
|
||||
}
|
||||
```
|
||||
|
||||
## UI
|
||||
|
||||
- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session
|
||||
- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“
|
||||
- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“
|
||||
- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche
|
||||
### Bearbeitung (Vollprofil)
|
||||
|
||||
Profil wird nach **Speichern** neu geladen (`skillProfileTick`).
|
||||
| Ort | Panel | `artifactType` |
|
||||
|-----|-------|----------------|
|
||||
| Rahmenprogramm bearbeiten | Fähigkeiten-Schwerpunkte (+ Sessions) | `framework_program` |
|
||||
| Trainingsmodul bearbeiten | Fähigkeiten im Modul | `training_module` |
|
||||
| Progressionsgraph | Fähigkeiten entlang des Pfads | `progression_graph` |
|
||||
|
||||
Anzeige: Top je Kategorie (Editor) oder alle Fähigkeiten (Modal). Hinweise nennen Peer-Kontext explizit (z. B. „72 % Rahmenpr.“).
|
||||
|
||||
### Listen & Filter (UX wie Übungsliste)
|
||||
|
||||
| Liste | Filter |
|
||||
|-------|--------|
|
||||
| Rahmenprogramme (`/planning/framework-programs`) | Suche, Katalog (Fokus/Trainingsart/Zielgruppe), Session-Dauer, **Fähigkeiten** (`SkillTreeMultiSelect`), Mindest-% im Peer-Kontext, Sortierung nach Stärke |
|
||||
| Trainingsmodule (`/planning/training-modules`) | Suche, **Fähigkeiten** (+ Min-%, Sortierung) |
|
||||
|
||||
- **Filter-Button** mit Badge, entfernbare **Chips**, Einstellungen im **Modal** (`PlanningArtifactFilterModal`)
|
||||
- KPI-Kacheln: Top-Fähigkeit **je Unterkategorie** mit Score + Peer-%
|
||||
- Vollprofil-Modal: `SkillProfileFullModal` mit `displayMode=full`
|
||||
|
||||
Profil wird nach Speichern neu geladen (`skillProfileTick` in Editoren).
|
||||
|
||||
### Discovery
|
||||
|
||||
**Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + API `/api/skill-discovery/suggestions` (optional Filter `types`).
|
||||
|
||||
## Frontend-Module (Auswahl)
|
||||
|
||||
| Pfad | Rolle |
|
||||
|------|--------|
|
||||
| `frontend/src/components/planning/PlanningArtifactFilterModal.jsx` | Filter-Modal |
|
||||
| `frontend/src/components/planning/PlanningSkillFilterSection.jsx` | Fähigkeiten-Block im Modal |
|
||||
| `frontend/src/utils/planningArtifactFilterChips.js` | Chip-Labels + Entfernen |
|
||||
| `frontend/src/utils/frameworkProgramListHelpers.js` | Client-Filter Rahmenprogramme |
|
||||
| `frontend/src/utils/trainingModuleListHelpers.js` | Client-Filter Module |
|
||||
| `frontend/src/components/skills/SkillProfileCompact.jsx` | KPI-Kacheln in Listen |
|
||||
| `frontend/src/components/SkillTreeMultiSelect.jsx` | Baumauswahl (Portal-Dropdown in Modals) |
|
||||
|
||||
## Grenzen / später
|
||||
|
||||
- Kein Cache in DB (`skill_profile_json`) — on-the-fly; bei Performance >50 Artefakte serverseitiger Index
|
||||
- Kein DB-Cache (`skill_profile_json`) — on-the-fly; bei >50 Artefakten pro Typ serverseitiger Index/Caching
|
||||
- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring)
|
||||
- KI-Zusammenfassung (Variante B Roadmap) nicht Teil von v1.0
|
||||
- KI-Zusammenfassung (Variante B) nicht Teil von v1.0
|
||||
- Trainings**einheiten** (Kalender) optional als nächste Erweiterung
|
||||
- Filter-Persistenz („Als Standard speichern“) wie bei Übungen — noch nicht für Planungslisten
|
||||
- Fähigkeiten-Filter im Dialog **Planung → Rahmen übernehmen** — Katalog ja, Skill-Filter optional nachziehen
|
||||
- API-Feldnamen `club_*` / `skillMinClubPercent` — technische Altlast, semantisch Peer-Kontext
|
||||
|
||||
## Tests
|
||||
|
||||
- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score
|
||||
- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score, Cap 100 %
|
||||
- **Offen:** dedizierte Tests für `compute_planning_corpus_by_type` (Typ-Trennung)
|
||||
|
||||
## Verweise
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| `functional/DOMAIN_MODEL.md` | Domänenabschnitt Planungs-Fähigkeiten-Profil |
|
||||
| `docs/FACHLICHE_NUTZERFUNKTIONEN.md` | Nutzerüberblick Listen/Filter |
|
||||
| `docs/HANDOVER.md` | Handover-Abschnitt Phase 3 |
|
||||
| `technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` | Sichtbarkeit / Mandant |
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
| `functional/DOMAIN_MODEL.md` | Fachliche Begriffe; Kurzverweis auf Progressionsgraph ergänzt. |
|
||||
| `TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md` | **Was** und **warum** (Bibliothek vs. Instanz, Governance, CURR‑Tabelle). |
|
||||
| `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `technical/PARALLEL_TRAINING_STREAMS_SPEC.md` | **Parallele Streams / Breakout innerhalb einer Einheit** — orthogonale Domäne zu **Rahmen‑Slots** (Serien‑Sessions). |
|
||||
| `technical/SKILL_SCORING_SPEC.md` | **Fähigkeiten-Profil** der Rahmen‑Slots / Module / Pfade; Listen-Filter und Peer‑Vergleich (nur gleicher Artefakttyp). |
|
||||
|
||||
**Konsequenz:** Diese Datei bleibt der **technische Arbeitspool** für Rahmenprogramm Stufe 1–2. Abschnitt **§4** beschreibt explizit den **aktuellen Produktfreigabe-Umfang** und **bekannte Lücken** (damit Trainingsplanung weiter gebaut werden kann ohne falscher Erwartung an „Alternative‑Pakete“ in der UI).
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Bezug:** `functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (Kopf „V3“, inkl. **§ 10.2.1**, **§ 10.4 Coaching-Stufen**, **Anhang A** Implementierungsabgleich — Drift-Schutz)
|
||||
**Technische Entwurfsspezifikation:** `technical/TRAINING_MODULES_AND_COMBINATION_EXERCISES_SPEC.md`
|
||||
**Stand dieses Dokuments:** 2026-05-12 (Abgleich mit Code App **0.8.110**, siehe `backend/version.py`)
|
||||
**Stand dieses Dokuments:** 2026-05-20 (Abgleich mit Code, siehe `backend/version.py`)
|
||||
|
||||
## Ziele
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ Umsetzung der MVP-Punkte aus der Fachspezifikation ohne die bestehende Planung z
|
|||
|
||||
| Phase | Inhalt | Status |
|
||||
|-------|--------|--------|
|
||||
| **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“ | **umgesetzt (MVP Schritt 1)** |
|
||||
| **1** | **Trainingsmodule (Bibliothek):** Tabellen `training_modules`, `training_module_items`; REST CRUD mit Governance wie andere Bibliotheken; Übernahme in eine bestehende Einheit per `POST /api/training-units/{id}/apply-training-module` (Anfügen ans Ende eines Abschnitts via `section_order_index`); optionale Lineage-Spalte `source_training_module_id` auf Planungsitems; UI: Liste/Editor unter `/planning/training-modules`, Link von der Planung, Modal „Modul übernehmen“; **Ergänzung 2026-05-20:** Fähigkeiten-Profil + Listen-Filter (Peer-Vergleich nur unter Modulen) — `technical/SKILL_SCORING_SPEC.md` | **umgesetzt (MVP Schritt 1)** |
|
||||
| **2** | Kombinationsübungen: `exercise_kind`/`combination_*`, Slots, Pools, `method_archetype`, `method_profile` (JSON) | **teilweise** — wie links; zusätzlich **057** `planning_method_profile`; Planungs-Merge Client (`effectiveComboMethodProfile`); Archetypen weiterhin **nur Code-Konstanten** (kein Admin) | **Offen:** Archetyp-Admin-UI; Profil↔Archetyp-Validierung Backend; „alle Slots vorbelegen“ / Presets (siehe Fachspez **§ 10.6**); Haupt-/Nebenmethoden an Kombi wo Spec es verlangt |
|
||||
| **3** | Planungsblöcke: Gruppierung, Auflösen, „als Modul speichern“, erweiterter Übernahmemodus (Zwischenposition) | geplant |
|
||||
| **4** | Coaching: Archetyp-Support | **teilweise:** **Stufe A** — Merge Katalog+Planung; `CombinationPlanBracket` in Peek/Run; globale Profilzahlen mit Labels (`describeGlobalComboProfile`); Stations-/Timing-Zusammenfassung inkl. Wdh.-Hinweise. **Stufe B/C** — **offen** (§ 10.6, Anhang A) |
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
|
||||
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
|
||||
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
||||
> | Fähigkeiten-Scoring (Planungs-Bausteine) | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
|
||||
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
|
||||
> | Fachlicher Nutzerüberblick (Design/Product) | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
|
||||
|
|
@ -90,6 +91,7 @@ Kurz (Stand 2026-05-14): App- und DB-Version siehe **`backend/version.py`**; Ker
|
|||
|
||||
### Log (Auszug)
|
||||
|
||||
- 2026-05-20: **Fähigkeiten-Scoring Phase 3** — gewichtete Profile für Module/Rahmen/Pfade; Peer-Vergleich getrennt nach Artefakttyp; Listen-Filter + Discovery — siehe `SKILL_SCORING_SPEC.md`, `docs/HANDOVER.md` §2.6, `FEATURES_DELIVERED_2026-Q2.md` §15.
|
||||
- 2026-05-07: **Medien** — zentrales Archiv (`media_assets`), Bibliothek-UI, Lifecycle/Papierkorb, `from-asset`, Speicherpfade `library/…`, Governance `official`/Copyright; **0.8.59** aktiver Verein UI/API-Sync — siehe `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12, `docs/HANDOVER.md`.
|
||||
- 2026-05-05: Rahmen nur Bibliothek (**036**), Slot‑Ablauf = `training_units` + Sektionen (**037**), `POST /api/training-units/from-framework-slot`, keine `training_framework_slot_exercises` mehr — siehe `DATABASE_SCHEMA.md` / `FEATURES_DELIVERED_2026-Q2.md`.
|
||||
- 2026-04-27: Übungsvarianten API/UI, Migration 030, Listen-UX-Suche, Admin-Upload-Limits — siehe `PROJECT_STATUS.md` und `docs/library/FEATURES_DELIVERED_2026-Q2.md`.
|
||||
|
|
|
|||
|
|
@ -113,6 +113,25 @@ def normalize_exercise_skill_level(value) -> Optional[str]:
|
|||
return s
|
||||
return _LEGACY_SKILL_LEVEL_SLUG.get(s)
|
||||
|
||||
|
||||
_ALLOWED_SKILL_INTENSITY = frozenset({"niedrig", "mittel", "hoch"})
|
||||
|
||||
|
||||
def normalize_exercise_skill_intensity(value) -> str:
|
||||
"""Kanonische Nutzeneinschätzung; leer/ungültig → mittel (kein leerer Wert)."""
|
||||
if value is None:
|
||||
return "mittel"
|
||||
key = str(value).strip().lower()
|
||||
if key in ("low",):
|
||||
return "niedrig"
|
||||
if key in ("medium",):
|
||||
return "mittel"
|
||||
if key in ("high",):
|
||||
return "hoch"
|
||||
if key in _ALLOWED_SKILL_INTENSITY:
|
||||
return key
|
||||
return "mittel"
|
||||
|
||||
MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
|
||||
MAX_EXERCISE_MEDIA = 10
|
||||
# Upload-Limits (Übungs-Medien): Trainer wie bisher kleiner; Admin/Superadmin höheres Limit für große Videos
|
||||
|
|
@ -1102,13 +1121,15 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
|||
FROM exercise_skills es
|
||||
JOIN skills s ON es.skill_id = s.id
|
||||
WHERE es.exercise_id = %s
|
||||
ORDER BY es.is_primary DESC, s.name""",
|
||||
ORDER BY s.name""",
|
||||
(exercise_id,)
|
||||
)
|
||||
exercise["skills"] = [r2d(r) for r in cur.fetchall()]
|
||||
for sk in exercise["skills"]:
|
||||
sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
|
||||
sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level"))
|
||||
sk["intensity"] = normalize_exercise_skill_intensity(sk.get("intensity"))
|
||||
sk["is_primary"] = False
|
||||
|
||||
# Variants (1:N) - mit Progression (Reihenfolge: sequence_order, dann progression_level)
|
||||
cur.execute(
|
||||
|
|
@ -1223,8 +1244,8 @@ def assign_exercise_relations(
|
|||
(
|
||||
exercise_id,
|
||||
skill["skill_id"],
|
||||
skill.get("is_primary", False),
|
||||
skill.get("intensity"),
|
||||
False,
|
||||
normalize_exercise_skill_intensity(skill.get("intensity")),
|
||||
normalize_exercise_skill_level(skill.get("required_level")),
|
||||
normalize_exercise_skill_level(skill.get("target_level")),
|
||||
skill.get("ai_suggested", False),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from db import get_db, get_cursor, r2d
|
|||
from auth import require_auth
|
||||
from smw_client import SmwClient, SmwClientError
|
||||
from smw_mapper import map_wiki_to_exercise, map_wiki_to_skill, map_wiki_to_method, build_skill_assignments
|
||||
from routers.exercises import normalize_exercise_skill_intensity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -615,14 +616,16 @@ def _assign_exercise_skills(cur, conn, exercise_id: int, skill_assignments: list
|
|||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (exercise_id, skill_id) DO UPDATE SET
|
||||
target_level = EXCLUDED.target_level,
|
||||
is_primary = EXCLUDED.is_primary""",
|
||||
required_level = EXCLUDED.required_level,
|
||||
intensity = EXCLUDED.intensity,
|
||||
is_primary = false""",
|
||||
(
|
||||
exercise_id,
|
||||
sid,
|
||||
assignment.get("target_level"),
|
||||
assignment.get("required_level"),
|
||||
assignment.get("intensity"),
|
||||
assignment.get("is_primary", False),
|
||||
normalize_exercise_skill_intensity(assignment.get("intensity")),
|
||||
False,
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -377,8 +377,8 @@ def build_skill_assignments(mapped: dict) -> list[dict]:
|
|||
"skill_name": skill_name,
|
||||
"target_level": target_slug,
|
||||
"required_level": None,
|
||||
"intensity": None,
|
||||
"is_primary": idx == 0,
|
||||
"intensity": "mittel",
|
||||
"is_primary": False,
|
||||
})
|
||||
return assignments
|
||||
|
||||
|
|
|
|||
20
backend/tests/test_exercise_skill_intensity.py
Normal file
20
backend/tests/test_exercise_skill_intensity.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Normalisierung Übung ↔ Fähigkeit Intensität."""
|
||||
from routers.exercises import normalize_exercise_skill_intensity
|
||||
|
||||
|
||||
def test_normalize_exercise_skill_intensity_defaults_to_mittel():
|
||||
assert normalize_exercise_skill_intensity(None) == "mittel"
|
||||
assert normalize_exercise_skill_intensity("") == "mittel"
|
||||
assert normalize_exercise_skill_intensity("—") == "mittel"
|
||||
|
||||
|
||||
def test_normalize_exercise_skill_intensity_canonical():
|
||||
assert normalize_exercise_skill_intensity("niedrig") == "niedrig"
|
||||
assert normalize_exercise_skill_intensity("mittel") == "mittel"
|
||||
assert normalize_exercise_skill_intensity("hoch") == "hoch"
|
||||
|
||||
|
||||
def test_normalize_exercise_skill_intensity_legacy_aliases():
|
||||
assert normalize_exercise_skill_intensity("low") == "niedrig"
|
||||
assert normalize_exercise_skill_intensity("medium") == "mittel"
|
||||
assert normalize_exercise_skill_intensity("high") == "hoch"
|
||||
|
|
@ -66,6 +66,8 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
|
|||
- **Globaler Fähigkeitskatalog** mit hierarchischer Struktur (Kategorien, Stufen); Zuordnung zu Übungen.
|
||||
- **Trainingsmethoden-Katalog** (bestehende Domäne).
|
||||
- **Admin/Katalog-Pflege** für Fokusbereiche, Stile, Zielgruppen und Zusammenhänge (Plattform-Admin-Bereich).
|
||||
- **Planungs-Vorschläge (Phase 3):** Auf der Fähigkeiten-Seite können Schwerpunkte gewählt werden; Shinkan schlägt passende **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** vor (Sortierung nach Trainingsgewicht).
|
||||
- **Fähigkeiten-Profile an Planungs-Bausteinen:** Listen zeigen pro Modul/Rahmenprogramm KPI-Kacheln (Top je Kategorie) mit **Score** und **Peer-Prozent** — Vergleich nur unter sichtbaren Bausteinen **desselben Typs** (Modul vs. Modul, nicht Modul vs. Plan). Filter analog zur Übungsliste (Modal, Chips, Baumauswahl).
|
||||
|
||||
### 4.3 Reifegradmodelle (Fähigkeitsmatrix)
|
||||
|
||||
|
|
@ -78,6 +80,8 @@ Die sichtbaren Funktionen hängen von **Rolle** und **Kontext** ab (eingeloggter
|
|||
- **Phasen & parallele Streams (Breakout):** Eine Einheit kann aus abwechselnden **Ganzgruppenphasen** und **Parallelphasen** bestehen; in einer Parallelphase führen **mehrere Streams** (Teilstrecken) je eigene Abschnitte/Übungen. Planung über Breakout-UI; API liefert **`phases`** und flache **`sections`** (Migration **063**, siehe **`docs/HANDOVER.md`**). Technische Details: `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||
- **Trainingsvorlagen / Mikrovorlagen** (wo eingerichtet): Struktur wiederverwenden (Phasen in Vorlagen: Ausbau siehe Handover „offen“).
|
||||
- **Trainingsrahmenprogramm (Bibliothek):** übergeordnete Programme mit **Zielen** und **Slots**; Slot-Inhalt technisch als **Blueprint-Trainingsunit** abgebildet.
|
||||
- **Trainingsmodule (Bibliothek):** wiederverwendbare Übungsfolgen; Übernahme in geplante Einheiten; **Fähigkeiten-Profil** und **Filter** in der Modul-Liste (Peer-Vergleich nur unter Modulen).
|
||||
- **Rahmenprogramm-Liste:** Suche und Filter (Katalog, Dauer, Fähigkeiten) — Peer-Vergleich nur unter Rahmenprogrammen.
|
||||
- **Materialisierung:** aus einem Rahmen-Slot kann eine **konkrete Kalender-Einheit** für eine Gruppe erzeugt werden (API vorhanden; UI-Anbindung kann erweitert werden).
|
||||
- **Durchführung („Plan & Ablauf“):** Ablauf anhand Phasen/Streams darstellen und abarbeiten (inkl. Split-Logik in der Anzeige).
|
||||
- **Coaching-Modus:** eigener Ablauf mit Schritt-für-Schritt-Timeline, Stream-Wahl pro Parallelphase, Hinweis **„Parallelphase · Abschluss“** (Gruppen zusammenführen) vor der nächsten Ganzgruppenphase oder vor dem nächsten Split; **Nachbereitung** mit Ist-Minuten und Speichern wie in der Planung (inkl. **`phases`**). Nach erfolgreichem Speichern Wechsel zur **Plan- und Ablaufsicht** derselben Einheit. Bei **Kombinationsübungen** zusätzlich **Stations-/Kandidaten-Schicht und Archetyp-Hinweise** (Fachspez **Anhang A**; Ausbauschritte B/C).
|
||||
|
|
@ -129,6 +133,7 @@ Nicht als „broken“ gemeint, sondern als **typische nächste Ausbaustellen**
|
|||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-05-20 | Fähigkeiten-Scoring Phase 3: Peer-Kontext, Listen-Filter Module/Rahmen, Planungs-Vorschläge. |
|
||||
| 2026-05-14 | Trainingsplanung: Phasen/parallele Streams, Coaching (Rejoin, Nachbereitung → Planansicht); Lücken §5 ergänzt. Verweis `HANDOVER.md`. |
|
||||
| 2026-05-12 | Erstfassung für Übergabe an fachliches Design; Abgleich mit Code-Navigation, `version.py`, `HANDOVER.md`, `FEATURES_DELIVERED`, `DOMAIN_MODEL`. |
|
||||
| 2026-05-12 | Kombinationsübungen + Coaching Stufe A; Verweise auf Fachspezifikation (`…Kombinationsuebungen…` V3 Anhang A) und `TRAINING_MODULES_IMPLEMENTATION_PLAN.md`. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-19
|
||||
**Stand:** 2026-05-20
|
||||
**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**.
|
||||
|
|
@ -35,6 +35,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
| **Umsetzungsplan** (Module/Kombination/Coach) | `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` |
|
||||
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
|
||||
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
|
||||
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
|
||||
| **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
|
||||
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
|
||||
|
||||
|
|
@ -69,6 +70,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
|
||||
- **`AdminMaturityModelsPage.jsx`**, **`MaturityModelBindingsAdmin.jsx`**, **`MaturityMatrixToolsAdmin.jsx`**; APIs in `api.js`.
|
||||
|
||||
### 2.6 Gewichtetes Fähigkeiten-Scoring (Phase 3, Stand 2026-05-20)
|
||||
|
||||
- **Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md`
|
||||
- **Backend:** `skill_scoring.py`, `routers/skill_profiles.py` — Profile on-the-fly aus Übungsvorkommen + `exercise_skills`; **Peer-Kontext getrennt** (`framework_program` | `training_module` | `progression_graph`) über `library_content_visibility_sql`
|
||||
- **Metriken:** Trainingsgewicht (`weight`); **Peer-%** (`universal_percent`, max. 100 %) nur unter sichtbaren Bausteinen **desselben Typs**; ★ = stärkster Peer je Fähigkeit
|
||||
- **API:** Skill-Profile pro Artefakt; `POST /api/skill-profiles/batch-summaries` für Listen; `GET /api/skill-discovery/suggestions`
|
||||
- **Frontend:** KPI-Kacheln + Filter-Modal (UX wie Übungsliste) auf **`/planning/framework-programs`** und **`/planning/training-modules`**; Panels in Editoren; Discovery auf Fähigkeiten-Seite; `SkillTreeMultiSelect` mit Portal-Dropdown in Modals
|
||||
- **Offen (Backlog):** Corpus-Caching bei großen Bibliotheken; Tests für Typ-Trennung; Filter-Persistenz; Skill-Filter im Dialog „Rahmen übernehmen“; API-Umbenennung `club_*` → Peer-Namen
|
||||
|
||||
---
|
||||
|
||||
## 3. Trainingsrahmenprogramm & Planungs‑Blueprint (kurz)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { stripHtmlToText } from '../utils/htmlUtils'
|
||||
import { normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../utils/combinationMethodProfileUi'
|
||||
import { normalizeExerciseSkillIntensity } from '../constants/exerciseSkillIntensity'
|
||||
import { request, API_URL, ACTIVE_CLUB_STORAGE_KEY } from './client.js'
|
||||
|
||||
/** Wie `mergeActiveClubHeader` in client.js — lokal, damit Raw-`fetch`-Pfade nicht von einem Namensimport abhängen. */
|
||||
|
|
@ -89,8 +90,7 @@ export function buildExerciseApiPayload(formData, extras = {}) {
|
|||
age_groups: [],
|
||||
skills: (formData.skills || []).map((s) => ({
|
||||
skill_id: s.skill_id,
|
||||
is_primary: !!s.is_primary,
|
||||
intensity: s.intensity || null,
|
||||
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
||||
required_level: s.required_level || null,
|
||||
target_level: s.target_level || null,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -6548,6 +6548,367 @@ html.modal-scroll-locked .app-main {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Übungsformular — Register-Tabs & farbige Bereiche */
|
||||
.exercise-form-edit {
|
||||
padding-top: 4px;
|
||||
}
|
||||
.exercise-form-edit__tabbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
margin: 0 0 16px;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 6;
|
||||
background: var(--surface);
|
||||
}
|
||||
.exercise-form-edit__tabbar .admin-page-subtabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-form-panel {
|
||||
padding: 4px 0 8px 14px;
|
||||
margin-bottom: 4px;
|
||||
border-left: 3px solid var(--border);
|
||||
}
|
||||
.exercise-form-panel--basics {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.exercise-form-panel--guide {
|
||||
border-left-color: color-mix(in srgb, #2563eb 70%, var(--accent));
|
||||
}
|
||||
.exercise-form-panel--classify {
|
||||
border-left-color: color-mix(in srgb, #7c3aed 65%, var(--accent));
|
||||
}
|
||||
.exercise-form-panel--combo {
|
||||
border-left-color: color-mix(in srgb, #d97706 70%, var(--accent-dark));
|
||||
}
|
||||
.exercise-form-panel--variants {
|
||||
border-left-color: color-mix(in srgb, #0891b2 70%, var(--accent));
|
||||
}
|
||||
.exercise-form-panel--media {
|
||||
border-left-color: color-mix(in srgb, var(--text3) 55%, var(--border));
|
||||
}
|
||||
.exercise-form-panel__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.exercise-form-panel__hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.exercise-form-panel__body {
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-form-type-box {
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.exercise-form-type-box__hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text2);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.exercise-form-inline-tab-link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent-dark);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.exercise-form-subsection {
|
||||
padding: 12px 0;
|
||||
border-top: 1px dashed var(--border);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.exercise-form-subsection:first-child {
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.exercise-form-subsection__title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.exercise-form-subsection__hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Übungsformular — Klassifikation & Meta-Chips */
|
||||
.exercise-form-meta-panel {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.exercise-form-meta-panel__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.exercise-form-meta-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.exercise-form-meta-panel__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.exercise-meta-block {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.exercise-meta-block--skills {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.exercise-meta-block__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.exercise-meta-block__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text1);
|
||||
}
|
||||
.exercise-meta-block__add {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exercise-meta-block__hint,
|
||||
.exercise-meta-block__empty {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.exercise-meta-block__empty {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.exercise-meta-block__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.exercise-catalog-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
max-width: 100%;
|
||||
padding: 2px 4px 2px 2px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
}
|
||||
.exercise-catalog-chip__select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
color: var(--text1);
|
||||
min-width: 0;
|
||||
max-width: min(220px, 72vw);
|
||||
cursor: pointer;
|
||||
}
|
||||
.exercise-catalog-chip__select:focus {
|
||||
outline: none;
|
||||
}
|
||||
.exercise-catalog-chip__primary,
|
||||
.exercise-catalog-chip__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exercise-catalog-chip__primary--on {
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.exercise-catalog-chip__remove:hover,
|
||||
.exercise-catalog-chip__primary:hover {
|
||||
background: color-mix(in srgb, var(--border) 40%, transparent);
|
||||
color: var(--text1);
|
||||
}
|
||||
.exercise-skills-add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.exercise-skills-add .skill-tree-select {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-skills-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.exercise-skill-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.exercise-skill-chip {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
.exercise-skill-chip__identity {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: min(240px, 100%);
|
||||
}
|
||||
.exercise-skill-chip__name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.exercise-skill-chip__path {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.exercise-skill-chip__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px 10px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-skill-chip__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-skill-chip__caption {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.exercise-skill-chip__levels {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.exercise-skill-level-select {
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
padding: 5px 6px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.exercise-skill-chip__dash {
|
||||
padding-bottom: 7px;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.exercise-intensity-segment {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
.exercise-intensity-segment__btn {
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.exercise-intensity-segment__btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.exercise-intensity-segment__btn--active {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--surface));
|
||||
color: var(--accent-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
.exercise-skill-chip__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exercise-skill-chip__remove:hover {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
|
||||
}
|
||||
|
||||
.skills-editor-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Kompakte Katalog-Zuordnung (Fokus, Stile, Zielgruppen …) als Chip-Zeilen.
|
||||
*/
|
||||
export default function ExerciseCatalogAssocEditor({
|
||||
title,
|
||||
rows,
|
||||
setRows,
|
||||
options,
|
||||
idKey,
|
||||
emptyLabel,
|
||||
showPrimary = true,
|
||||
}) {
|
||||
const setPrimary = (idx) => {
|
||||
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
|
||||
}
|
||||
const updateRow = (idx, patch) => {
|
||||
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
|
||||
if (patch.is_primary === true) {
|
||||
next.forEach((r, i) => {
|
||||
if (i !== idx) r.is_primary = false
|
||||
})
|
||||
}
|
||||
setRows(next)
|
||||
}
|
||||
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
|
||||
const removeRow = (idx) => {
|
||||
const next = rows.filter((_, i) => i !== idx)
|
||||
if (next.length && showPrimary && !next.some((r) => r.is_primary)) next[0].is_primary = true
|
||||
setRows(next)
|
||||
}
|
||||
|
||||
const optionLabel = (o) => {
|
||||
const parts = []
|
||||
if (o.icon) parts.push(o.icon)
|
||||
parts.push(o.name)
|
||||
if (o.abbreviation) parts.push(`(${o.abbreviation})`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="exercise-meta-block">
|
||||
<div className="exercise-meta-block__head">
|
||||
<h4 className="exercise-meta-block__title">{title}</h4>
|
||||
<button type="button" className="btn btn-secondary exercise-meta-block__add" onClick={addRow}>
|
||||
+ Eintrag
|
||||
</button>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<p className="exercise-meta-block__empty">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="exercise-meta-block__chips" role="list">
|
||||
{rows.map((row, idx) => (
|
||||
<div key={idx} className="exercise-catalog-chip" role="listitem">
|
||||
<select
|
||||
className="exercise-catalog-chip__select"
|
||||
value={row[idKey] || ''}
|
||||
aria-label={`${title} wählen`}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })
|
||||
}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{optionLabel(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showPrimary ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`exercise-catalog-chip__primary${row.is_primary ? ' exercise-catalog-chip__primary--on' : ''}`}
|
||||
title={row.is_primary ? 'Primär' : 'Als primär markieren'}
|
||||
aria-pressed={!!row.is_primary}
|
||||
onClick={() => setPrimary(idx)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="exercise-catalog-chip__remove"
|
||||
aria-label="Entfernen"
|
||||
title="Entfernen"
|
||||
onClick={() => removeRow(idx)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/components/exercises/ExerciseFormLayout.jsx
Normal file
32
frontend/src/components/exercises/ExerciseFormLayout.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react'
|
||||
import PageSectionNav from '../PageSectionNav'
|
||||
|
||||
export function ExerciseFormTabBar({ activeTab, onChange, items }) {
|
||||
return (
|
||||
<div className="exercise-form-edit__tabbar">
|
||||
<PageSectionNav
|
||||
ariaLabel="Übungsbereiche"
|
||||
value={activeTab}
|
||||
onChange={onChange}
|
||||
items={items}
|
||||
className="page-section-nav--embedded exercise-form-edit__section-nav"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExerciseFormPanel({ tab, activeTab, tone = 'default', title, hint, children }) {
|
||||
if (activeTab !== tab) return null
|
||||
return (
|
||||
<section
|
||||
id={`exercise-form-panel-${tab}`}
|
||||
className={`exercise-form-panel exercise-form-panel--${tone}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`exercise-form-tab-${tab}`}
|
||||
>
|
||||
{title ? <h3 className="exercise-form-panel__title">{title}</h3> : null}
|
||||
{hint ? <p className="exercise-form-panel__hint">{hint}</p> : null}
|
||||
<div className="exercise-form-panel__body">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,9 +14,9 @@ import {
|
|||
buildExerciseMediaDragPayload,
|
||||
} from '../../utils/exerciseInlineMediaRefs'
|
||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||
import SkillTreeSelect from '../SkillTreeSelect'
|
||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
||||
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
import {
|
||||
|
|
@ -26,9 +26,10 @@ import {
|
|||
} from '../../utils/activeClub'
|
||||
import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes'
|
||||
import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import { GripVertical, FileText, BookOpen, Tags, Layers, GitBranch, Image as ImageIcon } from 'lucide-react'
|
||||
import UnsavedChangesPrompt from '../UnsavedChangesPrompt'
|
||||
import PageFormEditorChrome from '../PageFormEditorChrome'
|
||||
import { ExerciseFormTabBar, ExerciseFormPanel } from './ExerciseFormLayout'
|
||||
import { useNavReturn } from '../../hooks/useNavReturn'
|
||||
import {
|
||||
EXERCISES_LIST_PATH,
|
||||
|
|
@ -39,12 +40,10 @@ import {
|
|||
} from '../../utils/navReturnContext'
|
||||
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker'
|
||||
|
||||
const INTENSITY_OPTIONS = [
|
||||
{ value: '', label: '—' },
|
||||
{ value: 'niedrig', label: 'niedrig' },
|
||||
{ value: 'mittel', label: 'mittel' },
|
||||
{ value: 'hoch', label: 'hoch' },
|
||||
]
|
||||
import {
|
||||
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||
normalizeExerciseSkillIntensity,
|
||||
} from '../../constants/exerciseSkillIntensity'
|
||||
|
||||
const VARIANT_DIFFICULTY = [
|
||||
{ value: '', label: '—' },
|
||||
|
|
@ -192,6 +191,26 @@ function buildVariantPayloadFromRow(row) {
|
|||
}
|
||||
}
|
||||
|
||||
function snapshotVariantPayload(row) {
|
||||
return JSON.stringify(buildVariantPayloadFromRow(row))
|
||||
}
|
||||
|
||||
function variantDraftHasContent(draft) {
|
||||
if (!draft) return false
|
||||
const p = buildVariantPayloadFromRow(draft)
|
||||
return (
|
||||
p.variant_name.length > 0 ||
|
||||
Boolean(p.description) ||
|
||||
Boolean(p.execution_changes) ||
|
||||
p.duration_min != null ||
|
||||
p.duration_max != null ||
|
||||
(Array.isArray(p.equipment_changes) && p.equipment_changes.length > 0) ||
|
||||
Boolean(p.difficulty_adjustment) ||
|
||||
(p.progression_level != null && p.progression_level !== 1) ||
|
||||
p.prerequisite_variant_id != null
|
||||
)
|
||||
}
|
||||
|
||||
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
|
||||
function ExerciseVariantFields({
|
||||
row,
|
||||
|
|
@ -391,8 +410,7 @@ function detailToForm(exercise) {
|
|||
skills:
|
||||
exercise.skills?.map((s) => ({
|
||||
skill_id: s.skill_id,
|
||||
is_primary: s.is_primary || false,
|
||||
intensity: s.intensity || '',
|
||||
intensity: normalizeExerciseSkillIntensity(s.intensity),
|
||||
required_level: normalizeSkillLevelSlug(s.required_level),
|
||||
target_level: normalizeSkillLevelSlug(s.target_level),
|
||||
})) || [],
|
||||
|
|
@ -411,71 +429,6 @@ function detailToForm(exercise) {
|
|||
}
|
||||
}
|
||||
|
||||
function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
|
||||
const setPrimary = (idx) => {
|
||||
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
|
||||
}
|
||||
const updateRow = (idx, patch) => {
|
||||
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
|
||||
if (patch.is_primary === true) {
|
||||
next.forEach((r, i) => {
|
||||
if (i !== idx) r.is_primary = false
|
||||
})
|
||||
}
|
||||
setRows(next)
|
||||
}
|
||||
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
|
||||
const removeRow = (idx) => {
|
||||
const next = rows.filter((_, i) => i !== idx)
|
||||
if (next.length && !next.some((r) => r.is_primary)) next[0].is_primary = true
|
||||
setRows(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="multi-assoc-block">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||||
<h3>{title}</h3>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={addRow}>
|
||||
+ Eintrag
|
||||
</button>
|
||||
</div>
|
||||
{rows.length === 0 && (
|
||||
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>{emptyLabel}</p>
|
||||
)}
|
||||
{rows.map((row, idx) => (
|
||||
<div key={idx} className="multi-assoc-row">
|
||||
<select
|
||||
className="form-input"
|
||||
value={row[idKey] || ''}
|
||||
onChange={(e) => updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.icon ? `${o.icon} ` : ''}
|
||||
{o.name}
|
||||
{o.abbreviation ? ` (${o.abbreviation})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', whiteSpace: 'nowrap' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`primary-${idKey}`}
|
||||
checked={!!row.is_primary}
|
||||
onChange={() => setPrimary(idx)}
|
||||
/>
|
||||
primär
|
||||
</label>
|
||||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeRow(idx)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExerciseFormPageRoot() {
|
||||
const { id: routeId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -547,7 +500,61 @@ function ExerciseFormPageRoot() {
|
|||
const [variantSavingId, setVariantSavingId] = useState(null)
|
||||
const [variantBusy, setVariantBusy] = useState(false)
|
||||
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
||||
const variantsDetailsRef = useRef(null)
|
||||
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
|
||||
const variantsSavedSnapshotRef = useRef({})
|
||||
|
||||
const exerciseFormTabs = useMemo(() => {
|
||||
const tabs = [
|
||||
{ id: 'stammdaten', label: 'Stammdaten', icon: FileText },
|
||||
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
||||
{ id: 'einordnung', label: 'Einordnung', icon: Tags },
|
||||
]
|
||||
if (formData.exercise_kind === 'combination') {
|
||||
tabs.push({ id: 'kombination', label: 'Kombination', icon: Layers })
|
||||
}
|
||||
if (isEdit) {
|
||||
if (formData.exercise_kind !== 'combination') {
|
||||
tabs.push({
|
||||
id: 'varianten',
|
||||
label: variants.length > 0 ? `Varianten (${variants.length})` : 'Varianten',
|
||||
icon: GitBranch,
|
||||
})
|
||||
}
|
||||
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon })
|
||||
} else {
|
||||
tabs.push({ id: 'varianten', label: 'Varianten', icon: GitBranch, disabled: true })
|
||||
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon, disabled: true })
|
||||
}
|
||||
return tabs
|
||||
}, [formData.exercise_kind, isEdit, variants.length])
|
||||
|
||||
useEffect(() => {
|
||||
const allowed = new Set(exerciseFormTabs.filter((t) => !t.disabled).map((t) => t.id))
|
||||
if (!allowed.has(activeFormTab)) setActiveFormTab('stammdaten')
|
||||
}, [exerciseFormTabs, activeFormTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.exercise_kind === 'combination' && activeFormTab === 'varianten') {
|
||||
setActiveFormTab('kombination')
|
||||
}
|
||||
}, [formData.exercise_kind, activeFormTab])
|
||||
|
||||
const syncVariantsSavedSnapshot = useCallback((rows) => {
|
||||
const snap = {}
|
||||
for (const v of rows || []) {
|
||||
if (v?.id != null) snap[v.id] = snapshotVariantPayload(v)
|
||||
}
|
||||
variantsSavedSnapshotRef.current = snap
|
||||
}, [])
|
||||
|
||||
const getDirtyVariantRows = useCallback((rows) => {
|
||||
return (rows || []).filter((v) => {
|
||||
if (v?.id == null) return false
|
||||
const saved = variantsSavedSnapshotRef.current[v.id]
|
||||
if (saved == null) return true
|
||||
return snapshotVariantPayload(v) !== saved
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [mediaFields, setMediaFields] = useState({})
|
||||
const [mediaSavingId, setMediaSavingId] = useState(null)
|
||||
|
|
@ -656,9 +663,11 @@ function ExerciseFormPageRoot() {
|
|||
try {
|
||||
const exercise = await api.getExercise(exerciseId)
|
||||
if (cancelled) return
|
||||
const variantRows = (exercise.variants || []).map(apiVariantToRow)
|
||||
setFormData(detailToForm(exercise))
|
||||
setMediaList(exercise.media || [])
|
||||
setVariants((exercise.variants || []).map(apiVariantToRow))
|
||||
setVariants(variantRows)
|
||||
syncVariantsSavedSnapshot(variantRows)
|
||||
setVariantDraft(emptyVariantDraft())
|
||||
setVariantEditSelection(null)
|
||||
setFormDirty(false)
|
||||
|
|
@ -685,10 +694,10 @@ function ExerciseFormPageRoot() {
|
|||
}, [variants, variantEditSelection])
|
||||
|
||||
useEffect(() => {
|
||||
if (variantEditSelection != null && variantsDetailsRef.current) {
|
||||
variantsDetailsRef.current.open = true
|
||||
if (variantEditSelection != null && isEdit && formData.exercise_kind !== 'combination') {
|
||||
setActiveFormTab('varianten')
|
||||
}
|
||||
}, [variantEditSelection])
|
||||
}, [variantEditSelection, isEdit, formData.exercise_kind])
|
||||
|
||||
const updateFormField = (field, value) => {
|
||||
setFormDirty(true)
|
||||
|
|
@ -820,8 +829,7 @@ function ExerciseFormPageRoot() {
|
|||
...formData.skills,
|
||||
{
|
||||
skill_id: id,
|
||||
is_primary: formData.skills.length === 0,
|
||||
intensity: '',
|
||||
intensity: EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||
required_level: '',
|
||||
target_level: '',
|
||||
},
|
||||
|
|
@ -829,10 +837,10 @@ function ExerciseFormPageRoot() {
|
|||
setSkillPick('')
|
||||
}
|
||||
|
||||
const setSkillPrimary = (idx) => {
|
||||
const removeSkillRow = (idx) => {
|
||||
updateFormField(
|
||||
'skills',
|
||||
formData.skills.map((s, i) => ({ ...s, is_primary: i === idx })),
|
||||
formData.skills.filter((_, i) => i !== idx),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -843,11 +851,62 @@ function ExerciseFormPageRoot() {
|
|||
)
|
||||
}
|
||||
|
||||
const removeSkillRow = (idx) => {
|
||||
const next = formData.skills.filter((_, i) => i !== idx)
|
||||
if (next.length && !next.some((s) => s.is_primary)) next[0].is_primary = true
|
||||
updateFormField('skills', next)
|
||||
const refreshVariants = useCallback(async () => {
|
||||
if (!exerciseId) return
|
||||
const ex = await api.getExercise(exerciseId)
|
||||
const rows = (ex.variants || []).map(apiVariantToRow)
|
||||
syncVariantsSavedSnapshot(rows)
|
||||
setVariants(rows)
|
||||
}, [exerciseId, syncVariantsSavedSnapshot])
|
||||
|
||||
const persistPendingVariantChanges = useCallback(async () => {
|
||||
if (!exerciseId) return true
|
||||
|
||||
const dirtyRows = getDirtyVariantRows(variants)
|
||||
if (dirtyRows.length > 0) {
|
||||
setVariantBusy(true)
|
||||
try {
|
||||
for (const row of dirtyRows) {
|
||||
const payload = buildVariantPayloadFromRow(row)
|
||||
if (payload.variant_name.length < 3) {
|
||||
toast.error(`Variante „${row.variant_name || `#${row.id}`}“: Name mindestens 3 Zeichen`)
|
||||
return false
|
||||
}
|
||||
setVariantSavingId(row.id)
|
||||
await api.updateExerciseVariant(exerciseId, row.id, payload)
|
||||
}
|
||||
await refreshVariants()
|
||||
} catch (e) {
|
||||
toast.error(e.message || String(e))
|
||||
return false
|
||||
} finally {
|
||||
setVariantSavingId(null)
|
||||
setVariantBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (variantDraftHasContent(variantDraft)) {
|
||||
const payload = buildVariantPayloadFromRow(variantDraft)
|
||||
if (payload.variant_name.length < 3) {
|
||||
toast.error('Variantenentwurf: Name mindestens 3 Zeichen, sonst Felder verwerfen oder ausfüllen.')
|
||||
return false
|
||||
}
|
||||
setVariantBusy(true)
|
||||
try {
|
||||
const created = await api.createExerciseVariant(exerciseId, payload)
|
||||
setVariantDraft(emptyVariantDraft())
|
||||
if (created?.id != null) setVariantEditSelection(created.id)
|
||||
await refreshVariants()
|
||||
} catch (e) {
|
||||
toast.error(e.message || String(e))
|
||||
return false
|
||||
} finally {
|
||||
setVariantBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}, [exerciseId, variantDraft, variants, getDirtyVariantRows, refreshVariants, toast])
|
||||
|
||||
const performSaveAttempt = useCallback(
|
||||
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
|
||||
|
|
@ -855,6 +914,10 @@ function ExerciseFormPageRoot() {
|
|||
toast.error('Titel mindestens 3 Zeichen')
|
||||
return false
|
||||
}
|
||||
if (isEdit && exerciseId) {
|
||||
const variantsOk = await persistPendingVariantChanges()
|
||||
if (!variantsOk) return false
|
||||
}
|
||||
const payloadBase = {
|
||||
...formData,
|
||||
equipment:
|
||||
|
|
@ -951,7 +1014,9 @@ function ExerciseFormPageRoot() {
|
|||
}
|
||||
const ex = await api.getExercise(exerciseId)
|
||||
setMediaList(ex.media || [])
|
||||
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||
const variantRows = (ex.variants || []).map(apiVariantToRow)
|
||||
setVariants(variantRows)
|
||||
syncVariantsSavedSnapshot(variantRows)
|
||||
setFormDirty(false)
|
||||
toast.success('Gespeichert.')
|
||||
if (closeAfter) goBack()
|
||||
|
|
@ -973,7 +1038,7 @@ function ExerciseFormPageRoot() {
|
|||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[exerciseId, formData, isEdit, navigate, location, toast, goBack],
|
||||
[exerciseId, formData, isEdit, navigate, location, toast, goBack, persistPendingVariantChanges, syncVariantsSavedSnapshot],
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
|
|
@ -1103,12 +1168,6 @@ function ExerciseFormPageRoot() {
|
|||
}
|
||||
}
|
||||
|
||||
const refreshVariants = async () => {
|
||||
if (!exerciseId) return
|
||||
const ex = await api.getExercise(exerciseId)
|
||||
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||
}
|
||||
|
||||
const updateVariantField = (id, patch) => {
|
||||
setFormDirty(true)
|
||||
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
|
||||
|
|
@ -1224,8 +1283,25 @@ function ExerciseFormPageRoot() {
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<div className="card exercise-form-edit">
|
||||
<form id="exercise-form" onSubmit={handleSubmit}>
|
||||
<ExerciseFormTabBar
|
||||
activeTab={activeFormTab}
|
||||
onChange={setActiveFormTab}
|
||||
items={exerciseFormTabs}
|
||||
/>
|
||||
|
||||
<ExerciseFormPanel
|
||||
tab="stammdaten"
|
||||
activeTab={activeFormTab}
|
||||
tone="basics"
|
||||
title="Stammdaten"
|
||||
hint={
|
||||
isEdit
|
||||
? 'Titel, Rahmendaten und Sichtbarkeit — Inhalt und Einordnung in den anderen Tabs.'
|
||||
: 'Titel und Rahmendaten. Varianten, Medien und Progressionsgraph sind nach dem ersten Speichern verfügbar.'
|
||||
}
|
||||
>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Titel *</label>
|
||||
<input
|
||||
|
|
@ -1251,16 +1327,7 @@ function ExerciseFormPageRoot() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, marginBottom: '10px', fontSize: '1rem' }}>Übungstyp</h3>
|
||||
<div className="exercise-form-type-box">
|
||||
<div className="form-row">
|
||||
<label className="form-label">Art</label>
|
||||
<select
|
||||
|
|
@ -1280,12 +1347,144 @@ function ExerciseFormPageRoot() {
|
|||
}
|
||||
: {}),
|
||||
}))
|
||||
if (nk === 'combination') setActiveFormTab('kombination')
|
||||
}}
|
||||
>
|
||||
<option value="simple">Einzelübung</option>
|
||||
<option value="combination">Kombinationsübung (Stationen / Pool)</option>
|
||||
</select>
|
||||
</div>
|
||||
{formData.exercise_kind === 'combination' ? (
|
||||
<p className="exercise-form-type-box__hint">
|
||||
Stationen und Ablaufprofil im Tab{' '}
|
||||
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('kombination')}>
|
||||
Kombination
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Dauer Min</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.duration_min}
|
||||
onChange={(e) =>
|
||||
updateFormField('duration_min', e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Dauer Max</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.duration_max}
|
||||
onChange={(e) =>
|
||||
updateFormField('duration_max', e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe Min</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.group_size_min}
|
||||
onChange={(e) =>
|
||||
updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe Max</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.group_size_max}
|
||||
onChange={(e) =>
|
||||
updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={formData.equipmentLines}
|
||||
onChange={(e) => updateFormField('equipmentLines', e.target.value)}
|
||||
placeholder="Matten Pratzen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.visibility}
|
||||
onChange={(e) => updateFormField('visibility', e.target.value)}
|
||||
>
|
||||
<option value="private">Privat</option>
|
||||
<option value="club">Verein</option>
|
||||
{isSuperadmin ? <option value="official">Offiziell</option> : null}
|
||||
</select>
|
||||
</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="draft">Entwurf</option>
|
||||
<option value="in_review">In Prüfung</option>
|
||||
<option value="approved">Freigegeben</option>
|
||||
<option value="archived">Archiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
|
||||
<div className="form-row" style={{ marginTop: '10px' }}>
|
||||
<label className="form-label">Verein (Sichtbarkeit)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
updateFormField('club_id', v === '' ? null : Number(v))
|
||||
}}
|
||||
>
|
||||
{visibilityClubChoices.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{(c.name || '').trim() || `Verein #${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)', lineHeight: 1.4 }}>
|
||||
Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</ExerciseFormPanel>
|
||||
|
||||
<ExerciseFormPanel
|
||||
tab="kombination"
|
||||
activeTab={activeFormTab}
|
||||
tone="combo"
|
||||
title="Kombinationsübung"
|
||||
hint="Stationen, Übungs-Pools und globales Ablaufprofil für Coach und Planung."
|
||||
>
|
||||
{formData.exercise_kind === 'combination' ? (
|
||||
<>
|
||||
<div className="form-row">
|
||||
|
|
@ -1699,9 +1898,20 @@ function ExerciseFormPageRoot() {
|
|||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="exercise-form-panel__hint" style={{ margin: 0 }}>
|
||||
Wähle unter <strong>Stammdaten</strong> die Art „Kombinationsübung“, um Stationen zu planen.
|
||||
</p>
|
||||
)}
|
||||
</ExerciseFormPanel>
|
||||
|
||||
<ExerciseFormPanel
|
||||
tab="anleitung"
|
||||
activeTab={activeFormTab}
|
||||
tone="guide"
|
||||
title="Anleitung"
|
||||
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
|
||||
>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Ziel *</label>
|
||||
<RichTextEditor
|
||||
|
|
@ -1753,79 +1963,28 @@ function ExerciseFormPageRoot() {
|
|||
onExerciseMediaListChanged={refreshMedia}
|
||||
/>
|
||||
</div>
|
||||
</ExerciseFormPanel>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={formData.equipmentLines}
|
||||
onChange={(e) => updateFormField('equipmentLines', e.target.value)}
|
||||
placeholder="Matten Pratzen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Dauer Min</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.duration_min}
|
||||
onChange={(e) =>
|
||||
updateFormField('duration_min', e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Dauer Max</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.duration_max}
|
||||
onChange={(e) =>
|
||||
updateFormField('duration_max', e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe Min</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.group_size_min}
|
||||
onChange={(e) =>
|
||||
updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe Max</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.group_size_max}
|
||||
onChange={(e) =>
|
||||
updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MultiAssocBlock
|
||||
title="Fokusbereiche (0…n, ein „primär“)"
|
||||
<ExerciseFormPanel
|
||||
tab="einordnung"
|
||||
activeTab={activeFormTab}
|
||||
tone="classify"
|
||||
title="Einordnung"
|
||||
hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil."
|
||||
>
|
||||
<section className="exercise-form-meta-panel" aria-label="Klassifikation">
|
||||
<div className="exercise-form-meta-panel__grid">
|
||||
<ExerciseCatalogAssocEditor
|
||||
title="Fokusbereiche"
|
||||
rows={formData.focus_areas_multi}
|
||||
setRows={(r) => updateFormField('focus_areas_multi', r)}
|
||||
options={focusAreas}
|
||||
idKey="focus_area_id"
|
||||
emptyLabel="Keine Zuordnung — optional „+ Eintrag“."
|
||||
emptyLabel="Optional — „+ Eintrag“."
|
||||
/>
|
||||
|
||||
<MultiAssocBlock
|
||||
title="Stilrichtungen (0…n, z. B. Shotokan)"
|
||||
<ExerciseCatalogAssocEditor
|
||||
title="Stilrichtungen"
|
||||
rows={formData.training_styles_multi}
|
||||
setRows={(r) => updateFormField('training_styles_multi', r)}
|
||||
options={styleDirections.map((sd) => ({
|
||||
|
|
@ -1833,172 +1992,49 @@ function ExerciseFormPageRoot() {
|
|||
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
|
||||
}))}
|
||||
idKey="training_style_id"
|
||||
emptyLabel="Keine Stilrichtung gewählt."
|
||||
emptyLabel="Optional."
|
||||
/>
|
||||
|
||||
<MultiAssocBlock
|
||||
title="Trainingsstil (0…n, z. B. Breitensport / Leistungssport)"
|
||||
<ExerciseCatalogAssocEditor
|
||||
title="Trainingsstil"
|
||||
rows={formData.training_types_multi}
|
||||
setRows={(r) => updateFormField('training_types_multi', r)}
|
||||
options={trainingTypes}
|
||||
idKey="training_type_id"
|
||||
emptyLabel="Kein Trainingsstil gewählt."
|
||||
emptyLabel="Optional."
|
||||
/>
|
||||
|
||||
<MultiAssocBlock
|
||||
title="Zielgruppen (0…n)"
|
||||
<ExerciseCatalogAssocEditor
|
||||
title="Zielgruppen"
|
||||
rows={formData.target_groups_multi}
|
||||
setRows={(r) => updateFormField('target_groups_multi', r)}
|
||||
options={targetGroups}
|
||||
idKey="target_group_id"
|
||||
emptyLabel="Keine Zielgruppe gewählt."
|
||||
emptyLabel="Optional."
|
||||
showPrimary={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px', alignItems: 'stretch' }}>
|
||||
<SkillTreeSelect
|
||||
value={skillPick}
|
||||
onChange={setSkillPick}
|
||||
skills={skillsCatalog}
|
||||
excludeIds={formData.skills.map((s) => s.skill_id)}
|
||||
placeholder="Fähigkeit wählen…"
|
||||
<ExerciseSkillsEditor
|
||||
rows={formData.skills}
|
||||
skillsCatalog={skillsCatalog}
|
||||
skillPick={skillPick}
|
||||
onSkillPickChange={setSkillPick}
|
||||
onAdd={addSkillRow}
|
||||
onRemove={removeSkillRow}
|
||||
onUpdateField={updateSkillField}
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{formData.skills.map((row, idx) => {
|
||||
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
|
||||
return (
|
||||
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
|
||||
<div>
|
||||
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
|
||||
{sk ? (
|
||||
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
|
||||
{skillCatalogPathLabel(sk)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="skill-primary"
|
||||
checked={row.is_primary}
|
||||
onChange={() => setSkillPrimary(idx)}
|
||||
/>
|
||||
primär
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={row.intensity || ''}
|
||||
onChange={(e) => updateSkillField(idx, 'intensity', e.target.value)}
|
||||
>
|
||||
{INTENSITY_OPTIONS.map((o) => (
|
||||
<option key={o.value || 'i'} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input"
|
||||
value={row.required_level || ''}
|
||||
onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)}
|
||||
>
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`r-${o.value}`} value={o.value}>
|
||||
von {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input"
|
||||
value={row.target_level || ''}
|
||||
onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)}
|
||||
>
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`t-${o.value}`} value={o.value}>
|
||||
bis {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeSkillRow(idx)}>
|
||||
Entf.
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</ExerciseFormPanel>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.visibility}
|
||||
onChange={(e) => updateFormField('visibility', e.target.value)}
|
||||
{isEdit && formData.exercise_kind !== 'combination' ? (
|
||||
<ExerciseFormPanel
|
||||
tab="varianten"
|
||||
activeTab={activeFormTab}
|
||||
tone="variants"
|
||||
title="Übungsvarianten"
|
||||
hint="Pro Durchgang eine Variante. Änderungen werden mit Speichern in der Aktionsleiste mitgesichert."
|
||||
>
|
||||
<option value="private">Privat</option>
|
||||
<option value="club">Verein</option>
|
||||
{isSuperadmin ? <option value="official">Offiziell</option> : null}
|
||||
</select>
|
||||
</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="draft">Entwurf</option>
|
||||
<option value="in_review">In Prüfung</option>
|
||||
<option value="approved">Freigegeben</option>
|
||||
<option value="archived">Archiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
|
||||
<div className="form-row" style={{ marginTop: '10px' }}>
|
||||
<label className="form-label">Verein (Sichtbarkeit)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
updateFormField('club_id', v === '' ? null : Number(v))
|
||||
}}
|
||||
>
|
||||
{visibilityClubChoices.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{(c.name || '').trim() || `Verein #${c.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text3)', lineHeight: 1.4 }}>
|
||||
Standard ist der aktive Verein aus der Navigation. Bei Plattform-Admins sind alle Vereine wählbar.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{isEdit && formData.exercise_kind !== 'combination' && (
|
||||
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
||||
<summary className="exercise-variants-summary">
|
||||
<span className="exercise-variants-summary__title">Übungsvarianten</span>
|
||||
<span className="exercise-variants-summary__badge">
|
||||
{variants.length === 0
|
||||
? 'keine'
|
||||
: `${variants.length} ${variants.length === 1 ? 'Variante' : 'Varianten'}`}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="exercise-variants-details__body">
|
||||
<p className="exercise-variants-hint">
|
||||
Pro Durchgang nur eine Variante bearbeiten – weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
|
||||
Training; „Voraussetzung“ nutzt ihr später für Progressions-Serien.
|
||||
</p>
|
||||
|
||||
{variants.length > 0 && (
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="variant-edit-select">
|
||||
|
|
@ -2106,12 +2142,13 @@ function ExerciseFormPageRoot() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginLeft: 'auto', fontSize: '12px' }}
|
||||
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
|
||||
onClick={() => saveVariantRow(selectedVariantForEdit)}
|
||||
title="Optional — Änderungen werden auch über die Aktionsleiste gespeichert"
|
||||
>
|
||||
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Speichern'}
|
||||
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Variante jetzt speichern'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2140,25 +2177,19 @@ function ExerciseFormPageRoot() {
|
|||
Wähle eine Variante zum Bearbeiten oder „Neue Variante anlegen…“.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</ExerciseFormPanel>
|
||||
) : null}
|
||||
|
||||
{isEdit && formData.exercise_kind !== 'combination' && (
|
||||
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
||||
<summary className="exercise-variants-summary">
|
||||
<span className="exercise-variants-summary__title">Progressionsgraph</span>
|
||||
<span className="exercise-variants-summary__badge">Übung → Übung</span>
|
||||
</summary>
|
||||
<div className="exercise-variants-details__body">
|
||||
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
||||
{isEdit ? (
|
||||
<ExerciseFormPanel
|
||||
tab="medien"
|
||||
activeTab={activeFormTab}
|
||||
tone="media"
|
||||
title="Medien & Erweiterungen"
|
||||
hint="Verknüpfte Dateien, Progressionsgraph und Medienarchiv."
|
||||
>
|
||||
<div className="exercise-form-subsection exercise-form-subsection--media">
|
||||
<h4 className="exercise-form-subsection__title">Medien</h4>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}>
|
||||
Neue Uploads oder Embeds über die Textfeld-Symbolleiste („Medien im Text“ / „Embed im Text“). Hier
|
||||
verwaltest du Verknüpfungen — Kachel in ein Textfeld ziehen, um sie an der Cursorposition einzufügen
|
||||
|
|
@ -2302,6 +2333,15 @@ function ExerciseFormPageRoot() {
|
|||
Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über
|
||||
Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formData.exercise_kind !== 'combination' ? (
|
||||
<div className="exercise-form-subsection exercise-form-subsection--graph">
|
||||
<h4 className="exercise-form-subsection__title">Progressionsgraph</h4>
|
||||
<p className="exercise-form-subsection__hint">Übergänge zu anderen Übungen für Progressions-Serien.</p>
|
||||
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
|
||||
</div>
|
||||
) : null}
|
||||
{archiveOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
|
|
@ -2442,8 +2482,11 @@ function ExerciseFormPageRoot() {
|
|||
onClose={() => setReportTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</ExerciseFormPanel>
|
||||
) : null}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExercisePickerModal
|
||||
open={comboStationPickerIx !== null}
|
||||
|
|
|
|||
134
frontend/src/components/exercises/ExerciseSkillsEditor.jsx
Normal file
134
frontend/src/components/exercises/ExerciseSkillsEditor.jsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React from 'react'
|
||||
import SkillTreeSelect from '../SkillTreeSelect'
|
||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
|
||||
import {
|
||||
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||
EXERCISE_SKILL_INTENSITY_OPTIONS,
|
||||
normalizeExerciseSkillIntensity,
|
||||
} from '../../constants/exerciseSkillIntensity'
|
||||
|
||||
export default function ExerciseSkillsEditor({
|
||||
rows,
|
||||
skillsCatalog,
|
||||
skillPick,
|
||||
onSkillPickChange,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onUpdateField,
|
||||
}) {
|
||||
return (
|
||||
<div className="exercise-meta-block exercise-meta-block--skills">
|
||||
<div className="exercise-meta-block__head">
|
||||
<h4 className="exercise-meta-block__title">Fähigkeiten</h4>
|
||||
</div>
|
||||
<p className="exercise-meta-block__hint">Je Übung mehrere Fähigkeiten mit Intensität und Niveau (von–bis).</p>
|
||||
|
||||
<div className="exercise-skills-add">
|
||||
<SkillTreeSelect
|
||||
value={skillPick}
|
||||
onChange={onSkillPickChange}
|
||||
skills={skillsCatalog}
|
||||
excludeIds={rows.map((s) => s.skill_id)}
|
||||
placeholder="Fähigkeit wählen…"
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={onAdd}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="exercise-meta-block__empty">Noch keine Fähigkeit zugeordnet.</p>
|
||||
) : (
|
||||
<ul className="exercise-skills-list">
|
||||
{rows.map((row, idx) => {
|
||||
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
|
||||
const intensity = normalizeExerciseSkillIntensity(row.intensity)
|
||||
return (
|
||||
<li key={`${row.skill_id}-${idx}`} className="exercise-skill-chip">
|
||||
<div className="exercise-skill-chip__identity">
|
||||
<span className="exercise-skill-chip__name">{sk?.name || `Skill #${row.skill_id}`}</span>
|
||||
{sk ? (
|
||||
<span className="exercise-skill-chip__path" title={skillCatalogPathLabel(sk)}>
|
||||
{skillCatalogPathLabel(sk)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="exercise-skill-chip__controls">
|
||||
<div className="exercise-skill-chip__field">
|
||||
<span className="exercise-skill-chip__caption">Intensität</span>
|
||||
<div className="exercise-intensity-segment" role="radiogroup" aria-label="Intensität">
|
||||
{EXERCISE_SKILL_INTENSITY_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={intensity === o.value}
|
||||
className={`exercise-intensity-segment__btn${
|
||||
intensity === o.value ? ' exercise-intensity-segment__btn--active' : ''
|
||||
}`}
|
||||
onClick={() => onUpdateField(idx, 'intensity', o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="exercise-skill-chip__levels">
|
||||
<label className="exercise-skill-chip__field">
|
||||
<span className="exercise-skill-chip__caption">von</span>
|
||||
<select
|
||||
className="form-input exercise-skill-level-select"
|
||||
value={row.required_level || ''}
|
||||
title="Mindest-Niveau"
|
||||
onChange={(e) => onUpdateField(idx, 'required_level', e.target.value)}
|
||||
>
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`r-${o.value}`} value={o.value} title={o.label}>
|
||||
{o.level != null ? o.level : '–'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="exercise-skill-chip__dash" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
<label className="exercise-skill-chip__field">
|
||||
<span className="exercise-skill-chip__caption">bis</span>
|
||||
<select
|
||||
className="form-input exercise-skill-level-select"
|
||||
value={row.target_level || ''}
|
||||
title="Ziel-Niveau"
|
||||
onChange={(e) => onUpdateField(idx, 'target_level', e.target.value)}
|
||||
>
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`t-${o.value}`} value={o.value} title={o.label}>
|
||||
{o.level != null ? o.level : '–'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="exercise-skill-chip__remove"
|
||||
aria-label="Fähigkeit entfernen"
|
||||
title="Entfernen"
|
||||
onClick={() => onRemove(idx)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { EXERCISE_SKILL_INTENSITY_DEFAULT }
|
||||
21
frontend/src/constants/exerciseSkillIntensity.js
Normal file
21
frontend/src/constants/exerciseSkillIntensity.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** Kanonische Intensität Übung ↔ Fähigkeit (Scoring + Formular). */
|
||||
|
||||
export const EXERCISE_SKILL_INTENSITY_DEFAULT = 'mittel'
|
||||
|
||||
export const EXERCISE_SKILL_INTENSITY_OPTIONS = [
|
||||
{ value: 'niedrig', label: 'niedrig' },
|
||||
{ value: 'mittel', label: 'mittel' },
|
||||
{ value: 'hoch', label: 'hoch' },
|
||||
]
|
||||
|
||||
export function normalizeExerciseSkillIntensity(value) {
|
||||
const key = String(value ?? '').trim().toLowerCase()
|
||||
if (key === 'niedrig' || key === 'low') return 'niedrig'
|
||||
if (key === 'hoch' || key === 'high') return 'hoch'
|
||||
return 'mittel'
|
||||
}
|
||||
|
||||
export function formatExerciseSkillIntensityLabel(value) {
|
||||
const n = normalizeExerciseSkillIntensity(value)
|
||||
return EXERCISE_SKILL_INTENSITY_OPTIONS.find((o) => o.value === n)?.label || n
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaS
|
|||
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||
import { formatExerciseSkillIntensityLabel } from '../constants/exerciseSkillIntensity'
|
||||
|
||||
function TagRow({ exercise }) {
|
||||
const tags = []
|
||||
|
|
@ -244,9 +245,9 @@ function ExerciseDetailPage() {
|
|||
const lvl =
|
||||
rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : ''
|
||||
return (
|
||||
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
|
||||
<span key={s.id} className="exercise-tag">
|
||||
{s.skill_name}
|
||||
{s.intensity ? ` · ${s.intensity}` : ''}
|
||||
{` · ${formatExerciseSkillIntensityLabel(s.intensity)}`}
|
||||
{lvl}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user