feat(training-units): enhance training unit sections with new module display and functionality
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Failing after 30s

- Added new CSS styles for module display, including a structured layout for exercises and notes within the Training Unit Sections Editor.
- Implemented a function to gather and render module outlines, improving the visibility of exercises and notes associated with training modules.
- Enhanced the TrainingPlanningPage to support module search and preview functionality, allowing users to filter and view module details before applying them.
- Improved state management for module application, ensuring a smoother user experience when inserting modules into training plans.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-12 22:17:44 +02:00
parent bfaf532ab2
commit e96951728d
3 changed files with 675 additions and 93 deletions

View File

@ -5116,6 +5116,7 @@ a.analysis-split__nav-item {
}
.tu-planning-module-band {
/* Legacy — Ersetzt durch .tu-module-bundle-head; wird aus Kompatibilität vorerst beibehalten. */
margin-top: 0.85rem;
margin-bottom: 0.05rem;
padding: 0.35rem 0.65rem;
@ -5128,6 +5129,180 @@ a.analysis-split__nav-item {
letter-spacing: 0.01em;
}
.tu-module-bundle-head {
display: flex;
align-items: stretch;
gap: 0;
margin: 0.65rem 0 0.15rem;
border-radius: 14px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border2));
background: linear-gradient(
134deg,
color-mix(in srgb, var(--accent-light) 55%, var(--surface)) 0%,
var(--surface) 68%
);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.tu-module-bundle-head__stripe {
width: 5px;
flex-shrink: 0;
background: linear-gradient(180deg, var(--accent) 0%, color-mix(in srgb, var(--accent-dark) 92%, black) 100%);
}
.tu-module-bundle-head__main {
flex: 1;
min-width: 0;
padding: 0.6rem 0.75rem 0.72rem;
}
.tu-module-bundle-head__kicker {
display: block;
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
color: color-mix(in srgb, var(--accent-dark) 88%, var(--text3));
margin-bottom: 0.12rem;
}
.tu-module-bundle-head__title {
display: block;
font-size: 1.02rem;
font-weight: 700;
color: var(--text1);
line-height: 1.25;
margin-bottom: 0.5rem;
}
.tu-module-bundle-head__list {
margin: 0;
padding-left: 1.15rem;
font-size: 0.84rem;
line-height: 1.48;
color: var(--text2);
}
.tu-module-bundle-head__list li {
margin-bottom: 0.12rem;
}
.tu-module-bundle-head__list li::marker {
color: var(--accent-dark);
}
.tu-module-bundle-head__more,
.tu-module-bundle-head__meta,
.tu-module-bundle-head__empty {
margin: 0.25rem 0 0;
font-size: 0.79rem;
line-height: 1.42;
color: var(--text3);
}
.tu-module-bundle-head__empty {
margin-top: 0.15rem;
}
.tu-modulepick-search {
width: 100%;
margin-bottom: 0.55rem;
}
.tu-modulepick-list {
max-height: 240px;
overflow-y: auto;
padding: 2px;
margin: 0 -2px;
border-radius: 10px;
border: 1px solid var(--border2);
background: color-mix(in srgb, var(--surface2) 22%, var(--surface));
display: flex;
flex-direction: column;
gap: 4px;
}
.tu-modulepick-item {
text-align: left;
appearance: none;
margin: 0;
cursor: pointer;
width: 100%;
padding: 0.45rem 0.62rem;
border-radius: 8px;
border: 1px solid transparent;
background: var(--surface);
color: var(--text1);
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.tu-modulepick-item:hover {
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
}
.tu-modulepick-item--active {
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent),
0 2px 10px rgba(29, 158, 117, 0.08);
}
.tu-modulepick-item__title {
display: block;
font-size: 0.9rem;
font-weight: 700;
}
.tu-modulepick-item__meta {
display: block;
margin-top: 0.08rem;
font-size: 0.74rem;
color: var(--text3);
line-height: 1.38;
}
.tu-modulepick-preview {
margin-top: 0.85rem;
padding: 0.55rem 0.72rem;
border-radius: 10px;
border: 1px dashed var(--border2);
background: color-mix(in srgb, var(--surface2) 38%, transparent);
}
.tu-modulepick-preview__title {
margin: 0 0 0.45rem;
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text3);
}
.tu-modulepick-preview__list {
margin: 0;
padding-left: 1.1rem;
font-size: 0.82rem;
color: var(--text2);
line-height: 1.42;
}
.tu-modulepick-preview__more {
margin: 0.35rem 0 0;
font-size: 0.78rem;
color: var(--text3);
}
.tu-module-apply-placement-details summary {
cursor: pointer;
font-weight: 600;
font-size: 0.82rem;
color: var(--accent-dark);
margin: 0.25rem 0 0;
}
/* Einfügen zwischen Ablaufzeilen (Übung / Modul / Anmerkung) */
.tu-insert-slot {
display: flex;
@ -5186,6 +5361,47 @@ a.analysis-split__nav-item {
opacity: 0.92;
}
.training-unit-sections-editor .tu-item-row {
border-top: none;
margin-top: 0;
}
.training-unit-sections-editor .tu-item-row--exercise {
margin-top: 0.58rem;
padding: 0.55rem 0.72rem 0.55rem;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--border2) 85%, var(--accent) 15%);
background: linear-gradient(160deg, var(--surface) 0%, color-mix(in srgb, var(--surface2) 35%, var(--surface)) 100%);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.56) inset,
0 2px 10px rgba(15, 23, 42, 0.05);
}
.training-unit-sections-editor .tu-item-row--note:not(.tu-item-row--separator-note) {
margin-top: 0.52rem;
padding: 0.48rem 0.72rem;
border-radius: 11px;
border: 1px solid color-mix(in srgb, var(--border2) 70%, transparent);
background: color-mix(in srgb, var(--surface2) 28%, var(--surface));
}
.training-unit-sections-editor .tu-item-row--separator-note {
margin-top: 0.42rem;
border-radius: 10px;
border: 1px dashed color-mix(in srgb, var(--border2) 75%, transparent);
background: color-mix(in srgb, var(--surface2) 15%, transparent);
}
.training-unit-sections-editor .tu-item-row--from-module.tu-item-row--exercise,
.training-unit-sections-editor .tu-item-row--from-module.tu-item-row--note:not(.tu-item-row--separator-note) {
border-left: 4px solid var(--accent);
padding-left: 0.6rem;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.52) inset,
0 2px 12px rgba(29, 158, 117, 0.07),
inset 10px 0 0 color-mix(in srgb, var(--accent) 14%, transparent);
}
.tu-item-row {
display: flex;
flex-wrap: wrap;

View File

@ -32,6 +32,63 @@ function truncatePreview(text, max = 160) {
return `${t.slice(0, max - 1)}`
}
/** Liest den zusammenhängenden Lauf eines Moduls im Abschnitt (ab erstem Item mit dieser Herkunfts-ID). */
function gatherPlanningModuleOutline(items, startIdx, moduleId) {
const exercises = []
let notes = 0
for (let j = startIdx; j < (items?.length ?? 0); j++) {
const row = items[j]
if (normalizedPlanningModuleChainId(row.source_training_module_id) !== moduleId) break
if (row.item_type === 'note') {
const bod = (row.note_body || '').trim()
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
notes += 1
continue
}
const t =
(row.exercise_title || '').trim() ||
(row.exercise_id ? `Übung #${row.exercise_id}` : 'Übung')
exercises.push(t)
}
return { exercises, notes }
}
const MODULE_OUTLINE_PREVIEW_MAX = 8
function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
if (!showModuleBand || !modOutline) return null
return (
<div className="tu-module-bundle-head" role="group" aria-label={`Modul ${modBandTitle}`}>
<div className="tu-module-bundle-head__stripe" aria-hidden />
<div className="tu-module-bundle-head__main">
<span className="tu-module-bundle-head__kicker">Aus Modul</span>
<strong className="tu-module-bundle-head__title">{modBandTitle}</strong>
{modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
<p className="tu-module-bundle-head__empty">Ohne strukturierten Inhalt angezeigt.</p>
) : (
<ol className="tu-module-bundle-head__list" start={1}>
{modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
<li key={`mo-li-${modBandTitle}-${ox}`}>{tx}</li>
))}
</ol>
)}
{modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
<p className="tu-module-bundle-head__more">
und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
{modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
</p>
) : null}
{modOutline.notes > 0 ? (
<p className="tu-module-bundle-head__meta">
sowie {modOutline.notes}{' '}
{modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
</p>
) : null}
</div>
</div>
)
}
function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
const b = [...blocks]
if (fromI < 0 || fromI >= b.length) return blocks
@ -598,25 +655,22 @@ export default function TrainingUnitSectionsEditor({
(it.source_module_title || '').trim() ||
(curMn != null ? `Modul #${curMn}` : '')
const modOutline =
showModuleBand && curMn != null ? gatherPlanningModuleOutline(sec.items, iIdx, curMn) : null
const fromModClass = curMn != null ? ' tu-item-row--from-module' : ''
if (it.item_type === 'note') {
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
const notePv = truncatePreview(it.note_body || '', 260)
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{showModuleBand ? (
<div
className="tu-planning-module-band"
role="group"
aria-label={`Baustein ${modBandTitle}`}
>
Baustein: {modBandTitle}
</div>
) : null}
{renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className={
`${rowCommon} tu-item-row--note` +
(isSepLine ? ' tu-item-row--separator-note' : '')
(isSepLine ? ' tu-item-row--separator-note' : '') +
fromModClass
}
{...dndRowProps}
>
@ -714,16 +768,8 @@ export default function TrainingUnitSectionsEditor({
return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{showModuleBand ? (
<div
className="tu-planning-module-band"
role="group"
aria-label={`Baustein ${modBandTitle}`}
>
Baustein: {modBandTitle}
</div>
) : null}
<div className={`${rowCommon} tu-item-row--exercise`} {...dndRowProps}>
{renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div className={`${rowCommon} tu-item-row--exercise${fromModClass}`} {...dndRowProps}>
<div className="tu-item-row__mainline">
{enableItemDragReorder ? (
<span

View File

@ -17,6 +17,15 @@ import {
insertTrainingModuleIntoPlanningSections,
} from '../utils/trainingUnitSectionsForm'
/** Kurz-Anzeige Sichtbarkeit (Trainingsmodule, Übungen) */
function trainingVisibilityShortDE(visibility) {
const v = String(visibility || '').trim().toLowerCase()
if (v === 'official') return 'Öffentliche Bibliothek'
if (v === 'club') return 'Verein'
if (v === 'private') return 'Privat'
return visibility ? String(visibility) : ''
}
function addDaysIsoDate(isoDay, daysDelta) {
const d = new Date(`${isoDay}T12:00:00`)
d.setDate(d.getDate() + daysDelta)
@ -152,6 +161,15 @@ function TrainingPlanningPage() {
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0')
const [moduleApplyErr, setModuleApplyErr] = useState('')
const [moduleApplyPlacementLocked, setModuleApplyPlacementLocked] = useState(false)
const [moduleApplySearchQuery, setModuleApplySearchQuery] = useState('')
const [modulePickPreview, setModulePickPreview] = useState({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
const [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater)
@ -189,6 +207,55 @@ function TrainingPlanningPage() {
const planningFormRef = useRef(formData)
planningFormRef.current = formData
const moduleApplyFilteredList = useMemo(() => {
const q = moduleApplySearchQuery.trim().toLowerCase().replace(/\s+/g, ' ')
const words = q ? q.split(' ').filter(Boolean) : []
const list = Array.isArray(moduleApplyList) ? moduleApplyList : []
if (!words.length) return list
return list.filter((m) => {
const blob = [
m.title,
m.summary,
m.goal,
m.target_group_notes,
m.deployment_context_notes,
]
.map((x) => String(x ?? '').toLowerCase())
.join('\n')
return words.every((w) => blob.includes(w))
})
}, [moduleApplySearchQuery, moduleApplyList])
const modulePlacementSummary = useMemo(() => {
const secs = Array.isArray(formData.sections) ? formData.sections : []
let si =
typeof moduleApplySectionIx === 'number'
? moduleApplySectionIx
: parseInt(String(moduleApplySectionIx), 10)
if (!Number.isFinite(si)) si = 0
si = Math.max(0, Math.min(si, secs.length ? secs.length - 1 : 0))
const cap = secs[si]?.items?.length ?? 0
let beforeIx = cap
if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
if (Number.isFinite(zi)) beforeIx = Math.min(Math.max(0, zi), cap)
}
const rawTitle = (secs[si]?.title || '').trim()
const secTitle = rawTitle || `Abschnitt ${si + 1}`
let positionDescription
if (cap <= 0) positionDescription = 'als erste Einträge dieses Abschnitts'
else if (beforeIx <= 0) positionDescription = 'vor dem ersten Eintrag dieses Abschnitts'
else if (beforeIx >= cap) positionDescription = 'nach dem letzten Eintrag dieses Abschnitts'
else positionDescription = `unmittelbar vor Eintrag ${beforeIx + 1} (${cap} Einträge im Abschnitt)`
return { secTitle, positionDescription }
}, [formData.sections, moduleApplySectionIx, moduleApplyInsertSlot])
useEffect(() => {
if (!moduleApplyOpen || !moduleApplyFilteredList.length) return
if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return
setModuleApplyModuleId(String(moduleApplyFilteredList[0].id))
}, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId])
const planningModalClubId = useMemo(() => {
const gid = Number(formData.group_id)
if (!Number.isFinite(gid) || gid < 1) return null
@ -686,6 +753,12 @@ function TrainingPlanningPage() {
const openModuleApplyModal = useCallback(async (placement) => {
setModuleApplyErr('')
setModuleApplySearchQuery('')
const placementLocked =
placement != null &&
typeof placement.sectionIndex === 'number' &&
typeof placement.insertBeforeIndex === 'number'
setModuleApplyPlacementLocked(placementLocked)
const secs = planningFormRef.current?.sections ?? []
let secIx = 0
let before = 0
@ -754,6 +827,7 @@ function TrainingPlanningPage() {
nextSections = await enrichSectionsWithVariants(nextSections)
setFormData((fd) => ({ ...fd, sections: nextSections }))
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
} catch (e) {
setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
} finally {
@ -761,6 +835,98 @@ function TrainingPlanningPage() {
}
}, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot])
useEffect(() => {
if (!moduleApplyOpen) {
setModulePickPreview({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
return undefined
}
const mid = parseInt(String(moduleApplyModuleId), 10)
if (!Number.isFinite(mid) || mid < 1) {
setModulePickPreview({
loading: false,
moduleId: '',
exercises: [],
notes: 0,
err: '',
})
return undefined
}
let cancelled = false
setModulePickPreview({
loading: true,
moduleId: String(mid),
exercises: [],
notes: 0,
err: '',
})
;(async () => {
try {
const detail = await api.getTrainingModule(mid)
if (cancelled) return
const itemsSorted = [...(detail.items ?? [])].sort(
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
)
const uniqueEx = new Set()
let notes = 0
for (const row of itemsSorted) {
if ((row.item_type || '') !== 'note') {
const eid = row.exercise_id
if (eid) uniqueEx.add(Number(eid))
continue
}
const b = String(row.note_body ?? '').trim()
if (b === '---') continue
notes += 1
}
const titleById = new Map()
await Promise.all(
[...uniqueEx].map(async (eid) => {
try {
const ex = await api.getExercise(eid)
titleById.set(eid, (ex?.title || '').trim() || `Übung #${eid}`)
} catch {
titleById.set(eid, `Übung #${eid}`)
}
})
)
if (cancelled) return
const exTitlesInOrder = []
for (const row of itemsSorted) {
if ((row.item_type || '') !== 'exercise') continue
const eid = Number(row.exercise_id)
if (!Number.isFinite(eid)) continue
exTitlesInOrder.push(titleById.get(eid) || `Übung #${eid}`)
}
setModulePickPreview({
loading: false,
moduleId: String(mid),
exercises: exTitlesInOrder,
notes,
err: '',
})
} catch (e) {
if (!cancelled) {
setModulePickPreview({
loading: false,
moduleId: String(mid),
exercises: [],
notes: 0,
err: e?.message || 'Vorschau fehlgeschlagen',
})
}
}
})()
return () => {
cancelled = true
}
}, [moduleApplyOpen, moduleApplyModuleId])
const handleTakeLead = async (unit) => {
if (!user?.id) return
try {
@ -1919,13 +2085,17 @@ function TrainingPlanningPage() {
overflowY: 'auto',
}}
role="presentation"
onMouseDown={(ev) => ev.target === ev.currentTarget && !moduleApplyBusy && setModuleApplyOpen(false)}
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(480px, 100%)',
maxWidth: 'min(560px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
@ -1935,39 +2105,84 @@ function TrainingPlanningPage() {
aria-labelledby="module-apply-title"
>
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
Modul einfügen
Trainingsmodul einfügen
</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Übungen und Notizen des Moduls werden <strong>kopiert</strong> wie bei einer einzelnen Übung
ohne die Einheit vorher gespeichert zu haben (Speichern am Ende wie gewohnt). Die Herkunft bleibt
am Block sichtbar; du kannst alles weiter anpassen.
<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}
<div className="form-row">
<label className="form-label">Modul</label>
{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={moduleApplyModuleId}
onChange={(e) => setModuleApplyModuleId(e.target.value)}
disabled={moduleApplyBusy || !moduleApplyList.length}
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}
>
{!moduleApplyList.length ? (
<option value="">Keine Module verfügbar</option>
) : null}
{moduleApplyList.map((m) => (
<option key={m.id} value={String(m.id)}>
{(m.title || '').trim() || `Modul #${m.id}`}
{(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">ZielAbschnitt (Reihenfolge wie im Editor)</label>
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
<select
className="form-input"
value={String(moduleApplySectionIx)}
@ -1997,7 +2212,7 @@ function TrainingPlanningPage() {
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
>
<option value={`before:${moduleApplyTargetItems.length}`}>
Ans Ende einfügen (nach allen Einträgen)
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) => {
@ -2015,6 +2230,107 @@ function TrainingPlanningPage() {
})}
</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={{
@ -2029,7 +2345,11 @@ function TrainingPlanningPage() {
type="button"
className="btn btn-secondary"
disabled={moduleApplyBusy}
onClick={() => !moduleApplyBusy && setModuleApplyOpen(false)}
onClick={() => {
if (moduleApplyBusy) return
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
>
Abbrechen
</button>