UX Verbesserung, Navigationsspeicherung #40
|
|
@ -2953,6 +2953,14 @@ html.modal-scroll-locked .app-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.exercise-card__body--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.exercise-card__body--clickable:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
.exercise-card-title {
|
.exercise-card-title {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export default function ExerciseListBulkToolbar({
|
||||||
bulkMaxIds,
|
bulkMaxIds,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
onOpenBulkModal,
|
onOpenBulkModal,
|
||||||
|
onOpenSaveModuleModal,
|
||||||
}) {
|
}) {
|
||||||
if (selectedCount < 1) return null
|
if (selectedCount < 1) return null
|
||||||
|
|
||||||
|
|
@ -14,6 +15,9 @@ export default function ExerciseListBulkToolbar({
|
||||||
<button type="button" className="btn btn-secondary btn-small" onClick={onClearSelection}>
|
<button type="button" className="btn btn-secondary btn-small" onClick={onClearSelection}>
|
||||||
Auswahl aufheben
|
Auswahl aufheben
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary btn-small" onClick={onOpenSaveModuleModal}>
|
||||||
|
Als Modul speichern…
|
||||||
|
</button>
|
||||||
<button type="button" className="btn btn-primary btn-small" onClick={onOpenBulkModal}>
|
<button type="button" className="btn btn-primary btn-small" onClick={onOpenBulkModal}>
|
||||||
Massenänderung…
|
Massenänderung…
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
|
@ -131,10 +131,27 @@ 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 }) {
|
export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete, onPeek }) {
|
||||||
|
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)
|
||||||
const typeNames = coerceApiNameList(exercise.training_type_names)
|
const typeNames = coerceApiNameList(exercise.training_type_names)
|
||||||
|
const titleText = (exercise.title || 'Übung').replace(/"/g, '')
|
||||||
|
|
||||||
|
const openExercisePage = () => navigate(`/exercises/${exercise.id}`)
|
||||||
|
|
||||||
|
const handleBodyClick = (e) => {
|
||||||
|
if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return
|
||||||
|
openExercisePage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBodyKeyDown = (e) => {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||||
|
if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return
|
||||||
|
e.preventDefault()
|
||||||
|
openExercisePage()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={exerciseCardClassName(exercise, user?.id)}>
|
<div className={exerciseCardClassName(exercise, user?.id)}>
|
||||||
<div className="exercise-card-layout exercise-card-layout--grow">
|
<div className="exercise-card-layout exercise-card-layout--grow">
|
||||||
|
|
@ -142,12 +159,21 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.has(Number(exercise.id))}
|
checked={selectedIds.has(Number(exercise.id))}
|
||||||
onChange={() => toggleSelect(exercise.id)}
|
onChange={() => toggleSelect(exercise.id)}
|
||||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
aria-label={`„${titleText}“ auswählen`}
|
||||||
className="exercise-card-layout__check"
|
className="exercise-card-layout__check"
|
||||||
/>
|
/>
|
||||||
<div className="exercise-card__body exercise-card-body-flex">
|
<div
|
||||||
|
className="exercise-card__body exercise-card-body-flex exercise-card__body--clickable"
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleBodyClick}
|
||||||
|
onKeyDown={handleBodyKeyDown}
|
||||||
|
aria-label={`„${titleText}“ öffnen`}
|
||||||
|
>
|
||||||
<h3 className="exercise-card-title">
|
<h3 className="exercise-card-title">
|
||||||
<Link to={`/exercises/${exercise.id}`}>{exercise.title}</Link>
|
<Link to={`/exercises/${exercise.id}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{exercise.title}
|
||||||
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="exercise-card-tags">
|
<div className="exercise-card-tags">
|
||||||
{focusNames.map((name) => (
|
{focusNames.map((name) => (
|
||||||
|
|
@ -191,14 +217,15 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
|
||||||
<ExerciseCardContentStats exercise={exercise} />
|
<ExerciseCardContentStats exercise={exercise} />
|
||||||
</div>
|
</div>
|
||||||
<div className="exercise-card__actions exercise-card__actions--icons">
|
<div className="exercise-card__actions exercise-card__actions--icons">
|
||||||
<Link
|
<button
|
||||||
to={`/exercises/${exercise.id}`}
|
type="button"
|
||||||
className="exercise-card__icon-btn"
|
className="exercise-card__icon-btn"
|
||||||
title="Ansehen"
|
title="Vorschau"
|
||||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
|
aria-label={`„${titleText}“ in Vorschau anzeigen`}
|
||||||
|
onClick={() => onPeek?.(exercise)}
|
||||||
>
|
>
|
||||||
<Eye size={18} strokeWidth={2} aria-hidden />
|
<Eye size={18} strokeWidth={2} aria-hidden />
|
||||||
</Link>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to={`/exercises/${exercise.id}/edit`}
|
to={`/exercises/${exercise.id}/edit`}
|
||||||
className="exercise-card__icon-btn"
|
className="exercise-card__icon-btn"
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ import ExerciseListFilterModal from './ExerciseListFilterModal'
|
||||||
import ExerciseListBulkModal from './ExerciseListBulkModal'
|
import ExerciseListBulkModal from './ExerciseListBulkModal'
|
||||||
import ExerciseListSearchBar from './ExerciseListSearchBar'
|
import ExerciseListSearchBar from './ExerciseListSearchBar'
|
||||||
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
||||||
|
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
||||||
|
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 { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
|
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
|
||||||
import {
|
import {
|
||||||
INITIAL_EXERCISE_LIST_FILTERS,
|
INITIAL_EXERCISE_LIST_FILTERS,
|
||||||
|
|
@ -66,12 +69,21 @@ function ExercisesListPageRoot() {
|
||||||
const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
|
const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
|
||||||
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
|
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
|
||||||
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
|
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
|
||||||
|
const [peekExercise, setPeekExercise] = useState(null)
|
||||||
|
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return
|
if (!user?.id) return
|
||||||
if (prefsAppliedRef.current) return
|
if (prefsAppliedRef.current) return
|
||||||
|
const session = readExerciseListSessionState()
|
||||||
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
|
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
|
||||||
setFilters(applyDashboardExerciseListUrl(merged))
|
const filtersFromSession = session?.filters
|
||||||
|
setFilters(applyDashboardExerciseListUrl(filtersFromSession ?? merged))
|
||||||
|
if (session) {
|
||||||
|
setSearchInput(session.searchInput || '')
|
||||||
|
setAiSearchInput(session.aiSearchInput || '')
|
||||||
|
setMineOnly(session.mineOnly)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const sp = new URLSearchParams(window.location.search)
|
const sp = new URLSearchParams(window.location.search)
|
||||||
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
|
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
|
||||||
|
|
@ -81,6 +93,16 @@ function ExercisesListPageRoot() {
|
||||||
prefsAppliedRef.current = true
|
prefsAppliedRef.current = true
|
||||||
}, [user?.id, user?.exercise_list_prefs])
|
}, [user?.id, user?.exercise_list_prefs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prefsAppliedRef.current) return
|
||||||
|
writeExerciseListSessionState({
|
||||||
|
filters,
|
||||||
|
searchInput,
|
||||||
|
aiSearchInput,
|
||||||
|
mineOnly,
|
||||||
|
})
|
||||||
|
}, [filters, searchInput, aiSearchInput, mineOnly])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) prefsAppliedRef.current = false
|
if (!user?.id) prefsAppliedRef.current = false
|
||||||
}, [user?.id])
|
}, [user?.id])
|
||||||
|
|
@ -247,6 +269,11 @@ function ExercisesListPageRoot() {
|
||||||
[exercises, selectedIds]
|
[exercises, selectedIds]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedExercisesInListOrder = useMemo(
|
||||||
|
() => exercises.filter((e) => selectedIds.has(Number(e.id))),
|
||||||
|
[exercises, selectedIds]
|
||||||
|
)
|
||||||
|
|
||||||
const bulkVisibilityOptions = useMemo(() => {
|
const bulkVisibilityOptions = useMemo(() => {
|
||||||
const base = [
|
const base = [
|
||||||
{ id: '', label: '— nicht ändern —' },
|
{ id: '', label: '— nicht ändern —' },
|
||||||
|
|
@ -480,6 +507,7 @@ function ExercisesListPageRoot() {
|
||||||
bulkMaxIds={BULK_MAX_IDS}
|
bulkMaxIds={BULK_MAX_IDS}
|
||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
onOpenBulkModal={openBulkModal}
|
onOpenBulkModal={openBulkModal}
|
||||||
|
onOpenSaveModuleModal={() => setSaveModuleModalOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ExerciseListFilterModal
|
<ExerciseListFilterModal
|
||||||
|
|
@ -540,6 +568,20 @@ function ExercisesListPageRoot() {
|
||||||
setBulkTargetGroupIds={setBulkTargetGroupIds}
|
setBulkTargetGroupIds={setBulkTargetGroupIds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SaveSelectedExercisesAsModuleModal
|
||||||
|
open={saveModuleModalOpen}
|
||||||
|
onClose={() => setSaveModuleModalOpen(false)}
|
||||||
|
selectedExercises={selectedExercisesInListOrder}
|
||||||
|
onSuccess={clearSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExercisePeekModal
|
||||||
|
open={peekExercise != null}
|
||||||
|
exerciseId={peekExercise?.id}
|
||||||
|
titleFallback={peekExercise?.title}
|
||||||
|
onClose={() => setPeekExercise(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{listFetching && exercises.length === 0 ? (
|
{listFetching && exercises.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" />
|
||||||
|
|
@ -569,6 +611,7 @@ function ExercisesListPageRoot() {
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
toggleSelect={toggleSelect}
|
toggleSelect={toggleSelect}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import FormActionBar from '../FormActionBar'
|
||||||
|
import FormModalOverlay from '../FormModalOverlay'
|
||||||
|
import api from '../../utils/api'
|
||||||
|
import { useToast } from '../../context/ToastContext'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub'
|
||||||
|
import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Listenreihenfolge).
|
||||||
|
*/
|
||||||
|
export default function SaveSelectedExercisesAsModuleModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
/** @type {Array<{ id: number, title?: string }>} */
|
||||||
|
selectedExercises,
|
||||||
|
onSuccess,
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
|
||||||
|
const roleLc = String(user?.role || '').toLowerCase()
|
||||||
|
const isSuperadmin = roleLc === 'superadmin'
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [loadErr, setLoadErr] = useState('')
|
||||||
|
/** @type {[Array<object>, Function]} */
|
||||||
|
const [rows, setRows] = useState([])
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [visibility, setVisibility] = useState('club')
|
||||||
|
const [clubId, setClubId] = useState('')
|
||||||
|
|
||||||
|
const resetLocal = useCallback(() => {
|
||||||
|
setLoadErr('')
|
||||||
|
setRows([])
|
||||||
|
setTitle('')
|
||||||
|
setVisibility('club')
|
||||||
|
setClubId('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
resetLocal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const defaultClub = getDefaultClubIdForGovernanceForms(user)
|
||||||
|
if (defaultClub != null) setClubId(String(defaultClub))
|
||||||
|
else if (memberClubs.length === 1) setClubId(String(memberClubs[0].id))
|
||||||
|
|
||||||
|
const count = selectedExercises?.length || 0
|
||||||
|
setTitle(count > 0 ? `Modul · ${count} Übung${count === 1 ? '' : 'en'}` : '')
|
||||||
|
|
||||||
|
if (!count) {
|
||||||
|
setRows([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setLoadErr('')
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const hydrated = []
|
||||||
|
for (const ex of selectedExercises) {
|
||||||
|
const row = await hydrateExercisePlanningRow({ id: ex.id, title: ex.title })
|
||||||
|
if (row) hydrated.push(row)
|
||||||
|
}
|
||||||
|
if (cancelled) return
|
||||||
|
if (!hydrated.length) {
|
||||||
|
setLoadErr('Ausgewählte Übungen konnten nicht geladen werden.')
|
||||||
|
setRows([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRows(hydrated)
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadErr(e.message || 'Übungen konnten nicht geladen werden')
|
||||||
|
setRows([])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [open, selectedExercises, user, memberClubs.length, resetLocal])
|
||||||
|
|
||||||
|
const updateRow = (idx, patch) => {
|
||||||
|
setRows((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (submitting || !rows.length) return
|
||||||
|
|
||||||
|
const tit = (title || '').trim()
|
||||||
|
if (!tit) {
|
||||||
|
toast.error('Bitte einen Modultitel angeben.')
|
||||||
|
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)
|
||||||
|
try {
|
||||||
|
const created = await api.createTrainingModule({
|
||||||
|
title: tit,
|
||||||
|
visibility,
|
||||||
|
club_id: cid,
|
||||||
|
items: itemsPayload,
|
||||||
|
})
|
||||||
|
toast.success('Trainingsmodul gespeichert.')
|
||||||
|
if (created?.id) {
|
||||||
|
navigate(`/planning/training-modules/${created.id}`)
|
||||||
|
}
|
||||||
|
onSuccess?.()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || 'Speichern fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModalOverlay open={open} raised onBackdropClick={onClose}>
|
||||||
|
<div className="card modal-panel--form modal-panel--narrow">
|
||||||
|
<h2 className="modal-panel__title">Auswahl als Trainingsmodul</h2>
|
||||||
|
<p className="modal-panel__intro">
|
||||||
|
Die gewählten Übungen werden in der <strong>Reihenfolge der Liste</strong> als Modulpositionen
|
||||||
|
übernommen. Pro Übung kann optional eine Variante gesetzt werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: 'var(--text2)' }}>Laden …</p>
|
||||||
|
) : loadErr ? (
|
||||||
|
<p style={{ color: 'var(--danger)' }}>{loadErr}</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text2)' }}>Keine Übungen ausgewählt.</p>
|
||||||
|
) : (
|
||||||
|
<form id="save-selected-module-form" className="modal-form-shell" onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-form-shell__body">
|
||||||
|
<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="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
padding: '10px 12px',
|
||||||
|
maxHeight: 'min(320px, 45vh)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||||
|
{rows.map((row, idx) => {
|
||||||
|
const isCombo = row.exercise_kind === 'combination'
|
||||||
|
const variants = Array.isArray(row.variants) ? row.variants : []
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${row.exercise_id}-${idx}`}
|
||||||
|
style={{
|
||||||
|
padding: '10px 0',
|
||||||
|
borderTop: idx === 0 ? 'none' : '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text1)', fontSize: '0.92rem', marginBottom: 8 }}>
|
||||||
|
{idx + 1}. {(row.exercise_title || '').trim() || `Übung #${row.exercise_id}`}
|
||||||
|
{isCombo ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--accent-dark)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Kombination
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!isCombo && variants.length > 0 ? (
|
||||||
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||||
|
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
||||||
|
Variante
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={String(row.exercise_variant_id ?? '')}
|
||||||
|
onChange={(e) => updateRow(idx, { exercise_variant_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Standard (keine Variante)</option>
|
||||||
|
{variants.map((v) => (
|
||||||
|
<option key={v.id} value={String(v.id)}>
|
||||||
|
{(v.variant_name || v.name || '').trim() || `Variante #${v.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : !isCombo ? (
|
||||||
|
<div style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>Keine Varianten hinterlegt</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<FormActionBar
|
||||||
|
placement="bottom"
|
||||||
|
variant="modal"
|
||||||
|
formId="save-selected-module-form"
|
||||||
|
saving={submitting}
|
||||||
|
showSave={false}
|
||||||
|
saveAndCloseLabel="Modul anlegen"
|
||||||
|
saveAndCloseShortLabel="Anlegen"
|
||||||
|
onCancel={onClose}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && (loadErr || rows.length === 0) ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</FormModalOverlay>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
frontend/src/utils/exerciseListSessionState.js
Normal file
48
frontend/src/utils/exerciseListSessionState.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi } from '../constants/exerciseListFilters'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'shinkan.exerciseList.session.v1'
|
||||||
|
|
||||||
|
function safeParse(raw) {
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {{ filters: object, searchInput: string, aiSearchInput: string, mineOnly: boolean } | null} */
|
||||||
|
export function readExerciseListSessionState() {
|
||||||
|
if (typeof sessionStorage === 'undefined') return null
|
||||||
|
const parsed = safeParse(sessionStorage.getItem(STORAGE_KEY))
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null
|
||||||
|
|
||||||
|
const filters =
|
||||||
|
parsed.filters && typeof parsed.filters === 'object'
|
||||||
|
? mergeExerciseListPrefsFromApi(parsed.filters)
|
||||||
|
: { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
searchInput: typeof parsed.searchInput === 'string' ? parsed.searchInput : '',
|
||||||
|
aiSearchInput: typeof parsed.aiSearchInput === 'string' ? parsed.aiSearchInput : '',
|
||||||
|
mineOnly: !!parsed.mineOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeExerciseListSessionState(state) {
|
||||||
|
if (typeof sessionStorage === 'undefined' || !state || typeof state !== 'object') return
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
filters: state.filters ?? { ...INITIAL_EXERCISE_LIST_FILTERS },
|
||||||
|
searchInput: typeof state.searchInput === 'string' ? state.searchInput : '',
|
||||||
|
aiSearchInput: typeof state.aiSearchInput === 'string' ? state.aiSearchInput : '',
|
||||||
|
mineOnly: !!state.mineOnly,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
/* quota / private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user