chore(version): update version and changelog for release 0.8.128
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m7s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m7s
- Bumped APP_VERSION to 0.8.128 and updated the changelog to reflect recent changes. - Added the TrainingPlanningModuleApplyModal component to the TrainingPlanningPage for enhanced training module application functionality. - Implemented a new callback function onModuleApplySectionIndexChange to manage module application section index changes.
This commit is contained in:
parent
e4e362b0a9
commit
45bc049c0d
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.126"
|
||||
APP_VERSION = "0.8.128"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260514062"
|
||||
|
||||
|
|
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.128",
|
||||
"date": "2026-05-13",
|
||||
"changes": [
|
||||
"Frontend Phase 3: TrainingPlanningModuleApplyModal (Trainingsmodul einfügen) aus Trainingsplanungsseite; gemeinsamer Callback onModuleApplySectionIndexChange.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.126",
|
||||
"date": "2026-05-13",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,319 @@
|
|||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { trainingVisibilityShortDE } from '../../utils/trainingPlanningPageHelpers'
|
||||
|
||||
/**
|
||||
* Dialog: Trainingsmodul in die Abschnitte einer Einheit einfügen (Bibliothekskopie).
|
||||
*/
|
||||
export default function TrainingPlanningModuleApplyModal({
|
||||
open,
|
||||
busy,
|
||||
err,
|
||||
placementLocked,
|
||||
placementSummary,
|
||||
sections,
|
||||
sectionIx,
|
||||
onSectionIndexChange,
|
||||
insertSlot,
|
||||
onInsertSlotChange,
|
||||
targetItems,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
filteredList,
|
||||
fullList,
|
||||
selectedModuleId,
|
||||
onSelectModuleId,
|
||||
modulePickPreview,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
const handleBackdropMouseDown = (ev) => {
|
||||
if (ev.target !== ev.currentTarget || busy) return
|
||||
onCancel()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="planning-module-apply-modal"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1010,
|
||||
padding: '1rem',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
role="presentation"
|
||||
onMouseDown={handleBackdropMouseDown}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 'clamp(14px, 3vw, 1.75rem)',
|
||||
maxWidth: 'min(560px, 100%)',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
role="dialog"
|
||||
aria-labelledby="module-apply-title"
|
||||
>
|
||||
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
|
||||
Trainingsmodul einfügen
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.87rem', marginBottom: '0.85rem', lineHeight: 1.5 }}>
|
||||
Alle Positionen des gewählten Moduls werden <strong>als neue Zeilen</strong> eingefügt (Kopie, mit klarer
|
||||
Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben — Speichern am Ende
|
||||
wie gewohnt. <strong>Vollständige Textsuche oder Modulkategorien</strong> planen wir serverseitig für
|
||||
eine spätere Iteration; vorerst steht hier eine{' '}
|
||||
<strong>Schnellsuche über Titel und Freitext-Felder</strong> zur Verfügung.
|
||||
</p>
|
||||
|
||||
{err ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{err}</p>
|
||||
) : null}
|
||||
|
||||
{placementLocked ? (
|
||||
<>
|
||||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', lineHeight: 1.5, color: 'var(--text2)' }}>
|
||||
Aktuelle Einfügeposition: Abschnitt <strong>{placementSummary.secTitle}</strong>{' '}
|
||||
<span aria-hidden>/</span> {placementSummary.positionDescription}
|
||||
</p>
|
||||
<details className="tu-module-apply-placement-details">
|
||||
<summary style={{ outline: 'none' }}>Abschnitt oder Position ändern</summary>
|
||||
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={String(sectionIx)}
|
||||
onChange={(e) => onSectionIndexChange(parseInt(e.target.value, 10))}
|
||||
disabled={busy || !sections?.length}
|
||||
>
|
||||
{(sections || []).map((s, i) => (
|
||||
<option key={`sec-opt-u-${i}`} value={String(i)}>
|
||||
{(s.title || `Abschnitt ${i + 1}`).trim()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Position in diesem Abschnitt</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={insertSlot}
|
||||
onChange={(e) => onInsertSlotChange(e.target.value)}
|
||||
disabled={busy || !(sections?.length > 0)}
|
||||
>
|
||||
<option value={`before:${targetItems.length}`}>
|
||||
Am Ende einfügen (nach allen Einträgen)
|
||||
</option>
|
||||
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
|
||||
{targetItems.map((row, xi) => {
|
||||
const labelPart =
|
||||
row.item_type === 'note'
|
||||
? 'Zwischen-Anmerkung'
|
||||
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
|
||||
const clipped =
|
||||
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
|
||||
return (
|
||||
<option key={`before-u-${xi}`} value={`before:${xi}`}>
|
||||
Vor Eintrag {xi + 1}: {clipped}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={String(sectionIx)}
|
||||
onChange={(e) => onSectionIndexChange(parseInt(e.target.value, 10))}
|
||||
disabled={busy || !sections?.length}
|
||||
>
|
||||
{(sections || []).map((s, i) => (
|
||||
<option key={`sec-opt-${i}`} value={String(i)}>
|
||||
{(s.title || `Abschnitt ${i + 1}`).trim()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Position in diesem Abschnitt</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={insertSlot}
|
||||
onChange={(e) => onInsertSlotChange(e.target.value)}
|
||||
disabled={busy || !(sections?.length > 0)}
|
||||
>
|
||||
<option value={`before:${targetItems.length}`}>
|
||||
Am Ende einfügen (nach allen Einträgen)
|
||||
</option>
|
||||
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
|
||||
{targetItems.map((row, xi) => {
|
||||
const labelPart =
|
||||
row.item_type === 'note'
|
||||
? 'Zwischen-Anmerkung'
|
||||
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
|
||||
const clipped =
|
||||
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
|
||||
return (
|
||||
<option key={`before-${xi}`} value={`before:${xi}`}>
|
||||
Vor Eintrag {xi + 1}: {clipped}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-row" style={{ marginTop: placementLocked ? '1rem' : undefined }}>
|
||||
<label className="form-label">Suche Module</label>
|
||||
<input
|
||||
type="search"
|
||||
enterKeyHint="search"
|
||||
className="form-input tu-modulepick-search"
|
||||
placeholder="Freitext: Titel, Kurzbeschreibung, Ziel, Zielgruppe …"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
disabled={busy}
|
||||
aria-label="Module durch Freitext filtern"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: '0.65rem' }}>
|
||||
<label className="form-label" id="module-pick-label">
|
||||
Modulliste
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="tu-modulepick-list"
|
||||
role="listbox"
|
||||
aria-labelledby="module-pick-label"
|
||||
aria-activedescendant={selectedModuleId ? `module-pick-opt-${selectedModuleId}` : undefined}
|
||||
>
|
||||
{!filteredList.length ? (
|
||||
<p style={{ margin: '0.45rem', fontSize: '0.86rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
{!fullList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'}
|
||||
</p>
|
||||
) : (
|
||||
filteredList.map((m) => {
|
||||
const title = ((m.title || '').trim() || `Modul #${m.id}`).trim()
|
||||
const visLbl = trainingVisibilityShortDE(m.visibility)
|
||||
const nPos = typeof m.items_count === 'number' ? m.items_count : '—'
|
||||
const selected = String(m.id) === String(selectedModuleId)
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
id={`module-pick-opt-${m.id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`tu-modulepick-item${selected ? ' tu-modulepick-item--active' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={() => onSelectModuleId(String(m.id))}
|
||||
>
|
||||
<span className="tu-modulepick-item__title">{title}</span>
|
||||
<span className="tu-modulepick-item__meta">
|
||||
{nPos} {typeof nPos === 'number' ? (nPos === 1 ? 'Position' : 'Positionen') : 'Position(en)'}
|
||||
{visLbl ? <> · {visLbl}</> : null}
|
||||
{m.summary ? <> · {(m.summary || '').trim().slice(0, 72)}{(m.summary || '').trim().length > 72 ? '…' : ''}</> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedModuleId ? (
|
||||
<div className="tu-modulepick-preview" aria-live="polite">
|
||||
<div className="tu-modulepick-preview__title">Ablauf-Vorschau (Bibliotheksmodul)</div>
|
||||
{modulePickPreview.loading ? (
|
||||
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
|
||||
Übungen und Hinweise laden …
|
||||
</p>
|
||||
) : modulePickPreview.err ? (
|
||||
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--danger)' }}>
|
||||
{modulePickPreview.err}
|
||||
</p>
|
||||
) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? (
|
||||
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
|
||||
Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben).
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ol className="tu-modulepick-preview__list">
|
||||
{(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => (
|
||||
<li key={`pv-ex-${qi}`}>{t}</li>
|
||||
))}
|
||||
</ol>
|
||||
{modulePickPreview.exercises.length > 12 ? (
|
||||
<p className="tu-modulepick-preview__more">
|
||||
… und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge.
|
||||
</p>
|
||||
) : null}
|
||||
{modulePickPreview.notes > 0 ? (
|
||||
<p className="tu-modulepick-preview__more">
|
||||
zusätzlich {modulePickPreview.notes}{' '}
|
||||
{modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '}
|
||||
(ohne Aufzählung)
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.65rem',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (busy) return
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={onConfirm}>
|
||||
{busy ? 'Einfügen …' : 'Einfügen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: '1rem 0 0', fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Neue Module kannst du unter{' '}
|
||||
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
Trainingsmodule
|
||||
</Link>{' '}
|
||||
anlegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor
|
|||
import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal'
|
||||
import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
|
||||
import {
|
||||
defaultSection,
|
||||
normalizeUnitToForm,
|
||||
|
|
@ -19,7 +20,6 @@ import {
|
|||
insertTrainingModuleIntoPlanningSections,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
import {
|
||||
trainingVisibilityShortDE,
|
||||
addDaysIsoDate,
|
||||
pad2,
|
||||
toIsoLocal,
|
||||
|
|
@ -709,6 +709,13 @@ function TrainingPlanningPage() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const onModuleApplySectionIndexChange = useCallback((newIx) => {
|
||||
setModuleApplySectionIx(newIx)
|
||||
const secsNow = planningFormRef.current?.sections ?? []
|
||||
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
|
||||
setModuleApplyInsertSlot(`before:${len}`)
|
||||
}, [])
|
||||
|
||||
const handleApplyTrainingModuleConfirm = useCallback(async () => {
|
||||
const mid = parseInt(moduleApplyModuleId, 10)
|
||||
if (!Number.isFinite(mid)) {
|
||||
|
|
@ -1987,306 +1994,31 @@ function TrainingPlanningPage() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{moduleApplyOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1010,
|
||||
padding: '1rem',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
role="presentation"
|
||||
onMouseDown={(ev) => {
|
||||
if (ev.target !== ev.currentTarget || moduleApplyBusy) return
|
||||
setModuleApplyOpen(false)
|
||||
setModuleApplyPlacementLocked(false)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: 'clamp(14px, 3vw, 1.75rem)',
|
||||
maxWidth: 'min(560px, 100%)',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
role="dialog"
|
||||
aria-labelledby="module-apply-title"
|
||||
>
|
||||
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
|
||||
Trainingsmodul einfügen
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.87rem', marginBottom: '0.85rem', lineHeight: 1.5 }}>
|
||||
Alle Positionen des gewählten Moduls werden <strong>als neue Zeilen</strong> eingefügt (Kopie, mit klarer
|
||||
Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben — Speichern am Ende
|
||||
wie gewohnt. <strong>Vollständige Textsuche oder Modulkategorien</strong> planen wir serverseitig für
|
||||
eine spätere Iteration; vorerst steht hier eine{' '}
|
||||
<strong>Schnellsuche über Titel und Freitext-Felder</strong> zur Verfügung.
|
||||
</p>
|
||||
|
||||
{moduleApplyErr ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: '0.9rem', marginBottom: '0.75rem' }}>{moduleApplyErr}</p>
|
||||
) : null}
|
||||
|
||||
{moduleApplyPlacementLocked ? (
|
||||
<>
|
||||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', lineHeight: 1.5, color: 'var(--text2)' }}>
|
||||
Aktuelle Einfügeposition: Abschnitt <strong>{modulePlacementSummary.secTitle}</strong>{' '}
|
||||
<span aria-hidden>/</span> {modulePlacementSummary.positionDescription}
|
||||
</p>
|
||||
<details className="tu-module-apply-placement-details">
|
||||
<summary style={{ outline: 'none' }}>Abschnitt oder Position ändern</summary>
|
||||
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={String(moduleApplySectionIx)}
|
||||
onChange={(e) => {
|
||||
const newIx = parseInt(e.target.value, 10)
|
||||
setModuleApplySectionIx(newIx)
|
||||
const secsNow = planningFormRef.current?.sections ?? []
|
||||
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
|
||||
setModuleApplyInsertSlot(`before:${len}`)
|
||||
}}
|
||||
disabled={moduleApplyBusy || !formData.sections?.length}
|
||||
>
|
||||
{(formData.sections || []).map((s, i) => (
|
||||
<option key={`sec-opt-u-${i}`} value={String(i)}>
|
||||
{(s.title || `Abschnitt ${i + 1}`).trim()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Position in diesem Abschnitt</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={moduleApplyInsertSlot}
|
||||
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
|
||||
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
|
||||
>
|
||||
<option value={`before:${moduleApplyTargetItems.length}`}>
|
||||
Am Ende einfügen (nach allen Einträgen)
|
||||
</option>
|
||||
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
|
||||
{moduleApplyTargetItems.map((row, xi) => {
|
||||
const labelPart =
|
||||
row.item_type === 'note'
|
||||
? 'Zwischen-Anmerkung'
|
||||
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
|
||||
const clipped =
|
||||
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
|
||||
return (
|
||||
<option key={`before-u-${xi}`} value={`before:${xi}`}>
|
||||
Vor Eintrag {xi + 1}: {clipped}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Abschnitt (Reihenfolge wie im Editor)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={String(moduleApplySectionIx)}
|
||||
onChange={(e) => {
|
||||
const newIx = parseInt(e.target.value, 10)
|
||||
setModuleApplySectionIx(newIx)
|
||||
const secsNow = planningFormRef.current?.sections ?? []
|
||||
const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0
|
||||
setModuleApplyInsertSlot(`before:${len}`)
|
||||
}}
|
||||
disabled={moduleApplyBusy || !formData.sections?.length}
|
||||
>
|
||||
{(formData.sections || []).map((s, i) => (
|
||||
<option key={`sec-opt-${i}`} value={String(i)}>
|
||||
{(s.title || `Abschnitt ${i + 1}`).trim()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Position in diesem Abschnitt</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={moduleApplyInsertSlot}
|
||||
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
|
||||
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
|
||||
>
|
||||
<option value={`before:${moduleApplyTargetItems.length}`}>
|
||||
Am Ende einfügen (nach allen Einträgen)
|
||||
</option>
|
||||
<option value="before:0">An den Anfang (vor dem ersten Eintrag)</option>
|
||||
{moduleApplyTargetItems.map((row, xi) => {
|
||||
const labelPart =
|
||||
row.item_type === 'note'
|
||||
? 'Zwischen-Anmerkung'
|
||||
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
|
||||
const clipped =
|
||||
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
|
||||
return (
|
||||
<option key={`before-${xi}`} value={`before:${xi}`}>
|
||||
Vor Eintrag {xi + 1}: {clipped}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-row" style={{ marginTop: moduleApplyPlacementLocked ? '1rem' : undefined }}>
|
||||
<label className="form-label">Suche Module</label>
|
||||
<input
|
||||
type="search"
|
||||
enterKeyHint="search"
|
||||
className="form-input tu-modulepick-search"
|
||||
placeholder="Freitext: Titel, Kurzbeschreibung, Ziel, Zielgruppe …"
|
||||
value={moduleApplySearchQuery}
|
||||
onChange={(e) => setModuleApplySearchQuery(e.target.value)}
|
||||
disabled={moduleApplyBusy}
|
||||
aria-label="Module durch Freitext filtern"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: '0.65rem' }}>
|
||||
<label className="form-label" id="module-pick-label">
|
||||
Modulliste
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="tu-modulepick-list"
|
||||
role="listbox"
|
||||
aria-labelledby="module-pick-label"
|
||||
aria-activedescendant={
|
||||
moduleApplyModuleId ? `module-pick-opt-${moduleApplyModuleId}` : undefined
|
||||
}
|
||||
>
|
||||
{!moduleApplyFilteredList.length ? (
|
||||
<p style={{ margin: '0.45rem', fontSize: '0.86rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
{!moduleApplyList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'}
|
||||
</p>
|
||||
) : (
|
||||
moduleApplyFilteredList.map((m) => {
|
||||
const title = ((m.title || '').trim() || `Modul #${m.id}`).trim()
|
||||
const visLbl = trainingVisibilityShortDE(m.visibility)
|
||||
const nPos = typeof m.items_count === 'number' ? m.items_count : '—'
|
||||
const selected = String(m.id) === String(moduleApplyModuleId)
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
id={`module-pick-opt-${m.id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`tu-modulepick-item${selected ? ' tu-modulepick-item--active' : ''}`}
|
||||
disabled={moduleApplyBusy}
|
||||
onClick={() => setModuleApplyModuleId(String(m.id))}
|
||||
>
|
||||
<span className="tu-modulepick-item__title">{title}</span>
|
||||
<span className="tu-modulepick-item__meta">
|
||||
{nPos} {typeof nPos === 'number' ? (nPos === 1 ? 'Position' : 'Positionen') : 'Position(en)'}
|
||||
{visLbl ? <> · {visLbl}</> : null}
|
||||
{m.summary ? <> · {(m.summary || '').trim().slice(0, 72)}{(m.summary || '').trim().length > 72 ? '…' : ''}</> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{moduleApplyModuleId ? (
|
||||
<div className="tu-modulepick-preview" aria-live="polite">
|
||||
<div className="tu-modulepick-preview__title">Ablauf-Vorschau (Bibliotheksmodul)</div>
|
||||
{modulePickPreview.loading ? (
|
||||
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
|
||||
Übungen und Hinweise laden …
|
||||
</p>
|
||||
) : modulePickPreview.err ? (
|
||||
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--danger)' }}>
|
||||
{modulePickPreview.err}
|
||||
</p>
|
||||
) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? (
|
||||
<p style={{ margin: '0.15rem 0 0', fontSize: '0.86rem', color: 'var(--text3)' }}>
|
||||
Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben).
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ol className="tu-modulepick-preview__list">
|
||||
{(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => (
|
||||
<li key={`pv-ex-${qi}`}>{t}</li>
|
||||
))}
|
||||
</ol>
|
||||
{modulePickPreview.exercises.length > 12 ? (
|
||||
<p className="tu-modulepick-preview__more">
|
||||
… und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge.
|
||||
</p>
|
||||
) : null}
|
||||
{modulePickPreview.notes > 0 ? (
|
||||
<p className="tu-modulepick-preview__more">
|
||||
zusätzlich {modulePickPreview.notes}{' '}
|
||||
{modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '}
|
||||
(ohne Aufzählung)
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.65rem',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={moduleApplyBusy}
|
||||
onClick={() => {
|
||||
if (moduleApplyBusy) return
|
||||
setModuleApplyOpen(false)
|
||||
setModuleApplyPlacementLocked(false)
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={moduleApplyBusy} onClick={handleApplyTrainingModuleConfirm}>
|
||||
{moduleApplyBusy ? 'Einfügen …' : 'Einfügen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: '1rem 0 0', fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Neue Module kannst du unter{' '}
|
||||
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||
Trainingsmodule
|
||||
</Link>{' '}
|
||||
anlegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TrainingPlanningModuleApplyModal
|
||||
open={moduleApplyOpen}
|
||||
busy={moduleApplyBusy}
|
||||
err={moduleApplyErr}
|
||||
placementLocked={moduleApplyPlacementLocked}
|
||||
placementSummary={modulePlacementSummary}
|
||||
sections={formData.sections}
|
||||
sectionIx={moduleApplySectionIx}
|
||||
onSectionIndexChange={onModuleApplySectionIndexChange}
|
||||
insertSlot={moduleApplyInsertSlot}
|
||||
onInsertSlotChange={setModuleApplyInsertSlot}
|
||||
targetItems={moduleApplyTargetItems}
|
||||
searchQuery={moduleApplySearchQuery}
|
||||
onSearchQueryChange={setModuleApplySearchQuery}
|
||||
filteredList={moduleApplyFilteredList}
|
||||
fullList={moduleApplyList}
|
||||
selectedModuleId={moduleApplyModuleId}
|
||||
onSelectModuleId={setModuleApplyModuleId}
|
||||
modulePickPreview={modulePickPreview}
|
||||
onConfirm={handleApplyTrainingModuleConfirm}
|
||||
onCancel={() => {
|
||||
setModuleApplyOpen(false)
|
||||
setModuleApplyPlacementLocked(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<TrainingPlanningFrameworkImportModal
|
||||
open={frameworkImportOpen}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user