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

- Bumped APP_VERSION to 0.8.128 and updated the changelog to reflect recent changes.
- Added the TrainingPlanningModuleApplyModal component to the TrainingPlanningPage for enhanced training module application functionality.
- Implemented a new callback function onModuleApplySectionIndexChange to manage module application section index changes.
This commit is contained in:
Lars 2026-05-14 13:44:37 +02:00
parent e4e362b0a9
commit 45bc049c0d
3 changed files with 360 additions and 302 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.126"
APP_VERSION = "0.8.128"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514062"
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.128",
"date": "2026-05-13",
"changes": [
"Frontend Phase 3: TrainingPlanningModuleApplyModal (Trainingsmodul einfügen) aus Trainingsplanungsseite; gemeinsamer Callback onModuleApplySectionIndexChange.",
],
},
{
"version": "0.8.126",
"date": "2026-05-13",

View File

@ -0,0 +1,319 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { trainingVisibilityShortDE } from '../../utils/trainingPlanningPageHelpers'
/**
* Dialog: Trainingsmodul in die Abschnitte einer Einheit einfügen (Bibliothekskopie).
*/
export default function TrainingPlanningModuleApplyModal({
open,
busy,
err,
placementLocked,
placementSummary,
sections,
sectionIx,
onSectionIndexChange,
insertSlot,
onInsertSlotChange,
targetItems,
searchQuery,
onSearchQueryChange,
filteredList,
fullList,
selectedModuleId,
onSelectModuleId,
modulePickPreview,
onConfirm,
onCancel,
}) {
if (!open) return null
const handleBackdropMouseDown = (ev) => {
if (ev.target !== ev.currentTarget || busy) return
onCancel()
}
return (
<div
data-testid="planning-module-apply-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',
}}
role="presentation"
onMouseDown={handleBackdropMouseDown}
>
<div
className="card"
style={{
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(560px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
role="dialog"
aria-labelledby="module-apply-title"
>
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
Trainingsmodul einfügen
</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.87rem', marginBottom: '0.85rem', lineHeight: 1.5 }}>
Alle Positionen des gewählten Moduls werden <strong>als neue Zeilen</strong> eingefügt (Kopie, mit klarer
Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben Speichern am Ende
wie gewohnt. <strong>Vollständige Textsuche oder Modulkategorien</strong> planen wir serverseitig für
eine spätere Iteration; vorerst steht hier eine{' '}
<strong>Schnellsuche über Titel und Freitext-Felder</strong> zur Verfügung.
</p>
{err ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{err}</p>
) : null}
{placementLocked ? (
<>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', lineHeight: 1.5, color: 'var(--text2)' }}>
Aktuelle Einfügeposition: Abschnitt <strong>{placementSummary.secTitle}</strong>{' '}
<span aria-hidden>/</span> {placementSummary.positionDescription}
</p>
<details className="tu-module-apply-placement-details">
<summary style={{ outline: 'none' }}>Abschnitt oder Position ändern</summary>
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(sectionIx)}
onChange={(e) => onSectionIndexChange(parseInt(e.target.value, 10))}
disabled={busy || !sections?.length}
>
{(sections || []).map((s, i) => (
<option key={`sec-opt-u-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={insertSlot}
onChange={(e) => onInsertSlotChange(e.target.value)}
disabled={busy || !(sections?.length > 0)}
>
<option value={`before:${targetItems.length}`}>
Am Ende einfügen (nach allen Einträgen)
</option>
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
{targetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-u-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
</div>
</details>
</>
) : (
<>
<div className="form-row">
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(sectionIx)}
onChange={(e) => onSectionIndexChange(parseInt(e.target.value, 10))}
disabled={busy || !sections?.length}
>
{(sections || []).map((s, i) => (
<option key={`sec-opt-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={insertSlot}
onChange={(e) => onInsertSlotChange(e.target.value)}
disabled={busy || !(sections?.length > 0)}
>
<option value={`before:${targetItems.length}`}>
Am Ende einfügen (nach allen Einträgen)
</option>
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
{targetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
</>
)}
<div className="form-row" style={{ marginTop: placementLocked ? '1rem' : undefined }}>
<label className="form-label">Suche Module</label>
<input
type="search"
enterKeyHint="search"
className="form-input tu-modulepick-search"
placeholder="Freitext: Titel, Kurzbeschreibung, Ziel, Zielgruppe …"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
disabled={busy}
aria-label="Module durch Freitext filtern"
/>
</div>
<div className="form-row" style={{ marginBottom: '0.65rem' }}>
<label className="form-label" id="module-pick-label">
Modulliste
</label>
</div>
<div
className="tu-modulepick-list"
role="listbox"
aria-labelledby="module-pick-label"
aria-activedescendant={selectedModuleId ? `module-pick-opt-${selectedModuleId}` : undefined}
>
{!filteredList.length ? (
<p style={{ margin: '0.45rem', fontSize: '0.86rem', color: 'var(--text3)', lineHeight: 1.45 }}>
{!fullList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'}
</p>
) : (
filteredList.map((m) => {
const title = ((m.title || '').trim() || `Modul #${m.id}`).trim()
const visLbl = trainingVisibilityShortDE(m.visibility)
const nPos = typeof m.items_count === 'number' ? m.items_count : '—'
const selected = String(m.id) === String(selectedModuleId)
return (
<button
key={m.id}
id={`module-pick-opt-${m.id}`}
type="button"
role="option"
aria-selected={selected}
className={`tu-modulepick-item${selected ? ' tu-modulepick-item--active' : ''}`}
disabled={busy}
onClick={() => onSelectModuleId(String(m.id))}
>
<span className="tu-modulepick-item__title">{title}</span>
<span className="tu-modulepick-item__meta">
{nPos} {typeof nPos === 'number' ? (nPos === 1 ? 'Position' : 'Positionen') : 'Position(en)'}
{visLbl ? <> · {visLbl}</> : null}
{m.summary ? <> · {(m.summary || '').trim().slice(0, 72)}{(m.summary || '').trim().length > 72 ? '…' : ''}</> : null}
</span>
</button>
)
})
)}
</div>
{selectedModuleId ? (
<div className="tu-modulepick-preview" aria-live="polite">
<div className="tu-modulepick-preview__title">Ablauf-Vorschau (Bibliotheksmodul)</div>
{modulePickPreview.loading ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
Übungen und Hinweise laden
</p>
) : modulePickPreview.err ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--danger)' }}>
{modulePickPreview.err}
</p>
) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben).
</p>
) : (
<>
<ol className="tu-modulepick-preview__list">
{(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => (
<li key={`pv-ex-${qi}`}>{t}</li>
))}
</ol>
{modulePickPreview.exercises.length > 12 ? (
<p className="tu-modulepick-preview__more">
und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge.
</p>
) : null}
{modulePickPreview.notes > 0 ? (
<p className="tu-modulepick-preview__more">
zusätzlich {modulePickPreview.notes}{' '}
{modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '}
(ohne Aufzählung)
</p>
) : null}
</>
)}
</div>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => {
if (busy) return
onCancel()
}}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={onConfirm}>
{busy ? 'Einfügen …' : 'Einfügen'}
</button>
</div>
<p style={{ margin: '1rem 0 0', fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Neue Module kannst du unter{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
anlegen.
</p>
</div>
</div>
)
}

View File

@ -10,6 +10,7 @@ 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 {
defaultSection,
normalizeUnitToForm,
@ -19,7 +20,6 @@ import {
insertTrainingModuleIntoPlanningSections,
} from '../utils/trainingUnitSectionsForm'
import {
trainingVisibilityShortDE,
addDaysIsoDate,
pad2,
toIsoLocal,
@ -709,6 +709,13 @@ function TrainingPlanningPage() {
}
}, [])
const onModuleApplySectionIndexChange = useCallback((newIx) => {
setModuleApplySectionIx(newIx)
const secsNow = planningFormRef.current?.sections ?? []
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
setModuleApplyInsertSlot(`before:${len}`)
}, [])
const handleApplyTrainingModuleConfirm = useCallback(async () => {
const mid = parseInt(moduleApplyModuleId, 10)
if (!Number.isFinite(mid)) {
@ -1987,306 +1994,31 @@ function TrainingPlanningPage() {
</div>
) : null}
{moduleApplyOpen && (
<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',
}}
role="presentation"
onMouseDown={(ev) => {
if (ev.target !== ev.currentTarget || moduleApplyBusy) return
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
>
<div
className="card"
style={{
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(560px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
role="dialog"
aria-labelledby="module-apply-title"
>
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
Trainingsmodul einfügen
</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.87rem', marginBottom: '0.85rem', lineHeight: 1.5 }}>
Alle Positionen des gewählten Moduls werden <strong>als neue Zeilen</strong> eingefügt (Kopie, mit klarer
Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben Speichern am Ende
wie gewohnt. <strong>Vollständige Textsuche oder Modulkategorien</strong> planen wir serverseitig für
eine spätere Iteration; vorerst steht hier eine{' '}
<strong>Schnellsuche über Titel und Freitext-Felder</strong> zur Verfügung.
</p>
{moduleApplyErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{moduleApplyErr}</p>
) : null}
{moduleApplyPlacementLocked ? (
<>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', lineHeight: 1.5, color: 'var(--text2)' }}>
Aktuelle Einfügeposition: Abschnitt <strong>{modulePlacementSummary.secTitle}</strong>{' '}
<span aria-hidden>/</span> {modulePlacementSummary.positionDescription}
</p>
<details className="tu-module-apply-placement-details">
<summary style={{ outline: 'none' }}>Abschnitt oder Position ändern</summary>
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(moduleApplySectionIx)}
onChange={(e) => {
const newIx = parseInt(e.target.value, 10)
setModuleApplySectionIx(newIx)
const secsNow = planningFormRef.current?.sections ?? []
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
setModuleApplyInsertSlot(`before:${len}`)
}}
disabled={moduleApplyBusy || !formData.sections?.length}
>
{(formData.sections || []).map((s, i) => (
<option key={`sec-opt-u-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
</option>
))}
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={moduleApplyInsertSlot}
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
>
<option value={`before:${moduleApplyTargetItems.length}`}>
Am Ende einfügen (nach allen Einträgen)
</option>
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
{moduleApplyTargetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-u-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
</div>
</details>
</>
) : (
<>
<div className="form-row">
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(moduleApplySectionIx)}
onChange={(e) => {
const newIx = parseInt(e.target.value, 10)
setModuleApplySectionIx(newIx)
const secsNow = planningFormRef.current?.sections ?? []
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
setModuleApplyInsertSlot(`before:${len}`)
}}
disabled={moduleApplyBusy || !formData.sections?.length}
>
{(formData.sections || []).map((s, i) => (
<option key={`sec-opt-${i}`} value={String(i)}>
{(s.title || `Abschnitt ${i + 1}`).trim()}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Position in diesem Abschnitt</label>
<select
className="form-input"
value={moduleApplyInsertSlot}
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
>
<option value={`before:${moduleApplyTargetItems.length}`}>
Am Ende einfügen (nach allen Einträgen)
</option>
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
{moduleApplyTargetItems.map((row, xi) => {
const labelPart =
row.item_type === 'note'
? 'Zwischen-Anmerkung'
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
const clipped =
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}` : labelPart
return (
<option key={`before-${xi}`} value={`before:${xi}`}>
Vor Eintrag {xi + 1}: {clipped}
</option>
)
})}
</select>
</div>
</>
)}
<div className="form-row" style={{ marginTop: moduleApplyPlacementLocked ? '1rem' : undefined }}>
<label className="form-label">Suche Module</label>
<input
type="search"
enterKeyHint="search"
className="form-input tu-modulepick-search"
placeholder="Freitext: Titel, Kurzbeschreibung, Ziel, Zielgruppe …"
value={moduleApplySearchQuery}
onChange={(e) => setModuleApplySearchQuery(e.target.value)}
disabled={moduleApplyBusy}
aria-label="Module durch Freitext filtern"
/>
</div>
<div className="form-row" style={{ marginBottom: '0.65rem' }}>
<label className="form-label" id="module-pick-label">
Modulliste
</label>
</div>
<div
className="tu-modulepick-list"
role="listbox"
aria-labelledby="module-pick-label"
aria-activedescendant={
moduleApplyModuleId ? `module-pick-opt-${moduleApplyModuleId}` : undefined
}
>
{!moduleApplyFilteredList.length ? (
<p style={{ margin: '0.45rem', fontSize: '0.86rem', color: 'var(--text3)', lineHeight: 1.45 }}>
{!moduleApplyList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'}
</p>
) : (
moduleApplyFilteredList.map((m) => {
const title = ((m.title || '').trim() || `Modul #${m.id}`).trim()
const visLbl = trainingVisibilityShortDE(m.visibility)
const nPos = typeof m.items_count === 'number' ? m.items_count : '—'
const selected = String(m.id) === String(moduleApplyModuleId)
return (
<button
key={m.id}
id={`module-pick-opt-${m.id}`}
type="button"
role="option"
aria-selected={selected}
className={`tu-modulepick-item${selected ? ' tu-modulepick-item--active' : ''}`}
disabled={moduleApplyBusy}
onClick={() => setModuleApplyModuleId(String(m.id))}
>
<span className="tu-modulepick-item__title">{title}</span>
<span className="tu-modulepick-item__meta">
{nPos} {typeof nPos === 'number' ? (nPos === 1 ? 'Position' : 'Positionen') : 'Position(en)'}
{visLbl ? <> · {visLbl}</> : null}
{m.summary ? <> · {(m.summary || '').trim().slice(0, 72)}{(m.summary || '').trim().length > 72 ? '…' : ''}</> : null}
</span>
</button>
)
})
)}
</div>
{moduleApplyModuleId ? (
<div className="tu-modulepick-preview" aria-live="polite">
<div className="tu-modulepick-preview__title">Ablauf-Vorschau (Bibliotheksmodul)</div>
{modulePickPreview.loading ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
Übungen und Hinweise laden
</p>
) : modulePickPreview.err ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--danger)' }}>
{modulePickPreview.err}
</p>
) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? (
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben).
</p>
) : (
<>
<ol className="tu-modulepick-preview__list">
{(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => (
<li key={`pv-ex-${qi}`}>{t}</li>
))}
</ol>
{modulePickPreview.exercises.length > 12 ? (
<p className="tu-modulepick-preview__more">
und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge.
</p>
) : null}
{modulePickPreview.notes > 0 ? (
<p className="tu-modulepick-preview__more">
zusätzlich {modulePickPreview.notes}{' '}
{modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '}
(ohne Aufzählung)
</p>
) : null}
</>
)}
</div>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={moduleApplyBusy}
onClick={() => {
if (moduleApplyBusy) return
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={moduleApplyBusy} onClick={handleApplyTrainingModuleConfirm}>
{moduleApplyBusy ? 'Einfügen …' : 'Einfügen'}
</button>
</div>
<p style={{ margin: '1rem 0 0', fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45 }}>
Neue Module kannst du unter{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
anlegen.
</p>
</div>
</div>
)}
<TrainingPlanningModuleApplyModal
open={moduleApplyOpen}
busy={moduleApplyBusy}
err={moduleApplyErr}
placementLocked={moduleApplyPlacementLocked}
placementSummary={modulePlacementSummary}
sections={formData.sections}
sectionIx={moduleApplySectionIx}
onSectionIndexChange={onModuleApplySectionIndexChange}
insertSlot={moduleApplyInsertSlot}
onInsertSlotChange={setModuleApplyInsertSlot}
targetItems={moduleApplyTargetItems}
searchQuery={moduleApplySearchQuery}
onSearchQueryChange={setModuleApplySearchQuery}
filteredList={moduleApplyFilteredList}
fullList={moduleApplyList}
selectedModuleId={moduleApplyModuleId}
onSelectModuleId={setModuleApplyModuleId}
modulePickPreview={modulePickPreview}
onConfirm={handleApplyTrainingModuleConfirm}
onCancel={() => {
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
/>
<TrainingPlanningFrameworkImportModal
open={frameworkImportOpen}