Enhance Exercise Progression Graph Panel with Club Management Features
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 43s
Test Suite / playwright-tests (push) Successful in 1m13s

- Added functionality to select and manage clubs within the Exercise Progression Graph Panel, allowing users to assign clubs to exercises.
- Introduced state management for club selection and manual entry, improving user experience for platform admins.
- Updated visibility handling to ensure proper governance and club association during exercise promotion.
- Enhanced error handling to provide clearer feedback when no club is selected, ensuring users are guided to make necessary selections.
This commit is contained in:
Lars 2026-06-14 07:10:11 +02:00
parent 4b9374765b
commit 87d9fa9b65

View File

@ -13,7 +13,7 @@ import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub'
import ProgressionGraphEditor from './ProgressionGraphEditor'
import ProgressionGraphListCard from './ProgressionGraphListCard'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
@ -41,6 +41,8 @@ function ExerciseProgressionGraphPanel(
const { user } = useAuth()
const location = useLocation()
const isSuperadmin = user?.role === 'superadmin'
const isPlatformAdmin = isSuperadmin || user?.role === 'admin'
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const filteredGraphVisOptions = useMemo(
@ -61,6 +63,8 @@ function ExerciseProgressionGraphPanel(
const [metaName, setMetaName] = useState('')
const [metaDescription, setMetaDescription] = useState('')
const [metaVisibility, setMetaVisibility] = useState('private')
const [metaClubSelect, setMetaClubSelect] = useState('')
const [metaClubManual, setMetaClubManual] = useState('')
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
@ -157,6 +161,8 @@ function ExerciseProgressionGraphPanel(
setMetaName('')
setMetaDescription('')
setMetaVisibility('private')
setMetaClubSelect('')
setMetaClubManual('')
return
}
const g = graphs.find((x) => x.id === selectedGraphId)
@ -164,6 +170,13 @@ function ExerciseProgressionGraphPanel(
setMetaName(g.name || '')
setMetaDescription(g.description || '')
setMetaVisibility(g.visibility || 'private')
if (g.club_id != null) {
setMetaClubSelect(String(g.club_id))
} else {
const fallback = getDefaultClubIdForGovernanceForms(user)
setMetaClubSelect(fallback != null ? String(fallback) : '')
}
setMetaClubManual('')
}
let cancelled = false
;(async () => {
@ -176,7 +189,20 @@ function ExerciseProgressionGraphPanel(
return () => {
cancelled = true
}
}, [selectedGraphId, graphs, refreshEdges])
}, [selectedGraphId, graphs, refreshEdges, user])
const resolveGovernanceClubId = useCallback(() => {
const g = graphs.find((x) => x.id === selectedGraphId)
if (g?.club_id != null) return Number(g.club_id)
const manual = String(metaClubManual || '').trim()
if (manual && /^\d+$/.test(manual)) return Number(manual)
const sel = String(metaClubSelect || '').trim()
if (sel && /^\d+$/.test(sel)) return Number(sel)
return getDefaultClubIdForGovernanceForms(user)
}, [graphs, selectedGraphId, metaClubManual, metaClubSelect, user])
const filteredEdges = useMemo(() => {
if (!filterAnchorOnly || anchorExerciseId == null) return edges
@ -226,13 +252,7 @@ function ExerciseProgressionGraphPanel(
}
}
const resolvePromoteClubId = () => {
const g = graphs.find((x) => x.id === selectedGraphId)
if (g?.club_id != null) return Number(g.club_id)
const memberships = activeClubMemberships(user?.clubs)
const active = memberships.find((c) => c.is_active) || memberships[0]
return active?.club_id != null ? Number(active.club_id) : null
}
const resolvePromoteClubId = resolveGovernanceClubId
const handleSaveMeta = async () => {
if (!selectedGraphId) return
@ -268,7 +288,9 @@ function ExerciseProgressionGraphPanel(
if (promote) {
const clubId = resolvePromoteClubId()
if (!clubId) {
alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.')
throw new Error(
'Kein Verein gewählt — bitte unter „Verein zuordnen“ einen Verein auswählen oder den Vereins-Umschalter nutzen.',
)
} else {
const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null)
const res = await api.bulkPatchExercisesMetadata({
@ -286,6 +308,11 @@ function ExerciseProgressionGraphPanel(
}
const promoteClubId = nextVis === 'club' ? resolvePromoteClubId() : null
if (nextVis === 'club' && !promoteClubId) {
throw new Error(
'Vereins-Sichtbarkeit: Bitte einen Verein unter „Verein zuordnen“ wählen oder den Vereins-Umschalter setzen.',
)
}
await api.updateExerciseProgressionGraph(selectedGraphId, {
name,
description: metaDescription.trim() || null,
@ -541,7 +568,14 @@ function ExerciseProgressionGraphPanel(
<select
className="form-input"
value={metaVisibility}
onChange={(e) => setMetaVisibility(e.target.value)}
onChange={(e) => {
const v = e.target.value
setMetaVisibility(v)
if (v === 'club' && !metaClubSelect) {
const fb = getDefaultClubIdForGovernanceForms(user)
if (fb != null) setMetaClubSelect(String(fb))
}
}}
>
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
@ -550,6 +584,38 @@ function ExerciseProgressionGraphPanel(
))}
</select>
</div>
{metaVisibility === 'club' ? (
<div className="form-row">
<label className="form-label">Verein zuordnen</label>
<select
className="form-input"
value={metaClubSelect}
onChange={(e) => setMetaClubSelect(e.target.value)}
>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{memberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
{isPlatformAdmin ? (
<>
<label className="form-label" style={{ marginTop: '10px' }}>
Oder Vereins-ID (Plattform-Admin)
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="Leer = wie Dropdown / aktiver Verein"
value={metaClubManual}
onChange={(e) => setMetaClubManual(e.target.value)}
/>
</>
) : null}
</div>
) : null}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={handleSaveMeta}>
Metadaten speichern