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 { .tu-planning-module-band {
/* Legacy — Ersetzt durch .tu-module-bundle-head; wird aus Kompatibilität vorerst beibehalten. */
margin-top: 0.85rem; margin-top: 0.85rem;
margin-bottom: 0.05rem; margin-bottom: 0.05rem;
padding: 0.35rem 0.65rem; padding: 0.35rem 0.65rem;
@ -5128,6 +5129,180 @@ a.analysis-split__nav-item {
letter-spacing: 0.01em; 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) */ /* Einfügen zwischen Ablaufzeilen (Übung / Modul / Anmerkung) */
.tu-insert-slot { .tu-insert-slot {
display: flex; display: flex;
@ -5186,6 +5361,47 @@ a.analysis-split__nav-item {
opacity: 0.92; 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 { .tu-item-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -32,6 +32,63 @@ function truncatePreview(text, max = 160) {
return `${t.slice(0, max - 1)}` 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) { function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
const b = [...blocks] const b = [...blocks]
if (fromI < 0 || fromI >= b.length) return blocks if (fromI < 0 || fromI >= b.length) return blocks
@ -598,25 +655,22 @@ export default function TrainingUnitSectionsEditor({
(it.source_module_title || '').trim() || (it.source_module_title || '').trim() ||
(curMn != null ? `Modul #${curMn}` : '') (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') { if (it.item_type === 'note') {
const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY
const notePv = truncatePreview(it.note_body || '', 260) const notePv = truncatePreview(it.note_body || '', 260)
const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine
return ( return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}> <Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{showModuleBand ? ( {renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div
className="tu-planning-module-band"
role="group"
aria-label={`Baustein ${modBandTitle}`}
>
Baustein: {modBandTitle}
</div>
) : null}
<div <div
className={ className={
`${rowCommon} tu-item-row--note` + `${rowCommon} tu-item-row--note` +
(isSepLine ? ' tu-item-row--separator-note' : '') (isSepLine ? ' tu-item-row--separator-note' : '') +
fromModClass
} }
{...dndRowProps} {...dndRowProps}
> >
@ -714,16 +768,8 @@ export default function TrainingUnitSectionsEditor({
return ( return (
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}> <Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
{showModuleBand ? ( {renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
<div <div className={`${rowCommon} tu-item-row--exercise${fromModClass}`} {...dndRowProps}>
className="tu-planning-module-band"
role="group"
aria-label={`Baustein ${modBandTitle}`}
>
Baustein: {modBandTitle}
</div>
) : null}
<div className={`${rowCommon} tu-item-row--exercise`} {...dndRowProps}>
<div className="tu-item-row__mainline"> <div className="tu-item-row__mainline">
{enableItemDragReorder ? ( {enableItemDragReorder ? (
<span <span

View File

@ -17,6 +17,15 @@ import {
insertTrainingModuleIntoPlanningSections, insertTrainingModuleIntoPlanningSections,
} from '../utils/trainingUnitSectionsForm' } 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) { function addDaysIsoDate(isoDay, daysDelta) {
const d = new Date(`${isoDay}T12:00:00`) const d = new Date(`${isoDay}T12:00:00`)
d.setDate(d.getDate() + daysDelta) d.setDate(d.getDate() + daysDelta)
@ -152,6 +161,15 @@ function TrainingPlanningPage() {
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0) const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0') const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0')
const [moduleApplyErr, setModuleApplyErr] = useState('') 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 [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater) const [endDate, setEndDate] = useState(thirtyDaysLater)
@ -189,6 +207,55 @@ function TrainingPlanningPage() {
const planningFormRef = useRef(formData) const planningFormRef = useRef(formData)
planningFormRef.current = 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 planningModalClubId = useMemo(() => {
const gid = Number(formData.group_id) const gid = Number(formData.group_id)
if (!Number.isFinite(gid) || gid < 1) return null if (!Number.isFinite(gid) || gid < 1) return null
@ -686,6 +753,12 @@ function TrainingPlanningPage() {
const openModuleApplyModal = useCallback(async (placement) => { const openModuleApplyModal = useCallback(async (placement) => {
setModuleApplyErr('') setModuleApplyErr('')
setModuleApplySearchQuery('')
const placementLocked =
placement != null &&
typeof placement.sectionIndex === 'number' &&
typeof placement.insertBeforeIndex === 'number'
setModuleApplyPlacementLocked(placementLocked)
const secs = planningFormRef.current?.sections ?? [] const secs = planningFormRef.current?.sections ?? []
let secIx = 0 let secIx = 0
let before = 0 let before = 0
@ -754,6 +827,7 @@ function TrainingPlanningPage() {
nextSections = await enrichSectionsWithVariants(nextSections) nextSections = await enrichSectionsWithVariants(nextSections)
setFormData((fd) => ({ ...fd, sections: nextSections })) setFormData((fd) => ({ ...fd, sections: nextSections }))
setModuleApplyOpen(false) setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
} catch (e) { } catch (e) {
setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen') setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
} finally { } finally {
@ -761,6 +835,98 @@ function TrainingPlanningPage() {
} }
}, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot]) }, [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) => { const handleTakeLead = async (unit) => {
if (!user?.id) return if (!user?.id) return
try { try {
@ -1919,13 +2085,17 @@ function TrainingPlanningPage() {
overflowY: 'auto', overflowY: 'auto',
}} }}
role="presentation" 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 <div
className="card" className="card"
style={{ style={{
padding: 'clamp(14px, 3vw, 1.75rem)', padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(480px, 100%)', maxWidth: 'min(560px, 100%)',
width: '100%', width: '100%',
maxHeight: '90vh', maxHeight: '90vh',
overflowY: 'auto', overflowY: 'auto',
@ -1935,87 +2105,233 @@ function TrainingPlanningPage() {
aria-labelledby="module-apply-title" aria-labelledby="module-apply-title"
> >
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}> <h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
Modul einfügen Trainingsmodul einfügen
</h2> </h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}> <p style={{ color: 'var(--text2)', fontSize: '0.87rem', marginBottom: '0.85rem', lineHeight: 1.5 }}>
Übungen und Notizen des Moduls werden <strong>kopiert</strong> wie bei einer einzelnen Übung Alle Positionen des gewählten Moduls werden <strong>als neue Zeilen</strong> eingefügt (Kopie, mit klarer
ohne die Einheit vorher gespeichert zu haben (Speichern am Ende wie gewohnt). Die Herkunft bleibt Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben Speichern am Ende
am Block sichtbar; du kannst alles weiter anpassen. 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> </p>
{moduleApplyErr ? ( {moduleApplyErr ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{moduleApplyErr}</p> <p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{moduleApplyErr}</p>
) : null} ) : null}
<div className="form-row"> {moduleApplyPlacementLocked ? (
<label className="form-label">Modul</label> <>
<select <p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', lineHeight: 1.5, color: 'var(--text2)' }}>
className="form-input" Aktuelle Einfügeposition: Abschnitt <strong>{modulePlacementSummary.secTitle}</strong>{' '}
value={moduleApplyModuleId} <span aria-hidden>/</span> {modulePlacementSummary.positionDescription}
onChange={(e) => setModuleApplyModuleId(e.target.value)} </p>
disabled={moduleApplyBusy || !moduleApplyList.length} <details className="tu-module-apply-placement-details">
> <summary style={{ outline: 'none' }}>Abschnitt oder Position ändern</summary>
{!moduleApplyList.length ? ( <div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<option value="">Keine Module verfügbar</option> <div className="form-row" style={{ marginBottom: 0 }}>
) : null} <label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
{moduleApplyList.map((m) => ( <select
<option key={m.id} value={String(m.id)}> className="form-input"
{(m.title || '').trim() || `Modul #${m.id}`} value={String(moduleApplySectionIx)}
</option> onChange={(e) => {
))} const newIx = parseInt(e.target.value, 10)
</select> setModuleApplySectionIx(newIx)
</div> 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"> <div className="form-row">
<label className="form-label">ZielAbschnitt (Reihenfolge wie im Editor)</label> <label className="form-label">Position in diesem Abschnitt</label>
<select <select
className="form-input" className="form-input"
value={String(moduleApplySectionIx)} value={moduleApplyInsertSlot}
onChange={(e) => { onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
const newIx = parseInt(e.target.value, 10) disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
setModuleApplySectionIx(newIx) >
const secsNow = planningFormRef.current?.sections ?? [] <option value={`before:${moduleApplyTargetItems.length}`}>
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0 Am Ende einfügen (nach allen Einträgen)
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}`}>
Ans 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> </option>
) <option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
})} {moduleApplyTargetItems.map((row, xi) => {
</select> 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>
<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 <div
style={{ style={{
display: 'flex', display: 'flex',
@ -2029,7 +2345,11 @@ function TrainingPlanningPage() {
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
disabled={moduleApplyBusy} disabled={moduleApplyBusy}
onClick={() => !moduleApplyBusy && setModuleApplyOpen(false)} onClick={() => {
if (moduleApplyBusy) return
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
> >
Abbrechen Abbrechen
</button> </button>