diff --git a/backend/version.py b/backend/version.py
index 013f24b..7dc65a0 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.130"
+APP_VERSION = "0.8.131"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062"
@@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.131",
+ "date": "2026-05-13",
+ "changes": [
+ "Frontend Phase 3: TrainingPlanningUnitFormModal (Neu/Bearbeiten-Einheit); frameworkLineageText in trainingPlanningPageHelpers; BASELINE_SNAPSHOT §3.4 k6-Log-Mapping.",
+ ],
+ },
{
"version": "0.8.130",
"date": "2026-05-13",
diff --git a/docs/architecture/BASELINE_SNAPSHOT.md b/docs/architecture/BASELINE_SNAPSHOT.md
index 0018eca..0b77b98 100644
--- a/docs/architecture/BASELINE_SNAPSHOT.md
+++ b/docs/architecture/BASELINE_SNAPSHOT.md
@@ -90,6 +90,22 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
|----------|-------------------|------------------|
| 10 VUs, 30 s `/health` | *—* | *nach Messung* |
+### 3.4 Aus dem Deployment-/CI-Log übernehmen (k6 `k6-health-baseline`)
+
+Das Skript `scripts/load/k6-health-baseline.js` nutzt **10 VUs**, **30 s**, Ziel **`GET {BASE_URL}/health`** (siehe Workflow-Env für `BASE_URL`).
+
+**In die Tabelle oben (Abschnitt 3.3) eintragen — aus der k6-Zusammenfassung am Ende des Jobs:**
+
+| Feld in BASELINE_SNAPSHOT | Wo im k6-Log (typisch) |
+|---------------------------|-------------------------|
+| **p95** (Latenz ms) | Zeile **`http_req_duration`** → Wert **`p(95)=…`** (ganze Zahl oder ms mit Einheit wie `12.34ms`) |
+| **Fehlerquote** | Zeile **`http_req_failed`** → z. B. `0.00%` bzw. `✓ 0%` — oder kurz „0 %“ notieren |
+| **Checks** (optional) | Zeile **`checks`** → Anteil **`✓`** (soll **100 %** sein, sonst Hinweis) |
+| **Datum / BASE_URL** | Deploy-Datum + die **öffentliche** Basis-URL des Laufs (wie im Workflow gesetzt, z. B. `https://dev.shinkan.jinkendo.de`) |
+| **App-Version** (optional) | dieselbe wie im Deploy (`backend/version.py` / Release), damit M2-Vergleich ressortfähig bleibt |
+
+**Zusätzlich (Abschnitt 2.2):** nur die Zeile **`/health` GET`** mit dem **gleichen** p95 befüllen, wenn ihr dort noch Platzhalter habt — echte API-Routen (`/api/...`) kommen weiter aus Monitoring/k6 mit Auth, nicht aus diesem Job.
+
---
## 4. Nächster Schritt (Roadmap)
diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx
new file mode 100644
index 0000000..fb61f7e
--- /dev/null
+++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx
@@ -0,0 +1,485 @@
+import React from 'react'
+import { Link } from 'react-router-dom'
+import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
+import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
+import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
+
+/**
+ * Großes Modal: Neue Trainingseinheit / Einheit bearbeiten (Planung, Trainer, Abschnitte, Durchführung, Notizen).
+ */
+export default function TrainingPlanningUnitFormModal({
+ open,
+ editingUnit,
+ formData,
+ updateFormField,
+ setFormData,
+ onSubmit,
+ onCancel,
+ draftPlanTemplateId,
+ onDraftTemplateSelect,
+ planTemplates,
+ clubDirectory,
+ clubDirectoryForCo,
+ planningModalClubId,
+ user,
+ onMetaRefresh,
+ sectionsEditMode,
+ setSectionsEditMode,
+ onSaveAsTemplate,
+ onRequestTrainingModulePick,
+ onRequestExercisePick,
+ onPeekExercise,
+}) {
+ if (!open) return null
+
+ return (
+
+
+
+ {editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
+
+
+ {editingUnit?.origin_framework_slot_id
+ ? (() => {
+ const L = frameworkLineageText(editingUnit)
+ return (
+
+
Herkunft: {' '}
+ {editingUnit.origin_framework_program_id ? (
+
+ {L.fpTitle}
+
+ ) : (
+ L.fpTitle
+ )}
+
· {L.slotBit}
+
+ Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
+ geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
+
+
+ )
+ })()
+ : null}
+
+ {!editingUnit && (
+
+
+ Vorlage für den Ablauf
+
+
onDraftTemplateSelect(e.target.value)}
+ >
+ Ohne Vorlage — leere Gliederung (ein Abschnitt)
+ {planTemplates.map((t) => (
+
+ {t.name}
+ {typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
+
+ ))}
+
+
+ Übernimmt nur die Sektionsstruktur aus der Bibliothek; Übungen trägst du unten bei den
+ Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 2de2a15..2bb9bb4 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -6,12 +6,11 @@ import { useToast } from '../context/ToastContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
-import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
-import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel'
import PageSectionNav from '../components/PageSectionNav'
import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal'
import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
import TrainingPlanningTrainerAssignModal from '../components/planning/TrainingPlanningTrainerAssignModal'
+import TrainingPlanningUnitFormModal from '../components/planning/TrainingPlanningUnitFormModal'
import {
defaultSection,
normalizeUnitToForm,
@@ -33,6 +32,7 @@ import {
sessionAssignDefaults,
normalizeGroupCoTrainerIds,
filterDirectoryExcludingLead,
+ frameworkLineageText,
} from '../utils/trainingPlanningPageHelpers'
function TrainingPlanningPage() {
@@ -482,15 +482,6 @@ function TrainingPlanningPage() {
}
}
- const frameworkLineageText = (unit) => {
- const fpTitle = (unit.origin_framework_program_title || '').trim() || 'Rahmenprogramm'
- const st = (unit.origin_framework_slot_title || '').trim()
- const idx = unit.origin_framework_slot_sort_order
- const slotBit =
- st || (typeof idx === 'number' ? `Session ${idx + 1}` : 'Session')
- return { fpTitle, slotBit, fpId: unit.origin_framework_program_id }
- }
-
const handleCreate = () => {
if (!selectedGroupId) {
toast.error('Bitte wähle zuerst eine Trainingsgruppe')
@@ -1926,473 +1917,47 @@ function TrainingPlanningPage() {
onClose={() => setFrameworkImportOpen(false)}
/>
- {showModal && (
-
-
-
- {editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
-
-
- {editingUnit?.origin_framework_slot_id ? (() => {
- const L = frameworkLineageText(editingUnit)
- return (
-
-
Herkunft: {' '}
- {editingUnit.origin_framework_program_id ? (
-
- {L.fpTitle}
-
- ) : (
- L.fpTitle
- )}
-
· {L.slotBit}
-
- Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
- geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
-
-
- )
- })() : null}
-
- {!editingUnit && (
-
-
- Vorlage für den Ablauf
-
-
applyTemplateFromSelect(e.target.value)}
- >
- Ohne Vorlage — leere Gliederung (ein Abschnitt)
- {planTemplates.map((t) => (
-
- {t.name}
- {typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
-
- ))}
-
-
- Übernimmt nur die Sektionsstruktur aus der Bibliothek; Übungen trägst du unten bei
- den Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
-
-
- )}
-
-
- Planung
-
-
-
-
- Trainingsfokus
- updateFormField('planned_focus', e.target.value)}
- placeholder="z.B. Grundlagen, Kinder altersgerecht"
- />
-
-
-
-
Trainerzuordnung (diese Einheit)
-
-
Leitung
-
updateFormField('lead_trainer_profile_id', e.target.value)}
- disabled={!editingUnit && !formData.group_id}
- >
- Standard (Haupttrainer der Gruppe)
- {clubDirectory.map((m) => {
- const idStr = String(m.id)
- return (
-
- {(m.name || '').trim() || m.email || `Profil ${m.id}`}
-
- )
- })}
-
-
- Für Vertretungen genügt in der Regel die Vereinsmitgliedschaft; Zuweisen dürfen u. a.
- Haupt-/Co‑Trainer dieser Gruppe, der/die Ersteller:in der Einheit oder Vereinsadmins.
-
-
-
-
-
- updateFormField('session_assistants_inherit', e.target.checked)
- }
- />
-
- Co-Trainer wie in der Trainingsgruppe (Standard)
-
-
-
- {!formData.session_assistants_inherit ? (
-
- {clubDirectoryForCo.map((m) => {
- const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
- const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
- const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
- return (
-
- {
- setFormData((prev) => {
- const was = prev.session_assistant_profile_ids.includes(mid)
- const nextIds = was
- ? prev.session_assistant_profile_ids.filter((x) => x !== mid)
- : [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b)
- return { ...prev, session_assistant_profile_ids: nextIds }
- })
- }}
- />
- {labelText}
-
- )
- })}
-
- ) : null}
- {!clubDirectory.length && showModal ? (
-
- Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).
-
- ) : null}
-
-
-
-
-
- {editingUnit ? (
-
-
-
- Ablauf bearbeiten als
-
-
- {[
- { id: 'planning', label: 'Planung' },
- { id: 'debrief', label: 'Nachbereitung' },
- ].map((opt, i) => (
- setSectionsEditMode(opt.id)}
- style={{
- border: 'none',
- padding: '8px 14px',
- fontWeight: 600,
- fontSize: '0.85rem',
- cursor: 'pointer',
- background: sectionsEditMode === opt.id ? 'var(--accent-dark)' : 'transparent',
- color: sectionsEditMode === opt.id ? '#fff' : 'var(--text1)',
- whiteSpace: 'nowrap',
- ...(i > 0 ? { borderLeft: '1.5px solid var(--border2)' } : {}),
- }}
- >
- {opt.label}
-
- ))}
-
-
-
- {sectionsEditMode === 'debrief'
- ? 'Ist‑Minuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.'
- : 'Ablauf, Übungen und geplante Minuten. Ist‑Werte und Abweichungen unter „Nachbereitung“.'}
-
-
- ) : null}
-
-
- Vorlage aus Aufbau speichern
-
- >
- }
- sections={formData.sections}
- wideExerciseGrid
- onSectionsChange={(updater) =>
- setFormData((prev) => ({
- ...prev,
- sections: updater(prev.sections),
- }))
- }
- onRequestTrainingModulePick={(ctx) => {
- void openModuleApplyModal(ctx)
- }}
- onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
- setExercisePickerTarget({
- sIdx: sectionIndex,
- iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
- insertBeforeIndex:
- typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
- ? insertBeforeIndex
- : undefined,
- })
- setExercisePickerOpen(true)
- }}
- onPeekExercise={(id, variantId, peekExtras) =>
- setPlanningPeekCtx({
- exerciseId: id,
- variantId: variantId ?? null,
- peekExtras: peekExtras ?? null,
- })
- }
- showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
- />
-
-
-
-
- {editingUnit && (
- <>
- Durchführung
-
-
-
-
- Status
- updateFormField('status', e.target.value)}
- >
- Geplant
- Durchgeführt
- Abgesagt
-
-
-
- {formData.status === 'completed' ? (
-
-
- updateFormField('debrief_completed', e.target.checked)}
- style={{ marginTop: '3px' }}
- />
-
- Rückschau erledigt
-
- Wenn angehakt, erscheint die Einheit nicht mehr unter „Offene Rückschau“ auf dem
- Dashboard (Nachbereitung gilt als abgeschlossen).
-
-
-
-
- ) : null}
- >
- )}
-
- Notizen
-
-
- Öffentliche Notizen
- updateFormField('notes', e.target.value)}
- placeholder="Für Teilnehmer"
- />
-
-
-
- Trainernotizen
- updateFormField('trainer_notes', e.target.value)}
- />
-
-
-
-
- {editingUnit ? 'Speichern' : 'Erstellen'}
-
- setShowModal(false)}>
- Abbrechen
-
-
-
-
-
- )}
+ setShowModal(false)}
+ draftPlanTemplateId={draftPlanTemplateId}
+ onDraftTemplateSelect={applyTemplateFromSelect}
+ planTemplates={planTemplates}
+ clubDirectory={clubDirectory}
+ clubDirectoryForCo={clubDirectoryForCo}
+ planningModalClubId={planningModalClubId}
+ user={user}
+ onMetaRefresh={refreshPlanningSectionMeta}
+ sectionsEditMode={sectionsEditMode}
+ setSectionsEditMode={setSectionsEditMode}
+ onSaveAsTemplate={handleSaveAsTemplate}
+ onRequestTrainingModulePick={(ctx) => {
+ void openModuleApplyModal(ctx)
+ }}
+ onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
+ setExercisePickerTarget({
+ sIdx: sectionIndex,
+ iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
+ insertBeforeIndex:
+ typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
+ ? insertBeforeIndex
+ : undefined,
+ })
+ setExercisePickerOpen(true)
+ }}
+ onPeekExercise={(id, variantId, peekExtras) =>
+ setPlanningPeekCtx({
+ exerciseId: id,
+ variantId: variantId ?? null,
+ peekExtras: peekExtras ?? null,
+ })
+ }
+ />
Number(m.id) !== ex)
}
+
+/** Kurztexte für Rahmen-Herkunft (Listen + Formular-Modal). */
+export function frameworkLineageText(unit) {
+ const fpTitle = (unit.origin_framework_program_title || '').trim() || 'Rahmenprogramm'
+ const st = (unit.origin_framework_slot_title || '').trim()
+ const idx = unit.origin_framework_slot_sort_order
+ const slotBit = st || (typeof idx === 'number' ? `Session ${idx + 1}` : 'Session')
+ return { fpTitle, slotBit, fpId: unit.origin_framework_program_id }
+}