diff --git a/frontend/src/app.css b/frontend/src/app.css index f5bcf83..566fc98 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2953,6 +2953,14 @@ html.modal-scroll-locked .app-main { flex: 1; 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 { margin: 0 0 8px; font-size: 1.05rem; diff --git a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx index 084dcd3..1fd134d 100644 --- a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx +++ b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx @@ -5,6 +5,7 @@ export default function ExerciseListBulkToolbar({ bulkMaxIds, onClearSelection, onOpenBulkModal, + onOpenSaveModuleModal, }) { if (selectedCount < 1) return null @@ -14,6 +15,9 @@ export default function ExerciseListBulkToolbar({ + diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx index 234c69d..c375723 100644 --- a/frontend/src/components/exercises/ExerciseListCard.jsx +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { Eye, 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). */ -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 styleNames = coerceApiNameList(exercise.style_direction_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 (
@@ -142,12 +159,21 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe type="checkbox" checked={selectedIds.has(Number(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" /> -
+

- {exercise.title} + e.stopPropagation()}> + {exercise.title} +

{focusNames.map((name) => ( @@ -191,14 +217,15 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
- onPeek?.(exercise)} > - + { if (!user?.id) return if (prefsAppliedRef.current) return + const session = readExerciseListSessionState() 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 { const sp = new URLSearchParams(window.location.search) if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true) @@ -81,6 +93,16 @@ function ExercisesListPageRoot() { prefsAppliedRef.current = true }, [user?.id, user?.exercise_list_prefs]) + useEffect(() => { + if (!prefsAppliedRef.current) return + writeExerciseListSessionState({ + filters, + searchInput, + aiSearchInput, + mineOnly, + }) + }, [filters, searchInput, aiSearchInput, mineOnly]) + useEffect(() => { if (!user?.id) prefsAppliedRef.current = false }, [user?.id]) @@ -247,6 +269,11 @@ function ExercisesListPageRoot() { [exercises, selectedIds] ) + const selectedExercisesInListOrder = useMemo( + () => exercises.filter((e) => selectedIds.has(Number(e.id))), + [exercises, selectedIds] + ) + const bulkVisibilityOptions = useMemo(() => { const base = [ { id: '', label: '— nicht ändern —' }, @@ -480,6 +507,7 @@ function ExercisesListPageRoot() { bulkMaxIds={BULK_MAX_IDS} onClearSelection={clearSelection} onOpenBulkModal={openBulkModal} + onOpenSaveModuleModal={() => setSaveModuleModalOpen(true)} /> + setSaveModuleModalOpen(false)} + selectedExercises={selectedExercisesInListOrder} + onSuccess={clearSelection} + /> + + setPeekExercise(null)} + /> + {listFetching && exercises.length === 0 ? (
@@ -569,6 +611,7 @@ function ExercisesListPageRoot() { selectedIds={selectedIds} toggleSelect={toggleSelect} onDelete={handleDelete} + onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })} /> ))}
diff --git a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx new file mode 100644 index 0000000..1fddf41 --- /dev/null +++ b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx @@ -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, 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 ( + +
+

Auswahl als Trainingsmodul

+

+ Die gewählten Übungen werden in der Reihenfolge der Liste als Modulpositionen + übernommen. Pro Übung kann optional eine Variante gesetzt werden. +

+ + {loading ? ( +

Laden …

+ ) : loadErr ? ( +

{loadErr}

+ ) : rows.length === 0 ? ( +

Keine Übungen ausgewählt.

+ ) : ( +
+
+
+ + setTitle(e.target.value)} + required + placeholder="z. B. Technikblock Grundlagen" + /> +
+ +
+
    + {rows.map((row, idx) => { + const isCombo = row.exercise_kind === 'combination' + const variants = Array.isArray(row.variants) ? row.variants : [] + return ( +
  • +
    + {idx + 1}. {(row.exercise_title || '').trim() || `Übung #${row.exercise_id}`} + {isCombo ? ( + + Kombination + + ) : null} +
    + {!isCombo && variants.length > 0 ? ( +
    + + +
    + ) : !isCombo ? ( +
    Keine Varianten hinterlegt
    + ) : null} +
  • + ) + })} +
+
+ +
+ + +
+ {visibility === 'club' ? ( +
+ + +
+ ) : null} +
+ + + + )} + + {loading ? ( +
+ +
+ ) : null} + + {!loading && (loadErr || rows.length === 0) ? ( +
+ +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/utils/exerciseListSessionState.js b/frontend/src/utils/exerciseListSessionState.js new file mode 100644 index 0000000..5822aca --- /dev/null +++ b/frontend/src/utils/exerciseListSessionState.js @@ -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 */ + } +}