chore(version): update version and changelog for release 0.8.126
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 41s
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 1m30s

- Bumped APP_VERSION to 0.8.126 and updated the changelog to reflect recent changes.
- Added the TrainingPlanningFrameworkImportModal component to the TrainingPlanningPage for improved training session management.
- Implemented a new Playwright test to verify the functionality of the framework import dialog in the training planning page.
This commit is contained in:
Lars 2026-05-14 13:26:02 +02:00
parent 300d916fad
commit e4e362b0a9
4 changed files with 263 additions and 193 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.125"
APP_VERSION = "0.8.126"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062"
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.126",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3: TrainingPlanningFrameworkImportModal aus Trainingsplanungsseite; Playwright-Test 13 (Rahmen-Dialog, skip ohne Gruppe).",
],
},
{
"version": "0.8.125",
"date": "2026-05-13",

View File

@ -0,0 +1,210 @@
import React from 'react'
/**
* Modal: geplante Einheiten aus einem Trainingsrahmenprogramm (Blueprint-Slots) erzeugen.
*/
export default function TrainingPlanningFrameworkImportModal({
open,
frameworkProgramsList,
fwImportProgramId,
onProgramChange,
fwImportLoading,
fwImportDetail,
fwImportSelectedSlots,
onToggleSlot,
fwImportSlotDates,
onSlotDateChange,
fwImportStartDate,
onFwImportStartDateChange,
fwImportIntervalDays,
onFwImportIntervalDaysChange,
fwImportSubmitting,
onApplyDateSuggestions,
onSubmit,
onClose,
}) {
if (!open) return null
return (
<div
data-testid="planning-framework-import-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: 1010,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(620px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '0.65rem' }}>Sessions aus Rahmen übernehmen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt.
</p>
<div className="form-row">
<label className="form-label">Rahmenprogramm</label>
<select
className="form-input"
value={fwImportProgramId}
onChange={(e) => onProgramChange(e.target.value)}
disabled={fwImportLoading || fwImportSubmitting}
>
<option value="">Bitte wählen</option>
{frameworkProgramsList.map((fp) => (
<option key={fp.id} value={String(fp.id)}>
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
</option>
))}
</select>
</div>
{fwImportLoading ? (
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>Laden der Sessions</p>
) : fwImportDetail?.slots?.length ? (
<>
<fieldset style={{ border: 'none', margin: '1rem 0', padding: 0 }}>
<legend className="form-label" style={{ padding: 0, marginBottom: '0.5rem' }}>
Sessions (mit Ablauf)
</legend>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{[...fwImportDetail.slots]
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.map((slot) => {
const hasBp = !!slot.blueprint_training_unit_id
const checked = fwImportSelectedSlots.has(slot.id)
const label =
(slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}`
return (
<li key={slot.id} style={{ marginBottom: '10px' }}>
<label
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
cursor: hasBp ? 'pointer' : 'not-allowed',
opacity: hasBp ? 1 : 0.55,
}}
>
<input
type="checkbox"
checked={checked}
disabled={!hasBp || fwImportSubmitting}
onChange={() => onToggleSlot(slot)}
style={{ marginTop: '0.2rem', flexShrink: 0 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<strong>{label}</strong>
{!hasBp ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
Ohne Session-Ablauf Übernahme nicht möglich.
</span>
) : null}
{hasBp && checked ? (
<span style={{ display: 'block', marginTop: '6px' }}>
<span className="form-label" style={{ fontSize: '0.78rem' }}>
Termin (Datum)
</span>
<input
type="date"
className="form-input"
style={{ maxWidth: '200px', marginTop: '4px' }}
value={fwImportSlotDates[String(slot.id)] || ''}
onChange={(e) => onSlotDateChange(String(slot.id), e.target.value)}
disabled={fwImportSubmitting}
/>
</span>
) : null}
</span>
</label>
</li>
)
})}
</ul>
</fieldset>
<div
className="responsive-grid-3"
style={{
marginBottom: '0.75rem',
padding: '12px',
background: 'var(--surface2)',
borderRadius: '8px',
}}
>
<div className="form-row">
<label className="form-label">Startdatum (Vorschlag)</label>
<input
type="date"
className="form-input"
value={fwImportStartDate}
onChange={(e) => onFwImportStartDateChange(e.target.value)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row">
<label className="form-label">Abstand (Tage)</label>
<input
type="number"
min={0}
className="form-input"
value={fwImportIntervalDays}
onChange={(e) => onFwImportIntervalDaysChange(parseInt(e.target.value, 10) || 0)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row" style={{ alignSelf: 'end' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={fwImportSubmitting}
onClick={onApplyDateSuggestions}
>
Datumsvorschläge setzen
</button>
</div>
</div>
</>
) : fwImportProgramId ? (
<p style={{ color: 'var(--text2)', marginTop: '0.75rem' }}>Keine Sessions in diesem Programm.</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}>
<button
type="button"
className="btn btn-primary"
disabled={fwImportSubmitting || !fwImportDetail}
onClick={onSubmit}
>
{fwImportSubmitting ? 'Übernehmen…' : 'In Planung übernehmen'}
</button>
<button type="button" className="btn btn-secondary" disabled={fwImportSubmitting} onClick={onClose}>
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@ -9,6 +9,7 @@ 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 {
defaultSection,
normalizeUnitToForm,
@ -2287,198 +2288,28 @@ function TrainingPlanningPage() {
</div>
)}
{frameworkImportOpen && (
<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: 1010,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(620px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '0.65rem' }}>Sessions aus Rahmen übernehmen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt.
</p>
<div className="form-row">
<label className="form-label">Rahmenprogramm</label>
<select
className="form-input"
value={fwImportProgramId}
onChange={(e) => onFwImportProgramChange(e.target.value)}
disabled={fwImportLoading || fwImportSubmitting}
>
<option value="">Bitte wählen</option>
{frameworkProgramsList.map((fp) => (
<option key={fp.id} value={String(fp.id)}>
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
</option>
))}
</select>
</div>
{fwImportLoading ? (
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>Laden der Sessions</p>
) : fwImportDetail?.slots?.length ? (
<>
<fieldset style={{ border: 'none', margin: '1rem 0', padding: 0 }}>
<legend className="form-label" style={{ padding: 0, marginBottom: '0.5rem' }}>
Sessions (mit Ablauf)
</legend>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{[...fwImportDetail.slots]
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.map((slot) => {
const hasBp = !!slot.blueprint_training_unit_id
const checked = fwImportSelectedSlots.has(slot.id)
const label =
(slot.title || '').trim() ||
`Session ${(slot.sort_order ?? 0) + 1}`
return (
<li key={slot.id} style={{ marginBottom: '10px' }}>
<label
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
cursor: hasBp ? 'pointer' : 'not-allowed',
opacity: hasBp ? 1 : 0.55,
}}
>
<input
type="checkbox"
checked={checked}
disabled={!hasBp || fwImportSubmitting}
onChange={() => toggleFwImportSlot(slot)}
style={{ marginTop: '0.2rem', flexShrink: 0 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<strong>{label}</strong>
{!hasBp ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
Ohne Session-Ablauf Übernahme nicht möglich.
</span>
) : null}
{hasBp && checked ? (
<span style={{ display: 'block', marginTop: '6px' }}>
<span className="form-label" style={{ fontSize: '0.78rem' }}>
Termin (Datum)
</span>
<input
type="date"
className="form-input"
style={{ maxWidth: '200px', marginTop: '4px' }}
value={fwImportSlotDates[String(slot.id)] || ''}
onChange={(e) =>
setFwImportSlotDates((prev) => ({
...prev,
[String(slot.id)]: e.target.value,
}))
}
disabled={fwImportSubmitting}
/>
</span>
) : null}
</span>
</label>
</li>
)
})}
</ul>
</fieldset>
<div
className="responsive-grid-3"
style={{
marginBottom: '0.75rem',
padding: '12px',
background: 'var(--surface2)',
borderRadius: '8px',
}}
>
<div className="form-row">
<label className="form-label">Startdatum (Vorschlag)</label>
<input
type="date"
className="form-input"
value={fwImportStartDate}
onChange={(e) => setFwImportStartDate(e.target.value)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row">
<label className="form-label">Abstand (Tage)</label>
<input
type="number"
min={0}
className="form-input"
value={fwImportIntervalDays}
onChange={(e) => setFwImportIntervalDays(parseInt(e.target.value, 10) || 0)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row" style={{ alignSelf: 'end' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={fwImportSubmitting}
onClick={applyFwImportDateSuggestions}
>
Datumsvorschläge setzen
</button>
</div>
</div>
</>
) : fwImportProgramId ? (
<p style={{ color: 'var(--text2)', marginTop: '0.75rem' }}>Keine Sessions in diesem Programm.</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}>
<button
type="button"
className="btn btn-primary"
disabled={fwImportSubmitting || !fwImportDetail}
onClick={submitFrameworkImport}
>
{fwImportSubmitting ? 'Übernehmen…' : 'In Planung übernehmen'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={fwImportSubmitting}
onClick={() => setFrameworkImportOpen(false)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
<TrainingPlanningFrameworkImportModal
open={frameworkImportOpen}
frameworkProgramsList={frameworkProgramsList}
fwImportProgramId={fwImportProgramId}
onProgramChange={onFwImportProgramChange}
fwImportLoading={fwImportLoading}
fwImportDetail={fwImportDetail}
fwImportSelectedSlots={fwImportSelectedSlots}
onToggleSlot={toggleFwImportSlot}
fwImportSlotDates={fwImportSlotDates}
onSlotDateChange={(slotId, value) =>
setFwImportSlotDates((prev) => ({ ...prev, [slotId]: value }))
}
fwImportStartDate={fwImportStartDate}
onFwImportStartDateChange={setFwImportStartDate}
fwImportIntervalDays={fwImportIntervalDays}
onFwImportIntervalDaysChange={setFwImportIntervalDays}
fwImportSubmitting={fwImportSubmitting}
onApplyDateSuggestions={applyFwImportDateSuggestions}
onSubmit={submitFrameworkImport}
onClose={() => setFrameworkImportOpen(false)}
/>
{showModal && (
<div

View File

@ -283,6 +283,28 @@ test('12. Trainingsplanung: Seite lädt mit Überschrift', async ({ page }) => {
console.log('✓ Trainingsplanung: Grundansicht');
});
test('13. Trainingsplanung: Rahmen-Import-Dialog öffnet und schließt', async ({ page }, testInfo) => {
await login(page);
await page.goto('/planning', { waitUntil: 'networkidle' });
const main = page.locator('.app-main');
await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 25000 });
await expect(main.getByRole('heading', { level: 1, name: 'Trainingsplanung' })).toBeVisible({
timeout: 20000,
});
const openBtn = main.getByRole('button', { name: /Aus Rahmen übernehmen/i });
if (await openBtn.isDisabled()) {
testInfo.skip(true, 'Keine Trainingsgruppe — Button bleibt deaktiviert');
return;
}
await openBtn.click();
const dlg = page.getByTestId('planning-framework-import-modal');
await expect(dlg).toBeVisible({ timeout: 10000 });
await expect(dlg.getByRole('heading', { name: /Sessions aus Rahmen übernehmen/i })).toBeVisible();
await dlg.getByRole('button', { name: 'Abbrechen' }).click();
await expect(dlg).toHaveCount(0);
console.log('✓ Trainingsplanung: Rahmen-Import-Dialog Smoke');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);