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
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.126"
|
APP_VERSION = "0.8.128"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260514062"
|
DB_SCHEMA_VERSION = "20260514062"
|
||||||
|
|
||||||
|
|
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.126",
|
||||||
"date": "2026-05-13",
|
"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 TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal'
|
import TrainingPlanningFrameworkImportModal from '../components/planning/TrainingPlanningFrameworkImportModal'
|
||||||
|
import TrainingPlanningModuleApplyModal from '../components/planning/TrainingPlanningModuleApplyModal'
|
||||||
import {
|
import {
|
||||||
defaultSection,
|
defaultSection,
|
||||||
normalizeUnitToForm,
|
normalizeUnitToForm,
|
||||||
|
|
@ -19,7 +20,6 @@ import {
|
||||||
insertTrainingModuleIntoPlanningSections,
|
insertTrainingModuleIntoPlanningSections,
|
||||||
} from '../utils/trainingUnitSectionsForm'
|
} from '../utils/trainingUnitSectionsForm'
|
||||||
import {
|
import {
|
||||||
trainingVisibilityShortDE,
|
|
||||||
addDaysIsoDate,
|
addDaysIsoDate,
|
||||||
pad2,
|
pad2,
|
||||||
toIsoLocal,
|
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 handleApplyTrainingModuleConfirm = useCallback(async () => {
|
||||||
const mid = parseInt(moduleApplyModuleId, 10)
|
const mid = parseInt(moduleApplyModuleId, 10)
|
||||||
if (!Number.isFinite(mid)) {
|
if (!Number.isFinite(mid)) {
|
||||||
|
|
@ -1987,306 +1994,31 @@ function TrainingPlanningPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{moduleApplyOpen && (
|
<TrainingPlanningModuleApplyModal
|
||||||
<div
|
open={moduleApplyOpen}
|
||||||
style={{
|
busy={moduleApplyBusy}
|
||||||
position: 'fixed',
|
err={moduleApplyErr}
|
||||||
top: 0,
|
placementLocked={moduleApplyPlacementLocked}
|
||||||
left: 0,
|
placementSummary={modulePlacementSummary}
|
||||||
right: 0,
|
sections={formData.sections}
|
||||||
bottom: 0,
|
sectionIx={moduleApplySectionIx}
|
||||||
background: 'rgba(0,0,0,0.5)',
|
onSectionIndexChange={onModuleApplySectionIndexChange}
|
||||||
display: 'flex',
|
insertSlot={moduleApplyInsertSlot}
|
||||||
alignItems: 'center',
|
onInsertSlotChange={setModuleApplyInsertSlot}
|
||||||
justifyContent: 'center',
|
targetItems={moduleApplyTargetItems}
|
||||||
zIndex: 1010,
|
searchQuery={moduleApplySearchQuery}
|
||||||
padding: '1rem',
|
onSearchQueryChange={setModuleApplySearchQuery}
|
||||||
overflowY: 'auto',
|
filteredList={moduleApplyFilteredList}
|
||||||
}}
|
fullList={moduleApplyList}
|
||||||
role="presentation"
|
selectedModuleId={moduleApplyModuleId}
|
||||||
onMouseDown={(ev) => {
|
onSelectModuleId={setModuleApplyModuleId}
|
||||||
if (ev.target !== ev.currentTarget || moduleApplyBusy) return
|
modulePickPreview={modulePickPreview}
|
||||||
|
onConfirm={handleApplyTrainingModuleConfirm}
|
||||||
|
onCancel={() => {
|
||||||
setModuleApplyOpen(false)
|
setModuleApplyOpen(false)
|
||||||
setModuleApplyPlacementLocked(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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TrainingPlanningFrameworkImportModal
|
<TrainingPlanningFrameworkImportModal
|
||||||
open={frameworkImportOpen}
|
open={frameworkImportOpen}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user