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 {
|
.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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">Ziel‑Abschnitt (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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user