Enhance exercise selection and display features
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 41s
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 1m18s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 41s
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 1m18s
- Introduced new CSS styles for exercise cards and selection sections, improving visual feedback for selected exercises. - Updated ExerciseListCard to support a new `selectionPinned` prop, allowing for a badge display on selected exercises. - Refactored selection handling in ExercisesListPageRoot to manage selected entries more effectively, replacing the previous Set-based approach. - Enhanced SaveSelectedExercisesAsModuleModal to support appending exercises to existing modules, improving module management capabilities. - Updated session state handling to include selected entries, ensuring persistence across sessions.
This commit is contained in:
parent
ef4dd93324
commit
14b005e9b8
|
|
@ -2949,6 +2949,45 @@ html.modal-scroll-locked .app-main {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.exercise-card--selection-pinned {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
.exercise-card__selection-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.exercises-selection-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.exercises-selection-section__head {
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
.exercises-selection-section__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
.exercises-selection-section__hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text3);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.exercises-list-grid--selection {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
.exercise-card-body-flex {
|
.exercise-card-body-flex {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -2966,6 +3005,10 @@ html.modal-scroll-locked .app-main {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
}
|
}
|
||||||
.exercise-card-title a {
|
.exercise-card-title a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,15 @@ function ExerciseCardScopeStatus({ exercise }) {
|
||||||
/**
|
/**
|
||||||
* Kartenzeile in der Übungsliste (Fokus/Planung — keine Virtualisierung im Grid, dafür content-visibility in app.css).
|
* Kartenzeile in der Übungsliste (Fokus/Planung — keine Virtualisierung im Grid, dafür content-visibility in app.css).
|
||||||
*/
|
*/
|
||||||
export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete, onPeek }) {
|
export default function ExerciseListCard({
|
||||||
|
exercise,
|
||||||
|
user,
|
||||||
|
selectedIds,
|
||||||
|
toggleSelect,
|
||||||
|
onDelete,
|
||||||
|
onPeek,
|
||||||
|
selectionPinned = false,
|
||||||
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const focusNames = exerciseFocusNames(exercise)
|
const focusNames = exerciseFocusNames(exercise)
|
||||||
const styleNames = coerceApiNameList(exercise.style_direction_names)
|
const styleNames = coerceApiNameList(exercise.style_direction_names)
|
||||||
|
|
@ -153,12 +161,19 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={exerciseCardClassName(exercise, user?.id)}>
|
<div
|
||||||
|
className={[
|
||||||
|
exerciseCardClassName(exercise, user?.id),
|
||||||
|
selectionPinned ? 'exercise-card--selection-pinned' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
<div className="exercise-card-layout exercise-card-layout--grow">
|
<div className="exercise-card-layout exercise-card-layout--grow">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.has(Number(exercise.id))}
|
checked={selectedIds.has(Number(exercise.id))}
|
||||||
onChange={() => toggleSelect(exercise.id)}
|
onChange={() => toggleSelect(exercise)}
|
||||||
aria-label={`„${titleText}“ auswählen`}
|
aria-label={`„${titleText}“ auswählen`}
|
||||||
className="exercise-card-layout__check"
|
className="exercise-card-layout__check"
|
||||||
/>
|
/>
|
||||||
|
|
@ -174,6 +189,11 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
|
||||||
<Link to={`/exercises/${exercise.id}`} onClick={(e) => e.stopPropagation()}>
|
<Link to={`/exercises/${exercise.id}`} onClick={(e) => e.stopPropagation()}>
|
||||||
{exercise.title}
|
{exercise.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
{selectionPinned ? (
|
||||||
|
<span className="exercise-card__selection-badge" title="In Modul-Auswahl">
|
||||||
|
Auswahl
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="exercise-card-tags">
|
<div className="exercise-card-tags">
|
||||||
{focusNames.map((name) => (
|
{focusNames.map((name) => (
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ import ExercisePeekModal from '../ExercisePeekModal'
|
||||||
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
||||||
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
||||||
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
||||||
|
import {
|
||||||
|
mergeSelectedWithListEntries,
|
||||||
|
normalizeSelectedEntries,
|
||||||
|
snapshotExerciseForSelection,
|
||||||
|
} from '../../utils/exerciseListSelection'
|
||||||
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
|
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
|
||||||
import {
|
import {
|
||||||
INITIAL_EXERCISE_LIST_FILTERS,
|
INITIAL_EXERCISE_LIST_FILTERS,
|
||||||
|
|
@ -54,7 +59,7 @@ function ExercisesListPageRoot() {
|
||||||
const [pageTab, setPageTab] = useState('list')
|
const [pageTab, setPageTab] = useState('list')
|
||||||
const prefsAppliedRef = useRef(false)
|
const prefsAppliedRef = useRef(false)
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState(() => new Set())
|
const [selectedEntries, setSelectedEntries] = useState(() => [])
|
||||||
const [bulkModalOpen, setBulkModalOpen] = useState(false)
|
const [bulkModalOpen, setBulkModalOpen] = useState(false)
|
||||||
const [bulkVisibility, setBulkVisibility] = useState('')
|
const [bulkVisibility, setBulkVisibility] = useState('')
|
||||||
const [bulkStatus, setBulkStatus] = useState('')
|
const [bulkStatus, setBulkStatus] = useState('')
|
||||||
|
|
@ -83,6 +88,7 @@ function ExercisesListPageRoot() {
|
||||||
setSearchInput(session.searchInput || '')
|
setSearchInput(session.searchInput || '')
|
||||||
setAiSearchInput(session.aiSearchInput || '')
|
setAiSearchInput(session.aiSearchInput || '')
|
||||||
setMineOnly(session.mineOnly)
|
setMineOnly(session.mineOnly)
|
||||||
|
setSelectedEntries(normalizeSelectedEntries(session.selectedEntries))
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sp = new URLSearchParams(window.location.search)
|
const sp = new URLSearchParams(window.location.search)
|
||||||
|
|
@ -100,8 +106,9 @@ function ExercisesListPageRoot() {
|
||||||
searchInput,
|
searchInput,
|
||||||
aiSearchInput,
|
aiSearchInput,
|
||||||
mineOnly,
|
mineOnly,
|
||||||
|
selectedEntries,
|
||||||
})
|
})
|
||||||
}, [filters, searchInput, aiSearchInput, mineOnly])
|
}, [filters, searchInput, aiSearchInput, mineOnly, selectedEntries])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) prefsAppliedRef.current = false
|
if (!user?.id) prefsAppliedRef.current = false
|
||||||
|
|
@ -142,9 +149,20 @@ function ExercisesListPageRoot() {
|
||||||
loadMore,
|
loadMore,
|
||||||
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
|
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedIds = useMemo(
|
||||||
setSelectedIds(new Set())
|
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
|
||||||
}, [queryBase])
|
[selectedEntries]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedExercisesDisplay = useMemo(
|
||||||
|
() => mergeSelectedWithListEntries(selectedEntries, exercises),
|
||||||
|
[selectedEntries, exercises]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filterResultExercises = useMemo(
|
||||||
|
() => exercises.filter((e) => !selectedIds.has(Number(e.id))),
|
||||||
|
[exercises, selectedIds]
|
||||||
|
)
|
||||||
|
|
||||||
const focusOptions = useMemo(
|
const focusOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -237,42 +255,46 @@ function ExercisesListPageRoot() {
|
||||||
? Number(user.active_club_id)
|
? Number(user.active_club_id)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const toggleSelect = useCallback((id) => {
|
const toggleSelect = useCallback((exercise) => {
|
||||||
setSelectedIds((prev) => {
|
const snap = snapshotExerciseForSelection(exercise)
|
||||||
const n = new Set(prev)
|
if (!snap) return
|
||||||
const nid = Number(id)
|
setSelectedEntries((prev) => {
|
||||||
if (Number.isNaN(nid)) return prev
|
const idx = prev.findIndex((e) => Number(e.id) === snap.id)
|
||||||
if (n.has(nid)) n.delete(nid)
|
if (idx >= 0) return prev.filter((_, i) => i !== idx)
|
||||||
else n.add(nid)
|
return [...prev, snap]
|
||||||
return n
|
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
|
const clearSelection = useCallback(() => setSelectedEntries([]), [])
|
||||||
|
|
||||||
const toggleSelectAllPage = useCallback(() => {
|
const toggleSelectAllPage = useCallback(() => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedEntries((prev) => {
|
||||||
const n = new Set(prev)
|
const ids = new Set(prev.map((e) => Number(e.id)))
|
||||||
const allSel =
|
const pageIds = filterResultExercises.map((e) => Number(e.id))
|
||||||
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
|
const allSel = pageIds.length > 0 && pageIds.every((id) => ids.has(id))
|
||||||
if (allSel) {
|
if (allSel) {
|
||||||
exercises.forEach((e) => n.delete(Number(e.id)))
|
const remove = new Set(pageIds)
|
||||||
} else {
|
return prev.filter((e) => !remove.has(Number(e.id)))
|
||||||
exercises.forEach((e) => n.add(Number(e.id)))
|
|
||||||
}
|
}
|
||||||
return n
|
const next = [...prev]
|
||||||
|
for (const ex of filterResultExercises) {
|
||||||
|
const snap = snapshotExerciseForSelection(ex)
|
||||||
|
if (!snap || ids.has(snap.id)) continue
|
||||||
|
ids.add(snap.id)
|
||||||
|
next.push(snap)
|
||||||
|
}
|
||||||
|
return next
|
||||||
})
|
})
|
||||||
}, [exercises])
|
}, [filterResultExercises])
|
||||||
|
|
||||||
const allOnPageSelected = useMemo(
|
const allOnPageSelected = useMemo(
|
||||||
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
|
() =>
|
||||||
[exercises, selectedIds]
|
filterResultExercises.length > 0 &&
|
||||||
|
filterResultExercises.every((e) => selectedIds.has(Number(e.id))),
|
||||||
|
[filterResultExercises, selectedIds]
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedExercisesInListOrder = useMemo(
|
const selectedExercisesInListOrder = selectedExercisesDisplay
|
||||||
() => exercises.filter((e) => selectedIds.has(Number(e.id))),
|
|
||||||
[exercises, selectedIds]
|
|
||||||
)
|
|
||||||
|
|
||||||
const bulkVisibilityOptions = useMemo(() => {
|
const bulkVisibilityOptions = useMemo(() => {
|
||||||
const base = [
|
const base = [
|
||||||
|
|
@ -289,6 +311,7 @@ function ExercisesListPageRoot() {
|
||||||
try {
|
try {
|
||||||
await api.deleteExercise(exercise.id)
|
await api.deleteExercise(exercise.id)
|
||||||
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
|
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
|
||||||
|
setSelectedEntries((prev) => prev.filter((e) => Number(e.id) !== Number(exercise.id)))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Fehler beim Löschen: ' + err.message)
|
alert('Fehler beim Löschen: ' + err.message)
|
||||||
}
|
}
|
||||||
|
|
@ -582,45 +605,81 @@ function ExercisesListPageRoot() {
|
||||||
onClose={() => setPeekExercise(null)}
|
onClose={() => setPeekExercise(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{listFetching && exercises.length === 0 ? (
|
{listFetching && exercises.length === 0 && selectedEntries.length === 0 ? (
|
||||||
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
<p className="muted" style={{ marginTop: '12px' }}>
|
<p className="muted" style={{ marginTop: '12px' }}>
|
||||||
Lade Übungen…
|
Lade Übungen…
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : exercises.length === 0 ? (
|
) : exercises.length === 0 && selectedEntries.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{listFetching ? (
|
{selectedEntries.length > 0 ? (
|
||||||
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
<section className="exercises-selection-section" data-testid="exercises-selection-section">
|
||||||
|
<div className="exercises-selection-section__head">
|
||||||
|
<h2 className="exercises-selection-section__title">Auswahl ({selectedEntries.length})</h2>
|
||||||
|
<p className="exercises-selection-section__hint">
|
||||||
|
Bleibt sichtbar, auch wenn du den Filter wechselst — ideal für die Modul-Zusammenstellung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="exercises-list-grid exercises-list-grid--selection">
|
||||||
|
{selectedExercisesDisplay.map((exercise) => (
|
||||||
|
<ExerciseListCard
|
||||||
|
key={`sel-${exercise.id}`}
|
||||||
|
exercise={exercise}
|
||||||
|
user={user}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
toggleSelect={toggleSelect}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
|
||||||
|
selectionPinned
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="exercises-meta-line">
|
|
||||||
{exercises.length} angezeigt
|
{filterResultExercises.length === 0 ? (
|
||||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
selectedEntries.length > 0 ? (
|
||||||
</p>
|
<p className="exercises-meta-line exercises-meta-line--muted">
|
||||||
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
Keine weiteren Treffer für den aktuellen Filter.
|
||||||
{exercises.map((exercise) => (
|
</p>
|
||||||
<ExerciseListCard
|
) : null
|
||||||
key={exercise.id}
|
) : (
|
||||||
exercise={exercise}
|
<>
|
||||||
user={user}
|
{listFetching ? (
|
||||||
selectedIds={selectedIds}
|
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
||||||
toggleSelect={toggleSelect}
|
) : null}
|
||||||
onDelete={handleDelete}
|
<p className="exercises-meta-line">
|
||||||
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
|
{filterResultExercises.length} Treffer
|
||||||
/>
|
{selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
|
||||||
))}
|
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||||
</div>
|
</p>
|
||||||
{hasMore && (
|
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
||||||
<div className="exercises-load-more">
|
{filterResultExercises.map((exercise) => (
|
||||||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
<ExerciseListCard
|
||||||
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
key={exercise.id}
|
||||||
</button>
|
exercise={exercise}
|
||||||
</div>
|
user={user}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
toggleSelect={toggleSelect}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMore && (
|
||||||
|
<div className="exercises-load-more">
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||||
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import { useToast } from '../../context/ToastContext'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub'
|
import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub'
|
||||||
import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm'
|
import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm'
|
||||||
|
import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Listenreihenfolge).
|
* Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge).
|
||||||
|
* Optional: Positionen an ein bestehendes Modul anfügen.
|
||||||
*/
|
*/
|
||||||
export default function SaveSelectedExercisesAsModuleModal({
|
export default function SaveSelectedExercisesAsModuleModal({
|
||||||
open,
|
open,
|
||||||
|
|
@ -30,6 +32,11 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
const [loadErr, setLoadErr] = useState('')
|
const [loadErr, setLoadErr] = useState('')
|
||||||
/** @type {[Array<object>, Function]} */
|
/** @type {[Array<object>, Function]} */
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
|
const [moduleOptions, setModuleOptions] = useState([])
|
||||||
|
const [modulesLoading, setModulesLoading] = useState(false)
|
||||||
|
|
||||||
|
const [targetMode, setTargetMode] = useState('new')
|
||||||
|
const [existingModuleId, setExistingModuleId] = useState('')
|
||||||
|
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [visibility, setVisibility] = useState('club')
|
const [visibility, setVisibility] = useState('club')
|
||||||
|
|
@ -38,6 +45,9 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
const resetLocal = useCallback(() => {
|
const resetLocal = useCallback(() => {
|
||||||
setLoadErr('')
|
setLoadErr('')
|
||||||
setRows([])
|
setRows([])
|
||||||
|
setModuleOptions([])
|
||||||
|
setTargetMode('new')
|
||||||
|
setExistingModuleId('')
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setVisibility('club')
|
setVisibility('club')
|
||||||
setClubId('')
|
setClubId('')
|
||||||
|
|
@ -92,6 +102,27 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
}
|
}
|
||||||
}, [open, selectedExercises, user, memberClubs.length, resetLocal])
|
}, [open, selectedExercises, user, memberClubs.length, resetLocal])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
let cancelled = false
|
||||||
|
setModulesLoading(true)
|
||||||
|
api
|
||||||
|
.listTrainingModules()
|
||||||
|
.then((list) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setModuleOptions(Array.isArray(list) ? list : [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setModuleOptions([])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setModulesLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
const updateRow = (idx, patch) => {
|
const updateRow = (idx, patch) => {
|
||||||
setRows((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
setRows((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
||||||
}
|
}
|
||||||
|
|
@ -100,47 +131,56 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (submitting || !rows.length) return
|
if (submitting || !rows.length) return
|
||||||
|
|
||||||
const tit = (title || '').trim()
|
const newItemsPayload = buildRowsPayload(rows)
|
||||||
if (!tit) {
|
if (!newItemsPayload.length) {
|
||||||
toast.error('Bitte einen Modultitel angeben.')
|
toast.error('Keine gültigen Übungspositionen.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsPayload = rows.map((row, oi) => ({
|
|
||||||
item_type: 'exercise',
|
|
||||||
order_index: oi,
|
|
||||||
exercise_id: row.exercise_id,
|
|
||||||
exercise_variant_id:
|
|
||||||
row.exercise_kind === 'combination' ||
|
|
||||||
row.exercise_variant_id === '' ||
|
|
||||||
row.exercise_variant_id == null
|
|
||||||
? null
|
|
||||||
: Number(row.exercise_variant_id),
|
|
||||||
planned_duration_min:
|
|
||||||
row.planned_duration_min !== '' && row.planned_duration_min != null
|
|
||||||
? Number(row.planned_duration_min)
|
|
||||||
: null,
|
|
||||||
notes: row.notes != null && String(row.notes).trim() ? String(row.notes).trim() : null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null
|
|
||||||
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
|
|
||||||
const fallback = getDefaultClubIdForGovernanceForms(user)
|
|
||||||
if (Number.isFinite(fallback) && fallback > 0) cid = fallback
|
|
||||||
}
|
|
||||||
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
|
|
||||||
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (visibility !== 'club') cid = null
|
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
|
if (targetMode === 'append') {
|
||||||
|
const mid = parseInt(existingModuleId, 10)
|
||||||
|
if (!Number.isFinite(mid) || mid < 1) {
|
||||||
|
toast.error('Bitte ein bestehendes Modul wählen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existing = await api.getTrainingModule(mid)
|
||||||
|
const existingItems = Array.isArray(existing?.items) ? existing.items : []
|
||||||
|
const merged = [
|
||||||
|
...existingItems.map((row, idx) => moduleItemToPayload(row, idx)).filter(Boolean),
|
||||||
|
...newItemsPayload.map((row, idx) => ({ ...row, order_index: existingItems.length + idx })),
|
||||||
|
]
|
||||||
|
await api.updateTrainingModule(mid, { items: merged })
|
||||||
|
toast.success(`${newItemsPayload.length} Übung(en) an Modul angefügt.`)
|
||||||
|
navigate(`/planning/training-modules/${mid}`)
|
||||||
|
onSuccess?.()
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tit = (title || '').trim()
|
||||||
|
if (!tit) {
|
||||||
|
toast.error('Bitte einen Modultitel angeben.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null
|
||||||
|
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
|
||||||
|
const fallback = getDefaultClubIdForGovernanceForms(user)
|
||||||
|
if (Number.isFinite(fallback) && fallback > 0) cid = fallback
|
||||||
|
}
|
||||||
|
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
|
||||||
|
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (visibility !== 'club') cid = null
|
||||||
|
|
||||||
const created = await api.createTrainingModule({
|
const created = await api.createTrainingModule({
|
||||||
title: tit,
|
title: tit,
|
||||||
visibility,
|
visibility,
|
||||||
club_id: cid,
|
club_id: cid,
|
||||||
items: itemsPayload,
|
items: newItemsPayload,
|
||||||
})
|
})
|
||||||
toast.success('Trainingsmodul gespeichert.')
|
toast.success('Trainingsmodul gespeichert.')
|
||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
|
|
@ -157,13 +197,15 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
|
const saveLabel = targetMode === 'append' ? 'An Modul anfügen' : 'Modul anlegen'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModalOverlay open={open} raised onBackdropClick={onClose}>
|
<FormModalOverlay open={open} raised onBackdropClick={onClose}>
|
||||||
<div className="card modal-panel--form modal-panel--narrow">
|
<div className="card modal-panel--form modal-panel--narrow">
|
||||||
<h2 className="modal-panel__title">Auswahl als Trainingsmodul</h2>
|
<h2 className="modal-panel__title">Auswahl als Trainingsmodul</h2>
|
||||||
<p className="modal-panel__intro">
|
<p className="modal-panel__intro">
|
||||||
Die gewählten Übungen werden in der <strong>Reihenfolge der Liste</strong> als Modulpositionen
|
Die gewählten Übungen werden in der <strong>Reihenfolge der Auswahl</strong> übernommen. Pro Übung kann
|
||||||
übernommen. Pro Übung kann optional eine Variante gesetzt werden.
|
optional eine Variante gesetzt werden.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -175,17 +217,95 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
) : (
|
) : (
|
||||||
<form id="save-selected-module-form" className="modal-form-shell" onSubmit={handleSubmit}>
|
<form id="save-selected-module-form" className="modal-form-shell" onSubmit={handleSubmit}>
|
||||||
<div className="modal-form-shell__body">
|
<div className="modal-form-shell__body">
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="form-row" style={{ marginBottom: '1rem' }}>
|
||||||
<label className="form-label">Modultitel</label>
|
<label className="form-label">Ziel</label>
|
||||||
<input
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={title}
|
value={targetMode}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTargetMode(e.target.value)}
|
||||||
required
|
>
|
||||||
placeholder="z. B. Technikblock Grundlagen"
|
<option value="new">Neues Modul anlegen</option>
|
||||||
/>
|
<option value="append">Bestehendes Modul erweitern</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{targetMode === 'append' ? (
|
||||||
|
<div className="form-row" style={{ marginBottom: '1rem' }}>
|
||||||
|
<label className="form-label">Bestehendes Modul</label>
|
||||||
|
{modulesLoading ? (
|
||||||
|
<p style={{ color: 'var(--text2)', margin: 0 }}>Module laden …</p>
|
||||||
|
) : moduleOptions.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text2)', margin: 0 }}>
|
||||||
|
Keine bearbeitbaren Module gefunden. Lege zuerst ein neues Modul an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={existingModuleId}
|
||||||
|
onChange={(e) => setExistingModuleId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">— Modul wählen —</option>
|
||||||
|
{moduleOptions.map((m) => (
|
||||||
|
<option key={m.id} value={String(m.id)}>
|
||||||
|
{(m.title || '').trim() || `Modul #${m.id}`}
|
||||||
|
{m.items_count != null ? ` (${m.items_count} Pos.)` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<p style={{ margin: '6px 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
|
Die neuen Übungen werden <strong>ans Ende</strong> des gewählten Moduls angefügt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label className="form-label">Modultitel</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="z. B. Technikblock Grundlagen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
|
||||||
|
<label className="form-label">Sichtbarkeit</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setVisibility(v)
|
||||||
|
if (v === 'club' && !clubId) {
|
||||||
|
const fallback = getDefaultClubIdForGovernanceForms(user)
|
||||||
|
if (fallback != null) setClubId(String(fallback))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="private">Privat</option>
|
||||||
|
<option value="club">Verein</option>
|
||||||
|
{isSuperadmin ? <option value="official">Offiziell</option> : null}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{visibility === 'club' ? (
|
||||||
|
<div className="form-row" style={{ marginBottom: '1rem' }}>
|
||||||
|
<label className="form-label">Verein</label>
|
||||||
|
<select className="form-input" value={clubId} onChange={(e) => setClubId(e.target.value)}>
|
||||||
|
<option value="">— Verein wählen —</option>
|
||||||
|
{memberClubs.map((cl) => (
|
||||||
|
<option key={cl.id} value={String(cl.id)}>
|
||||||
|
{cl.name || `Verein #${cl.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="card"
|
className="card"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -249,39 +369,6 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row" style={{ marginBottom: '0.85rem' }}>
|
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={visibility}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value
|
|
||||||
setVisibility(v)
|
|
||||||
if (v === 'club' && !clubId) {
|
|
||||||
const fallback = getDefaultClubIdForGovernanceForms(user)
|
|
||||||
if (fallback != null) setClubId(String(fallback))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="private">Privat</option>
|
|
||||||
<option value="club">Verein</option>
|
|
||||||
{isSuperadmin ? <option value="official">Offiziell</option> : null}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{visibility === 'club' ? (
|
|
||||||
<div className="form-row" style={{ marginBottom: '1rem' }}>
|
|
||||||
<label className="form-label">Verein</label>
|
|
||||||
<select className="form-input" value={clubId} onChange={(e) => setClubId(e.target.value)}>
|
|
||||||
<option value="">— Verein wählen —</option>
|
|
||||||
{memberClubs.map((cl) => (
|
|
||||||
<option key={cl.id} value={String(cl.id)}>
|
|
||||||
{cl.name || `Verein #${cl.id}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormActionBar
|
<FormActionBar
|
||||||
|
|
@ -290,8 +377,8 @@ export default function SaveSelectedExercisesAsModuleModal({
|
||||||
formId="save-selected-module-form"
|
formId="save-selected-module-form"
|
||||||
saving={submitting}
|
saving={submitting}
|
||||||
showSave={false}
|
showSave={false}
|
||||||
saveAndCloseLabel="Modul anlegen"
|
saveAndCloseLabel={saveLabel}
|
||||||
saveAndCloseShortLabel="Anlegen"
|
saveAndCloseShortLabel={targetMode === 'append' ? 'Anfügen' : 'Anlegen'}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
90
frontend/src/utils/exerciseListSelection.js
Normal file
90
frontend/src/utils/exerciseListSelection.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/** Minimaler Snapshot einer Übung für die modulübergreifende Auswahl (filterunabhängig). */
|
||||||
|
export function snapshotExerciseForSelection(exercise) {
|
||||||
|
if (!exercise || exercise.id == null) return null
|
||||||
|
const id = Number(exercise.id)
|
||||||
|
if (!Number.isFinite(id) || id < 1) return null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: exercise.title || '',
|
||||||
|
summary: exercise.summary || '',
|
||||||
|
visibility: exercise.visibility,
|
||||||
|
status: exercise.status,
|
||||||
|
exercise_kind: exercise.exercise_kind,
|
||||||
|
created_by: exercise.created_by,
|
||||||
|
focus_area: exercise.focus_area,
|
||||||
|
focus_area_names: exercise.focus_area_names,
|
||||||
|
style_direction_names: exercise.style_direction_names,
|
||||||
|
training_type_names: exercise.training_type_names,
|
||||||
|
media_count: exercise.media_count,
|
||||||
|
variant_count: exercise.variant_count,
|
||||||
|
media: Array.isArray(exercise.media) ? exercise.media : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSelectedEntries(raw) {
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const item of raw) {
|
||||||
|
const snap = snapshotExerciseForSelection(item)
|
||||||
|
if (!snap || seen.has(snap.id)) continue
|
||||||
|
seen.add(snap.id)
|
||||||
|
out.push(snap)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSelectedWithListEntries(selectedEntries, exercises) {
|
||||||
|
const byId = new Map()
|
||||||
|
for (const e of exercises || []) {
|
||||||
|
const id = Number(e?.id)
|
||||||
|
if (Number.isFinite(id) && id > 0) byId.set(id, e)
|
||||||
|
}
|
||||||
|
return (selectedEntries || []).map((entry) => byId.get(Number(entry.id)) || entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleItemToPayload(row, orderIndex) {
|
||||||
|
if ((row?.item_type || 'exercise') === 'note') {
|
||||||
|
return {
|
||||||
|
item_type: 'note',
|
||||||
|
order_index: orderIndex,
|
||||||
|
note_body: row.note_body ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const eid = Number(row.exercise_id)
|
||||||
|
if (!Number.isFinite(eid) || eid < 1) return null
|
||||||
|
const vidRaw = row.exercise_variant_id
|
||||||
|
const vid =
|
||||||
|
vidRaw === '' || vidRaw == null || row.exercise_kind === 'combination'
|
||||||
|
? null
|
||||||
|
: Number(vidRaw)
|
||||||
|
return {
|
||||||
|
item_type: 'exercise',
|
||||||
|
order_index: orderIndex,
|
||||||
|
exercise_id: eid,
|
||||||
|
exercise_variant_id: Number.isFinite(vid) && vid > 0 ? vid : null,
|
||||||
|
planned_duration_min:
|
||||||
|
row.planned_duration_min !== '' && row.planned_duration_min != null
|
||||||
|
? Number(row.planned_duration_min)
|
||||||
|
: null,
|
||||||
|
notes: row.notes != null && String(row.notes).trim() ? String(row.notes).trim() : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRowsPayload(rows) {
|
||||||
|
return rows
|
||||||
|
.map((row, idx) =>
|
||||||
|
moduleItemToPayload(
|
||||||
|
{
|
||||||
|
...row,
|
||||||
|
exercise_id: row.exercise_id,
|
||||||
|
exercise_variant_id: row.exercise_variant_id,
|
||||||
|
planned_duration_min: row.planned_duration_min,
|
||||||
|
notes: row.notes,
|
||||||
|
exercise_kind: row.exercise_kind,
|
||||||
|
},
|
||||||
|
idx
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi } from '../constants/exerciseListFilters'
|
import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi } from '../constants/exerciseListFilters'
|
||||||
|
import { normalizeSelectedEntries } from './exerciseListSelection'
|
||||||
|
|
||||||
const STORAGE_KEY = 'shinkan.exerciseList.session.v1'
|
const STORAGE_KEY = 'shinkan.exerciseList.session.v1'
|
||||||
|
|
||||||
|
|
@ -11,7 +12,7 @@ function safeParse(raw) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {{ filters: object, searchInput: string, aiSearchInput: string, mineOnly: boolean } | null} */
|
/** @returns {{ filters: object, searchInput: string, aiSearchInput: string, mineOnly: boolean, selectedEntries: object[] } | null} */
|
||||||
export function readExerciseListSessionState() {
|
export function readExerciseListSessionState() {
|
||||||
if (typeof sessionStorage === 'undefined') return null
|
if (typeof sessionStorage === 'undefined') return null
|
||||||
const parsed = safeParse(sessionStorage.getItem(STORAGE_KEY))
|
const parsed = safeParse(sessionStorage.getItem(STORAGE_KEY))
|
||||||
|
|
@ -27,6 +28,7 @@ export function readExerciseListSessionState() {
|
||||||
searchInput: typeof parsed.searchInput === 'string' ? parsed.searchInput : '',
|
searchInput: typeof parsed.searchInput === 'string' ? parsed.searchInput : '',
|
||||||
aiSearchInput: typeof parsed.aiSearchInput === 'string' ? parsed.aiSearchInput : '',
|
aiSearchInput: typeof parsed.aiSearchInput === 'string' ? parsed.aiSearchInput : '',
|
||||||
mineOnly: !!parsed.mineOnly,
|
mineOnly: !!parsed.mineOnly,
|
||||||
|
selectedEntries: normalizeSelectedEntries(parsed.selectedEntries),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +42,7 @@ export function writeExerciseListSessionState(state) {
|
||||||
searchInput: typeof state.searchInput === 'string' ? state.searchInput : '',
|
searchInput: typeof state.searchInput === 'string' ? state.searchInput : '',
|
||||||
aiSearchInput: typeof state.aiSearchInput === 'string' ? state.aiSearchInput : '',
|
aiSearchInput: typeof state.aiSearchInput === 'string' ? state.aiSearchInput : '',
|
||||||
mineOnly: !!state.mineOnly,
|
mineOnly: !!state.mineOnly,
|
||||||
|
selectedEntries: normalizeSelectedEntries(state.selectedEntries),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user