chore(version): update version and changelog for release 0.8.129
All checks were successful
Deploy Development / deploy (push) Successful in 38s
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 34s
Test Suite / playwright-tests (push) Successful in 1m8s

- Bumped APP_VERSION to 0.8.129 and updated the changelog to reflect recent changes.
- Added the TrainingPlanningTrainerAssignModal component to the TrainingPlanningPage for enhanced trainer assignment functionality.
- Implemented new callback functions for managing lead trainer and assistant assignments in the training planning process.
This commit is contained in:
Lars 2026-05-14 15:32:21 +02:00
parent 45bc049c0d
commit a1a3f2e0a1
3 changed files with 218 additions and 173 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.128"
APP_VERSION = "0.8.129"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062"
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.129",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3: TrainingPlanningTrainerAssignModal (Trainer zuweisen) aus Trainingsplanungsseite; Handler per useCallback.",
],
},
{
"version": "0.8.128",
"date": "2026-05-13",

View File

@ -0,0 +1,155 @@
import React from 'react'
/**
* Modal: organisatorische Trainer-Zuweisung (Leitung + Co) für eine bestehende Einheit.
*/
export default function TrainingPlanningTrainerAssignModal({
open,
unit,
leadTrainerProfileId,
onLeadChange,
sessionAssistantsInherit,
onSessionAssistantsInheritChange,
sessionAssistantProfileIds,
onCoTrainerToggle,
clubDirectory,
coTrainerOptions,
saving,
onBackdropRequestClose,
onCancel,
onSave,
}) {
if (!open || !unit) return null
return (
<div
data-testid="planning-trainer-assign-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: 1020,
padding: '1rem',
overflowY: 'auto',
}}
role="presentation"
onClick={() => {
if (!saving) onBackdropRequestClose()
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="trainer-assign-modal-title"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(460px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
>
<h2 id="trainer-assign-modal-title" style={{ marginBottom: '0.5rem', fontSize: '1.1rem' }}>
Trainer zuweisen (organisatorisch)
</h2>
<p style={{ fontSize: '0.86rem', color: 'var(--text2)', marginBottom: '1rem', lineHeight: 1.45 }}>
{(unit.planned_date || '').toString().slice(0, 10)}
{unit.planned_time_start ? ` · ${String(unit.planned_time_start).slice(0, 5)}` : ''}
{(unit.group_name || '').trim() ? ` · ${(unit.group_name || '').trim()}` : null}
</p>
<div className="form-row">
<label className="form-label">Leitung (diese Einheit)</label>
<select
className="form-input"
value={leadTrainerProfileId}
onChange={(e) => onLeadChange(e.target.value)}
disabled={saving}
>
<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>
</div>
<div className="form-row" style={{ marginTop: '0.85rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={sessionAssistantsInherit}
disabled={saving}
onChange={(e) => onSessionAssistantsInheritChange(e.target.checked)}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>Co-Trainer wie in der Trainingsgruppe</span>
</label>
</div>
{!sessionAssistantsInherit ? (
<div style={{ marginTop: '10px', maxHeight: '180px', overflowY: 'auto' }}>
{coTrainerOptions.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) && sessionAssistantProfileIds.includes(mid)
return (
<label
key={`assign-co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: saving ? 'default' : 'pointer',
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={isOn}
disabled={saving}
onChange={() => onCoTrainerToggle(mid)}
/>
<span>{labelText}</span>
</label>
)
})}
</div>
) : null}
{!clubDirectory.length ? (
<p style={{ marginTop: '10px', fontSize: '0.82rem', color: 'var(--text3)' }}>
Mitgliederverzeichnis konnte nicht geladen werden.
</p>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button type="button" className="btn btn-secondary" disabled={saving} onClick={onCancel}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={saving} onClick={onSave}>
{saving ? 'Speichern …' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}

View File

@ -11,6 +11,7 @@ import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerc
import PageSectionNav from '../components/PageSectionNav'
import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal'
import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
import TrainingPlanningTrainerAssignModal from '../components/planning/TrainingPlanningTrainerAssignModal'
import {
defaultSection,
normalizeUnitToForm,
@ -912,6 +913,42 @@ function TrainingPlanningPage() {
}
}
const handleAssignLeadSelectChange = useCallback((v) => {
setAssignDraft((prev) => {
const exclude = []
const tr = String(v || '').trim()
if (tr !== '') {
const n = parseInt(tr, 10)
if (Number.isFinite(n)) exclude.push(n)
} else if (prev.unit?.effective_lead_trainer_profile_id != null) {
const ef = Number(prev.unit.effective_lead_trainer_profile_id)
if (Number.isFinite(ef)) exclude.push(ef)
}
const exSet = new Set(exclude)
const co = exclude.length
? prev.session_assistant_profile_ids.filter((x) => !exSet.has(x))
: prev.session_assistant_profile_ids
return { ...prev, lead_trainer_profile_id: v, session_assistant_profile_ids: co }
})
}, [])
const handleAssignAssistantsInheritChange = useCallback((checked) => {
setAssignDraft((prev) => ({
...prev,
session_assistants_inherit: checked,
}))
}, [])
const handleAssignCoTrainerToggle = useCallback((mid) => {
setAssignDraft((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 }
})
}, [])
const handleDelete = async (unit) => {
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
try {
@ -1821,178 +1858,24 @@ function TrainingPlanningPage() {
</div>
)}
{assignModalOpen && assignDraft.unit ? (
<div
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: 1020,
padding: '1rem',
overflowY: 'auto',
}}
role="presentation"
onClick={() => {
if (!assignSaving) setAssignModalOpen(false)
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="trainer-assign-modal-title"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(460px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
>
<h2 id="trainer-assign-modal-title" style={{ marginBottom: '0.5rem', fontSize: '1.1rem' }}>
Trainer zuweisen (organisatorisch)
</h2>
<p style={{ fontSize: '0.86rem', color: 'var(--text2)', marginBottom: '1rem', lineHeight: 1.45 }}>
{(assignDraft.unit.planned_date || '').toString().slice(0, 10)}
{assignDraft.unit.planned_time_start
? ` · ${String(assignDraft.unit.planned_time_start).slice(0, 5)}`
: ''}
{(assignDraft.unit.group_name || '').trim()
? ` · ${(assignDraft.unit.group_name || '').trim()}`
: null}
</p>
<div className="form-row">
<label className="form-label">Leitung (diese Einheit)</label>
<select
className="form-input"
value={assignDraft.lead_trainer_profile_id}
onChange={(e) => {
const v = e.target.value
setAssignDraft((prev) => {
const exclude = []
const tr = String(v || '').trim()
if (tr !== '') {
const n = parseInt(tr, 10)
if (Number.isFinite(n)) exclude.push(n)
} else if (prev.unit?.effective_lead_trainer_profile_id != null) {
const ef = Number(prev.unit.effective_lead_trainer_profile_id)
if (Number.isFinite(ef)) exclude.push(ef)
}
const exSet = new Set(exclude)
const co = exclude.length
? prev.session_assistant_profile_ids.filter((x) => !exSet.has(x))
: prev.session_assistant_profile_ids
return { ...prev, lead_trainer_profile_id: v, session_assistant_profile_ids: co }
})
}}
disabled={assignSaving}
>
<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>
</div>
<div className="form-row" style={{ marginTop: '0.85rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={assignDraft.session_assistants_inherit}
disabled={assignSaving}
onChange={(e) =>
setAssignDraft((prev) => ({
...prev,
session_assistants_inherit: e.target.checked,
}))
}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
Co-Trainer wie in der Trainingsgruppe
</span>
</label>
</div>
{!assignDraft.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '180px', overflowY: 'auto' }}>
{clubDirectoryForAssignCo.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) && assignDraft.session_assistant_profile_ids.includes(mid)
return (
<label
key={`assign-co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: assignSaving ? 'default' : 'pointer',
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={isOn}
disabled={assignSaving}
onChange={() => {
setAssignDraft((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={{ marginTop: '10px', fontSize: '0.82rem', color: 'var(--text3)' }}>
Mitgliederverzeichnis konnte nicht geladen werden.
</p>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={assignSaving}
onClick={() => setAssignModalOpen(false)}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={assignSaving} onClick={saveTrainerAssignModal}>
{assignSaving ? 'Speichern …' : 'Speichern'}
</button>
</div>
</div>
</div>
) : null}
<TrainingPlanningTrainerAssignModal
open={assignModalOpen && !!assignDraft.unit}
unit={assignDraft.unit}
leadTrainerProfileId={assignDraft.lead_trainer_profile_id}
onLeadChange={handleAssignLeadSelectChange}
sessionAssistantsInherit={assignDraft.session_assistants_inherit}
onSessionAssistantsInheritChange={handleAssignAssistantsInheritChange}
sessionAssistantProfileIds={assignDraft.session_assistant_profile_ids}
onCoTrainerToggle={handleAssignCoTrainerToggle}
clubDirectory={clubDirectory}
coTrainerOptions={clubDirectoryForAssignCo}
saving={assignSaving}
onBackdropRequestClose={() => {
if (!assignSaving) setAssignModalOpen(false)
}}
onCancel={() => setAssignModalOpen(false)}
onSave={saveTrainerAssignModal}
/>
<TrainingPlanningModuleApplyModal
open={moduleApplyOpen}