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
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:
parent
bfaf532ab2
commit
e96951728d
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,87 +2105,233 @@ 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>
|
||||
<select
|
||||
className="form-input"
|
||||
value={moduleApplyModuleId}
|
||||
onChange={(e) => setModuleApplyModuleId(e.target.value)}
|
||||
disabled={moduleApplyBusy || !moduleApplyList.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}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{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">Ziel‑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}`}>
|
||||
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}
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<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',
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user