Bug_fixes #45

Merged
Lars merged 2 commits from develop into main 2026-05-22 07:16:33 +02:00
10 changed files with 79 additions and 66 deletions

View File

@ -7,6 +7,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
import { import {
INITIAL_EXERCISE_LIST_FILTERS, INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi, mergeExerciseListPrefsFromApi,
@ -373,7 +374,7 @@ export default function ExercisePickerModal({
{quickOpen ? ( {quickOpen ? (
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}> <div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}> <p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
Wird mit Sichtbarkeit <strong>privat</strong> und Status <strong>Entwurf</strong> gespeichert und Wird mit Freigabelevel <strong>privat</strong> und Status <strong>Entwurf</strong> gespeichert und
erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den
Ablauf übernommen. Ablauf übernommen.
</p> </p>
@ -462,7 +463,7 @@ export default function ExercisePickerModal({
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}> <div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
<p style={{ margin: '0 0 12px 0' }}> <p style={{ margin: '0 0 12px 0' }}>
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere + = alle zutreffend; schließt aus. Felder gelten mit <strong>UND</strong>. Kataloge: mehrere + = alle zutreffend; schließt aus.
Sichtbarkeit/Status: mehrere + = eine davon (ODER); blendet aus. Freigabelevel/Status: mehrere + = eine davon (ODER); blendet aus.
</p> </p>
<ExerciseFocusRulePicker <ExerciseFocusRulePicker
focusOptions={focusOptions} focusOptions={focusOptions}
@ -539,12 +540,12 @@ export default function ExercisePickerModal({
</div> </div>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}> <div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
<CatalogRulePicker <CatalogRulePicker
label="Sichtbarkeit" label={EXERCISE_VISIBILITY_FIELD_LABEL}
options={visibilityOptions} options={visibilityOptions}
rules={filters.visibility_rules} rules={filters.visibility_rules}
rulesFieldName="visibility_rules" rulesFieldName="visibility_rules"
idKind="string" idKind="string"
placeholder="Sichtbarkeit …" placeholder="Freigabelevel …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))} onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/> />
<CatalogRulePicker <CatalogRulePicker

View File

@ -9,6 +9,7 @@ import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub' import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal' import ExercisePickerModal from './ExercisePickerModal'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_OPTIONS = [ const VIS_OPTIONS = [
{ value: 'private', label: 'Privat' }, { value: 'private', label: 'Privat' },
@ -605,7 +606,7 @@ export default function ExerciseProgressionGraphPanel({
/> />
</div> </div>
<div className="form-row" style={{ flex: '1 1 140px', marginBottom: 0 }}> <div className="form-row" style={{ flex: '1 1 140px', marginBottom: 0 }}>
<label className="form-label">Sichtbarkeit</label> <label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select <select
className="form-input" className="form-input"
value={newGraphVisibility} value={newGraphVisibility}
@ -642,7 +643,7 @@ export default function ExerciseProgressionGraphPanel({
/> />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Sichtbarkeit</label> <label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}> <select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}>
{filteredGraphVisOptions.map((o) => ( {filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>

View File

@ -44,6 +44,10 @@ import {
EXERCISE_SKILL_INTENSITY_DEFAULT, EXERCISE_SKILL_INTENSITY_DEFAULT,
normalizeExerciseSkillIntensity, normalizeExerciseSkillIntensity,
} from '../../constants/exerciseSkillIntensity' } from '../../constants/exerciseSkillIntensity'
import {
EXERCISE_VISIBILITY_CLUB_FIELD_LABEL,
EXERCISE_VISIBILITY_FIELD_LABEL,
} from '../../constants/exerciseGovernanceLabels'
const VARIANT_DIFFICULTY = [ const VARIANT_DIFFICULTY = [
{ value: '', label: '—' }, { value: '', label: '—' },
@ -859,6 +863,33 @@ function ExerciseFormPageRoot() {
setVariants(rows) setVariants(rows)
}, [exerciseId, syncVariantsSavedSnapshot]) }, [exerciseId, syncVariantsSavedSnapshot])
const createVariantFromDraft = useCallback(
async ({ showSuccessToast = false } = {}) => {
if (!exerciseId) return false
if (!variantDraftHasContent(variantDraft)) return true
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
toast.error('Variantenname mindestens 3 Zeichen')
return false
}
setVariantBusy(true)
try {
const created = await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
if (created?.id != null) setVariantEditSelection(created.id)
await refreshVariants()
if (showSuccessToast) toast.success('Variante angelegt.')
return true
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantBusy(false)
}
},
[exerciseId, variantDraft, refreshVariants, toast],
)
const persistPendingVariantChanges = useCallback(async () => { const persistPendingVariantChanges = useCallback(async () => {
if (!exerciseId) return true if (!exerciseId) return true
@ -885,28 +916,9 @@ function ExerciseFormPageRoot() {
} }
} }
if (variantDraftHasContent(variantDraft)) { const draftOk = await createVariantFromDraft()
const payload = buildVariantPayloadFromRow(variantDraft) return draftOk
if (payload.variant_name.length < 3) { }, [exerciseId, variants, getDirtyVariantRows, refreshVariants, toast, createVariantFromDraft])
toast.error('Variantenentwurf: Name mindestens 3 Zeichen, sonst Felder verwerfen oder ausfüllen.')
return false
}
setVariantBusy(true)
try {
const created = await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
if (created?.id != null) setVariantEditSelection(created.id)
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantBusy(false)
}
}
return true
}, [exerciseId, variantDraft, variants, getDirtyVariantRows, refreshVariants, toast])
const performSaveAttempt = useCallback( const performSaveAttempt = useCallback(
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
@ -1223,27 +1235,9 @@ function ExerciseFormPageRoot() {
} }
} }
const createVariantSubmit = async (e) => { const handleCreateVariantClick = useCallback(async () => {
e.preventDefault() await createVariantFromDraft({ showSuccessToast: true })
if (!exerciseId) return }, [createVariantFromDraft])
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
toast.error('Variantenname mindestens 3 Zeichen')
return
}
setVariantBusy(true)
try {
const created = await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
await refreshVariants()
if (created?.id != null) setVariantEditSelection(created.id)
else setVariantEditSelection(null)
} catch (err) {
toast.error(err.message || String(err))
} finally {
setVariantBusy(false)
}
}
const selectedVariantForEdit = const selectedVariantForEdit =
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
@ -1298,7 +1292,7 @@ function ExerciseFormPageRoot() {
title="Stammdaten" title="Stammdaten"
hint={ hint={
isEdit isEdit
? 'Titel, Rahmendaten und Sichtbarkeit — Inhalt und Einordnung in den anderen Tabs.' ? 'Titel, Rahmendaten und Freigabelevel — Inhalt und Einordnung in den anderen Tabs.'
: 'Titel und Rahmendaten. Varianten, Medien und Progressionsgraph sind nach dem ersten Speichern verfügbar.' : 'Titel und Rahmendaten. Varianten, Medien und Progressionsgraph sind nach dem ersten Speichern verfügbar.'
} }
> >
@ -1428,7 +1422,7 @@ function ExerciseFormPageRoot() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row"> <div className="form-row">
<label className="form-label">Sichtbarkeit</label> <label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select <select
className="form-input" className="form-input"
value={formData.visibility} value={formData.visibility}
@ -1456,7 +1450,7 @@ function ExerciseFormPageRoot() {
{formData.visibility === 'club' && visibilityClubChoices.length > 0 ? ( {formData.visibility === 'club' && visibilityClubChoices.length > 0 ? (
<div className="form-row" style={{ marginTop: '10px' }}> <div className="form-row" style={{ marginTop: '10px' }}>
<label className="form-label">Verein (Sichtbarkeit)</label> <label className="form-label">{EXERCISE_VISIBILITY_CLUB_FIELD_LABEL}</label>
<select <select
className="form-input" className="form-input"
value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''} value={formData.club_id != null && formData.club_id !== '' ? String(formData.club_id) : ''}
@ -2081,9 +2075,8 @@ function ExerciseFormPageRoot() {
)} )}
{variantEditSelection === 'new' && ( {variantEditSelection === 'new' && (
<form <div
className="exercise-variant-single-form" className="exercise-variant-single-form"
onSubmit={createVariantSubmit}
style={{ marginTop: '14px', paddingTop: '14px', borderTop: '1px solid var(--border)' }} style={{ marginTop: '14px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}
> >
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3> <h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
@ -2099,10 +2092,19 @@ function ExerciseFormPageRoot() {
linkedExerciseMedia={isEdit ? mediaList : []} linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia} onExerciseMediaListChanged={refreshMedia}
/> />
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}> <button
type="button"
className="btn btn-primary"
style={{ marginTop: '10px' }}
disabled={variantBusy}
onClick={handleCreateVariantClick}
>
{variantBusy ? 'Anlegen…' : 'Variante anlegen'} {variantBusy ? 'Anlegen…' : 'Variante anlegen'}
</button> </button>
</form> <p className="exercise-form-panel__hint" style={{ marginTop: '8px', marginBottom: 0 }}>
Alternativ reicht Speichern in der Aktionsleiste der Entwurf wird dann mitgesichert.
</p>
</div>
)} )}
{selectedVariantForEdit && ( {selectedVariantForEdit && (

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { activeClubMemberships } from '../../utils/activeClub' import { activeClubMemberships } from '../../utils/activeClub'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../../constants/exerciseGovernanceLabels'
import MultiSelectCombo from '../MultiSelectCombo' import MultiSelectCombo from '../MultiSelectCombo'
/** /**
@ -83,7 +84,7 @@ export default function ExerciseListBulkModal({
Primärzuordnung. Primärzuordnung.
</p> </p>
<div className="form-row"> <div className="form-row">
<label className="form-label">Sichtbarkeit</label> <label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select className="form-input" value={bulkVisibility} onChange={(e) => setBulkVisibility(e.target.value)}> <select className="form-input" value={bulkVisibility} onChange={(e) => setBulkVisibility(e.target.value)}>
{bulkVisibilityOptions.map((o) => ( {bulkVisibilityOptions.map((o) => (
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}> <option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>

View File

@ -21,6 +21,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import ExerciseRichTextBlock from '../ExerciseRichTextBlock' import ExerciseRichTextBlock from '../ExerciseRichTextBlock'
import { coerceApiNameList } from '../../utils/sanitizeHtml' import { coerceApiNameList } from '../../utils/sanitizeHtml'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../../constants/exerciseGovernanceLabels'
import { canUserRequestExerciseDelete } from '../../utils/exercisePermissions' import { canUserRequestExerciseDelete } from '../../utils/exercisePermissions'
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' } const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
@ -118,7 +119,7 @@ function ExerciseCardScopeStatus({ exercise }) {
<div <div
className="exercise-card__meta-compact" className="exercise-card__meta-compact"
title={tip} title={tip}
aria-label={`Sichtbarkeit: ${visLabel}. Status: ${stLabel}.`} aria-label={`${EXERCISE_VISIBILITY_FIELD_LABEL}: ${visLabel}. Status: ${stLabel}.`}
> >
<span className="exercise-card__meta-glyph"> <span className="exercise-card__meta-glyph">
<VisIcon size={15} strokeWidth={2} aria-hidden /> <VisIcon size={15} strokeWidth={2} aria-hidden />

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels' import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../../constants/exerciseGovernanceLabels'
import SkillTreeMultiSelect from '../SkillTreeMultiSelect' import SkillTreeMultiSelect from '../SkillTreeMultiSelect'
import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker' import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker'
import CatalogRulePicker from '../CatalogRulePicker' import CatalogRulePicker from '../CatalogRulePicker'
@ -56,7 +57,7 @@ export default function ExerciseListFilterModal({
Zwischen den Bereichen gilt <strong>UND</strong>. Fokusbereiche: mehrere + mit bedeuten alle müssen Zwischen den Bereichen gilt <strong>UND</strong>. Fokusbereiche: mehrere + mit bedeuten alle müssen
gesetzt sein; ohne schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung / gesetzt sein; ohne schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
Trainingsstil / Zielgruppe: mehrere + = alle zutreffend (UND); verbietet die Zuordnung. Unter Trainingsstil / Zielgruppe: mehrere + = alle zutreffend (UND); verbietet die Zuordnung. Unter
Freigabe: Sichtbarkeit / Status mit + = eine davon (ODER); blendet aus. Freigabe: Freigabelevel / Status mit + = eine davon (ODER); blendet aus.
</p> </p>
<section className="exercise-filter-section"> <section className="exercise-filter-section">
@ -155,7 +156,7 @@ export default function ExerciseListFilterModal({
<section className="exercise-filter-section"> <section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Ausblenden / Liste</h4> <h4 className="exercise-filter-section-title">Ausblenden / Liste</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}> <p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}>
Sichtbarkeit und Status steuern Sie unter Freigabe mit + und . Hier nur globale Listen-Optionen. Freigabelevel und Status steuern Sie unter Freigabe mit + und . Hier nur globale Listen-Optionen.
</p> </p>
<div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label <label
@ -199,12 +200,12 @@ export default function ExerciseListFilterModal({
</p> </p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog"> <div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog">
<CatalogRulePicker <CatalogRulePicker
label="Sichtbarkeit" label={EXERCISE_VISIBILITY_FIELD_LABEL}
options={visibilityOptions} options={visibilityOptions}
rules={filters.visibility_rules} rules={filters.visibility_rules}
rulesFieldName="visibility_rules" rulesFieldName="visibility_rules"
idKind="string" idKind="string"
placeholder="Sichtbarkeit …" placeholder="Freigabelevel …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))} onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/> />
<CatalogRulePicker <CatalogRulePicker

View File

@ -373,7 +373,7 @@ function ExercisesListPageRoot() {
bulkPatchTargetGroups bulkPatchTargetGroups
if (!bulkVisibility && !bulkStatus && !anyRelationPatch) { if (!bulkVisibility && !bulkStatus && !anyRelationPatch) {
alert( alert(
'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).' 'Bitte mindestens eine Änderung wählen (Freigabelevel, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).'
) )
return return
} }

View File

@ -9,6 +9,7 @@ import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../..
import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm' import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm'
import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection' import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection'
import { navigateWithAppReturn } from '../../utils/navReturnContext' import { navigateWithAppReturn } from '../../utils/navReturnContext'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../../constants/exerciseGovernanceLabels'
/** /**
* Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge). * Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge).
@ -174,7 +175,7 @@ export default function SaveSelectedExercisesAsModuleModal({
if (Number.isFinite(fallback) && fallback > 0) cid = fallback if (Number.isFinite(fallback) && fallback > 0) cid = fallback
} }
if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).') toast.error('Bitte einen Verein wählen (Freigabelevel „Verein“).')
return return
} }
if (visibility !== 'club') cid = null if (visibility !== 'club') cid = null
@ -275,7 +276,7 @@ export default function SaveSelectedExercisesAsModuleModal({
</div> </div>
<div className="form-row" style={{ marginBottom: '0.85rem' }}> <div className="form-row" style={{ marginBottom: '0.85rem' }}>
<label className="form-label">Sichtbarkeit</label> <label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</label>
<select <select
className="form-input" className="form-input"
value={visibility} value={visibility}

View File

@ -0,0 +1,4 @@
/** UI-Bezeichnung für `exercises.visibility` (API/DB-Feldname unverändert). */
export const EXERCISE_VISIBILITY_FIELD_LABEL = 'Freigabelevel'
export const EXERCISE_VISIBILITY_CLUB_FIELD_LABEL = 'Verein (Freigabelevel)'

View File

@ -1,4 +1,5 @@
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@ -164,7 +165,7 @@ export function buildExerciseListFilterChips({
'visibility_rules', 'visibility_rules',
filters.visibility_rules, filters.visibility_rules,
visibilityOptions, visibilityOptions,
'Sichtbarkeit', EXERCISE_VISIBILITY_FIELD_LABEL,
setFilters setFilters
) )
pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters) pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters)