chore(version): update version and changelog for release 0.8.131
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
- Bumped APP_VERSION to 0.8.131 and updated the changelog to reflect recent changes. - Added the TrainingPlanningUnitFormModal component to the TrainingPlanningPage for enhanced training unit management. - Refactored frameworkLineageText utility function for better code organization and reusability in the training planning context. - Updated BASELINE_SNAPSHOT documentation to include new metrics and logging details for k6 health checks.
This commit is contained in:
parent
b0faa4bfab
commit
e09a2284e9
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.130"
|
APP_VERSION = "0.8.131"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260514062"
|
DB_SCHEMA_VERSION = "20260514062"
|
||||||
|
|
||||||
|
|
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.130",
|
||||||
"date": "2026-05-13",
|
"date": "2026-05-13",
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,22 @@ Messung: Repo-Root → `cd frontend && npm run build` (Vite Production).
|
||||||
|----------|-------------------|------------------|
|
|----------|-------------------|------------------|
|
||||||
| 10 VUs, 30 s `/health` | *—* | *nach Messung* |
|
| 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)
|
## 4. Nächster Schritt (Roadmap)
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
data-testid="planning-unit-form-modal"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
padding: '1rem',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: 'clamp(12px, 3vw, 2rem)',
|
||||||
|
maxWidth: 'min(1100px, 100%)',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '92vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: '1rem' }}>
|
||||||
|
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{editingUnit?.origin_framework_slot_id
|
||||||
|
? (() => {
|
||||||
|
const L = frameworkLineageText(editingUnit)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: '1.1rem',
|
||||||
|
padding: '12px 14px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: 'var(--text1)' }}>Herkunft:</strong>{' '}
|
||||||
|
{editingUnit.origin_framework_program_id ? (
|
||||||
|
<Link
|
||||||
|
to={`/planning/framework-programs/${editingUnit.origin_framework_program_id}`}
|
||||||
|
style={{ color: 'var(--accent-dark)' }}
|
||||||
|
>
|
||||||
|
{L.fpTitle}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
L.fpTitle
|
||||||
|
)}
|
||||||
|
<span style={{ color: 'var(--text2)' }}> · {L.slotBit}</span>
|
||||||
|
<p style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
|
||||||
|
Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
|
||||||
|
geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{!editingUnit && (
|
||||||
|
<div className="training-planning-template-panel" style={{ marginBottom: '1.35rem' }}>
|
||||||
|
<label className="form-label training-planning-template-panel__label" htmlFor="planning-draft-template">
|
||||||
|
Vorlage für den Ablauf
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="planning-draft-template"
|
||||||
|
className="form-input training-planning-template-panel__select"
|
||||||
|
value={draftPlanTemplateId}
|
||||||
|
onChange={(e) => onDraftTemplateSelect(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Ohne Vorlage — leere Gliederung (ein Abschnitt)</option>
|
||||||
|
{planTemplates.map((t) => (
|
||||||
|
<option key={t.id} value={String(t.id)}>
|
||||||
|
{t.name}
|
||||||
|
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="training-planning-template-panel__help">
|
||||||
|
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
|
||||||
|
Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
|
||||||
|
|
||||||
|
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Datum *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.planned_date}
|
||||||
|
onChange={(e) => updateFormField('planned_date', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Von</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.planned_time_start}
|
||||||
|
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Bis</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.planned_time_end}
|
||||||
|
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Trainingsfokus</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.planned_focus}
|
||||||
|
onChange={(e) => updateFormField('planned_focus', e.target.value)}
|
||||||
|
placeholder="z.B. Grundlagen, Kinder altersgerecht"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginTop: '1.25rem',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
padding: '12px 14px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ margin: '0 0 10px', fontSize: '1rem' }}>Trainerzuordnung (diese Einheit)</h3>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Leitung</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formData.lead_trainer_profile_id}
|
||||||
|
onChange={(e) => updateFormField('lead_trainer_profile_id', e.target.value)}
|
||||||
|
disabled={!editingUnit && !formData.group_id}
|
||||||
|
>
|
||||||
|
<option value="">Standard (Haupttrainer der Gruppe)</option>
|
||||||
|
{clubDirectory.map((m) => {
|
||||||
|
const idStr = String(m.id)
|
||||||
|
return (
|
||||||
|
<option key={idStr} value={idStr}>
|
||||||
|
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem', lineHeight: 1.45 }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.session_assistants_inherit}
|
||||||
|
onChange={(e) => updateFormField('session_assistants_inherit', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
|
||||||
|
Co-Trainer wie in der Trainingsgruppe (Standard)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!formData.session_assistants_inherit ? (
|
||||||
|
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
|
||||||
|
{clubDirectoryForCo.map((m) => {
|
||||||
|
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
|
||||||
|
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
|
||||||
|
const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={`co-${mid}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
marginBottom: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isOn}
|
||||||
|
onChange={() => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const was = prev.session_assistant_profile_ids.includes(mid)
|
||||||
|
const nextIds = was
|
||||||
|
? prev.session_assistant_profile_ids.filter((x) => x !== mid)
|
||||||
|
: [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b)
|
||||||
|
return { ...prev, session_assistant_profile_ids: nextIds }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{labelText}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!clubDirectory.length ? (
|
||||||
|
<p style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.4 }}>
|
||||||
|
Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrainingPlanExerciseVisibilityPanel
|
||||||
|
sections={formData.sections}
|
||||||
|
targetClubId={planningModalClubId}
|
||||||
|
user={user}
|
||||||
|
onMetaRefresh={onMetaRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '2rem' }}>
|
||||||
|
{editingUnit ? (
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Modus für Abschnitte und Übungen"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="form-label" style={{ marginBottom: 0, fontSize: '0.82rem' }}>
|
||||||
|
Ablauf bearbeiten als
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: '1.5px solid var(--border2)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ id: 'planning', label: 'Planung' },
|
||||||
|
{ id: 'debrief', label: 'Nachbereitung' },
|
||||||
|
].map((opt, i) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={sectionsEditMode === opt.id}
|
||||||
|
onClick={() => setSectionsEditMode(opt.id)}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: sectionsEditMode === opt.id ? 'var(--accent-dark)' : 'transparent',
|
||||||
|
color: sectionsEditMode === opt.id ? '#fff' : 'var(--text1)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
...(i > 0 ? { borderLeft: '1.5px solid var(--border2)' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
|
{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“.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<TrainingUnitSectionsEditor
|
||||||
|
heading="Abschnitte & Übungen"
|
||||||
|
headingAccessory={
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onSaveAsTemplate}>
|
||||||
|
Vorlage aus Aufbau speichern
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
sections={formData.sections}
|
||||||
|
wideExerciseGrid
|
||||||
|
onSectionsChange={(updater) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sections: updater(prev.sections),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onRequestTrainingModulePick={onRequestTrainingModulePick}
|
||||||
|
onRequestExercisePick={onRequestExercisePick}
|
||||||
|
onPeekExercise={onPeekExercise}
|
||||||
|
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.75rem' }} />
|
||||||
|
|
||||||
|
{editingUnit && (
|
||||||
|
<>
|
||||||
|
<h3 style={{ marginTop: '0.5rem', marginBottom: '1rem' }}>Durchführung</h3>
|
||||||
|
|
||||||
|
<div className="responsive-grid-4" style={{ marginBottom: '1rem' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Tatsächliches Datum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.actual_date}
|
||||||
|
onChange={(e) => updateFormField('actual_date', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Von</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.actual_time_start}
|
||||||
|
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Bis</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.actual_time_end}
|
||||||
|
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Teilnehmer</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.attendance_count}
|
||||||
|
onChange={(e) => updateFormField('attendance_count', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => updateFormField('status', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="planned">Geplant</option>
|
||||||
|
<option value="completed">Durchgeführt</option>
|
||||||
|
<option value="cancelled">Abgesagt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.status === 'completed' ? (
|
||||||
|
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!formData.debrief_completed}
|
||||||
|
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
|
||||||
|
style={{ marginTop: '3px' }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Rückschau erledigt</strong>
|
||||||
|
<span className="muted" style={{ display: 'block', fontSize: '0.82rem', marginTop: '5px' }}>
|
||||||
|
Wenn angehakt, erscheint die Einheit nicht mehr unter „Offene Rückschau“ auf dem Dashboard
|
||||||
|
(Nachbereitung gilt als abgeschlossen).
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Öffentliche Notizen</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => updateFormField('notes', e.target.value)}
|
||||||
|
placeholder="Für Teilnehmer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Trainernotizen</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={formData.trainer_notes}
|
||||||
|
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
||||||
|
{editingUnit ? 'Speichern' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onCancel}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,11 @@ import { useToast } from '../context/ToastContext'
|
||||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
|
||||||
import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel'
|
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal'
|
import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal'
|
||||||
import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
|
import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
|
||||||
import TrainingPlanningTrainerAssignModal from '../components/planning/TrainingPlanningTrainerAssignModal'
|
import TrainingPlanningTrainerAssignModal from '../components/planning/TrainingPlanningTrainerAssignModal'
|
||||||
|
import TrainingPlanningUnitFormModal from '../components/planning/TrainingPlanningUnitFormModal'
|
||||||
import {
|
import {
|
||||||
defaultSection,
|
defaultSection,
|
||||||
normalizeUnitToForm,
|
normalizeUnitToForm,
|
||||||
|
|
@ -33,6 +32,7 @@ import {
|
||||||
sessionAssignDefaults,
|
sessionAssignDefaults,
|
||||||
normalizeGroupCoTrainerIds,
|
normalizeGroupCoTrainerIds,
|
||||||
filterDirectoryExcludingLead,
|
filterDirectoryExcludingLead,
|
||||||
|
frameworkLineageText,
|
||||||
} from '../utils/trainingPlanningPageHelpers'
|
} from '../utils/trainingPlanningPageHelpers'
|
||||||
|
|
||||||
function TrainingPlanningPage() {
|
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 = () => {
|
const handleCreate = () => {
|
||||||
if (!selectedGroupId) {
|
if (!selectedGroupId) {
|
||||||
toast.error('Bitte wähle zuerst eine Trainingsgruppe')
|
toast.error('Bitte wähle zuerst eine Trainingsgruppe')
|
||||||
|
|
@ -1926,473 +1917,47 @@ function TrainingPlanningPage() {
|
||||||
onClose={() => setFrameworkImportOpen(false)}
|
onClose={() => setFrameworkImportOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showModal && (
|
<TrainingPlanningUnitFormModal
|
||||||
<div
|
open={showModal}
|
||||||
style={{
|
editingUnit={editingUnit}
|
||||||
position: 'fixed',
|
formData={formData}
|
||||||
top: 0,
|
updateFormField={updateFormField}
|
||||||
left: 0,
|
setFormData={setFormData}
|
||||||
right: 0,
|
onSubmit={handleSubmit}
|
||||||
bottom: 0,
|
onCancel={() => setShowModal(false)}
|
||||||
background: 'rgba(0,0,0,0.5)',
|
draftPlanTemplateId={draftPlanTemplateId}
|
||||||
display: 'flex',
|
onDraftTemplateSelect={applyTemplateFromSelect}
|
||||||
alignItems: 'center',
|
planTemplates={planTemplates}
|
||||||
justifyContent: 'center',
|
clubDirectory={clubDirectory}
|
||||||
zIndex: 1000,
|
clubDirectoryForCo={clubDirectoryForCo}
|
||||||
padding: '1rem',
|
planningModalClubId={planningModalClubId}
|
||||||
overflowY: 'auto'
|
user={user}
|
||||||
}}
|
onMetaRefresh={refreshPlanningSectionMeta}
|
||||||
>
|
sectionsEditMode={sectionsEditMode}
|
||||||
<div
|
setSectionsEditMode={setSectionsEditMode}
|
||||||
style={{
|
onSaveAsTemplate={handleSaveAsTemplate}
|
||||||
background: 'var(--surface)',
|
onRequestTrainingModulePick={(ctx) => {
|
||||||
borderRadius: '12px',
|
void openModuleApplyModal(ctx)
|
||||||
padding: 'clamp(12px, 3vw, 2rem)',
|
}}
|
||||||
maxWidth: 'min(1100px, 100%)',
|
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
|
||||||
width: '100%',
|
setExercisePickerTarget({
|
||||||
maxHeight: '92vh',
|
sIdx: sectionIndex,
|
||||||
overflowY: 'auto',
|
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
|
||||||
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
|
insertBeforeIndex:
|
||||||
boxSizing: 'border-box',
|
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
||||||
minWidth: 0
|
? insertBeforeIndex
|
||||||
}}
|
: undefined,
|
||||||
>
|
})
|
||||||
<h2 style={{ marginBottom: '1rem' }}>
|
setExercisePickerOpen(true)
|
||||||
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
}}
|
||||||
</h2>
|
onPeekExercise={(id, variantId, peekExtras) =>
|
||||||
|
setPlanningPeekCtx({
|
||||||
{editingUnit?.origin_framework_slot_id ? (() => {
|
exerciseId: id,
|
||||||
const L = frameworkLineageText(editingUnit)
|
variantId: variantId ?? null,
|
||||||
return (
|
peekExtras: peekExtras ?? null,
|
||||||
<div
|
})
|
||||||
className="card"
|
}
|
||||||
style={{
|
/>
|
||||||
marginBottom: '1.1rem',
|
|
||||||
padding: '12px 14px',
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<strong style={{ color: 'var(--text1)' }}>Herkunft:</strong>{' '}
|
|
||||||
{editingUnit.origin_framework_program_id ? (
|
|
||||||
<Link
|
|
||||||
to={`/planning/framework-programs/${editingUnit.origin_framework_program_id}`}
|
|
||||||
style={{ color: 'var(--accent-dark)' }}
|
|
||||||
>
|
|
||||||
{L.fpTitle}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
L.fpTitle
|
|
||||||
)}
|
|
||||||
<span style={{ color: 'var(--text2)' }}> · {L.slotBit}</span>
|
|
||||||
<p style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
|
|
||||||
Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
|
|
||||||
geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})() : null}
|
|
||||||
|
|
||||||
{!editingUnit && (
|
|
||||||
<div className="training-planning-template-panel" style={{ marginBottom: '1.35rem' }}>
|
|
||||||
<label className="form-label training-planning-template-panel__label" htmlFor="planning-draft-template">
|
|
||||||
Vorlage für den Ablauf
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="planning-draft-template"
|
|
||||||
className="form-input training-planning-template-panel__select"
|
|
||||||
value={draftPlanTemplateId}
|
|
||||||
onChange={(e) => applyTemplateFromSelect(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Ohne Vorlage — leere Gliederung (ein Abschnitt)</option>
|
|
||||||
{planTemplates.map((t) => (
|
|
||||||
<option key={t.id} value={String(t.id)}>
|
|
||||||
{t.name}
|
|
||||||
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="training-planning-template-panel__help">
|
|
||||||
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei
|
|
||||||
den Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
|
|
||||||
|
|
||||||
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Datum *</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="form-input"
|
|
||||||
value={formData.planned_date}
|
|
||||||
onChange={(e) => updateFormField('planned_date', e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Von</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
className="form-input"
|
|
||||||
value={formData.planned_time_start}
|
|
||||||
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Bis</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
className="form-input"
|
|
||||||
value={formData.planned_time_end}
|
|
||||||
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Trainingsfokus</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
value={formData.planned_focus}
|
|
||||||
onChange={(e) => updateFormField('planned_focus', e.target.value)}
|
|
||||||
placeholder="z.B. Grundlagen, Kinder altersgerecht"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="card"
|
|
||||||
style={{
|
|
||||||
marginTop: '1.25rem',
|
|
||||||
marginBottom: '0.25rem',
|
|
||||||
padding: '12px 14px',
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3 style={{ margin: '0 0 10px', fontSize: '1rem' }}>Trainerzuordnung (diese Einheit)</h3>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Leitung</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={formData.lead_trainer_profile_id}
|
|
||||||
onChange={(e) => updateFormField('lead_trainer_profile_id', e.target.value)}
|
|
||||||
disabled={!editingUnit && !formData.group_id}
|
|
||||||
>
|
|
||||||
<option value="">Standard (Haupttrainer der Gruppe)</option>
|
|
||||||
{clubDirectory.map((m) => {
|
|
||||||
const idStr = String(m.id)
|
|
||||||
return (
|
|
||||||
<option key={idStr} value={idStr}>
|
|
||||||
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
|
|
||||||
</option>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem', lineHeight: 1.45 }}>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.session_assistants_inherit}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFormField('session_assistants_inherit', e.target.checked)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
|
|
||||||
Co-Trainer wie in der Trainingsgruppe (Standard)
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{!formData.session_assistants_inherit ? (
|
|
||||||
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
|
|
||||||
{clubDirectoryForCo.map((m) => {
|
|
||||||
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
|
|
||||||
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
|
|
||||||
const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={`co-${mid}`}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
marginBottom: '6px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'var(--text1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isOn}
|
|
||||||
onChange={() => {
|
|
||||||
setFormData((prev) => {
|
|
||||||
const was = prev.session_assistant_profile_ids.includes(mid)
|
|
||||||
const nextIds = was
|
|
||||||
? prev.session_assistant_profile_ids.filter((x) => x !== mid)
|
|
||||||
: [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b)
|
|
||||||
return { ...prev, session_assistant_profile_ids: nextIds }
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{labelText}</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{!clubDirectory.length && showModal ? (
|
|
||||||
<p style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.4 }}>
|
|
||||||
Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TrainingPlanExerciseVisibilityPanel
|
|
||||||
sections={formData.sections}
|
|
||||||
targetClubId={planningModalClubId}
|
|
||||||
user={user}
|
|
||||||
onMetaRefresh={refreshPlanningSectionMeta}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '2rem' }}>
|
|
||||||
{editingUnit ? (
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Modus für Abschnitte und Übungen"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="form-label" style={{ marginBottom: 0, fontSize: '0.82rem' }}>
|
|
||||||
Ablauf bearbeiten als
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
borderRadius: '10px',
|
|
||||||
border: '1.5px solid var(--border2)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ id: 'planning', label: 'Planung' },
|
|
||||||
{ id: 'debrief', label: 'Nachbereitung' },
|
|
||||||
].map((opt, i) => (
|
|
||||||
<button
|
|
||||||
key={opt.id}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={sectionsEditMode === opt.id}
|
|
||||||
onClick={() => setSectionsEditMode(opt.id)}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
padding: '8px 14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: sectionsEditMode === opt.id ? 'var(--accent-dark)' : 'transparent',
|
|
||||||
color: sectionsEditMode === opt.id ? '#fff' : 'var(--text1)',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
...(i > 0 ? { borderLeft: '1.5px solid var(--border2)' } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
|
||||||
{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“.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<TrainingUnitSectionsEditor
|
|
||||||
heading="Abschnitte & Übungen"
|
|
||||||
headingAccessory={
|
|
||||||
<>
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
|
|
||||||
Vorlage aus Aufbau speichern
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
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'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1.75rem' }} />
|
|
||||||
|
|
||||||
{editingUnit && (
|
|
||||||
<>
|
|
||||||
<h3 style={{ marginTop: '0.5rem', marginBottom: '1rem' }}>Durchführung</h3>
|
|
||||||
|
|
||||||
<div className="responsive-grid-4" style={{ marginBottom: '1rem' }}>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Tatsächliches Datum</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="form-input"
|
|
||||||
value={formData.actual_date}
|
|
||||||
onChange={(e) => updateFormField('actual_date', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Von</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
className="form-input"
|
|
||||||
value={formData.actual_time_start}
|
|
||||||
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Bis</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
className="form-input"
|
|
||||||
value={formData.actual_time_end}
|
|
||||||
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Teilnehmer</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
value={formData.attendance_count}
|
|
||||||
onChange={(e) => updateFormField('attendance_count', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Status</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => updateFormField('status', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="planned">Geplant</option>
|
|
||||||
<option value="completed">Durchgeführt</option>
|
|
||||||
<option value="cancelled">Abgesagt</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.status === 'completed' ? (
|
|
||||||
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '10px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1.45,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!formData.debrief_completed}
|
|
||||||
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
|
|
||||||
style={{ marginTop: '3px' }}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>Rückschau erledigt</strong>
|
|
||||||
<span className="muted" style={{ display: 'block', fontSize: '0.82rem', marginTop: '5px' }}>
|
|
||||||
Wenn angehakt, erscheint die Einheit nicht mehr unter „Offene Rückschau“ auf dem
|
|
||||||
Dashboard (Nachbereitung gilt als abgeschlossen).
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Öffentliche Notizen</label>
|
|
||||||
<textarea
|
|
||||||
className="form-input"
|
|
||||||
rows={3}
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={(e) => updateFormField('notes', e.target.value)}
|
|
||||||
placeholder="Für Teilnehmer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Trainernotizen</label>
|
|
||||||
<textarea
|
|
||||||
className="form-input"
|
|
||||||
rows={3}
|
|
||||||
value={formData.trainer_notes}
|
|
||||||
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
|
||||||
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
|
||||||
{editingUnit ? 'Speichern' : 'Erstellen'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ExercisePickerModal
|
<ExercisePickerModal
|
||||||
open={exercisePickerOpen}
|
open={exercisePickerOpen}
|
||||||
multiSelect
|
multiSelect
|
||||||
|
|
|
||||||
|
|
@ -104,3 +104,12 @@ export function filterDirectoryExcludingLead(directory, excludeLeadPid) {
|
||||||
if (ex == null) return directory
|
if (ex == null) return directory
|
||||||
return directory.filter((m) => Number(m.id) !== ex)
|
return directory.filter((m) => 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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user